diff --git a/i18n/en.json b/i18n/en.json index 11eae2dc63..d70cd26f41 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -512,6 +512,7 @@ "all": "All", "all_albums": "All albums", "all_people": "All people", + "all_photos": "All photos", "all_videos": "All videos", "allow_dark_mode": "Allow dark mode", "allow_edits": "Allow edits", @@ -751,12 +752,13 @@ "cleanup_confirm_prompt_title": "Remove from this device?", "cleanup_deleted_assets": "Moved {count} assets to device trash", "cleanup_deleting": "Moving to trash...", - "cleanup_filter_description": "Choose which types of assets to remove in the cleanup", + "cleanup_filter_description": "Keep an extra copy of selected items on this device by excluding them from Free Up Space", "cleanup_found_assets": "Found {count} backed up assets", "cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan", + "cleanup_keep_all_media_type": "Always keep", "cleanup_no_assets_found": "No backed up assets found matching your criteria", "cleanup_preview_title": "Assets to remove ({count})", - "cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options", + "cleanup_step3_description": "Scan for backed up assets matching your date and keep settings", "cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device", "cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash", "clear": "Clear", @@ -1139,9 +1141,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", + "exclude_albums": "Keep albums", + "exclude_albums_description": "Assets in selected albums will remain on your device", + "excluded_albums_count": "Keeping {count} {count, plural, one {album} other {albums}}", "exclusion_pattern": "Exclusion pattern", "exif": "Exif", "exif_bottom_sheet_description": "Add Description...", @@ -1193,7 +1195,7 @@ "filetype": "Filetype", "filter": "Filter", "filter_description": "Conditions to filter the target assets", - "filter_options": "Filter options", + "filter_options": "Keep on device", "filter_people": "Filter people", "filter_places": "Filter places", "filters": "Filters", @@ -1326,8 +1328,11 @@ "keep": "Keep", "keep_all": "Keep All", "keep_favorites": "Keep favorites", - "keep_favorites_description": "Favorite assets will not be deleted from your device", + "keep_favorites_description": "Favorite assets will remain on your device", + "keep_on_device": "Keep on device", + "keep_on_device_hint": "Select items to keep an extra copy on this device", "keep_this_delete_others": "Keep this, delete others", + "keeping": "Keeping: {items}", "kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "Keyboard shortcuts", "language": "Language", @@ -1587,6 +1592,7 @@ "no_results_description": "Try a synonym or more general keyword", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", "no_uploads_in_progress": "No uploads in progress", + "none": "None", "not_allowed": "Not allowed", "not_available": "N/A", "not_in_any_album": "Not in any album", diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index c4505137d2..26c223afad 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -8,6 +8,6 @@ enum SortUserBy { id } enum ActionSource { timeline, viewer } -enum CleanupStep { selectDate, filterOptions, scan, delete } +enum CleanupStep { selectDate, scan, delete } -enum AssetFilterType { all, photosOnly, videosOnly } +enum AssetKeepType { none, photosOnly, videosOnly } diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index a18644cd2a..fb5479a548 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -82,7 +82,13 @@ enum StoreKey { useWifiForUploadPhotos._(1005), needBetaMigration._(1006), // TODO: Remove this after patching open-api - shouldResetSync._(1007); + shouldResetSync._(1007), + + // Free up space + cleanupKeepFavorites._(1008), + cleanupKeepMediaType._(1009), + cleanupExcludedAlbumIds._(1010), + cleanupCutoffDaysAgo._(1011); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 60685bf9b4..a4c98dae48 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -133,7 +133,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { Future> getRemovalCandidates( String userId, DateTime cutoffDate, { - AssetFilterType filterType = AssetFilterType.all, + AssetKeepType keepMediaType = AssetKeepType.none, bool keepFavorites = true, Set excludedAlbumIds = const {}, }) async { @@ -167,10 +167,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(excludedAlbumAssets); } - if (filterType == AssetFilterType.photosOnly) { - whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image); - } else if (filterType == AssetFilterType.videosOnly) { + // keepMediaType specifies what to KEEP, so we filter to DELETE the opposite + if (keepMediaType == AssetKeepType.photosOnly) { + // Keep photos = delete only videos whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video); + } else if (keepMediaType == AssetKeepType.videosOnly) { + // Keep videos = delete only photos + whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image); } if (keepFavorites) { diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart index 59ffc719d5..d0d13715f7 100644 --- a/mobile/lib/providers/cleanup.provider.dart +++ b/mobile/lib/providers/cleanup.provider.dart @@ -1,7 +1,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; class CleanupState { @@ -9,7 +11,7 @@ class CleanupState { final List assetsToDelete; final bool isScanning; final bool isDeleting; - final AssetFilterType filterType; + final AssetKeepType keepMediaType; final bool keepFavorites; final Set excludedAlbumIds; @@ -18,7 +20,7 @@ class CleanupState { this.assetsToDelete = const [], this.isScanning = false, this.isDeleting = false, - this.filterType = AssetFilterType.all, + this.keepMediaType = AssetKeepType.none, this.keepFavorites = true, this.excludedAlbumIds = const {}, }); @@ -28,7 +30,7 @@ class CleanupState { List? assetsToDelete, bool? isScanning, bool? isDeleting, - AssetFilterType? filterType, + AssetKeepType? keepMediaType, bool? keepFavorites, Set? excludedAlbumIds, }) { @@ -37,7 +39,7 @@ class CleanupState { assetsToDelete: assetsToDelete ?? this.assetsToDelete, isScanning: isScanning ?? this.isScanning, isDeleting: isDeleting ?? this.isDeleting, - filterType: filterType ?? this.filterType, + keepMediaType: keepMediaType ?? this.keepMediaType, keepFavorites: keepFavorites ?? this.keepFavorites, excludedAlbumIds: excludedAlbumIds ?? this.excludedAlbumIds, ); @@ -45,25 +47,56 @@ class CleanupState { } final cleanupProvider = StateNotifierProvider((ref) { - return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id); + return CleanupNotifier( + ref.watch(cleanupServiceProvider), + ref.watch(currentUserProvider)?.id, + ref.watch(appSettingsServiceProvider), + ); }); class CleanupNotifier extends StateNotifier { final CleanupService _cleanupService; final String? _userId; + final AppSettingsService _appSettingsService; - CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState()); + CleanupNotifier(this._cleanupService, this._userId, this._appSettingsService) : super(const CleanupState()) { + _loadPersistedSettings(); + } + + void _loadPersistedSettings() { + final keepFavorites = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepFavorites); + final keepMediaTypeIndex = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepMediaType); + final excludedAlbumIdsString = _appSettingsService.getSetting(AppSettingsEnum.cleanupExcludedAlbumIds); + final cutoffDaysAgo = _appSettingsService.getSetting(AppSettingsEnum.cleanupCutoffDaysAgo); + + final keepMediaType = AssetKeepType.values[keepMediaTypeIndex.clamp(0, AssetKeepType.values.length - 1)]; + final excludedAlbumIds = excludedAlbumIdsString.isEmpty ? {} : excludedAlbumIdsString.split(',').toSet(); + final selectedDate = cutoffDaysAgo > 0 ? DateTime.now().subtract(Duration(days: cutoffDaysAgo)) : null; + + state = state.copyWith( + keepFavorites: keepFavorites, + keepMediaType: keepMediaType, + excludedAlbumIds: excludedAlbumIds, + selectedDate: selectedDate, + ); + } void setSelectedDate(DateTime? date) { state = state.copyWith(selectedDate: date, assetsToDelete: []); + if (date != null) { + final daysAgo = DateTime.now().difference(date).inDays; + _appSettingsService.setSetting(AppSettingsEnum.cleanupCutoffDaysAgo, daysAgo); + } } - void setFilterType(AssetFilterType filterType) { - state = state.copyWith(filterType: filterType, assetsToDelete: []); + void setKeepMediaType(AssetKeepType keepMediaType) { + state = state.copyWith(keepMediaType: keepMediaType, assetsToDelete: []); + _appSettingsService.setSetting(AppSettingsEnum.cleanupKeepMediaType, keepMediaType.index); } void setKeepFavorites(bool keepFavorites) { state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []); + _appSettingsService.setSetting(AppSettingsEnum.cleanupKeepFavorites, keepFavorites); } void toggleExcludedAlbum(String albumId) { @@ -74,10 +107,16 @@ class CleanupNotifier extends StateNotifier { newExcludedAlbumIds.add(albumId); } state = state.copyWith(excludedAlbumIds: newExcludedAlbumIds, assetsToDelete: []); + _persistExcludedAlbumIds(newExcludedAlbumIds); } void setExcludedAlbumIds(Set albumIds) { state = state.copyWith(excludedAlbumIds: albumIds, assetsToDelete: []); + _persistExcludedAlbumIds(albumIds); + } + + void _persistExcludedAlbumIds(Set albumIds) { + _appSettingsService.setSetting(AppSettingsEnum.cleanupExcludedAlbumIds, albumIds.join(',')); } Future scanAssets() async { @@ -90,7 +129,7 @@ class CleanupNotifier extends StateNotifier { final assets = await _cleanupService.getRemovalCandidates( _userId, state.selectedDate!, - filterType: state.filterType, + keepMediaType: state.keepMediaType, keepFavorites: state.keepFavorites, excludedAlbumIds: state.excludedAlbumIds, ); @@ -120,6 +159,7 @@ class CleanupNotifier extends StateNotifier { } void reset() { - state = const CleanupState(); + // Only reset transient state, keep the persisted filter settings + state = state.copyWith(selectedDate: null, assetsToDelete: [], isScanning: false, isDeleting: false); } } diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index aa247682a7..f4ee3b36f4 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -54,7 +54,11 @@ enum AppSettingsEnum { readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false), albumGridView(StoreKey.albumGridView, "albumGridView", false), backupRequireCharging(StoreKey.backupRequireCharging, null, false), - backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30); + backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30), + cleanupKeepFavorites(StoreKey.cleanupKeepFavorites, null, true), + cleanupKeepMediaType(StoreKey.cleanupKeepMediaType, null, 0), + cleanupExcludedAlbumIds(StoreKey.cleanupExcludedAlbumIds, null, ""), + cleanupCutoffDaysAgo(StoreKey.cleanupCutoffDaysAgo, null, 60); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart index 9c66ff9832..792e04228d 100644 --- a/mobile/lib/services/cleanup.service.dart +++ b/mobile/lib/services/cleanup.service.dart @@ -18,14 +18,14 @@ class CleanupService { Future> getRemovalCandidates( String userId, DateTime cutoffDate, { - AssetFilterType filterType = AssetFilterType.all, + AssetKeepType keepMediaType = AssetKeepType.none, bool keepFavorites = true, Set excludedAlbumIds = const {}, }) { return _localAssetRepository.getRemovalCandidates( userId, cutoffDate, - filterType: filterType, + keepMediaType: keepMediaType, 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 8b1b4e809f..7a77eae551 100644 --- a/mobile/lib/widgets/settings/free_up_space_settings.dart +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -23,6 +23,7 @@ class FreeUpSpaceSettings extends ConsumerStatefulWidget { class _FreeUpSpaceSettingsState extends ConsumerState { CleanupStep _currentStep = CleanupStep.selectDate; bool _hasScanned = false; + bool _isKeepSettingsExpanded = false; void _resetState() { ref.read(cleanupProvider.notifier).reset(); @@ -37,17 +38,12 @@ class _FreeUpSpaceSettingsState extends ConsumerState { } if (state.selectedDate != null) { - return CleanupStep.filterOptions; + return CleanupStep.scan; } return CleanupStep.selectDate; } - void _goToFiltersStep() { - ref.read(hapticFeedbackProvider.notifier).mediumImpact(); - setState(() => _currentStep = CleanupStep.filterOptions); - } - void _goToScanStep() { ref.read(hapticFeedbackProvider.notifier).mediumImpact(); setState(() => _currentStep = CleanupStep.scan); @@ -88,6 +84,13 @@ class _FreeUpSpaceSettingsState extends ConsumerState { } } + void _onKeepSettingsChanged() { + setState(() { + _hasScanned = false; + _currentStep = CleanupStep.scan; + }); + } + Future _scanAssets() async { ref.read(hapticFeedbackProvider.notifier).mediumImpact(); @@ -176,33 +179,40 @@ class _FreeUpSpaceSettingsState extends ConsumerState { } final step1State = hasDate ? StepState.complete : StepState.indexed; - final step2State = hasDate ? StepState.complete : StepState.disabled; - final step3State = hasAssets + final step2State = hasAssets ? StepState.complete : hasDate ? StepState.indexed : StepState.disabled; - final step4State = hasAssets ? StepState.indexed : StepState.disabled; + final step3State = hasAssets ? StepState.indexed : StepState.disabled; - String getFilterSubtitle() { + final hasKeepSettings = + state.keepFavorites || state.excludedAlbumIds.isNotEmpty || state.keepMediaType != AssetKeepType.none; + + String getKeepSettingsSummary() { final parts = []; - switch (state.filterType) { - case AssetFilterType.all: - parts.add('all'.t(context: context)); - case AssetFilterType.photosOnly: - parts.add('photos_only'.t(context: context)); - case AssetFilterType.videosOnly: - parts.add('videos_only'.t(context: context)); + + if (state.keepMediaType == AssetKeepType.photosOnly) { + parts.add('all_photos'.t(context: context)); + } else if (state.keepMediaType == AssetKeepType.videosOnly) { + parts.add('all_videos'.t(context: context)); } + if (state.keepFavorites) { - parts.add('keep_favorites'.t(context: context)); + parts.add('favorites'.t(context: context).toLowerCase()); } + if (state.excludedAlbumIds.isNotEmpty) { parts.add( 'excluded_albums_count'.t(context: context, args: {'count': state.excludedAlbumIds.length.toString()}), ); } - return parts.join(' • '); + + if (parts.isEmpty) { + return 'none'.t(context: context); + } + + return parts.join(', '); } return PopScope( @@ -227,6 +237,119 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ), ), + // Keep on device settings card + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(12)), + side: BorderSide( + color: hasKeepSettings + ? context.colorScheme.primary.withValues(alpha: 0.5) + : context.colorScheme.outlineVariant, + width: hasKeepSettings ? 1.5 : 1, + ), + ), + color: hasKeepSettings + ? context.colorScheme.primaryContainer.withValues(alpha: 0.15) + : context.colorScheme.surfaceContainerLow, + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + initiallyExpanded: _isKeepSettingsExpanded, + onExpansionChanged: (expanded) { + setState(() => _isKeepSettingsExpanded = expanded); + }, + leading: Icon( + hasKeepSettings ? Icons.bookmark : Icons.bookmark_border, + color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant, + ), + title: Text( + 'keep_on_device'.t(context: context), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: hasKeepSettings ? context.colorScheme.primary : null, + ), + ), + subtitle: Text( + hasKeepSettings + ? 'keeping'.t(context: context, args: {'items': getKeepSettingsSummary()}) + : 'keep_on_device_hint'.t(context: context), + style: context.textTheme.bodySmall?.copyWith( + color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant, + ), + ), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('cleanup_filter_description'.t(context: context), style: subtitleStyle), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + 'keep_favorites'.t(context: context), + style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), + ), + subtitle: Text( + 'keep_favorites_description'.t(context: context), + style: context.textTheme.bodyMedium!.copyWith( + color: context.textTheme.bodyMedium!.color!.withAlpha(215), + ), + ), + value: state.keepFavorites, + onChanged: (value) { + ref.read(cleanupProvider.notifier).setKeepFavorites(value); + _onKeepSettingsChanged(); + }, + ), + const SizedBox(height: 8), + _ExcludedAlbumsSection( + excludedAlbumIds: state.excludedAlbumIds, + onAlbumToggled: (albumId) { + ref.read(cleanupProvider.notifier).toggleExcludedAlbum(albumId); + _onKeepSettingsChanged(); + }, + ), + const SizedBox(height: 16), + Text( + 'cleanup_keep_all_media_type'.t(context: context), + style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), + ), + const SizedBox(height: 4), + SegmentedButton( + segments: [ + const ButtonSegment(value: AssetKeepType.none, label: Text('—')), + ButtonSegment( + value: AssetKeepType.photosOnly, + label: Text('photos'.t(context: context)), + icon: const Icon(Icons.photo), + ), + ButtonSegment( + value: AssetKeepType.videosOnly, + label: Text('videos'.t(context: context)), + icon: const Icon(Icons.videocam), + ), + ], + selected: {state.keepMediaType}, + onSelectionChanged: (selection) { + ref.read(cleanupProvider.notifier).setKeepMediaType(selection.first); + _onKeepSettingsChanged(); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 8), + Stepper( physics: const NeverScrollableScrollPhysics(), currentStep: _currentStep.index, @@ -321,7 +444,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ), const SizedBox(height: 16), ElevatedButton.icon( - onPressed: hasDate ? () => _goToFiltersStep() : null, + onPressed: hasDate ? _goToScanStep : null, icon: const Icon(Icons.arrow_forward), label: Text('continue'.t(context: context)), style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), @@ -332,11 +455,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState { state: step1State, ), - // Step 2: Select Filter Options + // Step 2: Scan Assets Step( stepStyle: styleForState(step2State), title: Text( - 'filter_options'.t(context: context), + 'scan'.t(context: context), style: context.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: step2State == StepState.complete @@ -346,98 +469,6 @@ class _FreeUpSpaceSettingsState extends ConsumerState { : context.colorScheme.onSurface, ), ), - subtitle: hasDate - ? Text( - getFilterSubtitle(), - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ) - : null, - content: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('cleanup_filter_description'.t(context: context), style: subtitleStyle), - const SizedBox(height: 16), - SegmentedButton( - segments: [ - ButtonSegment( - value: AssetFilterType.all, - label: Text('all'.t(context: context)), - icon: const Icon(Icons.photo_library), - ), - ButtonSegment( - value: AssetFilterType.photosOnly, - label: Text('photos'.t(context: context)), - icon: const Icon(Icons.photo), - ), - ButtonSegment( - value: AssetFilterType.videosOnly, - label: Text('videos'.t(context: context)), - icon: const Icon(Icons.videocam), - ), - ], - selected: {state.filterType}, - onSelectionChanged: (selection) { - ref.read(cleanupProvider.notifier).setFilterType(selection.first); - setState(() => _hasScanned = false); - }, - ), - const SizedBox(height: 16), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text( - 'keep_favorites'.t(context: context), - style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), - ), - subtitle: Text( - 'keep_favorites_description'.t(context: context), - style: context.textTheme.bodyMedium!.copyWith( - color: context.textTheme.bodyMedium!.color!.withAlpha(215), - ), - ), - value: state.keepFavorites, - onChanged: (value) { - ref.read(cleanupProvider.notifier).setKeepFavorites(value); - setState(() => _hasScanned = false); - }, - ), - 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), - label: Text('continue'.t(context: context)), - style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), - ), - ], - ), - isActive: hasDate, - state: step2State, - ), - - // Step 3: Scan Assets - Step( - stepStyle: styleForState(step3State), - title: Text( - 'scan'.t(context: context), - style: context.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: step3State == StepState.complete - ? context.colorScheme.primary - : step3State == StepState.disabled - ? context.colorScheme.onSurface.withValues(alpha: 0.38) - : context.colorScheme.onSurface, - ), - ), subtitle: _hasScanned ? Text( 'cleanup_found_assets'.t( @@ -518,17 +549,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ], ), isActive: hasDate, - state: step3State, + state: step2State, ), - // Step 4: Delete Assets + // Step 3: Delete Assets Step( - stepStyle: styleForState(step4State, isDestructive: true), + stepStyle: styleForState(step3State, isDestructive: true), title: Text( 'move_to_device_trash'.t(context: context), style: context.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, - color: step4State == StepState.disabled + color: step3State == StepState.disabled ? context.colorScheme.onSurface.withValues(alpha: 0.38) : context.colorScheme.error, ), @@ -588,7 +619,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ], ), isActive: hasAssets, - state: step4State, + state: step3State, ), ], ), @@ -786,7 +817,10 @@ class _ExcludedAlbumsSection extends ConsumerWidget { 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), + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, + ), ), ], ], @@ -807,13 +841,13 @@ class _AlbumExclusionTile extends StatelessWidget { 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, + isExcluded ? Icons.check_circle : Icons.circle_outlined, + color: isExcluded ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant, size: 20, ), title: Text( album.name, - style: context.textTheme.bodyMedium?.copyWith(color: isExcluded ? context.colorScheme.error : null), + style: context.textTheme.bodyMedium?.copyWith(color: isExcluded ? context.colorScheme.primary : null), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart index 53b4b766fd..60300f98be 100644 --- a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart +++ b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart @@ -190,8 +190,8 @@ void main() { expect(candidates[0].isFavorite, true); }); - test('filters by photos only', () async { - // Photo + test('keepMediaType photosOnly returns only videos for deletion', () async { + // Photo - should be kept await insertLocalAsset( id: 'local-photo', checksum: 'checksum-photo', @@ -201,7 +201,7 @@ void main() { ); await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId); - // Video + // Video - should be deleted await insertLocalAsset( id: 'local-video', checksum: 'checksum-video', @@ -214,39 +214,7 @@ void main() { final candidates = await repository.getRemovalCandidates( userId, cutoffDate, - filterType: AssetFilterType.photosOnly, - ); - - expect(candidates.length, 1); - expect(candidates[0].id, 'local-photo'); - expect(candidates[0].type, AssetType.image); - }); - - test('filters by videos only', () async { - // Photo - await insertLocalAsset( - id: 'local-photo', - checksum: 'checksum-photo', - createdAt: beforeCutoff, - type: AssetType.image, - isFavorite: false, - ); - await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId); - - // Video - 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); - - final candidates = await repository.getRemovalCandidates( - userId, - cutoffDate, - filterType: AssetFilterType.videosOnly, + keepMediaType: AssetKeepType.photosOnly, ); expect(candidates.length, 1); @@ -254,7 +222,39 @@ void main() { expect(candidates[0].type, AssetType.video); }); - test('returns both photos and videos with filterType.all', () async { + test('keepMediaType videosOnly returns only photos for deletion', () async { + // Photo - should be deleted + await insertLocalAsset( + id: 'local-photo', + checksum: 'checksum-photo', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId); + + // Video - should be kept + 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); + + final candidates = await repository.getRemovalCandidates( + userId, + cutoffDate, + keepMediaType: AssetKeepType.videosOnly, + ); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-photo'); + expect(candidates[0].type, AssetType.image); + }); + + test('returns both photos and videos with keepMediaType.all', () async { // Photo await insertLocalAsset( id: 'local-photo', @@ -275,7 +275,7 @@ void main() { ); await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId); - final candidates = await repository.getRemovalCandidates(userId, cutoffDate, filterType: AssetFilterType.all); + final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.none); expect(candidates.length, 2); final ids = candidates.map((a) => a.id).toSet(); @@ -604,11 +604,11 @@ void main() { expect(candidates[0].id, 'local-no-album'); }); - test('combines excludedAlbumIds with other filters correctly', () async { + test('combines excludedAlbumIds with keepMediaType 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 + // Photo in excluded album - should NOT be included (album excluded) await insertLocalAsset( id: 'local-photo-excluded', checksum: 'checksum-photo-excluded', @@ -619,7 +619,7 @@ void main() { 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) + // Video in regular album - should be included (keepMediaType photosOnly = delete videos) await insertLocalAsset( id: 'local-video', checksum: 'checksum-video', @@ -630,7 +630,7 @@ void main() { 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 + // Photo in regular album - should NOT be included (keepMediaType photosOnly = keep photos) await insertLocalAsset( id: 'local-photo-regular', checksum: 'checksum-photo-regular', @@ -644,12 +644,12 @@ void main() { final candidates = await repository.getRemovalCandidates( userId, cutoffDate, - filterType: AssetFilterType.photosOnly, + keepMediaType: AssetKeepType.photosOnly, excludedAlbumIds: {'album-excluded'}, ); expect(candidates.length, 1); - expect(candidates[0].id, 'local-photo-regular'); + expect(candidates[0].id, 'local-video'); }); }); }