feat: make keep options into persistent settings

This commit is contained in:
Alex
2026-01-23 09:45:15 -06:00
parent a644cc0a3c
commit 327b784c86
9 changed files with 287 additions and 194 deletions

View File

@@ -512,6 +512,7 @@
"all": "All",
"all_albums": "All albums",
"all_people": "All people",
"all_photos": "All photos",
"all_videos": "All videos",
"allow_dark_mode": "Allow dark mode",
"allow_edits": "Allow edits",
@@ -751,12 +752,13 @@
"cleanup_confirm_prompt_title": "Remove from this device?",
"cleanup_deleted_assets": "Moved {count} assets to device trash",
"cleanup_deleting": "Moving to trash...",
"cleanup_filter_description": "Choose which types of assets to remove in the cleanup",
"cleanup_filter_description": "Keep an extra copy of selected items on this device by excluding them from Free Up Space",
"cleanup_found_assets": "Found {count} backed up assets",
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
"cleanup_keep_all_media_type": "Always keep",
"cleanup_no_assets_found": "No backed up assets found matching your criteria",
"cleanup_preview_title": "Assets to remove ({count})",
"cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options",
"cleanup_step3_description": "Scan for backed up assets matching your date and keep settings",
"cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device",
"cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
"clear": "Clear",
@@ -1139,9 +1141,9 @@
"unable_to_upload_file": "Unable to upload file"
},
"errors_text": "Errors",
"exclude_albums": "Exclude albums",
"exclude_albums_description": "Assets in selected albums will not be removed from your device",
"excluded_albums_count": "{count} albums excluded",
"exclude_albums": "Keep albums",
"exclude_albums_description": "Assets in selected albums will remain on your device",
"excluded_albums_count": "Keeping {count} {count, plural, one {album} other {albums}}",
"exclusion_pattern": "Exclusion pattern",
"exif": "Exif",
"exif_bottom_sheet_description": "Add Description...",
@@ -1193,7 +1195,7 @@
"filetype": "Filetype",
"filter": "Filter",
"filter_description": "Conditions to filter the target assets",
"filter_options": "Filter options",
"filter_options": "Keep on device",
"filter_people": "Filter people",
"filter_places": "Filter places",
"filters": "Filters",
@@ -1326,8 +1328,11 @@
"keep": "Keep",
"keep_all": "Keep All",
"keep_favorites": "Keep favorites",
"keep_favorites_description": "Favorite assets will not be deleted from your device",
"keep_favorites_description": "Favorite assets will remain on your device",
"keep_on_device": "Keep on device",
"keep_on_device_hint": "Select items to keep an extra copy on this device",
"keep_this_delete_others": "Keep this, delete others",
"keeping": "Keeping: {items}",
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
"keyboard_shortcuts": "Keyboard shortcuts",
"language": "Language",
@@ -1587,6 +1592,7 @@
"no_results_description": "Try a synonym or more general keyword",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"no_uploads_in_progress": "No uploads in progress",
"none": "None",
"not_allowed": "Not allowed",
"not_available": "N/A",
"not_in_any_album": "Not in any album",

View File

@@ -8,6 +8,6 @@ enum SortUserBy { id }
enum ActionSource { timeline, viewer }
enum CleanupStep { selectDate, filterOptions, scan, delete }
enum CleanupStep { selectDate, scan, delete }
enum AssetFilterType { all, photosOnly, videosOnly }
enum AssetKeepType { none, photosOnly, videosOnly }

View File

