diff --git a/mobile/lib/presentation/actions/open_in_browser.action.dart b/mobile/lib/presentation/actions/open_in_browser.action.dart new file mode 100644 index 0000000000..b6cc85f96f --- /dev/null +++ b/mobile/lib/presentation/actions/open_in_browser.action.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class OpenInBrowserAction extends ActionBuilder { + final String remoteId; + final TimelineOrigin origin; + + const OpenInBrowserAction({required this.remoteId, required this.origin}); + + @override + ActionData build(BuildContext context, WidgetRef ref) => + .new(icon: Icons.open_in_browser, label: context.t.open_in_browser, onAction: () => _open(ref)); + + Future _open(WidgetRef ref) async { + final serverEndpoint = ref.read(storeServiceProvider).get(.serverEndpoint).replaceFirst('/api', ''); + final url = Uri.parse('$serverEndpoint${webPathFor(origin)}/photos/$remoteId'); + + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: .externalApplication); + } + } +} + +@visibleForTesting +String webPathFor(TimelineOrigin origin) => switch (origin) { + .favorite => '/favorites', + .trash => '/trash', + .archive => '/archive', + _ => '', +}; diff --git a/mobile/lib/presentation/actions/set_profile_picture.action.dart b/mobile/lib/presentation/actions/set_profile_picture.action.dart new file mode 100644 index 0000000000..9ded6d815a --- /dev/null +++ b/mobile/lib/presentation/actions/set_profile_picture.action.dart @@ -0,0 +1,22 @@ +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/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class SetProfilePictureAction extends ActionBuilder { + final BaseAsset asset; + + const SetProfilePictureAction({required this.asset}); + + @override + ActionData build(BuildContext context, WidgetRef ref) => .new( + icon: Icons.account_circle_outlined, + label: context.t.set_as_profile_picture, + onAction: () async => unawaited(context.pushRoute(ProfilePictureCropRoute(asset: asset))), + ); +} diff --git a/mobile/lib/presentation/actions/similar_photos.action.dart b/mobile/lib/presentation/actions/similar_photos.action.dart new file mode 100644 index 0000000000..6833d81315 --- /dev/null +++ b/mobile/lib/presentation/actions/similar_photos.action.dart @@ -0,0 +1,42 @@ +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/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class SimilarPhotosAction extends ActionBuilder { + final String assetId; + + const SimilarPhotosAction({required this.assetId}); + + @override + ActionData build(BuildContext context, WidgetRef ref) => + .new(icon: Icons.compare, label: context.t.view_similar_photos, onAction: () => _search(context, ref)); + + Future _search(BuildContext context, WidgetRef ref) async { + ref.invalidate(assetViewerProvider); + ref.invalidate(paginatedSearchProvider); + + ref.read(searchPreFilterProvider.notifier) + ..clear() + ..setFilter( + .new( + assetId: assetId, + people: {}, + location: .new(), + camera: .new(), + date: .new(), + display: .new(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: .new(), + mediaType: .other, + ), + ); + + unawaited(context.navigateTo(const DriftSearchRoute())); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart deleted file mode 100644 index 541a9f8093..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class OpenInBrowserActionButton extends ConsumerWidget { - final String remoteId; - final TimelineOrigin origin; - final bool iconOnly; - final bool menuItem; - - const OpenInBrowserActionButton({ - super.key, - required this.remoteId, - required this.origin, - this.iconOnly = false, - this.menuItem = false, - }); - - void _onTap() async { - final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', ''); - - String originPath = ''; - switch (origin) { - case TimelineOrigin.favorite: - originPath = '/favorites'; - break; - case TimelineOrigin.trash: - originPath = '/trash'; - break; - case TimelineOrigin.archive: - originPath = '/archive'; - break; - default: - break; - } - - final url = '$serverEndpoint$originPath/photos/$remoteId'; - if (await canLaunchUrl(Uri.parse(url))) { - await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); - } - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - label: 'open_in_browser'.t(context: context), - iconData: Icons.open_in_browser, - iconOnly: iconOnly, - menuItem: menuItem, - onPressed: _onTap, - ); - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart deleted file mode 100644 index c8dbb7cb1f..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart +++ /dev/null @@ -1,35 +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/routing/router.dart'; - -class SetProfilePictureActionButton extends ConsumerWidget { - final BaseAsset asset; - final bool iconOnly; - final bool menuItem; - - const SetProfilePictureActionButton({super.key, required this.asset, this.iconOnly = false, this.menuItem = false}); - - void _onTap(BuildContext context) { - if (!context.mounted) { - return; - } - - context.pushRoute(ProfilePictureCropRoute(asset: asset)); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - iconData: Icons.account_circle_outlined, - label: "set_as_profile_picture".t(context: context), - iconOnly: iconOnly, - menuItem: menuItem, - onPressed: () => _onTap(context), - maxWidth: 100, - ); - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart deleted file mode 100644 index 42dcfa683a..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart +++ /dev/null @@ -1,58 +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/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/models/search/search_filter.model.dart'; -import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.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/routing/router.dart'; - -class SimilarPhotosActionButton extends ConsumerWidget { - final String assetId; - final bool iconOnly; - final bool menuItem; - - const SimilarPhotosActionButton({super.key, required this.assetId, this.iconOnly = false, this.menuItem = false}); - - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - ref.invalidate(assetViewerProvider); - ref.invalidate(paginatedSearchProvider); - - ref.read(searchPreFilterProvider.notifier) - ..clear() - ..setFilter( - SearchFilter( - assetId: assetId, - people: {}, - location: SearchLocationFilter(), - camera: SearchCameraFilter(), - date: SearchDateFilter(), - display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), - rating: SearchRatingFilter(), - mediaType: AssetType.other, - ), - ); - - unawaited(context.navigateTo(const DriftSearchRoute())); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - iconData: Icons.compare, - label: "view_similar_photos".t(context: context), - iconOnly: iconOnly, - menuItem: menuItem, - onPressed: () => _onTap(context, ref), - maxWidth: 100, - ); - } -} diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index b8d231e436..32aaf360d0 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -12,19 +12,19 @@ import 'package:immich_mobile/presentation/actions/archive.action.dart'; import 'package:immich_mobile/presentation/actions/asset_debug.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/open_in_browser.action.dart'; import 'package:immich_mobile/presentation/actions/restore.action.dart'; +import 'package:immich_mobile/presentation/actions/set_profile_picture.action.dart'; +import 'package:immich_mobile/presentation/actions/similar_photos.action.dart'; import 'package:immich_mobile/presentation/actions/stack.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/cast_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/like_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/set_album_cover.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_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/similar_photos_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -203,22 +203,13 @@ enum ActionButtonType { ), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unstack => ActionMenuItem(action: StackAction(source: context.source)), - ActionButtonType.openInBrowser => OpenInBrowserActionButton( - remoteId: context.asset.remoteId!, - origin: context.timelineOrigin, - iconOnly: iconOnly, - menuItem: menuItem, + ActionButtonType.openInBrowser => ActionMenuItem( + action: OpenInBrowserAction(remoteId: context.asset.remoteId!, origin: context.timelineOrigin), ), - ActionButtonType.similarPhotos => SimilarPhotosActionButton( - assetId: (context.asset as RemoteAsset).id, - iconOnly: iconOnly, - menuItem: menuItem, - ), - ActionButtonType.setProfilePicture => SetProfilePictureActionButton( - asset: context.asset, - iconOnly: iconOnly, - menuItem: menuItem, + ActionButtonType.similarPhotos => ActionMenuItem( + action: SimilarPhotosAction(assetId: (context.asset as RemoteAsset).id), ), + ActionButtonType.setProfilePicture => ActionMenuItem(action: SetProfilePictureAction(asset: context.asset)), ActionButtonType.openInfo => BaseActionButton( label: 'info'.tr(), iconData: Icons.info_outline, diff --git a/mobile/test/unit/presentation/actions/open_in_browser_action_test.dart b/mobile/test/unit/presentation/actions/open_in_browser_action_test.dart new file mode 100644 index 0000000000..0ed99e7704 --- /dev/null +++ b/mobile/test/unit/presentation/actions/open_in_browser_action_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/open_in_browser.action.dart'; +import 'package:immich_ui/immich_ui.dart'; + +import '../presentation_context.dart'; + +void main() { + late PresentationContext context; + + setUp(() async { + context = await PresentationContext.create(); + }); + + tearDown(() { + context.dispose(); + }); + + group('webPathFor', () { + const dedicatedPages = { + TimelineOrigin.favorite: '/favorites', + TimelineOrigin.trash: '/trash', + TimelineOrigin.archive: '/archive', + }; + + for (final origin in TimelineOrigin.values) { + final expected = dedicatedPages[origin] ?? ''; + + test('opens ${origin.name} on ${expected.isEmpty ? 'the main timeline' : expected}', () { + expect(webPathFor(origin), expected); + }); + } + }); + + group('OpenInBrowserAction', () { + testWidgets('always renders, since the kebab menu decides whether to offer it', (tester) async { + await tester.pumpTestWidget( + context, + const ActionIconButton( + action: OpenInBrowserAction(remoteId: 'remote-1', origin: .main), + ), + ); + + expect(find.byType(ImmichIconButton), findsOneWidget); + }); + }); +}