diff --git a/i18n/en.json b/i18n/en.json index 521eac10b1..fa997ee0b5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -622,6 +622,7 @@ "backup_controller_page_background_charging": "Only while charging", "backup_controller_page_background_configure_error": "Failed to configure the background service", "backup_controller_page_background_delay": "Delay new assets backup: {duration}", + "backup_controller_page_background_delay_title": "Backup trigger delay", "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", "backup_controller_page_background_is_off": "Automatic background backup is off", "backup_controller_page_background_is_on": "Automatic background backup is on", @@ -653,6 +654,7 @@ "backup_controller_page_uploading_file_info": "Uploading file info", "backup_err_only_album": "Cannot remove the only album", "backup_error_sync_failed": "Sync failed. Cannot process backup.", + "backup_from_date_info": "Only backing up assets from {date} onwards", "backup_info_card_assets": "assets", "backup_manual_cancelled": "Cancelled", "backup_manual_in_progress": "Upload already in progress. Try after sometime", @@ -704,6 +706,7 @@ "cannot_update_the_description": "Cannot update the description", "cast": "Cast", "cast_description": "Configure available cast destinations", + "change": "Change", "change_date": "Change date", "change_description": "Change description", "change_display_order": "Change display order", @@ -734,6 +737,16 @@ "checksum": "Checksum", "choose_matching_people_to_merge": "Choose matching people to merge", "city": "City", + "cleanup": "Cleanup", + "cleanup_confirm_description": "You are about to move {count} assets to your device's trash. These assets were created before {date} and are safely backed up on the server. To reclaim storage space, empty your device's trash after this operation", + "cleanup_deleted_assets": "Moved {count} assets to device trash", + "cleanup_deleting": "Moving to trash...", + "cleanup_description": "Move backed up assets to your device's trash to free up storage. To fully reclaim space, empty your device's trash afterwards. This will not affect files on the server", + "cleanup_found_assets": "Found {count} backed up assets", + "cleanup_no_assets_found": "No backed up assets found before this date", + "cleanup_settings_subtitle": "Free up device storage", + "cleanup_step2_description": "Scan your device for assets that have been backed up to the server", + "cleanup_step3_summary": "{count} assets created before {date} will be moved to your device's trash", "clear": "Clear", "clear_all": "Clear all", "clear_all_recent_searches": "Clear all recent searches", @@ -1448,6 +1461,7 @@ "move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_locked_folder": "Move to locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", + "move_to_trash": "Move to Trash", "move_up": "Move up", "moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive", "moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library", @@ -1805,9 +1819,11 @@ "saved_settings": "Saved settings", "say_something": "Say something", "scaffold_body_error_occurred": "Error occurred", + "scan": "Scan", "scan_all_libraries": "Scan All Libraries", "scan_library": "Scan", "scan_settings": "Scan Settings", + "scanning": "Scanning", "scanning_for_album": "Scanning for album...", "search": "Search", "search_albums": "Search albums", @@ -1879,6 +1895,7 @@ "select_all_in": "Select all in {group}", "select_avatar_color": "Select avatar color", "select_count": "{count, plural, one {Select #} other {Select #}}", + "select_cutoff_date": "Select cutoff date", "select_face": "Select face", "select_featured_photo": "Select featured photo", "select_from_computer": "Select from computer", @@ -2197,11 +2214,15 @@ "upload": "Upload", "upload_action_prompt": "{count} queued for upload", "upload_concurrency": "Upload concurrency", + "upload_date_filter": "Date Filter", "upload_details": "Upload Details", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_title": "Upload Asset", "upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.", "upload_finished": "Upload finished", + "upload_from_date": "Upload from date", + "upload_from_date_description": "Only upload assets created on or after this date", + "upload_from_date_none": "All dates", "upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}", "upload_skipped_duplicates": "Skipped {count, plural, one {# duplicate asset} other {# duplicate assets}}", "upload_status_duplicates": "Duplicates", diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 96630f1eba..e866a965c4 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -79,6 +79,9 @@ class TimelineFactory { TimelineService fromAssets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssets(assets, type)); + TimelineService fromAssetsWithBuckets(List assets, TimelineOrigin type) => + TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); + TimelineService map(String userId, LatLngBounds bounds) => TimelineService(_timelineRepository.map(userId, bounds, groupBy)); } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 4d30e09716..f7b6f84484 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -126,4 +126,18 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { } return result; } + + Future> getRemovalCandidates(String userId, DateTime cutoffDate) async { + final query = _db.localAssetEntity.select().join([ + innerJoin( + _db.remoteAssetEntity, + _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum) & + _db.remoteAssetEntity.ownerId.equals(userId) & + _db.remoteAssetEntity.deletedAt.isNull(), + ), + ])..where(_db.localAssetEntity.createdAt.isSmallerOrEqualValue(cutoffDate)); + + final rows = await query.get(); + return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList(); + } } diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index d21e1e905b..66ae47a0b5 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -253,6 +253,24 @@ class DriftTimelineRepository extends DriftDatabaseRepository { origin: origin, ); + TimelineQuery fromAssetsWithBuckets(List assets, TimelineOrigin origin) { + // Sort assets by date descending and group by day + final sorted = List.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final Map bucketCounts = {}; + for (final asset in sorted) { + final date = DateTime(asset.createdAt.year, asset.createdAt.month, asset.createdAt.day); + bucketCounts[date] = (bucketCounts[date] ?? 0) + 1; + } + + final buckets = bucketCounts.entries.map((e) => TimeBucket(date: e.key, assetCount: e.value)).toList(); + + return ( + bucketSource: () => Stream.value(buckets), + assetSource: (offset, count) => Future.value(sorted.skip(offset).take(count).toList(growable: false)), + origin: origin, + ); + } + TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder( filter: (row) => row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId), diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 86c80253dc..56b140eb48 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewe import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart'; import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart'; +import 'package:immich_mobile/widgets/settings/cleanup_settings.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; import 'package:immich_mobile/widgets/settings/notification_setting.dart'; @@ -22,6 +23,7 @@ enum SettingSection { advanced('advanced', Icons.build_outlined, "advanced_settings_tile_subtitle"), assetViewer('asset_viewer_settings_title', Icons.image_outlined, "asset_viewer_settings_subtitle"), backup('backup', Icons.cloud_upload_outlined, "backup_settings_subtitle"), + cleanup('cleanup', Icons.cleaning_services_outlined, "cleanup_settings_subtitle"), languages('language', Icons.language, "setting_languages_subtitle"), networking('networking_settings', Icons.wifi, "networking_subtitle"), notifications('notifications', Icons.notifications_none_rounded, "setting_notifications_subtitle"), @@ -38,6 +40,7 @@ enum SettingSection { SettingSection.assetViewer => const AssetViewerSettings(), SettingSection.backup => Store.tryGet(StoreKey.betaTimeline) ?? false ? const DriftBackupSettings() : const BackupSettings(), + SettingSection.cleanup => const CleanupSettings(), SettingSection.languages => const LanguageSettings(), SettingSection.networking => const NetworkingSettings(), SettingSection.notifications => const NotificationSetting(), diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index a04e26d653..ac20e73190 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -42,6 +42,7 @@ class Timeline extends StatelessWidget { this.withScrubber = true, this.snapToMonth = true, this.initialScrollOffset, + this.readOnly = false, }); final Widget? topSliverWidget; @@ -54,6 +55,7 @@ class Timeline extends StatelessWidget { final bool withScrubber; final bool snapToMonth; final double? initialScrollOffset; + final bool readOnly; @override Widget build(BuildContext context) { @@ -73,6 +75,7 @@ class Timeline extends StatelessWidget { groupBy: groupBy, ), ), + if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), ], child: _SliverTimeline( topSliverWidget: topSliverWidget, @@ -89,6 +92,17 @@ class Timeline extends StatelessWidget { } } +class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier { + @override + bool build() => true; + + @override + void setReadonlyMode(bool value) {} + + @override + void toggleReadonlyMode() {} +} + class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ this.topSliverWidget, diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart new file mode 100644 index 0000000000..91164625ab --- /dev/null +++ b/mobile/lib/providers/cleanup.provider.dart @@ -0,0 +1,84 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/cleanup.service.dart'; + +class CleanupState { + final DateTime? selectedDate; + final List assetsToDelete; + final bool isScanning; + final bool isDeleting; + + const CleanupState({ + this.selectedDate, + this.assetsToDelete = const [], + this.isScanning = false, + this.isDeleting = false, + }); + + CleanupState copyWith({ + DateTime? selectedDate, + List? assetsToDelete, + bool? isScanning, + bool? isDeleting, + }) { + return CleanupState( + selectedDate: selectedDate ?? this.selectedDate, + assetsToDelete: assetsToDelete ?? this.assetsToDelete, + isScanning: isScanning ?? this.isScanning, + isDeleting: isDeleting ?? this.isDeleting, + ); + } +} + +final cleanupProvider = StateNotifierProvider((ref) { + return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id); +}); + +class CleanupNotifier extends StateNotifier { + final CleanupService _cleanupService; + final String? _userId; + + CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState()); + + void setSelectedDate(DateTime? date) { + state = state.copyWith(selectedDate: date, assetsToDelete: []); + } + + Future scanAssets() async { + if (_userId == null || state.selectedDate == null) { + return; + } + + state = state.copyWith(isScanning: true); + try { + final assets = await _cleanupService.getRemovalCandidates(_userId, state.selectedDate!); + state = state.copyWith(assetsToDelete: assets, isScanning: false); + } catch (e) { + state = state.copyWith(isScanning: false); + rethrow; + } + } + + Future deleteAssets() async { + if (state.assetsToDelete.isEmpty) { + return 0; + } + + state = state.copyWith(isDeleting: true); + try { + final deletedCount = await _cleanupService.deleteLocalAssets(state.assetsToDelete.map((a) => a.id).toList()); + + state = state.copyWith(assetsToDelete: [], isDeleting: false); + + return deletedCount; + } catch (e) { + state = state.copyWith(isDeleting: false); + rethrow; + } + } + + void reset() { + state = const CleanupState(); + } +} diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart new file mode 100644 index 0000000000..4fdb739fb1 --- /dev/null +++ b/mobile/lib/services/cleanup.service.dart @@ -0,0 +1,34 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; + +final cleanupServiceProvider = Provider((ref) { + return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider)); +}); + +class CleanupService { + final DriftLocalAssetRepository _localAssetRepository; + final AssetMediaRepository _assetMediaRepository; + + const CleanupService(this._localAssetRepository, this._assetMediaRepository); + + Future> getRemovalCandidates(String userId, DateTime cutoffDate) { + return _localAssetRepository.getRemovalCandidates(userId, cutoffDate); + } + + Future deleteLocalAssets(List localIds) async { + if (localIds.isEmpty) { + return 0; + } + + final deletedIds = await _assetMediaRepository.deleteAll(localIds); + if (deletedIds.isNotEmpty) { + await _localAssetRepository.delete(deletedIds); + return deletedIds.length; + } + + return 0; + } +} diff --git a/mobile/lib/widgets/settings/cleanup_settings.dart b/mobile/lib/widgets/settings/cleanup_settings.dart new file mode 100644 index 0000000000..284ccd968f --- /dev/null +++ b/mobile/lib/widgets/settings/cleanup_settings.dart @@ -0,0 +1,474 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/cleanup.provider.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +class CleanupSettings extends ConsumerStatefulWidget { + const CleanupSettings({super.key}); + + @override + ConsumerState createState() => _CleanupSettingsState(); +} + +class _CleanupSettingsState extends ConsumerState { + int _currentStep = 0; + bool _hasScanned = false; + + void _resetState() { + ref.read(cleanupProvider.notifier).reset(); + _hasScanned = false; + } + + int get _calculatedStep { + final state = ref.read(cleanupProvider); + + if (state.assetsToDelete.isNotEmpty) { + return 2; + } + + if (state.selectedDate != null) { + return 1; + } + + return 0; + } + + Future _selectDate() async { + final state = ref.read(cleanupProvider); + final notifier = ref.read(cleanupProvider.notifier); + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + + final DateTime? picked = await showDatePicker( + context: context, + initialDate: state.selectedDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now(), + ); + if (picked != null) { + notifier.setSelectedDate(picked); + setState(() => _currentStep = 1); + } + } + + Future _scanAssets() async { + final notifier = ref.read(cleanupProvider.notifier); + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + + await notifier.scanAssets(); + final state = ref.read(cleanupProvider); + + setState(() { + _hasScanned = true; + if (state.assetsToDelete.isNotEmpty) { + _currentStep = 2; + } + }); + } + + Future _deleteAssets() async { + final state = ref.read(cleanupProvider); + final notifier = ref.read(cleanupProvider.notifier); + + if (state.assetsToDelete.isEmpty || state.selectedDate == null) { + return; + } + + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + final confirmed = await showDialog( + context: context, + builder: (ctx) => + _DeleteConfirmationDialog(assetCount: state.assetsToDelete.length, cutoffDate: state.selectedDate!), + ); + + if (confirmed != true) { + return; + } + + final deletedCount = await notifier.deleteAssets(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('cleanup_deleted_assets'.t(context: context, args: {'count': deletedCount.toString()})), + ), + ); + setState(() => _currentStep = 0); + } + } + + String _formatDate(DateTime date) { + return DateFormat.yMMMd().format(date); + } + + void _showAssetsPreview(List assets) { + ref.read(hapticFeedbackProvider.notifier).mediumImpact(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => _CleanupAssetsPreview(assets: assets), + ), + ); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(cleanupProvider); + final hasDate = state.selectedDate != null; + final hasAssets = _hasScanned && state.assetsToDelete.isNotEmpty; + + StepStyle styleForState(StepState stepState, {bool isDestructive = false}) { + switch (stepState) { + case StepState.complete: + return StepStyle( + color: context.colorScheme.primary, + indexStyle: TextStyle(color: context.colorScheme.onPrimary, fontWeight: FontWeight.w500), + ); + case StepState.disabled: + return StepStyle( + color: context.colorScheme.onSurface.withValues(alpha: 0.38), + indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500), + ); + case StepState.indexed: + case StepState.editing: + case StepState.error: + if (isDestructive) { + return StepStyle( + color: context.colorScheme.error, + indexStyle: TextStyle(color: context.colorScheme.onError, fontWeight: FontWeight.w500), + ); + } + return StepStyle( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + indexStyle: TextStyle(color: context.colorScheme.surface, fontWeight: FontWeight.w500), + ); + } + } + + final step1State = hasDate ? StepState.complete : StepState.indexed; + final step2State = hasAssets + ? StepState.complete + : hasDate + ? StepState.indexed + : StepState.disabled; + final step3State = hasAssets ? StepState.indexed : StepState.disabled; + + return PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + _resetState(); + } + }, + child: Stepper( + currentStep: _currentStep, + onStepTapped: (step) { + // Only allow going back or to completed steps + if (step <= _calculatedStep) { + setState(() => _currentStep = step); + } + }, + onStepContinue: () async { + switch (_currentStep) { + case 0: + await _selectDate(); + break; + case 1: + await _scanAssets(); + break; + case 2: + await _deleteAssets(); + break; + } + }, + onStepCancel: () { + if (_currentStep > 0) { + setState(() => _currentStep -= 1); + } + }, + controlsBuilder: (_, __) => const SizedBox.shrink(), + steps: [ + // Step 1: Select Cutoff Date + Step( + stepStyle: styleForState(step1State), + title: Text( + 'select_cutoff_date'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step1State == StepState.complete ? context.colorScheme.primary : context.colorScheme.onSurface, + ), + ), + subtitle: hasDate + ? Text( + _formatDate(state.selectedDate!), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('cleanup_description'.t(context: context), style: context.textTheme.bodyLarge), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _selectDate, + icon: const Icon(Icons.calendar_today), + label: Text(hasDate ? 'change'.t(context: context) : 'select_cutoff_date'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + ], + ), + isActive: true, + state: step1State, + ), + + // Step 2: Scan Assets + Step( + stepStyle: styleForState(step2State), + title: Text( + 'scan'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step2State == StepState.complete + ? context.colorScheme.primary + : step2State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : context.colorScheme.onSurface, + ), + ), + subtitle: _hasScanned + ? Text( + 'cleanup_found_assets'.t(context: context, args: {'count': state.assetsToDelete.length.toString()}), + style: context.textTheme.bodyMedium?.copyWith( + color: state.assetsToDelete.isNotEmpty + ? context.colorScheme.primary + : context.colorScheme.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ) + : null, + content: Column( + children: [ + Text('cleanup_step2_description'.t(context: context), style: context.textTheme.bodyLarge), + const SizedBox(height: 16), + state.isScanning + ? SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2, + backgroundColor: context.colorScheme.primary.withAlpha(50), + ), + ) + : ElevatedButton.icon( + onPressed: state.isScanning ? null : _scanAssets, + icon: const Icon(Icons.search), + label: Text(_hasScanned ? 'rescan'.t(context: context) : 'scan'.t(context: context)), + style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + if (_hasScanned && state.assetsToDelete.isEmpty) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Row( + children: [ + const Icon(Icons.info, color: Colors.orange), + const SizedBox(width: 12), + Expanded( + child: Text( + 'cleanup_no_assets_found'.t(context: context), + style: context.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ], + ), + isActive: hasDate, + state: step2State, + ), + + // Step 3: Delete Assets + Step( + stepStyle: styleForState(step3State, isDestructive: true), + title: Text( + 'move_to_trash'.t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: step3State == StepState.disabled + ? context.colorScheme.onSurface.withValues(alpha: 0.38) + : context.colorScheme.error, + ), + ), + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.colorScheme.errorContainer.withValues(alpha: 0.3), + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)), + ), + child: hasAssets + ? Text( + 'cleanup_step3_summary'.t( + context: context, + args: { + 'count': state.assetsToDelete.length.toString(), + 'date': _formatDate(state.selectedDate!), + }, + ), + style: context.textTheme.bodyMedium, + ) + : null, + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => _showAssetsPreview(state.assetsToDelete), + icon: const Icon(Icons.preview), + label: Text('preview'.t(context: context)), + style: OutlinedButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: state.isDeleting ? null : _deleteAssets, + icon: state.isDeleting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.delete_forever), + label: Text( + state.isDeleting ? 'cleanup_deleting'.t(context: context) : 'move_to_trash'.t(context: context), + ), + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error, + foregroundColor: context.colorScheme.onError, + minimumSize: const Size(double.infinity, 56), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ], + ), + isActive: hasAssets, + state: step3State, + ), + ], + ), + ); + } +} + +class _DeleteConfirmationDialog extends StatelessWidget { + final int assetCount; + final DateTime cutoffDate; + + const _DeleteConfirmationDialog({required this.assetCount, required this.cutoffDate}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('confirm'.t(context: context)), + content: Text( + 'cleanup_confirm_description'.t( + context: context, + args: {'count': assetCount.toString(), 'date': DateFormat.yMMMd().format(cutoffDate)}, + ), + ), + actions: [ + TextButton( + onPressed: () => context.pop(false), + child: Text('cancel'.t(context: context)), + ), + ElevatedButton( + onPressed: () => context.pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error, + foregroundColor: context.colorScheme.onError, + ), + child: Text('move_to_trash'.t(context: context)), + ), + ], + ); + } +} + +class _CleanupAssetsPreview extends StatelessWidget { + final List assets; + + const _CleanupAssetsPreview({required this.assets}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const _DragHandle(), + Expanded( + child: ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith((ref) { + final timelineService = ref + .watch(timelineFactoryProvider) + .fromAssetsWithBuckets(assets.cast(), TimelineOrigin.search); + ref.onDispose(timelineService.dispose); + return timelineService; + }), + ], + child: const Timeline( + appBar: null, + bottomSheet: null, + withScrubber: false, + groupBy: GroupAssetsBy.day, + readOnly: true, + ), + ), + ), + ], + ); + } +} + +class _DragHandle extends StatelessWidget { + const _DragHandle(); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 38, + child: Center( + child: SizedBox( + width: 32, + height: 6, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + color: context.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + ), + ), + ), + ), + ); + } +} diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index b736998e28..ae2b5110c2 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -493,6 +493,9 @@ select "asset"."fileCreatedAt", "asset_exif"."timeZone", "asset_exif"."fileSizeInByte", + "asset_exif"."make", + "asset_exif"."model", + "asset_exif"."lensModel", ( select coalesce(json_agg(agg), '[]') @@ -529,6 +532,9 @@ select "asset"."fileCreatedAt", "asset_exif"."timeZone", "asset_exif"."fileSizeInByte", + "asset_exif"."make", + "asset_exif"."model", + "asset_exif"."lensModel", ( select coalesce(json_agg(agg), '[]') diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 214d42747f..8beb053aac 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -324,6 +324,9 @@ export class AssetJobRepository { 'asset.fileCreatedAt', 'asset_exif.timeZone', 'asset_exif.fileSizeInByte', + 'asset_exif.make', + 'asset_exif.model', + 'asset_exif.lensModel', ]) .select((eb) => withFiles(eb, AssetFileType.Sidecar)) .where('asset.deletedAt', 'is', null); diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index d0d7ea3a3c..c1898f8f12 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -84,6 +84,7 @@ describe(StorageTemplateService.name, () => { '{{y}}/{{y}}-{{MM}}/{{assetId}}', '{{y}}/{{y}}-{{WW}}/{{assetId}}', '{{album}}/{{filename}}', + '{{make}}/{{model}}/{{lensModel}}/{{filename}}', ], secondOptions: ['s', 'ss', 'SSS'], weekOptions: ['W', 'WW'], diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 864207bf05..bbf5a8a6bf 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -53,6 +53,7 @@ const storagePresets = [ '{{y}}/{{y}}-{{MM}}/{{assetId}}', '{{y}}/{{y}}-{{WW}}/{{assetId}}', '{{album}}/{{filename}}', + '{{make}}/{{model}}/{{lensModel}}/{{filename}}', ]; export interface MoveAssetMetadata { @@ -67,6 +68,9 @@ interface RenderMetadata { albumName: string | null; albumStartDate: Date | null; albumEndDate: Date | null; + make: string | null; + model: string | null; + lensModel: string | null; } @Injectable() @@ -115,6 +119,9 @@ export class StorageTemplateService extends BaseService { albumName: 'album', albumStartDate: new Date(), albumEndDate: new Date(), + make: 'FUJIFILM', + model: 'X-T50', + lensModel: 'XF27mm F2.8 R WR', }); } catch (error) { this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`); @@ -301,6 +308,9 @@ export class StorageTemplateService extends BaseService { albumName, albumStartDate, albumEndDate, + make: asset.make, + model: asset.model, + lensModel: asset.lensModel, }); const fullPath = path.normalize(path.join(rootPath, storagePath)); let destination = `${fullPath}.${extension}`; @@ -365,7 +375,7 @@ export class StorageTemplateService extends BaseService { } private render(template: HandlebarsTemplateDelegate, options: RenderMetadata) { - const { filename, extension, asset, albumName, albumStartDate, albumEndDate } = options; + const { filename, extension, asset, albumName, albumStartDate, albumEndDate, make, model, lensModel } = options; const substitutions: Record = { filename, ext: extension, @@ -375,6 +385,9 @@ export class StorageTemplateService extends BaseService { assetIdShort: asset.id.slice(-12), //just throw into the root if it doesn't belong to an album album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '', + make: make ?? '', + model: model ?? '', + lensModel: lensModel ?? '', }; const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; diff --git a/server/src/types.ts b/server/src/types.ts index e404332fac..779de1ee37 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -472,6 +472,9 @@ export type StorageAsset = { originalFileName: string; fileSizeInByte: number | null; files: AssetFile[]; + make: string | null; + model: string | null; + lensModel: string | null; }; export type OnThisDayData = { year: number }; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index f5935d5d0e..6e4193c110 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -65,6 +65,9 @@ export const assetStub = { originalFileName: 'IMG_123.jpg', fileSizeInByte: 12_345, files: [], + make: 'FUJIFILM', + model: 'X-T50', + lensModel: 'XF27mm F2.8 R WR', ...asset, }), noResizePath: Object.freeze({ diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index e119e8d8b0..e8514358ee 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -60,6 +60,9 @@ assetId: 'a8312960-e277-447d-b4ea-56717ccba856', assetIdShort: '56717ccba856', album: $t('album_name'), + make: 'FUJIFILM', + model: 'X-T50', + lensModel: 'XF27mm F2.8 R WR', }; const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString()); diff --git a/web/src/lib/components/admin-settings/SupportedVariablesPanel.svelte b/web/src/lib/components/admin-settings/SupportedVariablesPanel.svelte index 74a05b553f..4274ac70bc 100644 --- a/web/src/lib/components/admin-settings/SupportedVariablesPanel.svelte +++ b/web/src/lib/components/admin-settings/SupportedVariablesPanel.svelte @@ -24,10 +24,8 @@
-

{$t('other')}

+

{$t('album')}

    -
  • {`{{assetId}}`} - Asset ID
  • -
  • {`{{assetIdShort}}`} - Asset ID (last 12 characters)
  • {`{{album}}`} - Album Name
  • {`{{album-startDate-x}}`} - Album Start Date and Time (e.g. album-startDate-yy). @@ -39,5 +37,20 @@
+
+

{$t('camera')}

+
    +
  • {`{{make}}`} - FUJIFILM
  • +
  • {`{{model}}`} - X-T50
  • +
  • {`{{lensModel}}`} - XF27mm F2.8 R WR
  • +
+
+
+

{$t('other')}

+
    +
  • {`{{assetId}}`} - Asset ID
  • +
  • {`{{assetIdShort}}`} - Asset ID (last 12 characters)
  • +
+