From e7d051db3c1a427ce675e304e69f87bbf8bb1a87 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Wed, 30 Jul 2025 14:40:13 -0500 Subject: [PATCH] feat: drift edit time and date action (#20377) * feat: drift edit time and date action * feat: add edit button on asset viewer bottom sheet * update localDateTime column in addition to createdAt to keep consistency * fix: dont update local dateTime Server calcs this anyway and it will be synced when the change is applied. We don't use localDateTime on mobile so there is no reason to update this value * fix: padding around edit icon in ListTile Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> * chore: format * fix: hide date edit control when asset does not have a remote * fix: pull timezones correctly from image --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> --- i18n/en.json | 1 + .../repositories/remote_asset.repository.dart | 17 ++++++++ .../edit_date_time_action_button.widget.dart | 37 +++++++++++++++++- .../asset_viewer/bottom_sheet.widget.dart | 20 +++++++++- .../archive_bottom_sheet.widget.dart | 2 +- .../favorite_bottom_sheet.widget.dart | 2 +- .../general_bottom_sheet.widget.dart | 2 +- .../remote_album_bottom_sheet.widget.dart | 2 +- .../infrastructure/action.provider.dart | 15 +++++++ .../repositories/asset_api.repository.dart | 4 ++ mobile/lib/services/action.service.dart | 39 +++++++++++++++++++ .../lib/widgets/common/date_time_picker.dart | 2 +- 12 files changed, 136 insertions(+), 7 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index c4cf101b73..700ff60c53 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -835,6 +835,7 @@ "edit_birthday": "Edit Birthday", "edit_date": "Edit date", "edit_date_and_time": "Edit date and time", + "edit_date_and_time_action_prompt": "{count} date and time edited", "edit_description": "Edit description", "edit_description_prompt": "Please select a new description:", "edit_exclusion_pattern": "Edit exclusion pattern", diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 33735f1709..44d7cfb6bb 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -186,6 +186,23 @@ class RemoteAssetRepository extends DriftDatabaseRepository { }); } + Future updateDateTime(List ids, DateTime dateTime) { + return _db.batch((batch) async { + for (final id in ids) { + batch.update( + _db.remoteExifEntity, + RemoteExifEntityCompanion(dateTimeOriginal: Value(dateTime)), + where: (e) => e.assetId.equals(id), + ); + batch.update( + _db.remoteAssetEntity, + RemoteAssetEntityCompanion(createdAt: Value(dateTime)), + where: (e) => e.id.equals(id), + ); + } + }); + } + Future stack(String userId, StackResponse stack) { return _db.transaction(() async { final stackIds = await _db.managers.stackEntity diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart index 3db3dde44d..6eeec0658b 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart @@ -1,10 +1,44 @@ import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class EditDateTimeActionButton extends ConsumerWidget { - const EditDateTimeActionButton({super.key}); + final ActionSource source; + + const EditDateTimeActionButton({super.key, required this.source}); + + _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).editDateTime(source, context); + if (result == null) { + return; + } + + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'edit_date_and_time_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } @override Widget build(BuildContext context, WidgetRef ref) { @@ -12,6 +46,7 @@ class EditDateTimeActionButton extends ConsumerWidget { maxWidth: 95.0, iconData: Icons.edit_calendar_outlined, label: "control_bottom_app_bar_edit_time".t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 17b4cdb214..1d76d3c39d 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -143,12 +143,18 @@ class _AssetDetailBottomSheet extends ConsumerWidget { final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final cameraTitle = _getCameraInfoTitle(exifInfo); + Future editDateTime() async { + await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context); + } + return SliverList.list( children: [ // Asset Date and Time _SheetTile( title: _getDateTime(context, asset), titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null, + onTap: asset.hasRemote ? () async => await editDateTime() : null, ), if (exifInfo != null) _SheetAssetDescription(exif: exifInfo), const SheetPeopleDetails(), @@ -194,11 +200,21 @@ class _AssetDetailBottomSheet extends ConsumerWidget { class _SheetTile extends StatelessWidget { final String title; final Widget? leading; + final Widget? trailing; final String? subtitle; final TextStyle? titleStyle; final TextStyle? subtitleStyle; + final VoidCallback? onTap; - const _SheetTile({required this.title, this.titleStyle, this.leading, this.subtitle, this.subtitleStyle}); + const _SheetTile({ + required this.title, + this.titleStyle, + this.leading, + this.subtitle, + this.subtitleStyle, + this.trailing, + this.onTap, + }); @override Widget build(BuildContext context) { @@ -234,8 +250,10 @@ class _SheetTile extends StatelessWidget { title: titleWidget, titleAlignment: ListTileTitleAlignment.center, leading: leading, + trailing: trailing, contentPadding: leading == null ? null : const EdgeInsets.only(left: 25), subtitle: subtitleWidget, + onTap: onTap, ); } } diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 6485926996..45c602935d 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -40,7 +40,7 @@ class ArchiveBottomSheet extends ConsumerWidget { isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton(source: ActionSource.timeline), - const EditDateTimeActionButton(), + const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline), const StackActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index ec0fded6c3..3fb499f2a1 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -40,7 +40,7 @@ class FavoriteBottomSheet extends ConsumerWidget { isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton(source: ActionSource.timeline), - const EditDateTimeActionButton(), + const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline), const StackActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 3912aef15c..70b2fb00b0 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -76,7 +76,7 @@ class GeneralBottomSheet extends ConsumerWidget { if (multiselect.hasLocal || multiselect.hasMerged) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), ], - const EditDateTimeActionButton(), + const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline), const StackActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 9765b61684..9f41a0c681 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -43,7 +43,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton(source: ActionSource.timeline), - const EditDateTimeActionButton(), + const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline), const StackActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 80e27b5970..21a22e7e5f 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -266,6 +266,21 @@ class ActionNotifier extends Notifier { } } + Future editDateTime(ActionSource source, BuildContext context) async { + final ids = _getOwnedRemoteIdsForSource(source); + try { + final isEdited = await _service.editDateTime(ids, context); + if (!isEdited) { + return null; + } + + return ActionResult(count: ids.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to edit date and time for assets', error, stack); + return ActionResult(count: ids.length, success: false, error: error.toString()); + } + } + Future removeFromAlbum(ActionSource source, String albumId) async { final ids = _getRemoteIdsForSource(source); try { diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index bbb176ffa7..07639fbb3a 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -66,6 +66,10 @@ class AssetApiRepository extends ApiRepository { return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude)); } + Future updateDateTime(List ids, DateTime dateTime) async { + return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: dateTime.toIso8601String())); + } + Future stack(List ids) async { final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids))); diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index f45071e7f7..9a12745acd 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -159,6 +160,44 @@ class ActionService { return true; } + Future editDateTime(List remoteIds, BuildContext context) async { + DateTime? initialDate; + String? timeZone; + Duration? offset; + + if (remoteIds.length == 1) { + final assetId = remoteIds.first; + final asset = await _remoteAssetRepository.get(assetId); + if (asset == null) { + return false; + } + + final exifData = await _remoteAssetRepository.getExif(assetId); + initialDate = asset.createdAt.toLocal(); + offset = initialDate.timeZoneOffset; + timeZone = exifData?.timeZone; + } + + final dateTime = await showDateTimePicker( + context: context, + initialDateTime: initialDate, + initialTZ: timeZone, + initialTZOffset: offset, + ); + + if (dateTime == null) { + return false; + } + + // convert dateTime to DateTime object + final parsedDateTime = DateTime.parse(dateTime); + + await _assetApiRepository.updateDateTime(remoteIds, parsedDateTime); + await _remoteAssetRepository.updateDateTime(remoteIds, parsedDateTime); + + return true; + } + Future removeFromAlbum(List remoteIds, String albumId) async { int removedCount = 0; final result = await _albumApiRepository.removeAssets(albumId, remoteIds); diff --git a/mobile/lib/widgets/common/date_time_picker.dart b/mobile/lib/widgets/common/date_time_picker.dart index 113462c6c8..9cc8de29ee 100644 --- a/mobile/lib/widgets/common/date_time_picker.dart +++ b/mobile/lib/widgets/common/date_time_picker.dart @@ -145,7 +145,7 @@ class _DateTimePicker extends HookWidget { 1, ), trailing: Icon(Icons.edit_outlined, size: 18, color: context.primaryColor), - title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium).tr(), + title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium), onTap: pickDate, ), const SizedBox(height: 24),