mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
303 lines
9.4 KiB
Dart
303 lines
9.4 KiB
Dart
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<T?> showMornyeContextMenu<T>({
|
|
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<T>(
|
|
RawDialogRoute<T>(
|
|
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<double>(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<MornyeMenuAction> quickActions;
|
|
final List<List<MornyeMenuAction>> 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,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|