diff --git a/i18n/en.json b/i18n/en.json index 210e05459d..951602ef86 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", 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..b62398c03e 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 @@ -38,6 +38,29 @@ class BaseActionButton extends StatelessWidget { ); } + if (context.findAncestorWidgetOfExactType() != 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( diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 582a33136a..9ced79db25 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -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, 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 ab88dffab4..e5bab6635b 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'; @@ -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 = [ 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(); 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..ea6fd7ed32 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -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 = [ + 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(); + } + }, + ); + }, + ); + } +} diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 42729becc9..23790e2b25 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -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 _actionTypes = ActionButtonType.values; - static List 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 kebabMenuActionTypes = ActionButtonType.values + .where((type) => !_excludedActions.contains(type)) + .toList(); + + static List build(ActionButtonContext context, {List? actionTypes}) { + final types = actionTypes ?? _actionTypes; + return types.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } }