diff --git a/i18n/en.json b/i18n/en.json index d81a6270ad..ea7a8243ea 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -790,6 +790,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 608a03f2b2..654f2b3b34 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -12,6 +12,7 @@ class RemoteAsset extends BaseAsset { final String id; final String? localId; final String? thumbHash; + final DateTime? localDateTime; final AssetVisibility visibility; final String ownerId; @@ -29,6 +30,7 @@ class RemoteAsset extends BaseAsset { super.durationInSeconds, super.isFavorite = false, this.thumbHash, + this.localDateTime, this.visibility = AssetVisibility.timeline, }); @@ -51,6 +53,7 @@ class RemoteAsset extends BaseAsset { localId: ${localId ?? ""}, isFavorite: $isFavorite, thumbHash: ${thumbHash ?? ""}, + localDateTime: ${localDateTime ?? ""}, visibility: $visibility, }'''; } @@ -64,6 +67,7 @@ class RemoteAsset extends BaseAsset { ownerId == other.ownerId && localId == other.localId && thumbHash == other.thumbHash && + localDateTime == other.localDateTime && visibility == other.visibility; } @@ -74,5 +78,6 @@ class RemoteAsset extends BaseAsset { ownerId.hashCode ^ localId.hashCode ^ thumbHash.hashCode ^ + localDateTime.hashCode ^ visibility.hashCode; } diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index c78643d89b..4d5ecbecc6 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart' hide Query; import 'package:immich_mobile/domain/models/exif.model.dart' as domain; +import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; @@ -143,3 +144,22 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin { @override Set get primaryKey => {assetId}; } + +extension RemoteExifEntityDataDomainEx on RemoteExifEntityData { + domain.ExifInfo toDto() => domain.ExifInfo ( + fileSize: fileSize, + description: description, + orientation: orientation, + timeZone: timeZone, + dateTimeOriginal: dateTimeOriginal, + latitude: latitude, + longitude: longitude, + city: city, + state: state, + country: country, + make: make, + model: model, + f: fNumber, + iso: iso, + ); +} diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index c08401356c..c2d8df24d1 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -49,6 +49,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { isFavorite: isFavorite, height: height, width: width, + localDateTime: localDateTime, thumbHash: thumbHash, visibility: visibility, localId: null, diff --git a/mobile/lib/infrastructure/repositories/exif.repository.dart b/mobile/lib/infrastructure/repositories/exif.repository.dart index d25572fdad..36b1cf00c7 100644 --- a/mobile/lib/infrastructure/repositories/exif.repository.dart +++ b/mobile/lib/infrastructure/repositories/exif.repository.dart @@ -2,7 +2,6 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' as entity; -import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:isar/isar.dart'; @@ -52,27 +51,6 @@ class DriftRemoteExifRepository extends DriftDatabaseRepository { final query = _db.remoteExifEntity.select() ..where((exif) => exif.assetId.equals(assetId)); - return query.map((asset) => asset.toDto()).getSingleOrNull(); - } -} - -extension on RemoteExifEntityData { - ExifInfo toDto() { - return ExifInfo( - fileSize: fileSize, - description: description, - orientation: orientation, - timeZone: timeZone, - dateTimeOriginal: dateTimeOriginal, - latitude: latitude, - longitude: longitude, - city: city, - state: state, - country: country, - make: make, - model: model, - f: fNumber, - iso: iso, - ); + return query.map((exif) => exif.toDto()).getSingleOrNull(); } } diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index a9e0811104..90349e2bd0 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -1,6 +1,7 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -9,6 +10,13 @@ class DriftRemoteAssetRepository extends DriftDatabaseRepository { final Drift _db; const DriftRemoteAssetRepository(this._db) : super(_db); + Future get(String assetId) { + final query = _db.remoteAssetEntity.select() + ..where((asset) => asset.id.equals(assetId)); + + return query.map((asset) => asset.toDto()).getSingleOrNull(); + } + Future updateFavorite(List ids, bool isFavorite) { return _db.batch((batch) async { for (final id in ids) { @@ -47,4 +55,26 @@ class DriftRemoteAssetRepository 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), + ); + } + }); + } } 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..14de392fb9 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,49 @@ 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/infrastructure/timeline.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; + } + + await ref.read(timelineServiceProvider).reloadBucket(); + 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 +51,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/action_buttons/edit_location_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_location_action_button.widget.dart index c7279995ff..e87fc000af 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_location_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_location_action_button.widget.dart @@ -5,6 +5,7 @@ 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/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -24,6 +25,7 @@ class EditLocationActionButton extends ConsumerWidget { return; } + await ref.read(timelineServiceProvider).reloadBucket(); ref.read(multiSelectProvider.notifier).reset(); final successMessage = 'edit_location_action_prompt'.t( diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart index d122d188ff..0cd7b689e0 100644 --- a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart @@ -41,7 +41,7 @@ class HomeBottomAppBar extends ConsumerWidget { isTrashEnable ? const TrashActionButton() : const DeletePermanentActionButton(), - 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 57dde6456e..1dbb8f6556 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -194,6 +194,28 @@ class ActionNotifier extends Notifier { ); } } + + Future editDateTime( + ActionSource source, + BuildContext context, + ) async { + final ids = _getOwnedRemoteForSource(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(), + ); + } + } } extension on Iterable { diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 9631428409..1b289637cf 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -79,6 +79,18 @@ class AssetApiRepository extends ApiRepository { ); } + Future updateDateTime( + List ids, + String dateTime, + ) async { + return _api.updateAssets( + AssetBulkUpdateDto( + ids: ids, + dateTimeOriginal: dateTime, + ), + ); + } + _mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) { AssetVisibilityEnum.timeline => AssetVisibility.timeline, AssetVisibilityEnum.hidden => AssetVisibility.hidden, diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index b5ddbac270..353369dcd0 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -8,9 +8,11 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/repositories/asset_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'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:timezone/timezone.dart'; final actionServiceProvider = Provider( (ref) => ActionService( @@ -99,7 +101,7 @@ class ActionService { ) async { LatLng? initialLatLng; if (remoteIds.length == 1) { - final exif = await _remoteExifRepository.get(remoteIds[0]); + final exif = await _remoteExifRepository.get(remoteIds.first); if (exif?.latitude != null && exif?.longitude != null) { initialLatLng = LatLng(exif!.latitude!, exif.longitude!); @@ -126,4 +128,61 @@ 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.get(remoteIds.first); + final exif = await _remoteExifRepository.get(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; + } }