Compare commits

...

10 Commits

Author SHA1 Message Date
Alex
cc52864790 Merge branch 'exclude-album-from-free-up-space' of github.com:immich-app/immich into exclude-album-from-free-up-space 2026-01-23 11:57:31 -06:00
Alex
9f1a4fd7ac add file size information 2026-01-23 11:29:17 -06:00
Alex
9cbc7be726 Update i18n/en.json
Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2026-01-23 11:28:27 -06:00
Alex
02fd8d8259 more copywriting 2026-01-23 11:03:25 -06:00
Alex
1560aecdff fix: date selection rerender 2026-01-23 10:57:06 -06:00
Alex
e09ae7dd9f add free up space to app bar dialog 2026-01-23 10:38:25 -06:00
Alex
d38a6229d8 chore: refactor 2026-01-23 10:36:43 -06:00
Alex
00374f2d6f chore: refactor 2026-01-23 10:36:30 -06:00
Alex
327b784c86 feat: make keep options into persistent settings 2026-01-23 10:05:22 -06:00
Alex
a644cc0a3c feat: album exclusion filter in free up space 2026-01-22 14:56:28 -06:00
10 changed files with 711 additions and 201 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",
@@ -519,6 +520,9 @@
"allow_public_user_to_upload": "Allow public user to upload",
"allowed": "Allowed",
"alt_text_qr_code": "QR code image",
"always_keep": "Always keep",
"always_keep_photos_hint": "Free Up Space will keep all photos on this device.",
"always_keep_videos_hint": "Free Up Space will keep all videos on this device.",
"anti_clockwise": "Anti-clockwise",
"api_key": "API Key",
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
@@ -751,13 +755,14 @@
"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_found_assets_with_size": "Found {count} backed up assets ({size})",
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
"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_step4_summary": "{count} assets created before {date} are queued for removal from your device",
"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_storage_savings": "Free up {size} of storage",
"cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
"clear": "Clear",
"clear_all": "Clear all",
@@ -1007,6 +1012,7 @@
"error_change_sort_album": "Failed to change album sort order",
"error_delete_face": "Error deleting face from asset",
"error_getting_places": "Error getting places",
"error_loading_albums": "Error loading albums",
"error_loading_image": "Error loading image",
"error_loading_partners": "Error loading partners: {error}",
"error_retrieving_asset_information": "Error retrieving asset information",
@@ -1189,7 +1195,6 @@
"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",
@@ -1203,7 +1208,7 @@
"forgot_pin_code_question": "Forgot your PIN?",
"forward": "Forward",
"free_up_space": "Free Up Space",
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe",
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe.",
"free_up_space_settings_subtitle": "Free up device storage",
"full_path": "Full path: {path}",
"gcast_enabled": "Google Cast",
@@ -1320,10 +1325,15 @@
"json_editor": "JSON editor",
"json_error": "JSON error",
"keep": "Keep",
"keep_albums": "Keep albums",
"keep_albums_count": "Keeping {count} {count, plural, one {album} other {albums}}",
"keep_all": "Keep All",
"keep_description": "Choose what stays on your device when freeing up space.",
"keep_favorites": "Keep favorites",
"keep_favorites_description": "Favorite assets will not be deleted from your device",
"keep_on_device": "Keep on device",
"keep_on_device_hint": "Select items to keep 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",
@@ -1553,6 +1563,7 @@
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_found": "No albums found",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
"no_albums_yet": "It looks like you do not have any albums yet.",
@@ -1582,6 +1593,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),
cleanupKeepAlbumIds<String>._(1010),
cleanupCutoffDaysAgo<int>._(1011);
const StoreKey._(this.id);
final int id;

View File

