Files
plezy/lib/widgets/app_menu.dart
T
edde746 ef75887504 style: apply analyzer lint fixes across lib
Initializing formals, unawaited() on fire-and-forget futures, unused
import removal, wildcard params, and mounted vs context.mounted.
2026-07-06 15:56:56 +02:00

716 lines
24 KiB
Dart

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<T> = List<AppMenuEntry<T>> Function(BuildContext context);
enum AppMenuAnchorAlignment { start, end, center }
abstract class AppMenuEntry<T> {
const AppMenuEntry();
}
class AppMenuItem<T> extends AppMenuEntry<T> {
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<T> extends AppMenuEntry<T> {
const AppMenuDivider();
}
class AppMenuHeader<T> extends AppMenuEntry<T> {
final String? label;
final Widget? child;
const AppMenuHeader({this.label, this.child})
: assert(label != null || child != null, 'AppMenuHeader requires either label or child');
}
Future<T?> showAppMenu<T>(
BuildContext context, {
required List<AppMenuEntry<T>> 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<T>(
context: context,
barrierDismissible: true,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 120),
pageBuilder: (dialogContext, _, _) => _AppMenuPopup<T>(
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<T> extends StatefulWidget {
final Widget? icon;
final Widget? child;
final String? tooltip;
final bool enabled;
final AppMenuEntryBuilder<T> entriesBuilder;
final ValueChanged<T>? 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<AppMenuButton<T>> createState() => AppMenuButtonState<T>();
}
class AppMenuButtonState<T> extends State<AppMenuButton<T>> {
Future<T?> 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<T>(
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<void> _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<T> extends StatelessWidget {
final String? title;
final Widget? titleWidget;
final List<AppMenuEntry<T>> entries;
final bool focusFirstItem;
final ValueChanged<T>? 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<T>(
entries: entries,
focusFirstItem: focusFirstItem,
onSelected: (value) {
if (closeOnSelected) OverlaySheetController.closeAdaptive(context, value);
onSelected?.call(value);
},
),
),
),
],
);
}
}
class AppMenuList<T> extends StatefulWidget {
final List<AppMenuEntry<T>> entries;
final bool focusFirstItem;
final ValueChanged<T> 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<AppMenuList<T>> createState() => _AppMenuListState<T>();
}
class _AppMenuListState<T> extends State<AppMenuList<T>> {
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<T>() => _buildItem(
entry,
initialFocusAssigned: () {
if (initialFocusAssigned || !entry.enabled) return false;
initialFocusAssigned = true;
return widget.focusFirstItem;
}(),
),
AppMenuDivider<T>() => const Padding(padding: EdgeInsets.symmetric(vertical: 4), child: Divider()),
AppMenuHeader<T>() => _AppMenuHeaderTile(entry: entry),
_ => const SizedBox.shrink(),
},
],
),
);
}
Widget _buildItem(AppMenuItem<T> item, {required bool initialFocusAssigned}) {
return AppMenuItemTile<T>(
item: item,
focusNode: initialFocusAssigned ? _initialFocusNode : null,
onPressed: item.enabled ? () => widget.onSelected(item.value) : null,
);
}
}
class AppMenuItemTile<T> extends StatefulWidget {
final AppMenuItem<T> item;
final VoidCallback? onPressed;
final FocusNode? focusNode;
const AppMenuItemTile({super.key, required this.item, this.onPressed, this.focusNode});
@override
State<AppMenuItemTile<T>> createState() => _AppMenuItemTileState<T>();
}
class _AppMenuItemTileState<T> extends State<AppMenuItemTile<T>> with FocusableTileStateMixin<AppMenuItemTile<T>> {
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<T> 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<T> extends StatelessWidget {
final AppMenuHeader<T> 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<T> extends StatefulWidget {
final List<AppMenuEntry<T>> 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<T>> createState() => _AppMenuPopupState<T>();
}
class _AppMenuPopupState<T> extends State<_AppMenuPopup<T>> {
@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<T>(
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<T>) {
longest = math.max(longest, entry.label?.length ?? 0);
} else if (entry is AppMenuHeader<T>) {
longest = math.max(longest, entry.label?.length ?? 0);
}
}
return math.min(360, math.max(widget.minWidth, 96 + longest * 7.5));
}
}
class _AppMenuSurface<T> extends StatelessWidget {
final double width;
final double maxHeight;
final List<AppMenuEntry<T>> 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<T>(
entries: entries,
focusFirstItem: focusFirstItem,
onSelected: (value) => Navigator.pop(context, value),
),
),
),
),
);
}
}
double _estimateMenuHeight<T>(List<AppMenuEntry<T>> entries) {
var height = 10.0;
for (final entry in entries) {
switch (entry) {
case AppMenuItem<T>():
height += entry.subtitle != null || entry.subtitleWidget != null ? 54 : 42;
case AppMenuDivider<T>():
height += 9;
case AppMenuHeader<T>():
height += 32;
default:
break;
}
}
return height;
}
Color _destructiveMenuForeground(BuildContext context) {
return Theme.of(context).colorScheme.brightness == Brightness.dark
? const Color(0xFFFF453A)
: const Color(0xFFFF3B30);
}