forked from Foxyz/sync_replay_project
One app menu style for every picker; links go Through me by default
- AppSelect / AppMenuButton (widgets/app_menu.dart): glass menu anchored to its button, check on the current item, keyboard and Esc; replaces the Material dropdowns in Settings, player settings, Jellyfin and the member menu. Material menus elsewhere get the same surface by theme. - Link page: Through me is the main choice (you can't know which sites need cookies); Everyone / Server fetches it sit under "Other ways". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a7b553597
commit
95cd668cfb
17 files changed
+630
-125
No files matched your search
@@ -0,0 +1,433 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../shell/motion.dart';
|
||||
|
||||
// The app's one menu: a frosted dark panel anchored under (or over) the
|
||||
// widget that opens it, rows with a hover and keyboard highlight, a check
|
||||
// on the current one. [AppSelect] is the value picker built on it (in place
|
||||
// of a dropdown), [AppMenuButton] the "⋮" action menu. The menu is a popup
|
||||
// route: a click outside or Esc closes it, arrows and Enter pick, and the
|
||||
// player's keys stay off while it is open (in fullscreen too).
|
||||
|
||||
/// The menu surface, shared with Material's own menus via [appMenuTheme].
|
||||
abstract final class AppMenuStyle {
|
||||
/// Over a blur ([showAppMenu]).
|
||||
static const glass = Color(0xD918181C);
|
||||
/// Without a blur (Material menus).
|
||||
static const solid = Color(0xF518181C);
|
||||
static const border = Color(0x1FFFFFFF);
|
||||
static const hover = Color(0x14FFFFFF);
|
||||
static const focus = Color(0x24FFFFFF);
|
||||
static const radius = 12.0;
|
||||
static const rowRadius = 8.0;
|
||||
static const rowHeight = 36.0;
|
||||
static const maxWidth = 360.0;
|
||||
|
||||
static final shape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
side: const BorderSide(color: border, width: 0.5),
|
||||
);
|
||||
|
||||
/// The same surface for `showMenu`, `PopupMenuButton` and `MenuAnchor`.
|
||||
static ThemeData theme(ThemeData base) => base.copyWith(
|
||||
popupMenuTheme: PopupMenuThemeData(
|
||||
color: solid,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 8,
|
||||
shape: shape,
|
||||
menuPadding: const EdgeInsets.all(4),
|
||||
),
|
||||
menuTheme: MenuThemeData(
|
||||
style: MenuStyle(
|
||||
backgroundColor: const WidgetStatePropertyAll(solid),
|
||||
surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||
elevation: const WidgetStatePropertyAll(8),
|
||||
shape: WidgetStatePropertyAll(shape),
|
||||
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One row: [label], a dim [hint] at the right, [checked] = the current
|
||||
/// one (null: the menu has no checks for this row).
|
||||
class AppMenuItem<T> {
|
||||
final T value;
|
||||
final String label;
|
||||
final String? hint;
|
||||
final bool? checked;
|
||||
final bool enabled;
|
||||
final Key? key;
|
||||
const AppMenuItem({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.hint,
|
||||
this.checked,
|
||||
this.enabled = true,
|
||||
this.key,
|
||||
});
|
||||
}
|
||||
|
||||
/// Opens the menu at [context]'s box (the button). Resolves to the picked
|
||||
/// value, or null when dismissed. [minWidth]: at least this wide (a picker
|
||||
/// is as wide as its button).
|
||||
Future<T?> showAppMenu<T>(
|
||||
BuildContext context, {
|
||||
required List<AppMenuItem<T>> items,
|
||||
double minWidth = 0,
|
||||
}) async {
|
||||
final i = await _openMenu(context, items, minWidth);
|
||||
return i == null ? null : items[i].value;
|
||||
}
|
||||
|
||||
/// The picked row's index (the value may itself be null).
|
||||
Future<int?> _openMenu(BuildContext context, List<AppMenuItem<Object?>> items, double minWidth) {
|
||||
final navigator = Navigator.of(context);
|
||||
final overlay = navigator.overlay!.context.findRenderObject() as RenderBox;
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
final anchor = Rect.fromPoints(
|
||||
box.localToGlobal(Offset.zero, ancestor: overlay),
|
||||
box.localToGlobal(box.size.bottomRight(Offset.zero), ancestor: overlay),
|
||||
);
|
||||
Future<int?> open() => navigator.push<int>(_AppMenuRoute(
|
||||
anchor: anchor,
|
||||
items: items,
|
||||
minWidth: minWidth,
|
||||
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
|
||||
));
|
||||
// Over the uosc controls: keep them up while the menu is open.
|
||||
final controls = UoscControls.maybeOf(context);
|
||||
return controls == null ? open() : controls.holdDuring(open);
|
||||
}
|
||||
|
||||
class _AppMenuRoute extends PopupRoute<int> {
|
||||
final Rect anchor;
|
||||
final List<AppMenuItem<Object?>> items;
|
||||
final double minWidth;
|
||||
final CapturedThemes capturedThemes;
|
||||
_AppMenuRoute({required this.anchor, required this.items, required this.minWidth, required this.capturedThemes});
|
||||
|
||||
static const _gap = 4.0;
|
||||
static const _margin = 8.0;
|
||||
|
||||
// Opens down unless the room below is short and above is roomier.
|
||||
bool _below(Size screen) {
|
||||
final want = items.length * AppMenuStyle.rowHeight + 8;
|
||||
final below = screen.height - anchor.bottom - _gap - _margin;
|
||||
final above = anchor.top - _gap - _margin;
|
||||
return below >= want || below >= above;
|
||||
}
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => 'Close menu';
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 200);
|
||||
|
||||
@override
|
||||
Duration get reverseTransitionDuration => const Duration(milliseconds: 120);
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final below = _below(constraints.biggest);
|
||||
final a = CurvedAnimation(parent: animation, curve: ease, reverseCurve: Curves.easeIn);
|
||||
return CustomSingleChildLayout(
|
||||
delegate: _MenuLayout(anchor: anchor, below: below, minWidth: minWidth),
|
||||
child: FadeTransition(
|
||||
opacity: a,
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: 0.96, end: 1.0).animate(a),
|
||||
alignment: below ? Alignment.topCenter : Alignment.bottomCenter,
|
||||
child: capturedThemes.wrap(_AppMenuPanel(
|
||||
items: items,
|
||||
onPick: (i) => Navigator.of(context).pop(i),
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuLayout extends SingleChildLayoutDelegate {
|
||||
final Rect anchor;
|
||||
final bool below;
|
||||
final double minWidth;
|
||||
const _MenuLayout({required this.anchor, required this.below, required this.minWidth});
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
const m = _AppMenuRoute._margin, gap = _AppMenuRoute._gap;
|
||||
final maxW = math.max(0.0, math.min(math.max(AppMenuStyle.maxWidth, minWidth), constraints.maxWidth - 2 * m));
|
||||
final maxH = below ? constraints.maxHeight - anchor.bottom - gap - m : anchor.top - gap - m;
|
||||
return BoxConstraints(
|
||||
minWidth: math.min(minWidth, maxW),
|
||||
maxWidth: maxW,
|
||||
maxHeight: math.max(AppMenuStyle.rowHeight + 8, maxH),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
const m = _AppMenuRoute._margin, gap = _AppMenuRoute._gap;
|
||||
final x = anchor.left.clamp(m, math.max(m, size.width - childSize.width - m)).toDouble();
|
||||
final y = below ? anchor.bottom + gap : anchor.top - gap - childSize.height;
|
||||
return Offset(x, y.clamp(m, math.max(m, size.height - childSize.height - m)).toDouble());
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_MenuLayout old) => old.anchor != anchor || old.below != below || old.minWidth != minWidth;
|
||||
}
|
||||
|
||||
class _AppMenuPanel extends StatefulWidget {
|
||||
final List<AppMenuItem<Object?>> items;
|
||||
final ValueChanged<int> onPick;
|
||||
const _AppMenuPanel({required this.items, required this.onPick});
|
||||
|
||||
@override
|
||||
State<_AppMenuPanel> createState() => _AppMenuPanelState();
|
||||
}
|
||||
|
||||
class _AppMenuPanelState extends State<_AppMenuPanel> {
|
||||
final _current = GlobalKey();
|
||||
|
||||
// Focus (and scroll to) the current row, else the first enabled one.
|
||||
late final int _start = () {
|
||||
final checked = widget.items.indexWhere((i) => i.checked == true && i.enabled);
|
||||
return checked >= 0 ? checked : widget.items.indexWhere((i) => i.enabled);
|
||||
}();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final c = _current.currentContext;
|
||||
if (c != null && c.mounted) Scrollable.ensureVisible(c, alignment: 0.5);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final checks = widget.items.any((i) => i.checked != null);
|
||||
final r = BorderRadius.circular(AppMenuStyle.radius);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: r,
|
||||
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8))],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: r,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||||
child: Material(
|
||||
key: const ValueKey('app-menu'),
|
||||
color: AppMenuStyle.glass,
|
||||
shape: AppMenuStyle.shape,
|
||||
child: IntrinsicWidth(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, item) in widget.items.indexed)
|
||||
_MenuRow(
|
||||
key: i == _start ? _current : null,
|
||||
item: item,
|
||||
checks: checks,
|
||||
autofocus: i == _start,
|
||||
onTap: () => widget.onPick(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuRow extends StatelessWidget {
|
||||
final AppMenuItem<Object?> item;
|
||||
final bool checks;
|
||||
final bool autofocus;
|
||||
final VoidCallback onTap;
|
||||
const _MenuRow({super.key, required this.item, required this.checks, required this.autofocus, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final on = item.checked == true;
|
||||
final color = item.enabled ? scheme.onSurface : scheme.onSurface.withValues(alpha: 0.38);
|
||||
return InkWell(
|
||||
key: item.key,
|
||||
autofocus: autofocus,
|
||||
onTap: item.enabled ? onTap : null,
|
||||
borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius),
|
||||
hoverColor: AppMenuStyle.hover,
|
||||
focusColor: AppMenuStyle.focus,
|
||||
highlightColor: AppMenuStyle.focus,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
child: Container(
|
||||
height: AppMenuStyle.rowHeight,
|
||||
padding: EdgeInsets.only(left: checks ? 6 : 12, right: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (checks)
|
||||
SizedBox(
|
||||
width: 26,
|
||||
child: on ? Icon(Icons.check_rounded, size: 16, color: scheme.primary) : null,
|
||||
),
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 14, color: color, fontWeight: on ? FontWeight.w600 : FontWeight.normal),
|
||||
),
|
||||
),
|
||||
if (item.hint != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
Text(item.hint!, style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A value picker (in place of a dropdown): the current label and a small
|
||||
/// chevron on a soft rounded surface; the menu lists [items] (value,
|
||||
/// label) with the current one checked. [onChanged] null: disabled.
|
||||
/// [expanded]: fills the width it gets (the label ellipsizes).
|
||||
class AppSelect<T> extends StatefulWidget {
|
||||
final T value;
|
||||
final List<(T, String)> items;
|
||||
final ValueChanged<T>? onChanged;
|
||||
final bool expanded;
|
||||
final TextStyle? style;
|
||||
const AppSelect({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
this.expanded = false,
|
||||
this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppSelect<T>> createState() => _AppSelectState<T>();
|
||||
}
|
||||
|
||||
class _AppSelectState<T> extends State<AppSelect<T>> {
|
||||
bool _open = false;
|
||||
|
||||
Future<void> _show(BuildContext context) async {
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
setState(() => _open = true);
|
||||
final items = [
|
||||
for (final (v, label) in widget.items) AppMenuItem<T>(value: v, label: label, checked: v == widget.value),
|
||||
];
|
||||
final i = await _openMenu(context, items, box.size.width);
|
||||
if (!mounted) return;
|
||||
setState(() => _open = false);
|
||||
if (i != null) widget.onChanged?.call(widget.items[i].$1);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final enabled = widget.onChanged != null && widget.items.isNotEmpty;
|
||||
final label = widget.items.where((e) => e.$1 == widget.value).firstOrNull?.$2 ?? '';
|
||||
final base = Theme.of(context).textTheme.bodyMedium!.merge(widget.style);
|
||||
final style = enabled ? base : base.copyWith(color: base.color?.withValues(alpha: 0.38));
|
||||
final r = BorderRadius.circular(AppMenuStyle.rowRadius);
|
||||
final text = Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: style);
|
||||
return Semantics(
|
||||
button: true,
|
||||
value: label,
|
||||
child: Builder(
|
||||
builder: (context) => Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: enabled ? () => _show(context) : null,
|
||||
borderRadius: r,
|
||||
hoverColor: AppMenuStyle.hover,
|
||||
focusColor: AppMenuStyle.focus,
|
||||
highlightColor: Colors.transparent,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: ease,
|
||||
padding: const EdgeInsets.only(left: 10, right: 6, top: 6, bottom: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: _open ? 0.10 : 0.05),
|
||||
borderRadius: r,
|
||||
border: Border.all(color: _open ? scheme.primary.withValues(alpha: 0.6) : AppMenuStyle.border, width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: widget.expanded ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.expanded) Expanded(child: text) else Flexible(child: text),
|
||||
const SizedBox(width: 4),
|
||||
AnimatedRotation(
|
||||
turns: _open ? 0.5 : 0,
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
child: Icon(Icons.keyboard_arrow_down_rounded,
|
||||
size: 18, color: enabled ? scheme.onSurfaceVariant : style.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "⋮" (or [icon]) opening an action menu of [items]; [onSelected] gets
|
||||
/// the picked value.
|
||||
class AppMenuButton<T> extends StatelessWidget {
|
||||
final List<AppMenuItem<T>> Function() items;
|
||||
final ValueChanged<T> onSelected;
|
||||
final String? tooltip;
|
||||
final Widget icon;
|
||||
const AppMenuButton({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.onSelected,
|
||||
this.tooltip,
|
||||
this.icon = const Icon(Icons.more_vert, size: 18),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
tooltip: tooltip,
|
||||
icon: icon,
|
||||
onPressed: () async {
|
||||
final list = items();
|
||||
if (list.isEmpty) return;
|
||||
final i = await _openMenu(context, list, 0);
|
||||
if (i != null) onSelected(list[i].value);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user