diff --git a/i18n/en.json b/i18n/en.json index 529ab6dfdb..13fc965b65 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -734,6 +734,18 @@ "checksum": "Checksum", "choose_matching_people_to_merge": "Choose matching people to merge", "city": "City", + "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_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_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash", "clear": "Clear", "clear_all": "Clear all", "clear_all_recent_searches": "Clear all recent searches", @@ -823,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", @@ -1148,6 +1164,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", @@ -1160,6 +1177,9 @@ "folders_feature_description": "Browsing the folder view for the photos and videos on the file system", "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_settings_subtitle": "Free up device storage", "full_path": "Full path: {path}", "gcast_enabled": "Google Cast", "gcast_enabled_description": "This feature loads external resources from Google in order to work.", @@ -1276,6 +1296,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", @@ -1446,6 +1468,7 @@ "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", @@ -1628,6 +1651,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", @@ -1808,9 +1832,11 @@ "saved_settings": "Saved settings", "say_something": "Say something", "scaffold_body_error_occurred": "Error occurred", + "scan": "Scan", "scan_all_libraries": "Scan All Libraries", "scan_library": "Scan", "scan_settings": "Scan Settings", + "scanning": "Scanning", "scanning_for_album": "Scanning for album...", "search": "Search", "search_albums": "Search albums", @@ -1882,6 +1908,7 @@ "select_all_in": "Select all in {group}", "select_avatar_color": "Select avatar color", "select_count": "{count, plural, one {Select #} other {Select #}}", + "select_cutoff_date": "Select cutoff date", "select_face": "Select face", "select_featured_photo": "Select featured photo", "select_from_computer": "Select from computer", @@ -2250,6 +2277,7 @@ "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.", "videos": "Videos", "videos_count": "{count, plural, one {# Video} other {# Videos}}", + "videos_only": "Videos only", "view": "View", "view_album": "View Album", "view_all": "View All", diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 91ca50a2c0..c4505137d2 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -7,3 +7,7 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked } enum SortUserBy { id } enum ActionSource { timeline, viewer } + +enum CleanupStep { selectDate, filterOptions, scan, delete } + +enum AssetFilterType { all, photosOnly, videosOnly } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 96630f1eba..e866a965c4 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -79,6 +79,9 @@ class TimelineFactory { TimelineService fromAssets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssets(assets, type)); + TimelineService fromAssetsWithBuckets(List assets, TimelineOrigin type) => + TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); + TimelineService map(String userId, LatLngBounds bounds) => TimelineService(_timelineRepository.map(userId, bounds, groupBy)); } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 4d30e09716..8cbce084cd 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:immich_mobile/constants/constants.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/infrastructure/entities/local_album.entity.dart'; @@ -126,4 +127,49 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { } return result; } + + Future> getRemovalCandidates( + String userId, + DateTime cutoffDate, { + AssetFilterType filterType = AssetFilterType.all, + bool keepFavorites = true, + }) async { + final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + ]) + ..where(_db.localAlbumEntity.isIosSharedAlbum.equals(true)); + + final query = _db.localAssetEntity.select().join([ + innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)), + ]); + + Expression whereClause = + _db.localAssetEntity.createdAt.isSmallerOrEqualValue(cutoffDate) & + _db.remoteAssetEntity.ownerId.equals(userId) & + _db.remoteAssetEntity.deletedAt.isNull(); + + // 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) { + whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video); + } + + if (keepFavorites) { + whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false); + } + + query.where(whereClause); + + final rows = await query.get(); + return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList(); + } } diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index d21e1e905b..66ae47a0b5 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -253,6 +253,24 @@ class DriftTimelineRepository extends DriftDatabaseRepository { origin: origin, ); + TimelineQuery fromAssetsWithBuckets(List assets, TimelineOrigin origin) { + // Sort assets by date descending and group by day + final sorted = List.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final Map bucketCounts = {}; + for (final asset in sorted) { + final date = DateTime(asset.createdAt.year, asset.createdAt.month, asset.createdAt.day); + bucketCounts[date] = (bucketCounts[date] ?? 0) + 1; + } + + final buckets = bucketCounts.entries.map((e) => TimeBucket(date: e.key, assetCount: e.value)).toList(); + + return ( + bucketSource: () => Stream.value(buckets), + assetSource: (offset, count) => Future.value(sorted.skip(offset).take(count).toList(growable: false)), + origin: origin, + ); + } + TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder( filter: (row) => row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId), diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 86c80253dc..a1d7e55f32 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewe import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart'; +import 'package:immich_mobile/widgets/settings/free_up_space_settings.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; import 'package:immich_mobile/widgets/settings/notification_setting.dart'; @@ -22,6 +23,7 @@ enum SettingSection { advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"), assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"), backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"), + freeUpSpace('free_up_space', Icons.cleaning_services_outlined, "free_up_space_settings_subtitle"), languages('language', Icons.language, "setting_languages_subtitle"), networking('networking_settings', Icons.wifi, "networking_subtitle"), notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"), @@ -38,6 +40,7 @@ enum SettingSection { SettingSection.assetViewer => const AssetViewerSettings(), SettingSection.backup => Store.tryGet(StoreKey.betaTimeline) ?? false ? const DriftBackupSettings() : const BackupSettings(), + SettingSection.freeUpSpace => const FreeUpSpaceSettings(), SettingSection.languages => const LanguageSettings(), SettingSection.networking => const NetworkingSettings(), SettingSection.notifications => const NotificationSetting(), diff --git a/mobile/lib/presentation/pages/cleanup_preview.page.dart b/mobile/lib/presentation/pages/cleanup_preview.page.dart new file mode 100644 index 0000000000..556ed6412f --- /dev/null +++ b/mobile/lib/presentation/pages/cleanup_preview.page.dart @@ -0,0 +1,42 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +@RoutePage() +class CleanupPreviewPage extends StatelessWidget { + final List assets; + + const CleanupPreviewPage({super.key, required this.assets}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('cleanup_preview_title'.t(context: context, args: {'count': assets.length.toString()})), + centerTitle: true, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: context.colorScheme.surface, + ), + body: ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith((ref) { + final timelineService = ref + .watch(timelineFactoryProvider) + .fromAssetsWithBuckets(assets.cast(), TimelineOrigin.search); + ref.onDispose(timelineService.dispose); + return timelineService; + }), + ], + child: const Timeline(appBar: null, bottomSheet: null, groupBy: GroupAssetsBy.day, readOnly: true), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index a04e26d653..ac20e73190 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -42,6 +42,7 @@ class Timeline extends StatelessWidget { this.withScrubber = true, this.snapToMonth = true, this.initialScrollOffset, + this.readOnly = false, }); final Widget? topSliverWidget; @@ -54,6 +55,7 @@ class Timeline extends StatelessWidget { final bool withScrubber; final bool snapToMonth; final double? initialScrollOffset; + final bool readOnly; @override Widget build(BuildContext context) { @@ -73,6 +75,7 @@ class Timeline extends StatelessWidget { groupBy: groupBy, ), ), + if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), ], child: _SliverTimeline( topSliverWidget: topSliverWidget, @@ -89,6 +92,17 @@ class Timeline extends StatelessWidget { } } +class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier { + @override + bool build() => true; + + @override + void setReadonlyMode(bool value) {} + + @override + void toggleReadonlyMode() {} +} + class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ this.topSliverWidget, diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart new file mode 100644 index 0000000000..5b3b152f34 --- /dev/null +++ b/mobile/lib/providers/cleanup.provider.dart @@ -0,0 +1,106 @@ +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/user.provider.dart'; +import 'package:immich_mobile/services/cleanup.service.dart'; + +class CleanupState { + final DateTime? selectedDate; + final List assetsToDelete; + final bool isScanning; + final bool isDeleting; + final AssetFilterType filterType; + final bool keepFavorites; + + const CleanupState({ + this.selectedDate, + this.assetsToDelete = const [], + this.isScanning = false, + this.isDeleting = false, + this.filterType = AssetFilterType.all, + this.keepFavorites = true, + }); + + CleanupState copyWith({ + DateTime? selectedDate, + List? assetsToDelete, + bool? isScanning, + bool? isDeleting, + AssetFilterType? filterType, + bool? keepFavorites, + }) { + return CleanupState( + selectedDate: selectedDate ?? this.selectedDate, + assetsToDelete: assetsToDelete ?? this.assetsToDelete, + isScanning: isScanning ?? this.isScanning, + isDeleting: isDeleting ?? this.isDeleting, + filterType: filterType ?? this.filterType, + keepFavorites: keepFavorites ?? this.keepFavorites, + ); + } +} + +final cleanupProvider = StateNotifierProvider((ref) { + return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id); +}); + +class CleanupNotifier extends StateNotifier { + final CleanupService _cleanupService; + final String? _userId; + + CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState()); + + void setSelectedDate(DateTime? date) { + state = state.copyWith(selectedDate: date, assetsToDelete: []); + } + + void setFilterType(AssetFilterType filterType) { + state = state.copyWith(filterType: filterType, assetsToDelete: []); + } + + void setKeepFavorites(bool keepFavorites) { + state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []); + } + + Future scanAssets() async { + if (_userId == null || state.selectedDate == null) { + return; + } + + state = state.copyWith(isScanning: true); + try { + 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); + rethrow; + } + } + + Future deleteAssets() async { + if (state.assetsToDelete.isEmpty) { + return 0; + } + + state = state.copyWith(isDeleting: true); + try { + final deletedCount = await _cleanupService.deleteLocalAssets(state.assetsToDelete.map((a) => a.id).toList()); + + state = state.copyWith(assetsToDelete: [], isDeleting: false); + + return deletedCount; + } catch (e) { + state = state.copyWith(isDeleting: false); + rethrow; + } + } + + void reset() { + state = const CleanupState(); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 9c4a193381..9468b105e5 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -88,6 +88,7 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart'; +import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; @@ -338,6 +339,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 939bf73369..b287d73114 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -611,6 +611,43 @@ class ChangePasswordRoute extends PageRouteInfo { ); } +/// generated route for +/// [CleanupPreviewPage] +class CleanupPreviewRoute extends PageRouteInfo { + CleanupPreviewRoute({ + Key? key, + required List assets, + List? children, + }) : super( + CleanupPreviewRoute.name, + args: CleanupPreviewRouteArgs(key: key, assets: assets), + initialChildren: children, + ); + + static const String name = 'CleanupPreviewRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return CleanupPreviewPage(key: args.key, assets: args.assets); + }, + ); +} + +class CleanupPreviewRouteArgs { + const CleanupPreviewRouteArgs({this.key, required this.assets}); + + final Key? key; + + final List assets; + + @override + String toString() { + return 'CleanupPreviewRouteArgs{key: $key, assets: $assets}'; + } +} + /// generated route for /// [CreateAlbumPage] class CreateAlbumRoute extends PageRouteInfo { diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart new file mode 100644 index 0000000000..6a4318d209 --- /dev/null +++ b/mobile/lib/services/cleanup.service.dart @@ -0,0 +1,45 @@ +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'; + +final cleanupServiceProvider = Provider((ref) { + return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider)); +}); + +class CleanupService { + final DriftLocalAssetRepository _localAssetRepository; + final AssetMediaRepository _assetMediaRepository; + + const CleanupService(this._localAssetRepository, this._assetMediaRepository); + + Future> getRemovalCandidates( + String userId, + DateTime cutoffDate, { + AssetFilterType filterType = AssetFilterType.all, + bool keepFavorites = true, + }) { + return _localAssetRepository.getRemovalCandidates( + userId, + cutoffDate, + filterType: filterType, + keepFavorites: keepFavorites, + ); + } + + Future deleteLocalAssets(List localIds) async { + if (localIds.isEmpty) { + return 0; + } + + final deletedIds = await _assetMediaRepository.deleteAll(localIds); + if (deletedIds.isNotEmpty) { + await _localAssetRepository.delete(deletedIds); + return deletedIds.length; + } + + return 0; + } +} diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart new file mode 100644 index 0000000000..7acb04686b --- /dev/null +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -0,0 +1,702 @@ +import 'package:auto_route/auto_route.dart'; +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/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/routing/router.dart'; + +class FreeUpSpaceSettings extends ConsumerStatefulWidget { + const FreeUpSpaceSettings({super.key}); + + @override + ConsumerState createState() => _FreeUpSpaceSettingsState(); +} + +class _FreeUpSpaceSettingsState extends ConsumerState { + CleanupStep _currentStep = CleanupStep.selectDate; + bool _hasScanned = false; + + void _resetState() { + ref.read(cleanupProvider.notifier).reset(); + _hasScanned = false; + } + + CleanupStep get _calculatedStep { + final state = ref.read(cleanupProvider); + + if (state.assetsToDelete.isNotEmpty) { + return CleanupStep.delete; + } + + if (state.selectedDate != null) { + return CleanupStep.filterOptions; + } + + 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); + } + + void _setPresetDate(int daysAgo) { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + final date = DateTime.now().subtract(Duration(days: daysAgo)); + ref.read(cleanupProvider.notifier).setSelectedDate(date); + setState(() => _hasScanned = false); + } + + bool _isPresetSelected(int? daysAgo) { + final state = ref.read(cleanupProvider); + if (state.selectedDate == null) return false; + + final expectedDate = daysAgo != null ? DateTime.now().subtract(Duration(days: daysAgo)) : DateTime(2000); + + // Check if dates match (ignoring time component) + return state.selectedDate!.year == expectedDate.year && + state.selectedDate!.month == expectedDate.month && + state.selectedDate!.day == expectedDate.day; + } + + Future _selectDate() async { + final state = ref.read(cleanupProvider); + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + + final DateTime? picked = await showDatePicker( + context: context, + initialDate: state.selectedDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now(), + ); + + if (picked != null) { + ref.read(cleanupProvider.notifier).setSelectedDate(picked); + } + } + + Future _scanAssets() async { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + + await ref.read(cleanupProvider.notifier).scanAssets(); + final state = ref.read(cleanupProvider); + + setState(() { + _hasScanned = true; + if (state.assetsToDelete.isNotEmpty) { + _currentStep = CleanupStep.delete; + } + }); + } + + Future _deleteAssets() async { + final state = ref.read(cleanupProvider); + + if (state.assetsToDelete.isEmpty || state.selectedDate == null) { + return; + } + + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + final confirmed = await showDialog( + context: context, + builder: (ctx) => + _DeleteConfirmationDialog(assetCount: state.assetsToDelete.length, cutoffDate: state.selectedDate!), + ); + + if (confirmed != true) { + return; + } + + final deletedCount = await ref.read(cleanupProvider.notifier).deleteAssets(); + + if (mounted && deletedCount > 0) { + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + + await showDialog( + context: context, + builder: (ctx) => _DeleteSuccessDialog(deletedCount: deletedCount), + ); + } + + setState(() => _currentStep = CleanupStep.selectDate); + } + + void _showAssetsPreview(List assets) { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + context.pushRoute(CleanupPreviewRoute(assets: assets)); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(cleanupProvider); + final hasDate = state.selectedDate != null; + final hasAssets = _hasScanned && state.assetsToDelete.isNotEmpty; + + StepStyle styleForState(StepState stepState, {bool isDestructive = false}) { + switch (stepState) { + case StepState.complete: + return StepStyle( + color: context.colorScheme.primary, + indexStyle: TextStyle(color: context.colorScheme.onPrimary, fontWeight: FontWeight.w500), + ); + case StepState.disabled: + return StepStyle( + color: context.colorScheme.onSurface.withValues(alpha: 0.38), + indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500), + ); + case StepState.indexed: + case StepState.editing: + case StepState.error: + if (isDestructive) { + return StepStyle( + color: context.colorScheme.error, + indexStyle: TextStyle(color: context.colorScheme.onError, fontWeight: FontWeight.w500), + ); + } + return StepStyle( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500), + ); + } + } + + final step1State = hasDate ? StepState.complete : StepState.indexed; + final step2State = hasDate ? StepState.complete : StepState.disabled; + final step3State = hasAssets + ? StepState.complete + : hasDate + ? StepState.indexed + : StepState.disabled; + final step4State = hasAssets ? StepState.indexed : StepState.disabled; + + String getFilterSubtitle() { + final parts = []; + switch (state.filterType) { + case AssetFilterType.all: + parts.add('all'.t(context: context)); + case AssetFilterType.photosOnly: + parts.add('photos_only'.t(context: context)); + case AssetFilterType.videosOnly: + parts.add('videos_only'.t(context: context)); + } + if (state.keepFavorites) { + parts.add('keep_favorites'.t(context: context)); + } + return parts.join(' • '); + } + + return PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + _resetState(); + } + }, + 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( + 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]); + } + }, + 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( + DateFormat.yMMMd().format(state.selectedDate!), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('cutoff_date_description'.t(context: context), style: context.textTheme.labelLarge), + const SizedBox(height: 16), + 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 ? () => _goToFiltersStep() : null, + icon: const Icon(Icons.arrow_forward), + label: Text('continue'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + ], + ), + isActive: true, + state: step1State, + ), + + // Step 2: Select Filter Options + Step( + stepStyle: styleForState(step2State), + title: Text( + 'filter_options'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step2State == StepState.complete + ? context.colorScheme.primary + : step2State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : context.colorScheme.onSurface, + ), + ), + subtitle: hasDate + ? Text( + getFilterSubtitle(), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('cleanup_filter_description'.t(context: context), style: context.textTheme.labelLarge), + const SizedBox(height: 16), + SegmentedButton( + segments: [ + ButtonSegment( + value: AssetFilterType.all, + label: Text('all'.t(context: context)), + icon: const Icon(Icons.photo_library), + ), + ButtonSegment( + value: AssetFilterType.photosOnly, + label: Text('photos'.t(context: context)), + icon: const Icon(Icons.photo), + ), + ButtonSegment( + value: AssetFilterType.videosOnly, + label: Text('videos'.t(context: context)), + icon: const Icon(Icons.videocam), + ), + ], + selected: {state.filterType}, + onSelectionChanged: (selection) { + ref.read(cleanupProvider.notifier).setFilterType(selection.first); + setState(() => _hasScanned = false); + }, + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text('keep_favorites'.t(context: context), style: context.textTheme.titleSmall), + subtitle: Text( + 'keep_favorites_description'.t(context: context), + style: context.textTheme.labelLarge, + ), + 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()}, + ), + 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), + ), + if (CurrentPlatform.isIOS) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: context.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'cleanup_icloud_shared_albums_excluded'.t(context: context), + style: context.textTheme.labelLarge, + ), + ), + ], + ), + ), + ], + 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': DateFormat.yMMMd().format(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: hasAssets, + state: step4State, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _DeleteConfirmationDialog extends StatelessWidget { + final int assetCount; + final DateTime cutoffDate; + + const _DeleteConfirmationDialog({required this.assetCount, required this.cutoffDate}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + 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( + onPressed: () => context.pop(false), + child: Text('cancel'.t(context: context)), + ), + ElevatedButton( + onPressed: () => context.pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error, + foregroundColor: context.colorScheme.onError, + ), + child: Text('confirm'.t(context: context)), + ), + ], + ); + } +} + +class _DeleteSuccessDialog extends StatelessWidget { + final int deletedCount; + + const _DeleteSuccessDialog({required this.deletedCount}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: Icon(Icons.check_circle, color: context.colorScheme.primary, size: 48), + title: Text('success'.t(context: context)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'cleanup_deleted_assets'.t(context: context, args: {'count': deletedCount.toString()}), + style: context.textTheme.labelLarge?.copyWith(fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'cleanup_trash_hint'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(fontSize: 16, color: context.primaryColor), + textAlign: TextAlign.center, + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: () => context.pop(), + child: Text('done'.t(context: context)), + ), + ], + ); + } +} + +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), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart new file mode 100644 index 0000000000..0d686fbc09 --- /dev/null +++ b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart @@ -0,0 +1,438 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.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/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; + +void main() { + late Drift db; + late DriftLocalAssetRepository repository; + + setUp(() { + db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + repository = DriftLocalAssetRepository(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('getRemovalCandidates', () { + final userId = 'user-123'; + final otherUserId = 'user-456'; + final now = DateTime(2024, 1, 15); + final cutoffDate = DateTime(2024, 1, 10); + final beforeCutoff = DateTime(2024, 1, 5); + final afterCutoff = DateTime(2024, 1, 12); + + Future insertUser(String id, String email) async { + await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email)); + } + + setUp(() async { + await insertUser(userId, 'user@test.com'); + await insertUser(otherUserId, 'other@test.com'); + }); + + Future insertLocalAsset({ + required String id, + required String checksum, + required DateTime createdAt, + required AssetType type, + required bool isFavorite, + }) async { + await db + .into(db.localAssetEntity) + .insert( + LocalAssetEntityCompanion.insert( + id: id, + name: 'asset_$id.jpg', + checksum: Value(checksum), + type: type, + createdAt: Value(createdAt), + updatedAt: Value(createdAt), + isFavorite: Value(isFavorite), + ), + ); + } + + Future insertRemoteAsset({ + required String id, + required String checksum, + required String ownerId, + DateTime? deletedAt, + }) async { + await db + .into(db.remoteAssetEntity) + .insert( + RemoteAssetEntityCompanion.insert( + id: id, + name: 'remote_$id.jpg', + checksum: checksum, + type: AssetType.image, + createdAt: Value(now), + updatedAt: Value(now), + ownerId: ownerId, + visibility: AssetVisibility.timeline, + deletedAt: Value(deletedAt), + ), + ); + } + + Future insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async { + await db + .into(db.localAlbumEntity) + .insert( + LocalAlbumEntityCompanion.insert( + id: id, + name: name, + updatedAt: Value(now), + backupSelection: BackupSelection.none, + isIosSharedAlbum: Value(isIosSharedAlbum), + ), + ); + } + + Future insertLocalAlbumAsset({required String albumId, required String assetId}) async { + await db + .into(db.localAlbumAssetEntity) + .insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId)); + } + + test('returns only assets that match all criteria', () async { + // Asset 1: Should be included - backed up, before cutoff, correct owner, not deleted, not favorite + 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); + + // Asset 2: Should NOT be included - not backed up (no remote asset) + await insertLocalAsset( + id: 'local-2', + checksum: 'checksum-2', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + + // Asset 3: Should NOT be included - after cutoff date + await insertLocalAsset( + id: 'local-3', + checksum: 'checksum-3', + createdAt: afterCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId); + + // Asset 4: Should NOT be included - different owner + await insertLocalAsset( + id: 'local-4', + checksum: 'checksum-4', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-4', checksum: 'checksum-4', ownerId: otherUserId); + + // Asset 5: Should NOT be included - remote asset is deleted + await insertLocalAsset( + id: 'local-5', + checksum: 'checksum-5', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-5', checksum: 'checksum-5', ownerId: userId, deletedAt: now); + + // Asset 6: Should NOT be included - is favorite (when keepFavorites=true) + await insertLocalAsset( + id: 'local-6', + checksum: 'checksum-6', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: true, + ); + await insertRemoteAsset(id: 'remote-6', checksum: 'checksum-6', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-1'); + }); + + test('includes favorites when keepFavorites is false', () async { + await insertLocalAsset( + id: 'local-favorite', + checksum: 'checksum-fav', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: true, + ); + await insertRemoteAsset(id: 'remote-favorite', checksum: 'checksum-fav', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-favorite'); + expect(candidates[0].isFavorite, true); + }); + + test('filters by photos 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.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, + ); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-video'); + expect(candidates[0].type, AssetType.video); + }); + + test('returns both photos and videos with filterType.all', () 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.all); + + expect(candidates.length, 2); + final ids = candidates.map((a) => a.id).toSet(); + expect(ids, containsAll(['local-photo', 'local-video'])); + }); + + test('excludes assets in iOS shared albums', () async { + // Regular album + await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false); + + // iOS shared album + await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true); + + // Asset in regular album (should be included) + await insertLocalAsset( + id: 'local-regular', + checksum: 'checksum-regular', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-regular', checksum: 'checksum-regular', ownerId: userId); + await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-regular'); + + // Asset in iOS shared album (should be excluded) + await insertLocalAsset( + id: 'local-shared', + checksum: 'checksum-shared', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + 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); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-regular'); + }); + + test('includes assets at exact cutoff date', () async { + await insertLocalAsset( + id: 'local-exact', + checksum: 'checksum-exact', + createdAt: cutoffDate, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-exact', checksum: 'checksum-exact', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-exact'); + }); + + test('returns empty list when no assets match criteria', () async { + // Only assets after cutoff + await insertLocalAsset( + id: 'local-after', + checksum: 'checksum-after', + createdAt: afterCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-after', checksum: 'checksum-after', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates, isEmpty); + }); + + test('handles multiple assets with same checksum', () async { + // Two local assets with same checksum (edge case, but should handle it) + await insertLocalAsset( + id: 'local-dup1', + checksum: 'checksum-dup', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertLocalAsset( + id: 'local-dup2', + checksum: 'checksum-dup', + createdAt: beforeCutoff, + type: AssetType.image, + isFavorite: false, + ); + await insertRemoteAsset(id: 'remote-dup', checksum: 'checksum-dup', ownerId: userId); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 2); + expect(candidates.map((a) => a.checksum).toSet(), equals({'checksum-dup'})); + }); + + test('includes assets not in any album', () async { + // 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); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates.length, 1); + expect(candidates[0].id, 'local-no-album'); + }); + + test('excludes asset that is in both regular and iOS shared album', () async { + // Regular album + await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false); + + // iOS shared album + await insertLocalAlbum(id: 'album-shared', name: 'Shared Album', isIosSharedAlbum: true); + + // Asset in BOTH albums - should be excluded because it's in an iOS shared 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-regular', assetId: 'local-both'); + await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-both'); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates, isEmpty); + }); + + test('excludes assets with null checksum (not backed up)', () async { + // Asset with null checksum cannot be matched to remote asset + await db + .into(db.localAssetEntity) + .insert( + LocalAssetEntityCompanion.insert( + id: 'local-null-checksum', + name: 'asset_null.jpg', + checksum: const Value.absent(), // null checksum + type: AssetType.image, + createdAt: Value(beforeCutoff), + updatedAt: Value(beforeCutoff), + isFavorite: const Value(false), + ), + ); + + final candidates = await repository.getRemovalCandidates(userId, cutoffDate); + + expect(candidates, isEmpty); + }); + }); +}