mirror of
https://github.com/immich-app/immich.git
synced 2026-01-08 19:32:44 -08:00
feat: cutoff date preset options and filter options
This commit is contained in:
21
i18n/en.json
21
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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user