diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index decb0a72e1..c092b79bd1 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -573,5 +573,9 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", - "viewer_unstack": "Un-Stack" + "viewer_unstack": "Un-Stack", + "sync_albums": "Sync albums", + "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", + "sync": "Sync" } diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index a84f980001..1dda2b9a12 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -234,6 +234,8 @@ enum StoreKey { primaryColor(128, type: String), dynamicTheme(129, type: bool), colorfulInterface(130, type: bool), + + syncAlbums(131, type: bool), ; const StoreKey( diff --git a/mobile/lib/models/backup/backup_candidate.model.dart b/mobile/lib/models/backup/backup_candidate.model.dart new file mode 100644 index 0000000000..5ef1516745 --- /dev/null +++ b/mobile/lib/models/backup/backup_candidate.model.dart @@ -0,0 +1,19 @@ +import 'package:photo_manager/photo_manager.dart'; + +class BackupCandidate { + BackupCandidate({required this.asset, required this.albumNames}); + + AssetEntity asset; + List albumNames; + + @override + int get hashCode => asset.hashCode; + + @override + bool operator ==(Object other) { + if (other is! BackupCandidate) { + return false; + } + return asset == other.asset; + } +} diff --git a/mobile/lib/models/backup/backup_state.model.dart b/mobile/lib/models/backup/backup_state.model.dart index bb693a5b75..d829f411fc 100644 --- a/mobile/lib/models/backup/backup_state.model.dart +++ b/mobile/lib/models/backup/backup_state.model.dart @@ -2,7 +2,7 @@ import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -41,7 +41,7 @@ class BackUpState { final Set excludedBackupAlbums; /// Assets that are not overlapping in selected backup albums and excluded backup albums - final Set allUniqueAssets; + final Set allUniqueAssets; /// All assets from the selected albums that have been backup final Set selectedAlbumsBackupAssetsIds; @@ -94,7 +94,7 @@ class BackUpState { List? availableAlbums, Set? selectedBackupAlbums, Set? excludedBackupAlbums, - Set? allUniqueAssets, + Set? allUniqueAssets, Set? selectedAlbumsBackupAssetsIds, CurrentUploadAsset? currentUploadAsset, }) { diff --git a/mobile/lib/models/backup/success_upload_asset.model.dart b/mobile/lib/models/backup/success_upload_asset.model.dart new file mode 100644 index 0000000000..045715e8cb --- /dev/null +++ b/mobile/lib/models/backup/success_upload_asset.model.dart @@ -0,0 +1,42 @@ +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; + +class SuccessUploadAsset { + final BackupCandidate candidate; + final String remoteAssetId; + final bool isDuplicate; + + SuccessUploadAsset({ + required this.candidate, + required this.remoteAssetId, + required this.isDuplicate, + }); + + SuccessUploadAsset copyWith({ + BackupCandidate? candidate, + String? remoteAssetId, + bool? isDuplicate, + }) { + return SuccessUploadAsset( + candidate: candidate ?? this.candidate, + remoteAssetId: remoteAssetId ?? this.remoteAssetId, + isDuplicate: isDuplicate ?? this.isDuplicate, + ); + } + + @override + String toString() => + 'SuccessUploadAsset(asset: $candidate, remoteAssetId: $remoteAssetId, isDuplicate: $isDuplicate)'; + + @override + bool operator ==(covariant SuccessUploadAsset other) { + if (identical(this, other)) return true; + + return other.candidate == candidate && + other.remoteAssetId == remoteAssetId && + other.isDuplicate == isDuplicate; + } + + @override + int get hashCode => + candidate.hashCode ^ remoteAssetId.hashCode ^ isDuplicate.hashCode; +} diff --git a/mobile/lib/pages/backup/backup_album_selection.page.dart b/mobile/lib/pages/backup/backup_album_selection.page.dart index 9f3e387755..8dccece325 100644 --- a/mobile/lib/pages/backup/backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/backup_album_selection.page.dart @@ -4,19 +4,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/backup/album_info_card.dart'; import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @RoutePage() class BackupAlbumSelectionPage extends HookConsumerWidget { const BackupAlbumSelectionPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - // final availableAlbums = ref.watch(backupProvider).availableAlbums; final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums; final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums; + final enableSyncUploadAlbum = + useAppSettingsState(AppSettingsEnum.syncAlbums); final isDarkTheme = context.isDarkTheme; final albums = ref.watch(backupProvider).availableAlbums; @@ -144,47 +149,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { }).toSet(); } - // buildSearchBar() { - // return Padding( - // padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0), - // child: TextFormField( - // onChanged: (searchValue) { - // // if (searchValue.isEmpty) { - // // albums = availableAlbums; - // // } else { - // // albums.value = availableAlbums - // // .where( - // // (album) => album.name - // // .toLowerCase() - // // .contains(searchValue.toLowerCase()), - // // ) - // // .toList(); - // // } - // }, - // decoration: InputDecoration( - // contentPadding: const EdgeInsets.symmetric( - // horizontal: 8.0, - // vertical: 8.0, - // ), - // hintText: "Search", - // hintStyle: TextStyle( - // color: isDarkTheme ? Colors.white : Colors.grey, - // fontSize: 14.0, - // ), - // prefixIcon: const Icon( - // Icons.search, - // color: Colors.grey, - // ), - // border: OutlineInputBorder( - // borderRadius: BorderRadius.circular(10), - // borderSide: BorderSide.none, - // ), - // filled: true, - // fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200], - // ), - // ), - // ); - // } + handleSyncAlbumToggle(bool isEnable) async { + if (isEnable) { + await ref.read(albumProvider.notifier).getAllAlbums(); + for (final album in selectedBackupAlbums) { + await ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } + } + } return Scaffold( appBar: AppBar( @@ -226,6 +198,20 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { ), ), + SettingsSwitchListTile( + valueNotifier: enableSyncUploadAlbum, + title: "sync_albums".tr(), + subtitle: "sync_upload_album_setting_subtitle".tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + titleStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.primary, + ), + onChanged: handleSyncAlbumToggle, + ), + ListTile( title: Text( "backup_album_selection_page_albums_device".tr( diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index 8251d5e66b..ed9dc07f5e 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -23,6 +23,7 @@ class AlbumNotifier extends StateNotifier> { }); _streamSub = query.watch().listen((data) => state = data); } + final AlbumService _albumService; late final StreamSubscription> _streamSub; @@ -41,6 +42,23 @@ class AlbumNotifier extends StateNotifier> { ) => _albumService.createAlbum(albumTitle, assets, []); + Future getAlbumByName(String albumName, {bool remoteOnly = false}) => + _albumService.getAlbumByName(albumName, remoteOnly); + + /// Create an album on the server with the same name as the selected album for backup + /// First this will check if the album already exists on the server with name + /// If it does not exist, it will create the album on the server + Future createSyncAlbum( + String albumName, + ) async { + final album = await getAlbumByName(albumName, remoteOnly: true); + if (album != null) { + return; + } + + await createAlbum(albumName, {}); + } + @override void dispose() { _streamSub.cancel(); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 58027e3b94..02f1f07904 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -2,13 +2,16 @@ import 'dart:io'; import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; @@ -290,8 +293,8 @@ class BackupNotifier extends StateNotifier { /// Future _updateBackupAssetCount() async { final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); - final Set assetsFromSelectedAlbums = {}; - final Set assetsFromExcludedAlbums = {}; + final Set assetsFromSelectedAlbums = {}; + final Set assetsFromExcludedAlbums = {}; for (final album in state.selectedBackupAlbums) { final assetCount = await album.albumEntity.assetCountAsync; @@ -304,7 +307,27 @@ class BackupNotifier extends StateNotifier { start: 0, end: assetCount, ); - assetsFromSelectedAlbums.addAll(assets); + + // Add album's name to the asset info + for (final asset in assets) { + List albumNames = [album.name]; + + final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull( + (a) => a.asset.id == asset.id, + ); + + if (existingAsset != null) { + albumNames.addAll(existingAsset.albumNames); + assetsFromSelectedAlbums.remove(existingAsset); + } + + assetsFromSelectedAlbums.add( + BackupCandidate( + asset: asset, + albumNames: albumNames, + ), + ); + } } for (final album in state.excludedBackupAlbums) { @@ -318,11 +341,17 @@ class BackupNotifier extends StateNotifier { start: 0, end: assetCount, ); - assetsFromExcludedAlbums.addAll(assets); + + for (final asset in assets) { + assetsFromExcludedAlbums.add( + BackupCandidate(asset: asset, albumNames: [album.name]), + ); + } } - final Set allUniqueAssets = + final Set allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); + final allAssetsInDatabase = await _backupService.getDeviceBackupAsset(); if (allAssetsInDatabase == null) { @@ -331,14 +360,14 @@ class BackupNotifier extends StateNotifier { // Find asset that were backup from selected albums final Set selectedAlbumsBackupAssets = - Set.from(allUniqueAssets.map((e) => e.id)); + Set.from(allUniqueAssets.map((e) => e.asset.id)); selectedAlbumsBackupAssets .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId)); // Remove duplicated asset from all unique assets allUniqueAssets.removeWhere( - (asset) => duplicatedAssetIds.contains(asset.id), + (candidate) => duplicatedAssetIds.contains(candidate.asset.id), ); if (allUniqueAssets.isEmpty) { @@ -433,10 +462,10 @@ class BackupNotifier extends StateNotifier { return; } - Set assetsWillBeBackup = Set.from(state.allUniqueAssets); + Set assetsWillBeBackup = Set.from(state.allUniqueAssets); // Remove item that has already been backed up for (final assetId in state.allAssetsInDatabase) { - assetsWillBeBackup.removeWhere((e) => e.id == assetId); + assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId); } if (assetsWillBeBackup.isEmpty) { @@ -456,11 +485,11 @@ class BackupNotifier extends StateNotifier { await _backupService.backupAsset( assetsWillBeBackup, state.cancelToken, - pmProgressHandler, - _onAssetUploaded, - _onUploadProgress, - _onSetCurrentBackupAsset, - _onBackupError, + pmProgressHandler: pmProgressHandler, + onSuccess: _onAssetUploaded, + onProgress: _onUploadProgress, + onCurrentAsset: _onSetCurrentBackupAsset, + onError: _onBackupError, ); await notifyBackgroundServiceCanRun(); } else { @@ -497,34 +526,36 @@ class BackupNotifier extends StateNotifier { ); } - void _onAssetUploaded( - String deviceAssetId, - String deviceId, - bool isDuplicated, - ) { - if (isDuplicated) { + void _onAssetUploaded(SuccessUploadAsset result) async { + if (result.isDuplicate) { state = state.copyWith( allUniqueAssets: state.allUniqueAssets - .where((asset) => asset.id != deviceAssetId) + .where( + (candidate) => candidate.asset.id != result.candidate.asset.id, + ) .toSet(), ); } else { state = state.copyWith( selectedAlbumsBackupAssetsIds: { ...state.selectedAlbumsBackupAssetsIds, - deviceAssetId, + result.candidate.asset.id, }, - allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId], + allAssetsInDatabase: [ + ...state.allAssetsInDatabase, + result.candidate.asset.id, + ], ); } if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) { - final latestAssetBackup = - state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce( - (v, e) => e.isAfter(v) ? e : v, - ); + final latestAssetBackup = state.allUniqueAssets + .map((candidate) => candidate.asset.modifiedDateTime) + .reduce( + (v, e) => e.isAfter(v) ? e : v, + ); state = state.copyWith( selectedBackupAlbums: state.selectedBackupAlbums .map((e) => e.copyWith(lastBackup: latestAssetBackup)) diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index b446711226..a76b56fea7 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -6,6 +6,8 @@ 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/models/backup/backup_candidate.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -22,6 +24,7 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; +import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -31,6 +34,7 @@ final manualUploadProvider = return ManualUploadNotifier( ref.watch(localNotificationService), ref.watch(backupProvider.notifier), + ref.watch(backupServiceProvider), ref, ); }); @@ -39,11 +43,13 @@ class ManualUploadNotifier extends StateNotifier { final Logger _log = Logger("ManualUploadNotifier"); final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; + final BackupService _backupService; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, + this._backupService, this.ref, ) : super( ManualUploadState( @@ -115,11 +121,7 @@ class ManualUploadNotifier extends StateNotifier { } } - void _onAssetUploaded( - String deviceAssetId, - String deviceId, - bool isDuplicated, - ) { + void _onAssetUploaded(SuccessUploadAsset result) { state = state.copyWith(successfulUploads: state.successfulUploads + 1); _backupProvider.updateDiskInfo(); } @@ -209,9 +211,23 @@ class ManualUploadNotifier extends StateNotifier { ); } - Set allUploadAssets = allAssetsFromDevice.nonNulls.toSet(); + final selectedBackupAlbums = + _backupService.selectedAlbumsQuery().findAllSync(); + final excludedBackupAlbums = + _backupService.excludedAlbumsQuery().findAllSync(); - if (allUploadAssets.isEmpty) { + // Get candidates from selected albums and excluded albums + Set candidates = + await _backupService.buildUploadCandidates( + selectedBackupAlbums, + excludedBackupAlbums, + ); + + // Extrack candidate from allAssetsFromDevice.nonNulls + final uploadAssets = candidates + .where((e) => allAssetsFromDevice.nonNulls.contains(e.asset)); + + if (uploadAssets.isEmpty) { debugPrint("[_startUpload] No Assets to upload - Abort Process"); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); return false; @@ -221,7 +237,7 @@ class ManualUploadNotifier extends StateNotifier { progressInPercentage: 0, progressInFileSize: "0 B / 0 B", progressInFileSpeed: 0, - totalAssetsToUpload: allUploadAssets.length, + totalAssetsToUpload: uploadAssets.length, successfulUploads: 0, currentAssetIndex: 0, currentUploadAsset: CurrentUploadAsset( @@ -250,13 +266,13 @@ class ManualUploadNotifier extends StateNotifier { final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final bool ok = await ref.read(backupServiceProvider).backupAsset( - allUploadAssets, + uploadAssets, state.cancelToken, - pmProgressHandler, - _onAssetUploaded, - _onProgress, - _onSetCurrentBackupAsset, - _onAssetUploadError, + pmProgressHandler: pmProgressHandler, + onSuccess: _onAssetUploaded, + onProgress: _onProgress, + onCurrentAsset: _onSetCurrentBackupAsset, + onError: _onAssetUploadError, ); // Close detailed notification diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index c2494680c7..ef56f9bf6c 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -28,7 +27,6 @@ final albumServiceProvider = Provider( ref.watch(userServiceProvider), ref.watch(syncServiceProvider), ref.watch(dbProvider), - ref.watch(backupServiceProvider), ), ); @@ -37,7 +35,6 @@ class AlbumService { final UserService _userService; final SyncService _syncService; final Isar _db; - final BackupService _backupService; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); @@ -47,9 +44,15 @@ class AlbumService { this._userService, this._syncService, this._db, - this._backupService, ); + QueryBuilder + selectedAlbumsQuery() => + _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); + QueryBuilder + excludedAlbumsQuery() => + _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); + /// Checks all selected device albums for changes of albums and their assets /// Updates the local database and returns `true` if there were any changes Future refreshDeviceAlbums() async { @@ -63,9 +66,9 @@ class AlbumService { bool changes = false; try { final List excludedIds = - await _backupService.excludedAlbumsQuery().idProperty().findAll(); + await excludedAlbumsQuery().idProperty().findAll(); final List selectedIds = - await _backupService.selectedAlbumsQuery().idProperty().findAll(); + await selectedAlbumsQuery().idProperty().findAll(); if (selectedIds.isEmpty) { final numLocal = await _db.albums.where().localIdIsNotNull().count(); if (numLocal > 0) { @@ -441,4 +444,33 @@ class AlbumService { return false; } } + + Future getAlbumByName(String name, bool remoteOnly) async { + return _db.albums + .filter() + .optional(remoteOnly, (q) => q.localIdIsNull()) + .nameEqualTo(name) + .sharedEqualTo(false) + .findFirst(); + } + + /// + /// Add the uploaded asset to the selected albums + /// + Future syncUploadAlbums( + List albumNames, + List assetIds, + ) async { + for (final albumName in albumNames) { + Album? album = await getAlbumByName(albumName, true); + album ??= await createAlbum(albumName, []); + + if (album != null && album.remoteId != null) { + await _apiService.albumsApi.addAssetsToAlbum( + album.remoteId!, + BulkIdsDto(ids: assetIds), + ); + } + } + } } diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index bd25403215..8f773e1bb3 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -76,6 +76,7 @@ enum AppSettingsEnum { false, ), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), + syncAlbums(StoreKey.syncAlbums, null, false), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index d37133a63b..17508cba51 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -2,15 +2,20 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:isar/isar.dart'; @@ -23,6 +28,8 @@ final assetServiceProvider = Provider( ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), + ref.watch(backupServiceProvider), + ref.watch(albumServiceProvider), ref.watch(dbProvider), ), ); @@ -31,6 +38,8 @@ class AssetService { final ApiService _apiService; final SyncService _syncService; final UserService _userService; + final BackupService _backupService; + final AlbumService _albumService; final log = Logger('AssetService'); final Isar _db; @@ -38,6 +47,8 @@ class AssetService { this._apiService, this._syncService, this._userService, + this._backupService, + this._albumService, this._db, ); @@ -284,4 +295,64 @@ class AssetService { return Future.value(null); } } + + Future syncUploadedAssetToAlbums() async { + try { + final [selectedAlbums, excludedAlbums] = await Future.wait([ + _backupService.selectedAlbumsQuery().findAll(), + _backupService.excludedAlbumsQuery().findAll(), + ]); + + final candidates = await _backupService.buildUploadCandidates( + selectedAlbums, + excludedAlbums, + useTimeFilter: false, + ); + + final duplicates = await _apiService.assetsApi.checkExistingAssets( + CheckExistingAssetsDto( + deviceAssetIds: candidates.map((c) => c.asset.id).toList(), + deviceId: Store.get(StoreKey.deviceId), + ), + ); + + if (duplicates != null) { + candidates + .removeWhere((c) => !duplicates.existingIds.contains(c.asset.id)); + } + + await refreshRemoteAssets(); + final remoteAssets = await _db.assets + .where() + .localIdIsNotNull() + .filter() + .remoteIdIsNotNull() + .findAll(); + + /// Map + Map> assetToAlbums = {}; + + for (BackupCandidate candidate in candidates) { + final asset = remoteAssets.firstWhereOrNull( + (a) => a.localId == candidate.asset.id, + ); + + if (asset != null) { + for (final albumName in candidate.albumNames) { + assetToAlbums.putIfAbsent(albumName, () => []).add(asset.remoteId!); + } + } + } + + // Upload assets to albums + for (final entry in assetToAlbums.entries) { + final albumName = entry.key; + final assetIds = entry.value; + + await _albumService.syncUploadAlbums([albumName], assetIds); + } + } catch (error, stack) { + log.severe("Error while syncing uploaded asset to albums", error, stack); + } + } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index ba8f5c01ed..b27ed34b94 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -10,6 +10,10 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/main.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -18,6 +22,9 @@ import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/partner.service.dart'; +import 'package:immich_mobile/services/sync.service.dart'; +import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; @@ -345,8 +352,16 @@ class BackgroundService { ApiService apiService = ApiService(); apiService.setAccessToken(Store.get(StoreKey.accessToken)); AppSettingsService settingService = AppSettingsService(); - BackupService backupService = BackupService(apiService, db, settingService); AppSettingsService settingsService = AppSettingsService(); + PartnerService partnerService = PartnerService(apiService, db); + HashService hashService = HashService(db, this); + SyncService syncSerive = SyncService(db, hashService); + UserService userService = + UserService(apiService, db, syncSerive, partnerService); + AlbumService albumService = + AlbumService(apiService, userService, syncSerive, db); + BackupService backupService = + BackupService(apiService, db, settingService, albumService); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); @@ -416,7 +431,7 @@ class BackgroundService { return false; } - List toUpload = await backupService.buildUploadCandidates( + Set toUpload = await backupService.buildUploadCandidates( selectedAlbums, excludedAlbums, ); @@ -460,29 +475,47 @@ class BackgroundService { final bool ok = await backupService.backupAsset( toUpload, _cancellationToken!, - pmProgressHandler, - notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {}, - notifySingleProgress ? _onProgress : (sent, total) {}, - notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {}, - _onBackupError, - sortAssets: true, + pmProgressHandler: pmProgressHandler, + onSuccess: (result) => _onAssetUploaded( + result: result, + shouldNotify: notifyTotalProgress, + ), + onProgress: (bytes, totalBytes) => + _onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress), + onCurrentAsset: (asset) => + _onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress), + onError: _onBackupError, + isBackground: true, ); + if (!ok && !_cancellationToken!.isCancelled) { _showErrorNotification( title: "backup_background_service_error_title".tr(), content: "backup_background_service_backup_failed_message".tr(), ); } + return ok; } - void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) { + void _onAssetUploaded({ + required SuccessUploadAsset result, + bool shouldNotify = false, + }) async { + if (!shouldNotify) { + return; + } + _uploadedAssetsCount++; _throttledNotifiy(); } - void _onProgress(int sent, int total) { - _throttledDetailNotify(progress: sent, total: total); + void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) { + if (!shouldNotify) { + return; + } + + _throttledDetailNotify(progress: bytes, total: totalBytes); } void _updateDetailProgress(String? title, int progress, int total) { @@ -522,7 +555,14 @@ class BackgroundService { ); } - void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { + void _onSetCurrentBackupAsset( + CurrentUploadAsset currentUploadAsset, { + bool shouldNotify = false, + }) { + if (!shouldNotify) { + return; + } + _throttledDetailNotify.title = "backup_background_service_current_upload_notification" .tr(args: [currentUploadAsset.fileName]); diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 64d683dc2a..12edd14d60 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -9,11 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; +import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:isar/isar.dart'; @@ -28,6 +31,7 @@ final backupServiceProvider = Provider( ref.watch(apiServiceProvider), ref.watch(dbProvider), ref.watch(appSettingsServiceProvider), + ref.watch(albumServiceProvider), ), ); @@ -37,8 +41,14 @@ class BackupService { final Isar _db; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; + final AlbumService _albumService; - BackupService(this._apiService, this._db, this._appSetting); + BackupService( + this._apiService, + this._db, + this._appSetting, + this._albumService, + ); Future?> getDeviceBackupAsset() async { final String deviceId = Store.get(StoreKey.deviceId); @@ -70,10 +80,12 @@ class BackupService { _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); /// Returns all assets newer than the last successful backup per album - Future> buildUploadCandidates( + /// if `useTimeFilter` is set to true, all assets will be returned + Future> buildUploadCandidates( List selectedBackupAlbums, - List excludedBackupAlbums, - ) async { + List excludedBackupAlbums, { + bool useTimeFilter = true, + }) async { final filter = FilterOptionGroup( containsPathModified: true, orders: [const OrderOption(type: OrderOptionType.updateDate)], @@ -82,105 +94,156 @@ class BackupService { videoOption: const FilterOption(needTitle: true), ); final now = DateTime.now(); + final List selectedAlbums = - await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now); + await _loadAlbumsWithTimeFilter( + selectedBackupAlbums, + filter, + now, + useTimeFilter: useTimeFilter, + ); + if (selectedAlbums.every((e) => e == null)) { - return []; - } - final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll); - if (allIdx != -1) { - final List excludedAlbums = - await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now); - final List toAdd = await _fetchAssetsAndUpdateLastBackup( - selectedAlbums.slice(allIdx, allIdx + 1), - selectedBackupAlbums.slice(allIdx, allIdx + 1), - now, - ); - final List toRemove = await _fetchAssetsAndUpdateLastBackup( - excludedAlbums, - excludedBackupAlbums, - now, - ); - return toAdd.toSet().difference(toRemove.toSet()).toList(); - } else { - return await _fetchAssetsAndUpdateLastBackup( - selectedAlbums, - selectedBackupAlbums, - now, - ); + return {}; } + + final List excludedAlbums = + await _loadAlbumsWithTimeFilter( + excludedBackupAlbums, + filter, + now, + useTimeFilter: useTimeFilter, + ); + + final Set toAdd = await _fetchAssetsAndUpdateLastBackup( + selectedAlbums, + selectedBackupAlbums, + now, + useTimeFilter: useTimeFilter, + ); + + final Set toRemove = await _fetchAssetsAndUpdateLastBackup( + excludedAlbums, + excludedBackupAlbums, + now, + useTimeFilter: useTimeFilter, + ); + + return toAdd.difference(toRemove); } Future> _loadAlbumsWithTimeFilter( List albums, FilterOptionGroup filter, - DateTime now, - ) async { + DateTime now, { + bool useTimeFilter = true, + }) async { List result = []; - for (BackupAlbum a in albums) { + for (BackupAlbum backupAlbum in albums) { try { + final optionGroup = useTimeFilter + ? filter.copyWith( + updateTimeCond: DateTimeCond( + // subtract 2 seconds to prevent missing assets due to rounding issues + min: backupAlbum.lastBackup + .subtract(const Duration(seconds: 2)), + max: now, + ), + ) + : filter; + final AssetPathEntity album = await AssetPathEntity.obtainPathFromProperties( - id: a.id, - optionGroup: filter.copyWith( - updateTimeCond: DateTimeCond( - // subtract 2 seconds to prevent missing assets due to rounding issues - min: a.lastBackup.subtract(const Duration(seconds: 2)), - max: now, - ), - ), + id: backupAlbum.id, + optionGroup: optionGroup, maxDateTimeToNow: false, ); + result.add(album); } on StateError { // either there are no assets matching the filter criteria OR the album no longer exists } } + return result; } - Future> _fetchAssetsAndUpdateLastBackup( - List albums, + Future> _fetchAssetsAndUpdateLastBackup( + List localAlbums, List backupAlbums, - DateTime now, - ) async { - List result = []; - for (int i = 0; i < albums.length; i++) { - final AssetPathEntity? a = albums[i]; - if (a != null && - a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) { - result.addAll( - await a.getAssetListRange(start: 0, end: await a.assetCountAsync), - ); - backupAlbums[i].lastBackup = now; + DateTime now, { + bool useTimeFilter = true, + }) async { + Set candidate = {}; + + for (int i = 0; i < localAlbums.length; i++) { + final localAlbum = localAlbums[i]; + if (localAlbum == null) { + continue; } + + if (useTimeFilter && + localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) == + true) { + continue; + } + + final assets = await localAlbum.getAssetListRange( + start: 0, + end: await localAlbum.assetCountAsync, + ); + + // Add album's name to the asset info + for (final asset in assets) { + List albumNames = [localAlbum.name]; + + final existingAsset = candidate.firstWhereOrNull( + (a) => a.asset.id == asset.id, + ); + + if (existingAsset != null) { + albumNames.addAll(existingAsset.albumNames); + candidate.remove(existingAsset); + } + + candidate.add( + BackupCandidate( + asset: asset, + albumNames: albumNames, + ), + ); + } + + backupAlbums[i].lastBackup = now; } - return result; + + return candidate; } /// Returns a new list of assets not yet uploaded - Future> removeAlreadyUploadedAssets( - List candidates, + Future> removeAlreadyUploadedAssets( + Set candidates, ) async { if (candidates.isEmpty) { return candidates; } + final Set duplicatedAssetIds = await getDuplicatedAssetIds(); - candidates = duplicatedAssetIds.isEmpty - ? candidates - : candidates - .whereNot((asset) => duplicatedAssetIds.contains(asset.id)) - .toList(); + candidates.removeWhere( + (candidate) => duplicatedAssetIds.contains(candidate.asset.id), + ); + if (candidates.isEmpty) { return candidates; } + final Set existing = {}; try { final String deviceId = Store.get(StoreKey.deviceId); final CheckExistingAssetsResponseDto? duplicates = await _apiService.assetsApi.checkExistingAssets( CheckExistingAssetsDto( - deviceAssetIds: candidates.map((e) => e.id).toList(), + deviceAssetIds: candidates.map((c) => c.asset.id).toList(), deviceId: deviceId, ), ); @@ -194,55 +257,75 @@ class BackupService { existing.addAll(allAssetsInDatabase); } } - return existing.isEmpty - ? candidates - : candidates.whereNot((e) => existing.contains(e.id)).toList(); + + if (existing.isNotEmpty) { + candidates.removeWhere((c) => existing.contains(c.asset.id)); + } + + return candidates; } - Future backupAsset( - Iterable assetList, - http.CancellationToken cancelToken, - PMProgressHandler? pmProgressHandler, - Function(String, String, bool) uploadSuccessCb, - Function(int, int) uploadProgressCb, - Function(CurrentUploadAsset) setCurrentUploadAssetCb, - Function(ErrorUploadAsset) errorCb, { - bool sortAssets = false, - }) async { - final bool isIgnoreIcloudAssets = - _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets); - + Future _checkPermissions() async { if (Platform.isAndroid && !(await pm.Permission.accessMediaLocation.status).isGranted) { // double check that permission is granted here, to guard against // uploading corrupt assets without EXIF information _log.warning("Media location permission is not granted. " "Cannot access original assets for backup."); + return false; } - final String deviceId = Store.get(StoreKey.deviceId); - final String savedEndpoint = Store.get(StoreKey.serverEndpoint); - bool anyErrors = false; - final List duplicatedAssetIds = []; // DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS if (Platform.isIOS) { await PhotoManager.requestPermissionExtend(); } - List assetsToUpload = sortAssets - // Upload images before video assets - // these are further sorted by using their creation date - ? assetList.sorted( - (a, b) { - final cmp = a.typeInt - b.typeInt; - if (cmp != 0) return cmp; - return a.createDateTime.compareTo(b.createDateTime); - }, - ) - : assetList.toList(); + return true; + } - for (var entity in assetsToUpload) { + /// Upload images before video assets for background tasks + /// these are further sorted by using their creation date + List _sortPhotosFirst(List candidates) { + return candidates.sorted( + (a, b) { + final cmp = a.asset.typeInt - b.asset.typeInt; + if (cmp != 0) return cmp; + return a.asset.createDateTime.compareTo(b.asset.createDateTime); + }, + ); + } + + Future backupAsset( + Iterable assets, + http.CancellationToken cancelToken, { + bool isBackground = false, + PMProgressHandler? pmProgressHandler, + required void Function(SuccessUploadAsset result) onSuccess, + required void Function(int bytes, int totalBytes) onProgress, + required void Function(CurrentUploadAsset asset) onCurrentAsset, + required void Function(ErrorUploadAsset error) onError, + }) async { + final bool isIgnoreIcloudAssets = + _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets); + final shouldSyncAlbums = _appSetting.getSetting(AppSettingsEnum.syncAlbums); + final String deviceId = Store.get(StoreKey.deviceId); + final String savedEndpoint = Store.get(StoreKey.serverEndpoint); + final List duplicatedAssetIds = []; + bool anyErrors = false; + + final hasPermission = await _checkPermissions(); + if (!hasPermission) { + return false; + } + + List candidates = assets.toList(); + if (isBackground) { + candidates = _sortPhotosFirst(candidates); + } + + for (final candidate in candidates) { + final AssetEntity entity = candidate.asset; File? file; File? livePhotoFile; @@ -257,7 +340,7 @@ class BackupService { continue; } - setCurrentUploadAssetCb( + onCurrentAsset( CurrentUploadAsset( id: entity.id, fileCreatedAt: entity.createDateTime.year == 1970 @@ -299,23 +382,22 @@ class BackupService { } } - var fileStream = file.openRead(); - var assetRawUploadData = http.MultipartFile( + final fileStream = file.openRead(); + final assetRawUploadData = http.MultipartFile( "assetData", fileStream, file.lengthSync(), filename: originalFileName, ); - var baseRequest = MultipartRequest( + final baseRequest = MultipartRequest( 'POST', Uri.parse('$savedEndpoint/assets'), - onProgress: ((bytes, totalBytes) => - uploadProgressCb(bytes, totalBytes)), + onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), ); + baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.headers["Transfer-Encoding"] = "chunked"; - baseRequest.fields['deviceAssetId'] = entity.id; baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['fileCreatedAt'] = @@ -324,12 +406,9 @@ class BackupService { entity.modifiedDateTime.toUtc().toIso8601String(); baseRequest.fields['isFavorite'] = entity.isFavorite.toString(); baseRequest.fields['duration'] = entity.videoDuration.toString(); - baseRequest.files.add(assetRawUploadData); - var fileSize = file.lengthSync(); - - setCurrentUploadAssetCb( + onCurrentAsset( CurrentUploadAsset( id: entity.id, fileCreatedAt: entity.createDateTime.year == 1970 @@ -337,7 +416,7 @@ class BackupService { : entity.createDateTime, fileName: originalFileName, fileType: _getAssetType(entity.type), - fileSize: fileSize, + fileSize: file.lengthSync(), iCloudAsset: false, ), ); @@ -356,22 +435,23 @@ class BackupService { baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - var response = await httpClient.send( + final response = await httpClient.send( baseRequest, cancellationToken: cancelToken, ); - var responseBody = jsonDecode(await response.stream.bytesToString()); + final responseBody = + jsonDecode(await response.stream.bytesToString()); if (![200, 201].contains(response.statusCode)) { - var error = responseBody; - var errorMessage = error['message'] ?? error['error']; + final error = responseBody; + final errorMessage = error['message'] ?? error['error']; debugPrint( "Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}", ); - errorCb( + onError( ErrorUploadAsset( asset: entity, id: entity.id, @@ -386,23 +466,37 @@ class BackupService { anyErrors = true; break; } + continue; } - var isDuplicate = false; + bool isDuplicate = false; if (response.statusCode == 200) { isDuplicate = true; duplicatedAssetIds.add(entity.id); } - uploadSuccessCb(entity.id, deviceId, isDuplicate); + onSuccess( + SuccessUploadAsset( + candidate: candidate, + remoteAssetId: responseBody['id'] as String, + isDuplicate: isDuplicate, + ), + ); + + if (shouldSyncAlbums && !isDuplicate) { + await _albumService.syncUploadAlbums( + candidate.albumNames, + [responseBody['id'] as String], + ); + } } } on http.CancelledException { debugPrint("Backup was cancelled by the user"); anyErrors = true; break; - } catch (e) { - debugPrint("ERROR backupAsset: ${e.toString()}"); + } catch (error, stackTrace) { + debugPrint("Error backup asset: ${error.toString()}: $stackTrace"); anyErrors = true; continue; } finally { @@ -416,9 +510,11 @@ class BackupService { } } } + if (duplicatedAssetIds.isNotEmpty) { await _saveDuplicatedAssetIds(duplicatedAssetIds); } + return !anyErrors; } diff --git a/mobile/lib/widgets/backup/album_info_card.dart b/mobile/lib/widgets/backup/album_info_card.dart index e9349bd69e..0c9cd2d89d 100644 --- a/mobile/lib/widgets/backup/album_info_card.dart +++ b/mobile/lib/widgets/backup/album_info_card.dart @@ -5,15 +5,21 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumInfoCard extends HookConsumerWidget { final AvailableAlbum album; - const AlbumInfoCard({super.key, required this.album}); + const AlbumInfoCard({ + super.key, + required this.album, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -21,6 +27,9 @@ class AlbumInfoCard extends HookConsumerWidget { ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album); + final syncAlbum = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); final isDarkTheme = context.isDarkTheme; @@ -85,6 +94,9 @@ class AlbumInfoCard extends HookConsumerWidget { ref.read(backupProvider.notifier).removeAlbumForBackup(album); } else { ref.read(backupProvider.notifier).addAlbumForBackup(album); + if (syncAlbum) { + ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } } }, onDoubleTap: () { diff --git a/mobile/lib/widgets/backup/album_info_list_tile.dart b/mobile/lib/widgets/backup/album_info_list_tile.dart index 7cdc595c7f..d326bad3e0 100644 --- a/mobile/lib/widgets/backup/album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/album_info_list_tile.dart @@ -5,9 +5,12 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumInfoListTile extends HookConsumerWidget { @@ -21,7 +24,10 @@ class AlbumInfoListTile extends HookConsumerWidget { ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album); - var assetCount = useState(0); + final assetCount = useState(0); + final syncAlbum = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); useEffect( () { @@ -98,6 +104,9 @@ class AlbumInfoListTile extends HookConsumerWidget { ref.read(backupProvider.notifier).removeAlbumForBackup(album); } else { ref.read(backupProvider.notifier).addAlbumForBackup(album); + if (syncAlbum) { + ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } } }, leading: buildIcon(), diff --git a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart index 25bcf2d06e..c093e8f1e3 100644 --- a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart @@ -1,9 +1,12 @@ import 'dart:io'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/backup/backup_verification.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/background_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; @@ -23,7 +26,21 @@ class BackupSettings extends HookConsumerWidget { useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets); final isAdvancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); + final albumSync = useAppSettingsState(AppSettingsEnum.syncAlbums); final isCorruptCheckInProgress = ref.watch(backupVerificationProvider); + final isAlbumSyncInProgress = useState(false); + + syncAlbums() async { + isAlbumSyncInProgress.value = true; + try { + await ref.read(assetServiceProvider).syncUploadedAssetToAlbums(); + } catch (_) { + } finally { + Future.delayed(const Duration(seconds: 1), () { + isAlbumSyncInProgress.value = false; + }); + } + } final backupSettings = [ const ForegroundBackupSettings(), @@ -58,6 +75,23 @@ class BackupSettings extends HookConsumerWidget { .performBackupCheck(context) : null, ), + if (albumSync.value) + SettingsButtonListTile( + icon: Icons.photo_album_outlined, + title: 'sync_albums'.tr(), + subtitle: Text( + "sync_albums_manual_subtitle".tr(), + ), + buttonText: 'sync_albums'.tr(), + child: isAlbumSyncInProgress.value + ? const CircularProgressIndicator.adaptive( + strokeWidth: 2, + ) + : ElevatedButton( + onPressed: syncAlbums, + child: Text('sync'.tr()), + ), + ), ]; return SettingsSubPageScaffold( diff --git a/mobile/lib/widgets/settings/settings_button_list_tile.dart b/mobile/lib/widgets/settings/settings_button_list_tile.dart index 196e3d170f..c8bd8e4b58 100644 --- a/mobile/lib/widgets/settings/settings_button_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_button_list_tile.dart @@ -9,6 +9,7 @@ class SettingsButtonListTile extends StatelessWidget { final Widget? subtitle; final String? subtileText; final String buttonText; + final Widget? child; final void Function()? onButtonTap; const SettingsButtonListTile({ @@ -18,6 +19,7 @@ class SettingsButtonListTile extends StatelessWidget { this.subtileText, this.subtitle, required this.buttonText, + this.child, this.onButtonTap, super.key, }); @@ -48,7 +50,8 @@ class SettingsButtonListTile extends StatelessWidget { ), if (subtitle != null) subtitle!, const SizedBox(height: 6), - ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), + child ?? + ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), ], ), ); diff --git a/mobile/lib/widgets/settings/settings_switch_list_tile.dart b/mobile/lib/widgets/settings/settings_switch_list_tile.dart index 78f1738266..8aa4ec0a60 100644 --- a/mobile/lib/widgets/settings/settings_switch_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_switch_list_tile.dart @@ -9,6 +9,9 @@ class SettingsSwitchListTile extends StatelessWidget { final String? subtitle; final IconData? icon; final Function(bool)? onChanged; + final EdgeInsets? contentPadding; + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; const SettingsSwitchListTile({ required this.valueNotifier, @@ -17,6 +20,9 @@ class SettingsSwitchListTile extends StatelessWidget { this.icon, this.enabled = true, this.onChanged, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 20), + this.titleStyle, + this.subtitleStyle, super.key, }); @@ -30,7 +36,7 @@ class SettingsSwitchListTile extends StatelessWidget { } return SwitchListTile.adaptive( - contentPadding: const EdgeInsets.symmetric(horizontal: 20), + contentPadding: contentPadding, selectedTileColor: enabled ? null : context.themeData.disabledColor, value: valueNotifier.value, onChanged: onSwitchChanged, @@ -45,20 +51,22 @@ class SettingsSwitchListTile extends StatelessWidget { : null, title: Text( title, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - color: enabled ? null : context.themeData.disabledColor, - height: 1.5, - ), + style: titleStyle ?? + context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: enabled ? null : context.themeData.disabledColor, + height: 1.5, + ), ), subtitle: subtitle != null ? Text( subtitle!, - style: context.textTheme.bodyMedium?.copyWith( - color: enabled - ? context.colorScheme.onSurfaceSecondary - : context.themeData.disabledColor, - ), + style: subtitleStyle ?? + context.textTheme.bodyMedium?.copyWith( + color: enabled + ? context.colorScheme.onSurfaceSecondary + : context.themeData.disabledColor, + ), ) : null, );