feat: cutoff date preset options and filter options

This commit is contained in:
Alex
2026-01-05 14:16:57 -06:00
parent 44368a45fa
commit f275eaffe1
5 changed files with 535 additions and 231 deletions

View File

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

View File

@@ -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<List<LocalAsset>> getRemovalCandidates(String userId, DateTime cutoffDate) async {
Future<List<LocalAsset>> 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<bool> 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();

View File

@@ -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<LocalAsset> 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<LocalAsset>? 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<CleanupState> {
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<void> scanAssets() async {
if (_userId == null || state.selectedDate == null) {
return;
@@ -52,7 +70,12 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
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);

View File

@@ -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<List<LocalAsset>> getRemovalCandidates(String userId, DateTime cutoffDate) {
return _localAssetRepository.getRemovalCandidates(userId, cutoffDate);
Future<List<LocalAsset>> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
bool keepFavorites = true,
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
filterType: filterType,
keepFavorites: keepFavorites,
);
}
Future<int> deleteLocalAssets(List<String> localIds) async {

View File

@@ -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<FreeUpSpaceSettings> {
int _currentStep = 0;
CleanupStep _currentStep = CleanupStep.selectDate;
bool _hasScanned = false;
void _resetState() {
@@ -27,18 +29,44 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
_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<void> _selectDate() async {
@@ -54,7 +82,6 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
if (picked != null) {
ref.read(cleanupProvider.notifier).setSelectedDate(picked);
setState(() => _currentStep = 1);
}
}
@@ -67,7 +94,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
setState(() {
_hasScanned = true;
if (state.assetsToDelete.isNotEmpty) {
_currentStep = 2;
_currentStep = CleanupStep.delete;
}
});
}
@@ -98,7 +125,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
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<FreeUpSpaceSettings> {
}
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 = <String>[];
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<FreeUpSpaceSettings> {
_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<AssetFilterType>(
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),
),
),
],
),
),
),
);
}
}