From c0fe5a534be8d666481ab4a45cc98dfaa7307755 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:48:35 +0530 Subject: [PATCH] refactor: mobile edit asset actions --- .../actions/asset_debug.action.dart | 7 +- .../actions/edit_asset.action.dart | 84 +++++++++ .../actions/edit_datetime.action.dart | 98 ++++++++++ .../actions/edit_location.action.dart | 83 +++++++++ .../edit_date_time_action_button.widget.dart | 52 ------ .../edit_image_action_button.widget.dart | 59 ------ .../edit_location_action_button.widget.dart | 48 ----- .../date_time_details.widget.dart | 9 +- .../location_details.widget.dart | 14 +- .../asset_viewer/bottom_bar.widget.dart | 9 +- .../archive_bottom_sheet.widget.dart | 8 +- .../favorite_bottom_sheet.widget.dart | 8 +- .../general_bottom_sheet.widget.dart | 8 +- .../remote_album_bottom_sheet.widget.dart | 8 +- .../infrastructure/action.provider.dart | 80 -------- mobile/lib/services/action.service.dart | 89 --------- .../infrastructure/action_provider_test.dart | 112 ------------ mobile/test/riverpod_mocks.dart | 8 + mobile/test/services/action.service_test.dart | 42 ----- .../actions/edit_action_test.dart | 172 ++++++++++++++++++ .../presentation/presentation_context.dart | 1 + 21 files changed, 478 insertions(+), 521 deletions(-) create mode 100644 mobile/lib/presentation/actions/edit_asset.action.dart create mode 100644 mobile/lib/presentation/actions/edit_datetime.action.dart create mode 100644 mobile/lib/presentation/actions/edit_location.action.dart delete mode 100644 mobile/lib/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart delete mode 100644 mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart delete mode 100644 mobile/lib/presentation/widgets/action_buttons/edit_location_action_button.widget.dart delete mode 100644 mobile/test/providers/infrastructure/action_provider_test.dart create mode 100644 mobile/test/riverpod_mocks.dart create mode 100644 mobile/test/unit/presentation/actions/edit_action_test.dart diff --git a/mobile/lib/presentation/actions/asset_debug.action.dart b/mobile/lib/presentation/actions/asset_debug.action.dart index dbddd275ce..9dd55842c1 100644 --- a/mobile/lib/presentation/actions/asset_debug.action.dart +++ b/mobile/lib/presentation/actions/asset_debug.action.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/generated/translations.g.dart'; @@ -13,16 +14,16 @@ class AssetDebugAction extends AssetActionBuilder { @override ActionData? build(BuildContext context, WidgetRef ref) { - final assets = ref.watch(assetsActionProvider(source)).assets; + final asset = ref.watch(assetsActionProvider(source)).assets.singleOrNull; final troubleshootEnabled = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting); - if (!troubleshootEnabled || assets.length != 1) { + if (!troubleshootEnabled || asset == null) { return null; } return .new( icon: Icons.help_outline_rounded, label: context.t.troubleshoot, - onAction: () async => unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.single))), + onAction: () async => unawaited(context.pushRoute(AssetTroubleshootRoute(asset: asset))), ); } } diff --git a/mobile/lib/presentation/actions/edit_asset.action.dart b/mobile/lib/presentation/actions/edit_asset.action.dart new file mode 100644 index 0000000000..34007e9d48 --- /dev/null +++ b/mobile/lib/presentation/actions/edit_asset.action.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/error_handler.dart'; +import 'package:immich_mobile/utils/semver.dart'; + +const _minimumServerVersion = SemVer(major: 2, minor: 6, patch: 0); + +final _stateProvider = Provider.family.autoDispose((ref, source) { + final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion >= _minimumServerVersion)); + if (!isSupported) { + return null; + } + + final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source)); + return ownedAssets.where((asset) => asset.isEditable).singleOrNull; +}); + +class EditAssetAction extends AssetActionBuilder { + const EditAssetAction({required super.source}); + + @override + ActionData? build(BuildContext context, WidgetRef ref) { + if (!ref.watch(_stateProvider(source).select((asset) => asset != null))) { + return null; + } + + return .new(icon: Icons.tune, label: context.t.edit, onAction: () => _edit(context, ref)); + } + + Future _edit(BuildContext context, WidgetRef ref) async { + final asset = ref.read(_stateProvider(source)); + if (asset == null) { + return; + } + + try { + // TODO(shenlong): Move all EXIF and Apply Edits logic onto the Route + final repository = ref.read(remoteAssetRepositoryProvider); + final (edits, exif) = await (repository.getAssetEdits(asset.id), repository.getExif(asset.id)).wait; + if (exif == null || !context.mounted) { + return; + } + + ref.read(editorStateProvider.notifier).init(edits, exif); + unawaited( + context.pushRoute( + DriftEditImageRoute( + image: Image(image: getFullImageProvider(asset, edited: false)), + applyEdits: (newEdits) => applyEdits(ref, asset.id, newEdits), + ), + ), + ); + } catch (error, stack) { + handleError(error, stack: stack, description: "Failed to open the editor for the asset"); + } + } +} + +@visibleForTesting +Future applyEdits(WidgetRef ref, String remoteId, List edits) async { + final websocket = ref.read(websocketProvider.notifier); + + bool isCurrentId(dynamic data) => data is Map && (data['asset'] as Map?)?['id'] == remoteId; + await ref.read(assetServiceProvider).applyEdits(remoteId, edits); + await Future.any([ + websocket.waitForEvent('AssetEditReadyV1', isCurrentId, const .new(seconds: 10)), + websocket.waitForEvent('AssetEditReadyV2', isCurrentId, const .new(seconds: 10)), + ]).catchError((_) {}); +} diff --git a/mobile/lib/presentation/actions/edit_datetime.action.dart b/mobile/lib/presentation/actions/edit_datetime.action.dart new file mode 100644 index 0000000000..db5a28c8bc --- /dev/null +++ b/mobile/lib/presentation/actions/edit_datetime.action.dart @@ -0,0 +1,98 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; +import 'package:immich_mobile/utils/error_handler.dart'; +import 'package:immich_mobile/utils/timezone.dart'; +import 'package:immich_mobile/widgets/common/date_time_picker.dart'; + +typedef _State = ({List assetIds, RemoteAsset? origin}); + +final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) { + final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source)); + if (ownedAssets.isEmpty) { + return null; + } + + return (assetIds: ownedAssets.map((asset) => asset.id).toList(growable: false), origin: ownedAssets.singleOrNull); +}); + +class EditDateTimeAction extends AssetActionBuilder { + const EditDateTimeAction({required super.source}); + + @override + ActionData? build(BuildContext context, WidgetRef ref) { + if (!ref.watch(_stateProvider(source).select((state) => state != null))) { + return null; + } + + return .new( + icon: Icons.edit_calendar_outlined, + label: context.t.control_bottom_app_bar_edit_time, + onAction: () => _edit(context, ref), + ); + } + + Future _edit(BuildContext context, WidgetRef ref) async { + final state = ref.read(_stateProvider(source)); + if (state == null) { + return; + } + + final (:assetIds, :origin) = state; + + try { + DateTime? initialDate; + String? timeZone; + Duration? offset; + + if (origin != null) { + final exif = await ref.read(remoteAssetRepositoryProvider).getExif(origin.id); + + // Prefer the EXIF timezone, so the picker opens on what the asset actually shows. + DateTime dateTime = origin.createdAt.toLocal(); + offset = dateTime.timeZoneOffset; + if (exif?.dateTimeOriginal case final original?) { + timeZone = exif!.timeZone; + (dateTime, offset) = applyTimezoneOffset(dateTime: original, timeZone: exif.timeZone); + } + initialDate = dateTime; + + if (!context.mounted) { + return; + } + } + + final picked = await showDateTimePicker( + context: context, + initialDateTime: initialDate, + initialTZ: timeZone, + initialTZOffset: offset, + ); + if (picked == null || !context.mounted) { + return; + } + + await saveDateTime(context, ref, assetIds, picked); + ref.read(assetsActionProvider(source).notifier).clearSelect(); + } catch (error, stack) { + handleError(error, stack: stack, description: "Failed to update the date and time for assets"); + } + } +} + +@visibleForTesting +Future saveDateTime(BuildContext context, WidgetRef ref, List assetIds, String dateTime) async { + final message = context.t.edit_date_and_time_action_prompt(count: assetIds.length); + final toast = ref.read(toastRepositoryProvider); + + await ref.read(assetServiceProvider).update(assetIds, dateTime: .some(dateTime)); + ref.invalidate(assetExifProvider); + toast.success(message); +} diff --git a/mobile/lib/presentation/actions/edit_location.action.dart b/mobile/lib/presentation/actions/edit_location.action.dart new file mode 100644 index 0000000000..32f509a647 --- /dev/null +++ b/mobile/lib/presentation/actions/edit_location.action.dart @@ -0,0 +1,83 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; +import 'package:immich_mobile/utils/error_handler.dart'; +import 'package:immich_mobile/widgets/common/location_picker.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +typedef _State = ({List assetIds, RemoteAsset? origin}); + +final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) { + final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source)); + if (ownedAssets.isEmpty) { + return null; + } + + return (assetIds: ownedAssets.map((asset) => asset.id).toList(growable: false), origin: ownedAssets.singleOrNull); +}); + +class EditLocationAction extends AssetActionBuilder { + const EditLocationAction({required super.source}); + + @override + ActionData? build(BuildContext context, WidgetRef ref) { + if (!ref.watch(_stateProvider(source).select((state) => state != null))) { + return null; + } + + return .new( + icon: Icons.edit_location_alt_outlined, + label: context.t.control_bottom_app_bar_edit_location, + onAction: () => _edit(context, ref), + ); + } + + Future _edit(BuildContext context, WidgetRef ref) async { + final state = ref.read(_stateProvider(source)); + if (state == null) { + return; + } + + final (:assetIds, :origin) = state; + + try { + LatLng? initialLatLng; + if (origin != null) { + final exif = await ref.read(remoteAssetRepositoryProvider).getExif(origin.id); + if (exif?.latitude != null && exif?.longitude != null) { + initialLatLng = LatLng(exif!.latitude!, exif.longitude!); + } + if (!context.mounted) { + return; + } + } + + final location = await showLocationPicker(context: context, initialLatLng: initialLatLng); + if (location == null || !context.mounted) { + return; + } + + await saveLocation(context, ref, assetIds, location); + ref.read(assetsActionProvider(source).notifier).clearSelect(); + } catch (error, stack) { + handleError(error, stack: stack, description: "Failed to update the location for assets"); + } + } +} + +@visibleForTesting +Future saveLocation(BuildContext context, WidgetRef ref, List assetIds, LatLng location) async { + final message = context.t.edit_location_action_prompt(count: assetIds.length); + final toast = ref.read(toastRepositoryProvider); + + await ref.read(assetServiceProvider).update(assetIds, location: .some(location)); + ref.invalidate(assetExifProvider); + toast.success(message); +} 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 deleted file mode 100644 index 6eeec0658b..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart +++ /dev/null @@ -1,52 +0,0 @@ -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 { - 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) { - return BaseActionButton( - 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_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart deleted file mode 100644 index 564b02d884..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/domain/models/asset_edit.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; - -class EditImageActionButton extends ConsumerWidget { - const EditImageActionButton({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); - - Future editImage(List edits) async { - if (currentAsset == null || currentAsset.remoteId == null) { - return; - } - - await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits); - } - - Future onPress() async { - if (currentAsset == null || currentAsset.remoteId == null) { - return; - } - - final imageProvider = getFullImageProvider(currentAsset, edited: false); - - final image = Image(image: imageProvider); - final (edits, exifInfo) = await ( - ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!), - ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!), - ).wait; - - if (exifInfo == null) { - return; - } - - ref.read(editorStateProvider.notifier).init(edits, exifInfo); - await context.pushRoute(DriftEditImageRoute(image: image, applyEdits: editImage)); - } - - return BaseActionButton( - iconData: Icons.tune, - label: "edit".t(context: context), - onPressed: onPress, - ); - } -} 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 deleted file mode 100644 index 1a8a1a5c39..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/edit_location_action_button.widget.dart +++ /dev/null @@ -1,48 +0,0 @@ -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 EditLocationActionButton extends ConsumerWidget { - final ActionSource source; - - const EditLocationActionButton({super.key, required this.source}); - - _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - final result = await ref.read(actionProvider.notifier).editLocation(source, context); - if (result == null) { - return; - } - - ref.read(multiSelectProvider.notifier).reset(); - - final successMessage = 'edit_location_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) { - return BaseActionButton( - iconData: Icons.edit_location_alt_outlined, - label: "control_bottom_app_bar_edit_location".t(context: context), - onPressed: () => _onTap(context, ref), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart index 27bac68310..ab101b7d51 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -1,4 +1,5 @@ import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -8,6 +9,7 @@ import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -27,16 +29,15 @@ class DateTimeDetails extends ConsumerWidget { final asset = this.asset; final exifInfo = this.exifInfo; final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); + final editDateTime = const EditDateTimeAction(source: .viewer).build(context, ref); return Column( children: [ SheetTile( title: _getDateTime(context, asset, exifInfo), titleStyle: context.textTheme.labelLarge, - trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, - onTap: asset.hasRemote && isOwner - ? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context) - : null, + trailing: editDateTime == null ? null : const Icon(Icons.edit, size: 18), + onTap: editDateTime?.onAction, ), if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart index 8c144a83bd..a808870c70 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/actions/edit_location.action.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -53,10 +52,6 @@ class _LocationDetailsState extends ConsumerState { } } - void editLocation() async { - await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context); - } - @override Widget build(BuildContext context) { final asset = widget.asset; @@ -68,6 +63,7 @@ class _LocationDetailsState extends ConsumerState { return const SizedBox.shrink(); } + final editLocation = const EditLocationAction(source: .viewer).build(context, ref); final locationName = _getLocationName(exifInfo); final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}"; @@ -79,8 +75,8 @@ class _LocationDetailsState extends ConsumerState { SheetTile( title: 'location'.t(context: context), titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null, - onTap: editLocation, + trailing: hasCoordinates && editLocation != null ? const Icon(Icons.edit_location_alt, size: 20) : null, + onTap: editLocation?.onAction, ), if (hasCoordinates) Padding( @@ -115,7 +111,7 @@ class _LocationDetailsState extends ConsumerState { color: context.primaryColor, ), leading: const Icon(Icons.location_off), - onTap: editLocation, + onTap: editLocation?.onAction, ), ], ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 9d62218a18..a7425bbfb4 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -5,9 +5,9 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; +import 'package:immich_mobile/presentation/actions/edit_asset.action.dart'; import 'package:immich_mobile/presentation/actions/restore.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart'; @@ -15,8 +15,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart' import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/providers/server_info.provider.dart'; -import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; import 'package:immich_ui/immich_ui.dart'; @@ -41,7 +39,6 @@ class ViewerBottomBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); - final serverInfo = ref.watch(serverInfoProvider); final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; final originalTheme = context.themeData; @@ -53,9 +50,7 @@ class ViewerBottomBar extends ConsumerWidget { if (!isInLockedView) ...[ if (!isInTrash) ...[ if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), - // edit sync was added in 2.6.0 - if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) - const EditImageActionButton(), + ..._actionColumnButtons(context, ref, const [EditAssetAction(source: .viewer)]), if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), ], ..._actionColumnButtons(context, ref, const [DeleteAction(source: .viewer)]), 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 5dddd9a779..ca410b5908 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 @@ -4,14 +4,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; +import 'package:immich_mobile/presentation/actions/edit_location.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/lock.action.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/stack.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; @@ -83,8 +83,8 @@ class _ArchiveBottomSheetState extends ConsumerState { const ActionColumnButton(action: FavoriteAction(source: .timeline)), if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), const ActionColumnButton(action: DeleteAction(source: .timeline)), - const EditDateTimeActionButton(source: ActionSource.timeline), - const EditLocationActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: EditDateTimeAction(source: .timeline)), + const ActionColumnButton(action: EditLocationAction(source: .timeline)), const ActionColumnButton(action: LockAction(source: .timeline)), const ActionColumnButton(action: StackAction(source: .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 af1840ab35..e347ef7817 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 @@ -5,14 +5,14 @@ import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; +import 'package:immich_mobile/presentation/actions/edit_location.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/lock.action.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/stack.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; @@ -80,8 +80,8 @@ class FavoriteBottomSheet extends ConsumerWidget { const ActionColumnButton(action: ArchiveAction(source: .timeline)), if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), const ActionColumnButton(action: DeleteAction(source: .timeline)), - const EditDateTimeActionButton(source: ActionSource.timeline), - const EditLocationActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: EditDateTimeAction(source: .timeline)), + const ActionColumnButton(action: EditLocationAction(source: .timeline)), const ActionColumnButton(action: LockAction(source: .timeline)), const ActionColumnButton(action: StackAction(source: .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 c2bf3edf96..f6202541ba 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 @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; +import 'package:immich_mobile/presentation/actions/edit_location.action.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; @@ -11,8 +13,6 @@ import 'package:immich_mobile/presentation/actions/lock.action.dart'; import 'package:immich_mobile/presentation/actions/stack.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; @@ -90,8 +90,8 @@ class _GeneralBottomSheetState extends ConsumerState { const ActionColumnButton(action: FavoriteAction(source: .timeline)), const ActionColumnButton(action: ArchiveAction(source: .timeline)), if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline), - const EditDateTimeActionButton(source: ActionSource.timeline), - const EditLocationActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: EditDateTimeAction(source: .timeline)), + const ActionColumnButton(action: EditLocationAction(source: .timeline)), const ActionColumnButton(action: LockAction(source: .timeline)), const ActionColumnButton(action: StackAction(source: .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 1b500f7072..460fae6335 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 @@ -4,14 +4,14 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; +import 'package:immich_mobile/presentation/actions/edit_location.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/lock.action.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/stack.action.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; @@ -97,8 +97,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState const DownloadActionButton(source: ActionSource.timeline), if (ownsAlbum) ...[ const ActionColumnButton(action: DeleteAction(source: .timeline)), - const EditDateTimeActionButton(source: ActionSource.timeline), - const EditLocationActionButton(source: ActionSource.timeline), + const ActionColumnButton(action: EditDateTimeAction(source: .timeline)), + const ActionColumnButton(action: EditLocationAction(source: .timeline)), const ActionColumnButton(action: LockAction(source: .timeline)), const ActionColumnButton(action: StackAction(source: .timeline)), ], diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 6375be602f..488280bc38 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -6,23 +6,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider; import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; -import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; -import 'package:immich_mobile/utils/semver.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final actionProvider = NotifierProvider(ActionNotifier.new, dependencies: [multiSelectProvider]); @@ -119,50 +113,6 @@ class ActionNotifier extends Notifier { } } - Future editLocation(ActionSource source, BuildContext context) async { - final ids = _getOwnedRemoteIdsForSource(source); - try { - final isEdited = await _service.editLocation(ids, context); - if (!isEdited) { - return null; - } - - // This must be called since editing location - // does not update the currentAsset which means - // the exif provider will not be refreshed automatically - if (source == ActionSource.viewer) { - final currentAsset = ref.read(assetViewerProvider).currentAsset; - if (currentAsset != null) { - ref.invalidate(assetExifProvider(currentAsset)); - } - } - - return ActionResult(count: ids.length, success: true); - } catch (error, stack) { - _logger.severe('Failed to edit location for assets', error, stack); - return ActionResult(count: ids.length, success: false, error: error.toString()); - } - } - - Future editDateTime(ActionSource source, BuildContext context) async { - final ids = _getOwnedRemoteIdsForSource(source); - try { - final isEdited = await _service.editDateTime(ids, context); - if (!isEdited) { - return null; - } - - if (source == ActionSource.viewer) { - ref.invalidate(assetExifProvider); - } - - 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 tagAssets(ActionSource source, BuildContext context) async { final ids = _getOwnedRemoteIdsForSource(source); try { @@ -406,36 +356,6 @@ class ActionNotifier extends Notifier { } } - Future applyEdits(ActionSource source, List edits) async { - final ids = _getOwnedRemoteIdsForSource(source); - - if (ids.length != 1) { - _logger.warning('applyEdits called with multiple assets, expected single asset'); - return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits'); - } - - Future editReady; - if (ref.read(serverInfoProvider).serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) { - editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV2", (dynamic data) { - final eventAsset = SyncAssetV2.fromJson(data["asset"]); - return eventAsset?.id == ids.first; - }, const Duration(seconds: 10)); - } else { - editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) { - final eventAsset = SyncAssetV1.fromJson(data["asset"]); - return eventAsset?.id == ids.first; - }, const Duration(seconds: 10)); - } - - try { - await _service.applyEdits(ids.first, edits); - await editReady; - return const ActionResult(count: 1, success: true); - } catch (error, stack) { - _logger.severe('Failed to apply edits to assets', error, stack); - return ActionResult(count: ids.length, success: false, error: error.toString()); - } - } } extension on Iterable { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 0261e50202..47883b212e 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/services/tag.service.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; @@ -16,11 +15,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/timezone.dart'; -import 'package:immich_mobile/widgets/common/date_time_picker.dart'; -import 'package:immich_mobile/widgets/common/location_picker.dart'; import 'package:immich_mobile/widgets/common/tag_picker.dart'; -import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; final actionServiceProvider = Provider( (ref) => ActionService( @@ -69,83 +64,6 @@ class ActionService { return count; } - Future editLocation(List remoteIds, BuildContext context) async { - maplibre.LatLng? initialLatLng; - if (remoteIds.length == 1) { - final exif = await _remoteAssetRepository.getExif(remoteIds[0]); - - if (exif?.latitude != null && exif?.longitude != null) { - initialLatLng = maplibre.LatLng(exif!.latitude!, exif.longitude!); - } - } - - final location = await showLocationPicker(context: context, initialLatLng: initialLatLng); - - if (location == null) { - return false; - } - - await _assetApiRepository.updateLocation(remoteIds, location); - await _remoteAssetRepository.updateLocation(remoteIds, location); - - 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); - - // Use EXIF timezone information if available (matching web app and display behavior) - DateTime dt = asset.createdAt.toLocal(); - offset = dt.timeZoneOffset; - - if (exifData?.dateTimeOriginal != null) { - timeZone = exifData!.timeZone; - (dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone); - } - - initialDate = dt; - } - - final dateTime = await showDateTimePicker( - context: context, - initialDateTime: initialDate, - initialTZ: timeZone, - initialTZOffset: offset, - ); - - if (dateTime == null) { - return false; - } - - await applyDateTime(remoteIds, dateTime); - - return true; - } - - @visibleForTesting - Future applyDateTime(List remoteIds, String dateTime) async { - final parsedDateTime = DateTime.parse(dateTime); - final offset = RegExp(r'[+-]\d{2}:\d{2}$').firstMatch(dateTime)?.group(0); - - await _assetApiRepository.updateDateTime(remoteIds, dateTime); - await _remoteAssetRepository.updateDateTime( - remoteIds, - parsedDateTime, - timeZone: offset == null ? null : 'UTC$offset', - ); - } - Future removeFromAlbum(List remoteIds, String albumId) async { final result = await _albumApiRepository.removeAssets(albumId, remoteIds); if (result.removed.isNotEmpty) { @@ -217,11 +135,4 @@ class ActionService { return true; } - Future applyEdits(String remoteId, List edits) async { - if (edits.isEmpty) { - await _assetApiRepository.removeEdits(remoteId); - } else { - await _assetApiRepository.editAsset(remoteId, edits); - } - } } diff --git a/mobile/test/providers/infrastructure/action_provider_test.dart b/mobile/test/providers/infrastructure/action_provider_test.dart deleted file mode 100644 index 7f5c8d4ec2..0000000000 --- a/mobile/test/providers/infrastructure/action_provider_test.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/user.model.dart'; -import 'package:immich_mobile/domain/services/asset.service.dart'; -import 'package:immich_mobile/domain/services/user.service.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/action.service.dart'; -import 'package:immich_mobile/services/foreground_upload.service.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockActionService extends Mock implements ActionService {} - -class MockAssetService extends Mock implements AssetService {} - -class MockForegroundUploadService extends Mock implements ForegroundUploadService {} - -class MockUserService extends Mock implements UserService {} - -class FakeBuildContext extends Fake implements BuildContext {} - -final _user = UserDto(id: 'user-1', email: 'user@test.dev', name: 'user', profileChangedAt: DateTime(2026)); - -final _asset = RemoteAsset( - id: 'asset-1', - name: 'photo.jpg', - ownerId: 'user-1', - checksum: 'checksum-1', - type: AssetType.image, - createdAt: DateTime(2026, 6, 10, 10, 27), - updatedAt: DateTime(2026, 6, 10, 10, 27), - isEdited: false, -); - -void main() { - late ProviderContainer container; - late MockActionService actionService; - late MockAssetService assetService; - - setUpAll(() { - registerFallbackValue(FakeBuildContext()); - registerFallbackValue(_asset); - registerFallbackValue([]); - }); - - setUp(() { - actionService = MockActionService(); - assetService = MockAssetService(); - final userService = MockUserService(); - - when(() => actionService.editDateTime(any(), any())).thenAnswer((_) async => true); - when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty()); - when(() => assetService.getExif(any())).thenAnswer((_) async => null); - when(() => userService.tryGetMyUser()).thenReturn(_user); - when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty()); - - container = ProviderContainer( - overrides: [ - actionServiceProvider.overrideWithValue(actionService), - assetServiceProvider.overrideWithValue(assetService), - foregroundUploadServiceProvider.overrideWithValue(MockForegroundUploadService()), - currentUserProvider.overrideWith((ref) => CurrentUserProvider(userService)), - ], - ); - addTearDown(container.dispose); - }); - - group('editDateTime', () { - test('refreshes the exif provider when editing from the viewer', () async { - container.read(assetViewerProvider.notifier).setAsset(_asset); - container.listen(assetExifProvider(_asset), (_, __) {}); - await container.read(assetExifProvider(_asset).future); - - final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext()); - - expect(result?.success, isTrue); - await container.read(assetExifProvider(_asset).future); - verify(() => assetService.getExif(_asset)).called(2); - }); - - test('leaves the exif provider cached when editing from the timeline', () async { - container.read(assetViewerProvider.notifier).setAsset(_asset); - container.listen(assetExifProvider(_asset), (_, __) {}); - await container.read(assetExifProvider(_asset).future); - - final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.timeline, FakeBuildContext()); - - expect(result?.success, isTrue); - await container.read(assetExifProvider(_asset).future); - verify(() => assetService.getExif(_asset)).called(1); - }); - - test('does not refresh the exif provider when the edit is cancelled', () async { - when(() => actionService.editDateTime(any(), any())).thenAnswer((_) async => false); - container.read(assetViewerProvider.notifier).setAsset(_asset); - container.listen(assetExifProvider(_asset), (_, __) {}); - await container.read(assetExifProvider(_asset).future); - - final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext()); - - expect(result, isNull); - await container.read(assetExifProvider(_asset).future); - verify(() => assetService.getExif(_asset)).called(1); - }); - }); -} diff --git a/mobile/test/riverpod_mocks.dart b/mobile/test/riverpod_mocks.dart new file mode 100644 index 0000000000..24867b226e --- /dev/null +++ b/mobile/test/riverpod_mocks.dart @@ -0,0 +1,8 @@ +import 'package:immich_mobile/models/server_info/server_version.model.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; + +class StubServerInfoNotifier extends ServerInfoNotifier { + StubServerInfoNotifier(super.serverInfoService, {required ServerVersion version}) { + state = state.copyWith(serverVersion: version); + } +} diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index 76f02e29d0..0a1fbf08bc 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -89,46 +89,4 @@ void main() { }); }); - group('ActionService.applyDateTime', () { - const ids = ['asset_id_1']; - - test('sends the picked value to the api with its offset intact', () async { - const picked = '2026-06-10T19:15:00.000+06:00'; - when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {}); - when( - () => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00'), - ).thenAnswer((_) async {}); - - await sut.applyDateTime(ids, picked); - - verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1); - verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00')).called(1); - }); - - test('handles negative offsets', () async { - const picked = '2026-01-05T08:00:00.000-05:30'; - when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {}); - when( - () => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30'), - ).thenAnswer((_) async {}); - - await sut.applyDateTime(ids, picked); - - verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1); - verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30')).called(1); - }); - - test('writes no timezone when the value has no offset', () async { - const picked = '2026-06-10T13:15:00.000Z'; - when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {}); - when( - () => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null), - ).thenAnswer((_) async {}); - - await sut.applyDateTime(ids, picked); - - verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1); - verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null)).called(1); - }); - }); } diff --git a/mobile/test/unit/presentation/actions/edit_action_test.dart b/mobile/test/unit/presentation/actions/edit_action_test.dart new file mode 100644 index 0000000000..063301f9c4 --- /dev/null +++ b/mobile/test/unit/presentation/actions/edit_action_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/models/server_info/server_version.model.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/edit_asset.action.dart'; +import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; +import 'package:immich_mobile/presentation/actions/edit_location.action.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/utils/option.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../riverpod_mocks.dart'; +import '../../../service.mocks.dart'; +import '../../factories/remote_asset_factory.dart'; +import '../presentation_context.dart'; + +void main() { + late PresentationContext context; + late MockAssetService assetService; + + setUp(() async { + context = await PresentationContext.create(); + assetService = context.service.asset.service; + }); + + tearDown(() { + context.dispose(); + }); + + RemoteAsset owned({AssetType type = .image}) => + RemoteAssetFactory.create(ownerId: context.currentUser.id, type: type); + + const supportedVersion = ServerVersion(major: 2, minor: 6, patch: 0); + + List reportedVersion(ServerVersion version) => [ + serverInfoProvider.overrideWith((ref) => StubServerInfoNotifier(context.service.serverInfo, version: version)), + ]; + + late BuildContext actionContext; + late WidgetRef actionRef; + + Future pumpAction( + WidgetTester tester, + ActionBuilder action, + Set selection, { + List overrides = const [], + }) => tester.pumpTestWidget( + context, + Consumer( + builder: (widgetContext, ref, _) { + actionContext = widgetContext; + actionRef = ref; + return ActionIconButton(action: action); + }, + ), + overrides: [...context.selected(selection), ...overrides], + ); + + group('EditAssetAction', () { + Future pumpEditAsset( + WidgetTester tester, + Set selection, { + ServerVersion version = supportedVersion, + }) => pumpAction( + tester, + const EditAssetAction(source: .timeline), + selection, + overrides: [...reportedVersion(version)], + ); + + testWidgets('offers to edit a single owned editable asset', (tester) async { + await pumpEditAsset(tester, {owned()}); + + expect(find.byType(ImmichIconButton), findsOneWidget); + }); + + testWidgets('is hidden when the server predates edit sync', (tester) async { + await pumpEditAsset(tester, {owned()}, version: const ServerVersion(major: 2, minor: 5, patch: 9)); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('is hidden for more than one asset', (tester) async { + await pumpEditAsset(tester, {owned(), owned()}); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('is hidden for an asset owned by someone else', (tester) async { + await pumpEditAsset(tester, {RemoteAssetFactory.create()}); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('is hidden for a non-editable asset', (tester) async { + await pumpEditAsset(tester, {owned(type: .video)}); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('reads the edits and exif for the asset it is about to open', (tester) async { + final asset = owned(); + + await pumpEditAsset(tester, {asset}); + await tester.tap(find.byType(ImmichIconButton)); + await tester.pump(); + + verify(() => context.repository.remoteAsset.repo.getAssetEdits(asset.id)).called(1); + verify(() => context.repository.remoteAsset.repo.getExif(asset.id)).called(1); + }); + }); + + group('EditLocationAction', () { + testWidgets('offers to edit an owned remote asset', (tester) async { + await pumpAction(tester, const EditLocationAction(source: .timeline), {owned()}); + + expect(find.byType(ImmichIconButton), findsOneWidget); + }); + + testWidgets('is hidden without any owned remote asset', (tester) async { + await pumpAction(tester, const EditLocationAction(source: .timeline), {RemoteAssetFactory.create()}); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('saves the location against every owned asset and toasts the count', (tester) async { + final mine = owned(); + final theirs = RemoteAssetFactory.create(); + + await pumpAction(tester, const EditLocationAction(source: .timeline), {mine, theirs}); + await saveLocation(actionContext, actionRef, [mine.id], const LatLng(1, 2)); + await tester.pumpAndSettle(); + + final location = + verify(() => assetService.update([mine.id], location: captureAny(named: 'location'))).captured.single + as Option; + expect(location.unwrapOrNull?.latitude, 1); + expect(location.unwrapOrNull?.longitude, 2); + }); + }); + + group('EditDateTimeAction', () { + testWidgets('offers to edit an owned remote asset', (tester) async { + await pumpAction(tester, const EditDateTimeAction(source: .timeline), {owned()}); + + expect(find.byType(ImmichIconButton), findsOneWidget); + }); + + testWidgets('is hidden without any owned remote asset', (tester) async { + await pumpAction(tester, const EditDateTimeAction(source: .timeline), {RemoteAssetFactory.create()}); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('saves the date against every owned asset and toasts the count', (tester) async { + const picked = '2026-06-10T19:15:00.000+06:00'; + final mine = owned(); + final theirs = RemoteAssetFactory.create(); + + await pumpAction(tester, const EditDateTimeAction(source: .timeline), {mine, theirs}); + await saveDateTime(actionContext, actionRef, [mine.id], picked); + await tester.pumpAndSettle(); + + verify(() => assetService.update([mine.id], dateTime: const Some(picked))).called(1); + }); + }); +} diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 2e5871a419..ba12183652 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -54,6 +54,7 @@ class PresentationContext { gCastServiceProvider.overrideWithValue(service.cast), serverInfoServiceProvider.overrideWithValue(service.serverInfo), inLockedViewProvider.overrideWithValue(false), + remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo), ]; List selected(Set assets) => [