import 'dart:async'; import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.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, ), ), ); }, ); } Future showAdaptiveAppMenu( BuildContext context, { required List> entries, String? title, Offset? position, Rect? anchorRect, AppMenuAnchorAlignment anchorAlignment = AppMenuAnchorAlignment.start, bool focusFirstItem = false, double minWidth = 220, double? maxWidth, bool isScrollControlled = false, }) { // ThemeData.platform follows the real target platform by default, including // Android TV and tvOS, while remaining overrideable in widget tests. final platform = Theme.of(context).platform; if (platform == TargetPlatform.iOS || platform == TargetPlatform.android) { return OverlaySheetController.showAdaptive( context, showDragHandle: true, isScrollControlled: isScrollControlled, builder: (context) => AppMenuSheet(title: title, entries: entries, focusFirstItem: focusFirstItem), ); } return showAppMenu( context, entries: entries, position: position, anchorRect: anchorRect, anchorAlignment: anchorAlignment, focusFirstItem: focusFirstItem, minWidth: minWidth, maxWidth: maxWidth, ); } 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, listen: false)); } @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(); effectiveFocusNode.addListener(_updateFocusedState); } @override void didUpdateWidget(AppMenuItemTile oldWidget) { final rebinds = oldWidget.focusNode != widget.focusNode; if (rebinds) effectiveFocusNode.removeListener(_updateFocusedState); super.didUpdateWidget(oldWidget); if (rebinds) { effectiveFocusNode.addListener(_updateFocusedState); _isFocused = effectiveFocusNode.hasFocus; } } @override void dispose() { effectiveFocusNode.removeListener(_updateFocusedState); 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 && InputModeTracker.isKeyboardMode(context)) || _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); }