From a644cc0a3c52f6b4e362b23e524ddefbf55e1acb Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 22 Jan 2026 14:56:28 -0600 Subject: [PATCH] feat: album exclusion filter in free up space --- i18n/en.json | 5 + .../repositories/local_asset.repository.dart | 8 + mobile/lib/providers/cleanup.provider.dart | 19 ++ mobile/lib/services/cleanup.service.dart | 2 + .../settings/free_up_space_settings.dart | 124 ++++++++++ .../local_asset_repository_test.dart | 217 ++++++++++++++++++ 6 files changed, 375 insertions(+) diff --git a/i18n/en.json b/i18n/en.json index a2da7b783b..11eae2dc63 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1007,6 +1007,7 @@ "error_change_sort_album": "Failed to change album sort order", "error_delete_face": "Error deleting face from asset", "error_getting_places": "Error getting places", + "error_loading_albums": "Error loading albums", "error_loading_image": "Error loading image", "error_loading_partners": "Error loading partners: {error}", "error_retrieving_asset_information": "Error retrieving asset information", @@ -1138,6 +1139,9 @@ "unable_to_upload_file": "Unable to upload file" }, "errors_text": "Errors", + "exclude_albums": "Exclude albums", + "exclude_albums_description": "Assets in selected albums will not be removed from your device", + "excluded_albums_count": "{count} albums excluded", "exclusion_pattern": "Exclusion pattern", "exif": "Exif", "exif_bottom_sheet_description": "Add Description...", @@ -1553,6 +1557,7 @@ "next_memory": "Next memory", "no": "No", "no_actions_added": "No actions added yet", + "no_albums_found": "No albums found", "no_albums_message": "Create an album to organize your photos and videos", "no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.", "no_albums_yet": "It looks like you do not have any albums yet.", diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 6a9181e604..60685bf9b4 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -135,6 +135,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { DateTime cutoffDate, { AssetFilterType filterType = AssetFilterType.all, bool keepFavorites = true, + Set excludedAlbumIds = const {}, }) async { final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly() ..addColumns([_db.localAlbumAssetEntity.assetId]) @@ -159,6 +160,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { // Exclude assets that are in iOS shared albums whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets); + if (excludedAlbumIds.isNotEmpty) { + final excludedAlbumAssets = _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..where(_db.localAlbumAssetEntity.albumId.isIn(excludedAlbumIds)); + whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(excludedAlbumAssets); + } + if (filterType == AssetFilterType.photosOnly) { whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image); } else if (filterType == AssetFilterType.videosOnly) { diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart index 5b3b152f34..59ffc719d5 100644 --- a/mobile/lib/providers/cleanup.provider.dart +++ b/mobile/lib/providers/cleanup.provider.dart @@ -11,6 +11,7 @@ class CleanupState { final bool isDeleting; final AssetFilterType filterType; final bool keepFavorites; + final Set excludedAlbumIds; const CleanupState({ this.selectedDate, @@ -19,6 +20,7 @@ class CleanupState { this.isDeleting = false, this.filterType = AssetFilterType.all, this.keepFavorites = true, + this.excludedAlbumIds = const {}, }); CleanupState copyWith({ @@ -28,6 +30,7 @@ class CleanupState { bool? isDeleting, AssetFilterType? filterType, bool? keepFavorites, + Set? excludedAlbumIds, }) { return CleanupState( selectedDate: selectedDate ?? this.selectedDate, @@ -36,6 +39,7 @@ class CleanupState { isDeleting: isDeleting ?? this.isDeleting, filterType: filterType ?? this.filterType, keepFavorites: keepFavorites ?? this.keepFavorites, + excludedAlbumIds: excludedAlbumIds ?? this.excludedAlbumIds, ); } } @@ -62,6 +66,20 @@ class CleanupNotifier extends StateNotifier { state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []); } + void toggleExcludedAlbum(String albumId) { + final newExcludedAlbumIds = Set.from(state.excludedAlbumIds); + if (newExcludedAlbumIds.contains(albumId)) { + newExcludedAlbumIds.remove(albumId); + } else { + newExcludedAlbumIds.add(albumId); + } + state = state.copyWith(excludedAlbumIds: newExcludedAlbumIds, assetsToDelete: []); + } + + void setExcludedAlbumIds(Set albumIds) { + state = state.copyWith(excludedAlbumIds: albumIds, assetsToDelete: []); + } + Future scanAssets() async { if (_userId == null || state.selectedDate == null) { return; @@ -74,6 +92,7 @@ class CleanupNotifier extends StateNotifier { state.selectedDate!, filterType: state.filterType, keepFavorites: state.keepFavorites, + excludedAlbumIds: state.excludedAlbumIds, ); state = state.copyWith(assetsToDelete: assets, isScanning: false); } catch (e) { diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart index 6a4318d209..9c66ff9832 100644 --- a/mobile/lib/services/cleanup.service.dart +++ b/mobile/lib/services/cleanup.service.dart @@ -20,12 +20,14 @@ class CleanupService { DateTime cutoffDate, { AssetFilterType filterType = AssetFilterType.all, bool keepFavorites = true, + Set excludedAlbumIds = const {}, }) { return _localAssetRepository.getRemovalCandidates( userId, cutoffDate, filterType: filterType, keepFavorites: keepFavorites, + excludedAlbumIds: excludedAlbumIds, ); } diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart index e24a4d481a..8b1b4e809f 100644 --- a/mobile/lib/widgets/settings/free_up_space_settings.dart +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -3,12 +3,14 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/cleanup.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class FreeUpSpaceSettings extends ConsumerStatefulWidget { @@ -195,6 +197,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState { if (state.keepFavorites) { parts.add('keep_favorites'.t(context: context)); } + if (state.excludedAlbumIds.isNotEmpty) { + parts.add( + 'excluded_albums_count'.t(context: context, args: {'count': state.excludedAlbumIds.length.toString()}), + ); + } return parts.join(' • '); } @@ -397,6 +404,14 @@ class _FreeUpSpaceSettingsState extends ConsumerState { }, ), const SizedBox(height: 16), + _ExcludedAlbumsSection( + excludedAlbumIds: state.excludedAlbumIds, + onAlbumToggled: (albumId) { + ref.read(cleanupProvider.notifier).toggleExcludedAlbum(albumId); + setState(() => _hasScanned = false); + }, + ), + const SizedBox(height: 16), ElevatedButton.icon( onPressed: _goToScanStep, icon: const Icon(Icons.arrow_forward), @@ -701,3 +716,112 @@ class _DatePresetCard extends StatelessWidget { ); } } + +class _ExcludedAlbumsSection extends ConsumerWidget { + final Set excludedAlbumIds; + final ValueChanged onAlbumToggled; + + const _ExcludedAlbumsSection({required this.excludedAlbumIds, required this.onAlbumToggled}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albumsAsync = ref.watch(localAlbumProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'exclude_albums'.t(context: context), + style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), + ), + const SizedBox(height: 4), + Text( + 'exclude_albums_description'.t(context: context), + style: context.textTheme.bodyMedium!.copyWith(color: context.textTheme.bodyMedium!.color!.withAlpha(215)), + ), + const SizedBox(height: 12), + albumsAsync.when( + loading: () => const Center( + child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (error, stack) => Text( + 'error_loading_albums'.t(context: context), + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), + ), + data: (albums) { + if (albums.isEmpty) { + return Text( + 'no_albums_found'.t(context: context), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ); + } + return Container( + decoration: BoxDecoration( + border: Border.all(color: context.colorScheme.outlineVariant), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + constraints: const BoxConstraints(maxHeight: 200), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: ListView.builder( + shrinkWrap: true, + itemCount: albums.length, + itemBuilder: (context, index) { + final album = albums[index]; + final isExcluded = excludedAlbumIds.contains(album.id); + return _AlbumExclusionTile( + album: album, + isExcluded: isExcluded, + onToggle: () => onAlbumToggled(album.id), + ); + }, + ), + ), + ); + }, + ), + if (excludedAlbumIds.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'excluded_albums_count'.t(context: context, args: {'count': excludedAlbumIds.length.toString()}), + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error, fontWeight: FontWeight.w500), + ), + ], + ], + ); + } +} + +class _AlbumExclusionTile extends StatelessWidget { + final LocalAlbum album; + final bool isExcluded; + final VoidCallback onToggle; + + const _AlbumExclusionTile({required this.album, required this.isExcluded, required this.onToggle}); + + @override + Widget build(BuildContext context) { + return ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), + leading: Icon( + isExcluded ? Icons.remove_circle : Icons.photo_album_outlined, + color: isExcluded ? context.colorScheme.error : context.colorScheme.onSurfaceVariant, + size: 20, + ), + title: Text( + album.name, + style: context.textTheme.bodyMedium?.copyWith(color: isExcluded ? context.colorScheme.error : null), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Text( + album.assetCount.toString(), + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + onTap: onToggle, + ); + } +} diff --git a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart index 0d686fbc09..53b4b766fd 100644 --- a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart +++ b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart @@ -434,5 +434,222 @@ void main() { expect(candidates, isEmpty); }); + + test('excludes assets in user-excluded albums', () async { + // Create two regular albums + await insertLocalAlbum(id: 'album-include', name: 'Include Album', isIosSharedAlbum: false); + await insertLocalAlbum(id: 'album-exclude', name: 'Exclude Album', isIosSharedAlbum: false); + + // Asset in included album - should be included + await insertLocalAsset( + id: 'local-in-included', + checksum: 'checksum-included', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-included', checksum: 'checksum-included', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-include', assetId: 'local-in-included'); + + // Asset in excluded album - should NOT be included + await insertLocalAsset( + id: 'local-in-excluded', + checksum: 'checksum-excluded', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-excluded', checksum: 'checksum-excluded', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-exclude', assetId: 'local-in-excluded'); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate, excludedAlbumIds: {'album-exclude'}); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-in-included'); + }); + + test('excludes assets that are in any of multiple excluded albums', () async { + // Create multiple albums + await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false); + await insertLocalAlbum(id: 'album-2', name: 'Album 2', isIosSharedAlbum: false); + await insertLocalAlbum(id: 'album-3', name: 'Album 3', isIosSharedAlbum: false); + + // Asset in album-1 (excluded) - should NOT be included + await insertLocalAsset( + id: 'local-1', + checksum: 'checksum-1', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1'); + + // Asset in album-2 (excluded) - should NOT be included + await insertLocalAsset( + id: 'local-2', + checksum: 'checksum-2', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-2', assetId: 'local-2'); + + // Asset in album-3 (not excluded) - should be included + await insertLocalAsset( + id: 'local-3', + checksum: 'checksum-3', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3'); + + final candidates = await repository.getRemovalCandidates( + userId, + cutoffDate, + excludedAlbumIds: {'album-1', 'album-2'}, + ); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-3'); + }); + + test('excludes asset that is in both excluded and non-excluded album', () async { + await insertLocalAlbum(id: 'album-included', name: 'Included Album', isIosSharedAlbum: false); + await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false); + + // Asset in BOTH albums - should be excluded because it's in an excluded album + await insertLocalAsset( + id: 'local-both', + checksum: 'checksum-both', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-both', checksum: 'checksum-both', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-included', assetId: 'local-both'); + await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-both'); + + final candidates = await repository.getRemovalCandidates( + userId, + cutoffDate, + excludedAlbumIds: {'album-excluded'}, + ); + + expect(candidates, isEmpty); + }); + + test('includes all assets when excludedAlbumIds is empty', () async { + await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false); + + await insertLocalAsset( + id: 'local-1', + checksum: 'checksum-1', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1'); + + await insertLocalAsset( + id: 'local-2', + checksum: 'checksum-2', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId); + + // Empty excludedAlbumIds should include all eligible assets + final candidates = await repository.getRemovalCandidates(userId, cutoffDate, excludedAlbumIds: {}); + + expect(candidates.length, 2); + }); + + test('excludes asset not in any album when album is excluded', () async { + await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false); + + // Asset NOT in any album - should be included + await insertLocalAsset( + id: 'local-no-album', + checksum: 'checksum-no-album', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId); + + // Asset in excluded album - should NOT be included + await insertLocalAsset( + id: 'local-in-excluded', + checksum: 'checksum-in-excluded', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-in-excluded', checksum: 'checksum-in-excluded', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-in-excluded'); + + final candidates = await repository.getRemovalCandidates( + userId, + cutoffDate, + excludedAlbumIds: {'album-excluded'}, + ); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-no-album'); + }); + + test('combines excludedAlbumIds with other filters correctly', () async { + await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false); + await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false); + + // Photo in excluded album - should NOT be included + await insertLocalAsset( + id: 'local-photo-excluded', + checksum: 'checksum-photo-excluded', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-photo-excluded', checksum: 'checksum-photo-excluded', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-photo-excluded'); + + // Video in regular album - should NOT be included (filtering photos only) + await insertLocalAsset( + id: 'local-video', + checksum: 'checksum-video', + createdAt: beforeCutoff, + type: AssetType.video, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-video'); + + // Photo in regular album - should be included + await insertLocalAsset( + id: 'local-photo-regular', + checksum: 'checksum-photo-regular', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-photo-regular', checksum: 'checksum-photo-regular', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-photo-regular'); + + final candidates = await repository.getRemovalCandidates( + userId, + cutoffDate, + filterType: AssetFilterType.photosOnly, + excludedAlbumIds: {'album-excluded'}, + ); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-photo-regular'); + }); }); }