mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(mobile): Manual asset upload (#3445)
* fix: exclude albums filter in backup provider * refactor: Separate builder methods for Top Control App Bar buttons * fix: Show download button only for Remote only assets * fix(mobile): Force Refresh duration is too low to trigger it consistently * feat(mobile): Make Buttons dynamic in Home Selection DraggableScrollableSheet * feat(mobile): Manual Asset upload * refactor(mobile): Replace _showToast with ImmichToast calls * refactor(mobile): home_page selectionAssetState handling * chore(mobile): min and initial size of DraggableScrollState increased This is to prevent the buttons in the bottom sheet getting clipped behind the 3 way navigation buttons in the default density of Android devices * feat(mobile): notifications for manual upload progress * wording --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		@@ -7,6 +7,10 @@
 | 
			
		||||
      android:name="io.flutter.embedding.android.EnableImpeller"
 | 
			
		||||
      android:value="false" />
 | 
			
		||||
 | 
			
		||||
    <meta-data
 | 
			
		||||
      android:name="com.google.firebase.messaging.default_notification_icon"
 | 
			
		||||
      android:resource="@drawable/notification_icon" />
 | 
			
		||||
 | 
			
		||||
    <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop"
 | 
			
		||||
      android:theme="@style/LaunchTheme"
 | 
			
		||||
      android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
 | 
			
		||||
 
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 1.1 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 624 B  | 
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 1.5 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 2.8 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 4.2 KiB  | 
@@ -92,6 +92,10 @@
 | 
			
		||||
  "backup_controller_page_uploading_file_info": "Uploading file info",
 | 
			
		||||
  "backup_err_only_album": "Cannot remove the only album",
 | 
			
		||||
  "backup_info_card_assets": "assets",
 | 
			
		||||
  "backup_manual_failed": "Failed",
 | 
			
		||||
  "backup_manual_title": "Upload status",
 | 
			
		||||
  "backup_manual_success": "Success",
 | 
			
		||||
  "backup_manual_in_progress": "Upload already in progress. Try after sometime",
 | 
			
		||||
  "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)",
 | 
			
		||||
  "cache_settings_clear_cache_button": "Clear cache",
 | 
			
		||||
  "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.",
 | 
			
		||||
@@ -138,6 +142,10 @@
 | 
			
		||||
  "delete_dialog_cancel": "Cancel",
 | 
			
		||||
  "delete_dialog_ok": "Delete",
 | 
			
		||||
  "delete_dialog_title": "Delete Permanently",
 | 
			
		||||
  "upload_dialog_title": "Upload Asset",
 | 
			
		||||
  "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
 | 
			
		||||
  "upload_dialog_ok": "Upload",
 | 
			
		||||
  "upload_dialog_cancel": "Cancel",
 | 
			
		||||
  "description_input_hint_text": "Add description...",
 | 
			
		||||
  "description_input_submit_error": "Error updating description, check the log for more details",
 | 
			
		||||
  "exif_bottom_sheet_description": "Add Description...",
 | 
			
		||||
@@ -153,6 +161,7 @@
 | 
			
		||||
  "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
 | 
			
		||||
  "home_page_add_to_album_success": "Added {added} assets to album {album}.",
 | 
			
		||||
  "home_page_archive_err_local": "Can not archive local assets yet, skipping",
 | 
			
		||||
  "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
 | 
			
		||||
  "home_page_building_timeline": "Building the timeline",
 | 
			
		||||
  "home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
 | 
			
		||||
  "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,8 @@ PODS:
 | 
			
		||||
  - device_info_plus (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - Flutter (1.0.0)
 | 
			
		||||
  - flutter_local_notifications (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - flutter_native_splash (0.0.1):
 | 
			
		||||
    - Flutter
 | 
			
		||||
  - flutter_udid (0.0.1):
 | 
			
		||||
@@ -58,6 +60,7 @@ DEPENDENCIES:
 | 
			
		||||
  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
 | 
			
		||||
  - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
 | 
			
		||||
  - Flutter (from `Flutter`)
 | 
			
		||||
  - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
 | 
			
		||||
  - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
 | 
			
		||||
  - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
 | 
			
		||||
  - flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
 | 
			
		||||
@@ -91,6 +94,8 @@ EXTERNAL SOURCES:
 | 
			
		||||
    :path: ".symlinks/plugins/device_info_plus/ios"
 | 
			
		||||
  Flutter:
 | 
			
		||||
    :path: Flutter
 | 
			
		||||
  flutter_local_notifications:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_local_notifications/ios"
 | 
			
		||||
  flutter_native_splash:
 | 
			
		||||
    :path: ".symlinks/plugins/flutter_native_splash/ios"
 | 
			
		||||
  flutter_udid:
 | 
			
		||||
@@ -132,6 +137,7 @@ SPEC CHECKSUMS:
 | 
			
		||||
  connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
 | 
			
		||||
  device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
 | 
			
		||||
  Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
 | 
			
		||||
  flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
 | 
			
		||||
  flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
 | 
			
		||||
  flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
 | 
			
		||||
  flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
 | 
			
		||||
@@ -157,4 +163,4 @@ SPEC CHECKSUMS:
 | 
			
		||||
 | 
			
		||||
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
 | 
			
		||||
 | 
			
		||||
COCOAPODS: 1.12.1
 | 
			
		||||
COCOAPODS: 1.11.3
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,11 @@ import permission_handler_apple
 | 
			
		||||
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
 | 
			
		||||
  ) -> Bool {
 | 
			
		||||
 | 
			
		||||
      // Required for flutter_local_notification
 | 
			
		||||
      if #available(iOS 10.0, *) {
 | 
			
		||||
        UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      GeneratedPluginRegistrant.register(with: self)
 | 
			
		||||
      BackgroundServicePlugin.registerBackgroundProcessing()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
 | 
			
		||||
@@ -35,6 +36,7 @@ import 'package:immich_mobile/shared/providers/release_info.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/local_notification.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
 | 
			
		||||
@@ -166,7 +168,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
 | 
			
		||||
        ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive;
 | 
			
		||||
        ImmichLogger().flush();
 | 
			
		||||
        ref.watch(websocketProvider.notifier).disconnect();
 | 
			
		||||
        ref.watch(backupProvider.notifier).cancelBackup();
 | 
			
		||||
        ref.watch(manualUploadProvider.notifier).cancelBackup();
 | 
			
		||||
        ref.read(backupProvider.notifier).cancelBackup();
 | 
			
		||||
 | 
			
		||||
        break;
 | 
			
		||||
 | 
			
		||||
@@ -203,6 +206,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    SystemChrome.setSystemUIOverlayStyle(overlayStyle);
 | 
			
		||||
    await ref.read(localNotificationService).setup();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
 
 | 
			
		||||
@@ -13,11 +13,13 @@ class TopControlAppBar extends HookConsumerWidget {
 | 
			
		||||
    required this.onToggleMotionVideo,
 | 
			
		||||
    required this.isPlayingMotionVideo,
 | 
			
		||||
    required this.onFavorite,
 | 
			
		||||
    required this.onUploadPressed,
 | 
			
		||||
    required this.isFavorite,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  final Asset asset;
 | 
			
		||||
  final Function onMoreInfoPressed;
 | 
			
		||||
  final VoidCallback? onUploadPressed;
 | 
			
		||||
  final VoidCallback? onDownloadPressed;
 | 
			
		||||
  final VoidCallback onToggleMotionVideo;
 | 
			
		||||
  final VoidCallback onAddToAlbumPressed;
 | 
			
		||||
@@ -39,10 +41,69 @@ class TopControlAppBar extends HookConsumerWidget {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return AppBar(
 | 
			
		||||
      foregroundColor: Colors.grey[100],
 | 
			
		||||
      backgroundColor: Colors.transparent,
 | 
			
		||||
      leading: IconButton(
 | 
			
		||||
    Widget buildLivePhotoButton() {
 | 
			
		||||
      return IconButton(
 | 
			
		||||
        onPressed: () {
 | 
			
		||||
          onToggleMotionVideo();
 | 
			
		||||
        },
 | 
			
		||||
        icon: isPlayingMotionVideo
 | 
			
		||||
            ? Icon(
 | 
			
		||||
                Icons.motion_photos_pause_outlined,
 | 
			
		||||
                color: Colors.grey[200],
 | 
			
		||||
              )
 | 
			
		||||
            : Icon(
 | 
			
		||||
                Icons.play_circle_outline_rounded,
 | 
			
		||||
                color: Colors.grey[200],
 | 
			
		||||
              ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget buildMoreInfoButton() {
 | 
			
		||||
      return IconButton(
 | 
			
		||||
        onPressed: () {
 | 
			
		||||
          onMoreInfoPressed();
 | 
			
		||||
        },
 | 
			
		||||
        icon: Icon(
 | 
			
		||||
          Icons.info_outline_rounded,
 | 
			
		||||
          color: Colors.grey[200],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget buildDownloadButton() {
 | 
			
		||||
      return IconButton(
 | 
			
		||||
        onPressed: onDownloadPressed,
 | 
			
		||||
        icon: Icon(
 | 
			
		||||
          Icons.cloud_download_outlined,
 | 
			
		||||
          color: Colors.grey[200],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget buildAddToAlbumButtom() {
 | 
			
		||||
      return IconButton(
 | 
			
		||||
        onPressed: () {
 | 
			
		||||
          onAddToAlbumPressed();
 | 
			
		||||
        },
 | 
			
		||||
        icon: Icon(
 | 
			
		||||
          Icons.add,
 | 
			
		||||
          color: Colors.grey[200],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget buildUploadButton() {
 | 
			
		||||
      return IconButton(
 | 
			
		||||
        onPressed: onUploadPressed,
 | 
			
		||||
        icon: Icon(
 | 
			
		||||
          Icons.backup_outlined,
 | 
			
		||||
          color: Colors.grey[200],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget buildBackButton() {
 | 
			
		||||
      return IconButton(
 | 
			
		||||
        onPressed: () {
 | 
			
		||||
          AutoRouter.of(context).pop();
 | 
			
		||||
        },
 | 
			
		||||
@@ -51,54 +112,23 @@ class TopControlAppBar extends HookConsumerWidget {
 | 
			
		||||
          size: 20.0,
 | 
			
		||||
          color: Colors.grey[200],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return AppBar(
 | 
			
		||||
      foregroundColor: Colors.grey[100],
 | 
			
		||||
      backgroundColor: Colors.transparent,
 | 
			
		||||
      leading: buildBackButton(),
 | 
			
		||||
      actionsIconTheme: const IconThemeData(
 | 
			
		||||
        size: iconSize,
 | 
			
		||||
      ),
 | 
			
		||||
      actions: [
 | 
			
		||||
        if (asset.isRemote) buildFavoriteButton(),
 | 
			
		||||
        if (asset.livePhotoVideoId != null)
 | 
			
		||||
          IconButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              onToggleMotionVideo();
 | 
			
		||||
            },
 | 
			
		||||
            icon: isPlayingMotionVideo
 | 
			
		||||
                ? Icon(
 | 
			
		||||
                    Icons.motion_photos_pause_outlined,
 | 
			
		||||
                    color: Colors.grey[200],
 | 
			
		||||
                  )
 | 
			
		||||
                : Icon(
 | 
			
		||||
                    Icons.play_circle_outline_rounded,
 | 
			
		||||
                    color: Colors.grey[200],
 | 
			
		||||
                  ),
 | 
			
		||||
          ),
 | 
			
		||||
        if (asset.storage == AssetState.remote)
 | 
			
		||||
          IconButton(
 | 
			
		||||
            onPressed: onDownloadPressed,
 | 
			
		||||
            icon: Icon(
 | 
			
		||||
              Icons.cloud_download_outlined,
 | 
			
		||||
              color: Colors.grey[200],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        if (asset.isRemote)
 | 
			
		||||
          IconButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              onAddToAlbumPressed();
 | 
			
		||||
            },
 | 
			
		||||
            icon: Icon(
 | 
			
		||||
              Icons.add,
 | 
			
		||||
              color: Colors.grey[200],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        IconButton(
 | 
			
		||||
          onPressed: () {
 | 
			
		||||
            onMoreInfoPressed();
 | 
			
		||||
          },
 | 
			
		||||
          icon: Icon(
 | 
			
		||||
            Icons.info_outline_rounded,
 | 
			
		||||
            color: Colors.grey[200],
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
 | 
			
		||||
        if (asset.isLocal && !asset.isRemote) buildUploadButton(),
 | 
			
		||||
        if (asset.isRemote && !asset.isLocal) buildDownloadButton(),
 | 
			
		||||
        if (asset.isRemote) buildAddToAlbumButtom(),
 | 
			
		||||
        buildMoreInfoButton()
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -16,6 +16,8 @@ import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/store.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 | 
			
		||||
@@ -276,6 +278,21 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
      AutoRouter.of(context).pop();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleUpload(Asset asset) {
 | 
			
		||||
      showDialog(
 | 
			
		||||
        context: context,
 | 
			
		||||
        builder: (BuildContext _) {
 | 
			
		||||
          return UploadDialog(
 | 
			
		||||
            onUpload: () {
 | 
			
		||||
              ref
 | 
			
		||||
                  .read(manualUploadProvider.notifier)
 | 
			
		||||
                  .uploadAssets(context, [asset]);
 | 
			
		||||
            },
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    buildAppBar() {
 | 
			
		||||
      return IgnorePointer(
 | 
			
		||||
        ignoring: !ref.watch(showControlsProvider),
 | 
			
		||||
@@ -291,6 +308,8 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
              onMoreInfoPressed: showInfo,
 | 
			
		||||
              onFavorite:
 | 
			
		||||
                  asset().isRemote ? () => toggleFavorite(asset()) : null,
 | 
			
		||||
              onUploadPressed:
 | 
			
		||||
                  asset().isLocal ? () => handleUpload(asset()) : null,
 | 
			
		||||
              onDownloadPressed: asset().isLocal
 | 
			
		||||
                  ? null
 | 
			
		||||
                  : () => ref
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/backup/services/backup.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/store.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/api.service.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/backup_progress.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/diff.dart';
 | 
			
		||||
import 'package:isar/isar.dart';
 | 
			
		||||
import 'package:path_provider_ios/path_provider_ios.dart';
 | 
			
		||||
@@ -34,7 +35,6 @@ class BackgroundService {
 | 
			
		||||
      MethodChannel('immich/foregroundChannel');
 | 
			
		||||
  static const MethodChannel _backgroundChannel =
 | 
			
		||||
      MethodChannel('immich/backgroundChannel');
 | 
			
		||||
  static final NumberFormat numberFormat = NumberFormat("###0.##");
 | 
			
		||||
  static const notifyInterval = Duration(milliseconds: 400);
 | 
			
		||||
  bool _isBackgroundInitialized = false;
 | 
			
		||||
  CancellationToken? _cancellationToken;
 | 
			
		||||
@@ -48,10 +48,10 @@ class BackgroundService {
 | 
			
		||||
  int _assetsToUploadCount = 0;
 | 
			
		||||
  String _lastPrintedDetailContent = "";
 | 
			
		||||
  String? _lastPrintedDetailTitle;
 | 
			
		||||
  late final _Throttle _throttledNotifiy =
 | 
			
		||||
      _Throttle(_updateProgress, notifyInterval);
 | 
			
		||||
  late final _Throttle _throttledDetailNotify =
 | 
			
		||||
      _Throttle(_updateDetailProgress, notifyInterval);
 | 
			
		||||
  late final ThrottleProgressUpdate _throttledNotifiy =
 | 
			
		||||
      ThrottleProgressUpdate(_updateProgress, notifyInterval);
 | 
			
		||||
  late final ThrottleProgressUpdate _throttledDetailNotify =
 | 
			
		||||
      ThrottleProgressUpdate(_updateDetailProgress, notifyInterval);
 | 
			
		||||
 | 
			
		||||
  bool get isBackgroundInitialized {
 | 
			
		||||
    return _isBackgroundInitialized;
 | 
			
		||||
@@ -439,7 +439,12 @@ class BackgroundService {
 | 
			
		||||
    _uploadedAssetsCount = 0;
 | 
			
		||||
    _updateNotification(
 | 
			
		||||
      title: "backup_background_service_in_progress_notification".tr(),
 | 
			
		||||
      content: notifyTotalProgress ? _formatAssetBackupProgress() : null,
 | 
			
		||||
      content: notifyTotalProgress
 | 
			
		||||
          ? formatAssetBackupProgress(
 | 
			
		||||
              _uploadedAssetsCount,
 | 
			
		||||
              _assetsToUploadCount,
 | 
			
		||||
            )
 | 
			
		||||
          : null,
 | 
			
		||||
      progress: 0,
 | 
			
		||||
      max: notifyTotalProgress ? _assetsToUploadCount : 0,
 | 
			
		||||
      indeterminate: !notifyTotalProgress,
 | 
			
		||||
@@ -464,11 +469,6 @@ class BackgroundService {
 | 
			
		||||
    return ok;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  String _formatAssetBackupProgress() {
 | 
			
		||||
    final int percent = (_uploadedAssetsCount * 100) ~/ _assetsToUploadCount;
 | 
			
		||||
    return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
 | 
			
		||||
    _uploadedAssetsCount++;
 | 
			
		||||
    _throttledNotifiy();
 | 
			
		||||
@@ -480,7 +480,7 @@ class BackgroundService {
 | 
			
		||||
 | 
			
		||||
  void _updateDetailProgress(String? title, int progress, int total) {
 | 
			
		||||
    final String msg =
 | 
			
		||||
        total > 0 ? _humanReadableBytesProgress(progress, total) : "";
 | 
			
		||||
        total > 0 ? humanReadableBytesProgress(progress, total) : "";
 | 
			
		||||
    // only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
 | 
			
		||||
    if (msg != _lastPrintedDetailContent || _lastPrintedDetailTitle != title) {
 | 
			
		||||
      _lastPrintedDetailContent = msg;
 | 
			
		||||
@@ -500,7 +500,10 @@ class BackgroundService {
 | 
			
		||||
      progress: _uploadedAssetsCount,
 | 
			
		||||
      max: _assetsToUploadCount,
 | 
			
		||||
      title: title,
 | 
			
		||||
      content: _formatAssetBackupProgress(),
 | 
			
		||||
      content: formatAssetBackupProgress(
 | 
			
		||||
        _uploadedAssetsCount,
 | 
			
		||||
        _assetsToUploadCount,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -546,26 +549,6 @@ class BackgroundService {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
 | 
			
		||||
  static String _humanReadableBytesProgress(int bytes, int bytesTotal) {
 | 
			
		||||
    String unit = "KB"; // Kilobyte
 | 
			
		||||
    if (bytesTotal >= 0x40000000) {
 | 
			
		||||
      unit = "GB"; // Gigabyte
 | 
			
		||||
      bytes >>= 20;
 | 
			
		||||
      bytesTotal >>= 20;
 | 
			
		||||
    } else if (bytesTotal >= 0x100000) {
 | 
			
		||||
      unit = "MB"; // Megabyte
 | 
			
		||||
      bytes >>= 10;
 | 
			
		||||
      bytesTotal >>= 10;
 | 
			
		||||
    } else if (bytesTotal < 0x400) {
 | 
			
		||||
      return "$bytes / $bytesTotal B";
 | 
			
		||||
    }
 | 
			
		||||
    final int percent = (bytes * 100) ~/ bytesTotal;
 | 
			
		||||
    final String done = numberFormat.format(bytes / 1024.0);
 | 
			
		||||
    final String total = numberFormat.format(bytesTotal / 1024.0);
 | 
			
		||||
    return "$percent% ($done/$total$unit)";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
 | 
			
		||||
    if (!Platform.isIOS) {
 | 
			
		||||
      return null;
 | 
			
		||||
@@ -598,43 +581,6 @@ class BackgroundService {
 | 
			
		||||
 | 
			
		||||
enum IosBackgroundTask { fetch, processing }
 | 
			
		||||
 | 
			
		||||
class _Throttle {
 | 
			
		||||
  _Throttle(this._fun, Duration interval) : _interval = interval.inMicroseconds;
 | 
			
		||||
  final void Function(String?, int, int) _fun;
 | 
			
		||||
  final int _interval;
 | 
			
		||||
  int _invokedAt = 0;
 | 
			
		||||
  Timer? _timer;
 | 
			
		||||
 | 
			
		||||
  String? title;
 | 
			
		||||
  int progress = 0;
 | 
			
		||||
  int total = 0;
 | 
			
		||||
 | 
			
		||||
  void call({
 | 
			
		||||
    final String? title,
 | 
			
		||||
    final int progress = 0,
 | 
			
		||||
    final int total = 0,
 | 
			
		||||
  }) {
 | 
			
		||||
    final time = Timeline.now;
 | 
			
		||||
    this.title = title ?? this.title;
 | 
			
		||||
    this.progress = progress;
 | 
			
		||||
    this.total = total;
 | 
			
		||||
    if (time > _invokedAt + _interval) {
 | 
			
		||||
      _timer?.cancel();
 | 
			
		||||
      _onTimeElapsed();
 | 
			
		||||
    } else {
 | 
			
		||||
      _timer ??= Timer(Duration(microseconds: _interval), _onTimeElapsed);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onTimeElapsed() {
 | 
			
		||||
    _invokedAt = Timeline.now;
 | 
			
		||||
    _fun(title, progress, total);
 | 
			
		||||
    _timer = null;
 | 
			
		||||
    // clear title to not send/overwrite it next time if unchanged
 | 
			
		||||
    title = null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
 | 
			
		||||
@pragma('vm:entry-point')
 | 
			
		||||
void _nativeEntry() {
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,13 @@ import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
 | 
			
		||||
 | 
			
		||||
enum BackUpProgressEnum { idle, inProgress, inBackground, done }
 | 
			
		||||
enum BackUpProgressEnum {
 | 
			
		||||
  idle,
 | 
			
		||||
  inProgress,
 | 
			
		||||
  manualInProgress,
 | 
			
		||||
  inBackground,
 | 
			
		||||
  done
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class BackUpState {
 | 
			
		||||
  // enum
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,71 @@
 | 
			
		||||
import 'package:cancellation_token_http/http.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
 | 
			
		||||
 | 
			
		||||
class ManualUploadState {
 | 
			
		||||
  final CancellationToken cancelToken;
 | 
			
		||||
 | 
			
		||||
  final double progressInPercentage;
 | 
			
		||||
 | 
			
		||||
  // Current Backup Asset
 | 
			
		||||
  final CurrentUploadAsset currentUploadAsset;
 | 
			
		||||
 | 
			
		||||
  /// Manual Upload
 | 
			
		||||
  final int manualUploadsTotal;
 | 
			
		||||
  final int manualUploadFailures;
 | 
			
		||||
  final int manualUploadSuccess;
 | 
			
		||||
 | 
			
		||||
  const ManualUploadState({
 | 
			
		||||
    required this.progressInPercentage,
 | 
			
		||||
    required this.cancelToken,
 | 
			
		||||
    required this.currentUploadAsset,
 | 
			
		||||
    required this.manualUploadsTotal,
 | 
			
		||||
    required this.manualUploadFailures,
 | 
			
		||||
    required this.manualUploadSuccess,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  ManualUploadState copyWith({
 | 
			
		||||
    double? progressInPercentage,
 | 
			
		||||
    CancellationToken? cancelToken,
 | 
			
		||||
    CurrentUploadAsset? currentUploadAsset,
 | 
			
		||||
    int? manualUploadsTotal,
 | 
			
		||||
    int? manualUploadFailures,
 | 
			
		||||
    int? manualUploadSuccess,
 | 
			
		||||
  }) {
 | 
			
		||||
    return ManualUploadState(
 | 
			
		||||
      progressInPercentage: progressInPercentage ?? this.progressInPercentage,
 | 
			
		||||
      cancelToken: cancelToken ?? this.cancelToken,
 | 
			
		||||
      currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
 | 
			
		||||
      manualUploadsTotal: manualUploadsTotal ?? this.manualUploadsTotal,
 | 
			
		||||
      manualUploadFailures: manualUploadFailures ?? this.manualUploadFailures,
 | 
			
		||||
      manualUploadSuccess: manualUploadSuccess ?? this.manualUploadSuccess,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'ManualUploadState(progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, manualUploadsTotal: $manualUploadsTotal, manualUploadSuccess: $manualUploadSuccess, manualUploadFailures: $manualUploadFailures)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    if (identical(this, other)) return true;
 | 
			
		||||
 | 
			
		||||
    return other is ManualUploadState &&
 | 
			
		||||
        other.progressInPercentage == progressInPercentage &&
 | 
			
		||||
        other.cancelToken == cancelToken &&
 | 
			
		||||
        other.currentUploadAsset == currentUploadAsset &&
 | 
			
		||||
        other.manualUploadsTotal == manualUploadsTotal &&
 | 
			
		||||
        other.manualUploadFailures == manualUploadFailures &&
 | 
			
		||||
        other.manualUploadSuccess == manualUploadSuccess;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode {
 | 
			
		||||
    return progressInPercentage.hashCode ^
 | 
			
		||||
        cancelToken.hashCode ^
 | 
			
		||||
        currentUploadAsset.hashCode ^
 | 
			
		||||
        manualUploadsTotal.hashCode ^
 | 
			
		||||
        manualUploadFailures.hashCode ^
 | 
			
		||||
        manualUploadSuccess.hashCode;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -388,7 +388,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
 | 
			
		||||
    if (state.backupProgress != BackUpProgressEnum.inBackground) {
 | 
			
		||||
      await _getBackupAlbumsInfo();
 | 
			
		||||
      await _updateServerInfo();
 | 
			
		||||
      await updateServerInfo();
 | 
			
		||||
      await _updateBackupAssetCount();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -465,7 +465,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
        _onSetCurrentBackupAsset,
 | 
			
		||||
        _onBackupError,
 | 
			
		||||
      );
 | 
			
		||||
      await _notifyBackgroundServiceCanRun();
 | 
			
		||||
      await notifyBackgroundServiceCanRun();
 | 
			
		||||
    } else {
 | 
			
		||||
      openAppSettings();
 | 
			
		||||
    }
 | 
			
		||||
@@ -487,7 +487,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
 | 
			
		||||
  void cancelBackup() {
 | 
			
		||||
    if (state.backupProgress != BackUpProgressEnum.inProgress) {
 | 
			
		||||
      _notifyBackgroundServiceCanRun();
 | 
			
		||||
      notifyBackgroundServiceCanRun();
 | 
			
		||||
    }
 | 
			
		||||
    state.cancelToken.cancel();
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
@@ -537,7 +537,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
      _updatePersistentAlbumsSelection();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _updateServerInfo();
 | 
			
		||||
    updateServerInfo();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onUploadProgress(int sent, int total) {
 | 
			
		||||
@@ -546,7 +546,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _updateServerInfo() async {
 | 
			
		||||
  Future<void> updateServerInfo() async {
 | 
			
		||||
    final serverInfo = await _serverInfoService.getServerInfo();
 | 
			
		||||
 | 
			
		||||
    // Update server info
 | 
			
		||||
@@ -569,9 +569,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
 | 
			
		||||
    // Check if this device is enable backup by the user
 | 
			
		||||
    if (state.autoBackup) {
 | 
			
		||||
      // check if backup is alreayd in process - then return
 | 
			
		||||
      // check if backup is already in process - then return
 | 
			
		||||
      if (state.backupProgress == BackUpProgressEnum.inProgress) {
 | 
			
		||||
        log.info("[_resumeBackup] Backup is already in progress - abort");
 | 
			
		||||
        log.info("[_resumeBackup] Auto Backup is already in progress - abort");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@@ -580,6 +580,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (state.backupProgress == BackUpProgressEnum.manualInProgress) {
 | 
			
		||||
        log.info("[_resumeBackup] Manual upload is running - abort");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Run backup
 | 
			
		||||
      log.info("[_resumeBackup] Start back up");
 | 
			
		||||
      await startBackupProcess();
 | 
			
		||||
@@ -594,7 +599,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
        .findAll();
 | 
			
		||||
    final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
 | 
			
		||||
        .filter()
 | 
			
		||||
        .selectionEqualTo(BackupSelection.select)
 | 
			
		||||
        .selectionEqualTo(BackupSelection.exclude)
 | 
			
		||||
        .findAll();
 | 
			
		||||
    Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
 | 
			
		||||
    Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
 | 
			
		||||
@@ -646,7 +651,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _notifyBackgroundServiceCanRun() async {
 | 
			
		||||
  Future<void> notifyBackgroundServiceCanRun() async {
 | 
			
		||||
    const allowedStates = [
 | 
			
		||||
      AppStateEnum.inactive,
 | 
			
		||||
      AppStateEnum.paused,
 | 
			
		||||
@@ -656,6 +661,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
      _backgroundService.releaseLock();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  BackUpProgressEnum get backupProgress => state.backupProgress;
 | 
			
		||||
  void updateBackupProgress(BackUpProgressEnum backupProgress) {
 | 
			
		||||
    state = state.copyWith(backupProgress: backupProgress);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final backupProvider =
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										300
									
								
								mobile/lib/modules/backup/providers/manual_upload.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										300
									
								
								mobile/lib/modules/backup/providers/manual_upload.provider.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,300 @@
 | 
			
		||||
import 'package:cancellation_token_http/http.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/widgets.dart';
 | 
			
		||||
import 'package:fluttertoast/fluttertoast.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/manual_upload_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/local_notification.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/backup_progress.dart';
 | 
			
		||||
import 'package:permission_handler/permission_handler.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
final manualUploadProvider =
 | 
			
		||||
    StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
 | 
			
		||||
  return ManualUploadNotifier(
 | 
			
		||||
    ref.watch(localNotificationService),
 | 
			
		||||
    ref.watch(backgroundServiceProvider),
 | 
			
		||||
    ref.watch(backupServiceProvider),
 | 
			
		||||
    ref.watch(backupProvider.notifier),
 | 
			
		||||
    ref,
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
 | 
			
		||||
  final LocalNotificationService _localNotificationService;
 | 
			
		||||
  final BackgroundService _backgroundService;
 | 
			
		||||
  final BackupService _backupService;
 | 
			
		||||
  final BackupNotifier _backupProvider;
 | 
			
		||||
  final Ref ref;
 | 
			
		||||
 | 
			
		||||
  ManualUploadNotifier(
 | 
			
		||||
    this._localNotificationService,
 | 
			
		||||
    this._backgroundService,
 | 
			
		||||
    this._backupService,
 | 
			
		||||
    this._backupProvider,
 | 
			
		||||
    this.ref,
 | 
			
		||||
  ) : super(
 | 
			
		||||
          ManualUploadState(
 | 
			
		||||
            progressInPercentage: 0,
 | 
			
		||||
            cancelToken: CancellationToken(),
 | 
			
		||||
            currentUploadAsset: CurrentUploadAsset(
 | 
			
		||||
              id: '...',
 | 
			
		||||
              fileCreatedAt: DateTime.parse('2020-10-04'),
 | 
			
		||||
              fileName: '...',
 | 
			
		||||
              fileType: '...',
 | 
			
		||||
            ),
 | 
			
		||||
            manualUploadsTotal: 0,
 | 
			
		||||
            manualUploadSuccess: 0,
 | 
			
		||||
            manualUploadFailures: 0,
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
  int get _uploadedAssetsCount =>
 | 
			
		||||
      state.manualUploadSuccess + state.manualUploadFailures;
 | 
			
		||||
 | 
			
		||||
  String _lastPrintedDetailContent = '';
 | 
			
		||||
  String? _lastPrintedDetailTitle;
 | 
			
		||||
 | 
			
		||||
  static const notifyInterval = Duration(milliseconds: 500);
 | 
			
		||||
  late final ThrottleProgressUpdate _throttledNotifiy =
 | 
			
		||||
      ThrottleProgressUpdate(_updateProgress, notifyInterval);
 | 
			
		||||
  late final ThrottleProgressUpdate _throttledDetailNotify =
 | 
			
		||||
      ThrottleProgressUpdate(_updateDetailProgress, notifyInterval);
 | 
			
		||||
 | 
			
		||||
  void _updateProgress(String? title, int progress, int total) {
 | 
			
		||||
    // Guard against throttling calling this method after the upload is done
 | 
			
		||||
    if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
 | 
			
		||||
      _localNotificationService.showOrUpdateManualUploadStatus(
 | 
			
		||||
        "backup_background_service_in_progress_notification".tr(),
 | 
			
		||||
        formatAssetBackupProgress(
 | 
			
		||||
          _uploadedAssetsCount,
 | 
			
		||||
          state.manualUploadsTotal,
 | 
			
		||||
        ),
 | 
			
		||||
        maxProgress: state.manualUploadsTotal,
 | 
			
		||||
        progress: _uploadedAssetsCount,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _updateDetailProgress(String? title, int progress, int total) {
 | 
			
		||||
    // Guard against throttling calling this method after the upload is done
 | 
			
		||||
    if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
 | 
			
		||||
      final String msg =
 | 
			
		||||
          total > 0 ? humanReadableBytesProgress(progress, total) : "";
 | 
			
		||||
      // only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
 | 
			
		||||
      if (msg != _lastPrintedDetailContent ||
 | 
			
		||||
          title != _lastPrintedDetailTitle) {
 | 
			
		||||
        _lastPrintedDetailContent = msg;
 | 
			
		||||
        _lastPrintedDetailTitle = title;
 | 
			
		||||
        _localNotificationService.showOrUpdateManualUploadStatus(
 | 
			
		||||
          title ?? 'Uploading',
 | 
			
		||||
          msg,
 | 
			
		||||
          progress: total > 0 ? (progress * 1000) ~/ total : 0,
 | 
			
		||||
          maxProgress: 1000,
 | 
			
		||||
          isDetailed: true,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onManualAssetUploaded(
 | 
			
		||||
    String deviceAssetId,
 | 
			
		||||
    String deviceId,
 | 
			
		||||
    bool isDuplicated,
 | 
			
		||||
  ) {
 | 
			
		||||
    state = state.copyWith(manualUploadSuccess: state.manualUploadSuccess + 1);
 | 
			
		||||
    _backupProvider.updateServerInfo();
 | 
			
		||||
    if (state.manualUploadsTotal > 1) {
 | 
			
		||||
      _throttledNotifiy();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onManualBackupError(ErrorUploadAsset errorAssetInfo) {
 | 
			
		||||
    state =
 | 
			
		||||
        state.copyWith(manualUploadFailures: state.manualUploadFailures + 1);
 | 
			
		||||
    if (state.manualUploadsTotal > 1) {
 | 
			
		||||
      _throttledNotifiy();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onProgress(int sent, int total) {
 | 
			
		||||
    final title = "backup_background_service_current_upload_notification"
 | 
			
		||||
        .tr(args: [state.currentUploadAsset.fileName]);
 | 
			
		||||
    _throttledDetailNotify(title: title, progress: sent, total: total);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
 | 
			
		||||
    state = state.copyWith(currentUploadAsset: currentUploadAsset);
 | 
			
		||||
    _throttledDetailNotify.title =
 | 
			
		||||
        "backup_background_service_current_upload_notification"
 | 
			
		||||
            .tr(args: [currentUploadAsset.fileName]);
 | 
			
		||||
    _throttledDetailNotify.progress = 0;
 | 
			
		||||
    _throttledDetailNotify.total = 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<bool> _startUpload(Iterable<Asset> allManualUploads) async {
 | 
			
		||||
    try {
 | 
			
		||||
      _backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
 | 
			
		||||
 | 
			
		||||
      if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
 | 
			
		||||
        await PhotoManager.clearFileCache();
 | 
			
		||||
 | 
			
		||||
        Set<AssetEntity> allUploadAssets = allManualUploads
 | 
			
		||||
            .where((e) => e.isLocal && e.local != null)
 | 
			
		||||
            .map((e) => e.local!)
 | 
			
		||||
            .toSet();
 | 
			
		||||
 | 
			
		||||
        if (allUploadAssets.isEmpty) {
 | 
			
		||||
          debugPrint("[_startUpload] No Assets to upload - Abort Process");
 | 
			
		||||
          _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Reset state
 | 
			
		||||
        state = state.copyWith(
 | 
			
		||||
          manualUploadsTotal: allManualUploads.length,
 | 
			
		||||
          manualUploadSuccess: 0,
 | 
			
		||||
          manualUploadFailures: 0,
 | 
			
		||||
          currentUploadAsset: CurrentUploadAsset(
 | 
			
		||||
            id: '...',
 | 
			
		||||
            fileCreatedAt: DateTime.parse('2020-10-04'),
 | 
			
		||||
            fileName: '...',
 | 
			
		||||
            fileType: '...',
 | 
			
		||||
          ),
 | 
			
		||||
          cancelToken: CancellationToken(),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (state.manualUploadsTotal > 1) {
 | 
			
		||||
          _throttledNotifiy();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Show detailed asset if enabled in settings or if a single asset is uploaded
 | 
			
		||||
        bool showDetailedNotification =
 | 
			
		||||
            ref.read(appSettingsServiceProvider).getSetting<bool>(
 | 
			
		||||
                      AppSettingsEnum.backgroundBackupSingleProgress,
 | 
			
		||||
                    ) ||
 | 
			
		||||
                state.manualUploadsTotal == 1;
 | 
			
		||||
 | 
			
		||||
        final bool ok = await _backupService.backupAsset(
 | 
			
		||||
          allUploadAssets,
 | 
			
		||||
          state.cancelToken,
 | 
			
		||||
          _onManualAssetUploaded,
 | 
			
		||||
          showDetailedNotification ? _onProgress : (sent, total) {},
 | 
			
		||||
          showDetailedNotification ? _onSetCurrentBackupAsset : (asset) {},
 | 
			
		||||
          _onManualBackupError,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // Close detailed notification
 | 
			
		||||
        await _localNotificationService.closeNotification(
 | 
			
		||||
          LocalNotificationService.manualUploadDetailedNotificationID,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        bool hasErrors = false;
 | 
			
		||||
        if ((state.manualUploadFailures != 0 &&
 | 
			
		||||
                state.manualUploadSuccess == 0) ||
 | 
			
		||||
            (!ok && !state.cancelToken.isCancelled)) {
 | 
			
		||||
          await _localNotificationService.showOrUpdateManualUploadStatus(
 | 
			
		||||
            "backup_manual_title".tr(),
 | 
			
		||||
            "backup_manual_failed".tr(),
 | 
			
		||||
            presentBanner: true,
 | 
			
		||||
          );
 | 
			
		||||
          hasErrors = true;
 | 
			
		||||
        } else if (state.manualUploadSuccess != 0) {
 | 
			
		||||
          await _localNotificationService.showOrUpdateManualUploadStatus(
 | 
			
		||||
            "backup_manual_title".tr(),
 | 
			
		||||
            "backup_manual_success".tr(),
 | 
			
		||||
            presentBanner: true,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
 | 
			
		||||
        await _backupProvider.notifyBackgroundServiceCanRun();
 | 
			
		||||
        return !hasErrors;
 | 
			
		||||
      } else {
 | 
			
		||||
        openAppSettings();
 | 
			
		||||
        debugPrint("[_startUpload] Do not have permission to the gallery");
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      debugPrint("ERROR _startUpload: ${e.toString()}");
 | 
			
		||||
    }
 | 
			
		||||
    await _localNotificationService.closeNotification(
 | 
			
		||||
      LocalNotificationService.manualUploadDetailedNotificationID,
 | 
			
		||||
    );
 | 
			
		||||
    _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
 | 
			
		||||
    await _backupProvider.notifyBackgroundServiceCanRun();
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void cancelBackup() {
 | 
			
		||||
    if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
 | 
			
		||||
      _backupProvider.notifyBackgroundServiceCanRun();
 | 
			
		||||
    }
 | 
			
		||||
    state.cancelToken.cancel();
 | 
			
		||||
    _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<bool> uploadAssets(
 | 
			
		||||
    BuildContext context,
 | 
			
		||||
    Iterable<Asset> allManualUploads,
 | 
			
		||||
  ) async {
 | 
			
		||||
    // assumes the background service is currently running and
 | 
			
		||||
    // waits until it has stopped to start the backup.
 | 
			
		||||
    final bool hasLock = await _backgroundService.acquireLock();
 | 
			
		||||
    if (!hasLock) {
 | 
			
		||||
      debugPrint("[uploadAssets] could not acquire lock, exiting");
 | 
			
		||||
      ImmichToast.show(
 | 
			
		||||
        context: context,
 | 
			
		||||
        msg: "backup_manual_failed".tr(),
 | 
			
		||||
        toastType: ToastType.info,
 | 
			
		||||
        gravity: ToastGravity.BOTTOM,
 | 
			
		||||
        durationInSecond: 3,
 | 
			
		||||
      );
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    bool showInProgress = false;
 | 
			
		||||
 | 
			
		||||
    // check if backup is already in process - then return
 | 
			
		||||
    if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
 | 
			
		||||
      debugPrint("[uploadAssets] Manual upload is already running - abort");
 | 
			
		||||
      showInProgress = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (_backupProvider.backupProgress == BackUpProgressEnum.inProgress) {
 | 
			
		||||
      debugPrint("[uploadAssets] Auto Backup is already in progress - abort");
 | 
			
		||||
      showInProgress = true;
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (_backupProvider.backupProgress == BackUpProgressEnum.inBackground) {
 | 
			
		||||
      debugPrint("[uploadAssets] Background backup is running - abort");
 | 
			
		||||
      showInProgress = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (showInProgress) {
 | 
			
		||||
      if (context.mounted) {
 | 
			
		||||
        ImmichToast.show(
 | 
			
		||||
          context: context,
 | 
			
		||||
          msg: "backup_manual_in_progress".tr(),
 | 
			
		||||
          toastType: ToastType.info,
 | 
			
		||||
          gravity: ToastGravity.BOTTOM,
 | 
			
		||||
          durationInSecond: 3,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return _startUpload(allManualUploads);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -53,7 +53,8 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
      () {
 | 
			
		||||
        if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
 | 
			
		||||
        if (backupState.backupProgress != BackUpProgressEnum.inProgress &&
 | 
			
		||||
            backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
 | 
			
		||||
          ref.watch(backupProvider.notifier).getBackupInfo();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/album.dart';
 | 
			
		||||
 | 
			
		||||
@@ -15,10 +17,12 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
  final void Function() onDelete;
 | 
			
		||||
  final Function(Album album) onAddToAlbum;
 | 
			
		||||
  final void Function() onCreateNewAlbum;
 | 
			
		||||
  final void Function() onUpload;
 | 
			
		||||
 | 
			
		||||
  final List<Album> albums;
 | 
			
		||||
  final List<Album> sharedAlbums;
 | 
			
		||||
  final bool enabled;
 | 
			
		||||
  final AssetState selectionAssetState;
 | 
			
		||||
 | 
			
		||||
  const ControlBottomAppBar({
 | 
			
		||||
    Key? key,
 | 
			
		||||
@@ -30,12 +34,15 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
    required this.albums,
 | 
			
		||||
    required this.onAddToAlbum,
 | 
			
		||||
    required this.onCreateNewAlbum,
 | 
			
		||||
    required this.onUpload,
 | 
			
		||||
    this.selectionAssetState = AssetState.remote,
 | 
			
		||||
    this.enabled = true,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    var isDarkMode = Theme.of(context).brightness == Brightness.dark;
 | 
			
		||||
    var hasRemote = selectionAssetState == AssetState.remote;
 | 
			
		||||
 | 
			
		||||
    Widget renderActionButtons() {
 | 
			
		||||
      return Row(
 | 
			
		||||
@@ -47,11 +54,12 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
            label: "control_bottom_app_bar_share".tr(),
 | 
			
		||||
            onPressed: enabled ? onShare : null,
 | 
			
		||||
          ),
 | 
			
		||||
          ControlBoxButton(
 | 
			
		||||
            iconData: Icons.favorite_border_rounded,
 | 
			
		||||
            label: "control_bottom_app_bar_favorite".tr(),
 | 
			
		||||
            onPressed: enabled ? onFavorite : null,
 | 
			
		||||
          ),
 | 
			
		||||
          if (hasRemote)
 | 
			
		||||
            ControlBoxButton(
 | 
			
		||||
              iconData: Icons.favorite_border_rounded,
 | 
			
		||||
              label: "control_bottom_app_bar_favorite".tr(),
 | 
			
		||||
              onPressed: enabled ? onFavorite : null,
 | 
			
		||||
            ),
 | 
			
		||||
          ControlBoxButton(
 | 
			
		||||
            iconData: Icons.delete_outline_rounded,
 | 
			
		||||
            label: "control_bottom_app_bar_delete".tr(),
 | 
			
		||||
@@ -66,19 +74,35 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
                    )
 | 
			
		||||
                : null,
 | 
			
		||||
          ),
 | 
			
		||||
          ControlBoxButton(
 | 
			
		||||
            iconData: Icons.archive,
 | 
			
		||||
            label: "control_bottom_app_bar_archive".tr(),
 | 
			
		||||
            onPressed: enabled ? onArchive : null,
 | 
			
		||||
          ),
 | 
			
		||||
          if (!hasRemote)
 | 
			
		||||
            ControlBoxButton(
 | 
			
		||||
              iconData: Icons.backup_outlined,
 | 
			
		||||
              label: "Upload",
 | 
			
		||||
              onPressed: enabled
 | 
			
		||||
                  ? () => showDialog(
 | 
			
		||||
                        context: context,
 | 
			
		||||
                        builder: (BuildContext context) {
 | 
			
		||||
                          return UploadDialog(
 | 
			
		||||
                            onUpload: onUpload,
 | 
			
		||||
                          );
 | 
			
		||||
                        },
 | 
			
		||||
                      )
 | 
			
		||||
                  : null,
 | 
			
		||||
            ),
 | 
			
		||||
          if (hasRemote)
 | 
			
		||||
            ControlBoxButton(
 | 
			
		||||
              iconData: Icons.archive,
 | 
			
		||||
              label: "control_bottom_app_bar_archive".tr(),
 | 
			
		||||
              onPressed: enabled ? onArchive : null,
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return DraggableScrollableSheet(
 | 
			
		||||
      initialChildSize: 0.30,
 | 
			
		||||
      minChildSize: 0.15,
 | 
			
		||||
      maxChildSize: 0.57,
 | 
			
		||||
      initialChildSize: hasRemote ? 0.30 : 0.18,
 | 
			
		||||
      minChildSize: 0.18,
 | 
			
		||||
      maxChildSize: hasRemote ? 0.57 : 0.18,
 | 
			
		||||
      snap: true,
 | 
			
		||||
      builder: (
 | 
			
		||||
        BuildContext context,
 | 
			
		||||
@@ -105,29 +129,33 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
                    const CustomDraggingHandle(),
 | 
			
		||||
                    const SizedBox(height: 12),
 | 
			
		||||
                    renderActionButtons(),
 | 
			
		||||
                    const Divider(
 | 
			
		||||
                      indent: 16,
 | 
			
		||||
                      endIndent: 16,
 | 
			
		||||
                      thickness: 1,
 | 
			
		||||
                    ),
 | 
			
		||||
                    AddToAlbumTitleRow(
 | 
			
		||||
                      onCreateNewAlbum: enabled ? onCreateNewAlbum : null,
 | 
			
		||||
                    ),
 | 
			
		||||
                    if (hasRemote)
 | 
			
		||||
                      const Divider(
 | 
			
		||||
                        indent: 16,
 | 
			
		||||
                        endIndent: 16,
 | 
			
		||||
                        thickness: 1,
 | 
			
		||||
                      ),
 | 
			
		||||
                    if (hasRemote)
 | 
			
		||||
                      AddToAlbumTitleRow(
 | 
			
		||||
                        onCreateNewAlbum: enabled ? onCreateNewAlbum : null,
 | 
			
		||||
                      ),
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              SliverPadding(
 | 
			
		||||
                padding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
                sliver: AddToAlbumSliverList(
 | 
			
		||||
                  albums: albums,
 | 
			
		||||
                  sharedAlbums: sharedAlbums,
 | 
			
		||||
                  onAddToAlbum: onAddToAlbum,
 | 
			
		||||
                  enabled: enabled,
 | 
			
		||||
              if (hasRemote)
 | 
			
		||||
                SliverPadding(
 | 
			
		||||
                  padding: const EdgeInsets.symmetric(horizontal: 16),
 | 
			
		||||
                  sliver: AddToAlbumSliverList(
 | 
			
		||||
                    albums: albums,
 | 
			
		||||
                    sharedAlbums: sharedAlbums,
 | 
			
		||||
                    onAddToAlbum: onAddToAlbum,
 | 
			
		||||
                    enabled: enabled,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              const SliverToBoxAdapter(
 | 
			
		||||
                child: SizedBox(height: 200),
 | 
			
		||||
              )
 | 
			
		||||
              if (hasRemote)
 | 
			
		||||
                const SliverToBoxAdapter(
 | 
			
		||||
                  child: SizedBox(height: 200),
 | 
			
		||||
                )
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer_header.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
@@ -35,6 +36,7 @@ class ProfileDrawer extends HookConsumerWidget {
 | 
			
		||||
        onTap: () async {
 | 
			
		||||
          await ref.watch(authenticationProvider.notifier).logout();
 | 
			
		||||
 | 
			
		||||
          ref.read(manualUploadProvider.notifier).cancelBackup();
 | 
			
		||||
          ref.watch(backupProvider.notifier).cancelBackup();
 | 
			
		||||
          ref.watch(assetProvider.notifier).clearAllAsset();
 | 
			
		||||
          ref.watch(websocketProvider.notifier).disconnect();
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										16
									
								
								mobile/lib/modules/home/ui/upload_dialog.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/lib/modules/home/ui/upload_dialog.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
 | 
			
		||||
 | 
			
		||||
class UploadDialog extends ConfirmDialog {
 | 
			
		||||
  final Function onUpload;
 | 
			
		||||
 | 
			
		||||
  const UploadDialog({Key? key, required this.onUpload})
 | 
			
		||||
      : super(
 | 
			
		||||
          key: key,
 | 
			
		||||
          title: 'upload_dialog_title',
 | 
			
		||||
          content: 'upload_dialog_info',
 | 
			
		||||
          cancel: 'upload_dialog_cancel',
 | 
			
		||||
          ok: 'upload_dialog_ok',
 | 
			
		||||
          onOk: onUpload,
 | 
			
		||||
        );
 | 
			
		||||
}
 | 
			
		||||
@@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
 | 
			
		||||
@@ -36,6 +37,7 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final multiselectEnabled = ref.watch(multiselectProvider.notifier);
 | 
			
		||||
    final selectionEnabledHook = useState(false);
 | 
			
		||||
    final selectionAssetState = useState(AssetState.remote);
 | 
			
		||||
 | 
			
		||||
    final selection = useState(<Asset>{});
 | 
			
		||||
    final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
 | 
			
		||||
@@ -80,6 +82,9 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
      ) {
 | 
			
		||||
        selectionEnabledHook.value = multiselect;
 | 
			
		||||
        selection.value = selectedAssets;
 | 
			
		||||
        selectionAssetState.value = selectedAssets.any((e) => e.isRemote)
 | 
			
		||||
            ? AssetState.remote
 | 
			
		||||
            : AssetState.local;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      void onShareAssets() {
 | 
			
		||||
@@ -172,6 +177,28 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      void onUpload() async {
 | 
			
		||||
        processing.value = true;
 | 
			
		||||
        try {
 | 
			
		||||
          final Set<Asset> assets = selection.value;
 | 
			
		||||
          if (assets.length > 30) {
 | 
			
		||||
            ImmichToast.show(
 | 
			
		||||
              context: context,
 | 
			
		||||
              msg: 'home_page_upload_err_limit'.tr(),
 | 
			
		||||
              gravity: ToastGravity.BOTTOM,
 | 
			
		||||
            );
 | 
			
		||||
          } else {
 | 
			
		||||
            processing.value = false;
 | 
			
		||||
            selectionEnabledHook.value = false;
 | 
			
		||||
            await ref
 | 
			
		||||
                .read(manualUploadProvider.notifier)
 | 
			
		||||
                .uploadAssets(context, assets);
 | 
			
		||||
          }
 | 
			
		||||
        } finally {
 | 
			
		||||
          processing.value = false;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      void onAddToAlbum(Album album) async {
 | 
			
		||||
        processing.value = true;
 | 
			
		||||
        try {
 | 
			
		||||
@@ -253,7 +280,7 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
        } else {
 | 
			
		||||
          refreshCount.value++;
 | 
			
		||||
          // set counter back to 0 if user does not request refresh again
 | 
			
		||||
          Timer(const Duration(seconds: 2), () {
 | 
			
		||||
          Timer(const Duration(seconds: 4), () {
 | 
			
		||||
            refreshCount.value = 0;
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
@@ -330,7 +357,9 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
                albums: albums,
 | 
			
		||||
                sharedAlbums: sharedAlbums,
 | 
			
		||||
                onCreateNewAlbum: onCreateNewAlbum,
 | 
			
		||||
                onUpload: onUpload,
 | 
			
		||||
                enabled: !processing.value,
 | 
			
		||||
                selectionAssetState: selectionAssetState.value,
 | 
			
		||||
              ),
 | 
			
		||||
            if (processing.value) const Center(child: ImmichLoadingIndicator())
 | 
			
		||||
          ],
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
 | 
			
		||||
@@ -79,6 +80,9 @@ class ChangePasswordForm extends HookConsumerWidget {
 | 
			
		||||
                                .read(authenticationProvider.notifier)
 | 
			
		||||
                                .logout();
 | 
			
		||||
 | 
			
		||||
                            ref
 | 
			
		||||
                                .read(manualUploadProvider.notifier)
 | 
			
		||||
                                .cancelBackup();
 | 
			
		||||
                            ref.read(backupProvider.notifier).cancelBackup();
 | 
			
		||||
                            ref.read(assetProvider.notifier).clearAllAsset();
 | 
			
		||||
                            ref.read(websocketProvider.notifier).disconnect();
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										132
									
								
								mobile/lib/shared/services/local_notification.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								mobile/lib/shared/services/local_notification.service.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,132 @@
 | 
			
		||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
 | 
			
		||||
final localNotificationService = Provider((ref) => LocalNotificationService());
 | 
			
		||||
 | 
			
		||||
class LocalNotificationService {
 | 
			
		||||
  static final LocalNotificationService _instance =
 | 
			
		||||
      LocalNotificationService._internal();
 | 
			
		||||
  final FlutterLocalNotificationsPlugin _localNotificationPlugin =
 | 
			
		||||
      FlutterLocalNotificationsPlugin();
 | 
			
		||||
 | 
			
		||||
  static const manualUploadNotificationID = 4;
 | 
			
		||||
  static const manualUploadDetailedNotificationID = 5;
 | 
			
		||||
  static const manualUploadChannelName = 'Manual Asset Upload';
 | 
			
		||||
  static const manualUploadChannelID = 'immich/manualUpload';
 | 
			
		||||
  static const manualUploadChannelNameDetailed = 'Manual Asset Upload Detailed';
 | 
			
		||||
  static const manualUploadDetailedChannelID = 'immich/manualUploadDetailed';
 | 
			
		||||
 | 
			
		||||
  factory LocalNotificationService() => _instance;
 | 
			
		||||
  LocalNotificationService._internal();
 | 
			
		||||
 | 
			
		||||
  Future<void> setup() async {
 | 
			
		||||
    const androidSetting = AndroidInitializationSettings('notification_icon');
 | 
			
		||||
    const iosSetting = DarwinInitializationSettings();
 | 
			
		||||
 | 
			
		||||
    const initSettings =
 | 
			
		||||
        InitializationSettings(android: androidSetting, iOS: iosSetting);
 | 
			
		||||
 | 
			
		||||
    await _localNotificationPlugin.initialize(initSettings);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _showOrUpdateNotification(
 | 
			
		||||
    int id,
 | 
			
		||||
    String channelId,
 | 
			
		||||
    String channelName,
 | 
			
		||||
    String title,
 | 
			
		||||
    String body, {
 | 
			
		||||
    bool? ongoing,
 | 
			
		||||
    bool? playSound,
 | 
			
		||||
    bool? showProgress,
 | 
			
		||||
    Priority? priority,
 | 
			
		||||
    Importance? importance,
 | 
			
		||||
    bool? onlyAlertOnce,
 | 
			
		||||
    int? maxProgress,
 | 
			
		||||
    int? progress,
 | 
			
		||||
    bool? indeterminate,
 | 
			
		||||
    bool? presentBadge,
 | 
			
		||||
    bool? presentBanner,
 | 
			
		||||
    bool? presentList,
 | 
			
		||||
  }) async {
 | 
			
		||||
    var androidNotificationDetails = AndroidNotificationDetails(
 | 
			
		||||
      channelId,
 | 
			
		||||
      channelName,
 | 
			
		||||
      ticker: title,
 | 
			
		||||
      playSound: playSound ?? false,
 | 
			
		||||
      showProgress: showProgress ?? false,
 | 
			
		||||
      maxProgress: maxProgress ?? 0,
 | 
			
		||||
      progress: progress ?? 0,
 | 
			
		||||
      onlyAlertOnce: onlyAlertOnce ?? false,
 | 
			
		||||
      indeterminate: indeterminate ?? false,
 | 
			
		||||
      priority: priority ?? Priority.defaultPriority,
 | 
			
		||||
      importance: importance ?? Importance.defaultImportance,
 | 
			
		||||
      ongoing: ongoing ?? false,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    var iosNotificationDetails = DarwinNotificationDetails(
 | 
			
		||||
      presentBadge: presentBadge ?? false,
 | 
			
		||||
      presentBanner: presentBanner ?? false,
 | 
			
		||||
      presentList: presentList ?? false,
 | 
			
		||||
      
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    final notificationDetails = NotificationDetails(
 | 
			
		||||
      android: androidNotificationDetails,
 | 
			
		||||
      iOS: iosNotificationDetails,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    await _localNotificationPlugin.show(id, title, body, notificationDetails);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> closeNotification(int id) {
 | 
			
		||||
    return _localNotificationPlugin.cancel(id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> showOrUpdateManualUploadStatus(
 | 
			
		||||
    String title,
 | 
			
		||||
    String body, {
 | 
			
		||||
    bool? isDetailed,
 | 
			
		||||
    bool? presentBanner,
 | 
			
		||||
    int? maxProgress,
 | 
			
		||||
    int? progress,
 | 
			
		||||
  }) {
 | 
			
		||||
    var notificationlId = manualUploadNotificationID;
 | 
			
		||||
    var channelId = manualUploadChannelID;
 | 
			
		||||
    var channelName = manualUploadChannelName;
 | 
			
		||||
    // Separate Notification for Info/Alerts and Progress
 | 
			
		||||
    if (isDetailed != null && isDetailed) {
 | 
			
		||||
      notificationlId = manualUploadDetailedNotificationID;
 | 
			
		||||
      channelId = manualUploadDetailedChannelID;
 | 
			
		||||
      channelName = manualUploadChannelNameDetailed;
 | 
			
		||||
    }
 | 
			
		||||
    final isProgressNotification = maxProgress != null && progress != null;
 | 
			
		||||
    return isProgressNotification
 | 
			
		||||
        ? _showOrUpdateNotification(
 | 
			
		||||
            notificationlId,
 | 
			
		||||
            channelId,
 | 
			
		||||
            channelName,
 | 
			
		||||
            title,
 | 
			
		||||
            body,
 | 
			
		||||
            showProgress: true,
 | 
			
		||||
            onlyAlertOnce: true,
 | 
			
		||||
            maxProgress: maxProgress,
 | 
			
		||||
            progress: progress,
 | 
			
		||||
            indeterminate: false,
 | 
			
		||||
            presentList: true,
 | 
			
		||||
            priority: Priority.low,
 | 
			
		||||
            importance: Importance.low,
 | 
			
		||||
            presentBadge: true,
 | 
			
		||||
            ongoing: true,
 | 
			
		||||
          )
 | 
			
		||||
        : _showOrUpdateNotification(
 | 
			
		||||
            notificationlId,
 | 
			
		||||
            channelId,
 | 
			
		||||
            channelName,
 | 
			
		||||
            title,
 | 
			
		||||
            body,
 | 
			
		||||
            presentList: true,
 | 
			
		||||
            presentBadge: true,
 | 
			
		||||
            presentBanner: presentBanner,
 | 
			
		||||
          );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										69
									
								
								mobile/lib/utils/backup_progress.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								mobile/lib/utils/backup_progress.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,69 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
 | 
			
		||||
final NumberFormat numberFormat = NumberFormat("###0.##");
 | 
			
		||||
 | 
			
		||||
String formatAssetBackupProgress(int uploadedAssets, int assetsToUpload) {
 | 
			
		||||
  final int percent = (uploadedAssets * 100) ~/ assetsToUpload;
 | 
			
		||||
  return "$percent% ($uploadedAssets/$assetsToUpload)";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
 | 
			
		||||
String humanReadableBytesProgress(int bytes, int bytesTotal) {
 | 
			
		||||
  String unit = "KB"; // Kilobyte
 | 
			
		||||
  if (bytesTotal >= 0x40000000) {
 | 
			
		||||
    unit = "GB"; // Gigabyte
 | 
			
		||||
    bytes >>= 20;
 | 
			
		||||
    bytesTotal >>= 20;
 | 
			
		||||
  } else if (bytesTotal >= 0x100000) {
 | 
			
		||||
    unit = "MB"; // Megabyte
 | 
			
		||||
    bytes >>= 10;
 | 
			
		||||
    bytesTotal >>= 10;
 | 
			
		||||
  } else if (bytesTotal < 0x400) {
 | 
			
		||||
    return "$bytes / $bytesTotal B";
 | 
			
		||||
  }
 | 
			
		||||
  final int percent = (bytes * 100) ~/ bytesTotal;
 | 
			
		||||
  final String done = numberFormat.format(bytes / 1024.0);
 | 
			
		||||
  final String total = numberFormat.format(bytesTotal / 1024.0);
 | 
			
		||||
  return "$percent% ($done/$total$unit)";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ThrottleProgressUpdate {
 | 
			
		||||
  ThrottleProgressUpdate(this._fun, Duration interval)
 | 
			
		||||
      : _interval = interval.inMicroseconds;
 | 
			
		||||
  final void Function(String?, int, int) _fun;
 | 
			
		||||
  final int _interval;
 | 
			
		||||
  int _invokedAt = 0;
 | 
			
		||||
  Timer? _timer;
 | 
			
		||||
 | 
			
		||||
  String? title;
 | 
			
		||||
  int progress = 0;
 | 
			
		||||
  int total = 0;
 | 
			
		||||
 | 
			
		||||
  void call({
 | 
			
		||||
    final String? title,
 | 
			
		||||
    final int progress = 0,
 | 
			
		||||
    final int total = 0,
 | 
			
		||||
  }) {
 | 
			
		||||
    final time = Timeline.now;
 | 
			
		||||
    this.title = title ?? this.title;
 | 
			
		||||
    this.progress = progress;
 | 
			
		||||
    this.total = total;
 | 
			
		||||
    if (time > _invokedAt + _interval) {
 | 
			
		||||
      _timer?.cancel();
 | 
			
		||||
      _onTimeElapsed();
 | 
			
		||||
    } else {
 | 
			
		||||
      _timer ??= Timer(Duration(microseconds: _interval), _onTimeElapsed);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onTimeElapsed() {
 | 
			
		||||
    _invokedAt = Timeline.now;
 | 
			
		||||
    _fun(title, progress, total);
 | 
			
		||||
    _timer = null;
 | 
			
		||||
    // clear title to not send/overwrite it next time if unchanged
 | 
			
		||||
    title = null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -435,6 +435,30 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.0.1"
 | 
			
		||||
  flutter_local_notifications:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: flutter_local_notifications
 | 
			
		||||
      sha256: "3cc40fe8c50ab8383f3e053a499f00f975636622ecdc8e20a77418ece3b1e975"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "15.1.0+1"
 | 
			
		||||
  flutter_local_notifications_linux:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: flutter_local_notifications_linux
 | 
			
		||||
      sha256: "33f741ef47b5f63cc7f78fe75eeeac7e19f171ff3c3df054d84c1e38bedb6a03"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "4.0.0+1"
 | 
			
		||||
  flutter_local_notifications_platform_interface:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: flutter_local_notifications_platform_interface
 | 
			
		||||
      sha256: "7cf643d6d5022f3baed0be777b0662cce5919c0a7b86e700299f22dc4ae660ef"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "7.0.0+1"
 | 
			
		||||
  flutter_localizations:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description: flutter
 | 
			
		||||
@@ -1272,6 +1296,14 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.1.3"
 | 
			
		||||
  timezone:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: timezone
 | 
			
		||||
      sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.9.2"
 | 
			
		||||
  timing:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
 
 | 
			
		||||
@@ -49,6 +49,7 @@ dependencies:
 | 
			
		||||
  connectivity_plus: ^4.0.1
 | 
			
		||||
  crypto: ^3.0.3 # TODO remove once native crypto is used on iOS
 | 
			
		||||
  wakelock: ^0.6.2
 | 
			
		||||
  flutter_local_notifications: ^15.1.0+1
 | 
			
		||||
 | 
			
		||||
  openapi:
 | 
			
		||||
    path: openapi
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user