@@ -82,7 +82,13 @@ enum StoreKey<T> {
useWifiForUploadPhotos<bool>._(1005),
needBetaMigration<bool>._(1006),
// TODO: Remove this after patching open-api
shouldResetSync<bool>._(1007);
shouldResetSync<bool>._(1007),
// Free up space
cleanupKeepFavorites<bool>._(1008),
cleanupKeepMediaType<int>._(1009),
cleanupExcludedAlbumIds<String>._(1010),
cleanupCutoffDaysAgo<int>._(1011);
const StoreKey._(this.id);
final int id;

View File

@@ -133,7 +133,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
Future<List<LocalAsset>> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
AssetKeepType keepMediaType = AssetKeepType.none,
bool keepFavorites = true,
Set<String> excludedAlbumIds = const {},
}) async {
@@ -167,10 +167,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(excludedAlbumAssets);
}
if (filterType == AssetFilterType.photosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
} else if (filterType == AssetFilterType.videosOnly) {
// keepMediaType specifies what to KEEP, so we filter to DELETE the opposite
if (keepMediaType == AssetKeepType.photosOnly) {
// Keep photos = delete only videos
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video);
} else if (keepMediaType == AssetKeepType.videosOnly) {
// Keep videos = delete only photos
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
}
if (keepFavorites) {

View File

@@ -1,7 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
class CleanupState {
@@ -9,7 +11,7 @@ class CleanupState {
final List<LocalAsset> assetsToDelete;
final bool isScanning;
final bool isDeleting;
final AssetFilterType filterType;
final AssetKeepType keepMediaType;
final bool keepFavorites;
final Set<String> excludedAlbumIds;
@@ -18,7 +20,7 @@ class CleanupState {
this.assetsToDelete = const [],
this.isScanning = false,
this.isDeleting = false,
this.filterType = AssetFilterType.all,
this.keepMediaType = AssetKeepType.none,
this.keepFavorites = true,
this.excludedAlbumIds = const {},
});
@@ -28,7 +30,7 @@ class CleanupState {
List<LocalAsset>? assetsToDelete,
bool? isScanning,
bool? isDeleting,
AssetFilterType? filterType,
AssetKeepType? keepMediaType,
bool? keepFavorites,
Set<String>? excludedAlbumIds,
}) {
@@ -37,7 +39,7 @@ class CleanupState {
assetsToDelete: assetsToDelete ?? this.assetsToDelete,
isScanning: isScanning ?? this.isScanning,
isDeleting: isDeleting ?? this.isDeleting,
filterType: filterType ?? this.filterType,
keepMediaType: keepMediaType ?? this.keepMediaType,
keepFavorites: keepFavorites ?? this.keepFavorites,
excludedAlbumIds: excludedAlbumIds ?? this.excludedAlbumIds,
);
@@ -45,25 +47,56 @@ class CleanupState {
}
final cleanupProvider = StateNotifierProvider<CleanupNotifier, CleanupState>((ref) {
return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id);
return CleanupNotifier(
ref.watch(cleanupServiceProvider),
ref.watch(currentUserProvider)?.id,
ref.watch(appSettingsServiceProvider),
);
});
class CleanupNotifier extends StateNotifier<CleanupState> {
final CleanupService _cleanupService;
final String? _userId;
final AppSettingsService _appSettingsService;
CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState());
CleanupNotifier(this._cleanupService, this._userId, this._appSettingsService) : super(const CleanupState()) {
_loadPersistedSettings();
}
void _loadPersistedSettings() {
final keepFavorites = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepFavorites);
final keepMediaTypeIndex = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepMediaType);
final excludedAlbumIdsString = _appSettingsService.getSetting(AppSettingsEnum.cleanupExcludedAlbumIds);
final cutoffDaysAgo = _appSettingsService.getSetting(AppSettingsEnum.cleanupCutoffDaysAgo);
final keepMediaType = AssetKeepType.values[keepMediaTypeIndex.clamp(0, AssetKeepType.values.length - 1)];
final excludedAlbumIds = excludedAlbumIdsString.isEmpty ? <String>{} : excludedAlbumIdsString.split(',').toSet();
final selectedDate = cutoffDaysAgo > 0 ? DateTime.now().subtract(Duration(days: cutoffDaysAgo)) : null;
state = state.copyWith(
keepFavorites: keepFavorites,
keepMediaType: keepMediaType,
excludedAlbumIds: excludedAlbumIds,
selectedDate: selectedDate,
);
}
void setSelectedDate(DateTime? date) {
state = state.copyWith(selectedDate: date, assetsToDelete: []);
if (date != null) {
final daysAgo = DateTime.now().difference(date).inDays;
_appSettingsService.setSetting(AppSettingsEnum.cleanupCutoffDaysAgo, daysAgo);
}
}
void setFilterType(AssetFilterType filterType) {
state = state.copyWith(filterType: filterType, assetsToDelete: []);
void setKeepMediaType(AssetKeepType keepMediaType) {
state = state.copyWith(keepMediaType: keepMediaType, assetsToDelete: []);
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepMediaType, keepMediaType.index);
}
void setKeepFavorites(bool keepFavorites) {
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepFavorites, keepFavorites);
}
void toggleExcludedAlbum(String albumId) {
@@ -74,10 +107,16 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
newExcludedAlbumIds.add(albumId);
}
state = state.copyWith(excludedAlbumIds: newExcludedAlbumIds, assetsToDelete: []);
_persistExcludedAlbumIds(newExcludedAlbumIds);
}
void setExcludedAlbumIds(Set<String> albumIds) {
state = state.copyWith(excludedAlbumIds: albumIds, assetsToDelete: []);
_persistExcludedAlbumIds(albumIds);
}
void _persistExcludedAlbumIds(Set<String> albumIds) {
_appSettingsService.setSetting(AppSettingsEnum.cleanupExcludedAlbumIds, albumIds.join(','));
}
Future<void> scanAssets() async {
@@ -90,7 +129,7 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
final assets = await _cleanupService.getRemovalCandidates(
_userId,
state.selectedDate!,
filterType: state.filterType,
keepMediaType: state.keepMediaType,
keepFavorites: state.keepFavorites,
excludedAlbumIds: state.excludedAlbumIds,
);
@@ -120,6 +159,7 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
}
void reset() {
state = const CleanupState();
// Only reset transient state, keep the persisted filter settings
state = state.copyWith(selectedDate: null, assetsToDelete: [], isScanning: false, isDeleting: false);
}
}

