From f275eaffe1322285b7f42ddccb43f907f104065f Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 5 Jan 2026 14:16:57 -0600 Subject: [PATCH] feat: cutoff date preset options and filter options --- i18n/en.json | 21 +- .../repositories/local_asset.repository.dart | 24 +- mobile/lib/providers/cleanup.provider.dart | 25 +- mobile/lib/services/cleanup.service.dart | 15 +- .../settings/free_up_space_settings.dart | 681 ++++++++++++------ 5 files changed, 535 insertions(+), 231 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 0e690ee038..ffc8a3e572 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -737,13 +737,15 @@ "checksum": "Checksum", "choose_matching_people_to_merge": "Choose matching people to merge", "city": "City", - "cleanup_confirm_description": "You are about to move {count} assets to your device's trash. These assets were created before {date} and are safely backed up on the server. To reclaim storage space, empty your device's trash after this operation", + "cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?", + "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_found_assets": "Found {count} backed up assets", - "cleanup_no_assets_found": "No backed up assets found before this date", - "cleanup_step2_description": "Scan your device for assets that have been backed up to the server", - "cleanup_step3_summary": "{count} assets created before {date} will be moved to your device's trash", + "cleanup_no_assets_found": "No backed up assets found matching your criteria", + "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_step4_summary": "{count} assets created before {date} are queued for removal from your device", "clear": "Clear", "clear_all": "Clear all", "clear_all_recent_searches": "Clear all recent searches", @@ -833,9 +835,13 @@ "current_device": "Current device", "current_pin_code": "Current PIN code", "current_server_address": "Current server address", + "custom_date": "Custom date", "custom_locale": "Custom Locale", "custom_locale_description": "Format dates and numbers based on the language and the region", "custom_url": "Custom URL", + "cutoff_date_description": "Remove photos and videos older than", + "cutoff_day": "{count, plural, one {day} other {days}}", + "cutoff_year": "{count, plural, one {year} other {years}}", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "dark": "Dark", @@ -1157,6 +1163,7 @@ "filetype": "Filetype", "filter": "Filter", "filter_description": "Conditions to filter the target assets", + "filter_options": "Filter options", "filter_people": "Filter people", "filter_places": "Filter places", "filters": "Filters", @@ -1288,6 +1295,8 @@ "json_error": "JSON error", "keep": "Keep", "keep_all": "Keep All", + "keep_favorites": "Keep favorites", + "keep_favorites_description": "Favorite assets will not be deleted from your device", "keep_this_delete_others": "Keep this, delete others", "kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}", "keyboard_shortcuts": "Keyboard shortcuts", @@ -1458,10 +1467,10 @@ "move_down": "Move down", "move_off_locked_folder": "Move out of locked folder", "move_to": "Move to", + "move_to_device_trash": "Move to device trash", "move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_locked_folder": "Move to locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", - "move_to_trash": "Move to Trash", "move_up": "Move up", "moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive", "moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library", @@ -1641,6 +1650,7 @@ "photos_and_videos": "Photos & Videos", "photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}", "photos_from_previous_years": "Photos from previous years", + "photos_only": "Photos only", "pick_a_location": "Pick a location", "pick_custom_range": "Custom range", "pick_date_range": "Select a date range", @@ -2268,6 +2278,7 @@ "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.", "videos": "Videos", "videos_count": "{count, plural, one {# Video} other {# Videos}}", + "videos_only": "Videos only", "view": "View", "view_album": "View Album", "view_all": "View All", diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index f7b6f84484..e16e621020 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/providers/cleanup.provider.dart'; class DriftLocalAssetRepository extends DriftDatabaseRepository { final Drift _db; @@ -127,7 +128,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { return result; } - Future> getRemovalCandidates(String userId, DateTime cutoffDate) async { + Future> getRemovalCandidates( + String userId, + DateTime cutoffDate, { + AssetFilterType filterType = AssetFilterType.all, + bool keepFavorites = true, + }) async { final query = _db.localAssetEntity.select().join([ innerJoin( _db.remoteAssetEntity, @@ -135,7 +141,21 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { _db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.deletedAt.isNull(), ), - ])..where(_db.localAssetEntity.createdAt.isSmallerOrEqualValue(cutoffDate)); + ]); + + Expression whereClause = _db.localAssetEntity.createdAt.isSmallerOrEqualValue(cutoffDate); + + if (filterType == AssetFilterType.photosOnly) { + whereClause = whereClause & _db.localAssetEntity.type.equals(AssetType.image.index); + } else if (filterType == AssetFilterType.videosOnly) { + whereClause = whereClause & _db.localAssetEntity.type.equals(AssetType.video.index); + } + + if (keepFavorites) { + whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false); + } + + query.where(whereClause); final rows = await query.get(); return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList(); diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart index 91164625ab..6011fdd8d9 100644 --- a/mobile/lib/providers/cleanup.provider.dart +++ b/mobile/lib/providers/cleanup.provider.dart @@ -3,17 +3,23 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; +enum AssetFilterType { all, photosOnly, videosOnly } + class CleanupState { final DateTime? selectedDate; final List assetsToDelete; final bool isScanning; final bool isDeleting; + final AssetFilterType filterType; + final bool keepFavorites; const CleanupState({ this.selectedDate, this.assetsToDelete = const [], this.isScanning = false, this.isDeleting = false, + this.filterType = AssetFilterType.all, + this.keepFavorites = true, }); CleanupState copyWith({ @@ -21,12 +27,16 @@ class CleanupState { List? assetsToDelete, bool? isScanning, bool? isDeleting, + AssetFilterType? filterType, + bool? keepFavorites, }) { return CleanupState( selectedDate: selectedDate ?? this.selectedDate, assetsToDelete: assetsToDelete ?? this.assetsToDelete, isScanning: isScanning ?? this.isScanning, isDeleting: isDeleting ?? this.isDeleting, + filterType: filterType ?? this.filterType, + keepFavorites: keepFavorites ?? this.keepFavorites, ); } } @@ -45,6 +55,14 @@ class CleanupNotifier extends StateNotifier { state = state.copyWith(selectedDate: date, assetsToDelete: []); } + void setFilterType(AssetFilterType filterType) { + state = state.copyWith(filterType: filterType, assetsToDelete: []); + } + + void setKeepFavorites(bool keepFavorites) { + state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []); + } + Future scanAssets() async { if (_userId == null || state.selectedDate == null) { return; @@ -52,7 +70,12 @@ class CleanupNotifier extends StateNotifier { state = state.copyWith(isScanning: true); try { - final assets = await _cleanupService.getRemovalCandidates(_userId, state.selectedDate!); + final assets = await _cleanupService.getRemovalCandidates( + _userId, + state.selectedDate!, + filterType: state.filterType, + keepFavorites: state.keepFavorites, + ); state = state.copyWith(assetsToDelete: assets, isScanning: false); } catch (e) { state = state.copyWith(isScanning: false); diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart index 4fdb739fb1..4b1a79600d 100644 --- a/mobile/lib/services/cleanup.service.dart +++ b/mobile/lib/services/cleanup.service.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/providers/cleanup.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; @@ -14,8 +15,18 @@ class CleanupService { const CleanupService(this._localAssetRepository, this._assetMediaRepository); - Future> getRemovalCandidates(String userId, DateTime cutoffDate) { - return _localAssetRepository.getRemovalCandidates(userId, cutoffDate); + Future> getRemovalCandidates( + String userId, + DateTime cutoffDate, { + AssetFilterType filterType = AssetFilterType.all, + bool keepFavorites = true, + }) { + return _localAssetRepository.getRemovalCandidates( + userId, + cutoffDate, + filterType: filterType, + keepFavorites: keepFavorites, + ); } Future deleteLocalAssets(List localIds) async { diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart index ff321853ee..7294fa21a9 100644 --- a/mobile/lib/widgets/settings/free_up_space_settings.dart +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -11,6 +11,8 @@ import 'package:immich_mobile/providers/cleanup.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +enum CleanupStep { selectDate, filterOptions, scan, delete } + class FreeUpSpaceSettings extends ConsumerStatefulWidget { const FreeUpSpaceSettings({super.key}); @@ -19,7 +21,7 @@ class FreeUpSpaceSettings extends ConsumerStatefulWidget { } class _FreeUpSpaceSettingsState extends ConsumerState { - int _currentStep = 0; + CleanupStep _currentStep = CleanupStep.selectDate; bool _hasScanned = false; void _resetState() { @@ -27,18 +29,44 @@ class _FreeUpSpaceSettingsState extends ConsumerState { _hasScanned = false; } - int get _calculatedStep { + CleanupStep get _calculatedStep { final state = ref.read(cleanupProvider); if (state.assetsToDelete.isNotEmpty) { - return 2; + return CleanupStep.delete; } if (state.selectedDate != null) { - return 1; + return CleanupStep.filterOptions; } - return 0; + return CleanupStep.selectDate; + } + + void _goToFiltersStep() { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + setState(() => _currentStep = CleanupStep.scan); + } + + void _setPresetDate(int? daysAgo) { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + final date = daysAgo != null + ? DateTime.now().subtract(Duration(days: daysAgo)) + : DateTime(2000); // Very old date for "all" option + ref.read(cleanupProvider.notifier).setSelectedDate(date); + setState(() => _hasScanned = false); + } + + bool _isPresetSelected(int? daysAgo) { + final state = ref.read(cleanupProvider); + if (state.selectedDate == null) return false; + + final expectedDate = daysAgo != null ? DateTime.now().subtract(Duration(days: daysAgo)) : DateTime(2000); + + // Check if dates match (ignoring time component) + return state.selectedDate!.year == expectedDate.year && + state.selectedDate!.month == expectedDate.month && + state.selectedDate!.day == expectedDate.day; } Future _selectDate() async { @@ -54,7 +82,6 @@ class _FreeUpSpaceSettingsState extends ConsumerState { if (picked != null) { ref.read(cleanupProvider.notifier).setSelectedDate(picked); - setState(() => _currentStep = 1); } } @@ -67,7 +94,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { setState(() { _hasScanned = true; if (state.assetsToDelete.isNotEmpty) { - _currentStep = 2; + _currentStep = CleanupStep.delete; } }); } @@ -98,7 +125,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { content: Text('cleanup_deleted_assets'.t(context: context, args: {'count': deletedCount.toString()})), ), ); - setState(() => _currentStep = 0); + setState(() => _currentStep = CleanupStep.selectDate); } } @@ -157,12 +184,29 @@ class _FreeUpSpaceSettingsState extends ConsumerState { } final step1State = hasDate ? StepState.complete : StepState.indexed; - final step2State = hasAssets + final step2State = hasDate ? StepState.complete : StepState.disabled; + final step3State = hasAssets ? StepState.complete : hasDate ? StepState.indexed : StepState.disabled; - final step3State = hasAssets ? StepState.indexed : StepState.disabled; + final step4State = hasAssets ? StepState.indexed : StepState.disabled; + + String getFilterSubtitle() { + 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.keepFavorites) { + parts.add('keep_favorites'.t(context: context)); + } + return parts.join(' • '); + } return PopScope( onPopInvokedWithResult: (didPop, result) { @@ -170,230 +214,377 @@ class _FreeUpSpaceSettingsState extends ConsumerState { _resetState(); } }, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: context.colorScheme.surfaceContainerLow, - borderRadius: const BorderRadius.all(Radius.circular(12)), - border: Border.all(color: context.primaryColor.withValues(alpha: 0.25)), - ), - child: Text( - 'free_up_space_description'.t(context: context), - style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all(color: context.primaryColor.withValues(alpha: 0.25)), + ), + child: Text( + 'free_up_space_description'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ), ), ), - ), - Stepper( - currentStep: _currentStep, - onStepTapped: (step) { - // Only allow going back or to completed steps - if (step <= _calculatedStep) { - setState(() => _currentStep = step); - } - }, - onStepContinue: () async { - switch (_currentStep) { - case 0: - await _selectDate(); - case 1: - await _scanAssets(); - case 2: - await _deleteAssets(); - } - }, - onStepCancel: () { - if (_currentStep > 0) { - setState(() => _currentStep -= 1); - } - }, - controlsBuilder: (_, __) => const SizedBox.shrink(), - steps: [ - // Step 1: Select Cutoff Date - Step( - stepStyle: styleForState(step1State), - title: Text( - 'select_cutoff_date'.t(context: context), - style: context.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: step1State == StepState.complete - ? context.colorScheme.primary - : context.colorScheme.onSurface, - ), - ), - subtitle: hasDate - ? Text( - _formatDate(state.selectedDate!), - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ) - : null, - content: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ElevatedButton.icon( - onPressed: _selectDate, - icon: const Icon(Icons.calendar_today), - label: Text(hasDate ? 'change'.t(context: context) : 'select_cutoff_date'.t(context: context)), - style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + Stepper( + physics: const NeverScrollableScrollPhysics(), + currentStep: _currentStep.index, + onStepTapped: (step) { + // Only allow going back or to completed steps + if (step <= _calculatedStep.index) { + setState(() => _currentStep = CleanupStep.values[step]); + } + }, + onStepContinue: () async { + switch (_currentStep) { + case CleanupStep.selectDate: + await _selectDate(); + case CleanupStep.filterOptions: + _goToFiltersStep(); + case CleanupStep.scan: + await _scanAssets(); + case CleanupStep.delete: + await _deleteAssets(); + } + }, + onStepCancel: () { + if (_currentStep.index > 0) { + setState(() => _currentStep = CleanupStep.values[_currentStep.index - 1]); + } + }, + controlsBuilder: (_, __) => const SizedBox.shrink(), + steps: [ + // Step 1: Select Cutoff Date + Step( + stepStyle: styleForState(step1State), + title: Text( + 'select_cutoff_date'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step1State == StepState.complete + ? context.colorScheme.primary + : context.colorScheme.onSurface, ), - ], - ), - isActive: true, - state: step1State, - ), - - // Step 2: Scan Assets - Step( - stepStyle: styleForState(step2State), - title: Text( - 'scan'.t(context: context), - style: context.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: step2State == StepState.complete - ? context.colorScheme.primary - : step2State == StepState.disabled - ? context.colorScheme.onSurface.withValues(alpha: 0.38) - : context.colorScheme.onSurface, ), - ), - subtitle: _hasScanned - ? Text( - 'cleanup_found_assets'.t( - context: context, - args: {'count': state.assetsToDelete.length.toString()}, - ), - style: context.textTheme.bodyMedium?.copyWith( - color: state.assetsToDelete.isNotEmpty - ? context.colorScheme.primary - : context.colorScheme.onSurface.withValues(alpha: 0.6), - fontWeight: FontWeight.w500, - ), - ) - : null, - content: Column( - children: [ - Text('cleanup_step2_description'.t(context: context), style: context.textTheme.bodyLarge), - const SizedBox(height: 16), - state.isScanning - ? SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator( - strokeWidth: 2, - backgroundColor: context.colorScheme.primary.withAlpha(50), - ), - ) - : ElevatedButton.icon( - onPressed: state.isScanning ? null : _scanAssets, - icon: const Icon(Icons.search), - label: Text(_hasScanned ? 'rescan'.t(context: context) : 'scan'.t(context: context)), - style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + subtitle: hasDate + ? Text( + _formatDate(state.selectedDate!), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, ), - if (_hasScanned && state.assetsToDelete.isEmpty) ...[ + ) + : null, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('cutoff_date_description'.t(context: context), style: context.textTheme.labelLarge), const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.1), - borderRadius: const BorderRadius.all(Radius.circular(8)), + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.4, + children: [ + _DatePresetCard( + value: '30', + unit: 'cutoff_day'.t(context: context, args: {'count': '30'}), + onTap: () => _setPresetDate(30), + isSelected: _isPresetSelected(30), + ), + _DatePresetCard( + value: '60', + unit: 'cutoff_day'.t(context: context, args: {'count': '60'}), + + onTap: () => _setPresetDate(60), + isSelected: _isPresetSelected(60), + ), + _DatePresetCard( + value: '90', + unit: 'cutoff_day'.t(context: context, args: {'count': '90'}), + + onTap: () => _setPresetDate(90), + isSelected: _isPresetSelected(90), + ), + _DatePresetCard( + value: '1', + unit: 'cutoff_year'.t(context: context, args: {'count': '1'}), + onTap: () => _setPresetDate(365), + isSelected: _isPresetSelected(365), + ), + _DatePresetCard( + value: '2', + unit: 'cutoff_year'.t(context: context, args: {'count': '2'}), + onTap: () => _setPresetDate(730), + isSelected: _isPresetSelected(730), + ), + _DatePresetCard( + value: '3', + unit: 'cutoff_year'.t(context: context, args: {'count': '3'}), + onTap: () => _setPresetDate(1095), + isSelected: _isPresetSelected(1095), + ), + ], + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _selectDate, + icon: const Icon(Icons.calendar_today), + label: Text('custom_date'.t(context: context)), + style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: hasDate ? () => setState(() => _currentStep = CleanupStep.filterOptions) : null, + icon: const Icon(Icons.arrow_forward), + label: Text('continue'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + ], + ), + isActive: true, + state: step1State, + ), + + // Step 2: Select Filter Options + Step( + stepStyle: styleForState(step2State), + title: Text( + 'filter_options'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step2State == StepState.complete + ? context.colorScheme.primary + : step2State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : 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: context.textTheme.labelLarge), + 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.titleSmall), + subtitle: Text( + 'keep_favorites_description'.t(context: context), + style: context.textTheme.labelLarge, ), - child: Row( - children: [ - const Icon(Icons.info, color: Colors.orange), - const SizedBox(width: 12), - Expanded( - child: Text( - 'cleanup_no_assets_found'.t(context: context), - style: context.textTheme.bodyMedium, + value: state.keepFavorites, + onChanged: (value) { + ref.read(cleanupProvider.notifier).setKeepFavorites(value); + setState(() => _hasScanned = false); + }, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _goToFiltersStep, + 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( + context: context, + args: {'count': state.assetsToDelete.length.toString()}, + ), + style: context.textTheme.bodyMedium?.copyWith( + color: state.assetsToDelete.isNotEmpty + ? context.colorScheme.primary + : context.colorScheme.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + children: [ + Text( + 'cleanup_step3_description'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ), + const SizedBox(height: 16), + state.isScanning + ? SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2, + backgroundColor: context.colorScheme.primary.withAlpha(50), ), + ) + : ElevatedButton.icon( + onPressed: state.isScanning ? null : _scanAssets, + icon: const Icon(Icons.search), + label: Text(_hasScanned ? 'rescan'.t(context: context) : 'scan'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), ), - ], + if (_hasScanned && state.assetsToDelete.isEmpty) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Row( + children: [ + const Icon(Icons.info, color: Colors.orange), + const SizedBox(width: 12), + Expanded( + child: Text( + 'cleanup_no_assets_found'.t(context: context), + style: context.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ], + ), + isActive: hasDate, + state: step3State, + ), + + // Step 4: Delete Assets + Step( + stepStyle: styleForState(step4State, isDestructive: true), + title: Text( + 'move_to_device_trash'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step4State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : context.colorScheme.error, + ), + ), + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.colorScheme.errorContainer.withValues(alpha: 0.3), + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)), + ), + child: hasAssets + ? Text( + 'cleanup_step4_summary'.t( + context: context, + args: { + 'count': state.assetsToDelete.length.toString(), + 'date': _formatDate(state.selectedDate!), + }, + ), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ) + : null, + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => _showAssetsPreview(state.assetsToDelete), + icon: const Icon(Icons.preview), + label: Text('preview'.t(context: context)), + style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: state.isDeleting ? null : _deleteAssets, + icon: state.isDeleting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.delete_forever), + label: Text( + state.isDeleting + ? 'cleanup_deleting'.t(context: context) + : 'move_to_device_trash'.t(context: context), + ), + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error, + foregroundColor: context.colorScheme.onError, + minimumSize: const Size(double.infinity, 56), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ), ], - ], - ), - isActive: hasDate, - state: step2State, - ), - - // Step 3: Delete Assets - Step( - stepStyle: styleForState(step3State, isDestructive: true), - title: Text( - 'move_to_trash'.t(context: context), - style: context.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: step3State == StepState.disabled - ? context.colorScheme.onSurface.withValues(alpha: 0.38) - : context.colorScheme.error, ), + isActive: hasAssets, + state: step4State, ), - content: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: context.colorScheme.errorContainer.withValues(alpha: 0.3), - borderRadius: const BorderRadius.all(Radius.circular(8)), - border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)), - ), - child: hasAssets - ? Text( - 'cleanup_step3_summary'.t( - context: context, - args: { - 'count': state.assetsToDelete.length.toString(), - 'date': _formatDate(state.selectedDate!), - }, - ), - style: context.textTheme.bodyMedium, - ) - : null, - ), - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () => _showAssetsPreview(state.assetsToDelete), - icon: const Icon(Icons.preview), - label: Text('preview'.t(context: context)), - style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), - ), - const SizedBox(height: 12), - ElevatedButton.icon( - onPressed: state.isDeleting ? null : _deleteAssets, - icon: state.isDeleting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : const Icon(Icons.delete_forever), - label: Text( - state.isDeleting ? 'cleanup_deleting'.t(context: context) : 'move_to_trash'.t(context: context), - ), - style: ElevatedButton.styleFrom( - backgroundColor: context.colorScheme.error, - foregroundColor: context.colorScheme.onError, - minimumSize: const Size(double.infinity, 56), - textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - ), - ], - ), - isActive: hasAssets, - state: step3State, - ), - ], - ), - ], + ], + ), + ], + ), ), ); } @@ -408,12 +599,13 @@ class _DeleteConfirmationDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: Text('confirm'.t(context: context)), + title: Text('cleanup_confirm_prompt_title'.t(context: context)), content: Text( 'cleanup_confirm_description'.t( context: context, args: {'count': assetCount.toString(), 'date': DateFormat.yMMMd().format(cutoffDate)}, ), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), ), actions: [ TextButton( @@ -426,7 +618,7 @@ class _DeleteConfirmationDialog extends StatelessWidget { backgroundColor: context.colorScheme.error, foregroundColor: context.colorScheme.onError, ), - child: Text('move_to_trash'.t(context: context)), + child: Text('confirm'.t(context: context)), ), ], ); @@ -490,3 +682,50 @@ class _DragHandle extends StatelessWidget { ); } } + +class _DatePresetCard extends StatelessWidget { + final String value; + final String unit; + final VoidCallback onTap; + final bool isSelected; + + const _DatePresetCard({required this.value, required this.unit, required this.onTap, required this.isSelected}); + + @override + Widget build(BuildContext context) { + return Material( + color: isSelected ? context.colorScheme.primaryContainer.withAlpha(100) : context.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: InkWell( + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all(color: isSelected ? context.colorScheme.primary : Colors.transparent, width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + value, + style: context.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: isSelected ? context.colorScheme.primary : context.colorScheme.onSurface, + ), + ), + Text( + unit, + style: context.textTheme.bodySmall?.copyWith( + color: isSelected + ? context.colorScheme.primary + : context.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ), + ); + } +}