diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 84e4acf77d..da8f8bd220 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,10 @@ android:name="io.flutter.embedding.android.EnableImpeller" android:value="false" /> + + Bool { + // Required for flutter_local_notification + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + } + GeneratedPluginRegistrant.register(with: self) BackgroundServicePlugin.registerBackgroundProcessing() diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 5c9a60393d..482b3d00a1 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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 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 } } SystemChrome.setSystemUIOverlayStyle(overlayStyle); + await ref.read(localNotificationService).setup(); } @override diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index ddd1b40a81..4cc0b6f42c 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -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() ], ); } diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 4d36bf77f4..3c2ceb2cba 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -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 diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 5817e1d2b4..823a7c2bcb 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -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 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() { diff --git a/mobile/lib/modules/backup/models/backup_state.model.dart b/mobile/lib/modules/backup/models/backup_state.model.dart index cfec7513ef..e573600fe3 100644 --- a/mobile/lib/modules/backup/models/backup_state.model.dart +++ b/mobile/lib/modules/backup/models/backup_state.model.dart @@ -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 diff --git a/mobile/lib/modules/backup/models/manual_upload_state.model.dart b/mobile/lib/modules/backup/models/manual_upload_state.model.dart new file mode 100644 index 0000000000..f54ab6f2a3 --- /dev/null +++ b/mobile/lib/modules/backup/models/manual_upload_state.model.dart @@ -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; + } +} diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index 0781785b14..0c38a6831b 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -388,7 +388,7 @@ class BackupNotifier extends StateNotifier { if (state.backupProgress != BackUpProgressEnum.inBackground) { await _getBackupAlbumsInfo(); - await _updateServerInfo(); + await updateServerInfo(); await _updateBackupAssetCount(); } } @@ -465,7 +465,7 @@ class BackupNotifier extends StateNotifier { _onSetCurrentBackupAsset, _onBackupError, ); - await _notifyBackgroundServiceCanRun(); + await notifyBackgroundServiceCanRun(); } else { openAppSettings(); } @@ -487,7 +487,7 @@ class BackupNotifier extends StateNotifier { void cancelBackup() { if (state.backupProgress != BackUpProgressEnum.inProgress) { - _notifyBackgroundServiceCanRun(); + notifyBackgroundServiceCanRun(); } state.cancelToken.cancel(); state = state.copyWith( @@ -537,7 +537,7 @@ class BackupNotifier extends StateNotifier { _updatePersistentAlbumsSelection(); } - _updateServerInfo(); + updateServerInfo(); } void _onUploadProgress(int sent, int total) { @@ -546,7 +546,7 @@ class BackupNotifier extends StateNotifier { ); } - Future _updateServerInfo() async { + Future updateServerInfo() async { final serverInfo = await _serverInfoService.getServerInfo(); // Update server info @@ -569,9 +569,9 @@ class BackupNotifier extends StateNotifier { // 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 { 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 { .findAll(); final List excludedBackupAlbums = await _db.backupAlbums .filter() - .selectionEqualTo(BackupSelection.select) + .selectionEqualTo(BackupSelection.exclude) .findAll(); Set selectedAlbums = state.selectedBackupAlbums; Set excludedAlbums = state.excludedBackupAlbums; @@ -646,7 +651,7 @@ class BackupNotifier extends StateNotifier { return result; } - Future _notifyBackgroundServiceCanRun() async { + Future notifyBackgroundServiceCanRun() async { const allowedStates = [ AppStateEnum.inactive, AppStateEnum.paused, @@ -656,6 +661,11 @@ class BackupNotifier extends StateNotifier { _backgroundService.releaseLock(); } } + + BackUpProgressEnum get backupProgress => state.backupProgress; + void updateBackupProgress(BackUpProgressEnum backupProgress) { + state = state.copyWith(backupProgress: backupProgress); + } } final backupProvider = diff --git a/mobile/lib/modules/backup/providers/manual_upload.provider.dart b/mobile/lib/modules/backup/providers/manual_upload.provider.dart new file mode 100644 index 0000000000..492df2e6c9 --- /dev/null +++ b/mobile/lib/modules/backup/providers/manual_upload.provider.dart @@ -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((ref) { + return ManualUploadNotifier( + ref.watch(localNotificationService), + ref.watch(backgroundServiceProvider), + ref.watch(backupServiceProvider), + ref.watch(backupProvider.notifier), + ref, + ); +}); + +class ManualUploadNotifier extends StateNotifier { + 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 _startUpload(Iterable allManualUploads) async { + try { + _backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress); + + if (ref.read(galleryPermissionNotifier.notifier).hasPermission) { + await PhotoManager.clearFileCache(); + + Set 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( + 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 uploadAssets( + BuildContext context, + Iterable 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); + } +} diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index 241525921a..e6df35ff7a 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -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(); } diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 6ed0d49a14..607e79fcbe 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -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 albums; final List 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), + ) ], ), ); diff --git a/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart b/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart index 4587e50fe3..5d41a68426 100644 --- a/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart +++ b/mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart @@ -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(); diff --git a/mobile/lib/modules/home/ui/upload_dialog.dart b/mobile/lib/modules/home/ui/upload_dialog.dart new file mode 100644 index 0000000000..a3feafb1ff --- /dev/null +++ b/mobile/lib/modules/home/ui/upload_dialog.dart @@ -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, + ); +} diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 715dcc47a5..311bf98a79 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -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({}); 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 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()) ], diff --git a/mobile/lib/modules/login/ui/change_password_form.dart b/mobile/lib/modules/login/ui/change_password_form.dart index dac0bd163a..904c59563b 100644 --- a/mobile/lib/modules/login/ui/change_password_form.dart +++ b/mobile/lib/modules/login/ui/change_password_form.dart @@ -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(); diff --git a/mobile/lib/shared/services/local_notification.service.dart b/mobile/lib/shared/services/local_notification.service.dart new file mode 100644 index 0000000000..36a2400f16 --- /dev/null +++ b/mobile/lib/shared/services/local_notification.service.dart @@ -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 setup() async { + const androidSetting = AndroidInitializationSettings('notification_icon'); + const iosSetting = DarwinInitializationSettings(); + + const initSettings = + InitializationSettings(android: androidSetting, iOS: iosSetting); + + await _localNotificationPlugin.initialize(initSettings); + } + + Future _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 closeNotification(int id) { + return _localNotificationPlugin.cancel(id); + } + + Future 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, + ); + } +} diff --git a/mobile/lib/utils/backup_progress.dart b/mobile/lib/utils/backup_progress.dart new file mode 100644 index 0000000000..f24e8c6cf9 --- /dev/null +++ b/mobile/lib/utils/backup_progress.dart @@ -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; + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index a79fc0d766..5881a8e103 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f556de874e..91cdedc70f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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