@@ -11,6 +11,13 @@ 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';
class RemovalCandidatesResult {
final List<LocalAsset> assets;
final int totalBytes;
const RemovalCandidatesResult({required this.assets, required this.totalBytes});
}
class DriftLocalAssetRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -130,11 +137,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return result;
}
Future<List<LocalAsset>> getRemovalCandidates(
Future<RemovalCandidatesResult> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
AssetKeepType keepMediaType = AssetKeepType.none,
bool keepFavorites = true,
Set<String> keepAlbumIds = const {},
}) async {
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
@@ -149,6 +157,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
final query = _db.localAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)),
leftOuterJoin(_db.remoteExifEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteExifEntity.assetId)),
]);
Expression<bool> whereClause =
@@ -159,10 +168,19 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
// Exclude assets that are in iOS shared albums
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets);
if (filterType == AssetFilterType.photosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
} else if (filterType == AssetFilterType.videosOnly) {
if (keepAlbumIds.isNotEmpty) {
final keepAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(_db.localAlbumAssetEntity.albumId.isIn(keepAlbumIds));
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(keepAlbumAssets);
}
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) {
@@ -172,7 +190,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
query.where(whereClause);
final rows = await query.get();
return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
final assets = rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
final totalBytes = rows.fold<int>(0, (sum, row) {
final fileSize = row.readTableOrNull(_db.remoteExifEntity)?.fileSize;
return sum + (fileSize ?? 0);
});
return RemovalCandidatesResult(assets: assets, totalBytes: totalBytes);
}
Future<List<LocalAsset>> getEmptyCloudIdAssets() {

View File

@@ -1,65 +1,126 @@
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 {
final DateTime? selectedDate;
final List<LocalAsset> assetsToDelete;
final int totalBytes;
final bool isScanning;
final bool isDeleting;
final AssetFilterType filterType;
final AssetKeepType keepMediaType;
final bool keepFavorites;
final Set<String> keepAlbumIds;
const CleanupState({
this.selectedDate,
this.assetsToDelete = const [],
this.totalBytes = 0,
this.isScanning = false,
this.isDeleting = false,
this.filterType = AssetFilterType.all,
this.keepMediaType = AssetKeepType.none,
this.keepFavorites = true,
this.keepAlbumIds = const {},
});
CleanupState copyWith({
DateTime? selectedDate,
List<LocalAsset>? assetsToDelete,
int? totalBytes,
bool? isScanning,
bool? isDeleting,
AssetFilterType? filterType,
AssetKeepType? keepMediaType,
bool? keepFavorites,
Set<String>? keepAlbumIds,
}) {
return CleanupState(
selectedDate: selectedDate ?? this.selectedDate,
assetsToDelete: assetsToDelete ?? this.assetsToDelete,
totalBytes: totalBytes ?? this.totalBytes,
isScanning: isScanning ?? this.isScanning,
isDeleting: isDeleting ?? this.isDeleting,
filterType: filterType ?? this.filterType,
keepMediaType: keepMediaType ?? this.keepMediaType,
keepFavorites: keepFavorites ?? this.keepFavorites,
keepAlbumIds: keepAlbumIds ?? this.keepAlbumIds,
);
}
}
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 keepAlbumIdsString = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepAlbumIds);
final cutoffDaysAgo = _appSettingsService.getSetting(AppSettingsEnum.cleanupCutoffDaysAgo);
final keepMediaType = AssetKeepType.values[keepMediaTypeIndex.clamp(0, AssetKeepType.values.length - 1)];
final keepAlbumIds = keepAlbumIdsString.isEmpty ? <String>{} : keepAlbumIdsString.split(',').toSet();
final selectedDate = cutoffDaysAgo >= 0 ? DateTime.now().subtract(Duration(days: cutoffDaysAgo)) : null;
state = state.copyWith(
keepFavorites: keepFavorites,
keepMediaType: keepMediaType,
keepAlbumIds: keepAlbumIds,
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 toggleKeepAlbum(String albumId) {
final newKeepAlbumIds = Set<String>.from(state.keepAlbumIds);
if (newKeepAlbumIds.contains(albumId)) {
newKeepAlbumIds.remove(albumId);
} else {
newKeepAlbumIds.add(albumId);
}
state = state.copyWith(keepAlbumIds: newKeepAlbumIds, assetsToDelete: []);
_persistExcludedAlbumIds(newKeepAlbumIds);
}
void setExcludedAlbumIds(Set<String> albumIds) {
state = state.copyWith(keepAlbumIds: albumIds, assetsToDelete: []);
_persistExcludedAlbumIds(albumIds);
}
void _persistExcludedAlbumIds(Set<String> albumIds) {
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepAlbumIds, albumIds.join(','));
}
Future<void> scanAssets() async {
@@ -69,13 +130,15 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
state = state.copyWith(isScanning: true);
try {
final assets = await _cleanupService.getRemovalCandidates(
final result = await _cleanupService.getRemovalCandidates(
_userId,
state.selectedDate!,
filterType: state.filterType,
keepMediaType: state.keepMediaType,
keepFavorites: state.keepFavorites,
keepAlbumIds: state.keepAlbumIds,
);
state = state.copyWith(assetsToDelete: assets, isScanning: false);
state = state.copyWith(assetsToDelete: result.assets, totalBytes: result.totalBytes, isScanning: false);
} catch (e) {
state = state.copyWith(isScanning: false);
rethrow;
@@ -101,6 +164,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),
cleanupKeepAlbumIds<String>(StoreKey.cleanupKeepAlbumIds, null, ""),
cleanupCutoffDaysAgo<int>(StoreKey.cleanupCutoffDaysAgo, null, -1);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@@ -1,6 +1,5 @@
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/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
@@ -15,17 +14,19 @@ class CleanupService {
const CleanupService(this._localAssetRepository, this._assetMediaRepository);
Future<List<LocalAsset>> getRemovalCandidates(
Future<RemovalCandidatesResult> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
AssetKeepType keepMediaType = AssetKeepType.none,
bool keepFavorites = true,
Set<String> keepAlbumIds = const {},
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
filterType: filterType,
keepMediaType: keepMediaType,
keepFavorites: keepFavorites,
keepAlbumIds: keepAlbumIds,
);
}

View File

@@ -16,6 +16,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart';
@@ -87,6 +88,14 @@ class ImmichAppBarDialog extends HookConsumerWidget {
return buildActionButton(Icons.settings_outlined, "settings", () => context.pushRoute(const SettingsRoute()));
}
buildFreeUpSpaceButton() {
return buildActionButton(
Icons.cleaning_services_outlined,
"free_up_space",
() => context.pushRoute(SettingsSubRoute(section: SettingSection.freeUpSpace)),
);
}
buildAppLogButton() {
return buildActionButton(
Icons.assignment_outlined,
@@ -271,6 +280,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
const AppBarServerInfo(),
if (Store.isBetaTimelineEnabled && isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildFreeUpSpaceButton(),
buildSettingButton(),
buildSignOutButton(),
buildFooter(),

View File

@@ -3,13 +3,16 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/cleanup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
class FreeUpSpaceSettings extends ConsumerStatefulWidget {
const FreeUpSpaceSettings({super.key});
@@ -21,6 +24,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();
@@ -35,17 +39,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);
@@ -83,9 +82,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
if (picked != null) {
ref.read(cleanupProvider.notifier).setSelectedDate(picked);
setState(() => _hasScanned = false);
}
}
void _onKeepSettingsChanged() {
setState(() {
_hasScanned = false;
_currentStep = CleanupStep.scan;
});
}
Future<void> _scanAssets() async {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
@@ -145,6 +152,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
final subtitleStyle = context.textTheme.bodyMedium!.copyWith(
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
);
StepStyle styleForState(StepState stepState, {bool isDestructive = false}) {
switch (stepState) {
case StepState.complete:
@@ -174,28 +182,38 @@ 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.keepAlbumIds.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());
}
return parts.join('');
if (state.keepAlbumIds.isNotEmpty) {
parts.add('keep_albums_count'.t(context: context, args: {'count': state.keepAlbumIds.length.toString()}));
}
if (parts.isEmpty) {
return 'none'.t(context: context);
}
return parts.join(', ');
}
return PopScope(
@@ -220,6 +238,125 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
),
),
// Keep on device settings card
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.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('keep_description'.t(context: context), style: subtitleStyle),
const SizedBox(height: 4),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'keep_favorites'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
value: state.keepFavorites,
onChanged: (value) {
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
_onKeepSettingsChanged();
},
),
const SizedBox(height: 8),
_KeepAlbumsSection(
albumIds: state.keepAlbumIds,
onAlbumToggled: (albumId) {
ref.read(cleanupProvider.notifier).toggleKeepAlbum(albumId);
_onKeepSettingsChanged();
},
),
const SizedBox(height: 16),
Text(
'always_keep'.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();
},
),
if (state.keepMediaType != AssetKeepType.none) ...[
const SizedBox(height: 8),
Text(
state.keepMediaType == AssetKeepType.photosOnly
? 'always_keep_photos_hint'.t(context: context)
: 'always_keep_videos_hint'.t(context: context),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
),
),
),
const SizedBox(height: 8),
Stepper(
physics: const NeverScrollableScrollPhysics(),
currentStep: _currentStep.index,
@@ -314,7 +451,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)),
@@ -325,11 +462,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
@@ -339,96 +476,20 @@ 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),
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(
context: context,
args: {'count': state.assetsToDelete.length.toString()},
),
state.totalBytes > 0
? 'cleanup_found_assets_with_size'.t(
context: context,
args: {
'count': state.assetsToDelete.length.toString(),
'size': formatBytes(state.totalBytes),
},
)
: 'cleanup_found_assets'.t(
context: context,
args: {'count': state.assetsToDelete.length.toString()},
),
style: context.textTheme.bodyMedium?.copyWith(
color: state.assetsToDelete.isNotEmpty
? context.colorScheme.primary
@@ -503,17 +564,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,
),
@@ -529,18 +590,33 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
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': DateFormat.yMMMd().format(state.selectedDate!),
},
),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'cleanup_step4_summary'.t(
context: context,
args: {
'count': state.assetsToDelete.length.toString(),
'date': DateFormat.yMMMd().format(state.selectedDate!),
},
),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
],
)
: null,
),
if (state.totalBytes > 0) ...[
const SizedBox(height: 8),
Text(
'cleanup_storage_savings'.t(context: context, args: {'size': formatBytes(state.totalBytes)}),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: context.colorScheme.primary,
),
),
],
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => _showAssetsPreview(state.assetsToDelete),
@@ -573,7 +649,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
],
),
isActive: hasAssets,
state: step4State,
state: step3State,
),
],
),
@@ -701,3 +777,107 @@ class _DatePresetCard extends StatelessWidget {
);
}
}
class _KeepAlbumsSection extends ConsumerWidget {
final Set<String> albumIds;
final ValueChanged<String> onAlbumToggled;
const _KeepAlbumsSection({required this.albumIds, required this.onAlbumToggled});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumsAsync = ref.watch(localAlbumProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'keep_albums'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
const SizedBox(height: 8),
albumsAsync.when(
loading: () => const Center(
child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (error, stack) => Text(
'error_loading_albums'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
),
data: (albums) {
if (albums.isEmpty) {
return Text(
'no_albums_found'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
);
}
return Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.outlineVariant),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
constraints: const BoxConstraints(maxHeight: 200),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: ListView.builder(
shrinkWrap: true,
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
final isSelected = albumIds.contains(album.id);
return _AlbumTile(album: album, isSelected: isSelected, onToggle: () => onAlbumToggled(album.id));
},
),
),
);
},
),
if (albumIds.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'keep_albums_count'.t(context: context, args: {'count': albumIds.length.toString()}),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
],
);
}
}
class _AlbumTile extends StatelessWidget {
final LocalAlbum album;
final bool isSelected;
final VoidCallback onToggle;
const _AlbumTile({required this.album, required this.isSelected, required this.onToggle});
@override
Widget build(BuildContext context) {
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
leading: Icon(
isSelected ? Icons.check_circle : Icons.circle_outlined,
color: isSelected ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
size: 20,
),
title: Text(
album.name,
style: context.textTheme.bodyMedium?.copyWith(color: isSelected ? context.colorScheme.primary : null),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
album.assetCount.toString(),
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
onTap: onToggle,
);
}
}

