mirror of
https://github.com/immich-app/immich.git
synced 2026-01-23 09:58:56 -08:00
Compare commits
10 Commits
refactor/a
...
exclude-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc52864790 | ||
|
|
9f1a4fd7ac | ||
|
|
9cbc7be726 | ||
|
|
02fd8d8259 | ||
|
|
1560aecdff | ||
|
|
e09ae7dd9f | ||
|
|
d38a6229d8 | ||
|
|
00374f2d6f | ||
|
|
327b784c86 | ||
|
|
a644cc0a3c |
24
i18n/en.json
24
i18n/en.json
@@ -512,6 +512,7 @@
|
||||
"all": "All",
|
||||
"all_albums": "All albums",
|
||||
"all_people": "All people",
|
||||
"all_photos": "All photos",
|
||||
"all_videos": "All videos",
|
||||
"allow_dark_mode": "Allow dark mode",
|
||||
"allow_edits": "Allow edits",
|
||||
@@ -519,6 +520,9 @@
|
||||
"allow_public_user_to_upload": "Allow public user to upload",
|
||||
"allowed": "Allowed",
|
||||
"alt_text_qr_code": "QR code image",
|
||||
"always_keep": "Always keep",
|
||||
"always_keep_photos_hint": "Free Up Space will keep all photos on this device.",
|
||||
"always_keep_videos_hint": "Free Up Space will keep all videos on this device.",
|
||||
"anti_clockwise": "Anti-clockwise",
|
||||
"api_key": "API Key",
|
||||
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
|
||||
@@ -751,13 +755,14 @@
|
||||
"cleanup_confirm_prompt_title": "Remove from this device?",
|
||||
"cleanup_deleted_assets": "Moved {count} assets to device trash",
|
||||
"cleanup_deleting": "Moving to trash...",
|
||||
"cleanup_filter_description": "Choose which types of assets to remove in the cleanup",
|
||||
"cleanup_found_assets": "Found {count} backed up assets",
|
||||
"cleanup_found_assets_with_size": "Found {count} backed up assets ({size})",
|
||||
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
|
||||
"cleanup_no_assets_found": "No backed up assets found matching your criteria",
|
||||
"cleanup_preview_title": "Assets to remove ({count})",
|
||||
"cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options",
|
||||
"cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device",
|
||||
"cleanup_step3_description": "Scan for backed up assets matching your date and keep settings.",
|
||||
"cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device.",
|
||||
"cleanup_storage_savings": "Free up {size} of storage",
|
||||
"cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
|
||||
"clear": "Clear",
|
||||
"clear_all": "Clear all",
|
||||
@@ -1007,6 +1012,7 @@
|
||||
"error_change_sort_album": "Failed to change album sort order",
|
||||
"error_delete_face": "Error deleting face from asset",
|
||||
"error_getting_places": "Error getting places",
|
||||
"error_loading_albums": "Error loading albums",
|
||||
"error_loading_image": "Error loading image",
|
||||
"error_loading_partners": "Error loading partners: {error}",
|
||||
"error_retrieving_asset_information": "Error retrieving asset information",
|
||||
@@ -1189,7 +1195,6 @@
|
||||
"filetype": "Filetype",
|
||||
"filter": "Filter",
|
||||
"filter_description": "Conditions to filter the target assets",
|
||||
"filter_options": "Filter options",
|
||||
"filter_people": "Filter people",
|
||||
"filter_places": "Filter places",
|
||||
"filters": "Filters",
|
||||
@@ -1203,7 +1208,7 @@
|
||||
"forgot_pin_code_question": "Forgot your PIN?",
|
||||
"forward": "Forward",
|
||||
"free_up_space": "Free Up Space",
|
||||
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe",
|
||||
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe.",
|
||||
"free_up_space_settings_subtitle": "Free up device storage",
|
||||
"full_path": "Full path: {path}",
|
||||
"gcast_enabled": "Google Cast",
|
||||
@@ -1320,10 +1325,15 @@
|
||||
"json_editor": "JSON editor",
|
||||
"json_error": "JSON error",
|
||||
"keep": "Keep",
|
||||
"keep_albums": "Keep albums",
|
||||
"keep_albums_count": "Keeping {count} {count, plural, one {album} other {albums}}",
|
||||
"keep_all": "Keep All",
|
||||
"keep_description": "Choose what stays on your device when freeing up space.",
|
||||
"keep_favorites": "Keep favorites",
|
||||
"keep_favorites_description": "Favorite assets will not be deleted from your device",
|
||||
"keep_on_device": "Keep on device",
|
||||
"keep_on_device_hint": "Select items to keep on this device",
|
||||
"keep_this_delete_others": "Keep this, delete others",
|
||||
"keeping": "Keeping: {items}",
|
||||
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
|
||||
"keyboard_shortcuts": "Keyboard shortcuts",
|
||||
"language": "Language",
|
||||
@@ -1553,6 +1563,7 @@
|
||||
"next_memory": "Next memory",
|
||||
"no": "No",
|
||||
"no_actions_added": "No actions added yet",
|
||||
"no_albums_found": "No albums found",
|
||||
"no_albums_message": "Create an album to organize your photos and videos",
|
||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||
"no_albums_yet": "It looks like you do not have any albums yet.",
|
||||
@@ -1582,6 +1593,7 @@
|
||||
"no_results_description": "Try a synonym or more general keyword",
|
||||
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
||||
"no_uploads_in_progress": "No uploads in progress",
|
||||
"none": "None",
|
||||
"not_allowed": "Not allowed",
|
||||
"not_available": "N/A",
|
||||
"not_in_any_album": "Not in any album",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -82,7 +82,13 @@ enum StoreKey<T> {
|
||||
useWifiForUploadPhotos<bool>._(1005),
|
||||
needBetaMigration<bool>._(1006),
|
||||
// TODO: Remove this after patching open-api
|
||||
shouldResetSync<bool>._(1007);
|
||||
shouldResetSync<bool>._(1007),
|
||||
|
||||
// Free up space
|
||||
cleanupKeepFavorites<bool>._(1008),
|
||||
cleanupKeepMediaType<int>._(1009),
|
||||
cleanupKeepAlbumIds<String>._(1010),
|
||||
cleanupCutoffDaysAgo<int>._(1011);
|
||||
|
||||
const StoreKey._(this.id);
|
||||
final int id;
|
||||
|
||||
@@ -11,6 +11,13 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class RemovalCandidatesResult {
|
||||
final List<LocalAsset> assets;
|
||||
final int totalBytes;
|
||||
|
||||
const RemovalCandidatesResult({required this.assets, required this.totalBytes});
|
||||
}
|
||||
|
||||
class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
@@ -130,11 +137,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getRemovalCandidates(
|
||||
Future<RemovalCandidatesResult> getRemovalCandidates(
|
||||
String userId,
|
||||
DateTime cutoffDate, {
|
||||
AssetFilterType filterType = AssetFilterType.all,
|
||||
AssetKeepType keepMediaType = AssetKeepType.none,
|
||||
bool keepFavorites = true,
|
||||
Set<String> keepAlbumIds = const {},
|
||||
}) async {
|
||||
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
@@ -149,6 +157,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
|
||||
final query = _db.localAssetEntity.select().join([
|
||||
innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)),
|
||||
leftOuterJoin(_db.remoteExifEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteExifEntity.assetId)),
|
||||
]);
|
||||
|
||||
Expression<bool> whereClause =
|
||||
@@ -159,10 +168,19 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
// Exclude assets that are in iOS shared albums
|
||||
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets);
|
||||
|
||||
if (filterType == AssetFilterType.photosOnly) {
|
||||
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
|
||||
} else if (filterType == AssetFilterType.videosOnly) {
|
||||
if (keepAlbumIds.isNotEmpty) {
|
||||
final keepAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(_db.localAlbumAssetEntity.albumId.isIn(keepAlbumIds));
|
||||
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(keepAlbumAssets);
|
||||
}
|
||||
|
||||
if (keepMediaType == AssetKeepType.photosOnly) {
|
||||
// Keep photos = delete only videos
|
||||
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video);
|
||||
} else if (keepMediaType == AssetKeepType.videosOnly) {
|
||||
// Keep videos = delete only photos
|
||||
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
|
||||
}
|
||||
|
||||
if (keepFavorites) {
|
||||
@@ -172,7 +190,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
query.where(whereClause);
|
||||
|
||||
final rows = await query.get();
|
||||
return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
|
||||
final assets = rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
|
||||
final totalBytes = rows.fold<int>(0, (sum, row) {
|
||||
final fileSize = row.readTableOrNull(_db.remoteExifEntity)?.fileSize;
|
||||
return sum + (fileSize ?? 0);
|
||||
});
|
||||
|
||||
return RemovalCandidatesResult(assets: assets, totalBytes: totalBytes);
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getEmptyCloudIdAssets() {
|
||||
|
||||
@@ -1,65 +1,126 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/cleanup.service.dart';
|
||||
|
||||
class CleanupState {
|
||||
final DateTime? selectedDate;
|
||||
final List<LocalAsset> assetsToDelete;
|
||||
final int totalBytes;
|
||||
final bool isScanning;
|
||||
final bool isDeleting;
|
||||
final AssetFilterType filterType;
|
||||
final AssetKeepType keepMediaType;
|
||||
final bool keepFavorites;
|
||||
final Set<String> keepAlbumIds;
|
||||
|
||||
const CleanupState({
|
||||
this.selectedDate,
|
||||
this.assetsToDelete = const [],
|
||||
this.totalBytes = 0,
|
||||
this.isScanning = false,
|
||||
this.isDeleting = false,
|
||||
this.filterType = AssetFilterType.all,
|
||||
this.keepMediaType = AssetKeepType.none,
|
||||
this.keepFavorites = true,
|
||||
this.keepAlbumIds = const {},
|
||||
});
|
||||
|
||||
CleanupState copyWith({
|
||||
DateTime? selectedDate,
|
||||
List<LocalAsset>? assetsToDelete,
|
||||
int? totalBytes,
|
||||
bool? isScanning,
|
||||
bool? isDeleting,
|
||||
AssetFilterType? filterType,
|
||||
AssetKeepType? keepMediaType,
|
||||
bool? keepFavorites,
|
||||
Set<String>? keepAlbumIds,
|
||||
}) {
|
||||
return CleanupState(
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
assetsToDelete: assetsToDelete ?? this.assetsToDelete,
|
||||
totalBytes: totalBytes ?? this.totalBytes,
|
||||
isScanning: isScanning ?? this.isScanning,
|
||||
isDeleting: isDeleting ?? this.isDeleting,
|
||||
filterType: filterType ?? this.filterType,
|
||||
keepMediaType: keepMediaType ?? this.keepMediaType,
|
||||
keepFavorites: keepFavorites ?? this.keepFavorites,
|
||||
keepAlbumIds: keepAlbumIds ?? this.keepAlbumIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final cleanupProvider = StateNotifierProvider<CleanupNotifier, CleanupState>((ref) {
|
||||
return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id);
|
||||
return CleanupNotifier(
|
||||
ref.watch(cleanupServiceProvider),
|
||||
ref.watch(currentUserProvider)?.id,
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class CleanupNotifier extends StateNotifier<CleanupState> {
|
||||
final CleanupService _cleanupService;
|
||||
final String? _userId;
|
||||
final AppSettingsService _appSettingsService;
|
||||
|
||||
CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState());
|
||||
CleanupNotifier(this._cleanupService, this._userId, this._appSettingsService) : super(const CleanupState()) {
|
||||
_loadPersistedSettings();
|
||||
}
|
||||
|
||||
void _loadPersistedSettings() {
|
||||
final keepFavorites = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepFavorites);
|
||||
final keepMediaTypeIndex = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepMediaType);
|
||||
final keepAlbumIdsString = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepAlbumIds);
|
||||
final cutoffDaysAgo = _appSettingsService.getSetting(AppSettingsEnum.cleanupCutoffDaysAgo);
|
||||
|
||||
final keepMediaType = AssetKeepType.values[keepMediaTypeIndex.clamp(0, AssetKeepType.values.length - 1)];
|
||||
final keepAlbumIds = keepAlbumIdsString.isEmpty ? <String>{} : keepAlbumIdsString.split(',').toSet();
|
||||
final selectedDate = cutoffDaysAgo >= 0 ? DateTime.now().subtract(Duration(days: cutoffDaysAgo)) : null;
|
||||
|
||||
state = state.copyWith(
|
||||
keepFavorites: keepFavorites,
|
||||
keepMediaType: keepMediaType,
|
||||
keepAlbumIds: keepAlbumIds,
|
||||
selectedDate: selectedDate,
|
||||
);
|
||||
}
|
||||
|
||||
void setSelectedDate(DateTime? date) {
|
||||
state = state.copyWith(selectedDate: date, assetsToDelete: []);
|
||||
if (date != null) {
|
||||
final daysAgo = DateTime.now().difference(date).inDays;
|
||||
_appSettingsService.setSetting(AppSettingsEnum.cleanupCutoffDaysAgo, daysAgo);
|
||||
}
|
||||
}
|
||||
|
||||
void setFilterType(AssetFilterType filterType) {
|
||||
state = state.copyWith(filterType: filterType, assetsToDelete: []);
|
||||
void setKeepMediaType(AssetKeepType keepMediaType) {
|
||||
state = state.copyWith(keepMediaType: keepMediaType, assetsToDelete: []);
|
||||
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepMediaType, keepMediaType.index);
|
||||
}
|
||||
|
||||
void setKeepFavorites(bool keepFavorites) {
|
||||
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
|
||||
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepFavorites, keepFavorites);
|
||||
}
|
||||
|
||||
void toggleKeepAlbum(String albumId) {
|
||||
final newKeepAlbumIds = Set<String>.from(state.keepAlbumIds);
|
||||
if (newKeepAlbumIds.contains(albumId)) {
|
||||
newKeepAlbumIds.remove(albumId);
|
||||
} else {
|
||||
newKeepAlbumIds.add(albumId);
|
||||
}
|
||||
state = state.copyWith(keepAlbumIds: newKeepAlbumIds, assetsToDelete: []);
|
||||
_persistExcludedAlbumIds(newKeepAlbumIds);
|
||||
}
|
||||
|
||||
void setExcludedAlbumIds(Set<String> albumIds) {
|
||||
state = state.copyWith(keepAlbumIds: albumIds, assetsToDelete: []);
|
||||
_persistExcludedAlbumIds(albumIds);
|
||||
}
|
||||
|
||||
void _persistExcludedAlbumIds(Set<String> albumIds) {
|
||||
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepAlbumIds, albumIds.join(','));
|
||||
}
|
||||
|
||||
Future<void> scanAssets() async {
|
||||
@@ -69,13 +130,15 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
|
||||
|
||||
state = state.copyWith(isScanning: true);
|
||||
try {
|
||||
final assets = await _cleanupService.getRemovalCandidates(
|
||||
final result = await _cleanupService.getRemovalCandidates(
|
||||
_userId,
|
||||
state.selectedDate!,
|
||||
filterType: state.filterType,
|
||||
keepMediaType: state.keepMediaType,
|
||||
keepFavorites: state.keepFavorites,
|
||||
keepAlbumIds: state.keepAlbumIds,
|
||||
);
|
||||
state = state.copyWith(assetsToDelete: assets, isScanning: false);
|
||||
|
||||
state = state.copyWith(assetsToDelete: result.assets, totalBytes: result.totalBytes, isScanning: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isScanning: false);
|
||||
rethrow;
|
||||
@@ -101,6 +164,7 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const CleanupState();
|
||||
// Only reset transient state, keep the persisted filter settings
|
||||
state = state.copyWith(selectedDate: null, assetsToDelete: [], isScanning: false, isDeleting: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,11 @@ enum AppSettingsEnum<T> {
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
|
||||
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
|
||||
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
|
||||
cleanupKeepFavorites<bool>(StoreKey.cleanupKeepFavorites, null, true),
|
||||
cleanupKeepMediaType<int>(StoreKey.cleanupKeepMediaType, null, 0),
|
||||
cleanupKeepAlbumIds<String>(StoreKey.cleanupKeepAlbumIds, null, ""),
|
||||
cleanupCutoffDaysAgo<int>(StoreKey.cleanupCutoffDaysAgo, null, -1);
|
||||
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
@@ -15,17 +14,19 @@ class CleanupService {
|
||||
|
||||
const CleanupService(this._localAssetRepository, this._assetMediaRepository);
|
||||
|
||||
Future<List<LocalAsset>> getRemovalCandidates(
|
||||
Future<RemovalCandidatesResult> getRemovalCandidates(
|
||||
String userId,
|
||||
DateTime cutoffDate, {
|
||||
AssetFilterType filterType = AssetFilterType.all,
|
||||
AssetKeepType keepMediaType = AssetKeepType.none,
|
||||
bool keepFavorites = true,
|
||||
Set<String> keepAlbumIds = const {},
|
||||
}) {
|
||||
return _localAssetRepository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
filterType: filterType,
|
||||
keepMediaType: keepMediaType,
|
||||
keepFavorites: keepFavorites,
|
||||
keepAlbumIds: keepAlbumIds,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -3,13 +3,16 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/cleanup.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
|
||||
class FreeUpSpaceSettings extends ConsumerStatefulWidget {
|
||||
const FreeUpSpaceSettings({super.key});
|
||||
@@ -21,6 +24,7 @@ class FreeUpSpaceSettings extends ConsumerStatefulWidget {
|
||||
class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
CleanupStep _currentStep = CleanupStep.selectDate;
|
||||
bool _hasScanned = false;
|
||||
bool _isKeepSettingsExpanded = false;
|
||||
|
||||
void _resetState() {
|
||||
ref.read(cleanupProvider.notifier).reset();
|
||||
@@ -35,17 +39,12 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
}
|
||||
|
||||
if (state.selectedDate != null) {
|
||||
return CleanupStep.filterOptions;
|
||||
return CleanupStep.scan;
|
||||
}
|
||||
|
||||
return CleanupStep.selectDate;
|
||||
}
|
||||
|
||||
void _goToFiltersStep() {
|
||||
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
|
||||
setState(() => _currentStep = CleanupStep.filterOptions);
|
||||
}
|
||||
|
||||
void _goToScanStep() {
|
||||
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
|
||||
setState(() => _currentStep = CleanupStep.scan);
|
||||
@@ -83,9 +82,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
|
||||
if (picked != null) {
|
||||
ref.read(cleanupProvider.notifier).setSelectedDate(picked);
|
||||
setState(() => _hasScanned = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onKeepSettingsChanged() {
|
||||
setState(() {
|
||||
_hasScanned = false;
|
||||
_currentStep = CleanupStep.scan;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _scanAssets() async {
|
||||
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
|
||||
|
||||
@@ -145,6 +152,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
final subtitleStyle = context.textTheme.bodyMedium!.copyWith(
|
||||
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
|
||||
);
|
||||
|
||||
StepStyle styleForState(StepState stepState, {bool isDestructive = false}) {
|
||||
switch (stepState) {
|
||||
case StepState.complete:
|
||||
@@ -174,28 +182,38 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
}
|
||||
|
||||
final step1State = hasDate ? StepState.complete : StepState.indexed;
|
||||
final step2State = hasDate ? StepState.complete : StepState.disabled;
|
||||
final step3State = hasAssets
|
||||
final step2State = hasAssets
|
||||
? StepState.complete
|
||||
: hasDate
|
||||
? StepState.indexed
|
||||
: StepState.disabled;
|
||||
final step4State = hasAssets ? StepState.indexed : StepState.disabled;
|
||||
final step3State = hasAssets ? StepState.indexed : StepState.disabled;
|
||||
|
||||
String getFilterSubtitle() {
|
||||
final hasKeepSettings =
|
||||
state.keepFavorites || state.keepAlbumIds.isNotEmpty || state.keepMediaType != AssetKeepType.none;
|
||||
|
||||
String getKeepSettingsSummary() {
|
||||
final parts = <String>[];
|
||||
switch (state.filterType) {
|
||||
case AssetFilterType.all:
|
||||
parts.add('all'.t(context: context));
|
||||
case AssetFilterType.photosOnly:
|
||||
parts.add('photos_only'.t(context: context));
|
||||
case AssetFilterType.videosOnly:
|
||||
parts.add('videos_only'.t(context: context));
|
||||
|
||||
if (state.keepMediaType == AssetKeepType.photosOnly) {
|
||||
parts.add('all_photos'.t(context: context));
|
||||
} else if (state.keepMediaType == AssetKeepType.videosOnly) {
|
||||
parts.add('all_videos'.t(context: context));
|
||||
}
|
||||
|
||||
if (state.keepFavorites) {
|
||||
parts.add('keep_favorites'.t(context: context));
|
||||
parts.add('favorites'.t(context: context).toLowerCase());
|
||||
}
|
||||
return parts.join(' • ');
|
||||
|
||||
if (state.keepAlbumIds.isNotEmpty) {
|
||||
parts.add('keep_albums_count'.t(context: context, args: {'count': state.keepAlbumIds.length.toString()}));
|
||||
}
|
||||
|
||||
if (parts.isEmpty) {
|
||||
return 'none'.t(context: context);
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
@@ -220,6 +238,125 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
),
|
||||
),
|
||||
|
||||
// Keep on device settings card
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(
|
||||
color: hasKeepSettings
|
||||
? context.colorScheme.primary.withValues(alpha: 0.5)
|
||||
: context.colorScheme.outlineVariant,
|
||||
width: hasKeepSettings ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
color: hasKeepSettings
|
||||
? context.colorScheme.primaryContainer.withValues(alpha: 0.15)
|
||||
: context.colorScheme.surfaceContainerLow,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: _isKeepSettingsExpanded,
|
||||
onExpansionChanged: (expanded) {
|
||||
setState(() => _isKeepSettingsExpanded = expanded);
|
||||
},
|
||||
leading: Icon(
|
||||
hasKeepSettings ? Icons.bookmark : Icons.bookmark_border,
|
||||
color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Text(
|
||||
'keep_on_device'.t(context: context),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: hasKeepSettings ? context.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
hasKeepSettings
|
||||
? 'keeping'.t(context: context, args: {'items': getKeepSettingsSummary()})
|
||||
: 'keep_on_device_hint'.t(context: context),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('keep_description'.t(context: context), style: subtitleStyle),
|
||||
const SizedBox(height: 4),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
'keep_favorites'.t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
|
||||
value: state.keepFavorites,
|
||||
onChanged: (value) {
|
||||
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
|
||||
_onKeepSettingsChanged();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_KeepAlbumsSection(
|
||||
albumIds: state.keepAlbumIds,
|
||||
onAlbumToggled: (albumId) {
|
||||
ref.read(cleanupProvider.notifier).toggleKeepAlbum(albumId);
|
||||
_onKeepSettingsChanged();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'always_keep'.t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SegmentedButton<AssetKeepType>(
|
||||
segments: [
|
||||
const ButtonSegment(value: AssetKeepType.none, label: Text('—')),
|
||||
ButtonSegment(
|
||||
value: AssetKeepType.photosOnly,
|
||||
label: Text('photos'.t(context: context)),
|
||||
icon: const Icon(Icons.photo),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AssetKeepType.videosOnly,
|
||||
label: Text('videos'.t(context: context)),
|
||||
icon: const Icon(Icons.videocam),
|
||||
),
|
||||
],
|
||||
selected: {state.keepMediaType},
|
||||
onSelectionChanged: (selection) {
|
||||
ref.read(cleanupProvider.notifier).setKeepMediaType(selection.first);
|
||||
_onKeepSettingsChanged();
|
||||
},
|
||||
),
|
||||
if (state.keepMediaType != AssetKeepType.none) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.keepMediaType == AssetKeepType.photosOnly
|
||||
? 'always_keep_photos_hint'.t(context: context)
|
||||
: 'always_keep_videos_hint'.t(context: context),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Stepper(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
currentStep: _currentStep.index,
|
||||
@@ -314,7 +451,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: hasDate ? () => _goToFiltersStep() : null,
|
||||
onPressed: hasDate ? _goToScanStep : null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: Text('continue'.t(context: context)),
|
||||
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
|
||||
@@ -325,11 +462,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
state: step1State,
|
||||
),
|
||||
|
||||
// Step 2: Select Filter Options
|
||||
// Step 2: Scan Assets
|
||||
Step(
|
||||
stepStyle: styleForState(step2State),
|
||||
title: Text(
|
||||
'filter_options'.t(context: context),
|
||||
'scan'.t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: step2State == StepState.complete
|
||||
@@ -339,96 +476,20 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
: context.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: hasDate
|
||||
? Text(
|
||||
getFilterSubtitle(),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('cleanup_filter_description'.t(context: context), style: subtitleStyle),
|
||||
const SizedBox(height: 16),
|
||||
SegmentedButton<AssetFilterType>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: AssetFilterType.all,
|
||||
label: Text('all'.t(context: context)),
|
||||
icon: const Icon(Icons.photo_library),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AssetFilterType.photosOnly,
|
||||
label: Text('photos'.t(context: context)),
|
||||
icon: const Icon(Icons.photo),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AssetFilterType.videosOnly,
|
||||
label: Text('videos'.t(context: context)),
|
||||
icon: const Icon(Icons.videocam),
|
||||
),
|
||||
],
|
||||
selected: {state.filterType},
|
||||
onSelectionChanged: (selection) {
|
||||
ref.read(cleanupProvider.notifier).setFilterType(selection.first);
|
||||
setState(() => _hasScanned = false);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
'keep_favorites'.t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
subtitle: Text(
|
||||
'keep_favorites_description'.t(context: context),
|
||||
style: context.textTheme.bodyMedium!.copyWith(
|
||||
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
|
||||
),
|
||||
),
|
||||
value: state.keepFavorites,
|
||||
onChanged: (value) {
|
||||
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
|
||||
setState(() => _hasScanned = false);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _goToScanStep,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: Text('continue'.t(context: context)),
|
||||
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
|
||||
),
|
||||
],
|
||||
),
|
||||
isActive: hasDate,
|
||||
state: step2State,
|
||||
),
|
||||
|
||||
// Step 3: Scan Assets
|
||||
Step(
|
||||
stepStyle: styleForState(step3State),
|
||||
title: Text(
|
||||
'scan'.t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: step3State == StepState.complete
|
||||
? context.colorScheme.primary
|
||||
: step3State == StepState.disabled
|
||||
? context.colorScheme.onSurface.withValues(alpha: 0.38)
|
||||
: context.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: _hasScanned
|
||||
? Text(
|
||||
'cleanup_found_assets'.t(
|
||||
context: context,
|
||||
args: {'count': state.assetsToDelete.length.toString()},
|
||||
),
|
||||
state.totalBytes > 0
|
||||
? 'cleanup_found_assets_with_size'.t(
|
||||
context: context,
|
||||
args: {
|
||||
'count': state.assetsToDelete.length.toString(),
|
||||
'size': formatBytes(state.totalBytes),
|
||||
},
|
||||
)
|
||||
: 'cleanup_found_assets'.t(
|
||||
context: context,
|
||||
args: {'count': state.assetsToDelete.length.toString()},
|
||||
),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: state.assetsToDelete.isNotEmpty
|
||||
? context.colorScheme.primary
|
||||
@@ -503,17 +564,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
],
|
||||
),
|
||||
isActive: hasDate,
|
||||
state: step3State,
|
||||
state: step2State,
|
||||
),
|
||||
|
||||
// Step 4: Delete Assets
|
||||
// Step 3: Delete Assets
|
||||
Step(
|
||||
stepStyle: styleForState(step4State, isDestructive: true),
|
||||
stepStyle: styleForState(step3State, isDestructive: true),
|
||||
title: Text(
|
||||
'move_to_device_trash'.t(context: context),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: step4State == StepState.disabled
|
||||
color: step3State == StepState.disabled
|
||||
? context.colorScheme.onSurface.withValues(alpha: 0.38)
|
||||
: context.colorScheme.error,
|
||||
),
|
||||
@@ -529,18 +590,33 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: hasAssets
|
||||
? Text(
|
||||
'cleanup_step4_summary'.t(
|
||||
context: context,
|
||||
args: {
|
||||
'count': state.assetsToDelete.length.toString(),
|
||||
'date': DateFormat.yMMMd().format(state.selectedDate!),
|
||||
},
|
||||
),
|
||||
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'cleanup_step4_summary'.t(
|
||||
context: context,
|
||||
args: {
|
||||
'count': state.assetsToDelete.length.toString(),
|
||||
'date': DateFormat.yMMMd().format(state.selectedDate!),
|
||||
},
|
||||
),
|
||||
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (state.totalBytes > 0) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'cleanup_storage_savings'.t(context: context, args: {'size': formatBytes(state.totalBytes)}),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showAssetsPreview(state.assetsToDelete),
|
||||
@@ -573,7 +649,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
],
|
||||
),
|
||||
isActive: hasAssets,
|
||||
state: step4State,
|
||||
state: step3State,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -701,3 +777,107 @@ class _DatePresetCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeepAlbumsSection extends ConsumerWidget {
|
||||
final Set<String> albumIds;
|
||||
final ValueChanged<String> onAlbumToggled;
|
||||
|
||||
const _KeepAlbumsSection({required this.albumIds, required this.onAlbumToggled});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albumsAsync = ref.watch(localAlbumProvider);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'keep_albums'.t(context: context),
|
||||
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
albumsAsync.when(
|
||||
loading: () => const Center(
|
||||
child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
error: (error, stack) => Text(
|
||||
'error_loading_albums'.t(context: context),
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
|
||||
),
|
||||
data: (albums) {
|
||||
if (albums.isEmpty) {
|
||||
return Text(
|
||||
'no_albums_found'.t(context: context),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: context.colorScheme.outlineVariant),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index];
|
||||
final isSelected = albumIds.contains(album.id);
|
||||
return _AlbumTile(album: album, isSelected: isSelected, onToggle: () => onAlbumToggled(album.id));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (albumIds.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'keep_albums_count'.t(context: context, args: {'count': albumIds.length.toString()}),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumTile extends StatelessWidget {
|
||||
final LocalAlbum album;
|
||||
final bool isSelected;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
const _AlbumTile({required this.album, required this.isSelected, required this.onToggle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
|
||||
leading: Icon(
|
||||
isSelected ? Icons.check_circle : Icons.circle_outlined,
|
||||
color: isSelected ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(
|
||||
album.name,
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: isSelected ? context.colorScheme.primary : null),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
album.assetCount.toString(),
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
onTap: onToggle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user