feat(mobile): implement viewer kebab menu with about option

pull/24177/head
idubnori 2025-11-25 06:19:00 +07:00
parent 1e1c4ac9d2
commit 7f9ba91c8d
6 changed files with 134 additions and 48 deletions

@ -1496,6 +1496,7 @@
"online": "Online",
"only_favorites": "Only favorites",
"open": "Open",
"open_bottom_sheet_about": "About",
"open_in_map_view": "Open in map view",
"open_in_openstreetmap": "Open in OpenStreetMap",
"open_the_search_filters": "Open the search filters",

@ -38,6 +38,29 @@ class BaseActionButton extends StatelessWidget {
);
}
if (context.findAncestorWidgetOfExactType<MenuAnchor>() != null) {
final theme = context.themeData;
final textStyle = theme.textTheme.bodyMedium;
final defaultTextColor = theme.colorScheme.onSurfaceVariant;
final effectiveStyle = (textStyle ?? theme.textTheme.bodyMedium)?.copyWith(
color: (textStyle?.color ?? defaultTextColor),
);
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
return MenuItemButton(
style: MenuItemButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
visualDensity: const VisualDensity(vertical: -2.5),
),
trailingIcon: Icon(iconData, size: 18, color: effectiveIconColor),
onPressed: onPressed,
child: Align(
alignment: Alignment.centerLeft,
child: Text(label, style: effectiveStyle),
),
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton(

@ -8,7 +8,6 @@ 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/exif.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
@ -20,14 +19,9 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.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/setting.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@ -46,29 +40,8 @@ class AssetDetailBottomSheet extends ConsumerWidget {
return const SizedBox.shrink();
}
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final buttonContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView,
isStacked: asset is RemoteAsset && asset.stackId != null,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
);
final actions = ActionButtonBuilder.build(buttonContext);
return BaseBottomSheet(
actions: actions,
actions: [],
slivers: const [_AssetDetailBottomSheet()],
controller: controller,
initialChildSize: initialChildSize,

@ -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';
@ -89,12 +90,12 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true),
const _KebabMenu(),
const ViewerKebabMenu(),
];
final lockedViewActions = <Widget>[
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
const _KebabMenu(),
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();

@ -0,0 +1,90 @@
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/setting.model.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/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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 isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final buttonContext = ActionButtonContext(
asset: asset,
isOwner: isOwner,
isArchived: isArchived,
isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView,
isStacked: asset is RemoteAsset && asset.stackId != null,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
);
final theme = context.themeData;
final menuChildren = <Widget>[
BaseActionButton(
label: 'open_bottom_sheet_about'.t(context: context),
iconData: Icons.info_outline,
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
),
];
final actions = ActionButtonBuilder.build(
buttonContext,
actionTypes: ActionButtonBuilder.kebabMenuActionTypes,
).map((w) => w.build(context, ref)).expand((action) => [const Divider(height: 0), action]).toList(growable: false);
if (actions.isNotEmpty) {
menuChildren.addAll(actions);
}
return MenuAnchor(
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(theme.scaffoldBackgroundColor),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 2)),
),
menuChildren: menuChildren,
builder: (context, controller, child) {
return IconButton(
icon: const Icon(Icons.more_vert_rounded),
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
);
},
);
}
}

@ -1,4 +1,4 @@
import 'package:flutter/widgets.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';
@ -131,7 +131,7 @@ enum ActionButtonType {
};
}
Widget buildButton(ActionButtonContext context) {
ConsumerWidget buildButton(ActionButtonContext context) {
return switch (this) {
ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source),
ActionButtonType.share => ShareActionButton(source: context.source),
@ -160,7 +160,19 @@ enum ActionButtonType {
class ActionButtonBuilder {
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
static const _excludedActions = {
ActionButtonType.share,
ActionButtonType.archive,
ActionButtonType.delete,
ActionButtonType.moveToLockFolder,
};
static final List<ActionButtonType> kebabMenuActionTypes = ActionButtonType.values
.where((type) => !_excludedActions.contains(type))
.toList();
static List<ConsumerWidget> build(ActionButtonContext context, {List<ActionButtonType>? actionTypes}) {
final types = actionTypes ?? _actionTypes;
return types.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
}