import 'dart:math' as math; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; /// Bounds of the tapped control or row, in the root overlay's coordinates. Rect? mornyeMenuAnchor(BuildContext context) { final box = context.findRenderObject(); if (box is! RenderBox || !box.hasSize) return null; if (box.size.height > MediaQuery.sizeOf(context).height * 0.65) return null; return box.localToGlobal(Offset.zero) & box.size; } Future showMornyeContextMenu({ required BuildContext context, required WidgetBuilder builder, Rect? anchor, bool preferAbove = false, double maxWidth = 320, }) { final navigator = Navigator.of(context, rootNavigator: true); final themes = InheritedTheme.capture(from: context, to: navigator.context); final target = anchor ?? mornyeMenuAnchor(context); return navigator.push( RawDialogRoute( barrierColor: Colors.black.withValues(alpha: 0.12), barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, transitionDuration: MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(milliseconds: 160), // Opacity layers isolate a BackdropFilter from the underlying artwork. transitionBuilder: (_, _, _, child) => child, pageBuilder: (context, animation, _) { final media = MediaQuery.of(context); return Semantics( scopesRoute: true, namesRoute: true, label: MaterialLocalizations.of(context).popupMenuLabel, explicitChildNodes: true, child: CustomSingleChildLayout( delegate: _MenuLayout( anchor: target, preferAbove: preferAbove, maxWidth: maxWidth, padding: media.padding.copyWith( bottom: math.max(media.padding.bottom, media.viewInsets.bottom), ), ), child: ScaleTransition( alignment: preferAbove ? Alignment.bottomRight : Alignment.center, scale: animation.drive(Tween(begin: 0.96, end: 1)), child: themes.wrap(Builder(builder: builder)), ), ), ); }, ), ); } class _MenuLayout extends SingleChildLayoutDelegate { const _MenuLayout({ required this.anchor, required this.padding, required this.preferAbove, required this.maxWidth, }); final Rect? anchor; final EdgeInsets padding; final bool preferAbove; final double maxWidth; static const double _margin = 16; @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { final width = math.min( maxWidth, math.max(0.0, constraints.maxWidth - padding.horizontal - _margin * 2), ); var height = math.max( 0.0, constraints.maxHeight - padding.vertical - _margin * 2, ); final above = (anchor?.top ?? 0) - padding.top - _margin - 8; if (preferAbove && above >= 160) height = math.min(height, above); return BoxConstraints(minWidth: width, maxWidth: width, maxHeight: height); } @override Offset getPositionForChild(Size size, Size childSize) { final left = padding.left + _margin; final top = padding.top + _margin; final right = math.max( left, size.width - padding.right - _margin - childSize.width, ); final bottom = math.max( top, size.height - padding.bottom - _margin - childSize.height, ); final x = anchor == null ? (size.width - childSize.width) / 2 : anchor!.right - childSize.width; var y = size.height * 0.18; if (anchor != null) { final above = anchor!.top - childSize.height - 8; final below = anchor!.bottom + 8; if (preferAbove) { y = above >= top ? above : below <= bottom ? below : top; } else { y = below <= bottom ? below : above >= top ? above : bottom; } } return Offset(x.clamp(left, right), y.clamp(top, bottom)); } @override bool shouldRelayout(_MenuLayout oldDelegate) => anchor != oldDelegate.anchor || padding != oldDelegate.padding || maxWidth != oldDelegate.maxWidth || preferAbove != oldDelegate.preferAbove; } class MornyeMenuAction { const MornyeMenuAction({ required this.icon, required this.label, required this.onPressed, this.subtitle, this.selected = false, this.destructive = false, }); final IconData icon; final String label; final String? subtitle; final VoidCallback? onPressed; final bool selected; final bool destructive; } /// One frosted popover, with compact shortcuts and separated action groups. class MornyeContextMenu extends StatelessWidget { const MornyeContextMenu({ super.key, this.quickActions = const [], required this.groups, this.inheritSurface = false, this.dense = false, this.liquidGlass = false, }); final List quickActions; final List> groups; final bool inheritSurface; final bool dense; final bool liquidGlass; @override Widget build(BuildContext context) { final theme = inheritSurface ? Theme.of(context) : MornyeTheme.build(Theme.of(context).brightness); final visibleGroups = groups.where((group) => group.isNotEmpty).toList(); final shortcuts = quickActions.isEmpty ? null : Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final action in quickActions) Expanded(child: _action(theme, action, compact: true)), ], ), ); final actions = Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < visibleGroups.length; i++) ...[ if (i > 0 || quickActions.isNotEmpty) Padding( padding: EdgeInsets.symmetric(vertical: i == 0 ? 0 : 8), child: const Divider( height: 0.5, thickness: 0.5, indent: 20, endIndent: 20, ), ), for (final action in visibleGroups[i]) _action(theme, action), ], ], ), ); return Theme( data: theme, child: MornyeGlassPanel.overlay( radius: 28, liquidGlass: liquidGlass, tintOpacity: liquidGlass ? 0.24 : 0.78, child: LayoutBuilder( builder: (context, constraints) { // Let the entire menu scroll when pinning the shortcuts would // leave too little room for a row in landscape or large text. if (constraints.maxHeight < 360 || MediaQuery.textScalerOf(context).scale(17) > 28) { return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [?shortcuts, actions], ), ); } return Column( mainAxisSize: MainAxisSize.min, children: [ ?shortcuts, Flexible(child: SingleChildScrollView(child: actions)), ], ); }, ), ), ); } Widget _action( ThemeData theme, MornyeMenuAction action, { bool compact = false, }) { final color = action.onPressed == null ? theme.colorScheme.onSurfaceVariant : action.destructive || action.selected ? theme.colorScheme.primary : theme.colorScheme.onSurface; final icon = Icon(action.icon, size: compact ? 23 : 21, color: color); final label = Text( action.label, textAlign: compact ? TextAlign.center : TextAlign.start, style: theme.textTheme.bodyLarge?.copyWith( color: color, fontSize: compact ? 13 : dense ? 15 : 17, fontWeight: compact ? FontWeight.w600 : FontWeight.w400, ), ); return Semantics( selected: action.selected, child: CupertinoButton( padding: compact ? const EdgeInsets.symmetric(horizontal: 4, vertical: 10) : EdgeInsets.symmetric(horizontal: 20, vertical: dense ? 10 : 13), onPressed: action.onPressed, child: compact ? Column( mainAxisSize: MainAxisSize.min, children: [icon, const SizedBox(height: 7), label], ) : Row( children: [ icon, const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ label, if (action.subtitle != null) ...[ const SizedBox(height: 3), Text( action.subtitle!, style: theme.textTheme.bodySmall, ), ], ], ), ), ], ), ), ); } }