diff --git a/i18n/en.json b/i18n/en.json index 8be82ae453..d0e66ab830 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -514,6 +514,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", @@ -521,6 +522,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.", @@ -753,13 +757,13 @@ "cleanup_confirm_prompt_title": "Remove from this device?", "cleanup_deleted_assets": "Moved {count} assets to device trash", "cleanup_deleting": "Moving to trash...", - "cleanup_filter_description": "Choose which types of assets to remove in the cleanup", "cleanup_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_no_assets_found": "No assets found matching the criteria above. Free Up Space can only remove assets that have been backed up to the server", "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}) to remove from your local device. Photos will remain accessible from the Immich app.", "cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash", "clear": "Clear", "clear_all": "Clear all", @@ -857,7 +861,7 @@ "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_date_description": "Keep photos from the last…", "cutoff_day": "{count, plural, one {day} other {days}}", "cutoff_year": "{count, plural, one {year} other {years}}", "daily_title_text_date": "E, MMM dd", @@ -1009,6 +1013,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", @@ -1191,7 +1196,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", @@ -1205,7 +1209,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", @@ -1322,10 +1326,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", @@ -1555,6 +1564,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.", @@ -1584,6 +1594,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", diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index c4505137d2..26c223afad 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -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 } diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index a18644cd2a..eaf9593f0b 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -82,7 +82,14 @@ enum StoreKey { useWifiForUploadPhotos._(1005), needBetaMigration._(1006), // TODO: Remove this after patching open-api - shouldResetSync._(1007); + shouldResetSync._(1007), + + // Free up space + cleanupKeepFavorites._(1008), + cleanupKeepMediaType._(1009), + cleanupKeepAlbumIds._(1010), + cleanupCutoffDaysAgo._(1011), + cleanupDefaultsInitialized._(1012); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 6a9181e604..7b2be63b37 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -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 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> getRemovalCandidates( + Future getRemovalCandidates( String userId, DateTime cutoffDate, { - AssetFilterType filterType = AssetFilterType.all, + AssetKeepType keepMediaType = AssetKeepType.none, bool keepFavorites = true, + Set 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 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(0, (sum, row) { + final fileSize = row.readTableOrNull(_db.remoteExifEntity)?.fileSize; + return sum + (fileSize ?? 0); + }); + + return RemovalCandidatesResult(assets: assets, totalBytes: totalBytes); } Future> getEmptyCloudIdAssets() { diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 22bc893cac..e8f5eb2ee2 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -142,7 +142,7 @@ class SettingsSubPage extends StatelessWidget { context.locale; return Scaffold( appBar: AppBar(centerTitle: false, title: Text(section.title).tr()), - body: Padding(padding: const EdgeInsets.only(bottom: 60.0), child: section.widget), + body: section.widget, ); } } diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart index 5b3b152f34..4d0bdba301 100644 --- a/mobile/lib/providers/cleanup.provider.dart +++ b/mobile/lib/providers/cleanup.provider.dart @@ -1,65 +1,150 @@ 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 assetsToDelete; + final int totalBytes; final bool isScanning; final bool isDeleting; - final AssetFilterType filterType; + final AssetKeepType keepMediaType; final bool keepFavorites; + final Set 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? assetsToDelete, + int? totalBytes, bool? isScanning, bool? isDeleting, - AssetFilterType? filterType, + AssetKeepType? keepMediaType, bool? keepFavorites, + Set? 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((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 { 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 ? {} : 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.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 albumIds) { + state = state.copyWith(keepAlbumIds: albumIds, assetsToDelete: []); + _persistExcludedAlbumIds(albumIds); + } + + void _persistExcludedAlbumIds(Set albumIds) { + _appSettingsService.setSetting(AppSettingsEnum.cleanupKeepAlbumIds, albumIds.join(',')); + } + + void cleanupStaleAlbumIds(Set existingAlbumIds) { + final staleIds = state.keepAlbumIds.difference(existingAlbumIds); + if (staleIds.isNotEmpty) { + final cleanedIds = state.keepAlbumIds.intersection(existingAlbumIds); + state = state.copyWith(keepAlbumIds: cleanedIds); + _persistExcludedAlbumIds(cleanedIds); + } + } + + void applyDefaultAlbumSelections(List<(String id, String name)> albums) { + final isInitialized = _appSettingsService.getSetting(AppSettingsEnum.cleanupDefaultsInitialized); + if (isInitialized) return; + + final toKeep = _cleanupService.getDefaultKeepAlbumIds(albums); + + if (toKeep.isNotEmpty) { + final keepAlbumIds = {...state.keepAlbumIds, ...toKeep}; + state = state.copyWith(keepAlbumIds: keepAlbumIds); + _persistExcludedAlbumIds(keepAlbumIds); + } + + _appSettingsService.setSetting(AppSettingsEnum.cleanupDefaultsInitialized, true); } Future scanAssets() async { @@ -69,13 +154,15 @@ class CleanupNotifier extends StateNotifier { 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 +188,7 @@ class CleanupNotifier extends StateNotifier { } void reset() { - state = const CleanupState(); + // Only reset transient state, keep the persisted filter settings + state = state.copyWith(selectedDate: null, assetsToDelete: [], isScanning: false, isDeleting: false); } } diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index aa247682a7..4e740ebfe5 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -54,7 +54,12 @@ enum AppSettingsEnum { readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false), albumGridView(StoreKey.albumGridView, "albumGridView", false), backupRequireCharging(StoreKey.backupRequireCharging, null, false), - backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30); + backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30), + cleanupKeepFavorites(StoreKey.cleanupKeepFavorites, null, true), + cleanupKeepMediaType(StoreKey.cleanupKeepMediaType, null, 0), + cleanupKeepAlbumIds(StoreKey.cleanupKeepAlbumIds, null, ""), + cleanupCutoffDaysAgo(StoreKey.cleanupCutoffDaysAgo, null, -1), + cleanupDefaultsInitialized(StoreKey.cleanupDefaultsInitialized, null, false); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart index 6a4318d209..86ccac8067 100644 --- a/mobile/lib/services/cleanup.service.dart +++ b/mobile/lib/services/cleanup.service.dart @@ -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> getRemovalCandidates( + Future getRemovalCandidates( String userId, DateTime cutoffDate, { - AssetFilterType filterType = AssetFilterType.all, + AssetKeepType keepMediaType = AssetKeepType.none, bool keepFavorites = true, + Set keepAlbumIds = const {}, }) { return _localAssetRepository.getRemovalCandidates( userId, cutoffDate, - filterType: filterType, + keepMediaType: keepMediaType, keepFavorites: keepFavorites, + keepAlbumIds: keepAlbumIds, ); } @@ -42,4 +43,18 @@ class CleanupService { return 0; } + + /// Returns album IDs that should be kept by default (e.g., messaging app albums) + Set getDefaultKeepAlbumIds(List<(String id, String name)> albums) { + const messagingApps = ['whatsapp', 'telegram', 'signal', 'messenger', 'viber', 'wechat', 'line']; + + final toKeep = {}; + for (final (id, name) in albums) { + final albumName = name.toLowerCase(); + if (messagingApps.any((app) => albumName.contains(app))) { + toKeep.add(id); + } + } + return toKeep; + } } diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 53fc32ddb3..58c73a77b8 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -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(), diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index aee28c9449..b8de27fd44 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -153,6 +153,7 @@ class AdvancedSettings extends HookConsumerWidget { ); }, ), + const SizedBox(height: 60), ]; return SettingsSubPageScaffold(settings: advancedSettings); diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart index e24a4d481a..fbd6a08736 100644 --- a/mobile/lib/widgets/settings/free_up_space_settings.dart +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -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,25 @@ class FreeUpSpaceSettings extends ConsumerStatefulWidget { class _FreeUpSpaceSettingsState extends ConsumerState { CleanupStep _currentStep = CleanupStep.selectDate; bool _hasScanned = false; + bool _isKeepSettingsExpanded = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _initializeAlbumDefaults(); + }); + } + + Future _initializeAlbumDefaults() async { + final albums = await ref.read(localAlbumProvider.future); + final existingAlbumIds = albums.map((a) => a.id).toSet(); + final albumsWithNames = albums.map((a) => (a.id, a.name)).toList(); + + final notifier = ref.read(cleanupProvider.notifier); + notifier.applyDefaultAlbumSelections(albumsWithNames); + notifier.cleanupStaleAlbumIds(existingAlbumIds); + } void _resetState() { ref.read(cleanupProvider.notifier).reset(); @@ -35,20 +57,16 @@ class _FreeUpSpaceSettingsState extends ConsumerState { } 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); + _scanAssets(); } void _setPresetDate(int daysAgo) { @@ -83,9 +101,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState { if (picked != null) { ref.read(cleanupProvider.notifier).setSelectedDate(picked); + setState(() => _hasScanned = false); } } + void _onKeepSettingsChanged() { + setState(() { + _hasScanned = false; + _currentStep = CleanupStep.scan; + }); + } + Future _scanAssets() async { ref.read(hapticFeedbackProvider.notifier).mediumImpact(); @@ -127,6 +153,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState { context: context, builder: (ctx) => _DeleteSuccessDialog(deletedCount: deletedCount), ); + + if (mounted) { + context.router.popUntilRoot(); + } + return; } setState(() => _currentStep = CleanupStep.selectDate); @@ -145,6 +176,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { 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 +206,38 @@ class _FreeUpSpaceSettingsState extends ConsumerState { } 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 = []; - 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 +262,126 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ), ), + // 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( + showSelectedIcon: false, + 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 +476,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ), 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 +487,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState { 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 +501,20 @@ class _FreeUpSpaceSettingsState extends ConsumerState { : 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( - 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 +589,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ], ), 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,15 +615,20 @@ class _FreeUpSpaceSettingsState extends ConsumerState { 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, ), @@ -573,10 +664,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ], ), isActive: hasAssets, - state: step4State, + state: step3State, ), ], ), + const SizedBox(height: 60), ], ), ), @@ -701,3 +793,107 @@ class _DatePresetCard extends StatelessWidget { ); } } + +class _KeepAlbumsSection extends ConsumerWidget { + final Set albumIds; + final ValueChanged 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, + ); + } +} diff --git a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart index 0d686fbc09..5989058174 100644 --- a/mobile/test/infrastructure/repositories/local_asset_repository_test.dart +++ b/mobile/test/infrastructure/repositories/local_asset_repository_test.dart @@ -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'); }); }); } diff --git a/web/src/lib/managers/upload-manager.svelte.ts b/web/src/lib/managers/upload-manager.svelte.ts index 1b5b73ecd9..dd8e7c9076 100644 --- a/web/src/lib/managers/upload-manager.svelte.ts +++ b/web/src/lib/managers/upload-manager.svelte.ts @@ -6,8 +6,10 @@ class UploadManager { mediaTypes = $state({ image: [], sidecar: [], video: [] }); constructor() { - eventManager.on('AppInit', () => this.#loadExtensions()); - eventManager.on('AuthLogout', () => this.reset()); + eventManager.onMany([ + ['AppInit', () => this.#loadExtensions()], + ['AuthLogout', () => this.reset()], + ]); } reset() { diff --git a/web/src/lib/modals/AlbumAddUsersModal.svelte b/web/src/lib/modals/AlbumAddUsersModal.svelte index 724317ac47..ede6a66b74 100644 --- a/web/src/lib/modals/AlbumAddUsersModal.svelte +++ b/web/src/lib/modals/AlbumAddUsersModal.svelte @@ -2,7 +2,7 @@ import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; import { handleAddUsersToAlbum } from '$lib/services/album.service'; import { searchUsers, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk'; - import { FormModal, ListButton, Stack, Text } from '@immich/ui'; + import { FormModal, ListButton, LoadingSpinner, Stack, Text } from '@immich/ui'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteMap } from 'svelte/reactivity'; @@ -18,6 +18,7 @@ const excludedUserIds = $derived([album.ownerId, ...album.albumUsers.map(({ user: { id } }) => id)]); const filteredUsers = $derived(users.filter(({ id }) => !excludedUserIds.includes(id))); const selectedUsers = new SvelteMap(); + let loading = $state(true); const handleToggle = (user: UserResponseDto) => { if (selectedUsers.has(user.id)) { @@ -36,6 +37,7 @@ onMount(async () => { users = await searchUsers(); + loading = false; }); @@ -47,17 +49,23 @@ disabled={selectedUsers.size === 0} {onClose} > - - {#each filteredUsers as user (user.id)} - handleToggle(user)}> - -
- {user.name} - {user.email} -
-
- {:else} - {$t('album_share_no_users')} - {/each} -
+ {#if loading} +
+ +
+ {:else} + + {#each filteredUsers as user (user.id)} + handleToggle(user)}> + +
+ {user.name} + {user.email} +
+
+ {:else} + {$t('album_share_no_users')} + {/each} +
+ {/if} diff --git a/web/src/lib/modals/PartnerSelectionModal.svelte b/web/src/lib/modals/PartnerSelectionModal.svelte index 65e263918f..cc29e73af1 100644 --- a/web/src/lib/modals/PartnerSelectionModal.svelte +++ b/web/src/lib/modals/PartnerSelectionModal.svelte @@ -1,8 +1,7 @@