View File

@@ -54,7 +54,11 @@ enum AppSettingsEnum<T> {
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
cleanupKeepFavorites<bool>(StoreKey.cleanupKeepFavorites, null, true),
cleanupKeepMediaType<int>(StoreKey.cleanupKeepMediaType, null, 0),
cleanupExcludedAlbumIds<String>(StoreKey.cleanupExcludedAlbumIds, null, ""),
cleanupCutoffDaysAgo<int>(StoreKey.cleanupCutoffDaysAgo, null, 60);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@@ -18,14 +18,14 @@ class CleanupService {
Future<List<LocalAsset>> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
AssetKeepType keepMediaType = AssetKeepType.none,
bool keepFavorites = true,
Set<String> excludedAlbumIds = const {},
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
filterType: filterType,
keepMediaType: keepMediaType,
keepFavorites: keepFavorites,
excludedAlbumIds: excludedAlbumIds,
);

View File

@@ -23,6 +23,7 @@ class FreeUpSpaceSettings extends ConsumerStatefulWidget {
class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
CleanupStep _currentStep = CleanupStep.selectDate;
bool _hasScanned = false;
bool _isKeepSettingsExpanded = false;
void _resetState() {
ref.read(cleanupProvider.notifier).reset();
@@ -37,17 +38,12 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
}
if (state.selectedDate != null) {
return CleanupStep.filterOptions;
return CleanupStep.scan;
}
return CleanupStep.selectDate;
}
void _goToFiltersStep() {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
setState(() => _currentStep = CleanupStep.filterOptions);
}
void _goToScanStep() {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
setState(() => _currentStep = CleanupStep.scan);
@@ -88,6 +84,13 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
}
}
void _onKeepSettingsChanged() {
setState(() {
_hasScanned = false;
_currentStep = CleanupStep.scan;
});
}
Future<void> _scanAssets() async {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
@@ -176,33 +179,40 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
}
final step1State = hasDate ? StepState.complete : StepState.indexed;
final step2State = hasDate ? StepState.complete : StepState.disabled;
final step3State = hasAssets
final step2State = hasAssets
? StepState.complete
: hasDate
? StepState.indexed
: StepState.disabled;
final step4State = hasAssets ? StepState.indexed : StepState.disabled;
final step3State = hasAssets ? StepState.indexed : StepState.disabled;
String getFilterSubtitle() {
final hasKeepSettings =
state.keepFavorites || state.excludedAlbumIds.isNotEmpty || state.keepMediaType != AssetKeepType.none;
String getKeepSettingsSummary() {
final parts = <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.keepMediaType == AssetKeepType.photosOnly) {
parts.add('all_photos'.t(context: context));
} else if (state.keepMediaType == AssetKeepType.videosOnly) {
parts.add('all_videos'.t(context: context));
}
if (state.keepFavorites) {
parts.add('keep_favorites'.t(context: context));
parts.add('favorites'.t(context: context).toLowerCase());
}
if (state.excludedAlbumIds.isNotEmpty) {
parts.add(
'excluded_albums_count'.t(context: context, args: {'count': state.excludedAlbumIds.length.toString()}),
);
}
return parts.join('');
if (parts.isEmpty) {
return 'none'.t(context: context);
}
return parts.join(', ');
}
return PopScope(
@@ -227,6 +237,119 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
),
),
// Keep on device settings card
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
side: BorderSide(
color: hasKeepSettings
? context.colorScheme.primary.withValues(alpha: 0.5)
: context.colorScheme.outlineVariant,
width: hasKeepSettings ? 1.5 : 1,
),
),
color: hasKeepSettings
? context.colorScheme.primaryContainer.withValues(alpha: 0.15)
: context.colorScheme.surfaceContainerLow,
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: _isKeepSettingsExpanded,
onExpansionChanged: (expanded) {
setState(() => _isKeepSettingsExpanded = expanded);
},
leading: Icon(
hasKeepSettings ? Icons.bookmark : Icons.bookmark_border,
color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
),
title: Text(
'keep_on_device'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: hasKeepSettings ? context.colorScheme.primary : null,
),
),
subtitle: Text(
hasKeepSettings
? 'keeping'.t(context: context, args: {'items': getKeepSettingsSummary()})
: 'keep_on_device_hint'.t(context: context),
style: context.textTheme.bodySmall?.copyWith(
color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
),
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('cleanup_filter_description'.t(context: context), style: subtitleStyle),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'keep_favorites'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
subtitle: Text(
'keep_favorites_description'.t(context: context),
style: context.textTheme.bodyMedium!.copyWith(
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
),
),
value: state.keepFavorites,
onChanged: (value) {
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
_onKeepSettingsChanged();
},
),
const SizedBox(height: 8),
_ExcludedAlbumsSection(
excludedAlbumIds: state.excludedAlbumIds,
onAlbumToggled: (albumId) {
ref.read(cleanupProvider.notifier).toggleExcludedAlbum(albumId);
_onKeepSettingsChanged();
},
),
const SizedBox(height: 16),
Text(
'cleanup_keep_all_media_type'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
const SizedBox(height: 4),
SegmentedButton<AssetKeepType>(
segments: [
const ButtonSegment(value: AssetKeepType.none, label: Text('')),
ButtonSegment(
value: AssetKeepType.photosOnly,
label: Text('photos'.t(context: context)),
icon: const Icon(Icons.photo),
),
ButtonSegment(
value: AssetKeepType.videosOnly,
label: Text('videos'.t(context: context)),
icon: const Icon(Icons.videocam),
),
],
selected: {state.keepMediaType},
onSelectionChanged: (selection) {
ref.read(cleanupProvider.notifier).setKeepMediaType(selection.first);
_onKeepSettingsChanged();
},
),
],
),
),
],
),
),
),
),
const SizedBox(height: 8),
Stepper(
physics: const NeverScrollableScrollPhysics(),
currentStep: _currentStep.index,
@@ -321,7 +444,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: hasDate ? () => _goToFiltersStep() : null,
onPressed: hasDate ? _goToScanStep : null,
icon: const Icon(Icons.arrow_forward),
label: Text('continue'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
@@ -332,11 +455,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
state: step1State,
),
// Step 2: Select Filter Options
// Step 2: Scan Assets
Step(
stepStyle: styleForState(step2State),
title: Text(
'filter_options'.t(context: context),
'scan'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step2State == StepState.complete
@@ -346,98 +469,6 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
: context.colorScheme.onSurface,
),
),
subtitle: hasDate
? Text(
getFilterSubtitle(),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
)
: null,
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('cleanup_filter_description'.t(context: context), style: subtitleStyle),
const SizedBox(height: 16),
SegmentedButton<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.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
subtitle: Text(
'keep_favorites_description'.t(context: context),
style: context.textTheme.bodyMedium!.copyWith(
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
),
),
value: state.keepFavorites,
onChanged: (value) {
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
_ExcludedAlbumsSection(
excludedAlbumIds: state.excludedAlbumIds,
onAlbumToggled: (albumId) {
ref.read(cleanupProvider.notifier).toggleExcludedAlbum(albumId);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _goToScanStep,
icon: const Icon(Icons.arrow_forward),
label: Text('continue'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
],
),
isActive: hasDate,
state: step2State,
),
// Step 3: Scan Assets
Step(
stepStyle: styleForState(step3State),
title: Text(
'scan'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step3State == StepState.complete
? context.colorScheme.primary
: step3State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.onSurface,
),
),
subtitle: _hasScanned
? Text(
'cleanup_found_assets'.t(
@@ -518,17 +549,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
],
),
isActive: hasDate,
state: step3State,
state: step2State,
),
// Step 4: Delete Assets
// Step 3: Delete Assets
Step(
stepStyle: styleForState(step4State, isDestructive: true),
stepStyle: styleForState(step3State, isDestructive: true),
title: Text(
'move_to_device_trash'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step4State == StepState.disabled
color: step3State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.error,
),
@@ -588,7 +619,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
],
),
isActive: hasAssets,
state: step4State,
state: step3State,
),
],
),
@@ -786,7 +817,10 @@ class _ExcludedAlbumsSection extends ConsumerWidget {
const SizedBox(height: 8),
Text(
'excluded_albums_count'.t(context: context, args: {'count': excludedAlbumIds.length.toString()}),
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.error, fontWeight: FontWeight.w500),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
],
@@ -807,13 +841,13 @@ class _AlbumExclusionTile extends StatelessWidget {
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
leading: Icon(
isExcluded ? Icons.remove_circle : Icons.photo_album_outlined,
color: isExcluded ? context.colorScheme.error : context.colorScheme.onSurfaceVariant,
isExcluded ? Icons.check_circle : Icons.circle_outlined,
color: isExcluded ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
size: 20,
),
title: Text(
album.name,
style: context.textTheme.bodyMedium?.copyWith(color: isExcluded ? context.colorScheme.error : null),
style: context.textTheme.bodyMedium?.copyWith(color: isExcluded ? context.colorScheme.primary : null),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),

View File

@@ -190,8 +190,8 @@ void main() {
expect(candidates[0].isFavorite, true);
});
test('filters by photos only', () async {
// Photo
test('keepMediaType photosOnly returns only videos for deletion', () async {
// Photo - should be kept
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
@@ -201,7 +201,7 @@ void main() {
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
// Video - should be deleted
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
@@ -214,39 +214,7 @@ void main() {
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.photosOnly,
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-photo');
expect(candidates[0].type, AssetType.image);
});
test('filters by videos only', () async {
// Photo
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
createdAt: beforeCutoff,
type: AssetType.video,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.videosOnly,
keepMediaType: AssetKeepType.photosOnly,
);
expect(candidates.length, 1);
@@ -254,7 +222,39 @@ void main() {
expect(candidates[0].type, AssetType.video);
});
test('returns both photos and videos with filterType.all', () async {
test('keepMediaType videosOnly returns only photos for deletion', () async {
// Photo - should be deleted
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video - should be kept
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
createdAt: beforeCutoff,
type: AssetType.video,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
keepMediaType: AssetKeepType.videosOnly,
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-photo');
expect(candidates[0].type, AssetType.image);
});
test('returns both photos and videos with keepMediaType.all', () async {
// Photo
await insertLocalAsset(
id: 'local-photo',
@@ -275,7 +275,7 @@ void main() {
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, filterType: AssetFilterType.all);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.none);
expect(candidates.length, 2);
final ids = candidates.map((a) => a.id).toSet();
@@ -604,11 +604,11 @@ void main() {
expect(candidates[0].id, 'local-no-album');
});
test('combines excludedAlbumIds with other filters correctly', () async {
test('combines excludedAlbumIds with keepMediaType correctly', () async {
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false);
// Photo in excluded album - should NOT be included
// Photo in excluded album - should NOT be included (album excluded)
await insertLocalAsset(
id: 'local-photo-excluded',
checksum: 'checksum-photo-excluded',
@@ -619,7 +619,7 @@ void main() {
await insertRemoteAsset(id: 'remote-photo-excluded', checksum: 'checksum-photo-excluded', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-photo-excluded');
// Video in regular album - should NOT be included (filtering photos only)
// Video in regular album - should be included (keepMediaType photosOnly = delete videos)
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
@@ -630,7 +630,7 @@ void main() {
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-video');
// Photo in regular album - should be included
// Photo in regular album - should NOT be included (keepMediaType photosOnly = keep photos)
await insertLocalAsset(
id: 'local-photo-regular',
checksum: 'checksum-photo-regular',
@@ -644,12 +644,12 @@ void main() {
final candidates = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.photosOnly,
keepMediaType: AssetKeepType.photosOnly,
excludedAlbumIds: {'album-excluded'},
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-photo-regular');
expect(candidates[0].id, 'local-video');
});
});
}