From b536fdf186c8deab8f4ff8071292f40360b65c54 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 28 Jun 2024 13:22:57 -0500 Subject: [PATCH] Revert "chore(mobile): remove exclude album mechanism for backup (#10552)" This reverts commit 5f47cf604ad6719f70a0e98d34de85510d4758f3. --- mobile/lib/entities/backup_album.entity.dart | 1 + .../lib/entities/backup_album.entity.g.dart | 2 + .../lib/models/backup/backup_state.model.dart | 20 ++-- .../backup/backup_album_selection.page.dart | 80 ++++++++++++++ .../pages/backup/backup_controller.page.dart | 30 +++++- .../lib/providers/backup/backup.provider.dart | 102 +++++++++++++++--- .../backup/backup_verification.provider.dart | 2 +- .../backup_verification.provider.g.dart | 2 +- mobile/lib/services/background.service.dart | 6 +- mobile/lib/services/backup.service.dart | 14 ++- .../lib/widgets/backup/album_info_card.dart | 45 +++++++- .../widgets/backup/album_info_list_tile.dart | 37 +++++++ 12 files changed, 309 insertions(+), 32 deletions(-) diff --git a/mobile/lib/entities/backup_album.entity.dart b/mobile/lib/entities/backup_album.entity.dart index 5229d93782fc1..4d4d7b3aa39eb 100644 --- a/mobile/lib/entities/backup_album.entity.dart +++ b/mobile/lib/entities/backup_album.entity.dart @@ -18,4 +18,5 @@ class BackupAlbum { enum BackupSelection { none, select, + exclude; } diff --git a/mobile/lib/entities/backup_album.entity.g.dart b/mobile/lib/entities/backup_album.entity.g.dart index aad62aed742dd..7fb6c0e03b6b2 100644 --- a/mobile/lib/entities/backup_album.entity.g.dart +++ b/mobile/lib/entities/backup_album.entity.g.dart @@ -107,10 +107,12 @@ P _backupAlbumDeserializeProp

( const _BackupAlbumselectionEnumValueMap = { 'none': 0, 'select': 1, + 'exclude': 2, }; const _BackupAlbumselectionValueEnumMap = { 0: BackupSelection.none, 1: BackupSelection.select, + 2: BackupSelection.exclude, }; Id _backupAlbumGetId(BackupAlbum object) { diff --git a/mobile/lib/models/backup/backup_state.model.dart b/mobile/lib/models/backup/backup_state.model.dart index b9fcefc79d1d5..bb693a5b75f7a 100644 --- a/mobile/lib/models/backup/backup_state.model.dart +++ b/mobile/lib/models/backup/backup_state.model.dart @@ -38,9 +38,10 @@ class BackUpState { /// All available albums on the device final List availableAlbums; final Set selectedBackupAlbums; + final Set excludedBackupAlbums; /// Assets that are not overlapping in selected backup albums and excluded backup albums - final Set backupCandidates; + final Set allUniqueAssets; /// All assets from the selected albums that have been backup final Set selectedAlbumsBackupAssetsIds; @@ -67,7 +68,8 @@ class BackUpState { required this.backupTriggerDelay, required this.availableAlbums, required this.selectedBackupAlbums, - required this.backupCandidates, + required this.excludedBackupAlbums, + required this.allUniqueAssets, required this.selectedAlbumsBackupAssetsIds, required this.currentUploadAsset, }); @@ -91,7 +93,8 @@ class BackUpState { int? backupTriggerDelay, List? availableAlbums, Set? selectedBackupAlbums, - Set? backupCandidates, + Set? excludedBackupAlbums, + Set? allUniqueAssets, Set? selectedAlbumsBackupAssetsIds, CurrentUploadAsset? currentUploadAsset, }) { @@ -118,7 +121,8 @@ class BackUpState { backupTriggerDelay: backupTriggerDelay ?? this.backupTriggerDelay, availableAlbums: availableAlbums ?? this.availableAlbums, selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums, - backupCandidates: backupCandidates ?? this.backupCandidates, + excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums, + allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets, selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds, currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset, @@ -127,7 +131,7 @@ class BackUpState { @override String toString() { - return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, backupCandidates: $backupCandidates, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; + return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; } @override @@ -154,7 +158,8 @@ class BackUpState { other.backupTriggerDelay == backupTriggerDelay && collectionEquals(other.availableAlbums, availableAlbums) && collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) && - collectionEquals(other.backupCandidates, backupCandidates) && + collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) && + collectionEquals(other.allUniqueAssets, allUniqueAssets) && collectionEquals( other.selectedAlbumsBackupAssetsIds, selectedAlbumsBackupAssetsIds, @@ -182,7 +187,8 @@ class BackUpState { backupTriggerDelay.hashCode ^ availableAlbums.hashCode ^ selectedBackupAlbums.hashCode ^ - backupCandidates.hashCode ^ + excludedBackupAlbums.hashCode ^ + allUniqueAssets.hashCode ^ selectedAlbumsBackupAssetsIds.hashCode ^ currentUploadAsset.hashCode; } diff --git a/mobile/lib/pages/backup/backup_album_selection.page.dart b/mobile/lib/pages/backup/backup_album_selection.page.dart index 70dc0dbf53e0a..ecfebd3cb75e8 100644 --- a/mobile/lib/pages/backup/backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/backup_album_selection.page.dart @@ -3,6 +3,7 @@ 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/constants/immich_colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/widgets/backup/album_info_card.dart'; @@ -16,6 +17,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { 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 isDarkTheme = context.isDarkTheme; final albums = ref.watch(backupProvider).availableAlbums; @@ -109,6 +111,83 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { }).toSet(); } + buildExcludedAlbumNameChip() { + return excludedBackupAlbums.map((album) { + void removeSelection() { + ref + .watch(backupProvider.notifier) + .removeExcludedAlbumForBackup(album); + } + + return GestureDetector( + onTap: removeSelection, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Chip( + label: Text( + album.name, + style: TextStyle( + fontSize: 12, + color: isDarkTheme ? Colors.black : immichBackgroundColor, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: Colors.red[300], + deleteIconColor: + isDarkTheme ? Colors.black : immichBackgroundColor, + deleteIcon: const Icon( + Icons.cancel_rounded, + size: 15, + ), + onDeleted: removeSelection, + ), + ), + ); + }).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], + // ), + // ), + // ); + // } + return Scaffold( appBar: AppBar( leading: IconButton( @@ -144,6 +223,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { child: Wrap( children: [ ...buildSelectedAlbumNameChip(), + ...buildExcludedAlbumNameChip(), ], ), ), diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart index 847b2518b6d32..89384cf97ac7f 100644 --- a/mobile/lib/pages/backup/backup_controller.page.dart +++ b/mobile/lib/pages/backup/backup_controller.page.dart @@ -29,7 +29,7 @@ class BackupControllerPage extends HookConsumerWidget { final didGetBackupInfo = useState(false); bool hasExclusiveAccess = backupState.backupProgress != BackUpProgressEnum.inBackground; - bool shouldBackup = backupState.backupCandidates.length - + bool shouldBackup = backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 || !hasExclusiveAccess @@ -100,6 +100,29 @@ class BackupControllerPage extends HookConsumerWidget { } } + Widget buildExcludedAlbumName() { + var text = "backup_controller_page_excluded".tr(); + var albums = ref.watch(backupProvider).excludedBackupAlbums; + + if (albums.isNotEmpty) { + for (var album in albums) { + text += "${album.name}, "; + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + text.trim().substring(0, text.length - 2), + style: context.textTheme.labelLarge?.copyWith( + color: Colors.red[300], + ), + ), + ); + } else { + return const SizedBox(); + } + } + buildFolderSelectionTile() { return Padding( padding: const EdgeInsets.only(top: 8.0), @@ -131,6 +154,7 @@ class BackupControllerPage extends HookConsumerWidget { style: context.textTheme.bodyMedium, ).tr(), buildSelectedAlbumName(), + buildExcludedAlbumName(), ], ), ), @@ -268,7 +292,7 @@ class BackupControllerPage extends HookConsumerWidget { subtitle: "backup_controller_page_total_sub".tr(), info: ref.watch(backupProvider).availableAlbums.isEmpty ? "..." - : "${backupState.backupCandidates.length}", + : "${backupState.allUniqueAssets.length}", ), BackupInfoCard( title: "backup_controller_page_backup".tr(), @@ -282,7 +306,7 @@ class BackupControllerPage extends HookConsumerWidget { subtitle: "backup_controller_page_remainder_sub".tr(), info: ref.watch(backupProvider).availableAlbums.isEmpty ? "..." - : "${max(0, backupState.backupCandidates.length - backupState.selectedAlbumsBackupAssetsIds.length)}", + : "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}", ), const Divider(), const CurrentUploadingAssetInfoBox(), diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 301b50aef6507..58027e3b941e0 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -61,7 +61,8 @@ class BackupNotifier extends StateNotifier { ), availableAlbums: const [], selectedBackupAlbums: const {}, - backupCandidates: const {}, + excludedBackupAlbums: const {}, + allUniqueAssets: const {}, selectedAlbumsBackupAssetsIds: const {}, currentUploadAsset: CurrentUploadAsset( id: '...', @@ -93,10 +94,22 @@ class BackupNotifier extends StateNotifier { /// The total unique assets will be used for backing mechanism /// void addAlbumForBackup(AvailableAlbum album) { + if (state.excludedBackupAlbums.contains(album)) { + removeExcludedAlbumForBackup(album); + } + state = state .copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album}); } + void addExcludedAlbumForBackup(AvailableAlbum album) { + if (state.selectedBackupAlbums.contains(album)) { + removeAlbumForBackup(album); + } + state = state + .copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album}); + } + void removeAlbumForBackup(AvailableAlbum album) { Set currentSelectedAlbums = state.selectedBackupAlbums; @@ -105,6 +118,14 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums); } + void removeExcludedAlbumForBackup(AvailableAlbum album) { + Set currentExcludedAlbums = state.excludedBackupAlbums; + + currentExcludedAlbums.removeWhere((a) => a == album); + + state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums); + } + Future backupAlbumSelectionDone() { if (state.selectedBackupAlbums.isEmpty) { // disable any backup @@ -219,6 +240,8 @@ class BackupNotifier extends StateNotifier { } state = state.copyWith(availableAlbums: availableAlbums); + final List excludedBackupAlbums = + await _backupService.excludedAlbumsQuery().findAll(); final List selectedBackupAlbums = await _backupService.selectedAlbumsQuery().findAll(); @@ -236,8 +259,22 @@ class BackupNotifier extends StateNotifier { } } + final Set excludedAlbums = {}; + for (final BackupAlbum ba in excludedBackupAlbums) { + final albumAsset = albumMap[ba.id]; + + if (albumAsset != null) { + excludedAlbums.add( + AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), + ); + } else { + log.severe('Excluded album not found'); + } + } + state = state.copyWith( selectedBackupAlbums: selectedAlbums, + excludedBackupAlbums: excludedAlbums, ); log.info( @@ -253,7 +290,8 @@ class BackupNotifier extends StateNotifier { /// Future _updateBackupAssetCount() async { final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); - final Set backupCandidates = {}; + final Set assetsFromSelectedAlbums = {}; + final Set assetsFromExcludedAlbums = {}; for (final album in state.selectedBackupAlbums) { final assetCount = await album.albumEntity.assetCountAsync; @@ -266,9 +304,25 @@ class BackupNotifier extends StateNotifier { start: 0, end: assetCount, ); - backupCandidates.addAll(assets); + assetsFromSelectedAlbums.addAll(assets); + } + + for (final album in state.excludedBackupAlbums) { + final assetCount = await album.albumEntity.assetCountAsync; + + if (assetCount == 0) { + continue; + } + + final assets = await album.albumEntity.getAssetListRange( + start: 0, + end: assetCount, + ); + assetsFromExcludedAlbums.addAll(assets); } + final Set allUniqueAssets = + assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); final allAssetsInDatabase = await _backupService.getDeviceBackupAsset(); if (allAssetsInDatabase == null) { @@ -277,28 +331,28 @@ class BackupNotifier extends StateNotifier { // Find asset that were backup from selected albums final Set selectedAlbumsBackupAssets = - Set.from(backupCandidates.map((e) => e.id)); + Set.from(allUniqueAssets.map((e) => e.id)); selectedAlbumsBackupAssets .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId)); // Remove duplicated asset from all unique assets - backupCandidates.removeWhere( + allUniqueAssets.removeWhere( (asset) => duplicatedAssetIds.contains(asset.id), ); - if (backupCandidates.isEmpty) { + if (allUniqueAssets.isEmpty) { log.info("No assets are selected for back up"); state = state.copyWith( backupProgress: BackUpProgressEnum.idle, allAssetsInDatabase: allAssetsInDatabase, - backupCandidates: {}, + allUniqueAssets: {}, selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, ); } else { state = state.copyWith( allAssetsInDatabase: allAssetsInDatabase, - backupCandidates: backupCandidates, + allUniqueAssets: allUniqueAssets, selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, ); } @@ -333,8 +387,10 @@ class BackupNotifier extends StateNotifier { final selected = state.selectedBackupAlbums.map( (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select), ); - - final backupAlbums = selected.toList(); + final excluded = state.excludedBackupAlbums.map( + (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude), + ); + final backupAlbums = selected.followedBy(excluded).toList(); backupAlbums.sortBy((e) => e.id); return _db.writeTxn(() async { final dbAlbums = await _db.backupAlbums.where().sortById().findAll(); @@ -371,13 +427,13 @@ class BackupNotifier extends StateNotifier { if (hasPermission) { await PhotoManager.clearFileCache(); - if (state.backupCandidates.isEmpty) { + if (state.allUniqueAssets.isEmpty) { log.info("No Asset On Device - Abort Backup Process"); state = state.copyWith(backupProgress: BackUpProgressEnum.idle); return; } - Set assetsWillBeBackup = Set.from(state.backupCandidates); + 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); @@ -448,7 +504,7 @@ class BackupNotifier extends StateNotifier { ) { if (isDuplicated) { state = state.copyWith( - backupCandidates: state.backupCandidates + allUniqueAssets: state.allUniqueAssets .where((asset) => asset.id != deviceAssetId) .toSet(), ); @@ -462,17 +518,20 @@ class BackupNotifier extends StateNotifier { ); } - if (state.backupCandidates.length - + if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) { final latestAssetBackup = - state.backupCandidates.map((e) => e.modifiedDateTime).reduce( + state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce( (v, e) => e.isAfter(v) ? e : v, ); state = state.copyWith( selectedBackupAlbums: state.selectedBackupAlbums .map((e) => e.copyWith(lastBackup: latestAssetBackup)) .toSet(), + excludedBackupAlbums: state.excludedBackupAlbums + .map((e) => e.copyWith(lastBackup: latestAssetBackup)) + .toSet(), backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0, progressInFileSize: "0 B / 0 B", @@ -571,8 +630,12 @@ class BackupNotifier extends StateNotifier { .filter() .selectionEqualTo(BackupSelection.select) .findAll(); - + final List excludedBackupAlbums = await _db.backupAlbums + .filter() + .selectionEqualTo(BackupSelection.exclude) + .findAll(); Set selectedAlbums = state.selectedBackupAlbums; + Set excludedAlbums = state.excludedBackupAlbums; if (selectedAlbums.isNotEmpty) { selectedAlbums = _updateAlbumsBackupTime( selectedAlbums, @@ -580,10 +643,17 @@ class BackupNotifier extends StateNotifier { ); } + if (excludedAlbums.isNotEmpty) { + excludedAlbums = _updateAlbumsBackupTime( + excludedAlbums, + excludedBackupAlbums, + ); + } final BackUpProgressEnum previous = state.backupProgress; state = state.copyWith( backupProgress: BackUpProgressEnum.inBackground, selectedBackupAlbums: selectedAlbums, + excludedBackupAlbums: excludedAlbums, ); // assumes the background service is currently running // if true, waits until it has stopped to start the backup diff --git a/mobile/lib/providers/backup/backup_verification.provider.dart b/mobile/lib/providers/backup/backup_verification.provider.dart index bd2c5d5f9d026..894b807ec8759 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.dart @@ -23,7 +23,7 @@ class BackupVerification extends _$BackupVerification { state = true; final backupState = ref.read(backupProvider); - if (backupState.backupCandidates.length > + if (backupState.allUniqueAssets.length > backupState.selectedAlbumsBackupAssetsIds.length) { if (context.mounted) { ImmichToast.show( diff --git a/mobile/lib/providers/backup/backup_verification.provider.g.dart b/mobile/lib/providers/backup/backup_verification.provider.g.dart index aa00550d3aa6d..f222c9bd83e12 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.g.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.g.dart @@ -7,7 +7,7 @@ part of 'backup_verification.provider.dart'; // ************************************************************************** String _$backupVerificationHash() => - r'4f64459d68d20de4a61160ec8e9be347ec945fb6'; + r'b691e0cc27856eef189258d3c102cc73ce4812a4'; /// See also [BackupVerification]. @ProviderFor(BackupVerification) diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index a2a2abae8d6c2..ba8f5c01ed963 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -349,6 +349,7 @@ class BackgroundService { AppSettingsService settingsService = AppSettingsService(); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); + final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); if (selectedAlbums.isEmpty) { return true; } @@ -360,10 +361,11 @@ class BackgroundService { backupService, settingsService, selectedAlbums, + excludedAlbums, ); if (backupOk) { await Store.delete(StoreKey.backupFailedSince); - final backupAlbums = [...selectedAlbums]; + final backupAlbums = [...selectedAlbums, ...excludedAlbums]; backupAlbums.sortBy((e) => e.id); db.writeTxnSync(() { final dbAlbums = db.backupAlbums.where().sortById().findAllSync(); @@ -402,6 +404,7 @@ class BackgroundService { BackupService backupService, AppSettingsService settingsService, List selectedAlbums, + List excludedAlbums, ) async { _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService); final bool notifyTotalProgress = settingsService @@ -415,6 +418,7 @@ class BackgroundService { List toUpload = await backupService.buildUploadCandidates( selectedAlbums, + excludedAlbums, ); try { diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index e7cb4d27eee49..a42c587435b1d 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -65,10 +65,14 @@ class BackupService { QueryBuilder selectedAlbumsQuery() => _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); + QueryBuilder + excludedAlbumsQuery() => + _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); /// Returns all assets newer than the last successful backup per album Future> buildUploadCandidates( List selectedBackupAlbums, + List excludedBackupAlbums, ) async { final filter = FilterOptionGroup( containsPathModified: true, @@ -85,13 +89,19 @@ class BackupService { } 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, ); - - return toAdd.toSet().toList(); + final List toRemove = await _fetchAssetsAndUpdateLastBackup( + excludedAlbums, + excludedBackupAlbums, + now, + ); + return toAdd.toSet().difference(toRemove.toSet()).toList(); } else { return await _fetchAssetsAndUpdateLastBackup( selectedAlbums, diff --git a/mobile/lib/widgets/backup/album_info_card.dart b/mobile/lib/widgets/backup/album_info_card.dart index c65f14e953621..e9349bd69eccf 100644 --- a/mobile/lib/widgets/backup/album_info_card.dart +++ b/mobile/lib/widgets/backup/album_info_card.dart @@ -1,12 +1,14 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +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/backup/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumInfoCard extends HookConsumerWidget { final AvailableAlbum album; @@ -17,6 +19,8 @@ class AlbumInfoCard extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final bool isSelected = ref.watch(backupProvider).selectedBackupAlbums.contains(album); + final bool isExcluded = + ref.watch(backupProvider).excludedBackupAlbums.contains(album); final isDarkTheme = context.isDarkTheme; @@ -24,7 +28,8 @@ class AlbumInfoCard extends HookConsumerWidget { context.primaryColor.withAlpha(100), BlendMode.darken, ); - + ColorFilter excludedFilter = + ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken); ColorFilter unselectedFilter = const ColorFilter.mode(Colors.black, BlendMode.color); @@ -43,6 +48,20 @@ class AlbumInfoCard extends HookConsumerWidget { ).tr(), backgroundColor: context.primaryColor, ); + } else if (isExcluded) { + return Chip( + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + label: Text( + "album_info_card_backup_album_excluded", + style: TextStyle( + fontSize: 10, + color: isDarkTheme ? Colors.black : Colors.white, + fontWeight: FontWeight.bold, + ), + ).tr(), + backgroundColor: Colors.red[300], + ); } return const SizedBox(); @@ -51,6 +70,8 @@ class AlbumInfoCard extends HookConsumerWidget { buildImageFilter() { if (isSelected) { return selectedFilter; + } else if (isExcluded) { + return excludedFilter; } else { return unselectedFilter; } @@ -66,6 +87,28 @@ class AlbumInfoCard extends HookConsumerWidget { ref.read(backupProvider.notifier).addAlbumForBackup(album); } }, + onDoubleTap: () { + ref.read(hapticFeedbackProvider.notifier).selectionClick(); + + if (isExcluded) { + // Remove from exclude album list + ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album); + } else { + // Add to exclude album list + + if (album.id == 'isAll' || album.name == 'Recents') { + ImmichToast.show( + context: context, + msg: 'Cannot exclude album contains all assets', + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album); + } + }, child: Card( clipBehavior: Clip.hardEdge, margin: const EdgeInsets.all(1), diff --git a/mobile/lib/widgets/backup/album_info_list_tile.dart b/mobile/lib/widgets/backup/album_info_list_tile.dart index 69415ee126c50..2e10fe0b75874 100644 --- a/mobile/lib/widgets/backup/album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/album_info_list_tile.dart @@ -1,12 +1,14 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +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/backup/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumInfoListTile extends HookConsumerWidget { final AvailableAlbum album; @@ -17,6 +19,8 @@ class AlbumInfoListTile extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final bool isSelected = ref.watch(backupProvider).selectedBackupAlbums.contains(album); + final bool isExcluded = + ref.watch(backupProvider).excludedBackupAlbums.contains(album); var assetCount = useState(0); useEffect( @@ -32,6 +36,10 @@ class AlbumInfoListTile extends HookConsumerWidget { return context.isDarkTheme ? context.primaryColor.withAlpha(100) : context.primaryColor.withAlpha(25); + } else if (isExcluded) { + return context.isDarkTheme + ? Colors.red[300]?.withAlpha(150) + : Colors.red[100]?.withAlpha(150); } else { return Colors.transparent; } @@ -45,6 +53,13 @@ class AlbumInfoListTile extends HookConsumerWidget { ); } + if (isExcluded) { + return const Icon( + Icons.remove_circle_rounded, + color: Colors.red, + ); + } + return Icon( Icons.circle, color: context.isDarkTheme ? Colors.grey[400] : Colors.black45, @@ -52,6 +67,28 @@ class AlbumInfoListTile extends HookConsumerWidget { } return GestureDetector( + onDoubleTap: () { + ref.watch(hapticFeedbackProvider.notifier).selectionClick(); + + if (isExcluded) { + // Remove from exclude album list + ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album); + } else { + // Add to exclude album list + + if (album.id == 'isAll' || album.name == 'Recents') { + ImmichToast.show( + context: context, + msg: 'Cannot exclude album contains all assets', + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album); + } + }, child: ListTile( tileColor: buildTileColor(), contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),