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 c4cf101b7..700ff60c5 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 33735f170..44d7cfb6b 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 3db3dde44..6eeec0658 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 17b4cdb21..1d76d3c39 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 648592699..45c602935 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 ec0fded6c..3fb499f2a 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 3912aef15..70b2fb00b 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 9765b6168..9f41a0c68 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 80e27b597..21a22e7e5 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 bbb176ffa..07639fbb3 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 f45071e7f..9a12745ac 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 113462c6c..9cc8de29e 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),