feat: album exclusion filter in free up space

This commit is contained in:
Alex
2026-01-22 14:56:28 -06:00
parent dd72c32c60
commit a644cc0a3c
6 changed files with 375 additions and 0 deletions

View File

@@ -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.",

View File

@@ -135,6 +135,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
Set<String> 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) {

View File

@@ -11,6 +11,7 @@ class CleanupState {
final bool isDeleting;
final AssetFilterType filterType;
final bool keepFavorites;
final Set<String> 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<String>? 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<CleanupState> {
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
}
void toggleExcludedAlbum(String albumId) {
final newExcludedAlbumIds = Set<String>.from(state.excludedAlbumIds);
if (newExcludedAlbumIds.contains(albumId)) {
newExcludedAlbumIds.remove(albumId);
} else {
newExcludedAlbumIds.add(albumId);
}
state = state.copyWith(excludedAlbumIds: newExcludedAlbumIds, assetsToDelete: []);
}
void setExcludedAlbumIds(Set<String> albumIds) {
state = state.copyWith(excludedAlbumIds: albumIds, assetsToDelete: []);
}
Future<void> scanAssets() async {
if (_userId == null || state.selectedDate == null) {
return;
@@ -74,6 +92,7 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
state.selectedDate!,
filterType: state.filterType,
keepFavorites: state.keepFavorites,
excludedAlbumIds: state.excludedAlbumIds,
);
state = state.copyWith(assetsToDelete: assets, isScanning: false);
} catch (e) {

View File

@@ -20,12 +20,14 @@ class CleanupService {
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
Set<String> excludedAlbumIds = const {},
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
filterType: filterType,
keepFavorites: keepFavorites,
excludedAlbumIds: excludedAlbumIds,
);
}

View File

@@ -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<FreeUpSpaceSettings> {
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<FreeUpSpaceSettings> {
},
),
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<String> excludedAlbumIds;
final ValueChanged<String> 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,
);
}
}

View File

@@ -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');
});
});
}