From 75b1c863066e86f9f155aaa4715acb154ac2446e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:22:31 +0200 Subject: [PATCH] feat(ui): add m3 app menus --- lib/main.dart | 2 +- lib/screens/discover_screen.dart | 84 +- lib/screens/libraries/libraries_screen.dart | 102 +-- lib/screens/livetv/tabs/guide_tab.dart | 131 ++-- .../profile/profile_detail_screen.dart | 8 +- .../profile/profile_switch_screen.dart | 13 +- lib/screens/settings/mpv_config_screen.dart | 5 +- .../screens/watch_together_screen.dart | 40 +- lib/widgets/app_menu.dart | 716 ++++++++++++++++++ lib/widgets/focusable_popup_menu_button.dart | 20 +- lib/widgets/media_context_menu.dart | 290 +------ .../focusable_popup_menu_button_test.dart | 3 +- 12 files changed, 904 insertions(+), 510 deletions(-) create mode 100644 lib/widgets/app_menu.dart diff --git a/lib/main.dart b/lib/main.dart index 8c447567..79d7ae8a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -913,7 +913,7 @@ class _MainAppState extends State with WidgetsBindingObserver { // Siri Remote select + gamepad A report as // LogicalKeyboardKey.{select,gameButtonA} which aren't // in Flutter's default shortcut set — Material-level - // widgets (PopupMenuItem, showModalBottomSheet actions) + // widgets (menu items, showModalBottomSheet actions) // ignore them. Map both to ActivateIntent so tapping // select on tvOS activates the focused widget. shortcuts: { diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 9953be38..e7d72585 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -27,6 +27,7 @@ import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; import '../widgets/hub_section.dart'; +import '../widgets/app_menu.dart'; import '../widgets/clickable_cursor.dart'; import '../widgets/loading_indicator_box.dart'; import '../widgets/profile_switching_overlay.dart'; @@ -210,6 +211,7 @@ class _DiscoverScreenState extends State // Hero and app bar focus late FocusNode _heroFocusNode; final _actionBarKey = GlobalKey(); + final _userMenuKey = GlobalKey>(); /// Backend-neutral hero client lookup. Returns the actual /// [MediaServerClient] for the item's server (Plex or Jellyfin) so @@ -1099,7 +1101,7 @@ class _DiscoverScreenState extends State Navigator.push(context, MaterialPageRoute(builder: (context) => const ProfileSwitchScreen())); } - /// Build the [FocusableAction] wrapping the user-menu PopupMenuButton. + /// Build the [FocusableAction] wrapping the user menu. /// Pulls live state from [ActiveProfileProvider]; the menu reuses /// [_userMenuItems] for the menu contents so d-pad and tap paths /// stay in sync. @@ -1109,60 +1111,42 @@ class _DiscoverScreenState extends State final profiles = activeProvider.profiles; return FocusableAction( - onPressed: _switchingProfile ? null : () => _showUserMenu(context), - child: PopupMenuButton( + onPressed: _switchingProfile ? null : () => _userMenuKey.currentState?.showButtonMenu(focusFirstItem: true), + child: AppMenuButton( + key: _userMenuKey, enabled: !_switchingProfile, icon: active != null ? ProfileAvatar(profile: active, size: 32) : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white), - itemBuilder: (context) => _userMenuItems(context, activeProfile: active, profiles: profiles), + tooltip: t.profiles.sectionTitle, + anchorAlignment: AppMenuAnchorAlignment.end, + onSelected: (value) => unawaited(_handleUserMenuAction(context, value)), + entriesBuilder: (context) => _userMenuItems(context, activeProfile: active, profiles: profiles), ), ); } - List> _userMenuItems( + List> _userMenuItems( BuildContext context, { required Profile? activeProfile, required List profiles, }) { final theme = Theme.of(context); final switchable = profiles.where((p) => p.id != activeProfile?.id).toList(); - void deferAction(String value) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) _handleUserMenuAction(context, value); - }); - } return [ for (final p in switchable) - PopupMenuItem( + AppMenuItem( value: 'profile:${p.id}', - onTap: () => deferAction('profile:${p.id}'), - child: Row( - children: [ - ProfileAvatar(profile: p, size: 24), - const SizedBox(width: 12), - Expanded(child: Text(p.displayName, overflow: .ellipsis)), - if (p.isPinProtected) ...[ - const SizedBox(width: 8), - AppIcon(Symbols.lock_rounded, fill: 1, size: 14, color: theme.colorScheme.onSurfaceVariant), - ], - ], - ), + leading: ProfileAvatar(profile: p, size: 24), + label: p.displayName, + trailing: p.isPinProtected + ? AppIcon(Symbols.lock_rounded, fill: 1, size: 14, color: theme.colorScheme.onSurfaceVariant) + : null, ), - if (switchable.isNotEmpty) const PopupMenuDivider(), - PopupMenuItem( - value: 'manage_profiles', - onTap: () => deferAction('manage_profiles'), - child: const Row(children: [AppIcon(Symbols.group_rounded, fill: 1), SizedBox(width: 8), Text('Profiles')]), - ), - PopupMenuItem( - value: 'logout', - onTap: () => deferAction('logout'), - child: Row( - children: [const AppIcon(Symbols.logout_rounded, fill: 1), const SizedBox(width: 8), Text(t.common.logout)], - ), - ), + if (switchable.isNotEmpty) const AppMenuDivider(), + AppMenuItem(value: 'manage_profiles', icon: Symbols.group_rounded, label: t.profiles.sectionTitle), + AppMenuItem(value: 'logout', icon: Symbols.logout_rounded, label: t.common.logout), ]; } @@ -1197,34 +1181,6 @@ class _DiscoverScreenState extends State } } - /// Show user menu programmatically (for D-pad select) - void _showUserMenu(BuildContext context) { - if (_switchingProfile) return; - final actionBar = _actionBarKey.currentState; - if (actionBar == null) return; - final lastNode = actionBar.getFocusNode(actionBar.widget.actions.length - 1); - final RenderBox? button = lastNode?.context?.findRenderObject() as RenderBox?; - if (button == null) return; - - final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; - final position = RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(Offset.zero, ancestor: overlay), - button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay), - ), - Offset.zero & overlay.size, - ); - - final activeProvider = context.read(); - unawaited( - showMenu( - context: context, - position: position, - items: _userMenuItems(context, activeProfile: activeProvider.active, profiles: activeProvider.profiles), - ), - ); - } - Widget _buildOverlaidAppBar() { final statusBarHeight = MediaQuery.paddingOf(context).top; final colorScheme = Theme.of(context).colorScheme; diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index ab5f93f1..2bbccee8 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -28,6 +28,7 @@ import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/content_utils.dart'; +import '../../widgets/app_menu.dart'; import '../../widgets/backend_badge.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/overlay_sheet.dart'; @@ -104,8 +105,8 @@ class _LibrariesScreenState extends State /// Track which tabs have loaded data (used to trigger focus after tab restore) final Set _loadedTabs = {}; - /// Key for the library dropdown popup menu button - final _libraryDropdownKey = GlobalKey>(); + /// Key for the library dropdown menu button. + final _libraryDropdownKey = GlobalKey>(); // Dynamic visible tabs and their focus nodes List _visibleTabs = LibraryTabType.values; @@ -828,15 +829,13 @@ class _LibrariesScreenState extends State ); } - PopupMenuItem _buildLibraryServerHeaderMenuItem(MediaLibrary library, String serverKey) { + AppMenuHeader _buildLibraryServerHeaderMenuItem(MediaLibrary library, String serverKey) { final style = Theme.of(context).textTheme.labelSmall?.copyWith( fontWeight: .w600, letterSpacing: 0.4, color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.65), ); - return PopupMenuItem( - enabled: false, - height: 32, + return AppMenuHeader( child: _buildLibraryServerLabel( library, style, @@ -847,51 +846,26 @@ class _LibrariesScreenState extends State ); } - PopupMenuItem _buildLibraryMenuItem(MediaLibrary library, {required bool showServerName}) { + AppMenuItem _buildLibraryMenuItem(MediaLibrary library, {required bool showServerName}) { final isSelected = library.globalKey == _selectedLibraryGlobalKey; - return PopupMenuItem( + return AppMenuItem( value: library.globalKey, - child: Row( - children: [ - AppIcon( - ContentTypeHelper.getLibraryIcon(library.kind.id), - fill: 1, - size: 20, - color: isSelected ? Theme.of(context).colorScheme.primary : null, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: .start, - mainAxisSize: .min, - children: [ - Text( - library.title, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - color: isSelected ? Theme.of(context).colorScheme.primary : null, - ), - ), - if (showServerName) - _buildLibraryServerLabel( - library, - TextStyle( - fontSize: 11, - color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6), - ), - badgeSize: 10, - constrainText: true, - ), - ], - ), - ), - ], - ), + icon: ContentTypeHelper.getLibraryIcon(library.kind.id), + label: library.title, + selected: isSelected, + subtitleWidget: showServerName + ? _buildLibraryServerLabel( + library, + TextStyle(fontSize: 11, color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6)), + badgeSize: 10, + constrainText: true, + ) + : null, ); } /// Build dropdown menu items with server subtitle when needed for clarity. - List> _buildGroupedLibraryMenuItems( + List> _buildGroupedLibraryMenuItems( List visibleLibraries, { required bool showServerHeaders, }) { @@ -904,7 +878,7 @@ class _LibrariesScreenState extends State } final grouped = groupLibrariesByFirstAppearance(visibleLibraries); - final menuItems = >[]; + final menuItems = >[]; for (final serverKey in grouped.serverOrder) { final bucket = grouped.byServer[serverKey]!; if (serverKey.isNotEmpty) { @@ -958,14 +932,14 @@ class _LibrariesScreenState extends State if (selectedLibrary == null) return Text(t.libraries.title); final showServerHeaders = _hasMultipleServers(visibleLibraries) && groupByServer; - return PopupMenuButton( + return AppMenuButton( key: _libraryDropdownKey, - offset: const Offset(0, 48), tooltip: t.libraries.selectLibrary, onSelected: (libraryGlobalKey) { _loadLibraryContent(libraryGlobalKey); }, - itemBuilder: (context) => _buildGroupedLibraryMenuItems(visibleLibraries, showServerHeaders: showServerHeaders), + entriesBuilder: (context) => + _buildGroupedLibraryMenuItems(visibleLibraries, showServerHeaders: showServerHeaders), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( @@ -1442,29 +1416,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { final menuItems = widget.getLibraryMenuItems(library); OverlaySheetController.pushAdaptive( outerContext, - builder: (context) => SafeArea( - top: false, - child: Column( - mainAxisSize: .min, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: .w600)), - ), - ...menuItems.indexed.map( - (entry) => ListTile( - leading: AppIcon(entry.$2.icon, fill: 1), - title: Text(entry.$2.label), - onTap: () { - // Close the entire overlay sheet, then let the parent handle - // confirmation and execution (parent state is always mounted) - OverlaySheetController.closeAdaptive(context); - widget.onLibraryMenuAction(entry.$2.value, library); - }, - ), - ), - ], - ), + builder: (context) => AppMenuSheet( + title: library.title, + entries: [ + for (final item in menuItems) + AppMenuItem(value: item.value, icon: item.icon, label: item.label, destructive: item.isDestructive), + ], + onSelected: (value) => widget.onLibraryMenuAction(value, library), ), ); } diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 2ba19a39..30bb188e 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -24,6 +24,7 @@ import '../../../utils/live_tv_matching.dart'; import '../../../utils/media_image_helper.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../widgets/app_icon.dart'; +import '../../../widgets/app_menu.dart'; import '../../../widgets/clickable_cursor.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/optimized_media_image.dart'; @@ -857,114 +858,90 @@ class GuideTabState extends State with MountedSetStateMixin { (t.liveTv.lateNight, 22), ]; - RelativeRect _menuPosition() { + Rect? _menuAnchorRect() { final renderBox = _dayPickerKey.currentContext?.findRenderObject() as RenderBox?; - final overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; - if (renderBox == null || overlay == null) return RelativeRect.fill; + if (renderBox == null) return null; final buttonPos = renderBox.localToGlobal(Offset.zero); final buttonSize = renderBox.size; - return RelativeRect.fromRect( - Rect.fromLTWH(buttonPos.dx, buttonPos.dy + buttonSize.height, buttonSize.width, 0), - Offset.zero & overlay.size, - ); + return Rect.fromLTWH(buttonPos.dx, buttonPos.dy, buttonSize.width, buttonSize.height); } - void _showDayPicker() { + Future _showDayPicker() async { + final anchorRect = _menuAnchorRect(); + if (anchorRect == null) return; + final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final gridDay = DateTime(_gridStart.year, _gridStart.month, _gridStart.day); - final theme = Theme.of(context); final days = []; for (var i = 0; i < 8; i++) { days.add(today.add(Duration(days: i))); } - showMenu( - context: context, - position: _menuPosition(), - items: [ - PopupMenuItem( - value: 'now', - child: Text(t.liveTv.now, style: theme.textTheme.bodyMedium), - ), + final value = await showAppMenu( + context, + anchorRect: anchorRect, + focusFirstItem: InputModeTracker.isKeyboardMode(context), + entries: [ + AppMenuItem(value: 'now', label: t.liveTv.now), ...days.map((day) { final isSelected = day == gridDay; final label = _dayLabel(day); - return PopupMenuItem( - value: day, - child: Row( - children: [ - Expanded( - child: Text( - label, - style: theme.textTheme.bodyMedium?.copyWith(color: isSelected ? theme.colorScheme.primary : null), - ), - ), - if (isSelected) AppIcon(Symbols.check_rounded, size: 18, color: theme.colorScheme.primary), - ], - ), - ); + return AppMenuItem(value: day, label: label, selected: isSelected); }), ], - ).then((value) { - if (!mounted) return; - if (value == null) { - _guideFocusNode.requestFocus(); - return; - } - if (value is String && value == 'now') { - _jumpToNow(); - _guideFocusNode.requestFocus(); - } else if (value is DateTime) { - _showTimeSlotPicker(value); - } - }); + ); + if (!mounted) return; + if (value == null) { + _guideFocusNode.requestFocus(); + return; + } + if (value is String && value == 'now') { + _jumpToNow(); + _guideFocusNode.requestFocus(); + } else if (value is DateTime) { + await _showTimeSlotPicker(value); + } } - void _showTimeSlotPicker(DateTime day) { - final theme = Theme.of(context); + Future _showTimeSlotPicker(DateTime day) async { + final anchorRect = _menuAnchorRect(); + if (anchorRect == null) return; + final label = _dayLabel(day).toUpperCase(); - showMenu( - context: context, - position: _menuPosition(), - items: [ - PopupMenuItem( + final value = await showAppMenu( + context, + anchorRect: anchorRect, + focusFirstItem: InputModeTracker.isKeyboardMode(context), + entries: [ + AppMenuItem( value: -1, - child: Row( - children: [ - AppIcon(Symbols.chevron_left_rounded, size: 20, color: theme.colorScheme.onSurface), - const SizedBox(width: 8), - Text(label, style: theme.textTheme.titleSmall?.copyWith(fontWeight: .bold)), - ], - ), + icon: Symbols.chevron_left_rounded, + child: Text(label, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: .bold)), ), - const PopupMenuDivider(), + const AppMenuDivider(), ..._timeSlots.map((slot) { - return PopupMenuItem( - value: slot.$2, - child: Text(slot.$1, style: theme.textTheme.bodyMedium), - ); + return AppMenuItem(value: slot.$2, label: slot.$1); }), ], - ).then((value) { - if (value == null) { - _guideFocusNode.requestFocus(); - return; - } - if (value == -1) { - _showDayPicker(); - return; - } - setState(() { - _gridStart = DateTime(day.year, day.month, day.day, value); - _gridEnd = _gridStart.add(const Duration(hours: 6)); - }); - _loadPrograms(); + ); + if (value == null) { _guideFocusNode.requestFocus(); + return; + } + if (value == -1) { + await _showDayPicker(); + return; + } + setState(() { + _gridStart = DateTime(day.year, day.month, day.day, value); + _gridEnd = _gridStart.add(const Duration(hours: 6)); }); + _loadPrograms(); + _guideFocusNode.requestFocus(); } Widget _timeNavFocusWrap({required Widget child, required int index, required ThemeData theme}) { diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index cd624698..7a3c22a8 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -21,6 +21,7 @@ import '../../providers/download_provider.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/app_menu.dart'; import '../../widgets/backend_badge.dart'; import '../../widgets/focusable_popup_menu_button.dart'; import '../../widgets/focused_scroll_scaffold.dart'; @@ -338,10 +339,9 @@ class _ConnectionsList extends StatelessWidget { } }, itemBuilder: (_) => [ - if (!pc.isDefault) PopupMenuItem(value: 'default', child: Text(t.profiles.makeDefault)), - if (conn is JellyfinConnection) - PopupMenuItem(value: 'edit', child: Text(t.common.edit)), - PopupMenuItem(value: 'remove', child: Text(t.profiles.removeConnection)), + if (!pc.isDefault) AppMenuItem(value: 'default', label: t.profiles.makeDefault), + if (conn is JellyfinConnection) AppMenuItem(value: 'edit', label: t.common.edit), + AppMenuItem(value: 'remove', label: t.profiles.removeConnection), ], ), ), diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index 400c7ec7..61e67510 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -25,6 +25,7 @@ import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/app_menu.dart'; import '../../widgets/backend_badge.dart'; import '../../widgets/focusable_popup_menu_button.dart'; import '../../widgets/focused_scroll_scaffold.dart'; @@ -54,7 +55,7 @@ class _ProfileSwitchScreenState extends State with MountedS bool _allowPop = false; final Map _profileFocusNodes = {}; final Map _profileMenuFocusNodes = {}; - final Map>> _profileMenuKeys = {}; + final Map>> _profileMenuKeys = {}; bool _focusRequested = false; bool _switching = false; Stream? _viewStream; @@ -171,8 +172,8 @@ class _ProfileSwitchScreenState extends State with MountedS return _profileMenuFocusNodes.putIfAbsent(profile.id, () => FocusNode(debugLabel: 'ProfileActions:${profile.id}')); } - GlobalKey> _profileMenuKey(Profile profile) { - return _profileMenuKeys.putIfAbsent(profile.id, () => GlobalKey>()); + GlobalKey> _profileMenuKey(Profile profile) { + return _profileMenuKeys.putIfAbsent(profile.id, () => GlobalKey>()); } void _pruneProfileFocusResources(Set activeIds) { @@ -389,7 +390,7 @@ class _ProfileTile extends StatelessWidget { final VoidCallback? onDelete; final VoidCallback? onSignOut; final FocusNode menuFocusNode; - final GlobalKey> menuKey; + final GlobalKey> menuKey; final VoidCallback onMenuNavigateLeft; const _ProfileTile({ @@ -487,7 +488,7 @@ class _ProfileTile extends StatelessWidget { } class _ProfileActionsButton extends StatelessWidget { - final GlobalKey> menuKey; + final GlobalKey> menuKey; final FocusNode focusNode; final VoidCallback onNavigateLeft; final ValueChanged<_TileAction> onSelected; @@ -511,7 +512,7 @@ class _ProfileActionsButton extends StatelessWidget { icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), tooltip: t.profiles.manage, onSelected: onSelected, - itemBuilder: (_) => [for (final action in actions) PopupMenuItem(value: action, child: Text(action.label))], + itemBuilder: (_) => [for (final action in actions) AppMenuItem(value: action, label: action.label)], ); } } diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index 7ff74631..0784eb49 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -13,6 +13,7 @@ import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; import '../../mixins/settings_effect_mixin.dart'; import '../../services/settings_service.dart'; +import '../../widgets/app_menu.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/focusable_popup_menu_button.dart'; import '../../widgets/settings_builder.dart'; @@ -226,8 +227,8 @@ class _MpvConfigScreenState extends State with SettingsEffectMi } }, itemBuilder: (context) => [ - PopupMenuItem(value: 'load', child: Text(t.mpvConfig.loadPreset)), - PopupMenuItem(value: 'delete', child: Text(t.mpvConfig.deletePreset)), + AppMenuItem(value: 'load', label: t.mpvConfig.loadPreset), + AppMenuItem(value: 'delete', label: t.mpvConfig.deletePreset), ], ), onTap: () => _loadPreset(preset), diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 022d095f..20e3b644 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -20,6 +20,7 @@ import '../../utils/snackbar_helper.dart'; import '../../widgets/dialog_action_button.dart'; import '../../utils/video_player_navigation.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/app_menu.dart'; import '../../widgets/overlay_sheet.dart'; import '../models/watch_session.dart'; import '../providers/watch_together_provider.dart'; @@ -430,28 +431,23 @@ class _RecentRoomTile extends StatelessWidget { void _showActions(BuildContext context) { OverlaySheetController.showAdaptive( context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: .min, - children: [ - ListTile( - leading: const Icon(Symbols.edit_rounded), - title: Text(t.watchTogether.renameRoom), - onTap: () { - OverlaySheetController.closeAdaptive(context); - onRename(); - }, - ), - ListTile( - leading: Icon(Symbols.delete_rounded, color: Theme.of(context).colorScheme.error), - title: Text(t.watchTogether.removeRoom, style: TextStyle(color: Theme.of(context).colorScheme.error)), - onTap: () { - OverlaySheetController.closeAdaptive(context); - onRemove(); - }, - ), - ], - ), + builder: (context) => AppMenuSheet( + entries: [ + AppMenuItem(value: 'rename', icon: Symbols.edit_rounded, label: t.watchTogether.renameRoom), + AppMenuItem( + value: 'remove', + icon: Symbols.delete_rounded, + label: t.watchTogether.removeRoom, + destructive: true, + ), + ], + onSelected: (value) { + if (value == 'rename') { + onRename(); + } else if (value == 'remove') { + onRemove(); + } + }, ), ); } diff --git a/lib/widgets/app_menu.dart b/lib/widgets/app_menu.dart new file mode 100644 index 00000000..9311ae61 --- /dev/null +++ b/lib/widgets/app_menu.dart @@ -0,0 +1,716 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../focus/dpad_navigator.dart'; +import '../focus/focusable_tile_mixin.dart'; +import '../focus/input_mode_tracker.dart'; +import '../focus/key_event_utils.dart'; +import '../theme/mono_tokens.dart'; +import '../utils/focus_utils.dart'; +import 'app_icon.dart'; +import 'clickable_cursor.dart'; +import 'overlay_sheet.dart'; + +typedef AppMenuEntryBuilder = List> Function(BuildContext context); + +enum AppMenuAnchorAlignment { start, end, center } + +abstract class AppMenuEntry { + const AppMenuEntry(); +} + +class AppMenuItem extends AppMenuEntry { + final T value; + final String? label; + final Widget? child; + final String? subtitle; + final Widget? subtitleWidget; + final IconData? icon; + final Widget? leading; + final Widget? trailing; + final bool enabled; + final bool selected; + final bool destructive; + final Color? foregroundColor; + final Color? stateLayerColor; + final String? semanticLabel; + + const AppMenuItem({ + required this.value, + this.label, + this.child, + this.subtitle, + this.subtitleWidget, + this.icon, + this.leading, + this.trailing, + this.enabled = true, + this.selected = false, + this.destructive = false, + this.foregroundColor, + this.stateLayerColor, + this.semanticLabel, + }) : assert(label != null || child != null, 'AppMenuItem requires either label or child'), + assert(subtitle == null || subtitleWidget == null, 'Provide subtitle or subtitleWidget, not both'), + assert(icon == null || leading == null, 'Provide icon or leading, not both'); +} + +class AppMenuDivider extends AppMenuEntry { + const AppMenuDivider(); +} + +class AppMenuHeader extends AppMenuEntry { + final String? label; + final Widget? child; + + const AppMenuHeader({this.label, this.child}) + : assert(label != null || child != null, 'AppMenuHeader requires either label or child'); +} + +Future showAppMenu( + BuildContext context, { + required List> entries, + Offset? position, + Rect? anchorRect, + AppMenuAnchorAlignment anchorAlignment = AppMenuAnchorAlignment.start, + bool focusFirstItem = false, + double minWidth = 220, + double? maxWidth, +}) { + assert(position != null || anchorRect != null, 'showAppMenu requires a position or anchorRect'); + + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 120), + pageBuilder: (dialogContext, _, _) => _AppMenuPopup( + entries: entries, + position: position, + anchorRect: anchorRect, + anchorAlignment: anchorAlignment, + focusFirstItem: focusFirstItem, + minWidth: minWidth, + maxWidth: maxWidth, + ), + transitionBuilder: (dialogContext, animation, _, child) { + final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic); + final alignment = _transitionAlignment(dialogContext, position: position, anchorRect: anchorRect); + + return FadeTransition( + opacity: curved, + child: AnimatedBuilder( + animation: curved, + child: child, + builder: (context, child) => Transform.scale( + scale: 0.96 + curved.value * 0.04, + alignment: alignment, + transformHitTests: false, + child: child, + ), + ), + ); + }, + ); +} + +Alignment _transitionAlignment(BuildContext context, {Offset? position, Rect? anchorRect}) { + final size = MediaQuery.sizeOf(context); + final origin = position ?? anchorRect?.center ?? Offset(size.width / 2, size.height / 2); + return Alignment( + size.width <= 0 ? 0 : ((origin.dx / size.width) * 2 - 1).clamp(-1.0, 1.0).toDouble(), + size.height <= 0 ? 0 : ((origin.dy / size.height) * 2 - 1).clamp(-1.0, 1.0).toDouble(), + ); +} + +class AppMenuButton extends StatefulWidget { + final Widget? icon; + final Widget? child; + final String? tooltip; + final bool enabled; + final AppMenuEntryBuilder entriesBuilder; + final ValueChanged? onSelected; + final AppMenuAnchorAlignment anchorAlignment; + final Offset alignmentOffset; + final double minWidth; + final double? maxWidth; + final EdgeInsetsGeometry? childPadding; + + const AppMenuButton({ + super.key, + this.icon, + this.child, + this.tooltip, + this.enabled = true, + required this.entriesBuilder, + this.onSelected, + this.anchorAlignment = AppMenuAnchorAlignment.start, + this.alignmentOffset = Offset.zero, + this.minWidth = 220, + this.maxWidth, + this.childPadding, + }) : assert(icon != null || child != null, 'AppMenuButton requires icon or child'); + + @override + State> createState() => AppMenuButtonState(); +} + +class AppMenuButtonState extends State> { + Future showButtonMenu({bool focusFirstItem = true}) async { + if (!widget.enabled) return null; + + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null) return null; + + final topLeft = renderBox.localToGlobal(Offset.zero) + widget.alignmentOffset; + final anchorRect = Rect.fromLTWH(topLeft.dx, topLeft.dy, renderBox.size.width, renderBox.size.height); + final selected = await showAppMenu( + context, + entries: widget.entriesBuilder(context), + anchorRect: anchorRect, + anchorAlignment: widget.anchorAlignment, + focusFirstItem: focusFirstItem, + minWidth: widget.minWidth, + maxWidth: widget.maxWidth, + ); + if (!mounted || selected == null) return selected; + widget.onSelected?.call(selected); + return selected; + } + + Future _handlePressed() async { + await showButtonMenu(focusFirstItem: InputModeTracker.isKeyboardMode(context)); + } + + @override + Widget build(BuildContext context) { + final child = widget.child; + if (child != null) { + final content = Padding(padding: widget.childPadding ?? EdgeInsets.zero, child: child); + final button = ClickableCursor( + enabled: widget.enabled, + child: InkWell( + onTap: widget.enabled ? _handlePressed : null, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: content, + ), + ); + final tooltip = widget.tooltip; + return tooltip == null ? button : Tooltip(message: tooltip, child: button); + } + + return IconButton(icon: widget.icon!, tooltip: widget.tooltip, onPressed: widget.enabled ? _handlePressed : null); + } +} + +class AppMenuSheet extends StatelessWidget { + final String? title; + final Widget? titleWidget; + final List> entries; + final bool focusFirstItem; + final ValueChanged? onSelected; + final bool closeOnSelected; + + const AppMenuSheet({ + super.key, + this.title, + this.titleWidget, + required this.entries, + this.focusFirstItem = false, + this.onSelected, + this.closeOnSelected = true, + }) : assert(title == null || titleWidget == null, 'Provide title or titleWidget, not both'); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (titleWidget != null || title != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: + titleWidget ?? + Text( + title!, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Flexible( + child: SingleChildScrollView( + child: AppMenuList( + entries: entries, + focusFirstItem: focusFirstItem, + onSelected: (value) { + if (closeOnSelected) OverlaySheetController.closeAdaptive(context, value); + onSelected?.call(value); + }, + ), + ), + ), + ], + ); + } +} + +class AppMenuList extends StatefulWidget { + final List> entries; + final bool focusFirstItem; + final ValueChanged onSelected; + final EdgeInsetsGeometry padding; + + const AppMenuList({ + super.key, + required this.entries, + required this.onSelected, + this.focusFirstItem = false, + this.padding = const EdgeInsets.symmetric(vertical: 5), + }); + + @override + State> createState() => _AppMenuListState(); +} + +class _AppMenuListState extends State> { + late final FocusNode _initialFocusNode; + + @override + void initState() { + super.initState(); + _initialFocusNode = FocusNode(debugLabel: 'AppMenuInitialFocus'); + if (widget.focusFirstItem) { + FocusUtils.requestFocusAfterBuild(this, _initialFocusNode); + } + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + var initialFocusAssigned = false; + return Padding( + padding: widget.padding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final entry in widget.entries) + switch (entry) { + AppMenuItem() => _buildItem( + entry, + initialFocusAssigned: () { + if (initialFocusAssigned || !entry.enabled) return false; + initialFocusAssigned = true; + return widget.focusFirstItem; + }(), + ), + AppMenuDivider() => const Padding(padding: EdgeInsets.symmetric(vertical: 4), child: Divider()), + AppMenuHeader() => _AppMenuHeaderTile(entry: entry), + _ => const SizedBox.shrink(), + }, + ], + ), + ); + } + + Widget _buildItem(AppMenuItem item, {required bool initialFocusAssigned}) { + return AppMenuItemTile( + item: item, + focusNode: initialFocusAssigned ? _initialFocusNode : null, + onPressed: item.enabled ? () => widget.onSelected(item.value) : null, + ); + } +} + +class AppMenuItemTile extends StatefulWidget { + final AppMenuItem item; + final VoidCallback? onPressed; + final FocusNode? focusNode; + + const AppMenuItemTile({super.key, required this.item, this.onPressed, this.focusNode}); + + @override + State> createState() => _AppMenuItemTileState(); +} + +class _AppMenuItemTileState extends State> with FocusableTileStateMixin> { + bool _isHovered = false; + bool _isFocused = false; + + @override + FocusNode? get widgetFocusNode => widget.focusNode; + + @override + void initState() { + super.initState(); + initFocusNode(); + effectiveFocusNode.addListener(_updateFocusedState); + } + + @override + void didUpdateWidget(AppMenuItemTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + effectiveFocusNode.removeListener(_updateFocusedState); + updateFocusNode(oldWidget.focusNode); + effectiveFocusNode.addListener(_updateFocusedState); + _isFocused = effectiveFocusNode.hasFocus; + } + } + + @override + void dispose() { + effectiveFocusNode.removeListener(_updateFocusedState); + disposeFocusNode(); + super.dispose(); + } + + void _updateFocusedState() { + final focused = effectiveFocusNode.hasFocus; + if (_isFocused != focused) setState(() => _isFocused = focused); + } + + @override + Widget build(BuildContext context) { + final item = widget.item; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final enabled = item.enabled && widget.onPressed != null; + final active = enabled && (_isFocused || _isHovered); + final foreground = _foregroundColor(context, active: active); + final subtitleColor = foreground.withValues(alpha: active && item.stateLayerColor != null ? 0.86 : 0.68); + final background = _backgroundColor(context, active: active); + + final leading = item.leading ?? (item.icon != null ? AppIcon(item.icon!, fill: 1, size: 20) : null); + final trailing = item.trailing ?? (item.selected ? AppIcon(Symbols.check_rounded, size: 18) : null); + final subtitle = item.subtitleWidget ?? (item.subtitle != null ? Text(item.subtitle!) : null); + + return Semantics( + button: true, + enabled: enabled, + selected: item.selected, + label: item.semanticLabel, + child: Focus( + focusNode: effectiveFocusNode, + canRequestFocus: enabled, + onKeyEvent: (node, event) { + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) return KeyEventResult.handled; + return dpadKeyHandler(onSelect: enabled ? widget.onPressed : null, trapHorizontalEdges: true)(node, event); + }, + child: MouseRegion( + cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer, + onEnter: enabled ? (_) => setState(() => _isHovered = true) : null, + onExit: enabled ? (_) => setState(() => _isHovered = false) : null, + child: ClickableCursor( + enabled: enabled, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled ? widget.onPressed : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + child: AnimatedContainer( + duration: tokens(context).fast, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + ), + constraints: BoxConstraints(minHeight: subtitle == null ? 40 : 52), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + if (leading != null) ...[ + SizedBox( + width: 24, + child: IconTheme.merge( + data: IconThemeData(color: foreground), + child: leading, + ), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + DefaultTextStyle.merge( + style: textTheme.bodyMedium?.copyWith( + color: enabled ? foreground : colorScheme.onSurface.withValues(alpha: 0.38), + fontWeight: item.selected ? FontWeight.w600 : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: item.child ?? Text(item.label!), + ), + if (subtitle != null) + DefaultTextStyle.merge( + style: textTheme.labelSmall?.copyWith( + color: enabled ? subtitleColor : colorScheme.onSurface.withValues(alpha: 0.38), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: subtitle, + ), + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 12), + IconTheme.merge( + data: IconThemeData(color: foreground), + child: trailing, + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + + Color _foregroundColor(BuildContext context, {required bool active}) { + final item = widget.item; + final colorScheme = Theme.of(context).colorScheme; + if (active && item.stateLayerColor != null) return colorScheme.onError; + if (item.foregroundColor != null) return item.foregroundColor!; + if (item.destructive) return _destructiveMenuForeground(context); + if (item.selected) return colorScheme.primary; + return colorScheme.onSurface; + } + + Color _backgroundColor(BuildContext context, {required bool active}) { + final item = widget.item; + final colorScheme = Theme.of(context).colorScheme; + if (active && item.stateLayerColor != null) return item.stateLayerColor!; + if (active) return colorScheme.onSurface.withValues(alpha: 0.08); + if (item.selected) return colorScheme.primary.withValues(alpha: 0.12); + return Colors.transparent; + } +} + +class _AppMenuHeaderTile extends StatelessWidget { + final AppMenuHeader entry; + + const _AppMenuHeaderTile({required this.entry}); + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.62), + ); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: DefaultTextStyle.merge( + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: entry.child ?? Text(entry.label!), + ), + ); + } +} + +class _AppMenuPopup extends StatefulWidget { + final List> entries; + final Offset? position; + final Rect? anchorRect; + final AppMenuAnchorAlignment anchorAlignment; + final bool focusFirstItem; + final double minWidth; + final double? maxWidth; + + const _AppMenuPopup({ + required this.entries, + required this.position, + required this.anchorRect, + required this.anchorAlignment, + required this.focusFirstItem, + required this.minWidth, + required this.maxWidth, + }); + + @override + State<_AppMenuPopup> createState() => _AppMenuPopupState(); +} + +class _AppMenuPopupState extends State<_AppMenuPopup> { + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.sizeOf(context); + const edgePadding = 8.0; + final desiredWidth = widget.maxWidth ?? math.max(widget.minWidth, _estimateMenuWidth(context)); + final menuWidth = desiredWidth.clamp( + widget.minWidth, + math.max(widget.minWidth, screenSize.width - edgePadding * 2), + ); + final estimatedHeight = _estimateMenuHeight(widget.entries); + final availableHeight = math.max(0.0, screenSize.height - edgePadding * 2); + final menuHeight = estimatedHeight.clamp(0.0, availableHeight).toDouble(); + final (:left, :top) = _resolvePosition(screenSize, menuWidth.toDouble(), menuHeight, edgePadding); + + return FocusScope( + autofocus: false, + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) { + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) return KeyEventResult.handled; + if (BackKeyUpSuppressor.consumeIfSuppressed(event)) return KeyEventResult.handled; + if (event.logicalKey.isBackKey) return handleBackKeyAction(event, () => Navigator.pop(context)); + return KeyEventResult.ignored; + }, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + if ((event.buttons & kSecondaryMouseButton) != 0) Navigator.pop(context); + }, + child: Stack( + children: [ + Positioned( + left: left, + top: top, + child: _AppMenuSurface( + width: menuWidth.toDouble(), + maxHeight: menuHeight, + entries: widget.entries, + focusFirstItem: widget.focusFirstItem, + ), + ), + ], + ), + ), + ), + ); + } + + ({double left, double top}) _resolvePosition( + Size screenSize, + double menuWidth, + double menuHeight, + double edgePadding, + ) { + final anchorRect = widget.anchorRect; + if (anchorRect != null) { + final leftCandidate = switch (widget.anchorAlignment) { + AppMenuAnchorAlignment.start => anchorRect.left, + AppMenuAnchorAlignment.end => anchorRect.right - menuWidth, + AppMenuAnchorAlignment.center => anchorRect.center.dx - menuWidth / 2, + }; + final maxLeft = screenSize.width - menuWidth - edgePadding; + final left = leftCandidate.clamp(edgePadding, maxLeft < edgePadding ? edgePadding : maxLeft).toDouble(); + + const gap = 4.0; + final below = anchorRect.bottom + gap; + final above = anchorRect.top - menuHeight - gap; + final fitsBelow = below + menuHeight <= screenSize.height - edgePadding; + final topCandidate = fitsBelow ? below : above; + final maxTop = screenSize.height - menuHeight - edgePadding; + final top = topCandidate.clamp(edgePadding, maxTop < edgePadding ? edgePadding : maxTop).toDouble(); + return (left: left, top: top); + } + + final position = widget.position ?? Offset(screenSize.width / 2, screenSize.height / 2); + final maxLeft = screenSize.width - menuWidth - edgePadding; + final left = (position.dx - menuWidth / 2) + .clamp(edgePadding, maxLeft < edgePadding ? edgePadding : maxLeft) + .toDouble(); + final maxTop = screenSize.height - menuHeight - edgePadding; + final top = (position.dy - menuHeight / 2) + .clamp(edgePadding, maxTop < edgePadding ? edgePadding : maxTop) + .toDouble(); + return (left: left, top: top); + } + + double _estimateMenuWidth(BuildContext context) { + var longest = 0; + for (final entry in widget.entries) { + if (entry is AppMenuItem) { + longest = math.max(longest, entry.label?.length ?? 0); + } else if (entry is AppMenuHeader) { + longest = math.max(longest, entry.label?.length ?? 0); + } + } + return math.min(360, math.max(widget.minWidth, 96 + longest * 7.5)); + } +} + +class _AppMenuSurface extends StatelessWidget { + final double width; + final double maxHeight; + final List> entries; + final bool focusFirstItem; + + const _AppMenuSurface({ + required this.width, + required this.maxHeight, + required this.entries, + required this.focusFirstItem, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final surface = Color.alphaBlend(colorScheme.onSurface.withValues(alpha: 0.08), colorScheme.surface); + return Material( + elevation: 3, + shadowColor: colorScheme.shadow, + color: surface, + borderRadius: BorderRadius.circular(tokens(context).radiusMd), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: width, maxWidth: width, maxHeight: maxHeight), + child: PrimaryScrollController.none( + child: SingleChildScrollView( + child: AppMenuList( + entries: entries, + focusFirstItem: focusFirstItem, + onSelected: (value) => Navigator.pop(context, value), + ), + ), + ), + ), + ); + } +} + +double _estimateMenuHeight(List> entries) { + var height = 10.0; + for (final entry in entries) { + switch (entry) { + case AppMenuItem(): + height += entry.subtitle != null || entry.subtitleWidget != null ? 54 : 42; + case AppMenuDivider(): + height += 9; + case AppMenuHeader(): + height += 32; + default: + break; + } + } + return height; +} + +Color _destructiveMenuForeground(BuildContext context) { + return Theme.of(context).colorScheme.brightness == Brightness.dark + ? const Color(0xFFFF453A) + : const Color(0xFFFF3B30); +} diff --git a/lib/widgets/focusable_popup_menu_button.dart b/lib/widgets/focusable_popup_menu_button.dart index 6a00091a..2156a644 100644 --- a/lib/widgets/focusable_popup_menu_button.dart +++ b/lib/widgets/focusable_popup_menu_button.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; import '../focus/focusable_wrapper.dart'; +import 'app_menu.dart'; -/// A [PopupMenuButton] that can be focused and opened with D-pad select. +/// An [AppMenuButton] that can be focused and opened with D-pad select. class FocusablePopupMenuButton extends StatefulWidget { final Widget? icon; final String? tooltip; - final PopupMenuItemBuilder itemBuilder; - final PopupMenuItemSelected? onSelected; - final GlobalKey>? menuKey; + final AppMenuEntryBuilder itemBuilder; + final ValueChanged? onSelected; + final GlobalKey>? menuKey; final FocusNode? focusNode; final VoidCallback? onNavigateUp; final VoidCallback? onNavigateDown; @@ -42,11 +43,11 @@ class FocusablePopupMenuButton extends StatefulWidget { } class _FocusablePopupMenuButtonState extends State> { - final _ownedMenuKey = GlobalKey>(); + final _ownedMenuKey = GlobalKey>(); - GlobalKey> get _menuKey => widget.menuKey ?? _ownedMenuKey; + GlobalKey> get _menuKey => widget.menuKey ?? _ownedMenuKey; - void _showMenu() => _menuKey.currentState?.showButtonMenu(); + void _showMenu() => _menuKey.currentState?.showButtonMenu(focusFirstItem: true); @override Widget build(BuildContext context) { @@ -64,13 +65,12 @@ class _FocusablePopupMenuButtonState extends State( + child: AppMenuButton( key: _menuKey, icon: widget.icon, tooltip: widget.tooltip, - requestFocus: true, onSelected: widget.onSelected, - itemBuilder: widget.itemBuilder, + entriesBuilder: widget.itemBuilder, ), ); } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 1f5b6c18..6934cf2e 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,7 +1,6 @@ import 'dart:async'; import '../media/ids.dart'; import 'dart:io'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -38,18 +37,16 @@ import '../utils/media_server_http_client.dart'; import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; import '../utils/dialogs.dart'; -import '../utils/focus_utils.dart'; import '../services/external_player_service.dart'; import '../focus/focusable_button.dart'; import '../focus/focusable_text_field.dart'; -import '../focus/dpad_navigator.dart'; import '../screens/plex_match_screen.dart'; import '../screens/media_detail_screen.dart'; import '../screens/metadata_edit_screen.dart'; import '../utils/smart_deletion_handler.dart'; import '../utils/video_player_navigation.dart'; import '../utils/deletion_notifier.dart'; -import '../theme/mono_tokens.dart'; +import '../widgets/app_menu.dart'; import '../widgets/file_info_bottom_sheet.dart'; import 'pill_input_decoration.dart'; import '../widgets/focusable_list_tile.dart'; @@ -61,18 +58,9 @@ class _MenuAction { final String value; final IconData icon; final String label; - final Color? hoverColor; - final Color? foregroundColor; + final bool destructive; - _MenuAction({required this.value, required this.icon, required this.label, this.hoverColor, this.foregroundColor}); -} - -Color _destructiveMenuForeground(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - if (colorScheme.brightness != Brightness.dark) return colorScheme.error; - - final error = HSLColor.fromColor(colorScheme.error); - return error.withLightness(error.lightness < 0.72 ? 0.72 : error.lightness).toColor(); + _MenuAction({required this.value, required this.icon, required this.label, this.destructive = false}); } bool isAdminActionAllowedForMediaItem({ @@ -272,7 +260,9 @@ class MediaContextMenuState extends State { } } - menuActions.add(_MenuAction(value: 'delete', icon: Symbols.delete_rounded, label: t.common.delete)); + menuActions.add( + _MenuAction(value: 'delete', icon: Symbols.delete_rounded, label: t.common.delete, destructive: true), + ); } else { if (hasActiveProgress) { menuActions.add( @@ -441,12 +431,22 @@ class MediaContextMenuState extends State { ); if (hasAnyDownload) { menuActions.add( - _MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload), + _MenuAction( + value: 'delete_download', + icon: Symbols.delete_rounded, + label: t.downloads.deleteDownload, + destructive: true, + ), ); } } else if (hasAnyDownload) { menuActions.add( - _MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload), + _MenuAction( + value: 'delete_download', + icon: Symbols.delete_rounded, + label: t.downloads.deleteDownload, + destructive: true, + ), ); } else { menuActions.add( @@ -480,8 +480,7 @@ class MediaContextMenuState extends State { value: 'delete_media', icon: Symbols.delete_forever_rounded, label: t.mediaMenu.deleteFromServer, - hoverColor: Theme.of(context).colorScheme.error, - foregroundColor: _destructiveMenuForeground(context), + destructive: true, ), ); } @@ -496,57 +495,27 @@ class MediaContextMenuState extends State { selected = await OverlaySheetController.showAdaptive( context, showDragHandle: true, - builder: (context) => _FocusableContextMenuSheet( + builder: (context) => AppMenuSheet( title: _itemDisplayTitle(), - actions: menuActions, + entries: _menuEntries(menuActions), focusFirstItem: openedFromKeyboard, ), ); } else { - final RenderBox? overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; - Offset position; if (_tapPosition != null) { position = _tapPosition!; } else { + final RenderBox? overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; final RenderBox renderBox = context.findRenderObject() as RenderBox; position = renderBox.localToGlobal(Offset.zero, ancestor: overlay); } - selected = await showGeneralDialog( - context: context, - barrierDismissible: true, - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, - barrierColor: Colors.transparent, - transitionDuration: const Duration(milliseconds: 120), - pageBuilder: (dialogContext, _, _) => - _FocusablePopupMenu(actions: menuActions, position: position, focusFirstItem: openedFromKeyboard), - transitionBuilder: (dialogContext, animation, _, child) { - final curved = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - final screenSize = MediaQuery.sizeOf(dialogContext); - final alignment = Alignment( - screenSize.width <= 0 ? 0 : ((position.dx / screenSize.width) * 2 - 1).clamp(-1.0, 1.0).toDouble(), - screenSize.height <= 0 ? 0 : ((position.dy / screenSize.height) * 2 - 1).clamp(-1.0, 1.0).toDouble(), - ); - - return FadeTransition( - opacity: curved, - child: AnimatedBuilder( - animation: curved, - child: child, - builder: (context, child) => Transform.scale( - scale: 0.96 + curved.value * 0.04, - alignment: alignment, - transformHitTests: false, - child: child, - ), - ), - ); - }, + selected = await showAppMenu( + context, + entries: _menuEntries(menuActions), + position: position, + focusFirstItem: openedFromKeyboard, ); } @@ -772,6 +741,18 @@ class MediaContextMenuState extends State { } } + List> _menuEntries(List<_MenuAction> actions) { + return [ + for (final action in actions) + AppMenuItem( + value: action.value, + icon: action.icon, + label: action.label, + destructive: action.destructive, + ), + ]; + } + /// Execute an action with error handling and refresh Future _executeAction(BuildContext context, Future Function() action, String successMessage) async { try { @@ -1876,196 +1857,3 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> ); } } - -/// Focusable context menu sheet for keyboard/gamepad navigation (mobile) -class _FocusableContextMenuSheet extends StatefulWidget { - final String title; - final List<_MenuAction> actions; - final bool focusFirstItem; - - const _FocusableContextMenuSheet({required this.title, required this.actions, this.focusFirstItem = false}); - - @override - State<_FocusableContextMenuSheet> createState() => _FocusableContextMenuSheetState(); -} - -class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet> { - late final FocusNode _initialFocusNode; - - @override - void initState() { - super.initState(); - _initialFocusNode = FocusNode(debugLabel: 'ContextMenuSheetInitialFocus'); - } - - @override - void dispose() { - _initialFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: .min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text(widget.title, style: Theme.of(context).textTheme.titleMedium, maxLines: 1, overflow: .ellipsis), - ), - Flexible( - child: SingleChildScrollView( - child: Column( - mainAxisSize: .min, - children: [ - ...widget.actions.asMap().entries.map((entry) { - final index = entry.key; - final action = entry.value; - return FocusableListTile( - key: ValueKey(action.value), - focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, - leading: AppIcon(action.icon, fill: 1), - title: Text(action.label), - onTap: () => OverlaySheetController.closeAdaptive(context, action.value), - hoverColor: action.hoverColor, - textColor: action.foregroundColor, - iconColor: action.foregroundColor, - ); - }), - ], - ), - ), - ), - ], - ); - } -} - -/// Focusable popup menu for keyboard/gamepad navigation (desktop) -class _FocusablePopupMenu extends StatefulWidget { - final List<_MenuAction> actions; - final Offset position; - final bool focusFirstItem; - - const _FocusablePopupMenu({required this.actions, required this.position, this.focusFirstItem = false}); - - @override - State<_FocusablePopupMenu> createState() => _FocusablePopupMenuState(); -} - -class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { - late final FocusNode _initialFocusNode; - - @override - void initState() { - super.initState(); - _initialFocusNode = FocusNode(debugLabel: 'PopupMenuInitialFocus'); - if (widget.focusFirstItem) { - FocusUtils.requestFocusAfterBuild(this, _initialFocusNode); - } - } - - @override - void dispose() { - _initialFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final screenSize = MediaQuery.sizeOf(context); - const menuWidth = 220.0; - - // Treat the requested origin as the menu center, then clamp to screen bounds. - const edgePadding = 8.0; - final estimatedHeight = widget.actions.length * 48.0 + 16; - final maxLeft = screenSize.width - menuWidth - edgePadding; - final left = (widget.position.dx - menuWidth / 2) - .clamp(edgePadding, maxLeft < edgePadding ? edgePadding : maxLeft) - .toDouble(); - - final availableHeight = screenSize.height - edgePadding * 2; - final menuHeight = availableHeight <= 0 ? 0.0 : estimatedHeight.clamp(0.0, availableHeight).toDouble(); - final maxTop = screenSize.height - menuHeight - edgePadding; - final top = (widget.position.dy - menuHeight / 2) - .clamp(edgePadding, maxTop < edgePadding ? edgePadding : maxTop) - .toDouble(); - final maxHeight = menuHeight; - - return FocusScope( - // When opened via mouse, don't autofocus any item — let hover handle highlights. - // When opened via keyboard/dpad, autofocus is handled by _initialFocusNode. - autofocus: false, - child: Focus( - canRequestFocus: false, - skipTraversal: true, - onKeyEvent: (node, event) { - if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { - return KeyEventResult.handled; - } - if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (event) { - if ((event.buttons & kSecondaryMouseButton) != 0) { - Navigator.pop(context); - } - }, - child: Stack( - children: [ - // Barrier to close menu when clicking outside - Positioned.fill( - child: GestureDetector( - onTap: () => Navigator.pop(context), - behavior: HitTestBehavior.opaque, - child: const ColoredBox(color: Colors.transparent), - ), - ), - // Menu - Positioned( - left: left, - top: top, - child: Material( - elevation: 8, - color: Color.alphaBlend( - Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08), - Theme.of(context).colorScheme.surface, - ), - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth, maxHeight: maxHeight), - child: SingleChildScrollView( - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .stretch, - children: widget.actions.asMap().entries.map((entry) { - final index = entry.key; - final action = entry.value; - return FocusableListTile( - key: ValueKey(action.value), - focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, - leading: AppIcon(action.icon, fill: 1, size: 20), - title: Text(action.label), - onTap: () => Navigator.pop(context, action.value), - hoverColor: action.hoverColor, - textColor: action.foregroundColor, - iconColor: action.foregroundColor, - ); - }).toList(), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/test/widgets/focusable_popup_menu_button_test.dart b/test/widgets/focusable_popup_menu_button_test.dart index 71632ee7..c905dd38 100644 --- a/test/widgets/focusable_popup_menu_button_test.dart +++ b/test/widgets/focusable_popup_menu_button_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/app_menu.dart'; import 'package:plezy/widgets/focusable_popup_menu_button.dart'; void main() { @@ -15,7 +16,7 @@ void main() { child: FocusablePopupMenuButton( focusNode: focusNode, icon: const Icon(Icons.more_vert), - itemBuilder: (_) => const [PopupMenuItem(value: 'one', child: Text('One'))], + itemBuilder: (_) => const [AppMenuItem(value: 'one', label: 'One')], ), ), ),