From 3c80049192e3906609f8837828ee1ff021758a4b Mon Sep 17 00:00:00 2001 From: idubnori Date: Sat, 6 Dec 2025 04:51:59 +0900 Subject: [PATCH] chore(mobile): add kebabu menu in asset viewer (#24387) * feat(mobile): implement viewer kebab menu with about option * feat: revert exisitng buttons, adjust label name * unify MenuAnchor usage --------- Co-authored-by: Alex --- .../pages/drift_activities.page.dart | 2 +- .../add_action_button.widget.dart | 148 +++++++++--------- .../base_action_button.widget.dart | 21 ++- .../cast_action_button.widget.dart | 4 +- .../download_action_button.widget.dart | 4 +- .../favorite_action_button.widget.dart | 4 +- .../like_activity_action_button.widget.dart | 5 +- .../motion_photo_action_button.widget.dart | 4 +- .../unfavorite_action_button.widget.dart | 4 +- .../asset_viewer/top_app_bar.widget.dart | 31 ++-- .../viewer_kebab_menu.widget.dart | 47 ++++++ 11 files changed, 168 insertions(+), 106 deletions(-) create mode 100644 mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index b92d429aa1..ac0cd7f309 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -37,7 +37,7 @@ class DriftActivitiesPage extends HookConsumerWidget { child: Scaffold( appBar: AppBar( title: Text(album.name), - actions: [const LikeActivityActionButton(menuItem: true)], + actions: [const LikeActivityActionButton(iconOnly: true)], actionsPadding: const EdgeInsets.only(right: 8), ), body: activities.widgetWhen( diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index 71fedf1258..054f058739 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -21,12 +21,34 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee enum AddToMenuItem { album, archive, unarchive, lockedFolder } -class AddActionButton extends ConsumerWidget { +class AddActionButton extends ConsumerStatefulWidget { const AddActionButton({super.key}); - Future _showAddOptions(BuildContext context, WidgetRef ref) async { + @override + ConsumerState createState() => _AddActionButtonState(); +} + +class _AddActionButtonState extends ConsumerState { + void _handleMenuSelection(AddToMenuItem selected) { + switch (selected) { + case AddToMenuItem.album: + _openAlbumSelector(); + break; + case AddToMenuItem.archive: + performArchiveAction(context, ref, source: ActionSource.viewer); + break; + case AddToMenuItem.unarchive: + performUnArchiveAction(context, ref, source: ActionSource.viewer); + break; + case AddToMenuItem.lockedFolder: + performMoveToLockFolderAction(context, ref, source: ActionSource.viewer); + break; + } + } + + List _buildMenuChildren() { final asset = ref.read(currentAssetNotifier); - if (asset == null) return; + if (asset == null) return []; final user = ref.read(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; @@ -35,93 +57,57 @@ class AddActionButton extends ConsumerWidget { final hasRemote = asset is RemoteAsset; final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived; final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived; - final menuItemHeight = 30.0; - final List> items = [ - PopupMenuItem( - enabled: false, - textStyle: context.textTheme.labelMedium, - height: 40, - child: Text("add_to_bottom_bar".tr()), + return [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium), ), - PopupMenuItem( - height: menuItemHeight, - value: AddToMenuItem.album, - child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())), + BaseActionButton( + iconData: Icons.photo_album_outlined, + label: "album".tr(), + menuItem: true, + onPressed: () => _handleMenuSelection(AddToMenuItem.album), ), - const PopupMenuDivider(), - PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())), + if (isOwner) ...[ + const PopupMenuDivider(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text("move_to".tr(), style: context.textTheme.labelMedium), + ), if (showArchive) - PopupMenuItem( - height: menuItemHeight, - value: AddToMenuItem.archive, - child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())), + BaseActionButton( + iconData: Icons.archive_outlined, + label: "archive".tr(), + menuItem: true, + onPressed: () => _handleMenuSelection(AddToMenuItem.archive), ), if (showUnarchive) - PopupMenuItem( - height: menuItemHeight, - value: AddToMenuItem.unarchive, - child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())), + BaseActionButton( + iconData: Icons.unarchive_outlined, + label: "unarchive".tr(), + menuItem: true, + onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive), ), - PopupMenuItem( - height: menuItemHeight, - value: AddToMenuItem.lockedFolder, - child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())), + BaseActionButton( + iconData: Icons.lock_outline, + label: "locked_folder".tr(), + menuItem: true, + onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder), ), ], ]; - - final AddToMenuItem? selected = await showMenu( - context: context, - color: context.themeData.scaffoldBackgroundColor, - position: _menuPosition(context), - items: items, - popUpAnimationStyle: AnimationStyle.noAnimation, - ); - - if (selected == null) { - return; - } - - switch (selected) { - case AddToMenuItem.album: - _openAlbumSelector(context, ref); - break; - case AddToMenuItem.archive: - await performArchiveAction(context, ref, source: ActionSource.viewer); - break; - case AddToMenuItem.unarchive: - await performUnArchiveAction(context, ref, source: ActionSource.viewer); - break; - case AddToMenuItem.lockedFolder: - await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer); - break; - } } - RelativeRect _menuPosition(BuildContext context) { - final renderObject = context.findRenderObject(); - if (renderObject is! RenderBox) { - return RelativeRect.fill; - } - - final size = renderObject.size; - final position = renderObject.localToGlobal(Offset.zero); - - return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy); - } - - void _openAlbumSelector(BuildContext context, WidgetRef ref) { + void _openAlbumSelector() { final currentAsset = ref.read(currentAssetNotifier); if (currentAsset == null) { ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); return; } - final List slivers = [ - AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)), - ]; + final List slivers = [AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album))]; showModalBottomSheet( context: context, @@ -141,7 +127,7 @@ class AddActionButton extends ConsumerWidget { ); } - Future _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async { + Future _addCurrentAssetToAlbum(RemoteAlbum album) async { final latest = ref.read(currentAssetNotifier); if (latest == null) { @@ -174,17 +160,27 @@ class AddActionButton extends ConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final asset = ref.watch(currentAssetNotifier); if (asset == null) { return const SizedBox.shrink(); } - return Builder( - builder: (buttonContext) { + + return MenuAnchor( + consumeOutsideTap: true, + style: MenuStyle( + backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor), + elevation: const WidgetStatePropertyAll(4), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + ), + ), + menuChildren: _buildMenuChildren(), + builder: (context, controller, child) { return BaseActionButton( iconData: Icons.add, label: "add_to_bottom_bar".tr(), - onPressed: () => _showAddOptions(buttonContext, ref), + onPressed: () => controller.isOpen ? controller.close() : controller.open(), ); }, ); diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 5ec6c8bc54..e6098b07b4 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -11,6 +11,7 @@ class BaseActionButton extends StatelessWidget { this.onLongPressed, this.maxWidth = 90.0, this.minWidth, + this.iconOnly = false, this.menuItem = false, }); @@ -19,6 +20,11 @@ class BaseActionButton extends StatelessWidget { final Color? iconColor; final double maxWidth; final double? minWidth; + + /// When true, renders only an IconButton without text label + final bool iconOnly; + + /// When true, renders as a MenuItemButton for use in MenuAnchor menus final bool menuItem; final void Function()? onPressed; final void Function()? onLongPressed; @@ -31,13 +37,26 @@ class BaseActionButton extends StatelessWidget { final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; final textColor = context.themeData.textTheme.labelLarge?.color; - if (menuItem) { + if (iconOnly) { return IconButton( onPressed: onPressed, icon: Icon(iconData, size: iconSize, color: iconColor), ); } + if (menuItem) { + final theme = context.themeData; + final effectiveStyle = theme.textTheme.labelLarge; + final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant; + + return MenuItemButton( + style: MenuItemButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12)), + leadingIcon: Icon(iconData, color: effectiveIconColor, size: 20), + onPressed: onPressed, + child: Text(label, style: effectiveStyle), + ); + } + return ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: MaterialButton( diff --git a/mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart index 26b8ba6f47..2840ad294b 100644 --- a/mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart @@ -7,8 +7,9 @@ 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.menuItem = true}); + const CastActionButton({super.key, this.iconOnly = true, this.menuItem = false}); + final bool iconOnly; final bool menuItem; @override @@ -22,6 +23,7 @@ class CastActionButton extends ConsumerWidget { onPressed: () { showDialog(context: context, builder: (context) => const CastDialog()); }, + iconOnly: iconOnly, menuItem: menuItem, ); } diff --git a/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart index cb898f069a..a5129b643a 100644 --- a/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart @@ -10,8 +10,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; class DownloadActionButton extends ConsumerWidget { final ActionSource source; + final bool iconOnly; final bool menuItem; - const DownloadActionButton({super.key, required this.source, this.menuItem = false}); + const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async { if (!context.mounted) { @@ -38,6 +39,7 @@ class DownloadActionButton extends ConsumerWidget { iconData: Icons.download, maxWidth: 95, label: "download".t(context: context), + iconOnly: iconOnly, menuItem: menuItem, onPressed: () => _onTap(context, ref, backgroundManager), ); diff --git a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart index 0aca5158ef..ba2491365d 100644 --- a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart @@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; class FavoriteActionButton extends ConsumerWidget { final ActionSource source; + final bool iconOnly; final bool menuItem; - const FavoriteActionButton({super.key, required this.source, this.menuItem = false}); + const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); void _onTap(BuildContext context, WidgetRef ref) async { if (!context.mounted) { @@ -44,6 +45,7 @@ class FavoriteActionButton extends ConsumerWidget { return BaseActionButton( iconData: Icons.favorite_border_rounded, label: "favorite".t(context: context), + iconOnly: iconOnly, menuItem: menuItem, onPressed: () => _onTap(context, ref), ); diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index 33794eae11..a61f72ea01 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -12,8 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da import 'package:immich_mobile/providers/user.provider.dart'; class LikeActivityActionButton extends ConsumerWidget { - const LikeActivityActionButton({super.key, this.menuItem = false}); + const LikeActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false}); + final bool iconOnly; final bool menuItem; @override @@ -49,6 +50,7 @@ class LikeActivityActionButton extends ConsumerWidget { iconData: liked != null ? Icons.favorite : Icons.favorite_border, label: "like".t(context: context), onPressed: () => onTap(liked), + iconOnly: iconOnly, menuItem: menuItem, ); }, @@ -57,6 +59,7 @@ class LikeActivityActionButton extends ConsumerWidget { loading: () => BaseActionButton( iconData: Icons.favorite_border, label: "like".t(context: context), + iconOnly: iconOnly, menuItem: menuItem, ), error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])), diff --git a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart index 696b9ff367..9cf541f49f 100644 --- a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart @@ -5,8 +5,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; class MotionPhotoActionButton extends ConsumerWidget { - const MotionPhotoActionButton({super.key, this.menuItem = true}); + const MotionPhotoActionButton({super.key, this.iconOnly = true, this.menuItem = false}); + final bool iconOnly; final bool menuItem; @override @@ -17,6 +18,7 @@ class MotionPhotoActionButton extends ConsumerWidget { iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded, label: "play_motion_photo".t(context: context), onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle, + iconOnly: iconOnly, menuItem: menuItem, ); } diff --git a/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart index 7fdc5e81e8..ec5513e0a8 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart @@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; class UnFavoriteActionButton extends ConsumerWidget { final ActionSource source; + final bool iconOnly; final bool menuItem; - const UnFavoriteActionButton({super.key, required this.source, this.menuItem = false}); + const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); void _onTap(BuildContext context, WidgetRef ref) async { if (!context.mounted) { @@ -45,6 +46,7 @@ class UnFavoriteActionButton extends ConsumerWidget { iconData: Icons.favorite_rounded, label: "unfavorite".t(context: context), onPressed: () => _onTap(context, ref), + iconOnly: iconOnly, menuItem: menuItem, ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index 5114ef6fd2..b3129a9a0e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_actio import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; @@ -65,8 +66,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final actions = [ - if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true), - if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), + if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, iconOnly: true), + if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true), if (album != null && album.isActivityEnabled && album.isShared) IconButton( icon: const Icon(Icons.chat_outlined), @@ -85,16 +86,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { tooltip: 'view_in_timeline'.t(context: context), ), if (asset.hasRemote && isOwner && !asset.isFavorite) - const FavoriteActionButton(source: ActionSource.viewer, menuItem: true), + const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true), if (asset.hasRemote && isOwner && asset.isFavorite) - const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true), - if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true), - const _KebabMenu(), + const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true), + if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true), + const ViewerKebabMenu(), ]; final lockedViewActions = [ - if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), - const _KebabMenu(), + if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true), + const ViewerKebabMenu(), ]; return IgnorePointer( @@ -122,20 +123,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { Size get preferredSize => const Size.fromHeight(60.0); } -class _KebabMenu extends ConsumerWidget { - const _KebabMenu(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return IconButton( - onPressed: () { - EventStream.shared.emit(const ViewerOpenBottomSheetEvent()); - }, - icon: const Icon(Icons.more_vert_rounded), - ); - } -} - class _AppBarBackButton extends ConsumerWidget { const _AppBarBackButton(); diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart new file mode 100644 index 0000000000..4651b5eea8 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -0,0 +1,47 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; + +class ViewerKebabMenu extends ConsumerWidget { + const ViewerKebabMenu({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + + final menuChildren = [ + BaseActionButton( + label: 'about'.tr(), + iconData: Icons.info_outline, + menuItem: true, + onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), + ), + ]; + + return MenuAnchor( + consumeOutsideTap: true, + style: MenuStyle( + backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor), + elevation: const WidgetStatePropertyAll(4), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + ), + ), + menuChildren: menuChildren, + builder: (context, controller, child) { + return IconButton( + icon: const Icon(Icons.more_vert_rounded), + onPressed: () => controller.isOpen ? controller.close() : controller.open(), + ); + }, + ); + } +}