From 7af99b86068bd4407a1c0acab9117741e7846fbb Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 10 Dec 2025 03:26:28 +0900 Subject: [PATCH] feat(mobile): move top bar buttons into kebabu menu in AssetViewer (#24461) * chore(mobile): i18n: "open_asset_info" in viewer kebab menu * feat(mobile): move some top buttons into kebabu menu * refactor(mobile): viewer kebab menu to use context-based button generation * feat(mobile): refactor action button and kebab menu to use ConsumerWidget for improved state management * feat(mobile): pass original theme to ViewerKebabMenu for consistent styling * chore: styling --------- Co-authored-by: Alex --- .../add_action_button.widget.dart | 2 + .../base_action_button.widget.dart | 12 +- .../cast_action_button.widget.dart | 2 +- .../motion_photo_action_button.widget.dart | 2 +- .../asset_viewer/top_app_bar.widget.dart | 41 +------ .../viewer_kebab_menu.widget.dart | 48 +++++--- mobile/lib/utils/action_button.utils.dart | 107 +++++++++++++++++- 7 files changed, 156 insertions(+), 58 deletions(-) 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 acd7ede6dc..08ac9f982c 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 @@ -174,10 +174,12 @@ class _AddActionButtonState extends ConsumerState { consumeOutsideTap: true, style: MenuStyle( backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor), + surfaceTintColor: const WidgetStatePropertyAll(Colors.grey), elevation: const WidgetStatePropertyAll(4), shape: const WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), ), + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)), ), menuChildren: widget.originalTheme != null ? [ 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 e6098b07b4..675b5bf219 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 @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -class BaseActionButton extends StatelessWidget { +class BaseActionButton extends ConsumerWidget { const BaseActionButton({ super.key, required this.label, @@ -30,7 +31,7 @@ class BaseActionButton extends StatelessWidget { final void Function()? onLongPressed; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final iconTheme = IconTheme.of(context); final iconSize = iconTheme.size ?? 24.0; @@ -46,14 +47,13 @@ class BaseActionButton extends StatelessWidget { 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), + style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)), + leadingIcon: Icon(iconData, color: effectiveIconColor), onPressed: onPressed, - child: Text(label, style: effectiveStyle), + child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)), ); } 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 2840ad294b..7a4f84fb4f 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,7 +7,7 @@ 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 = true, this.menuItem = false}); + const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false}); final bool iconOnly; final bool menuItem; 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 9cf541f49f..3bd67978e2 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,7 +5,7 @@ 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.iconOnly = true, this.menuItem = false}); + const MotionPhotoActionButton({super.key, this.iconOnly = false, this.menuItem = false}); final bool iconOnly; final bool 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 b3129a9a0e..193cf60220 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 @@ -4,26 +4,19 @@ 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/events.model.dart'; -import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.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/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/favorite_action_button.widget.dart'; 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'; import 'package:immich_mobile/providers/infrastructure/current_album.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/user.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const ViewerTopAppBar({super.key}); @@ -42,15 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isInLockedView = ref.watch(inLockedViewProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); - final timelineOrigin = ref.read(timelineServiceProvider).origin; - final showViewInTimelineButton = - timelineOrigin != TimelineOrigin.main && - timelineOrigin != TimelineOrigin.deepLink && - timelineOrigin != TimelineOrigin.trash && - timelineOrigin != TimelineOrigin.archive && - timelineOrigin != TimelineOrigin.localAlbum && - isOwner; - final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); @@ -63,11 +47,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { opacity = 0; } - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final originalTheme = context.themeData; final actions = [ - if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, iconOnly: true), - if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true), + if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true), if (album != null && album.isActivityEnabled && album.isShared) IconButton( icon: const Icon(Icons.chat_outlined), @@ -75,28 +58,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); }, ), - if (showViewInTimelineButton) - IconButton( - onPressed: () async { - await context.maybePop(); - await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()])); - EventStream.shared.emit(ScrollToDateEvent(asset.createdAt)); - }, - icon: const Icon(Icons.image_search), - tooltip: 'view_in_timeline'.t(context: context), - ), + if (asset.hasRemote && isOwner && !asset.isFavorite) const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true), if (asset.hasRemote && isOwner && asset.isFavorite) const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true), - if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true), - const ViewerKebabMenu(), + + ViewerKebabMenu(originalTheme: originalTheme), ]; - final lockedViewActions = [ - if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true), - const ViewerKebabMenu(), - ]; + final lockedViewActions = [ViewerKebabMenu(originalTheme: originalTheme)]; return IgnorePointer( ignoring: opacity < 255, 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 index 4651b5eea8..ff638ee583 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -1,14 +1,17 @@ -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/domain/models/asset/base_asset.model.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/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; class ViewerKebabMenu extends ConsumerWidget { - const ViewerKebabMenu({super.key}); + const ViewerKebabMenu({super.key, this.originalTheme}); + + final ThemeData? originalTheme; @override Widget build(BuildContext context, WidgetRef ref) { @@ -17,25 +20,42 @@ class ViewerKebabMenu extends ConsumerWidget { return const SizedBox.shrink(); } - final menuChildren = [ - BaseActionButton( - label: 'about'.tr(), - iconData: Icons.info_outline, - menuItem: true, - onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), - ), - ]; + final user = ref.watch(currentUserProvider); + final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; + final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final timelineOrigin = ref.read(timelineServiceProvider).origin; + + final kebabContext = ViewerKebabMenuButtonContext( + asset: asset, + isOwner: isOwner, + isCasting: isCasting, + timelineOrigin: timelineOrigin, + originalTheme: originalTheme, + ); + + final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref); return MenuAnchor( consumeOutsideTap: true, style: MenuStyle( backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor), + surfaceTintColor: const WidgetStatePropertyAll(Colors.grey), elevation: const WidgetStatePropertyAll(4), shape: const WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), ), + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)), ), - menuChildren: menuChildren, + menuChildren: [ + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 150), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: menuChildren, + ), + ), + ], builder: (context, controller, child) { return IconButton( icon: const Icon(Icons.more_vert_rounded), diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 42729becc9..917ddbebca 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -1,9 +1,18 @@ -import 'package:flutter/widgets.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.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/events.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.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'; +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'; @@ -19,6 +28,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; +import 'package:immich_mobile/routing/router.dart'; class ActionButtonContext { final BaseAsset asset; @@ -164,3 +174,98 @@ class ActionButtonBuilder { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } } + +class ViewerKebabMenuButtonContext { + final BaseAsset asset; + final bool isOwner; + final bool isCasting; + final TimelineOrigin timelineOrigin; + final ThemeData? originalTheme; + + const ViewerKebabMenuButtonContext({ + required this.asset, + required this.isOwner, + required this.isCasting, + required this.timelineOrigin, + this.originalTheme, + }); +} + +enum ViewerKebabMenuButtonType { + openInfo, + viewInTimeline, + cast, + download; + + /// Defines which group each button belongs to. + /// Buttons in the same group will be displayed together, + /// with dividers separating different groups. + int get group => switch (this) { + ViewerKebabMenuButtonType.openInfo => 0, + ViewerKebabMenuButtonType.viewInTimeline => 1, + ViewerKebabMenuButtonType.cast => 1, + ViewerKebabMenuButtonType.download => 1, + }; + + bool shouldShow(ViewerKebabMenuButtonContext context) { + return switch (this) { + ViewerKebabMenuButtonType.openInfo => true, + ViewerKebabMenuButtonType.viewInTimeline => + context.timelineOrigin != TimelineOrigin.main && + context.timelineOrigin != TimelineOrigin.deepLink && + context.timelineOrigin != TimelineOrigin.trash && + context.timelineOrigin != TimelineOrigin.archive && + context.timelineOrigin != TimelineOrigin.localAlbum && + context.isOwner, + ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote, + ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly, + }; + } + + ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) { + return switch (this) { + ViewerKebabMenuButtonType.openInfo => BaseActionButton( + label: 'info'.tr(), + iconData: Icons.info_outline, + iconColor: context.originalTheme?.iconTheme.color, + menuItem: true, + onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), + ), + + ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton( + label: 'view_in_timeline'.t(context: buildContext), + iconData: Icons.image_search, + iconColor: context.originalTheme?.iconTheme.color, + menuItem: true, + onPressed: () async { + await buildContext.maybePop(); + await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()])); + EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt)); + }, + ), + ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true), + ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true), + }; + } +} + +class ViewerKebabMenuButtonBuilder { + static List build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) { + final visibleButtons = ViewerKebabMenuButtonType.values.where((type) => type.shouldShow(context)).toList(); + + if (visibleButtons.isEmpty) return []; + + final List result = []; + int? lastGroup; + + for (final type in visibleButtons) { + if (lastGroup != null && type.group != lastGroup) { + result.add(const Divider(height: 1)); + } + result.add(type.buildButton(context, buildContext).build(buildContext, ref)); + lastGroup = type.group; + } + + return result; + } +}