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