diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index ca16d1f980..97e2c735b4 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/domain/models/album/local_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/models/exif.model.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; @@ -77,4 +78,12 @@ class AssetService { await _apiRepository.updateFavorite(remoteIds, isFavorite); await _remoteRepository.updateFavorite(remoteIds, isFavorite); } + + Future applyEdits(String remoteId, List edits) async { + if (edits.isEmpty) { + await _apiRepository.removeEdits(remoteId); + } else { + await _apiRepository.editAsset(remoteId, edits); + } + } } diff --git a/mobile/lib/presentation/actions/edit.action.dart b/mobile/lib/presentation/actions/edit.action.dart new file mode 100644 index 0000000000..530a1058f4 --- /dev/null +++ b/mobile/lib/presentation/actions/edit.action.dart @@ -0,0 +1,70 @@ +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/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/semver.dart'; + +class EditAssetAction extends AssetAction { + const EditAssetAction({required super.assets}); + + @override + IconData get icon => Icons.tune; + + @override + String label(ActionScope scope) => scope.context.t.edit; + + @override + Iterable filter(ActionScope scope) => + assets.where((asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isEditable).cast(); + + @override + bool isVisible(ActionScope scope) => + filter(scope).length == 1 && + scope.ref.watch(serverInfoProvider).serverVersion >= const SemVer(major: 2, minor: 6, patch: 0); + + @override + Future onAction(ActionScope scope) async { + final ActionScope(:context, :ref) = scope; + + final asset = filter(scope).first; + final remoteId = asset.id; + final repository = ref.read(remoteAssetRepositoryProvider); + final (edits, exif) = await (repository.getAssetEdits(remoteId), repository.getExif(remoteId)).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, remoteId, newEdits), + ), + ), + ); + } + + @visibleForTesting + static 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 Duration(seconds: 10)), + websocket.waitForEvent('AssetEditReadyV2', isCurrentId, const Duration(seconds: 10)), + ]).catchError((_) {}); + } +} diff --git a/mobile/lib/presentation/actions/favorite.action.dart b/mobile/lib/presentation/actions/favorite.action.dart index 33d4bb3b6c..f70d2a1c8b 100644 --- a/mobile/lib/presentation/actions/favorite.action.dart +++ b/mobile/lib/presentation/actions/favorite.action.dart @@ -21,7 +21,7 @@ class FavoriteAction extends AssetAction { .where( (asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite, ) - .cast(); + .cast(); @override bool isVisible(ActionScope scope) => filter(scope).isNotEmpty; 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/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 01a48e7e97..f1d0a3359e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -4,11 +4,12 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; 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.widget.dart'; +import 'package:immich_mobile/presentation/actions/edit.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_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/restore_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'; @@ -17,10 +18,9 @@ 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/providers/user.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'; class ViewerBottomBar extends ConsumerWidget { const ViewerBottomBar({super.key}); @@ -37,10 +37,10 @@ class ViewerBottomBar extends ConsumerWidget { final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; 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; + final actionAsset = [asset]; final actions = [ if (isInTrash && isOwner && asset.hasRemote) @@ -51,9 +51,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(), + ActionColumnButtonWidget(action: EditAssetAction(assets: actionAsset)), if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), ], if (isOwner) ...[ @@ -104,7 +102,10 @@ class ViewerBottomBar extends ConsumerWidget { OcrToggleButton(asset: asset), if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), if (!isReadonlyModeEnabled) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ImmichColorOverride( + color: Colors.white, + child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ), ], ), ), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index ed62b9a0e8..597101f993 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -1,13 +1,11 @@ import 'dart:async'; -import 'package:auto_route/auto_route.dart'; import 'package:background_downloader/background_downloader.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/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/asset.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; @@ -17,18 +15,13 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.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/download.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; -import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final actionProvider = NotifierProvider(ActionNotifier.new, dependencies: [multiSelectProvider]); @@ -135,16 +128,6 @@ class ActionNotifier extends Notifier { }; } - Future troubleshoot(ActionSource source, BuildContext context) async { - final assets = _getAssets(source); - if (assets.length > 1) { - return ActionResult(count: assets.length, success: false, error: 'Cannot troubleshoot multiple assets'); - } - unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first))); - - return ActionResult(count: assets.length, success: true); - } - Future shareLink(ActionSource source, BuildContext context) async { final ids = _getRemoteIdsForSource(source); try { @@ -631,37 +614,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 8e01777c5d..a94fa6e3ea 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/models/store.model.dart'; import 'package:immich_mobile/domain/services/tag.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -305,14 +304,6 @@ class ActionService { return true; } - Future applyEdits(String remoteId, List edits) async { - if (edits.isEmpty) { - await _assetApiRepository.removeEdits(remoteId); - } else { - await _assetApiRepository.editAsset(remoteId, edits); - } - } - Future _deleteLocalAssets(List localIds) async { final deletedIds = await _assetMediaRepository.deleteAll(localIds); if (deletedIds.isEmpty) { diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index 70f706f7fe..fb4dc29159 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/server_info.service.dart'; import 'package:mocktail/mocktail.dart'; class MockStoreService extends Mock implements StoreService {} @@ -20,3 +21,5 @@ class MockPartnerService extends Mock implements PartnerService {} class MockAssetService extends Mock implements AssetService {} class MockUserService extends Mock implements UserService {} + +class MockServerInfoService extends Mock implements ServerInfoService {} diff --git a/mobile/test/unit/factories/remote_asset_factory.dart b/mobile/test/unit/factories/remote_asset_factory.dart index 669eb3998a..95283c8f81 100644 --- a/mobile/test/unit/factories/remote_asset_factory.dart +++ b/mobile/test/unit/factories/remote_asset_factory.dart @@ -5,7 +5,13 @@ import '../../utils.dart'; class RemoteAssetFactory { const RemoteAssetFactory(); - static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) { + static RemoteAsset create({ + String? id, + String? name, + String? ownerId, + bool isFavorite = false, + AssetType type = .image, + }) { id = TestUtils.uuid(id); return RemoteAsset( @@ -13,7 +19,7 @@ class RemoteAssetFactory { name: name ?? 'remote_$id.jpg', ownerId: TestUtils.uuid(ownerId), checksum: 'checksum-$id', - type: .image, + type: type, createdAt: TestUtils.yesterday(), updatedAt: TestUtils.now(), isFavorite: isFavorite, diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 49bab00704..c1b7561deb 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:mocktail/mocktail.dart' as mock; import 'package:mocktail/mocktail.dart'; @@ -15,6 +16,7 @@ class RepositoryMocks { final localAlbum = MockLocalAlbumRepository(); final localAsset = MockDriftLocalAssetRepository(); final trashedAsset = MockTrashedLocalAssetRepository(); + final remoteAsset = MockRemoteAssetRepository(); final nativeApi = MockNativeSyncApi(); @@ -28,12 +30,13 @@ class RepositoryMocks { reset(localAsset); reset(trashedAsset); reset(nativeApi); + reset(remoteAsset); } } class ServiceMocks { - final PartnerStub partner = PartnerStub(MockPartnerService()); - final UserStub user = UserStub(MockUserService()); + final partner = PartnerStub(MockPartnerService()); + final user = UserStub(MockUserService()); final asset = AssetStub(MockAssetService()); ServiceMocks() { @@ -69,6 +72,7 @@ class ServiceMocks { void _stubAssetService() { when(asset.updateFavorite).thenAnswer((_) async {}); + when(asset.applyEdits).thenAnswer((_) async {}); } } @@ -76,10 +80,11 @@ void _registerFallbacks() { registerFallbackValue(LocalAlbumFactory.create()); registerFallbackValue(LocalAssetFactory.create()); registerFallbackValue(Uint8List(0)); + registerFallbackValue([]); } -extension type const Stub(T mockedService) { - void reset() => mock.reset(mockedService); +extension type const Stub(T mockedClass) { + void reset() => mock.reset(mockedClass); } extension type const PartnerStub(MockPartnerService service) implements Stub { @@ -130,4 +135,7 @@ extension type const UserStub(MockUserService service) implements Stub { Future Function() get updateFavorite => () => service.updateFavorite(any(), any()); + + Future Function() get applyEdits => + () => service.applyEdits(any(), any()); } 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..47d3ec960b --- /dev/null +++ b/mobile/test/unit/presentation/actions/edit_action_test.dart @@ -0,0 +1,119 @@ +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/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/models/server_info/server_version.model.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/edit.action.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_ui/immich_ui.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../infrastructure/repository.mock.dart'; +import '../../factories/remote_asset_factory.dart'; +import '../../presentation_context.dart'; +import '../../riverpod_mocks.dart'; + +void main() { + late PresentationContext context; + + const supportedVersion = ServerVersion(major: 2, minor: 6, patch: 0); + const unsupportedVersion = ServerVersion(major: 2, minor: 5, patch: 9); + + setUp(() async { + context = await PresentationContext.create(); + }); + + tearDown(() { + context.dispose(); + }); + + List overrides(ServerVersion version) => [ + ...context.overrides, + serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier(version)), + ]; + + RemoteAsset owned({AssetType type = AssetType.image}) => + RemoteAssetFactory.create(ownerId: context.currentUser.id, type: type); + + Future pumpAction(WidgetTester tester, EditAssetAction action, {ServerVersion version = supportedVersion}) => + tester.pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides(version)); + + group('EditAssetAction', () { + testWidgets('visible for a single owned editable asset on a supported server', (tester) async { + await pumpAction(tester, EditAssetAction(assets: [owned()])); + + expect(find.byType(ImmichIconButton), findsOneWidget); + }); + + testWidgets('hidden when the server is older than 2.6.0', (tester) async { + await pumpAction(tester, EditAssetAction(assets: [owned()]), version: unsupportedVersion); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('hidden for more than one asset', (tester) async { + await pumpAction(tester, EditAssetAction(assets: [owned(), owned()])); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('hidden for an asset owned by someone else', (tester) async { + await pumpAction(tester, EditAssetAction(assets: [RemoteAssetFactory.create()])); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('hidden for a non-editable asset', (tester) async { + await pumpAction(tester, EditAssetAction(assets: [owned(type: AssetType.video)])); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); + + group('EditAssetAction onAction', () { + testWidgets('reads the edits and exif for the asset from the repository', (tester) async { + final asset = owned(); + final repository = MockRemoteAssetRepository(); + when(() => repository.getAssetEdits(any())).thenAnswer((_) async => const []); + when(() => repository.getExif(any())).thenAnswer((_) async => null); + + await tester.pumpTestAction( + EditAssetAction(assets: [asset]), + overrides: [...overrides(supportedVersion), remoteAssetRepositoryProvider.overrideWithValue(repository)], + ); + await tester.pumpAndSettle(); + + verify(() => repository.getAssetEdits(asset.id)).called(1); + verify(() => repository.getExif(asset.id)).called(1); + }); + + testWidgets('applyEdits forwards the edits to the service and waits for both ready events', (tester) async { + late FakeWebsocketNotifier websocket; + const edits = []; + + late WidgetRef capturedRef; + await tester.pumpTestWidget( + Consumer( + builder: (_, ref, _) { + capturedRef = ref; + return const SizedBox.shrink(); + }, + ), + overrides: [ + ...context.overrides, + assetServiceProvider.overrideWithValue(context.mocks.asset.service), + websocketProvider.overrideWith((ref) => websocket = FakeWebsocketNotifier(ref)), + ], + ); + + await EditAssetAction.applyEdits(capturedRef, 'asset-1', edits); + + verify(() => context.mocks.asset.service.applyEdits('asset-1', edits)).called(1); + expect(websocket.waitedEvents, containsAll(['AssetEditReadyV1', 'AssetEditReadyV2'])); + }); + }); +} diff --git a/mobile/test/unit/riverpod_mocks.dart b/mobile/test/unit/riverpod_mocks.dart new file mode 100644 index 0000000000..609f20c9f9 --- /dev/null +++ b/mobile/test/unit/riverpod_mocks.dart @@ -0,0 +1,24 @@ +import 'package:immich_mobile/models/server_info/server_version.model.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; + +import '../domain/service.mock.dart'; + +class FakeServerInfoNotifier extends ServerInfoNotifier { + FakeServerInfoNotifier([ServerVersion version = const ServerVersion(major: 2, minor: 6, patch: 0)]) + : super(MockServerInfoService()) { + state = state.copyWith(serverVersion: version); + } +} + +class FakeWebsocketNotifier extends WebsocketNotifier { + FakeWebsocketNotifier(super.ref); + + final List waitedEvents = []; + + @override + Future waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) { + waitedEvents.add(event); + return Future.value(); + } +}