View File

@@ -167,10 +167,10 @@ void main() {
);
await insertRemoteAsset(id: 'remote-6', checksum: 'checksum-6', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true);
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-1');
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-1');
});
test('includes favorites when keepFavorites is false', () async {
@@ -183,15 +183,15 @@ void main() {
);
await insertRemoteAsset(id: 'remote-favorite', checksum: 'checksum-fav', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false);
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-favorite');
expect(candidates[0].isFavorite, true);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-favorite');
expect(result.assets[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',
@@ -211,19 +211,19 @@ void main() {
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(
final result = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.photosOnly,
keepMediaType: AssetKeepType.photosOnly,
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-photo');
expect(candidates[0].type, AssetType.image);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-video');
expect(result.assets[0].type, AssetType.video);
});
test('filters by videos only', () async {
// Photo
test('keepMediaType videosOnly returns only photos for deletion', () async {
// Photo - should be deleted
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
@@ -233,7 +233,7 @@ void main() {
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
// Video - should be kept
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
@@ -243,18 +243,18 @@ void main() {
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(
final result = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.videosOnly,
keepMediaType: AssetKeepType.videosOnly,
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-video');
expect(candidates[0].type, AssetType.video);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-photo');
expect(result.assets[0].type, AssetType.image);
});
test('returns both photos and videos with filterType.all', () async {
test('returns both photos and videos with keepMediaType.all', () async {
// Photo
await insertLocalAsset(
id: 'local-photo',
@@ -275,10 +275,10 @@ void main() {
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, filterType: AssetFilterType.all);
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.none);
expect(candidates.length, 2);
final ids = candidates.map((a) => a.id).toSet();
expect(result.assets.length, 2);
final ids = result.assets.map((a) => a.id).toSet();
expect(ids, containsAll(['local-photo', 'local-video']));
});
@@ -311,10 +311,10 @@ void main() {
await insertRemoteAsset(id: 'remote-shared', checksum: 'checksum-shared', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-shared');
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-regular');
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-regular');
});
test('includes assets at exact cutoff date', () async {
@@ -327,10 +327,10 @@ void main() {
);
await insertRemoteAsset(id: 'remote-exact', checksum: 'checksum-exact', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-exact');
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-exact');
});
test('returns empty list when no assets match criteria', () async {
@@ -344,9 +344,9 @@ void main() {
);
await insertRemoteAsset(id: 'remote-after', checksum: 'checksum-after', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
expect(result.assets, isEmpty);
});
test('handles multiple assets with same checksum', () async {
@@ -367,10 +367,10 @@ void main() {
);
await insertRemoteAsset(id: 'remote-dup', checksum: 'checksum-dup', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 2);
expect(candidates.map((a) => a.checksum).toSet(), equals({'checksum-dup'}));
expect(result.assets.length, 2);
expect(result.assets.map((a) => a.checksum).toSet(), equals({'checksum-dup'}));
});
test('includes assets not in any album', () async {
@@ -384,10 +384,10 @@ void main() {
);
await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-no-album');
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-no-album');
});
test('excludes asset that is in both regular and iOS shared album', () async {
@@ -409,9 +409,9 @@ void main() {
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-both');
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-both');
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
expect(result.assets, isEmpty);
});
test('excludes assets with null checksum (not backed up)', () async {
@@ -430,9 +430,218 @@ void main() {
),
);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
expect(result.assets, isEmpty);
});
test('excludes assets in user-excluded albums', () async {
// Create two regular albums
await insertLocalAlbum(id: 'album-include', name: 'Include Album', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-exclude', name: 'Exclude Album', isIosSharedAlbum: false);
// Asset in included album - should be included
await insertLocalAsset(
id: 'local-in-included',
checksum: 'checksum-included',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-included', checksum: 'checksum-included', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-include', assetId: 'local-in-included');
// Asset in excluded album - should NOT be included
await insertLocalAsset(
id: 'local-in-excluded',
checksum: 'checksum-excluded',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-excluded', checksum: 'checksum-excluded', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-exclude', assetId: 'local-in-excluded');
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-exclude'});
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-in-included');
});
test('excludes assets that are in any of multiple excluded albums', () async {
// Create multiple albums
await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-2', name: 'Album 2', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-3', name: 'Album 3', isIosSharedAlbum: false);
// Asset in album-1 (excluded) - should NOT be included
await insertLocalAsset(
id: 'local-1',
checksum: 'checksum-1',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1');
// Asset in album-2 (excluded) - should NOT be included
await insertLocalAsset(
id: 'local-2',
checksum: 'checksum-2',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-2', assetId: 'local-2');
// Asset in album-3 (not excluded) - should be included
await insertLocalAsset(
id: 'local-3',
checksum: 'checksum-3',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3');
final result = await repository.getRemovalCandidates(
userId,
cutoffDate,
keepAlbumIds: {'album-1', 'album-2'},
);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-3');
});
test('excludes asset that is in both excluded and non-excluded album', () async {
await insertLocalAlbum(id: 'album-included', name: 'Included Album', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
// Asset in BOTH albums - should be excluded because it's in an excluded album
await insertLocalAsset(
id: 'local-both',
checksum: 'checksum-both',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-both', checksum: 'checksum-both', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-included', assetId: 'local-both');
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-both');
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-excluded'});
expect(result.assets, isEmpty);
});
test('includes all assets when excludedAlbumIds is empty', () async {
await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false);
await insertLocalAsset(
id: 'local-1',
checksum: 'checksum-1',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1');
await insertLocalAsset(
id: 'local-2',
checksum: 'checksum-2',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId);
// Empty excludedAlbumIds should include all eligible assets
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {});
expect(result.assets.length, 2);
});
test('excludes asset not in any album when album is excluded', () async {
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
// Asset NOT in any album - should be included
await insertLocalAsset(
id: 'local-no-album',
checksum: 'checksum-no-album',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId);
// Asset in excluded album - should NOT be included
await insertLocalAsset(
id: 'local-in-excluded',
checksum: 'checksum-in-excluded',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-in-excluded', checksum: 'checksum-in-excluded', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-in-excluded');
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-excluded'});
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-no-album');
});
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 (album excluded)
await insertLocalAsset(
id: 'local-photo-excluded',
checksum: 'checksum-photo-excluded',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
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 be included (keepMediaType photosOnly = delete videos)
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);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-video');
// Photo in regular album - should NOT be included (keepMediaType photosOnly = keep photos)
await insertLocalAsset(
id: 'local-photo-regular',
checksum: 'checksum-photo-regular',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo-regular', checksum: 'checksum-photo-regular', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-photo-regular');
final result = await repository.getRemovalCandidates(
userId,
cutoffDate,
keepMediaType: AssetKeepType.photosOnly,
keepAlbumIds: {'album-excluded'},
);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-video');
});
});
}