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:
foxyzandClaude Opus 5.5 committed 2026-10-11 05:58:18 +03:00
1 parent 2a7b553597
commit 95cd668cfb
17 files changed
+630 -125

No files matched your search

+4 -1
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'src/screens/home/ui.dart';
import 'src/shell/app_shell.dart';
import 'src/widgets/app_keys.dart';
import 'src/widgets/app_menu.dart';
class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key});
@@ -11,7 +12,9 @@ class WatchTogetherApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: appName,
theme: ThemeData.dark(),
// Material's own menus (the player's track menus, sort) wear the
// app menu's surface.
theme: AppMenuStyle.theme(ThemeData.dark()),
shortcuts: appShortcuts,
home: const AppKeys(child: AppShell()),
);
@@ -10,6 +10,7 @@ import '../shaders/shader_presets.dart';
import '../state/session_state.dart';
import '../shell/motion.dart';
import '../state/settings.dart';
import '../widgets/app_menu.dart';
import 'uosc/buttons.dart';
import 'uosc/controls.dart';
@@ -56,27 +57,14 @@ class SettingRow extends StatelessWidget {
}
}
DropdownButton<T> _dropdown<T>({
AppSelect<T> _dropdown<T>({
required Key key,
required T value,
required List<(T, String)> items,
required ValueChanged<T> onChanged,
bool expanded = false,
}) =>
DropdownButton<T>(
key: key,
value: value,
isExpanded: expanded,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(10),
items: [
for (final (v, label) in items)
DropdownMenuItem(value: v, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis)),
],
onChanged: (v) {
if (v != null) onChanged(v);
},
);
AppSelect<T>(key: key, value: value, items: items, onChanged: onChanged, expanded: expanded);
/// Standard (our bars over media_kit's controls) or uosc. Live.
class PlayerDesignSetting extends StatelessWidget {
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../shell/motion.dart';
import '../widgets/app_menu.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime, kindIcon;
@@ -392,17 +393,13 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
children: [
const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))),
if (seasons.length > 1)
DropdownButton<String>(
key: const ValueKey('details-season'),
value: seasonId,
isDense: true,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(10),
items: [
for (final s in seasons)
DropdownMenuItem(value: s.id, child: Text(s.name, overflow: TextOverflow.ellipsis)),
],
onChanged: (id) => setState(() => _seasonId = id),
Flexible(
child: AppSelect<String?>(
key: const ValueKey('details-season'),
value: seasonId,
items: [for (final s in seasons) (s.id, s.name)],
onChanged: (id) => setState(() => _seasonId = id),
),
),
],
);
+7 -6
View File
@@ -3,6 +3,7 @@ import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../state/settings.dart';
import '../widgets/app_menu.dart';
import 'browse_scope.dart';
import 'cards.dart';
import 'home_rows.dart';
@@ -184,18 +185,18 @@ class _LatestRow extends StatelessWidget {
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
child: const Text('Show all'),
),
PopupMenuButton<void>(
AppMenuButton<bool>(
key: ValueKey('latest-menu-${view.id}'),
tooltip: 'Row options',
icon: const Icon(Icons.more_vert, size: 18),
itemBuilder: (context) => [
CheckedPopupMenuItem(
items: () => [
AppMenuItem(
key: const ValueKey('hide-rereleases'),
value: !hide,
checked: hide,
onTap: () => AppSettings.instance.setHideReReleases(!hide),
child: const Text('Hide re-releases in Latest'),
label: 'Hide re-releases in Latest',
),
],
onSelected: AppSettings.instance.setHideReReleases,
),
],
),
@@ -27,7 +27,8 @@ class MediaPickerController extends ChangeNotifier {
final TextEditingController link;
final TextEditingController file;
MediaKind _source;
LinkRelay _linkRelay = LinkRelay.direct;
// Through me unless picked otherwise (Library → Link).
LinkRelay _linkRelay = LinkRelay.me;
JellyfinMedia? _jellyfin;
JellyfinSelection? _selected;
+3 -1
View File
@@ -379,7 +379,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
if (error != null || !mounted) return error;
}
// Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) media = LinkMedia(media.url, anyway: media.anyway);
if (media is LinkMedia && session.solo.peek()) {
media = LinkMedia(media.url, relay: LinkRelay.direct, anyway: media.anyway);
}
final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error;
final f = session.lastForm;
+101 -39
View File
@@ -178,7 +178,17 @@ String agoText(DateTime at, DateTime now) {
// -------------------------------------------------------------- widget
const _modes = [
// Through me is the default and the main choice: it plays whatever this
// computer can open, and nobody can tell ahead which sites will want
// sign-in cookies (YouTube may start asking any day). The other two sit
// behind "Other ways".
const _main = (
LinkRelay.me,
Icons.cell_tower,
'Through me',
'Your computer opens it and passes it on, so it plays for everyone whenever it plays for you.'
);
const _others = [
(LinkRelay.direct, Icons.groups_outlined, 'Everyone fetches it', 'Each app plays the link itself.'),
(
LinkRelay.server,
@@ -186,7 +196,6 @@ const _modes = [
'Server fetches it',
'Downloaded once by the server. For links slow or blocked for some.'
),
(LinkRelay.me, Icons.cell_tower, 'Through me', 'Your computer passes it on. For links only you can open.'),
];
String linkRelayHelp(LinkRelay r) => switch (r) {
@@ -232,6 +241,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
String? _error;
/// [_error] is the probe's problem: "Put it up anyway" is offered.
bool _canForce = false;
/// "Other ways" opened (always shown while one of them is picked).
bool _otherWays = false;
@override
void initState() {
@@ -317,6 +328,16 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
});
}
Widget _modeCard((LinkRelay, IconData, String, String) m, {bool compact = false}) => _ModeCard(
key: ValueKey('link-mode-${m.$1.name}'),
icon: m.$2,
title: m.$3,
sub: m.$4,
compact: compact,
selected: _c.linkRelay == m.$1,
onTap: () => setState(() => _c.linkRelay = m.$1),
);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
@@ -421,33 +442,63 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall),
const SizedBox(height: 10),
LayoutBuilder(builder: (context, box) {
final cards = [
for (final m in _modes)
_ModeCard(
key: ValueKey('link-mode-${m.$1.name}'),
icon: m.$2,
title: m.$3,
sub: m.$4,
selected: _c.linkRelay == m.$1,
onTap: () => setState(() => _c.linkRelay = m.$1),
_modeCard(_main),
const SizedBox(height: 6),
Builder(builder: (context) {
final open = _otherWays || _c.linkRelay != LinkRelay.me;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton.icon(
key: const ValueKey('link-other-ways'),
// Closing while one of them is picked goes back to Through me.
onPressed: () => setState(() {
_otherWays = !open;
if (open) _c.linkRelay = LinkRelay.me;
}),
style: TextButton.styleFrom(foregroundColor: scheme.onSurfaceVariant),
iconAlignment: IconAlignment.end,
icon: AnimatedRotation(
turns: open ? 0.5 : 0,
duration: easeTime,
curve: ease,
child: const Icon(Icons.keyboard_arrow_down_rounded, size: 18),
),
label: const Text('Other ways'),
),
];
if (box.maxWidth < 560) {
return Column(children: [
for (final (i, c) in cards.indexed) ...[if (i > 0) const SizedBox(height: 8), c],
]);
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, c) in cards.indexed) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(child: c),
],
],
),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topCenter,
child: !open
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 4),
child: LayoutBuilder(builder: (context, box) {
final cards = [for (final m in _others) _modeCard(m, compact: true)];
if (box.maxWidth < 560) {
return Column(children: [
for (final (i, c) in cards.indexed) ...[
if (i > 0) const SizedBox(height: 8),
c,
],
]);
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, c) in cards.indexed) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(child: c),
],
],
),
);
}),
),
),
],
);
}),
const SizedBox(height: 8),
@@ -542,6 +593,8 @@ class _ModeCard extends StatelessWidget {
final IconData icon;
final String title, sub;
final bool selected;
/// A secondary choice: icon beside the title, a quieter card.
final bool compact;
final VoidCallback onTap;
const _ModeCard({
super.key,
@@ -550,6 +603,7 @@ class _ModeCard extends StatelessWidget {
required this.sub,
required this.selected,
required this.onTap,
this.compact = false,
});
@override
@@ -565,23 +619,31 @@ class _ModeCard extends StatelessWidget {
duration: easeTime,
curve: ease,
width: double.infinity,
padding: const EdgeInsets.all(12),
padding: EdgeInsets.all(compact ? 10 : 14),
decoration: BoxDecoration(
color: selected ? scheme.primary.withValues(alpha: 0.10) : scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: selected ? scheme.primary : Colors.transparent, width: 2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Row(
crossAxisAlignment: compact ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [
Icon(icon, color: selected ? scheme.primary : scheme.onSurfaceVariant),
const SizedBox(height: 8),
Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
const SizedBox(height: 2),
Text(sub,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey, fontSize: 12)),
Icon(icon, size: compact ? 20 : 24, color: selected ? scheme.primary : scheme.onSurfaceVariant),
SizedBox(width: compact ? 10 : 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(fontWeight: FontWeight.w600, color: selected ? scheme.primary : null)),
const SizedBox(height: 2),
Text(sub,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
),
],
),
),
+7 -12
View File
@@ -11,6 +11,7 @@ import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
import '../state/session_state.dart';
import '../update/update_controller.dart';
import '../widgets/app_keys.dart';
import '../widgets/app_menu.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -578,9 +579,8 @@ class _MemberTile extends StatelessWidget {
],
),
subtitle: Text(status),
trailing: PopupMenuButton<String>(
trailing: AppMenuButton<String>(
tooltip: 'More',
icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) {
switch (v) {
case 'force':
@@ -593,18 +593,13 @@ class _MemberTile extends StatelessWidget {
coord.joinBack();
}
},
itemBuilder: (_) => [
if (!m.out)
PopupMenuItem(
value: 'force',
child: Text(m.ready ? 'Force unready' : 'Force ready'),
),
if (!info.self)
const PopupMenuItem(value: 'nudge', child: Text('Nudge')),
items: () => [
if (!m.out) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
if (info.self && m.out)
const PopupMenuItem(value: 'back', child: Text('Join back'))
const AppMenuItem(value: 'back', label: 'Join back')
else if (!m.out)
const PopupMenuItem(value: 'out', child: Text('Sit out')),
const AppMenuItem(value: 'out', label: 'Sit out'),
],
),
);
+18 -21
View File
@@ -22,6 +22,7 @@ import '../sfx/synth_sfx.dart';
import '../state/session_state.dart';
import '../state/settings.dart';
import '../update/update_controller.dart';
import '../widgets/app_menu.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -622,11 +623,15 @@ class _CookieBrowserDialogState extends State<_CookieBrowserDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
initialValue: _browser,
decoration: const InputDecoration(labelText: 'Browser'),
items: [for (final b in cookieBrowsers) DropdownMenuItem(value: b, child: Text(b))],
onChanged: (v) => setState(() => _browser = v ?? _browser),
Row(
children: [
const Expanded(child: Text('Browser')),
AppSelect<String>(
value: _browser,
items: [for (final b in cookieBrowsers) (b, b)],
onChanged: (v) => setState(() => _browser = v),
),
],
),
const SizedBox(height: 12),
TextField(
@@ -762,15 +767,11 @@ class NotificationsInfo extends StatelessWidget {
),
),
SignalBuilder(
builder: (context) => DropdownButton<ChatPopups>(
builder: (context) => AppSelect<ChatPopups>(
key: const ValueKey('chat-popups'),
value: AppSettings.instance.chatPopups.value,
onChanged: (v) {
if (v != null) AppSettings.instance.setChatPopups(v);
},
items: [
for (final m in ChatPopups.values) DropdownMenuItem(value: m, child: Text(m.label)),
],
onChanged: AppSettings.instance.setChatPopups,
items: [for (final m in ChatPopups.values) (m, m.label)],
),
),
],
@@ -832,17 +833,15 @@ class SoundSettings extends StatelessWidget {
Row(
children: [
const Expanded(child: Text('Sound set')),
DropdownButton<String>(
AppSelect<String>(
key: const ValueKey('sound-preset'),
value: overall,
onChanged: (v) {
if (v == null) return;
s.setSoundPreset(v);
_preview(SfxKind.ready);
},
items: [
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
DropdownMenuItem(value: p.name, child: Text(name)),
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
],
),
],
@@ -867,19 +866,17 @@ class SoundSettings extends StatelessWidget {
children: [Text(name), Text(help, style: muted)],
),
),
DropdownButton<String?>(
AppSelect<String?>(
key: ValueKey('sound-preset-${kind.name}'),
value: per[kind.name],
underline: const SizedBox.shrink(),
style: muted,
onChanged: (v) {
s.setSoundPresetFor(kind.name, v);
if (volume > 0) _preview(kind);
},
items: [
const DropdownMenuItem<String?>(value: null, child: Text('Same as set')),
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries)
DropdownMenuItem<String?>(value: p.name, child: Text(name)),
(null, 'Same as set'),
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
],
),
// A nudge always plays (an explicit poke): no switch,
+433
View File
@@ -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);
},
);
}
}