From d0e0c246909bcc0415dc8dfe60398678be5516f9 Mon Sep 17 00:00:00 2001 From: wuzihao051119 Date: Sat, 19 Jul 2025 18:26:20 +0800 Subject: [PATCH] feat(mobile): edit date time action --- i18n/en.json | 1 + .../models/asset/remote_asset.model.dart | 7 +++ .../entities/remote_asset.entity.dart | 1 + .../repositories/remote_asset.repository.dart | 30 +++++++++ .../edit_date_time_action_button.widget.dart | 40 +++++++++++- .../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 | 22 +++++++ .../repositories/asset_api.repository.dart | 12 ++++ mobile/lib/services/action.service.dart | 62 ++++++++++++++++++- 12 files changed, 177 insertions(+), 6 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 54c7ca6f1b..59cb5be6ae 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -817,6 +817,7 @@ "edit_avatar": "Edit avatar", "edit_date": "Edit date", "edit_date_and_time": "Edit date and time", + "edit_date_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/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 760a16170b..4e4ce8f27f 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -13,6 +13,7 @@ class RemoteAsset extends BaseAsset { final String? localId; final String? thumbHash; final AssetVisibility visibility; + final DateTime? localDateTime; final String ownerId; final String? stackId; final int stackCount; @@ -32,6 +33,7 @@ class RemoteAsset extends BaseAsset { super.isFavorite = false, this.thumbHash, this.visibility = AssetVisibility.timeline, + this.localDateTime, super.livePhotoVideoId, this.stackId, this.stackCount = 0, @@ -60,6 +62,7 @@ class RemoteAsset extends BaseAsset { isFavorite: $isFavorite, thumbHash: ${thumbHash ?? ""}, visibility: $visibility, + localDateTime: ${localDateTime ?? ""}, stackId: ${stackId ?? ""}, stackCount: $stackCount, checksum: $checksum, @@ -77,6 +80,7 @@ class RemoteAsset extends BaseAsset { ownerId == other.ownerId && thumbHash == other.thumbHash && visibility == other.visibility && + localDateTime == other.localDateTime && stackId == other.stackId && stackCount == other.stackCount; } @@ -89,6 +93,7 @@ class RemoteAsset extends BaseAsset { localId.hashCode ^ thumbHash.hashCode ^ visibility.hashCode ^ + localDateTime.hashCode ^ stackId.hashCode ^ stackCount.hashCode; @@ -107,6 +112,7 @@ class RemoteAsset extends BaseAsset { bool? isFavorite, String? thumbHash, AssetVisibility? visibility, + DateTime? localDateTime, String? livePhotoVideoId, String? stackId, int? stackCount, @@ -126,6 +132,7 @@ class RemoteAsset extends BaseAsset { isFavorite: isFavorite ?? this.isFavorite, thumbHash: thumbHash ?? this.thumbHash, visibility: visibility ?? this.visibility, + localDateTime: localDateTime ?? this.localDateTime, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, stackId: stackId ?? this.stackId, stackCount: stackCount ?? this.stackCount, diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 0b2896538e..a57d5cb7d2 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -53,6 +53,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { isFavorite: isFavorite, height: height, width: width, + localDateTime: localDateTime, thumbHash: thumbHash, visibility: visibility, livePhotoVideoId: livePhotoVideoId, diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 64c602a2c7..231fbc16bb 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -100,6 +100,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository { .getSingleOrNull(); } + Future getAsset(String id) { + return _db.managers.remoteAssetEntity + .filter((row) => row.id.equals(id)) + .map((row) => row.toDto()) + .getSingleOrNull(); + } + Future> getPlaces() { final asset = Subquery( _db.remoteAssetEntity.select() @@ -203,6 +210,29 @@ class RemoteAssetRepository extends DriftDatabaseRepository { }); } + Future updateDateTime(List ids, String dateTime) { + final localDateTime = + dateTime.replaceAll(RegExp(r'[\+|-][0-9]{2}:[0-9]{2}'), ''); + return _db.batch((batch) async { + for (final id in ids) { + batch.update( + _db.remoteAssetEntity, + RemoteAssetEntityCompanion( + localDateTime: Value(DateTime.parse(localDateTime).toUtc()), + ), + where: (e) => e.id.equals(id), + ); + batch.update( + _db.remoteExifEntity, + RemoteExifEntityCompanion( + dateTimeOriginal: Value(DateTime.parse(dateTime)), + ), + where: (e) => e.assetId.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..d15a9cbe37 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,47 @@ 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_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 +49,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/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index deaaea0d39..d2c690e125 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 @@ -44,7 +44,7 @@ class ArchiveBottomSheet extends ConsumerWidget { : const DeletePermanentActionButton( source: ActionSource.timeline, ), - const EditDateTimeActionButton(), + const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton( 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 8199271bfe..d54b71e071 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 @@ -44,7 +44,7 @@ class FavoriteBottomSheet extends ConsumerWidget { : const DeletePermanentActionButton( source: ActionSource.timeline, ), - const EditDateTimeActionButton(), + const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton( 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 d83b8e399d..5250d55449 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 @@ -47,7 +47,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, 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 2e6047f0ba..3fb882a296 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 @@ -47,7 +47,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { : const DeletePermanentActionButton( source: ActionSource.timeline, ), - const EditDateTimeActionButton(), + const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton( source: ActionSource.timeline, diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 419ba0f902..90a5e6da36 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -288,6 +288,28 @@ 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, diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 6d08b4f0d9..d4e9e9f18f 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -98,6 +98,18 @@ class AssetApiRepository extends ApiRepository { ); } + Future updateDateTime( + List ids, + String dateTime, + ) async { + return _api.updateAssets( + AssetBulkUpdateDto( + ids: ids, + dateTimeOriginal: dateTime, + ), + ); + } + 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 63bc053a41..8d58e7bc8a 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -13,9 +13,11 @@ 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'; +import 'package:timezone/timezone.dart'; final actionServiceProvider = Provider( (ref) => ActionService( @@ -159,7 +161,7 @@ class ActionService { ) async { maplibre.LatLng? initialLatLng; if (remoteIds.length == 1) { - final exif = await _remoteAssetRepository.getExif(remoteIds[0]); + final exif = await _remoteAssetRepository.getExif(remoteIds.first); if (exif?.latitude != null && exif?.longitude != null) { initialLatLng = maplibre.LatLng(exif!.latitude!, exif.longitude!); @@ -187,6 +189,64 @@ class ActionService { return true; } + Future editDateTime( + List remoteIds, + BuildContext context, + ) async { + DateTime? initialDateTime; + Duration? initialOffset; + String? initialTimeZone; + if (remoteIds.length == 1) { + final asset = await _remoteAssetRepository.getAsset(remoteIds.first); + final exif = await _remoteAssetRepository.getExif(remoteIds.first); + + initialDateTime = asset?.localDateTime; + initialTimeZone = exif?.timeZone; + if (initialDateTime != null && initialTimeZone != null) { + try { + final location = getLocation(initialTimeZone); + initialOffset = + TZDateTime.from(initialDateTime, location).timeZoneOffset; + } on LocationNotFoundException { + RegExp re = RegExp( + r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', + caseSensitive: false, + ); + final m = re.firstMatch(initialTimeZone); + if (m != null) { + final offset = Duration( + hours: int.parse(m.group(1) ?? '0'), + minutes: int.parse(m.group(2) ?? '0'), + ); + initialOffset = offset; + } + } + } + } + + final dateTime = await showDateTimePicker( + context: context, + initialDateTime: initialDateTime, + initialTZ: initialTimeZone, + initialTZOffset: initialOffset, + ); + + if (dateTime == null) { + return false; + } + + await _assetApiRepository.updateDateTime( + remoteIds, + dateTime, + ); + await _remoteAssetRepository.updateDateTime( + remoteIds, + dateTime, + ); + + return true; + } + Future removeFromAlbum(List remoteIds, String albumId) async { int removedCount = 0; final result = await _albumApiRepository.removeAssets(albumId, remoteIds);