Compare commits

...

2 Commits

Author SHA1 Message Date
shenlong-tanwen 1cf19f19df feat: open in browser, similar, set profile action 2026-07-09 23:57:54 +05:30
shenlong-tanwen cc5ac5cca5 feat: cast and slideshow action 2026-07-09 23:54:26 +05:30
12 changed files with 119 additions and 200 deletions
@@ -0,0 +1,25 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastAction extends BaseAction {
const CastAction._({required super.scope, required super.icon, required super.label});
factory CastAction({required ActionScope scope}) {
final casting = scope.ref.watch(castProvider.select((state) => state.isCasting));
return CastAction._(
scope: scope,
icon: casting ? Icons.cast_connected_rounded : Icons.cast_rounded,
label: scope.context.t.cast,
);
}
@override
Future<void> onAction() async {
unawaited(showDialog(context: scope.context, builder: (_) => const CastDialog()));
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:url_launcher/url_launcher.dart';
class OpenInBrowserAction extends BaseAction {
final String remoteId;
final TimelineOrigin origin;
OpenInBrowserAction({required this.remoteId, required this.origin, required super.scope})
: super(icon: Icons.open_in_browser, label: scope.context.t.open_in_browser);
@override
Future<void> onAction() async {
final serverEndpoint = Store.get(.serverEndpoint).replaceFirst('/api', '');
final originPath = switch (origin) {
.favorite => '/favorites',
.trash => '/trash',
.archive => '/archive',
_ => '',
};
final url = Uri.parse('$serverEndpoint$originPath/photos/$remoteId');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: .externalApplication);
}
}
}
@@ -0,0 +1,18 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.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 BaseAction {
final BaseAsset asset;
SetProfilePictureAction({required this.asset, required super.scope})
: super(icon: Icons.account_circle_outlined, label: scope.context.t.set_as_profile_picture);
@override
Future<void> onAction() async => unawaited(scope.context.pushRoute(ProfilePictureCropRoute(asset: asset)));
}
@@ -2,26 +2,23 @@ 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/generated/translations.g.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/actions/action.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 {
class SimilarPhotosAction extends BaseAction {
final String assetId;
final bool iconOnly;
final bool menuItem;
const SimilarPhotosActionButton({super.key, required this.assetId, this.iconOnly = false, this.menuItem = false});
SimilarPhotosAction({required this.assetId, required super.scope})
: super(icon: Icons.compare, label: scope.context.t.view_similar_photos);
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
ref.invalidate(assetViewerProvider);
ref.invalidate(paginatedSearchProvider);
@@ -43,16 +40,4 @@ class SimilarPhotosActionButton extends ConsumerWidget {
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,
);
}
}
@@ -0,0 +1,18 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SlideshowAction extends BaseAction {
SlideshowAction({required super.scope}) : super(icon: Icons.slideshow, label: scope.context.t.slideshow);
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
unawaited(context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))));
}
}
@@ -1,30 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastActionButton extends ConsumerWidget {
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
return BaseActionButton(
iconData: isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded,
iconColor: isCasting ? context.primaryColor : null, // null = default color
label: "cast".t(context: context),
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
iconOnly: iconOnly,
menuItem: menuItem,
);
}
}
@@ -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,
);
}
}
@@ -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,
);
}
}
@@ -1,34 +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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SlideshowActionButton extends ConsumerWidget {
final bool iconOnly;
final bool menuItem;
const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) {
if (!context.mounted) {
return;
}
context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.slideshow,
label: "slideshow".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
+13 -20
View File
@@ -12,24 +12,24 @@ import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
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/cast.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/slideshow.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/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/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/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -204,7 +204,7 @@ enum ActionButtonType {
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.slideshow => ActionMenuItemWidget(action: SlideshowAction(scope: scope)),
ActionButtonType.archive || ActionButtonType.unarchive => ActionMenuItemWidget(
action: ArchiveAction(assets: [context.asset], scope: scope),
),
@@ -247,21 +247,14 @@ enum ActionButtonType {
ActionButtonType.unstack => ActionMenuItemWidget(
action: StackAction(assets: [context.asset], scope: scope),
),
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
remoteId: context.asset.remoteId!,
origin: context.timelineOrigin,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.openInBrowser => ActionMenuItemWidget(
action: OpenInBrowserAction(remoteId: context.asset.remoteId!, origin: context.timelineOrigin, scope: scope),
),
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
assetId: (context.asset as RemoteAsset).id,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.similarPhotos => ActionMenuItemWidget(
action: SimilarPhotosAction(assetId: (context.asset as RemoteAsset).id, scope: scope),
),
ActionButtonType.setProfilePicture => SetProfilePictureActionButton(
asset: context.asset,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.setProfilePicture => ActionMenuItemWidget(
action: SetProfilePictureAction(asset: context.asset, scope: scope),
),
ActionButtonType.openInfo => BaseActionButton(
label: 'info'.tr(),
@@ -279,7 +272,7 @@ enum ActionButtonType {
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
),
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.cast => ActionMenuItemWidget(action: CastAction(scope: scope)),
};
}
+3
View File
@@ -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/gcast.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 MockGCastService extends Mock implements GCastService {}
@@ -17,9 +17,11 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../../domain/service.mock.dart';
import '../../test_utils.dart';
import '../factories/user_factory.dart';
import '../mocks.dart';
@@ -45,6 +47,7 @@ class PresentationContext {
assetServiceProvider.overrideWithValue(service.asset.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
toastRepositoryProvider.overrideWithValue(repository.toast),
gCastServiceProvider.overrideWithValue(MockGCastService()),
];
static Future<PresentationContext> create() async {