diff --git a/mobile/lib/presentation/actions/asset_debug.action.dart b/mobile/lib/presentation/actions/asset_debug.action.dart index aec99fc90b..da95cecf19 100644 --- a/mobile/lib/presentation/actions/asset_debug.action.dart +++ b/mobile/lib/presentation/actions/asset_debug.action.dart @@ -17,9 +17,14 @@ class AssetDebugAction extends AssetAction { @override String label(ActionScope scope) => scope.context.t.troubleshoot; + static bool canShow({required int assetCount, required bool advancedTroubleshooting}) => + assetCount == 1 && advancedTroubleshooting; + @override - bool isVisible(ActionScope scope) => - assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting); + bool isVisible(ActionScope scope) => canShow( + assetCount: assets.length, + advancedTroubleshooting: scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting), + ); @override Future onAction(ActionScope scope) async => diff --git a/mobile/lib/presentation/actions/edit_image.action.dart b/mobile/lib/presentation/actions/edit_image.action.dart new file mode 100644 index 0000000000..b2fd7cba10 --- /dev/null +++ b/mobile/lib/presentation/actions/edit_image.action.dart @@ -0,0 +1,67 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.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/presentation/pages/edit/editor.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_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/routes.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/semver.dart'; + +class EditImageAction extends AssetAction { + const EditImageAction({required super.assets}); + + @override + IconData get icon => Icons.tune; + + @override + String label(ActionScope scope) => scope.context.t.edit; + + static bool canShow({required BaseAsset asset, required bool isInLockedView, required SemVer serverVersion}) => + !isInLockedView && + asset.isEditable && + // edit sync was added in 2.6.0 + serverVersion >= const SemVer(major: 2, minor: 6, patch: 0); + + @override + bool isVisible(ActionScope scope) => + assets.length == 1 && + canShow( + asset: assets.first, + isInLockedView: scope.ref.watch(inLockedViewProvider), + serverVersion: scope.ref.watch(serverInfoProvider.select((state) => state.serverVersion)), + ); + + @override + Future onAction(ActionScope scope) async { + final ActionScope(:context, :ref) = scope; + final asset = assets.firstOrNull; + if (asset == null || asset.remoteId == null) { + return; + } + + final imageProvider = getFullImageProvider(asset, edited: false); + final image = Image(image: imageProvider); + final (edits, exifInfo) = await ( + ref.read(remoteAssetRepositoryProvider).getAssetEdits(asset.remoteId!), + ref.read(remoteAssetRepositoryProvider).getExif(asset.remoteId!), + ).wait; + + if (exifInfo == null) { + return; + } + + ref.read(editorStateProvider.notifier).init(edits, exifInfo); + await context.pushRoute( + DriftEditImageRoute( + image: image, + applyEdits: (edits) => ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits), + ), + ); + } +} diff --git a/mobile/lib/presentation/actions/open_activity.action.dart b/mobile/lib/presentation/actions/open_activity.action.dart new file mode 100644 index 0000000000..47b8c0670b --- /dev/null +++ b/mobile/lib/presentation/actions/open_activity.action.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.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/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class OpenActivityAction extends AssetAction { + const OpenActivityAction({required super.assets}); + + @override + IconData get icon => Icons.chat_outlined; + + @override + String label(ActionScope scope) => scope.context.t.activity; + + static bool canShow({required bool isInLockedView, required RemoteAlbum? album}) => + !isInLockedView && album != null && album.isActivityEnabled && album.isShared; + + @override + bool isVisible(ActionScope scope) => canShow( + isInLockedView: scope.ref.watch(inLockedViewProvider), + album: scope.ref.watch(currentRemoteAlbumProvider), + ); + + @override + Future onAction(ActionScope scope) async { + final album = scope.ref.read(currentRemoteAlbumProvider); + final asset = assets.firstOrNull; + if (album == null || asset == null) { + return; + } + + unawaited( + scope.context.pushRoute( + DriftActivitiesRoute(album: album, assetId: asset is RemoteAsset ? asset.id : null, assetName: asset.name), + ), + ); + } +} 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 21269951b5..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ /dev/null @@ -1,64 +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 { - final bool iconOnly; - final bool menuItem; - - const EditImageActionButton({super.key, this.iconOnly = false, this.menuItem = false}); - - @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, - iconOnly: iconOnly, - menuItem: menuItem, - ); - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/open_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/open_activity_action_button.widget.dart deleted file mode 100644 index 801ec82162..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/open_activity_action_button.widget.dart +++ /dev/null @@ -1,36 +0,0 @@ -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/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; - -class OpenActivityActionButton extends ConsumerWidget { - const OpenActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false}); - - final bool iconOnly; - final bool menuItem; - - void _onTap(BuildContext context, WidgetRef ref) { - final album = ref.read(currentRemoteAlbumProvider); - final asset = ref.read(assetViewerProvider).currentAsset; - if (album == null || asset == null) { - return; - } - context.router.push( - DriftActivitiesRoute(album: album, assetId: asset is RemoteAsset ? asset.id : null, assetName: asset.name), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) => BaseActionButton( - iconData: Icons.chat_outlined, - label: "activity".t(context: context), - onPressed: () => _onTap(context, ref), - iconOnly: iconOnly, - menuItem: menuItem, - ); -} diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 1efaa189b8..1ce78a8ec7 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -9,6 +9,8 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; +import 'package:immich_mobile/presentation/actions/edit_image.action.dart'; +import 'package:immich_mobile/presentation/actions/open_activity.action.dart'; import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; @@ -18,10 +20,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_a import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_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/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/like_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/open_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_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/remove_from_lock_folder_action_button.widget.dart'; @@ -111,7 +111,10 @@ enum ActionButtonType { bool shouldShow(ActionButtonContext context) { return switch (this) { - ActionButtonType.advancedInfo => context.advancedTroubleshooting, + ActionButtonType.advancedInfo => AssetDebugAction.canShow( + assetCount: context.selectedCount, + advancedTroubleshooting: context.advancedTroubleshooting, + ), ActionButtonType.share => true, ActionButtonType.shareLink => !context.isInLockedView && // @@ -195,14 +198,18 @@ enum ActionButtonType { context.timelineOrigin != TimelineOrigin.localAlbum && context.isOwner, ActionButtonType.cast => context.isCasting || context.asset.hasRemote, - ActionButtonType.editImage => - !context.isInLockedView && - context.asset.isEditable && - context.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0), + ActionButtonType.editImage => EditImageAction.canShow( + asset: context.asset, + isInLockedView: context.isInLockedView, + serverVersion: context.serverVersion, + ), ActionButtonType.addTo => !context.isInLockedView && // context.asset.hasRemote, - ActionButtonType.openActivity => _isInActivityAlbum(context), + ActionButtonType.openActivity => OpenActivityAction.canShow( + isInLockedView: context.isInLockedView, + album: context.currentAlbum, + ), ActionButtonType.slideshow => true, }; } @@ -319,9 +326,15 @@ enum ActionButtonType { }, ), ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem), - ActionButtonType.editImage => EditImageActionButton(iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.editImage when menuItem => ActionMenuItemWidget( + action: EditImageAction(assets: [context.asset]), + ), + ActionButtonType.editImage => ActionColumnButtonWidget(action: EditImageAction(assets: [context.asset])), ActionButtonType.addTo => AddActionButton(originalTheme: context.originalTheme), - ActionButtonType.openActivity => OpenActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.openActivity when menuItem => ActionMenuItemWidget( + action: OpenActivityAction(assets: [context.asset]), + ), + ActionButtonType.openActivity => ActionColumnButtonWidget(action: OpenActivityAction(assets: [context.asset])), }; } diff --git a/mobile/test/unit/factories/remote_album_factory.dart b/mobile/test/unit/factories/remote_album_factory.dart new file mode 100644 index 0000000000..89afce154e --- /dev/null +++ b/mobile/test/unit/factories/remote_album_factory.dart @@ -0,0 +1,25 @@ +import 'package:immich_mobile/domain/models/album/album.model.dart'; + +import '../../utils.dart'; + +class RemoteAlbumFactory { + const RemoteAlbumFactory(); + + static RemoteAlbum create({String? id, String? ownerId, bool isActivityEnabled = false, bool isShared = false}) { + id = TestUtils.uuid(id); + + return RemoteAlbum( + id: id, + name: 'album_$id', + ownerId: TestUtils.uuid(ownerId), + description: '', + createdAt: TestUtils.yesterday(), + updatedAt: TestUtils.now(), + isActivityEnabled: isActivityEnabled, + order: AlbumAssetOrder.desc, + assetCount: 1, + ownerName: 'owner', + isShared: isShared, + ); + } +} diff --git a/mobile/test/unit/presentation/actions/edit_image_action_test.dart b/mobile/test/unit/presentation/actions/edit_image_action_test.dart new file mode 100644 index 0000000000..12f75fbfc6 --- /dev/null +++ b/mobile/test/unit/presentation/actions/edit_image_action_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.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_image.action.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/services/server_info.service.dart'; +import 'package:immich_mobile/utils/semver.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../factories/local_asset_factory.dart'; +import '../../factories/remote_asset_factory.dart'; +import '../presentation_context.dart'; + +class _MockServerInfoService extends Mock implements ServerInfoService {} + +class _StubServerInfoNotifier extends ServerInfoNotifier { + _StubServerInfoNotifier(ServerVersion version) : super(_MockServerInfoService()) { + state = state.copyWith(serverVersion: version); + } +} + +void main() { + late PresentationContext context; + + const editCapableVersion = ServerVersion(major: 2, minor: 6, patch: 0); + + setUp(() async { + context = await PresentationContext.create(); + }); + + tearDown(() { + context.dispose(); + }); + + Future pumpAction( + WidgetTester tester, + EditImageAction action, { + ServerVersion serverVersion = editCapableVersion, + bool isInLockedView = false, + }) async { + await tester.pumpTestWidget( + context, + ActionIconButtonWidget(action: action), + overrides: [ + serverInfoProvider.overrideWith((ref) => _StubServerInfoNotifier(serverVersion)), + inLockedViewProvider.overrideWith((ref) => isInLockedView), + ], + ); + } + + group('EditImageAction', () { + testWidgets('visible for an editable remote asset on a capable server', (tester) async { + await pumpAction(tester, EditImageAction(assets: [RemoteAssetFactory.create()])); + + expect(find.byType(ImmichIconButton), findsOneWidget); + }); + + testWidgets('hidden for a local-only asset', (tester) async { + await pumpAction(tester, EditImageAction(assets: [LocalAssetFactory.create()])); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('hidden when the server is older than 2.6.0', (tester) async { + await pumpAction( + tester, + EditImageAction(assets: [RemoteAssetFactory.create()]), + serverVersion: const ServerVersion(major: 2, minor: 5, patch: 9), + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('hidden in the locked view', (tester) async { + await pumpAction(tester, EditImageAction(assets: [RemoteAssetFactory.create()]), isInLockedView: true); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('hidden for multiple assets', (tester) async { + await pumpAction(tester, EditImageAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()])); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); + + group('EditImageAction.canShow', () { + test('requires an editable asset, no locked view, and server >= 2.6.0', () { + final asset = RemoteAssetFactory.create(); + + expect(EditImageAction.canShow(asset: asset, isInLockedView: false, serverVersion: editCapableVersion), isTrue); + expect(EditImageAction.canShow(asset: asset, isInLockedView: true, serverVersion: editCapableVersion), isFalse); + expect( + EditImageAction.canShow( + asset: asset, + isInLockedView: false, + serverVersion: const SemVer(major: 2, minor: 5, patch: 9), + ), + isFalse, + ); + expect( + EditImageAction.canShow( + asset: LocalAssetFactory.create(), + isInLockedView: false, + serverVersion: editCapableVersion, + ), + isFalse, + ); + }); + }); +} diff --git a/mobile/test/unit/presentation/actions/open_activity_action_test.dart b/mobile/test/unit/presentation/actions/open_activity_action_test.dart new file mode 100644 index 0000000000..826518ece2 --- /dev/null +++ b/mobile/test/unit/presentation/actions/open_activity_action_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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/open_activity.action.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_ui/immich_ui.dart'; + +import '../../factories/remote_album_factory.dart'; +import '../../factories/remote_asset_factory.dart'; +import '../presentation_context.dart'; + +class _StubAlbumNotifier extends CurrentAlbumNotifier { + _StubAlbumNotifier(this.album); + + final RemoteAlbum? album; + + @override + RemoteAlbum? build() => album; +} + +void main() { + late PresentationContext context; + + setUp(() async { + context = await PresentationContext.create(); + }); + + tearDown(() { + context.dispose(); + }); + + Future pumpAction( + WidgetTester tester, { + RemoteAlbum? album, + bool isInLockedView = false, + List overrides = const [], + }) async { + await tester.pumpTestWidget( + context, + ActionIconButtonWidget(action: OpenActivityAction(assets: [RemoteAssetFactory.create()])), + overrides: [ + currentRemoteAlbumProvider.overrideWith(() => _StubAlbumNotifier(album)), + inLockedViewProvider.overrideWith((ref) => isInLockedView), + ...overrides, + ], + ); + } + + group('OpenActivityAction', () { + testWidgets('visible in a shared album with activity enabled', (tester) async { + await pumpAction(tester, album: RemoteAlbumFactory.create(isShared: true, isActivityEnabled: true)); + + expect(find.byType(ImmichIconButton), findsOneWidget); + }); + + testWidgets('hidden when there is no current album', (tester) async { + await pumpAction(tester, album: null); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('hidden when the album is not shared', (tester) async { + await pumpAction(tester, album: RemoteAlbumFactory.create(isShared: false, isActivityEnabled: true)); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('hidden when activity is disabled', (tester) async { + await pumpAction(tester, album: RemoteAlbumFactory.create(isShared: true, isActivityEnabled: false)); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('hidden in the locked view', (tester) async { + await pumpAction( + tester, + album: RemoteAlbumFactory.create(isShared: true, isActivityEnabled: true), + isInLockedView: true, + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); +}