From 87001d7137fbbb77c2f2c22be5b345c19815141e Mon Sep 17 00:00:00 2001 From: idubnori Date: Sun, 7 Dec 2025 00:55:36 +0900 Subject: [PATCH 1/7] chore(mobile): i18n: "open_asset_info" in viewer kebab menu --- i18n/en.json | 1 + .../widgets/asset_viewer/viewer_kebab_menu.widget.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/i18n/en.json b/i18n/en.json index 7eb9ffbef6767..6d8c57fcf169e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1511,6 +1511,7 @@ "online": "Online", "only_favorites": "Only favorites", "open": "Open", + "open_asset_info": "About", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", 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 4651b5eea8c9f..028f43cc6d265 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 @@ -19,7 +19,7 @@ class ViewerKebabMenu extends ConsumerWidget { final menuChildren = [ BaseActionButton( - label: 'about'.tr(), + label: 'open_asset_info'.tr(), iconData: Icons.info_outline, menuItem: true, onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), From 48597fbd98620890afdbcfcb58355abf83dd10da Mon Sep 17 00:00:00 2001 From: idubnori Date: Sun, 7 Dec 2025 01:58:31 +0900 Subject: [PATCH 2/7] feat(mobile): move some top buttons into kebabu menu --- .../cast_action_button.widget.dart | 2 +- .../asset_viewer/top_app_bar.widget.dart | 37 +----------- .../viewer_kebab_menu.widget.dart | 56 +++++++++++++++++++ 3 files changed, 58 insertions(+), 37 deletions(-) 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 2840ad294b10d..7a4f84fb4f2d3 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/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index b3129a9a0e650..80e6819537ea8 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,18 @@ 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 +34,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 +46,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { opacity = 0; } - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); - final actions = [ - 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), @@ -75,28 +54,14 @@ 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(), ]; - final lockedViewActions = [ - if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true), - const ViewerKebabMenu(), - ]; + final lockedViewActions = [const ViewerKebabMenu()]; 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 028f43cc6d265..d3ab9addfd577 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,11 +1,23 @@ +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/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/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/motion_photo_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/routing/router.dart'; class ViewerKebabMenu extends ConsumerWidget { const ViewerKebabMenu({super.key}); @@ -17,6 +29,19 @@ class ViewerKebabMenu extends ConsumerWidget { return const SizedBox.shrink(); } + 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 showViewInTimelineButton = + timelineOrigin != TimelineOrigin.main && + timelineOrigin != TimelineOrigin.deepLink && + timelineOrigin != TimelineOrigin.trash && + timelineOrigin != TimelineOrigin.archive && + timelineOrigin != TimelineOrigin.localAlbum && + isOwner; + final menuChildren = [ BaseActionButton( label: 'open_asset_info'.tr(), @@ -26,6 +51,37 @@ class ViewerKebabMenu extends ConsumerWidget { ), ]; + // Add motion photo button + if (asset.isMotionPhoto) { + menuChildren.add(const MotionPhotoActionButton(menuItem: true)); + } + + // Add view in timeline button + if (showViewInTimelineButton) { + menuChildren.add( + BaseActionButton( + label: 'view_in_timeline'.t(context: context), + iconData: Icons.image_search, + menuItem: true, + onPressed: () async { + await context.maybePop(); + await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()])); + EventStream.shared.emit(ScrollToDateEvent(asset.createdAt)); + }, + ), + ); + } + + // Add cast button if casting or has remote + if (isCasting || asset.hasRemote) { + menuChildren.add(const CastActionButton(menuItem: true)); + } + + // Add download button if remote only + if (asset.isRemoteOnly) { + menuChildren.add(const DownloadActionButton(source: ActionSource.viewer, menuItem: true)); + } + return MenuAnchor( consumeOutsideTap: true, style: MenuStyle( From 654c29866a6d9f5f45031db3f0e79d7dad99ee98 Mon Sep 17 00:00:00 2001 From: idubnori Date: Mon, 8 Dec 2025 11:06:03 +0900 Subject: [PATCH 3/7] refactor(mobile): viewer kebab menu to use context-based button generation --- .../viewer_kebab_menu.widget.dart | 67 ++------------ mobile/lib/utils/action_button.utils.dart | 87 ++++++++++++++++++- 2 files changed, 95 insertions(+), 59 deletions(-) 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 d3ab9addfd577..f3a93996c4b36 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,23 +1,12 @@ -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/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/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/motion_photo_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/routing/router.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; class ViewerKebabMenu extends ConsumerWidget { const ViewerKebabMenu({super.key}); @@ -32,55 +21,16 @@ class ViewerKebabMenu extends ConsumerWidget { 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 showViewInTimelineButton = - timelineOrigin != TimelineOrigin.main && - timelineOrigin != TimelineOrigin.deepLink && - timelineOrigin != TimelineOrigin.trash && - timelineOrigin != TimelineOrigin.archive && - timelineOrigin != TimelineOrigin.localAlbum && - isOwner; - - final menuChildren = [ - BaseActionButton( - label: 'open_asset_info'.tr(), - iconData: Icons.info_outline, - menuItem: true, - onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), - ), - ]; - // Add motion photo button - if (asset.isMotionPhoto) { - menuChildren.add(const MotionPhotoActionButton(menuItem: true)); - } - - // Add view in timeline button - if (showViewInTimelineButton) { - menuChildren.add( - BaseActionButton( - label: 'view_in_timeline'.t(context: context), - iconData: Icons.image_search, - menuItem: true, - onPressed: () async { - await context.maybePop(); - await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()])); - EventStream.shared.emit(ScrollToDateEvent(asset.createdAt)); - }, - ), - ); - } - - // Add cast button if casting or has remote - if (isCasting || asset.hasRemote) { - menuChildren.add(const CastActionButton(menuItem: true)); - } + final kebabContext = ViewerKebabMenuButtonContext( + asset: asset, + isOwner: isOwner, + isCasting: isCasting, + timelineOrigin: timelineOrigin, + ); - // Add download button if remote only - if (asset.isRemoteOnly) { - menuChildren.add(const DownloadActionButton(source: ActionSource.viewer, menuItem: true)); - } + final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context); return MenuAnchor( consumeOutsideTap: true, @@ -90,6 +40,7 @@ class ViewerKebabMenu extends ConsumerWidget { shape: const WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), ), + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 2)), ), menuChildren: menuChildren, builder: (context, controller, child) { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 42729becc9c6b..aa5d8c1840c53 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -1,14 +1,23 @@ -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: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'; 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/motion_photo_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/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_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,78 @@ 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; + + const ViewerKebabMenuButtonContext({ + required this.asset, + required this.isOwner, + required this.isCasting, + required this.timelineOrigin, + }); +} + +enum ViewerKebabMenuButtonType { + openInfo, + motionPhoto, + viewInTimeline, + cast, + download; + + bool shouldShow(ViewerKebabMenuButtonContext context) { + return switch (this) { + ViewerKebabMenuButtonType.openInfo => true, + ViewerKebabMenuButtonType.motionPhoto => context.asset.isMotionPhoto, + 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, + }; + } + + Widget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) { + return switch (this) { + ViewerKebabMenuButtonType.openInfo => BaseActionButton( + label: 'open_asset_info'.tr(), + iconData: Icons.info_outline, + menuItem: true, + onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), + ), + ViewerKebabMenuButtonType.motionPhoto => const MotionPhotoActionButton(menuItem: true), + ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton( + label: 'view_in_timeline'.t(context: buildContext), + iconData: Icons.image_search, + 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 const List _buttonTypes = ViewerKebabMenuButtonType.values; + + static List build(ViewerKebabMenuButtonContext context, BuildContext buildContext) { + return _buttonTypes + .where((type) => type.shouldShow(context)) + .map((type) => type.buildButton(context, buildContext)) + .expand((action) => [const Divider(height: 0), action]) + .skip(1) // to remove the first divider + .toList(); + } +} From 45d4e2c24fffe602ce659184b10e38d99bcf2fe6 Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 9 Dec 2025 02:10:53 +0900 Subject: [PATCH 4/7] feat(mobile): refactor action button and kebab menu to use ConsumerWidget for improved state management --- .../widgets/action_buttons/base_action_button.widget.dart | 5 +++-- .../widgets/asset_viewer/viewer_kebab_menu.widget.dart | 2 +- mobile/lib/utils/action_button.utils.dart | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) 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 e6098b07b44b6..9474a020f1434 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; 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 f3a93996c4b36..5d2c5d1296f68 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 @@ -30,7 +30,7 @@ class ViewerKebabMenu extends ConsumerWidget { timelineOrigin: timelineOrigin, ); - final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context); + final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref); return MenuAnchor( consumeOutsideTap: true, diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index aa5d8c1840c53..4ce8e11d9c539 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -1,6 +1,7 @@ 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'; @@ -212,7 +213,7 @@ enum ViewerKebabMenuButtonType { }; } - Widget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) { + ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) { return switch (this) { ViewerKebabMenuButtonType.openInfo => BaseActionButton( label: 'open_asset_info'.tr(), @@ -240,10 +241,10 @@ enum ViewerKebabMenuButtonType { class ViewerKebabMenuButtonBuilder { static const List _buttonTypes = ViewerKebabMenuButtonType.values; - static List build(ViewerKebabMenuButtonContext context, BuildContext buildContext) { + static List build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) { return _buttonTypes .where((type) => type.shouldShow(context)) - .map((type) => type.buildButton(context, buildContext)) + .map((type) => type.buildButton(context, buildContext).build(buildContext, ref)) .expand((action) => [const Divider(height: 0), action]) .skip(1) // to remove the first divider .toList(); From ec1f7578f6c06a9e6c76b6e0f47c1cac8e3011b1 Mon Sep 17 00:00:00 2001 From: idubnori Date: Tue, 9 Dec 2025 02:47:41 +0900 Subject: [PATCH 5/7] feat(mobile): pass original theme to ViewerKebabMenu for consistent styling --- .../widgets/asset_viewer/top_app_bar.widget.dart | 6 ++++-- .../widgets/asset_viewer/viewer_kebab_menu.widget.dart | 5 ++++- mobile/lib/utils/action_button.utils.dart | 4 ++++ 3 files changed, 12 insertions(+), 3 deletions(-) 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 80e6819537ea8..61534709a8653 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 @@ -46,6 +46,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { opacity = 0; } + final originalTheme = context.themeData; + final actions = [ if (album != null && album.isActivityEnabled && album.isShared) IconButton( @@ -58,10 +60,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true), if (asset.hasRemote && isOwner && asset.isFavorite) const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true), - const ViewerKebabMenu(), + ViewerKebabMenu(originalTheme: originalTheme), ]; - final lockedViewActions = [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 5d2c5d1296f68..c70cb40b14465 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 @@ -9,7 +9,9 @@ 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) { @@ -28,6 +30,7 @@ class ViewerKebabMenu extends ConsumerWidget { isOwner: isOwner, isCasting: isCasting, timelineOrigin: timelineOrigin, + originalTheme: originalTheme, ); final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref); diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 4ce8e11d9c539..6775624299a73 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -181,12 +181,14 @@ class ViewerKebabMenuButtonContext { 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, }); } @@ -218,6 +220,7 @@ enum ViewerKebabMenuButtonType { ViewerKebabMenuButtonType.openInfo => BaseActionButton( label: 'open_asset_info'.tr(), iconData: Icons.info_outline, + iconColor: context.originalTheme?.iconTheme.color, menuItem: true, onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), ), @@ -225,6 +228,7 @@ enum ViewerKebabMenuButtonType { 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(); From e7361accef9e604bf168804a8e3a726b2f2082d4 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 9 Dec 2025 10:44:05 -0600 Subject: [PATCH 6/7] chore: styling --- i18n/en.json | 1 - .../add_action_button.widget.dart | 2 + .../base_action_button.widget.dart | 7 ++-- .../motion_photo_action_button.widget.dart | 2 +- .../asset_viewer/top_app_bar.widget.dart | 4 ++ .../viewer_kebab_menu.widget.dart | 14 ++++++- mobile/lib/utils/action_button.utils.dart | 40 +++++++++++++------ 7 files changed, 50 insertions(+), 20 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 6d8c57fcf169e..7eb9ffbef6767 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1511,7 +1511,6 @@ "online": "Online", "only_favorites": "Only favorites", "open": "Open", - "open_asset_info": "About", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", 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 acd7ede6dce91..08ac9f982cc32 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 9474a020f1434..675b5bf219f66 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 @@ -47,14 +47,13 @@ class BaseActionButton extends ConsumerWidget { 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/motion_photo_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart index 9cf541f49f26e..3bd67978e238d 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 61534709a8653..193cf6022052e 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 @@ -7,6 +7,7 @@ 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/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'; @@ -49,6 +50,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final originalTheme = context.themeData; final actions = [ + if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true), if (album != null && album.isActivityEnabled && album.isShared) IconButton( icon: const Icon(Icons.chat_outlined), @@ -56,10 +58,12 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); }, ), + 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), + ViewerKebabMenu(originalTheme: originalTheme), ]; 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 c70cb40b14465..ff638ee5837ce 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 @@ -39,13 +39,23 @@ class ViewerKebabMenu extends ConsumerWidget { 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: 2)), + 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 6775624299a73..156592b69cc96 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -194,15 +194,23 @@ class ViewerKebabMenuButtonContext { enum ViewerKebabMenuButtonType { openInfo, - motionPhoto, 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.motionPhoto => context.asset.isMotionPhoto, ViewerKebabMenuButtonType.viewInTimeline => context.timelineOrigin != TimelineOrigin.main && context.timelineOrigin != TimelineOrigin.deepLink && @@ -218,13 +226,13 @@ enum ViewerKebabMenuButtonType { ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) { return switch (this) { ViewerKebabMenuButtonType.openInfo => BaseActionButton( - label: 'open_asset_info'.tr(), + label: 'info'.tr(), iconData: Icons.info_outline, iconColor: context.originalTheme?.iconTheme.color, menuItem: true, onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), ), - ViewerKebabMenuButtonType.motionPhoto => const MotionPhotoActionButton(menuItem: true), + ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton( label: 'view_in_timeline'.t(context: buildContext), iconData: Icons.image_search, @@ -243,14 +251,22 @@ enum ViewerKebabMenuButtonType { } class ViewerKebabMenuButtonBuilder { - static const List _buttonTypes = ViewerKebabMenuButtonType.values; - static List build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) { - return _buttonTypes - .where((type) => type.shouldShow(context)) - .map((type) => type.buildButton(context, buildContext).build(buildContext, ref)) - .expand((action) => [const Divider(height: 0), action]) - .skip(1) // to remove the first divider - .toList(); + 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; } } From 177bf7bee65cef442c3502840651af4808dd0ff8 Mon Sep 17 00:00:00 2001 From: idubnori Date: Wed, 10 Dec 2025 03:20:31 +0900 Subject: [PATCH 7/7] fix: dart analyze --- mobile/lib/utils/action_button.utils.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 156592b69cc96..917ddbebca8ee 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -18,7 +18,6 @@ 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/like_activity_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/move_to_lock_folder_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';