diff --git a/arb/intl_en.arb b/arb/intl_en.arb index 08c48971..66f8e11a 100644 --- a/arb/intl_en.arb +++ b/arb/intl_en.arb @@ -213,6 +213,7 @@ "layout": "Layout", "tight": "Tight", "standard": "Standard", + "ultra": "Ultra", "loose": "Loose", "profilesSort": "Sort profiles", "start": "Start", diff --git a/arb/intl_ja.arb b/arb/intl_ja.arb index ed42573b..af984c47 100644 --- a/arb/intl_ja.arb +++ b/arb/intl_ja.arb @@ -213,6 +213,7 @@ "layout": "レイアウト", "tight": "コンパクト", "standard": "標準", + "ultra": "最高", "loose": "ゆったり", "profilesSort": "プロファイルの並べ替え", "start": "開始", diff --git a/arb/intl_ru.arb b/arb/intl_ru.arb index 1b7d22ca..3b8f483d 100644 --- a/arb/intl_ru.arb +++ b/arb/intl_ru.arb @@ -213,6 +213,7 @@ "layout": "Макет", "tight": "Плотный", "standard": "Стандартный", + "ultra": "Максимум", "loose": "Свободный", "profilesSort": "Сортировка профилей", "start": "Старт", diff --git a/arb/intl_zh_CN.arb b/arb/intl_zh_CN.arb index 3080b42e..d6c9f169 100644 --- a/arb/intl_zh_CN.arb +++ b/arb/intl_zh_CN.arb @@ -213,6 +213,7 @@ "layout": "布局", "tight": "紧凑", "standard": "标准", + "ultra": "极致", "loose": "宽松", "profilesSort": "配置排序", "start": "启动", diff --git a/lib/common/feature.dart b/lib/common/feature.dart index ddb6c012..d260b429 100644 --- a/lib/common/feature.dart +++ b/lib/common/feature.dart @@ -1,6 +1,7 @@ -/// Features whose code stays but that ship switched off. A disabled feature -/// hides its setting and ignores the stored value, so switching it back on -/// restores what the user had chosen. +/// Build-time switches for features whose code stays either way, each +/// defaulting to how it ships. A disabled feature with a setting hides it and +/// ignores the stored value, so switching it back on restores what the user +/// had chosen. class Feature { const Feature({ this.sidebarBlur = const bool.fromEnvironment('FEATURE_SIDEBAR_BLUR'), diff --git a/lib/features/form/list_editor_page.dart b/lib/features/form/list_editor_page.dart index 0e1da87b..9e6fd605 100644 --- a/lib/features/form/list_editor_page.dart +++ b/lib/features/form/list_editor_page.dart @@ -116,6 +116,8 @@ class _ListEditorPageState extends ConsumerState> { message: TextSpan( text: context.appLocalizations.deleteMultipTip(widget.title), ), + destructive: true, + confirmText: context.appLocalizations.delete, ); if (res != true || !mounted) { return; @@ -347,7 +349,6 @@ class ListEditorAddAction extends StatelessWidget { ); } return CommonPopupBox( - anchorShift: Offset(0, context.isMobileView ? 0 : 20), targetBuilder: (open) => AppBarActionButton( data: IconButtonData( glyph: AppGlyphs.addCircle, diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index db07c2c7..9ae0ba74 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -1645,6 +1645,11 @@ class AppLocalizations { return Intl.message('Standard', name: 'standard', desc: '', args: []); } + /// `Ultra` + String get ultra { + return Intl.message('Ultra', name: 'ultra', desc: '', args: []); + } + /// `Loose` String get loose { return Intl.message('Loose', name: 'loose', desc: '', args: []); diff --git a/lib/manager/theme_manager.dart b/lib/manager/theme_manager.dart index 623bfe78..257da075 100644 --- a/lib/manager/theme_manager.dart +++ b/lib/manager/theme_manager.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:fl_clash/common/common.dart'; +import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/providers/action.dart'; import 'package:fl_clash/providers/app.dart'; import 'package:fl_clash/providers/config.dart'; @@ -115,7 +116,6 @@ class ThemeManager extends ConsumerWidget { final measure = Measure.of(context, textScaleFactor); globalState.measure = measure; - UiTextScale.factor = textScaleFactor; return MeasureScope( measure: measure, child: AppViewScope( @@ -167,11 +167,18 @@ class _LiquidGlassSetting extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch( + themeSettingProvider.select((state) => state.liquidGlass), + ); return LiquidGlassScope( - enabled: ref.watch( - themeSettingProvider.select((state) => state.liquidGlass), + enabled: mode != LiquidGlassMode.none, + quality: mode == LiquidGlassMode.ultra + ? LiquidGlassQuality.ultra + : LiquidGlassQuality.standard, + child: LiquidGlassHost( + enabled: mode != LiquidGlassMode.none, + child: child, ), - child: child, ); } } diff --git a/lib/pages/home.dart b/lib/pages/home.dart index a1141a46..81ff693a 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -29,11 +29,6 @@ class HomePage extends ConsumerWidget { .watch(currentNavigationItemsStateProvider) .value; final isMobile = ref.watch(isMobileViewProvider); - final floating = ref.watch( - appSettingProvider.select( - (state) => state.floatingNavigationBar, - ), - ); return _HomePageView( navigationItems: navigationItems, pageBuilder: (_, index) { @@ -42,7 +37,6 @@ class HomePage extends ConsumerWidget { key: ValueKey(navigationItem.label), item: navigationItem, isMobile: isMobile, - primaryActionInBar: !isMobile || floating, view: navigationItem.builder(context), ); }, @@ -185,20 +179,16 @@ class _NavigationPage extends StatelessWidget { super.key, required this.item, required this.isMobile, - required this.primaryActionInBar, required this.view, }); final NavigationItem item; final bool isMobile; - final bool primaryActionInBar; final Widget view; @override Widget build(BuildContext context) { - final scopedView = PageFocusScope( - child: PrimaryActionInBarScope(inBar: primaryActionInBar, child: view), - ); + final scopedView = PageFocusScope(child: view); final keptView = KeepScope( key: ValueKey(item.label), keep: item.keep, @@ -413,7 +403,8 @@ class _FadeTabPage extends StatelessWidget { return AnimatedBuilder( animation: controller, builder: (_, child) { - final delta = enabled ? _delta : 0.0; + final offset = _delta; + final delta = enabled ? offset : 0.0; return FractionalTranslation( translation: Offset(-delta, 0), child: Opacity(opacity: 1 - delta.abs(), child: child), diff --git a/lib/ui/context_menu.dart b/lib/ui/context_menu.dart index 6b83faab..f45fa307 100644 --- a/lib/ui/context_menu.dart +++ b/lib/ui/context_menu.dart @@ -7,6 +7,7 @@ import 'package:material_ui/material_ui.dart'; import 'focus.dart'; import 'popup.dart'; import 'sortable.dart'; +import 'surface.dart'; const _touchDevices = { PointerDeviceKind.touch, @@ -30,18 +31,67 @@ class ContextMenuRegion extends StatefulWidget { class _ContextMenuRegionState extends State { Timer? _holdTimer; LogicalKeyboardKey? _heldKey; + CommonPopupRoute? _menu; + + bool get _enabled { + final items = widget.menuItems; + return items != null && + items.isNotEmpty && + !SortableItem.isSorting(context); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _closeUnlessEnabled(); + } + + @override + void didUpdateWidget(ContextMenuRegion oldWidget) { + super.didUpdateWidget(oldWidget); + _closeUnlessEnabled(); + } @override void dispose() { _holdTimer?.cancel(); + _closeMenu(); super.dispose(); } + /// A row withdraws its menu while its item updates, which the open menu + /// would otherwise still act on. + void _closeUnlessEnabled() { + if (_enabled) { + return; + } + _holdTimer?.cancel(); + _heldKey = null; + _closeMenu(); + } + + void _closeMenu() { + final menu = _menu; + if (menu == null) { + return; + } + _menu = null; + // This runs while the tree builds or unmounts, when nothing may pop. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (menu.isCurrent) { + menu.navigator!.pop(); + } + }); + } + void _open(Offset globalPosition, {bool avoid = false}) { + final items = widget.menuItems; final navigator = Navigator.maybeOf(context); final navigatorBox = navigator?.context.findRenderObject(); final box = context.findRenderObject(); - if (navigator == null || + if (items == null || + items.isEmpty || + navigator == null || navigatorBox is! RenderBox || box is! RenderBox || !box.hasSize) { @@ -52,18 +102,20 @@ class _ContextMenuRegionState extends State { box.getTransformTo(navigatorBox), Offset.zero & box.size, ); + final menu = _menu = CommonPopupRoute( + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + placement: PopupPlacement.belowPoint, + elevated: ElevatedSurfaceTheme.isIn(context), + anchorOf: () => point & Size.zero, + avoid: avoid ? rowRect : null, + builder: (_) => CommonPopupMenu(items: items), + ); unawaited( - navigator.push( - CommonPopupRoute( - barrierLabel: MaterialLocalizations.of( - context, - ).modalBarrierDismissLabel, - placement: PopupPlacement.belowPoint, - anchorOf: () => point & Size.zero, - avoid: avoid ? rowRect : null, - builder: (_) => CommonPopupMenu(items: widget.menuItems!), - ), - ), + navigator.push(menu).whenComplete(() { + if (identical(_menu, menu)) { + _menu = null; + } + }), ); } @@ -138,9 +190,7 @@ class _ContextMenuRegionState extends State { @override Widget build(BuildContext context) { - final items = widget.menuItems; - final enabled = - items != null && items.isNotEmpty && !SortableItem.isSorting(context); + final enabled = _enabled; return Focus( canRequestFocus: false, skipTraversal: true, diff --git a/lib/ui/dialog.dart b/lib/ui/dialog.dart index 383ea527..e663cf0b 100644 --- a/lib/ui/dialog.dart +++ b/lib/ui/dialog.dart @@ -2,11 +2,14 @@ import 'dart:math' as math; import 'package:fl_clash/ui/color.dart'; import 'package:fl_clash/ui/context.dart'; +import 'package:fl_clash/ui/input.dart'; +import 'package:fl_clash/ui/liquid_glass.dart'; import 'package:fl_clash/ui/motion.dart'; import 'package:fl_clash/ui/navigation_dock.dart'; import 'package:fl_clash/ui/shape.dart'; import 'package:fl_clash/ui/surface.dart'; import 'package:fl_clash/ui/typography.dart'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/rendering.dart'; import 'package:material_ui/material_ui.dart'; @@ -18,6 +21,10 @@ const double _alertWidth = 348; const double _contentMaxWidth = 300; // Material 3 sets a filled field's text 4 in past its content padding. const double _fieldInputGap = 4; +const double _pressGrowth = 4; +const double _pressPull = 3; +const double _pressedFillShare = 0.4; +const double _pressedDarkDefaultShare = 0.5; // UIAlertController on iOS 27 grows in from 1.2 times its size and only fades // out, both on one critically damped spring that settles in 0.404 s. @@ -37,7 +44,7 @@ Future showFloatingDialog({ }) { return Navigator.of(context).push( _AlertRoute( - barrierColor: context.colorScheme.modalScrim, + barrierColor: context.colorScheme.alertScrim, barrierDismissible: dismissible, barrierLabel: dismissible ? MaterialLocalizations.of(context).modalBarrierDismissLabel @@ -187,7 +194,7 @@ class _AlertTransitionState extends State<_AlertTransition> { @override Widget build(BuildContext context) { - return FadeTransition( + return LiquidGlassFade( opacity: widget.animation, child: AnimatedBuilder( animation: widget.animation, @@ -198,6 +205,118 @@ class _AlertTransitionState extends State<_AlertTransition> { } } +RenderPhysicalShape? _surfaceIn(RenderObject node) { + if (node is RenderPhysicalShape) { + return node; + } + RenderPhysicalShape? surface; + node.visitChildren((child) => surface ??= _surfaceIn(child)); + return surface; +} + +Rect _surfaceRectIn(RenderBox box) { + final surface = _surfaceIn(box); + if (surface == null) { + return Offset.zero & box.size; + } + return MatrixUtils.transformRect( + surface.getTransformTo(box), + Offset.zero & surface.size, + ); +} + +/// An [AlertDialog] takes only a color, so the pane goes where its [Material] +/// lays out its [PhysicalShape]. +class _AlertGlass extends SingleChildRenderObjectWidget { + const _AlertGlass({ + required this.style, + required this.rim, + required this.shadows, + super.child, + }); + + final LiquidGlassStyle style; + final LiquidGlassRim rim; + final List shadows; + + @override + _RenderAlertGlass createRenderObject(BuildContext context) { + return _RenderAlertGlass(style, rim, shadows); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderAlertGlass renderObject, + ) { + renderObject + ..style = style + ..rim = rim + ..shadows = shadows; + } +} + +class _RenderAlertGlass extends RenderProxyBox { + _RenderAlertGlass(this._style, this._rim, this._shadows); + + late final _pane = LiquidGlassPane(markNeedsPaint); + + LiquidGlassStyle _style; + set style(LiquidGlassStyle value) { + if (value == _style) { + return; + } + _style = value; + markNeedsPaint(); + } + + LiquidGlassRim _rim; + set rim(LiquidGlassRim value) { + if (value == _rim) { + return; + } + _rim = value; + markNeedsPaint(); + } + + List _shadows; + set shadows(List value) { + if (listEquals(value, _shadows)) { + return; + } + _shadows = value; + markNeedsPaint(); + } + + @override + bool get alwaysNeedsCompositing => true; + + @override + void dispose() { + _pane.dispose(); + super.dispose(); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (_surfaceIn(this) == null) { + _pane.clear(); + } else { + final rect = _surfaceRectIn(this).shift(offset); + _pane.paint( + context, + rect, + shape: LiquidGlassCorners.circular(AppCorner.alert), + style: _style, + shadows: _shadows, + rim: _rim, + ); + _rim.paintLine(context.canvas, rect, AppCorner.alert); + } + super.paint(context, offset); + } +} + class CommonDialog extends StatelessWidget { final String title; final Widget? trailing; @@ -212,6 +331,9 @@ class CommonDialog extends StatelessWidget { /// Sets the fields in [child] as an iOS alert does: as wide as its buttons. final bool hasFields; + /// The last action destroys data: red on the plain fill, as in UIKit. + final bool destructive; + const CommonDialog({ super.key, required this.title, @@ -220,35 +342,53 @@ class CommonDialog extends StatelessWidget { this.child, this.padding, this.hasFields = false, + this.destructive = false, }); @override Widget build(BuildContext context) { final actions = this.actions ?? const []; final contentInset = hasFields ? _actionInset : _textInset; - return AlertDialog( - titleTextStyle: AppTextStyle.headline.of(context), + final glass = LiquidGlassScope.of(context); + final colorScheme = context.colorScheme; + final fill = glass ? glassFillOf(colorScheme) : null; + final dialog = AlertDialog( + backgroundColor: glass ? Colors.transparent : null, + titleTextStyle: hasFields + ? AppTextStyle.body.of(context) + : AppTextStyle.headline.of(context), contentTextStyle: AppTextStyle.subheadline .of(context) - .copyWith(color: context.colorScheme.onSurfaceVariant), - title: trailing == null - ? Text(title) - : Row( - children: [ - Expanded(child: Text(title)), - trailing!, - ], + .copyWith( + color: colorScheme.onSurfaceVariant.withValues( + alpha: colorScheme.brightness == Brightness.dark ? 1 : 0.85, ), - actions: actions.isEmpty ? null : [AlertActions(actions: actions)], + ), + title: _buildTitle(), + actions: actions.isEmpty + ? null + : [ + AlertActions( + actions: actions, + fill: fill, + destructive: destructive, + ), + ], actionsPadding: const EdgeInsets.fromLTRB( _actionInset, 0, _actionInset, _actionInset, ), - titlePadding: const EdgeInsets.fromLTRB(_textInset, 24, _textInset, 0), + titlePadding: const EdgeInsets.fromLTRB(_textInset, 21, _textInset, 0), contentPadding: - padding ?? EdgeInsets.fromLTRB(contentInset, 16, contentInset, 24), + padding ?? + EdgeInsets.fromLTRB( + contentInset, + hasFields ? 16 : 6, + contentInset, + 20, + ), content: Container( constraints: BoxConstraints( maxWidth: padding == null @@ -257,93 +397,247 @@ class CommonDialog extends StatelessWidget { ), width: double.infinity, child: SingleChildScrollView( - child: hasFields ? _AlertFieldTheme(child: child) : child, + child: hasFields + ? FilledInputTheme( + fillColor: fill, + borderRadius: AppRadius.all(_actionHeight / 2), + contentPadding: const EdgeInsets.symmetric( + horizontal: _textInset - _actionInset - _fieldInputGap, + vertical: 12, + ), + child: child ?? const SizedBox.shrink(), + ) + : child, ), ), ); + return ElasticPress( + pullPastEdges: true, + maxGrowth: _pressGrowth, + maxPull: _pressPull, + panelOf: _surfaceRectIn, + child: glass + ? _AlertGlass( + style: LiquidGlassStyle.alert(colorScheme), + rim: LiquidGlassRim.alert(colorScheme), + shadows: glassMenuShadows(colorScheme), + child: dialog, + ) + : dialog, + ); + } + + Widget _buildTitle() { + final trailing = this.trailing; + if (!hasFields) { + return trailing == null + ? Text(title) + : Row( + children: [ + Expanded(child: Text(title)), + trailing, + ], + ); + } + final text = Text(title, textAlign: TextAlign.center); + if (trailing == null) { + return SizedBox(width: double.infinity, child: text); + } + return _CenteredTitle(title: text, trailing: trailing); } } -class _AlertFieldTheme extends StatelessWidget { - const _AlertFieldTheme({required this.child}); - - final Widget? child; +/// A title centred on its whole width, beside a control at its end. +class _CenteredTitle extends MultiChildRenderObjectWidget { + _CenteredTitle({required Widget title, required Widget trailing}) + : super(children: [title, trailing]); @override - Widget build(BuildContext context) { - final colorScheme = context.colorScheme; - final shape = AppShape.input.copyWith( - borderRadius: AppRadius.all(_actionHeight / 2), + _RenderCenteredTitle createRenderObject(BuildContext context) { + return _RenderCenteredTitle(textDirection: Directionality.of(context)); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderCenteredTitle renderObject, + ) { + renderObject.textDirection = Directionality.of(context); + } +} + +class _RenderCenteredTitle extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderCenteredTitle({required TextDirection textDirection}) + : _textDirection = textDirection; + + TextDirection _textDirection; + + set textDirection(TextDirection value) { + if (value == _textDirection) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + RenderBox get _title => firstChild!; + + RenderBox get _trailing => lastChild!; + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! MultiChildLayoutParentData) { + child.parentData = MultiChildLayoutParentData(); + } + } + + @override + double computeMinIntrinsicWidth(double height) => + _title.getMinIntrinsicWidth(height) + + _trailing.getMinIntrinsicWidth(height) * 2; + + @override + double computeMaxIntrinsicWidth(double height) => + _title.getMaxIntrinsicWidth(height) + + _trailing.getMaxIntrinsicWidth(height) * 2; + + @override + double computeMinIntrinsicHeight(double width) => math.max( + _title.getMinIntrinsicHeight(width), + _trailing.getMinIntrinsicHeight(width), + ); + + @override + double computeMaxIntrinsicHeight(double width) => math.max( + _title.getMaxIntrinsicHeight(width), + _trailing.getMaxIntrinsicHeight(width), + ); + + double _widthOf(BoxConstraints constraints, double side) => + constraints.hasBoundedWidth + ? constraints.maxWidth + : _title.getMaxIntrinsicWidth(double.infinity) + side * 2; + + BoxConstraints _titleConstraints(double width, double side) => + BoxConstraints.tightFor(width: math.max(0.0, width - side * 2)); + + @override + Size computeDryLayout(covariant BoxConstraints constraints) { + final trailing = _trailing.getDryLayout(constraints.loosen()); + final width = _widthOf(constraints, trailing.width); + final title = _title.getDryLayout(_titleConstraints(width, trailing.width)); + return constraints.constrain( + Size(width, math.max(title.height, trailing.height)), ); - final none = shape.copyWith(borderSide: BorderSide.none); - return InputDecorationTheme( - data: InputDecorationTheme.of(context).copyWith( - filled: true, - fillColor: context.raisedSurfaceLevel.colorOf(colorScheme), - hoverColor: Colors.transparent, - floatingLabelBehavior: FloatingLabelBehavior.never, - contentPadding: const EdgeInsets.symmetric( - horizontal: _textInset - _actionInset - _fieldInputGap, - vertical: 12, - ), - border: none, - enabledBorder: none, - disabledBorder: none, - errorBorder: none, - focusedBorder: shape.copyWith( - borderSide: BorderSide(color: colorScheme.primary, width: 2), - ), - focusedErrorBorder: shape.copyWith( - borderSide: BorderSide(color: colorScheme.error, width: 2), - ), - ), - child: child ?? const SizedBox.shrink(), + } + + @override + void performLayout() { + _trailing.layout(constraints.loosen(), parentUsesSize: true); + final side = _trailing.size.width; + final width = _widthOf(constraints, side); + _title.layout(_titleConstraints(width, side), parentUsesSize: true); + size = constraints.constrain( + Size(width, math.max(_title.size.height, _trailing.size.height)), ); + final rtl = _textDirection == TextDirection.rtl; + (_title.parentData! as MultiChildLayoutParentData).offset = Offset( + side, + (size.height - _title.size.height) / 2, + ); + (_trailing.parentData! as MultiChildLayoutParentData).offset = Offset( + rtl ? 0 : size.width - side, + (size.height - _trailing.size.height) / 2, + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); } } /// Text buttons laid out and filled as [CommonDialog] sets its own. class AlertActions extends StatelessWidget { - const AlertActions({super.key, required this.actions}); + const AlertActions({ + super.key, + required this.actions, + this.fill, + this.destructive = false, + }); final List actions; + /// The plain buttons' fill, in place of the raised surface's. + final Color? fill; + + final bool destructive; + @override Widget build(BuildContext context) { return _AlertActions( children: [ for (final (index, action) in actions.indexed) - _AlertAction(isDefault: index == actions.length - 1, child: action), + _AlertAction( + isDefault: !destructive && index == actions.length - 1, + destructive: destructive && index == actions.length - 1, + fill: fill, + child: action, + ), ], ); } } class _AlertAction extends StatelessWidget { - const _AlertAction({required this.isDefault, required this.child}); + const _AlertAction({ + required this.isDefault, + required this.destructive, + required this.fill, + required this.child, + }); final bool isDefault; + final bool destructive; + final Color? fill; final Widget child; @override Widget build(BuildContext context) { final colorScheme = context.colorScheme; - final fill = context.raisedSurfaceLevel.colorOf(colorScheme); + final fill = this.fill ?? context.raisedSurfaceLevel.colorOf(colorScheme); final foreground = isDefault ? colorScheme.onPrimary + : destructive + ? colorScheme.destructive : colorScheme.onSurface; - final child = this.child; + final background = isDefault ? colorScheme.primary : fill; + final pressedShare = switch ((isDefault, colorScheme.brightness)) { + (true, Brightness.dark) => _pressedDarkDefaultShare, + (false, Brightness.dark) => 1.0, + _ => _pressedFillShare, + }; return TextButtonTheme( data: TextButtonThemeData( style: TextButton.styleFrom( - backgroundColor: isDefault ? colorScheme.primary : fill, foregroundColor: foreground, - disabledBackgroundColor: fill, disabledForegroundColor: colorScheme.onSurface.withValues( alpha: 0.38, ), - textStyle: AppTextStyle.body.of(context, emphasized: isDefault), + textStyle: isDefault + ? AppTextStyle.body.of(context, emphasized: true) + : AppTextStyle.body + .of(context) + .copyWith(fontWeight: FontWeight.w500), minimumSize: const Size(0, _actionHeight), padding: const EdgeInsets.symmetric(horizontal: 16), shape: AppShape.full, @@ -351,6 +645,13 @@ class _AlertAction extends StatelessWidget { tapTargetSize: MaterialTapTargetSize.shrinkWrap, splashFactory: NoSplash.splashFactory, ).copyWith( + backgroundColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: fill, + WidgetState.pressed: background.withValues( + alpha: background.a * pressedShare, + ), + WidgetState.any: background, + }), overlayColor: WidgetStateProperty.fromMap({ WidgetState.focused: foreground.withValues(alpha: 0.1), WidgetState.pressed: Colors.transparent, @@ -359,13 +660,7 @@ class _AlertAction extends StatelessWidget { side: focusRingStyle(colorScheme, standsOff: isDefault).side, ), ), - child: ElasticPress( - enabled: switch (child) { - ButtonStyleButton(:final enabled) => enabled, - _ => true, - }, - child: child, - ), + child: child, ); } } diff --git a/lib/ui/input.dart b/lib/ui/input.dart index e940b615..694c8fc3 100644 --- a/lib/ui/input.dart +++ b/lib/ui/input.dart @@ -314,11 +314,15 @@ class FilledInputTheme extends StatelessWidget { super.key, this.borderRadius = AppRadius.full, this.contentPadding, + this.fillColor, required this.child, }); final BorderRadius borderRadius; final EdgeInsetsGeometry? contentPadding; + + /// In place of the raised surface's. + final Color? fillColor; final Widget child; @override @@ -329,7 +333,7 @@ class FilledInputTheme extends StatelessWidget { return InputDecorationTheme( data: InputDecorationTheme.of(context).copyWith( filled: true, - fillColor: context.raisedSurfaceLevel.colorOf(colorScheme), + fillColor: fillColor ?? context.raisedSurfaceLevel.colorOf(colorScheme), hoverColor: Colors.transparent, floatingLabelBehavior: FloatingLabelBehavior.never, contentPadding: contentPadding, diff --git a/lib/ui/liquid_glass.dart b/lib/ui/liquid_glass.dart new file mode 100644 index 00000000..6d8d1587 --- /dev/null +++ b/lib/ui/liquid_glass.dart @@ -0,0 +1,1500 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:fl_clash/ui/context.dart'; +import 'package:fl_clash/ui/shape.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:material_ui/material_ui.dart'; + +part 'liquid_glass_capture.dart'; + +const _shaderAsset = 'shaders/liquid_glass.frag'; +const _glowIn = Duration(milliseconds: 120); +const _glowOut = Duration(milliseconds: 360); +const double _glowSpread = 3; +const _profileTurns = 64; +// kLine in shaders/liquid_glass.frag. +const double _hairlineWidth = 0.67; +// In corner radii from the corner of the bounds. +const _shineAround = [ + (along: 1.53, down: 0.0, lit: 0.68), + (along: 1.0, down: 0.0, lit: 0.48), + (along: 0.3, down: 0.3, lit: 0.38), + (along: 0.0, down: 1.0, lit: 0.17), + (along: 0.0, down: 1.5, lit: 0.07), + (along: 0.0, down: 2.0, lit: 0.03), +]; +const _shineBands = [ + (depth: 0.0, width: 1 / 3, lit: 1.0), + (depth: 1 / 3, width: 1 / 3, lit: 0.7), + (depth: 2 / 3, width: 1 / 3, lit: 0.47), + (depth: 1.0, width: 2 / 3, lit: 0.18), + (depth: 5 / 3, width: 2 / 3, lit: 0.12), + (depth: 7 / 3, width: 2 / 3, lit: 0.085), + (depth: 3.0, width: 2 / 3, lit: 0.065), + (depth: 11 / 3, width: 2 / 3, lit: 0.045), + (depth: 13 / 3, width: 2 / 3, lit: 0.035), +]; +// The size of uShineEnds in shaders/liquid_glass.frag. +const _shineSlots = 12; +final _noShine = List.unmodifiable( + List.filled(2 * (_shineAround.length + 2) + 2 * _shineSlots, 0), +); +final _flatProfile = List.unmodifiable( + List.filled(_profileTurns, 0), +); + +/// How much of iOS's glass the panes draw (.agents/architecture.md). +enum LiquidGlassQuality { standard, ultra } + +/// Whether the navigation dock and the buttons of a page's bar are glass. +class LiquidGlassScope extends InheritedWidget { + const LiquidGlassScope({ + super.key, + required this.enabled, + this.quality = LiquidGlassQuality.ultra, + required super.child, + }); + + final bool enabled; + final LiquidGlassQuality quality; + + static bool of(BuildContext context) => _of(context)?.enabled ?? false; + + static LiquidGlassQuality qualityOf(BuildContext context) => + _of(context)?.quality ?? LiquidGlassQuality.ultra; + + static LiquidGlassScope? _of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(LiquidGlassScope oldWidget) => + enabled != oldWidget.enabled || quality != oldWidget.quality; +} + +/// Lengths are dp. The shader shrinks [bevel] and [refraction] to fit a pane +/// too small for them, so that a button bends in proportion to the dock. +@immutable +class LiquidGlassStyle { + const LiquidGlassStyle({ + required this.tint, + this.contrast = 0, + this.blur = 2.5, + this.haze = 0, + this.hazeBlur = 0, + this.refraction = 60, + this.bevel = 20, + this.saturation = 1.5, + this.brightness = 1, + this.rim = 0.6, + this.rimWidth = 1.3, + this.rimTail = 0, + this.rimFocus = 0, + this.rimAdded = 0, + this.endShade = 0, + this.dispersion = 0, + this.rimBlur = 0, + this.hairline = 0, + this.hairlineSide, + }); + + factory LiquidGlassStyle.regular(ColorScheme colorScheme) { + final dark = colorScheme.brightness == Brightness.dark; + return dark + ? LiquidGlassStyle( + tint: colorScheme.surfaceContainerHighest.withValues(alpha: 0.62), + contrast: -0.4, + saturation: 2.84, + rim: 0.28, + rimWidth: 1.2, + rimTail: 0.17, + rimFocus: 1, + rimAdded: 0.38, + hairlineSide: 0.75, + ) + : const LiquidGlassStyle( + tint: Color(0x8AFDFDFD), + contrast: 0.4, + saturation: 2.08, + rim: 0.28, + rimWidth: 1.2, + rimTail: 0.17, + rimFocus: 1, + rimAdded: 0.38, + hairline: 0.25, + hairlineSide: 0.75, + ); + } + + factory LiquidGlassStyle.pill(ColorScheme colorScheme) { + final dark = colorScheme.brightness == Brightness.dark; + return dark + ? LiquidGlassStyle( + tint: colorScheme.surfaceContainerHighest.withValues(alpha: 0.48), + contrast: -0.4, + blur: 0, + haze: 0.72, + hazeBlur: 15.6, + saturation: 1.925, + rim: 0.35, + ) + : const LiquidGlassStyle( + tint: Color(0x93F4F4F4), + contrast: 0.4, + blur: 0, + haze: 0.72, + hazeBlur: 15.6, + saturation: 2.1, + hairline: 0.35, + ); + } + + /// Thicker than [LiquidGlassStyle.regular], for rows that read over anything. + /// It takes its rim from a [LiquidGlassRim]. + factory LiquidGlassStyle.menu(ColorScheme colorScheme) { + final dark = colorScheme.brightness == Brightness.dark; + return dark + ? LiquidGlassStyle( + tint: colorScheme.surfaceContainerHighest.withValues(alpha: 0.63), + contrast: -0.53, + blur: 12, + saturation: 1.75, + brightness: 1.8, + rim: 0, + ) + : LiquidGlassStyle( + tint: colorScheme.surfaceBright.withValues(alpha: 0.55), + blur: 12, + saturation: 2, + brightness: 1.1, + rim: 0, + ); + } + + factory LiquidGlassStyle.alert(ColorScheme colorScheme) => + LiquidGlassStyle.menu(colorScheme).copyWith(blur: 16); + + factory LiquidGlassStyle.tinted(ColorScheme colorScheme, Color color) { + final dark = colorScheme.brightness == Brightness.dark; + return LiquidGlassStyle( + tint: color.withValues(alpha: 0.82), + saturation: 1.2, + rim: dark ? 0.14 : 0.2, + rimWidth: 1.2, + rimTail: 0.17, + rimFocus: 1, + rimAdded: 0.38, + hairline: dark ? 0 : 0.34, + hairlineSide: dark ? 0.73 : 0.5, + ); + } + + factory LiquidGlassStyle.lens(ColorScheme colorScheme) { + final dark = colorScheme.brightness == Brightness.dark; + return LiquidGlassStyle( + tint: Colors.transparent, + blur: 0, + refraction: -4.3, + bevel: 5.5, + saturation: 1, + brightness: dark ? 1.33 : 1, + rim: dark ? 0.3 : 0.6, + rimWidth: 1.1, + endShade: 0.045, + dispersion: 6.5, + rimBlur: 1.5, + hairline: 0.42, + ); + } + + factory LiquidGlassStyle.lensContent(ColorScheme colorScheme) { + final dark = colorScheme.brightness == Brightness.dark; + return LiquidGlassStyle( + tint: Colors.transparent, + blur: 0, + refraction: 14, + bevel: 11.2, + saturation: 1, + brightness: dark ? 1.33 : 1, + rim: 0, + dispersion: 3.6, + rimBlur: 2.5, + ); + } + + final Color tint; + + /// How far the tint thickens toward opaque over content much darker, or if + /// negative much lighter, than it, so what sits on the glass stays legible. + final double contrast; + final double blur; + + /// The share of the backdrop seen through [hazeBlur] rather than [blur]. + final double haze; + final double hazeBlur; + + /// Negative for a raised lens, which reads outward all around its rim. + final double refraction; + final double bevel; + final double saturation; + final double brightness; + + final double rim; + final double rimWidth; + + final double rimTail; + + /// 0 dims [rim] round the ends as on iOS's tab bar, 1 as on its controls. + final double rimFocus; + + /// The share of [rim] added as light rather than screened toward white. + final double rimAdded; + + final double endShade; + final double dispersion; + + final double rimBlur; + final double hairline; + final double? hairlineSide; + + /// Eases in from bare air, as glass materializes by bending, not fading. + LiquidGlassStyle scale(double t) { + final amount = t.clamp(0.0, 1.0); + return LiquidGlassStyle( + tint: tint.withValues(alpha: tint.a * amount), + contrast: contrast * amount, + blur: blur * amount, + haze: haze * amount, + hazeBlur: hazeBlur, + refraction: refraction * amount, + bevel: bevel, + saturation: 1 + (saturation - 1) * amount, + brightness: 1 + (brightness - 1) * amount, + rim: rim * amount, + rimWidth: rimWidth, + rimTail: rimTail, + rimFocus: rimFocus, + rimAdded: rimAdded, + endShade: endShade * amount, + dispersion: dispersion * amount, + rimBlur: rimBlur * amount, + hairline: hairline * amount, + hairlineSide: hairlineSide == null ? null : hairlineSide! * amount, + ); + } + + LiquidGlassStyle copyWith({double? blur}) => LiquidGlassStyle( + tint: tint, + contrast: contrast, + blur: blur ?? this.blur, + haze: haze, + hazeBlur: hazeBlur, + refraction: refraction, + bevel: bevel, + saturation: saturation, + brightness: brightness, + rim: rim, + rimWidth: rimWidth, + rimTail: rimTail, + rimFocus: rimFocus, + rimAdded: rimAdded, + endShade: endShade, + dispersion: dispersion, + rimBlur: rimBlur, + hairline: hairline, + hairlineSide: hairlineSide, + ); + + static LiquidGlassStyle lerp( + LiquidGlassStyle a, + LiquidGlassStyle b, + double t, + ) { + double between(double a, double b) => ui.lerpDouble(a, b, t)!; + return LiquidGlassStyle( + tint: Color.lerp(a.tint, b.tint, t)!, + contrast: between(a.contrast, b.contrast), + blur: between(a.blur, b.blur), + haze: between(a.haze, b.haze), + hazeBlur: between(a.hazeBlur, b.hazeBlur), + refraction: between(a.refraction, b.refraction), + bevel: between(a.bevel, b.bevel), + saturation: between(a.saturation, b.saturation), + brightness: between(a.brightness, b.brightness), + rim: between(a.rim, b.rim), + rimWidth: between(a.rimWidth, b.rimWidth), + rimTail: between(a.rimTail, b.rimTail), + rimFocus: between(a.rimFocus, b.rimFocus), + rimAdded: between(a.rimAdded, b.rimAdded), + endShade: between(a.endShade, b.endShade), + dispersion: between(a.dispersion, b.dispersion), + rimBlur: between(a.rimBlur, b.rimBlur), + hairline: between(a.hairline, b.hairline), + hairlineSide: between(a._hairlineSide, b._hairlineSide), + ); + } + + @override + bool operator ==(Object other) => + other is LiquidGlassStyle && + other.tint == tint && + other.contrast == contrast && + other.blur == blur && + other.haze == haze && + other.hazeBlur == hazeBlur && + other.refraction == refraction && + other.bevel == bevel && + other.saturation == saturation && + other.brightness == brightness && + other.rim == rim && + other.rimWidth == rimWidth && + other.rimTail == rimTail && + other.rimFocus == rimFocus && + other.rimAdded == rimAdded && + other.endShade == endShade && + other.dispersion == dispersion && + other.rimBlur == rimBlur && + other.hairline == hairline && + other.hairlineSide == hairlineSide; + + @override + int get hashCode => Object.hash( + tint, + contrast, + blur, + haze, + hazeBlur, + refraction, + bevel, + saturation, + brightness, + rim, + rimWidth, + rimTail, + rimFocus, + rimAdded, + endShade, + dispersion, + rimBlur, + hairline, + hairlineSide, + ); + + double get _hairlineSide => hairlineSide ?? hairline; + + /// The standard tier's: no colors parted, and a haze for the whole frost. + LiquidGlassStyle get _standard { + if (haze == 0 && dispersion == 0) { + return this; + } + return LiquidGlassStyle( + tint: tint, + contrast: contrast, + blur: haze > 0 ? hazeBlur : blur, + refraction: refraction, + bevel: bevel, + saturation: saturation, + brightness: brightness, + rim: rim, + rimWidth: rimWidth, + rimTail: rimTail, + rimFocus: rimFocus, + rimAdded: rimAdded, + endShade: endShade, + rimBlur: rimBlur, + hairline: hairline, + hairlineSide: hairlineSide, + ); + } +} + +/// An iOS 27 menu's or alert's, measured over white. +List glassMenuShadows(ColorScheme colorScheme, [double depth = 1]) { + final strength = colorScheme.brightness == Brightness.dark ? 1.5 : 1.0; + final alpha = strength * depth; + return [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.09 * alpha), + blurRadius: 16.5, + offset: const Offset(0, 4), + ), + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.045 * alpha), + blurRadius: 42, + offset: const Offset(0, 8), + ), + ]; +} + +Color glassFillOf(ColorScheme colorScheme) => + (colorScheme.brightness == Brightness.dark ? Colors.white : Colors.black) + .withValues(alpha: 0.13); + +/// iOS 27's rim around a menu's or an alert's glass (.agents/architecture.md). +@immutable +class LiquidGlassRim { + const LiquidGlassRim({ + required this.side, + required this.corner, + required this.flat, + required this.shine, + }); + + factory LiquidGlassRim.alert(ColorScheme colorScheme) => + colorScheme.brightness == Brightness.dark + ? const LiquidGlassRim(side: 0.75, corner: 0.1, flat: 0.03, shine: 0.22) + : const LiquidGlassRim(side: 0.35, corner: 0.22, flat: 0.12, shine: 0.16); + + factory LiquidGlassRim.menu(ColorScheme colorScheme) => + colorScheme.brightness == Brightness.dark + ? const LiquidGlassRim(side: 0.27, corner: 0.1, flat: 0.02, shine: 0.22) + : const LiquidGlassRim(side: 0.25, corner: 0.15, flat: 0.09, shine: 0.16); + + final double side; + final double corner; + final double flat; + + /// Added light, not white laid over: iOS lifts dark and light panes alike. + final double shine; + + LiquidGlassRim scale(double t) { + final amount = t.clamp(0.0, 1.0); + return LiquidGlassRim( + side: side * amount, + corner: corner * amount, + flat: flat * amount, + shine: shine * amount, + ); + } + + /// The line, outside the pane; the pane given this rim draws the highlight. + void paintLine(Canvas canvas, Rect rect, double radius) { + if (rect.isEmpty) { + return; + } + final round = _roundOf(rect.size, radius); + final shape = AppRadius.all(round).toRSuperellipse(rect); + final across = math.min(round / rect.width, 0.4); + Color dark(double alpha) => Colors.black.withValues(alpha: alpha); + canvas.drawRSuperellipse( + shape.inflate(_hairlineWidth / 2), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = _hairlineWidth + ..shader = LinearGradient( + colors: [ + dark(side), + dark(corner), + dark(flat), + dark(flat), + dark(corner), + dark(side), + ], + stops: [0, across / 4, across, 1 - across, 1 - across / 4, 1], + ).createShader(rect), + ); + } + + static double _roundOf(Size size, double radius) => + math.min(math.max(0.0, radius), size.shortestSide / 2); + + List _uniforms(Size size, double radius) { + final turns = _turns(size, _roundOf(size, radius)); + final ends = [for (final band in _shineBands) band.depth + band.width]; + return [ + for (final (turn, _) in turns) turn, + for (final (_, lit) in turns) shine * lit, + for (var index = 0; index < _shineSlots; index++) + ends[math.min(index, ends.length - 1)], + for (var index = 0; index < _shineSlots; index++) + index < _shineBands.length ? _shineBands[index].lit : 0.0, + ]; + } + + void _paintShine(Canvas canvas, Rect rect, double radius) { + final round = _roundOf(rect.size, radius); + final shape = AppRadius.all(round).toRSuperellipse(rect); + final around = _sweepStops(rect.size, round); + final stops = [for (final (stop, _) in around) stop]; + for (final band in _shineBands) { + final alpha = shine * band.lit; + // A ring, not a stroke: Impeller widens a stroke thinner than a pixel. + canvas.drawPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRSuperellipse(shape.deflate(band.depth)) + ..addRSuperellipse(shape.deflate(band.depth + band.width)), + Paint() + ..blendMode = BlendMode.plus + ..shader = SweepGradient( + colors: [ + for (final (_, lit) in around) + Colors.white.withValues(alpha: math.min(alpha * lit, 1)), + ], + stops: stops, + ).createShader(rect), + ); + } + } + + static List<(double, double)> _turns(Size size, double round) { + final halfWidth = size.width / 2; + final halfHeight = size.height / 2; + final turns = [(0.0, 1.0)]; + for (final (:along, :down, :lit) in _shineAround) { + final turn = math.atan2( + halfWidth - math.min(along * round, halfWidth), + halfHeight - math.min(down * round, halfHeight), + ); + turns.add((math.max(turns.last.$1, turn), lit)); + } + turns.add((math.pi / 2, 0)); + return turns; + } + + static List<(double, double)> _sweepStops(Size size, double round) { + final turns = _turns(size, round); + const full = 2 * math.pi; + return [ + for (final (turn, lit) in turns.reversed) + ((math.pi / 2 - turn) / full, lit), + for (final (turn, lit) in turns.skip(1)) + ((math.pi / 2 + turn) / full, lit), + for (final (turn, lit) in turns.reversed.skip(1)) + ((math.pi * 1.5 - turn) / full, lit), + for (final (turn, lit) in turns.skip(1)) + ((math.pi * 1.5 + turn) / full, lit), + ]; + } + + @override + bool operator ==(Object other) => + other is LiquidGlassRim && + other.side == side && + other.corner == corner && + other.flat == flat && + other.shine == shine; + + @override + int get hashCode => Object.hash(side, corner, flat, shine); +} + +/// Light a press casts on a pane, in its coordinates: [strength] at [center], +/// falling off over [reach] to [wash] of it across the rest of the pane. +@immutable +class LiquidGlassGlow { + const LiquidGlassGlow({ + required this.center, + required this.reach, + required this.strength, + this.wash = 0, + }); + + static const none = LiquidGlassGlow( + center: Offset.zero, + reach: 1, + strength: 0, + ); + + final Offset center; + final double reach; + final double strength; + final double wash; + + @override + bool operator ==(Object other) => + other is LiquidGlassGlow && + other.center == center && + other.reach == reach && + other.strength == strength && + other.wash == wash; + + @override + int get hashCode => Object.hash(center, reach, strength, wash); +} + +/// A pane of glass behind [child], after iOS's Liquid Glass, a pill unless +/// [clipper] shapes it. It casts [shadows] only outside itself, as one +/// under it would show through. Without Impeller's shader filters it frosts +/// but does not bend. +class LiquidGlass extends StatelessWidget { + const LiquidGlass({ + super.key, + required this.style, + this.clipper, + this.shadows = const [], + this.pressed = false, + this.glow, + this.child, + }); + + final LiquidGlassStyle style; + + /// Shapes the pane to its path in place of the pill. The glass bends + /// to the path by how far it reaches from the pane's center in each + /// direction, so it must meet every ray from the center once. + final CustomClipper? clipper; + + final List shadows; + + final bool pressed; + + /// Lights the pane in place of the glow [pressed] lights at its center. + final LiquidGlassGlow? glow; + + final Widget? child; + + /// Builds the scenes of [capture] without panes, for a snapshot: offscreen + /// a pane has no backdrop to bend and comes out a flat tint. + static T withoutPanes(T Function() capture) { + _LiquidGlassLayer._skipped++; + try { + return capture(); + } finally { + _LiquidGlassLayer._skipped--; + } + } + + /// Replaces a button's fill with glass. + static ButtonStyle buttonStyle( + LiquidGlassStyle Function(Set states) styleOf, + ) { + return ButtonStyle( + backgroundColor: const WidgetStatePropertyAll(Colors.transparent), + backgroundBuilder: (context, states, child) => LiquidGlass( + style: styleOf(states), + pressed: states.contains(WidgetState.pressed), + child: child, + ), + ); + } + + @override + Widget build(BuildContext context) { + if (glow case final glow?) { + return _LiquidGlassPane( + style: style, + clipper: clipper, + shadows: shadows, + press: 0, + fading: false, + glow: glow, + child: child, + ); + } + return TweenAnimationBuilder( + tween: Tween(end: pressed ? 1 : 0), + duration: context.motionDuration(pressed ? _glowIn : _glowOut), + curve: Curves.easeOut, + builder: (context, press, child) => _LiquidGlassPane( + style: style, + clipper: clipper, + shadows: shadows, + press: press, + fading: !pressed, + glow: null, + child: child, + ), + child: child, + ); + } +} + +class _LiquidGlassPane extends SingleChildRenderObjectWidget { + const _LiquidGlassPane({ + required this.style, + required this.clipper, + required this.shadows, + required this.press, + required this.fading, + required this.glow, + super.child, + }); + + final LiquidGlassStyle style; + final CustomClipper? clipper; + final List shadows; + final double press; + final bool fading; + final LiquidGlassGlow? glow; + + @override + _RenderLiquidGlass createRenderObject(BuildContext context) { + return _RenderLiquidGlass( + style: style, + clipper: clipper, + shadows: shadows, + press: press, + fading: fading, + glow: glow, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderLiquidGlass renderObject, + ) { + renderObject + ..style = style + ..clipper = clipper + ..shadows = shadows + ..press = press + ..fading = fading + ..glow = glow; + } +} + +class _RenderLiquidGlass extends RenderProxyBox { + _RenderLiquidGlass({ + required LiquidGlassStyle style, + required CustomClipper? clipper, + required List shadows, + required double press, + required bool fading, + required LiquidGlassGlow? glow, + }) : _style = style, + _clipper = clipper, + _shadows = shadows, + _press = press, + _fading = fading, + _glow = glow; + + late final _pane = LiquidGlassPane(markNeedsPaint); + + LiquidGlassStyle _style; + set style(LiquidGlassStyle value) { + if (value == _style) { + return; + } + _style = value; + markNeedsPaint(); + } + + CustomClipper? _clipper; + set clipper(CustomClipper? value) { + final old = _clipper; + if (value == old) { + return; + } + if (attached) { + old?.removeListener(_reclip); + value?.addListener(_reclip); + } + _clipper = value; + if (value == null || + old == null || + value.runtimeType != old.runtimeType || + value.shouldReclip(old)) { + _reclip(); + } + } + + Path? _outline; + Size? _outlineSize; + + void _reclip() { + _outline = null; + markNeedsPaint(); + } + + List _shadows; + set shadows(List value) { + if (listEquals(value, _shadows)) { + return; + } + _shadows = value; + markNeedsPaint(); + } + + double _press; + set press(double value) { + if (value == _press) { + return; + } + _press = value; + if (value == 0) { + _touch = null; + } + markNeedsPaint(); + } + + bool _fading; + set fading(bool value) { + if (value == _fading) { + return; + } + _fading = value; + markNeedsPaint(); + } + + // The pane wraps what it is the glass of, so it hears the presses on it and + // lights from the finger, as iOS does; a key press lights its center. + Offset? _touch; + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + if (event is PointerDownEvent || event is PointerMoveEvent) { + _touch = event.localPosition; + if (_press > 0) { + markNeedsPaint(); + } + } else if (_press == 0) { + _touch = null; + } + } + + LiquidGlassGlow? _glow; + set glow(LiquidGlassGlow? value) { + if (value == _glow) { + return; + } + _glow = value; + markNeedsPaint(); + } + + @override + bool get alwaysNeedsCompositing => true; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _clipper?.addListener(_reclip); + } + + @override + void detach() { + _clipper?.removeListener(_reclip); + super.detach(); + } + + @override + void dispose() { + _pane.dispose(); + super.dispose(); + } + + Path? get _currentOutline { + final clipper = _clipper; + if (clipper == null) { + return null; + } + if (_outline == null || _outlineSize != size) { + _outline = clipper.getClip(size); + _outlineSize = size; + } + return _outline; + } + + @override + void paint(PaintingContext context, Offset offset) { + if (size.isEmpty) { + _pane.clear(); + } else { + final outline = _currentOutline; + _pane.paint( + context, + offset & size, + shape: outline != null + ? LiquidGlassOutline(outline) + : LiquidGlassCorners(BorderRadius.circular(size.shortestSide / 2)), + style: _style, + shadows: _shadows, + glow: + _glow ?? + LiquidGlassGlow( + center: _touch ?? size.center(Offset.zero), + reach: + size.height / + math.sqrt2 * + (1 + (_fading ? _glowSpread * (1 - _press) : 0)), + strength: _press * 0.16, + ), + ); + } + super.paint(context, offset); + } +} + +sealed class LiquidGlassShape { + const LiquidGlassShape(); +} + +/// A rounded rectangle, no corner rounder than half the pane's short side. +final class LiquidGlassCorners extends LiquidGlassShape { + const LiquidGlassCorners(this.radius); + + LiquidGlassCorners.circular(double radius) + : radius = BorderRadius.circular(radius); + + final BorderRadius radius; +} + +/// A path laid out from the pane's top left, as [LiquidGlass.clipper] gives. +final class LiquidGlassOutline extends LiquidGlassShape { + const LiquidGlassOutline(this.path); + + final Path path; +} + +/// A pane for a render object that shapes the glass itself, as a morph does. +class LiquidGlassPane { + LiquidGlassPane(this.onReady); + + final VoidCallback onReady; + + final _layer = LayerHandle<_LiquidGlassLayer>(); + Path? _profiled; + List _profile = _flatProfile; + + /// A pane moving within [hold] keeps one capture (.agents/architecture.md). + void paint( + PaintingContext context, + Rect rect, { + required LiquidGlassShape shape, + required LiquidGlassStyle style, + List shadows = const [], + LiquidGlassGlow glow = LiquidGlassGlow.none, + Rect? hold, + LiquidGlassRim? rim, + }) { + final pane = _layer.layer ??= _LiquidGlassLayer(); + assert(rim == null || shape is LiquidGlassCorners); + switch (shape) { + case LiquidGlassCorners(:final radius): + final limit = Radius.circular(rect.shortestSide / 2); + final corners = BorderRadius.only( + topLeft: radius.topLeft.clamp(maximum: limit), + topRight: radius.topRight.clamp(maximum: limit), + bottomLeft: radius.bottomLeft.clamp(maximum: limit), + bottomRight: radius.bottomRight.clamp(maximum: limit), + ); + _profiled = null; + if (hold == null) { + _paintShadows(context.canvas, rect, corners, shadows); + } + pane + ..corners = corners + ..outline = null + ..profile = _flatProfile; + case LiquidGlassOutline(:final path): + assert(shadows.isEmpty, 'An outlined pane casts none'); + if (!identical(path, _profiled)) { + _profiled = path; + _profile = liquidGlassProfile(path, rect.size.center(Offset.zero)); + } + pane + ..corners = BorderRadius.zero + ..outline = path.shift(rect.topLeft) + ..profile = _profile; + } + pane + ..program = _GlassProgram.request(onReady) + ..rect = rect + ..style = style + ..glow = glow + ..hold = hold + ..rim = rim; + context.addLayer(pane); + if (hold != null) { + _paintShadows(context.canvas, rect, pane.corners, shadows); + } + } + + void clear() => _layer.layer = null; + + void dispose() { + _GlassProgram.forget(onReady); + clear(); + } + + static void _paintShadows( + Canvas canvas, + Rect rect, + BorderRadius corners, + List shadows, + ) { + if (shadows.isEmpty) { + return; + } + final shape = AppShape.of(corners); + final reach = shadows.fold( + 0.0, + (reach, shadow) => math.max( + reach, + shadow.offset.distance + shadow.spreadRadius + shadow.blurRadius * 2, + ), + ); + canvas + ..save() + ..clipPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRect(rect.inflate(reach)) + ..addPath(shape.getOuterPath(rect), Offset.zero), + ); + for (final shadow in shadows) { + canvas.drawRSuperellipse( + corners.toRSuperellipse( + rect.shift(shadow.offset).inflate(shadow.spreadRadius), + ), + shadow.toPaint(), + ); + } + canvas.restore(); + } +} + +abstract final class _GlassProgram { + static ui.FragmentProgram? _program; + static Future? _loading; + + /// Final: a bundled shader that failed once fails again, and every attempt + /// loads synchronously on the UI isolate. + static var _failed = false; + static final _waiting = {}; + + static ui.FragmentProgram? request(VoidCallback onLoaded) { + if (_program != null || + _failed || + !ui.ImageFilter.isShaderFilterSupported) { + return _program; + } + _waiting.add(onLoaded); + _loading ??= _load().then( + (program) { + _program = program; + final waiting = _waiting.toList(); + _waiting.clear(); + for (final callback in waiting) { + callback(); + } + }, + onError: (Object error, StackTrace stack) { + _failed = true; + _waiting.clear(); + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stack, + library: 'liquid glass', + ), + ); + }, + ); + return null; + } + + static void forget(VoidCallback onLoaded) => _waiting.remove(onLoaded); + + static Future _load() => + ui.FragmentProgram.fromAsset(_shaderAsset); +} + +class _LiquidGlassLayer extends Layer { + static int _skipped = 0; + + ui.FragmentProgram? program; + Rect rect = Rect.zero; + BorderRadius corners = BorderRadius.zero; + Path? outline; + List profile = _flatProfile; + LiquidGlassStyle style = const LiquidGlassStyle(tint: Colors.transparent); + LiquidGlassGlow glow = LiquidGlassGlow.none; + Rect? hold; + LiquidGlassRim? rim; + _Placement? _held; + + ui.Picture? _frost; + (Rect, BorderRadius, Path?, LiquidGlassStyle, LiquidGlassRim?)? _frostKey; + + int _capturedPass = -1; + ui.Picture? _captured; + ui.FragmentShader? _captureShader; + (ui.Image, Path?, BlendMode, ui.FragmentProgram?, List)? _captureKey; + + @override + bool get alwaysNeedsAddToScene => true; + + @override + void dispose() { + _frost?.dispose(); + _dropCaptured(); + _captureShader?.dispose(); + super.dispose(); + } + + @override + void addToScene(ui.SceneBuilder builder) { + if (_skipped > 0) { + return; + } + if (_capturedPass == _LiquidGlassHostLayer._pass) { + if (_captured case final picture?) { + builder.addPicture(Offset.zero, picture); + } + return; + } + _dropCaptured(); + _addFrost(builder); + } + + /// The uniforms from the pane's shape on, after where it and its texture + /// lie. + List _look(LiquidGlassStyle style) => [ + rect.width, + rect.height, + style.bevel, + style.blur, + corners.topLeft.x, + corners.topRight.x, + corners.bottomRight.x, + corners.bottomLeft.x, + ...liquidGlassCornerCurves(rect.size, corners), + style.refraction, + style.saturation, + style.brightness, + style.tint.r, + style.tint.g, + style.tint.b, + style.tint.a, + style.rim, + style.rimWidth, + style.endShade, + glow.center.dx, + glow.center.dy, + glow.strength, + style.contrast, + glow.reach, + glow.wash, + style.hairline, + style.dispersion, + style.rimBlur, + style.rimTail, + style.rimFocus, + style.rimAdded, + style._hairlineSide, + outline == null ? 0.0 : 1.0, + ...profile, + ...rim?._uniforms(rect.size, corners.topLeft.x) ?? _noShine, + ]; + + void _addFrost(ui.SceneBuilder builder) { + final key = (rect, corners, outline, style, rim); + if (_frost == null || key != _frostKey) { + _frost?.dispose(); + _frost = _paintFrost(); + _frostKey = key; + } + if (outline case final outline?) { + builder.pushClipPath(outline); + } else { + builder.pushClipRSuperellipse(corners.toRSuperellipse(rect)); + } + builder.addPicture(Offset.zero, _frost!); + builder.pop(); + } + + /// Draws the pane from [atlas], where a host laid out what lies behind it, + /// until the next pass of the host; [mapping] places the pane's slot. + void _captureFrom( + int pass, + ui.Image atlas, + List mapping, + BlendMode blendMode, + LiquidGlassStyle style, + ) { + _capturedPass = pass; + final program = this.program; + final uniforms = [ + atlas.width.toDouble(), + atlas.height.toDouble(), + -rect.left, + -rect.top, + 1, + 1, + ...mapping, + ..._look(style), + ]; + if (_captureKey + case (final image, final path, final mode, final shaded, final values)? + when _captured != null && + identical(image, atlas) && + identical(path, outline) && + mode == blendMode && + identical(shaded, program) && + listEquals(values, uniforms)) { + return; + } + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + if (program == null) { + _frostFrom(canvas, atlas, mapping, blendMode, style); + } else { + final shader = _captureShader ??= program.fragmentShader(); + shader.setImageSampler(0, atlas, filterQuality: FilterQuality.low); + for (final (index, value) in uniforms.indexed) { + shader.setFloat(index, value); + } + final paint = Paint() + ..shader = shader + ..blendMode = blendMode; + if (outline case final outline?) { + canvas.drawPath(outline, paint); + } else { + canvas.drawRSuperellipse(corners.toRSuperellipse(rect), paint); + } + } + _captured?.dispose(); + _captured = recorder.endRecording(); + _captureKey = (atlas, outline, blendMode, program, uniforms); + } + + /// Frosts the pane, without bending it, from the part of [atlas] behind it, + /// which holds its blur already. + void _frostFrom( + Canvas canvas, + ui.Image atlas, + List mapping, + BlendMode blendMode, + LiquidGlassStyle style, + ) { + canvas.save(); + if (outline case final outline?) { + canvas.clipPath(outline); + } else { + canvas.clipRSuperellipse(corners.toRSuperellipse(rect)); + } + final saturate = ColorFilter.matrix( + _optics(style.saturation, style.brightness), + ); + void drawSlot(int from, Paint paint) => canvas.drawImageRect( + atlas, + Rect.fromLTWH( + -mapping[from] / mapping[from + 2], + -mapping[from + 1] / mapping[from + 3], + rect.width / mapping[from + 2], + rect.height / mapping[from + 3], + ), + rect, + paint + ..filterQuality = FilterQuality.low + ..colorFilter = saturate, + ); + drawSlot(0, Paint()..blendMode = blendMode); + final haze = mapping[_hazeShare]; + if (haze > 0) { + drawSlot(_hazeSample, Paint()..color = Color.fromRGBO(0, 0, 0, haze)); + } + _drawFrost(canvas, overCapture: true); + canvas.restore(); + } + + /// Draws nothing until the next pass of the host, as nothing of the pane + /// shows. + void _captureNothing(int pass) { + _capturedPass = pass; + _dropCaptured(); + } + + void _dropCaptured() { + _captured?.dispose(); + _captured = null; + _captureKey = null; + } + + ui.Picture _paintFrost() { + final recorder = ui.PictureRecorder(); + _drawFrost(Canvas(recorder)); + return recorder.endRecording(); + } + + /// Thickens the tint to stand in for the frost, unless the pane lies + /// [overCapture], which holds the blur. + void _drawFrost(Canvas canvas, {bool overCapture = false}) { + final tint = style.tint; + final fill = Paint() + ..color = overCapture + ? tint + : tint.withValues(alpha: math.min(1, tint.a + 0.2)); + final rim = style.rim; + final stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = style.rimWidth + ..shader = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white.withValues(alpha: rim), + Colors.white.withValues(alpha: rim * 0.15), + Colors.white.withValues(alpha: rim * 0.5), + ], + ).createShader(rect); + final hairline = Paint() + ..style = PaintingStyle.stroke + ..color = Colors.black.withValues( + alpha: (style.hairline + style._hairlineSide) / 2 * 0.3, + ); + if (outline case final outline?) { + // The clip keeps the inner half of a stroke twice as wide. + canvas + ..drawPath(outline, fill) + ..drawPath(outline, stroke..strokeWidth = style.rimWidth * 2) + ..drawPath(outline, hairline..strokeWidth = _hairlineWidth * 2); + } else { + final shape = corners.toRSuperellipse(rect); + canvas + ..drawRSuperellipse(shape, fill) + ..drawRSuperellipse(shape.deflate(style.rimWidth / 2), stroke) + ..drawRSuperellipse( + shape.deflate(_hairlineWidth / 2), + hairline..strokeWidth = _hairlineWidth, + ); + this.rim?._paintShine(canvas, rect, corners.topLeft.x); + } + } +} + +/// How far [outline] reaches from [center] along [_profileTurns] even turns, +/// the first toward +x and the rest toward +y, as the shader reads it. +@visibleForTesting +List liquidGlassProfile(Path outline, Offset center) { + const step = 2 * math.pi / _profileTurns; + final profile = List.filled(_profileTurns, 0); + void cross(Offset from, Offset to) { + final start = from.direction; + var span = to.direction - start; + if (span > math.pi) { + span -= 2 * math.pi; + } else if (span <= -math.pi) { + span += 2 * math.pi; + } + final edge = to - from; + final first = (math.min(start, start + span) / step).ceil(); + final last = (math.max(start, start + span) / step).floor(); + for (var turn = first; turn <= last; turn++) { + final ray = Offset.fromDirection(turn * step); + final facing = ray.dx * edge.dy - ray.dy * edge.dx; + profile[turn % _profileTurns] = facing.abs() < 1e-9 + ? math.max(from.distance, to.distance) + : (from.dx * edge.dy - from.dy * edge.dx) / facing; + } + } + + for (final metric in outline.computeMetrics()) { + final samples = math.max(_profileTurns * 4, metric.length.ceil()); + Offset pointAt(int index) => + metric.getTangentForOffset(metric.length * index / samples)!.position - + center; + var previous = pointAt(0); + for (var index = 1; index <= samples; index++) { + final next = pointAt(index); + cross(previous, next); + previous = next; + } + } + return profile; +} + +@visibleForTesting +List liquidGlassCornerCurves(Size size, BorderRadius corners) => [ + for (final radius in [ + corners.topLeft.x, + corners.topRight.x, + corners.bottomRight.x, + corners.bottomLeft.x, + ]) + ..._cornerCurve(size.width / 2, size.height / 2, radius), +]; + +List _cornerCurve(double halfWidth, double halfHeight, double radius) { + final along = _superellipseOctant(halfWidth, radius); + final across = _superellipseOctant(halfHeight, radius); + return [ + along.n, + along.joint.dx, + along.joint.dy, + along.radius, + across.n, + across.joint.dx, + across.joint.dy, + across.radius, + along.center.dx, + along.center.dy, + across.center.dx, + across.center.dy, + ]; +} + +typedef _Octant = ({double n, Offset joint, Offset center, double radius}); + +/// ComputeOctant in impeller/geometry/round_superellipse_param.cc, which the +/// shader's cornerInset follows. +_Octant _superellipseOctant(double a, double radius) { + if (radius <= 1e-3) { + return (n: 0, joint: Offset.zero, center: Offset.zero, radius: 0); + } + final (n, jointAt) = _octantFit(a * 2 / radius); + final joint = Offset( + jointAt * a, + math.pow(1 - math.pow(jointAt, n), 1 / n) * a, + ); + final tangent = math.pow(joint.dx / joint.dy, n - 1); + final diagonal = (joint.dx - tangent * joint.dy) / (1 - tangent); + final meet = a - _octantGap * radius; + final arc = (meet - diagonal) * math.sqrt2; + final chord = Offset(meet, meet) - joint; + final reach = math.sqrt(math.max(0, arc * arc - chord.distanceSquared / 4)); + final center = + (joint + Offset(meet, meet)) / 2 + + Offset(chord.dy, -chord.dx) / chord.distance * reach; + return (n: n, joint: joint, center: center, radius: arc); +} + +const _octantGap = 1 - math.sqrt1_2; + +/// By the ratio of an octant's side to its radius: n, and 1 / (1 - xJ / a). +const _octantFits = [ + (2.0, 2.00000000, 1.13276676), + (2.1, 2.18349805, 1.20311921), + (2.2, 2.33888662, 1.28698796), + (2.3, 2.48660575, 1.36351941), + (2.4, 2.62226596, 1.44717976), + (2.5, 2.75148990, 1.53385819), + (3.0, 3.36298265, 1.98288283), + (3.5, 4.08649929, 2.23811846), + (4.0, 4.85481134, 2.47563463), + (4.5, 5.62945551, 2.72948597), + (5.0, 6.43023796, 2.98020421), +]; + +(double, double) _octantFit(double ratio) { + final (lastRatio, lastN, lastK) = _octantFits.last; + final past = ratio - lastRatio; + if (past > 0) { + return (lastN + 1.559599389 * past, 1 - 1 / (lastK + 0.522807185 * past)); + } + var index = 0; + while (index < _octantFits.length - 2 && ratio > _octantFits[index + 1].$1) { + index++; + } + final (r0, n0, k0) = _octantFits[index]; + final (r1, n1, k1) = _octantFits[index + 1]; + final t = math.max(0.0, (ratio - r0) / (r1 - r0)); + return (ui.lerpDouble(n0, n1, t)!, 1 - 1 / ui.lerpDouble(k0, k1, t)!); +} + +List _optics(double saturation, double brightness) { + final red = 0.2126 * brightness; + final green = 0.7152 * brightness; + final blue = 0.0722 * brightness; + final rest = 1 - saturation; + final kept = saturation * brightness; + return [ + red * rest + kept, green * rest, blue * rest, 0, 0, // + red * rest, green * rest + kept, blue * rest, 0, 0, + red * rest, green * rest, blue * rest + kept, 0, 0, + 0, 0, 0, 1, 0, + ]; +} diff --git a/lib/ui/liquid_glass_capture.dart b/lib/ui/liquid_glass_capture.dart new file mode 100644 index 00000000..3bbe5aff --- /dev/null +++ b/lib/ui/liquid_glass_capture.dart @@ -0,0 +1,967 @@ +part of 'liquid_glass.dart'; + +// A capture shrinks while a sigma of its frost still spans two of its own +// pixels; at one, where g1455 records its regular finish, text under dark glass +// shows blocks. g1455 measured no divisor past _maxDivisor. +const double _captureFrost = 2; +const int _maxDivisor = 8; +const double _maxCaptureSide = 2048; +// The blur of a downscaled capture read linearly, in its own pixels. +const double _captureSoftness = 0.5; +// How far content may paint past the bounds its layer was recorded for. +const double _overflow = 64; +// Impeller keeps the targets of layers and of their blurs by size for a few +// frames: a layer resized as content moves would miss them every frame. +const double _atlasStep = 32; +// Past this many sigmas of its frost a pane takes under 1% of what it reads, so +// one painted there does not raise it a level. +const double _levelReach = 2.5; +const double _heldFrostShare = 0.9; +// Offsets into a pane's mapping, in the order of the shader's uniforms. +const _hazeSample = 10; +const _hazeShare = 18; +const _noHaze = [0, 0, 1, 1, 0, 0, 0, 0]; + +typedef LiquidGlassCapture = ({ + int snapshots, + int keptBands, + List< + ({ + Rect pane, + Rect slot, + Rect place, + double scale, + int level, + ui.Image atlas, + }) + > + panes, +}); + +/// Draws the panes below from one capture of what lies behind them, taken as +/// the frame is composited, in place of each pane reading the backdrop +/// (.agents/architecture.md). Only one may enclose a pane. +class LiquidGlassHost extends StatelessWidget { + const LiquidGlassHost({ + super.key, + required this.enabled, + required this.child, + }); + + final bool enabled; + final Widget child; + + @visibleForTesting + static LiquidGlassCapture? debugLastCapture; + + @visibleForTesting + static bool? debugCarryBandsOverride; + + @override + Widget build(BuildContext context) { + return _LiquidGlassHostBox( + enabled: enabled, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + quality: LiquidGlassScope.qualityOf(context), + child: child, + ); + } +} + +/// Fades [child] as [FadeTransition] does, but as Core Animation fades a layer: +/// the panes within still bend what lies behind it, and fade with the rest. +class LiquidGlassFade extends SingleChildRenderObjectWidget { + const LiquidGlassFade({super.key, required this.opacity, super.child}); + + final Animation opacity; + + /// [PaintingContext.pushOpacity] for a render object, fading as this does. + static void push( + PaintingContext context, + Offset offset, + int alpha, + PaintingContextCallback painter, + ) { + context.pushLayer( + _LiquidGlassFadeLayer() + ..alpha = alpha + ..offset = offset, + painter, + Offset.zero, + ); + } + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderLiquidGlassFade(opacity); + } + + @override + void updateRenderObject(BuildContext context, RenderObject renderObject) { + (renderObject as _RenderLiquidGlassFade).opacity = opacity; + } +} + +class _RenderLiquidGlassFade extends RenderProxyBox { + _RenderLiquidGlassFade(this._opacity); + + final _layer = LayerHandle<_LiquidGlassFadeLayer>(); + + Animation _opacity; + set opacity(Animation value) { + if (value == _opacity) { + return; + } + if (attached) { + _opacity.removeListener(markNeedsPaint); + value.addListener(markNeedsPaint); + } + _opacity = value; + markNeedsPaint(); + } + + int get _alpha => + ui.Color.getAlphaFromOpacity(_opacity.value.clamp(0.0, 1.0)); + + @override + bool get alwaysNeedsCompositing => child != null; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _opacity.addListener(markNeedsPaint); + } + + @override + void detach() { + _opacity.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void dispose() { + _layer.layer = null; + super.dispose(); + } + + @override + void paint(PaintingContext context, Offset offset) { + final alpha = _alpha; + if (child == null || alpha == 0) { + _layer.layer = null; + return; + } + final layer = _layer.layer ??= _LiquidGlassFadeLayer(); + layer + ..alpha = alpha + ..offset = offset; + context.pushLayer(layer, super.paint, Offset.zero); + } + + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + if (_alpha != 0) { + super.visitChildrenForSemantics(visitor); + } + } +} + +class _LiquidGlassFadeLayer extends OpacityLayer {} + +class _LiquidGlassHostBox extends SingleChildRenderObjectWidget { + const _LiquidGlassHostBox({ + required this.enabled, + required this.devicePixelRatio, + required this.quality, + super.child, + }); + + final bool enabled; + final double devicePixelRatio; + final LiquidGlassQuality quality; + + @override + _RenderLiquidGlassHost createRenderObject(BuildContext context) { + return _RenderLiquidGlassHost( + enabled: enabled, + devicePixelRatio: devicePixelRatio, + quality: quality, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderLiquidGlassHost renderObject, + ) { + renderObject + ..enabled = enabled + ..devicePixelRatio = devicePixelRatio + ..quality = quality; + } +} + +class _RenderLiquidGlassHost extends RenderProxyBox { + _RenderLiquidGlassHost({ + required bool enabled, + required double devicePixelRatio, + required LiquidGlassQuality quality, + }) : _enabled = enabled, + _devicePixelRatio = devicePixelRatio, + _quality = quality; + + final _layer = LayerHandle<_LiquidGlassHostLayer>(); + + bool _enabled; + set enabled(bool value) { + if (value == _enabled) { + return; + } + _enabled = value; + markNeedsCompositingBitsUpdate(); + markNeedsPaint(); + } + + double _devicePixelRatio; + set devicePixelRatio(double value) { + if (value == _devicePixelRatio) { + return; + } + _devicePixelRatio = value; + markNeedsPaint(); + } + + LiquidGlassQuality _quality; + set quality(LiquidGlassQuality value) { + if (value == _quality) { + return; + } + _quality = value; + markNeedsPaint(); + } + + @override + bool get alwaysNeedsCompositing => _enabled; + + @override + void paint(PaintingContext context, Offset offset) { + if (!_enabled) { + _release(); + super.paint(context, offset); + return; + } + final layer = _layer.layer ??= _LiquidGlassHostLayer(); + layer + ..devicePixelRatio = _devicePixelRatio + ..quality = _quality + ..bounds = offset & size; + context.pushLayer(layer, super.paint, offset); + } + + // A pane captured on a pass still current would keep drawing from it. + void _release() { + if (_layer.layer != null) { + _layer.layer = null; + _LiquidGlassHostLayer._pass++; + } + } + + @override + void dispose() { + _release(); + super.dispose(); + } +} + +class _LiquidGlassHostLayer extends ContainerLayer { + static int _pass = 0; + + double devicePixelRatio = 1; + LiquidGlassQuality quality = LiquidGlassQuality.ultra; + Rect bounds = Rect.zero; + + final _atlases = <({ui.Image image, List<_Band> bands})>[]; + + @override + void addToScene(ui.SceneBuilder builder) { + _capture(); + super.addToScene(builder); + } + + @override + void dispose() { + for (final atlas in _atlases) { + atlas.image.dispose(); + } + _atlases.clear(); + super.dispose(); + } + + /// A pane over one painted before it in the same root sits a level above + /// it, and reads a capture with that one drawn as glass. + void _capture() { + _pass++; + final found = <_Behind>[]; + _gather(this, Matrix4.identity(), this, Matrix4.identity(), [], found); + final placed = <_Behind>[]; + for (final entry in found) { + if (_place(entry)) { + placed.add(entry); + if (entry.style.haze > 0) { + final haze = _Behind.hazeOf(entry); + if (_place(haze)) { + entry.haze = haze; + } + } + } else { + entry.pane._captureNothing(_pass); + } + } + var levels = 0; + for (final (index, entry) in placed.indexed) { + for (final before in placed.take(index)) { + if ((identical(entry.root, this) || + before.ancestors.contains(entry.root)) && + before.device.overlaps(entry.reach)) { + entry.level = math.max(entry.level, before.level + 1); + } + } + levels = math.max(levels, entry.level + 1); + } + var snapshots = 0; + var keptBands = 0; + for (var level = 0; level < levels; level++) { + final members = [ + for (final entry in placed) + if (entry.level == level) entry, + ]; + final (:atlas, :taken, :kept) = _snapshot(level, [ + for (final entry in members) ...[entry, ?entry.haze], + ]); + if (taken) { + snapshots++; + keptBands += kept; + } + for (final entry in members) { + entry.atlas = atlas; + _publish(entry, atlas); + } + } + while (_atlases.length > levels) { + _atlases.removeLast().image.dispose(); + } + assert(() { + LiquidGlassHost.debugLastCapture = ( + snapshots: snapshots, + keptBands: keptBands, + panes: [ + for (final entry in placed) + ( + pane: entry.device, + slot: entry.slot, + place: entry.place, + scale: entry.texelsPerDp, + level: entry.level, + atlas: entry.atlas!, + ), + ], + ); + return true; + }()); + } + + void _gather( + ContainerLayer layer, + Matrix4 toHost, + ContainerLayer root, + Matrix4 rootToHost, + List chain, + List<_Behind> found, + ) { + chain.add(layer); + for (Layer? child = layer.firstChild; child != null;) { + switch (child) { + case final _LiquidGlassLayer pane: + found.add( + _Behind( + pane, + switch (quality) { + LiquidGlassQuality.standard => pane.style._standard, + LiquidGlassQuality.ultra => pane.style, + }, + toHost, + root, + rootToHost, + chain.toSet(), + ), + ); + case final ContainerLayer container: + final offscreen = _drawsOffscreen(container); + _gather( + container, + _within(container, toHost), + offscreen ? container : root, + offscreen ? toHost : rootToHost, + chain, + found, + ); + } + child = child.nextSibling; + } + chain.removeLast(); + } + + /// Snaps the slot to the capture's pixels, so that content holding still + /// lands on the same ones while the pane moves. A pane with a hold keeps + /// its slot while it fits inside at no more frost, nor much less, as a + /// spring settling past its card would need, rather than glass fading. + bool _place(_Behind entry) { + final pane = entry.pane; + final style = entry.style; + final hazed = entry.hazed; + final blur = hazed ? style.hazeBlur : style.blur; + final storage = entry.toHost.storage; + final stretch = math.max(storage[0].abs(), storage[5].abs()); + final device = entry.device; + if (stretch == 0 || device.isEmpty || !device.overlaps(bounds)) { + pane._held = null; + return false; + } + final hold = hazed ? null : pane.hold; + final sweep = hold == null + ? device + : device.expandToInclude(MatrixUtils.transformRect(entry.toHost, hold)); + final bend = math.max(0.0, -style.refraction) + style.dispersion; + final reach = (3 * (blur + style.rimBlur) + bend + 2) * stretch; + entry.reach = sweep.inflate( + (_levelReach * (blur + style.rimBlur) + bend) * stretch, + ); + final needed = device.inflate(reach).intersect(bounds); + final covered = sweep.inflate(reach).intersect(bounds); + final longest = math.max(covered.width, covered.height) * devicePixelRatio; + final divisor = math.max( + math.min( + _maxDivisor, + math.max( + 1, + (blur * stretch * devicePixelRatio / _captureFrost).floor(), + ), + ), + (longest / _maxCaptureSide).ceil(), + ); + final scale = devicePixelRatio / divisor; + final frost = blur * stretch * scale; + final softness = divisor > 1 ? _captureSoftness : 0.0; + final blurSigma = frost > softness + ? math.sqrt(frost * frost - softness * softness) + : 0.0; + final held = pane._held; + final _Placement placement; + if (hold != null && + held != null && + held.texelsPerDp == scale && + held.blurSigma >= blurSigma && + blurSigma >= held.blurSigma * _heldFrostShare && + _encloses(held.slot, needed)) { + placement = held; + } else { + final left = (covered.left * scale).floorToDouble(); + final top = (covered.top * scale).floorToDouble(); + final right = (covered.right * scale).ceilToDouble(); + final bottom = (covered.bottom * scale).ceilToDouble(); + placement = ( + slot: Rect.fromLTRB( + left / scale, + top / scale, + right / scale, + bottom / scale, + ), + texels: Size(right - left, bottom - top), + texelsPerDp: scale, + heldFrost: math.max(frost, softness), + blurSigma: blurSigma, + ); + } + if (!hazed) { + pane._held = hold == null ? null : placement; + } + entry + ..texelsPerDp = placement.texelsPerDp + ..heldFrost = placement.heldFrost + ..blurSigma = placement.blurSigma + ..slot = placement.slot + ..texels = placement.texels; + return true; + } + + ({ui.Image atlas, bool taken, int kept}) _snapshot( + int level, + List<_Behind> members, + ) { + // Slots that blur alike lie together, under one layer that blurs them; + // one that does not blur lies alone, so it is kept or drawn on its own. + final classes = >{}; + for (final entry in members) { + (classes[entry.blurSigma == 0 ? entry : entry.blurSigma] ??= []).add( + entry, + ); + } + double step(double extent) => (extent / _atlasStep).ceil() * _atlasStep; + final bands = <_Band>[]; + final pictures = []; + var top = 0.0; + for (final entries in classes.values) { + var bottom = 0.0; + var width = 0.0; + for (final entry in entries) { + entry.place = Offset(0, top + bottom) & entry.texels; + bottom += entry.texels.height; + width = math.max(width, entry.texels.width); + } + final rect = Rect.fromLTWH(0, top, step(width), step(bottom)); + final sigma = entries.first.blurSigma; + final drawn = [sigma, rect.size]; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + for (final entry in entries) { + final place = entry.place.shift(-rect.topLeft); + drawn + ..add(place) + ..add(entry.slot) + ..addAll(entry.rootToHost.storage); + canvas + ..save() + ..clipRect(place) + ..translate(place.left, place.top) + ..scale(entry.texelsPerDp) + ..translate(-entry.slot.left, -entry.slot.top) + ..transform(entry.rootToHost.storage); + _Replay(canvas, drawn, entry).run(); + canvas.restore(); + } + bands.add((rect: rect, sigma: sigma, drawn: drawn)); + pictures.add(recorder.endRecording()); + top = rect.bottom; + } + final size = Size( + bands.fold(0.0, (width, band) => math.max(width, band.rect.width)), + top, + ); + final previous = level < _atlases.length ? _atlases[level] : null; + final last = previous?.bands ?? const <_Band>[]; + if (previous != null && + previous.image.width == size.width && + previous.image.height == size.height && + last.length == bands.length && + bands.indexed.every( + (band) => + band.$2.rect == last[band.$1].rect && + listEquals(band.$2.drawn, last[band.$1].drawn), + )) { + for (final picture in pictures) { + picture.dispose(); + } + return (atlas: previous.image, taken: false, kept: 0); + } + // Skia keeps what an image was drawn from for as long as the image lives, + // so a capture copied from the last would hold every one before it. + final carry = + LiquidGlassHost.debugCarryBandsOverride ?? + ui.ImageFilter.isShaderFilterSupported; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + var kept = 0; + for (final (index, band) in bands.indexed) { + final picture = pictures[index]; + final old = carry + ? last.where((old) => listEquals(old.drawn, band.drawn)).firstOrNull + : null; + if (old != null) { + canvas.drawImageRect( + previous!.image, + old.rect, + band.rect, + Paint() + ..filterQuality = FilterQuality.none + ..blendMode = BlendMode.src, + ); + kept++; + } else { + final sigma = band.sigma; + canvas + ..save() + ..clipRect(band.rect); + if (sigma > 0) { + canvas.saveLayer( + band.rect, + Paint() + ..imageFilter = ui.ImageFilter.blur( + sigmaX: sigma, + sigmaY: sigma, + tileMode: TileMode.clamp, + ), + ); + } + // Impeller filters a layer under the transform current at its + // restore (flutter-upgrade.md). + canvas + ..save() + ..translate(band.rect.left, band.rect.top) + ..drawPicture(picture) + ..restore(); + if (sigma > 0) { + canvas.restore(); + } + canvas.restore(); + } + picture.dispose(); + } + final picture = recorder.endRecording(); + final image = picture.toImageSync(size.width.toInt(), size.height.toInt()); + picture.dispose(); + if (previous == null) { + _atlases.add((image: image, bands: bands)); + } else { + previous.image.dispose(); + _atlases[level] = (image: image, bands: bands); + } + return (atlas: image, taken: true, kept: kept); + } + + void _publish(_Behind entry, ui.Image atlas) { + final pane = entry.pane; + final storage = entry.toHost.storage; + final haze = entry.haze; + pane._captureFrom( + _pass, + atlas, + [ + ..._sampling(entry), + 1 / (devicePixelRatio * math.max(storage[0].abs(), storage[5].abs())), + entry.heldFrost, + ...haze == null ? _noHaze : _sampling(haze), + if (haze == null) 0 else entry.style.haze, + ], + identical(entry.root, this) ? BlendMode.srcOver : BlendMode.src, + entry.style, + ); + } + + List _sampling(_Behind slot) { + final pane = slot.pane; + final storage = slot.toHost.storage; + final place = slot.place; + final scale = slot.texelsPerDp; + final texelsPerPaneX = storage[0] * scale; + final texelsPerPaneY = storage[5] * scale; + final originX = + place.left + + (storage[0] * pane.rect.left + storage[12] - slot.slot.left) * scale; + final originY = + place.top + + (storage[5] * pane.rect.top + storage[13] - slot.slot.top) * scale; + return [ + -originX / texelsPerPaneX, + -originY / texelsPerPaneY, + 1 / texelsPerPaneX, + 1 / texelsPerPaneY, + place.left + 0.5, + place.top + 0.5, + place.right - 0.5, + place.bottom - 0.5, + ]; + } +} + +typedef _Placement = ({ + Rect slot, + Size texels, + double texelsPerDp, + double heldFrost, + double blurSigma, +}); + +typedef _Band = ({Rect rect, double sigma, List drawn}); + +bool _encloses(Rect outer, Rect inner) => + outer.left <= inner.left && + outer.top <= inner.top && + outer.right >= inner.right && + outer.bottom >= inner.bottom; + +class _Behind { + _Behind( + this.pane, + this.style, + this.toHost, + this.root, + this.rootToHost, + this.ancestors, + ) : hazed = false; + + _Behind.hazeOf(_Behind owner) + : pane = owner.pane, + style = owner.style, + toHost = owner.toHost, + root = owner.root, + rootToHost = owner.rootToHost, + ancestors = owner.ancestors, + hazed = true; + + final _LiquidGlassLayer pane; + final LiquidGlassStyle style; + final Matrix4 toHost; + + /// The host, or below it the nearest layer that draws offscreen: a backdrop + /// filter there would read only that layer's texture. + final ContainerLayer root; + final Matrix4 rootToHost; + final Set ancestors; + + /// Captures the backdrop again for the pane's [LiquidGlassStyle.haze]. + final bool hazed; + + late final Rect device = MatrixUtils.transformRect(toHost, pane.rect); + + /// Where a pane painted before this one would show in what it reads. + Rect reach = Rect.zero; + Rect slot = Rect.zero; + double texelsPerDp = 1; + double heldFrost = 0; + double blurSigma = 0; + Size texels = Size.zero; + Rect place = Rect.zero; + int level = 0; + ui.Image? atlas; + _Behind? haze; +} + +/// The transform a layer gives its children, from what it holds now: +/// [Layer.applyTransform] may answer from the frame before. +Matrix4? _geometry(ContainerLayer layer) { + Matrix4 applied(Layer child) { + final matrix = Matrix4.identity(); + layer.applyTransform(child, matrix); + return matrix; + } + + return switch (layer) { + TransformLayer(:final transform?, :final offset) => + offset == Offset.zero + ? transform + : (Matrix4.translationValues(offset.dx, offset.dy, 0) + ..multiply(transform)), + OffsetLayer(:final offset) || LeaderLayer(:final offset) => + offset == Offset.zero + ? null + : Matrix4.translationValues(offset.dx, offset.dy, 0), + FollowerLayer(:final firstChild?) => applied(firstChild), + _ => null, + }; +} + +Matrix4 _within(ContainerLayer layer, Matrix4 toHost) { + final geometry = _geometry(layer); + return geometry == null ? toHost : (toHost.clone()..multiply(geometry)); +} + +bool _drawsOffscreen(Layer layer) { + return switch (layer) { + _LiquidGlassFadeLayer() => false, + ClipRectLayer(:final clipBehavior) || + ClipRRectLayer(:final clipBehavior) || + ClipRSuperellipseLayer(:final clipBehavior) || + ClipPathLayer( + :final clipBehavior, + ) => clipBehavior == Clip.antiAliasWithSaveLayer, + OpacityLayer(:final alpha) => alpha != null && alpha < 255, + ColorFilterLayer() || + ImageFilterLayer() || + ShaderMaskLayer() || + BackdropFilterLayer() => true, + _ => false, + }; +} + +/// Redraws onto a capture what a pane's root holds below the pane, as the +/// engine composites it, but for the root's own effect, which the engine +/// applies to the glass as well. +class _Replay { + _Replay(this.canvas, this.drawn, this.entry) : _toHost = [entry.rootToHost]; + + final Canvas canvas; + final List drawn; + final _Behind entry; + final List _toHost; + + // A layer's effects are applied only once something under it draws. + final _entered = <(ContainerLayer, Matrix4?, bool)>[]; + final _saves = []; + + void run() => _walk(entry.root, root: true); + + bool _walk(ContainerLayer layer, {required bool root}) { + if (!root && !entry.ancestors.contains(layer) && !_mayShow(layer)) { + return false; + } + final geometry = _geometry(layer); + final outer = _toHost.last; + _toHost.add(geometry == null ? outer : (outer.clone()..multiply(geometry))); + _entered.add((layer, geometry, root)); + var reached = false; + for (Layer? child = layer.firstChild; child != null && !reached;) { + switch (child) { + case _ when identical(child, entry.pane): + reached = true; + case final _LiquidGlassLayer pane + when pane._capturedPass == _LiquidGlassHostLayer._pass: + if (pane._captured case final picture? when _shows(pane.rect)) { + _draw(picture); + } + case final PictureLayer recorded: + if (recorded.picture case final picture? + when _shows(recorded.canvasBounds.inflate(_overflow))) { + _draw(picture); + } + case final ContainerLayer container: + reached = _walk(container, root: false); + } + child = child.nextSibling; + } + _leave(); + _toHost.removeLast(); + return reached; + } + + bool _mayShow(ContainerLayer layer) { + if (layer is OpacityLayer && layer.alpha == 0) { + return false; + } + final clip = layer.describeClipBounds(); + return clip == null || _shows(clip); + } + + bool _shows(Rect bounds) => + MatrixUtils.transformRect(_toHost.last, bounds).overlaps(entry.slot); + + void _draw(ui.Picture picture) { + while (_saves.length < _entered.length) { + _saves.add(_apply(_entered[_saves.length])); + } + canvas.drawPicture(picture); + drawn.add(picture); + } + + int _apply((ContainerLayer, Matrix4?, bool) entered) { + final (layer, geometry, root) = entered; + var saves = 0; + if (geometry != null) { + canvas + ..save() + ..transform(geometry.storage); + drawn.addAll(geometry.storage); + saves++; + } + final effect = root + ? null + : switch (layer) { + OpacityLayer(:final alpha?) when alpha < 255 => ( + Paint()..color = Color.fromARGB(alpha, 0, 0, 0), + alpha, + ), + ColorFilterLayer(:final colorFilter?) => ( + Paint()..colorFilter = colorFilter, + colorFilter, + ), + ImageFilterLayer(:final imageFilter?) => ( + Paint()..imageFilter = imageFilter, + imageFilter, + ), + ShaderMaskLayer(:final shader) => (Paint(), shader), + _ => null, + }; + if (effect case (final paint, final key)?) { + canvas.saveLayer(null, paint); + drawn + ..add(layer.runtimeType) + ..add(key); + saves++; + } + if (_clip(layer)) { + saves++; + } + return saves; + } + + bool _clip(ContainerLayer layer) { + final Object clip; + switch (layer) { + case ClipRectLayer(:final clipRect?, :final clipBehavior) + when clipBehavior != Clip.none: + canvas + ..save() + ..clipRect(clipRect, doAntiAlias: clipBehavior != Clip.hardEdge); + clip = clipRect; + case ClipRSuperellipseLayer( + :final clipRSuperellipse?, + :final clipBehavior, + ) + when clipBehavior != Clip.none: + canvas + ..save() + ..clipRSuperellipse( + clipRSuperellipse, + doAntiAlias: clipBehavior != Clip.hardEdge, + ); + clip = clipRSuperellipse; + case ClipPathLayer(:final clipPath?, :final clipBehavior) + when clipBehavior != Clip.none: + canvas + ..save() + ..clipPath(clipPath, doAntiAlias: clipBehavior != Clip.hardEdge); + clip = clipPath; + default: + // Lib clips to no rounded rect, so one a package draws clips by its + // bounds here. + final bounds = layer.describeClipBounds(); + if (bounds == null) { + return false; + } + canvas + ..save() + ..clipRect(bounds); + clip = bounds; + } + drawn.add(clip); + return true; + } + + void _leave() { + final (layer, _, root) = _entered.removeLast(); + if (_saves.length <= _entered.length) { + return; + } + final saves = _saves.removeLast(); + if (layer case ShaderMaskLayer( + :final shader?, + :final maskRect?, + :final blendMode?, + ) when !root) { + canvas + ..translate(maskRect.left, maskRect.top) + ..drawRect( + Offset.zero & maskRect.size, + Paint() + ..shader = shader + ..blendMode = blendMode, + ); + drawn + ..add(maskRect) + ..add(blendMode); + } + for (var index = 0; index < saves; index++) { + canvas.restore(); + } + drawn.add(saves); + } +} diff --git a/lib/widgets/navigation_dock.dart b/lib/ui/navigation_dock.dart similarity index 57% rename from lib/widgets/navigation_dock.dart rename to lib/ui/navigation_dock.dart index d0c702dd..1239929e 100644 --- a/lib/widgets/navigation_dock.dart +++ b/lib/ui/navigation_dock.dart @@ -1,70 +1,83 @@ import 'dart:math' as math; -import 'dart:ui' show ImageFilter, lerpDouble; -import 'package:fl_clash/common/common.dart'; -import 'package:fl_clash/icons/icons.dart'; +import 'package:fl_clash/ui/context.dart'; +import 'package:fl_clash/ui/icons/icons.dart'; +import 'package:fl_clash/ui/shape.dart'; +import 'package:fl_clash/ui/surface.dart'; +import 'package:fl_clash/ui/typography.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:material_color_utilities/material_color_utilities.dart' + show Hct; import 'package:material_ui/material_ui.dart'; +import 'liquid_glass.dart'; + const double _barHeight = 62; const double _barPadding = 4; const double _edgeMargin = 21; const double _shadowRoom = 8; -const double _fabGap = 8; const double _hitSlop = 8; -const double _maxItemExtent = 72; +const double _maxItemExtent = 86; +const double _minLensWidth = 84; const _fullWidthCount = 5; -const double _iconSize = 24; -const double _trailingIconSize = 28; +const double _iconSize = 28; const double _labelGap = 2; const double _labelInset = 2; const double _labelSize = 10; +const double _labelLeading = 14.5; const double _minLabelSize = 9; const double _pressGrowth = 1 / 8; const double _maxPressGrowth = 16; -const double _lensGrowth = 14; -const double _lensMagnify = 0.12; +const double _lensGrowth = 8; +const double _lensMagnify = 0.16; +const double _liftSnap = 0.975; +const double _liftRelease = 0.04; const double _hoverMagnet = 0.2; const double _hoverParallax = 0.5; const double _hoverAlpha = 0.08; const double _hoverSwell = 0.4; -const double _jellySpeed = 8; -const double _jellyStretch = 0.25; +const double _jellySpeed = 3; +const double _jellyStretch = 0.2; +const double _jellyBrake = 25; +const double _jellySquash = 0.15; +const double _jellyAspect = 1.4; +const double _glowReach = 28; +const double _glowGrowth = 3; +const double _glowPeak = 0.06; +const double _glowWash = 0.7; const double _overdrag = 0.35; const double _pullLimit = 7 / 32; const double _pullStretch = 0.5; const _flingProjection = Duration(milliseconds: 100); -const _slotDuration = Duration(milliseconds: 500); -const _slotExitDuration = Duration(milliseconds: 220); -const double _slotEnterScale = 0.5; -const double _slotExitScale = 0.7; -const double _slotBlur = 6; -const _slotRevealCurve = Interval(0, 0.5, curve: Curves.easeOut); -// No bounce: an overshoot would drive the slot's width below zero. -final _slotSizeCurve = SpringCurve( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 420), - ), - seconds: 0.5, -); -final _slotPopCurve = SpringCurve( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 400), - bounce: 0.3, - ), - seconds: 0.5, -); final _trackSpring = SpringDescription.withDurationAndBounce( duration: const Duration(milliseconds: 120), ); +final _pressSpring = SpringDescription.withDampingRatio( + mass: 1, + stiffness: 311, + ratio: 0.593, +); +final _followSpring = SpringDescription.withDampingRatio( + mass: 1, + stiffness: 1024, + ratio: 0.9, +); final _liftSpring = SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 280), - bounce: 0.2, + duration: const Duration(milliseconds: 250), +); +final _travelSpring = SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 400), + bounce: 0.15, +); +final _jellySpring = SpringDescription.withDampingRatio( + mass: 1, + stiffness: 100, + ratio: 0.4, ); final _settleSpring = SpringDescription.withDurationAndBounce( duration: const Duration(milliseconds: 500), @@ -77,33 +90,58 @@ final _hoverSpring = SpringDescription.withDurationAndBounce( final _fadeSpring = SpringDescription.withDurationAndBounce( duration: const Duration(milliseconds: 200), ); +final _glowInSpring = SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 100), +); +final _glowOutSpring = SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 500), +); double _rubberBand(double overshoot, double limit) { final pull = 1 - 1 / (overshoot.abs() * 0.55 / limit + 1); return limit * pull * overshoot.sign; } +double _saturate(double value) => 1 - 2 / (math.exp(2 * value) + 1); + /// The lens's box, measured from the start edge of the destination at 0. Rect _lensRect({ required double position, - required double velocity, - required double extent, + required double stretch, + required double pitch, + required double width, required double height, required double lift, }) { - final stretch = - (velocity.abs() / _jellySpeed).clamp(0.0, 1.0) * _jellyStretch; final growth = _lensGrowth * 2 * lift; - final width = (extent + growth) * (1 + stretch); - final lensHeight = (height + growth) * (1 - stretch / 2); + final lensWidth = (width + growth) * (1 + stretch); + final lensHeight = (height + growth) * (1 - stretch * _jellyAspect); return Rect.fromLTWH( - (position + 0.5) * extent - width / 2, + (position + 0.5) * pitch - lensWidth / 2, (height - lensHeight) / 2, - width, + lensWidth, lensHeight, ); } +/// Spaces destinations as iOS's tab bar does: the resting lens overhangs its +/// destination by [_barPadding] and never narrows below [_minLensWidth]. +({double pitch, double lens, double inset}) _dockLayout( + double span, + int count, +) { + if (count <= 1) { + return (pitch: span - _barPadding * 2, lens: span, inset: _barPadding); + } + final pitch = (span - _barPadding * 2) / count; + if (pitch + _barPadding * 2 >= _minLensWidth) { + return (pitch: pitch, lens: pitch + _barPadding * 2, inset: _barPadding); + } + final lens = math.min(_minLensWidth, span); + final closed = (span - lens) / (count - 1); + return (pitch: closed, lens: lens, inset: (lens - closed) / 2); +} + /// A value that springs toward a target the finger may move every frame. /// /// [AnimationController.animateWith] restarts its ticker, whose first frame @@ -118,6 +156,7 @@ class _Spring extends ChangeNotifier implements ValueListenable { double _value; double _target = 0; SpringSimulation? _simulation; + SpringDescription? _spring; double _now = 0; double _start = 0; @@ -128,8 +167,18 @@ class _Spring extends ChangeNotifier implements ValueListenable { double get velocity => _simulation?.dx(_now - _start) ?? 0; + double get acceleration { + final spring = _spring; + if (_simulation == null || spring == null) { + return 0; + } + return (spring.stiffness * (_target - _value) - spring.damping * velocity) / + spring.mass; + } + void springTo(double target, SpringDescription spring) { _simulation = SpringSimulation(spring, _value, target, velocity); + _spring = spring; _target = target; if (_ticker.isActive) { _start = _now; @@ -168,7 +217,7 @@ class _Spring extends ChangeNotifier implements ValueListenable { } // A soft, wide drop in the manner of iOS rather than a Material elevation. -List _dockShadows(ColorScheme colorScheme) { +List dockShadows(ColorScheme colorScheme) { final strength = colorScheme.brightness == Brightness.dark ? 3.0 : 1.0; return [ BoxShadow( @@ -184,6 +233,56 @@ List _dockShadows(ColorScheme colorScheme) { ]; } +// iOS 27 shades its glass bars only below, faintly, and no darker in dark mode. +List dockGlassShadows(ColorScheme colorScheme) => [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.013), + blurRadius: 3.8, + offset: const Offset(0, 6.5), + ), +]; + +/// What the dock's parts stand on: a floating surface, or glass. +class _DockSurface extends StatelessWidget { + const _DockSurface({ + required this.glass, + required this.style, + required this.shadows, + required this.glow, + required this.child, + }); + + final bool glass; + final LiquidGlassStyle style; + final List shadows; + final ValueListenable glow; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.colorScheme; + if (glass) { + return ValueListenableBuilder( + valueListenable: glow, + builder: (_, glow, child) => LiquidGlass( + style: style, + shadows: shadows, + glow: glow, + child: child, + ), + child: child, + ); + } + return DecoratedBox( + decoration: ShapeDecoration( + shape: AppShape.full, + shadows: dockShadows(colorScheme), + ), + child: child, + ); + } +} + ({double width, double height}) _measureLabel( BuildContext context, String label, @@ -207,43 +306,17 @@ class NavigationDockDestination { final String label; } -/// Marks a phone's home page, whose foot the navigation dock holds. -class DockedPageScope extends InheritedWidget { - const DockedPageScope({ - super.key, - required this.docked, - required super.child, - }); - - final bool docked; - - static bool of(BuildContext context) => - context.dependOnInheritedWidgetOfExactType()?.docked ?? - false; - - @override - bool updateShouldNotify(DockedPageScope oldWidget) => - docked != oldWidget.docked; -} - class NavigationDock extends StatelessWidget { const NavigationDock({ super.key, required this.destinations, required this.selectedIndex, required this.onSelected, - this.trailing, }); final List destinations; final int selectedIndex; final ValueChanged onSelected; - final Widget? trailing; - - /// Whether [context] sits in the dock's button slot, where a button keeps - /// to a circle the bar's height instead of growing a label. - static bool isDocked(BuildContext context) => - context.dependOnInheritedWidgetOfExactType<_DockedMarker>() != null; static double heightOf(BuildContext context) { return _barHeight + @@ -282,7 +355,7 @@ class NavigationDock extends StatelessWidget { maxWidth: destinations.length >= _fullWidthCount ? double.infinity : destinations.length * _maxItemExtent + - _barPadding * 2, + _barPadding * 4, ), child: FloatingNavigationBar( destinations: destinations, @@ -292,13 +365,18 @@ class NavigationDock extends StatelessWidget { ), ), ), - _DockTrailing(height: height, child: trailing), ], ), ), ), ); - return RepaintBoundary(child: dock); + // The bar rebuilds every frame its lens or a press moves; a scope of its + // own keeps that to the dock (.agents/architecture.md, Rebuild Scopes). + return SizedBox( + width: double.infinity, + height: _shadowRoom + height + _bottomMarginOf(context), + child: RepaintBoundary(child: LayoutBuilder(builder: (_, _) => dock)), + ); } } @@ -337,134 +415,21 @@ class _RenderHitSlop extends RenderProxyBox { } } -class _DockedMarker extends InheritedWidget { - const _DockedMarker({required super.child}); - - @override - bool updateShouldNotify(_DockedMarker oldWidget) => false; -} - -class _DockTrailing extends StatelessWidget { - const _DockTrailing({required this.height, required this.child}); - - final double height; - final Widget? child; - - ThemeData _dockedTheme(ThemeData theme) { - return theme.copyWith( - floatingActionButtonTheme: theme.floatingActionButtonTheme.copyWith( - shape: AppShape.full, - elevation: 0, - focusElevation: 0, - hoverElevation: 0, - highlightElevation: 0, - disabledElevation: 0, - sizeConstraints: BoxConstraints.tightFor(width: height, height: height), - iconSize: _trailingIconSize, - ), - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final child = this.child; - return AnimatedSize( - duration: _slotDuration, - curve: _slotSizeCurve, - alignment: AlignmentDirectional.centerEnd, - clipBehavior: Clip.none, - child: AnimatedSwitcher( - duration: _slotDuration, - reverseDuration: _slotExitDuration, - transitionBuilder: (child, animation) => - _SlotMaterialize(animation: animation, child: child), - // A leaving button takes no width, so the bar grows into its place - // while it dissolves instead of waiting for it. - layoutBuilder: (current, previous) => Stack( - alignment: AlignmentDirectional.centerEnd, - clipBehavior: Clip.none, - children: [ - for (final child in previous) - SizedBox( - width: 0, - child: OverflowBox( - alignment: AlignmentDirectional.centerEnd, - maxWidth: double.infinity, - child: IgnorePointer(child: child), - ), - ), - ?current, - ], - ), - child: child == null - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsetsDirectional.only(start: _fabGap), - child: ElasticButton( - child: DecoratedBox( - decoration: ShapeDecoration( - shape: AppShape.full, - shadows: _dockShadows(theme.colorScheme), - ), - child: Builder( - builder: (context) => Theme( - data: _dockedTheme(Theme.of(context)), - child: _DockedMarker(child: child), - ), - ), - ), - ), - ), - ), - ); - } -} - -class _SlotMaterialize extends StatelessWidget { - const _SlotMaterialize({required this.animation, required this.child}); - - final Animation animation; - final Widget child; - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: animation, - builder: (context, child) { - final t = animation.value; - final entering = animation.status != AnimationStatus.reverse; - final scale = entering - ? lerpDouble(_slotEnterScale, 1, _slotPopCurve.transform(t))! - : lerpDouble(_slotExitScale, 1, Curves.easeOutCubic.transform(t))!; - final reveal = entering - ? _slotRevealCurve.transform(t) - : Curves.easeOut.transform(t); - final blur = _slotBlur * (1 - reveal); - return Opacity( - opacity: reveal, - child: Transform.scale( - scale: scale, - child: ImageFiltered( - enabled: blur > 0.05, - imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), - child: child, - ), - ), - ); - }, - child: child, - ); - } -} - /// Grows under a press and stretches after a dragging finger, springing back /// on release, as the bar's lens does. /// /// The press only paints: layout, hit testing, and anything anchored to the /// child, such as a popup menu or a tooltip, see it at rest. class ElasticPress extends StatefulWidget { - const ElasticPress({super.key, this.enabled = true, required this.child}); + const ElasticPress({ + super.key, + this.enabled = true, + this.pullPastEdges = false, + this.maxGrowth = _maxPressGrowth, + this.maxPull, + this.panelOf, + required this.child, + }); /// Leaves a press on the button inside to the swell alone. static const buttonStyle = ButtonStyle( @@ -475,20 +440,67 @@ class ElasticPress extends StatefulWidget { ); final bool enabled; + final bool pullPastEdges; + final double maxGrowth; + final double? maxPull; + + /// Where the pressed panel lies in this box, when the box is larger. + final Rect Function(RenderBox box)? panelOf; + final Widget child; + static ElasticPressHandle? maybeOf(BuildContext context) => + context.findAncestorStateOfType<_ElasticPressState>(); + + /// Where [box] paints under the presses around it, in its own coordinates. + static Rect paintBoundsOf(RenderBox box) { + final bounds = Offset.zero & box.size; + var painted = Matrix4.identity(); + RenderObject from = box; + for (var node = box.parent; node != null; node = node.parent) { + if (node is _RenderPressTransform && node.hasSize) { + painted = node._transform.multiplied( + from.getTransformTo(node).multiplied(painted), + ); + from = node; + } + } + final rest = Matrix4.tryInvert(box.getTransformTo(from)); + if (identical(from, box) || rest == null) { + return bounds; + } + return MatrixUtils.transformRect(rest.multiplied(painted), bounds); + } + @override State createState() => _ElasticPressState(); } +/// Drives an [ElasticPress] with a press it does not hear, at global positions. +abstract interface class ElasticPressHandle { + void press(Offset position); + + void drag(Offset position); + + void release(); +} + class _ElasticPressState extends State - with TickerProviderStateMixin { + with TickerProviderStateMixin + implements ElasticPressHandle { late final _Spring _lift = _Spring(this, 0); late final _Spring _pullX = _Spring(this, 0); late final _Spring _pullY = _Spring(this, 0); late final Listenable _motion = Listenable.merge([_lift, _pullX, _pullY]); int? _pointer; Offset _origin = Offset.zero; + bool _releasing = false; + + @override + void initState() { + super.initState(); + _lift.addListener(_settle); + } @override void dispose() { @@ -498,6 +510,12 @@ class _ElasticPressState extends State super.dispose(); } + void _swell(Offset origin) { + _origin = origin; + _releasing = false; + _lift.springTo(1, _pressSpring); + } + void _handlePointerDown(PointerDownEvent event) { if (!widget.enabled || _pointer != null || @@ -505,8 +523,7 @@ class _ElasticPressState extends State return; } _pointer = event.pointer; - _origin = event.localPosition; - _lift.springTo(1, _liftSpring); + _swell(event.localPosition); } // A pointer's events keep reaching the hit-test path recorded at its down, @@ -515,8 +532,20 @@ class _ElasticPressState extends State if (!mounted || event.pointer != _pointer) { return; } - final limit = context.size!.shortestSide * _pullLimit; - final pull = event.localPosition - _origin; + _pullToward(event.localPosition); + } + + void _pullToward(Offset position) { + final box = context.findRenderObject()! as RenderBox; + final panel = widget.panelOf?.call(box) ?? Offset.zero & box.size; + final limit = widget.maxPull ?? panel.shortestSide * _pullLimit; + final pull = widget.pullPastEdges + ? position - + Offset( + position.dx.clamp(panel.left, panel.right), + position.dy.clamp(panel.top, panel.bottom), + ) + : position - _origin; _pullX.springTo(_rubberBand(pull.dx, limit), _trackSpring); _pullY.springTo(_rubberBand(pull.dy, limit), _trackSpring); } @@ -525,12 +554,44 @@ class _ElasticPressState extends State if (!mounted || event.pointer != _pointer) { return; } - _pointer = null; - _lift.springTo(0, _settleSpring); - _pullX.springTo(0, _settleSpring); - _pullY.springTo(0, _settleSpring); + _release(); } + void _release() { + _pointer = null; + _pullX.springTo(0, _pressSpring); + _pullY.springTo(0, _pressSpring); + _releasing = true; + _settle(); + } + + // A tap lets go before the swell has risen; it rises all the way first, as + // UIKit lifts the tab bar's lens all the way on a tap. + void _settle() { + if (!_releasing || _lift.value < _liftSnap) { + return; + } + _releasing = false; + _lift.springTo(0, _pressSpring); + } + + Offset _localOf(Offset position) => + (context.findRenderObject()! as RenderBox).globalToLocal(position); + + @override + void press(Offset position) { + if (!widget.enabled) { + return; + } + _swell(_localOf(position)); + } + + @override + void drag(Offset position) => _pullToward(_localOf(position)); + + @override + void release() => _release(); + @override Widget build(BuildContext context) { return Listener( @@ -543,6 +604,8 @@ class _ElasticPressState extends State builder: (_, child) => _PressTransform( lift: _lift.value, pull: Offset(_pullX.value, _pullY.value), + maxGrowth: widget.maxGrowth, + panelOf: widget.panelOf, child: child, ), child: widget.child, @@ -551,8 +614,8 @@ class _ElasticPressState extends State } } -/// Leaves a press on the filled button or FAB inside to [ElasticPress]; one -/// whose style sets a foreground color merges [ElasticPress.buttonStyle]. +/// Leaves a press on the filled button inside to [ElasticPress]; one whose +/// style sets a foreground color merges [ElasticPress.buttonStyle]. class ElasticButton extends StatelessWidget { const ElasticButton({super.key, this.enabled = true, required this.child}); @@ -593,14 +656,24 @@ class ElasticButton extends StatelessWidget { } class _PressTransform extends SingleChildRenderObjectWidget { - const _PressTransform({required this.lift, required this.pull, super.child}); + const _PressTransform({ + required this.lift, + required this.pull, + this.maxGrowth = _maxPressGrowth, + this.panelOf, + super.child, + }); final double lift; final Offset pull; + final double maxGrowth; + final Rect Function(RenderBox box)? panelOf; @override RenderObject createRenderObject(BuildContext context) { - return _RenderPressTransform(lift: lift, pull: pull); + return _RenderPressTransform(lift: lift, pull: pull) + ..maxGrowth = maxGrowth + ..panelOf = panelOf; } @override @@ -610,7 +683,9 @@ class _PressTransform extends SingleChildRenderObjectWidget { ) { renderObject ..lift = lift - ..pull = pull; + ..pull = pull + ..maxGrowth = maxGrowth + ..panelOf = panelOf; } } @@ -621,6 +696,8 @@ class _RenderPressTransform extends RenderProxyBox { double _lift; Offset _pull; + double maxGrowth = _maxPressGrowth; + Rect Function(RenderBox box)? panelOf; set lift(double value) { if (value == _lift) { @@ -639,12 +716,12 @@ class _RenderPressTransform extends RenderProxyBox { } Matrix4 get _transform { + final panel = panelOf?.call(this) ?? Offset.zero & size; final swell = - 1 + - _lift * math.min(_pressGrowth * 2, _maxPressGrowth / size.longestSide); - final scaleX = swell * (1 + _pull.dx.abs() / size.width * _pullStretch); - final scaleY = swell * (1 + _pull.dy.abs() / size.height * _pullStretch); - final center = size.center(Offset.zero); + 1 + _lift * math.min(_pressGrowth * 2, maxGrowth / panel.longestSide); + final scaleX = swell * (1 + _pull.dx.abs() / panel.width * _pullStretch); + final scaleY = swell * (1 + _pull.dy.abs() / panel.height * _pullStretch); + final center = panel.center; return Matrix4.diagonal3Values(scaleX, scaleY, 1)..setTranslationRaw( center.dx * (1 - scaleX) + _pull.dx, center.dy * (1 - scaleY) + _pull.dy, @@ -698,13 +775,23 @@ class _FloatingNavigationBarState extends State late final _Spring _hoverShow = _Spring(this, 0); late final _Spring _swell = _Spring(this, 0); late final _Spring _stretch = _Spring(this, 0); + late final _Spring _jelly = _Spring(this, 0); + late final _Spring _glow = _Spring(this, 0); + late final _Spring _glowSpread = _Spring(this, 0); + final ValueNotifier _touchGlow = ValueNotifier( + LiquidGlassGlow.none, + ); + Offset _glowAt = Offset.zero; late final Listenable _barMotion = Listenable.merge([_swell, _stretch]); - late final Listenable _motion = Listenable.merge([_lens, _lift]); + late final Listenable _motion = Listenable.merge([_lens, _lift, _jelly]); late final Listenable _hoverMotion = Listenable.merge([_hover, _hoverShow]); + final List<_Spring> _parallax = []; + bool _lifted = false; int? _pointer; int? _pressedIndex; double _pressX = 0; bool _dragging = false; + bool _dropping = false; VelocityTracker? _tracker; Offset? _cursor; final ValueNotifier _hoverAt = ValueNotifier(null); @@ -713,34 +800,128 @@ class _FloatingNavigationBarState extends State int get _selectedIndex => widget.selectedIndex.clamp(0, _lastIndex); + ({double pitch, double lens, double inset}) get _layout => _dockLayout( + context.size!.width - _barPadding * 2, + widget.destinations.length, + ); + + @override + void initState() { + super.initState(); + _lens + ..addListener(_wobble) + ..addListener(_dropLift); + _lift + ..addListener(_dropLift) + ..addListener(_noteLift); + _glow.addListener(_shine); + _glowSpread.addListener(_shine); + _hoverAt.addListener(_leanContent); + _syncParallax(); + } + @override void didUpdateWidget(covariant FloatingNavigationBar oldWidget) { super.didUpdateWidget(oldWidget); + _syncParallax(); + _leanContent(); final rest = _lensRest; if (_pressedIndex == null && _lens.target != rest) { - _lens.springTo(rest, _settleSpring); + _lens.springTo(rest, _travelSpring); } } @override void dispose() { + for (final parallax in _parallax) { + parallax.dispose(); + } _lens.dispose(); _lift.dispose(); _hover.dispose(); _hoverShow.dispose(); _swell.dispose(); _stretch.dispose(); + _jelly.dispose(); + _glow.dispose(); + _glowSpread.dispose(); + _touchGlow.dispose(); _hoverAt.dispose(); super.dispose(); } + void _wobble() { + final velocity = _lens.velocity; + final braking = math.max(0.0, -_lens.acceleration * velocity.sign); + final target = + _jellyStretch * _saturate(velocity.abs() / _jellySpeed) - + _jellySquash * _saturate(braking / _jellyBrake); + if (target != _jelly.target) { + _jelly.springTo(target, _jellySpring); + } + } + + // UIKit lights the bar from the finger, washing over the rest of it, and + // spreads the light out as it fades on release. + void _shine() { + final glow = _glow.value; + _touchGlow.value = LiquidGlassGlow( + center: _glowAt, + reach: _glowReach * (1 + _glowGrowth * _glowSpread.value), + strength: _glowPeak * glow * glow, + wash: _glowWash, + ); + } + + void _syncParallax() { + final count = widget.destinations.length; + while (_parallax.length > count) { + _parallax.removeLast().dispose(); + } + while (_parallax.length < count) { + _parallax.add(_Spring(this, 0)); + } + } + + void _leanContent() { + final hoverAt = _hoverAt.value; + for (final (index, parallax) in _parallax.indexed) { + final offset = hoverAt == null ? 0.0 : hoverAt - index; + final target = offset.abs() > 0.5 + ? 0.0 + : offset * _hoverMagnet * _hoverParallax; + if (target != parallax.target) { + parallax.springTo(target, _hoverSpring); + } + } + } + + void _noteLift() { + final lifted = _lift.value > 0; + if (lifted != _lifted) { + setState(() => _lifted = lifted); + } + } + + // UIKit keeps the lens lifted until it has risen all the way and arrived. + void _dropLift() { + if (!_dropping || _pointer != null || _lift.value < _liftSnap) { + return; + } + if ((_lens.target - _lens.value).abs() > _liftRelease) { + return; + } + _dropping = false; + _lift.springTo(0, _liftSpring); + } + double _positionAt(Offset localPosition) { final width = context.size!.width; final dx = Directionality.of(context) == TextDirection.ltr ? localPosition.dx : width - localPosition.dx; - final extent = (width - _barPadding * 2) / widget.destinations.length; - final position = (dx - _barPadding) / extent - 0.5; + final layout = _layout; + final position = (dx - _barPadding - layout.inset) / layout.pitch - 0.5; if (position < 0) { return _rubberBand(position, _overdrag); } @@ -793,10 +974,10 @@ class _FloatingNavigationBarState extends State _leanLens(); } - void _settleSwell() { + void _settleSwell(SpringDescription spring) { final target = _cursor == null ? 0.0 : _hoverSwell; if (_pointer == null && _swell.target != target) { - _swell.springTo(target, _settleSpring); + _swell.springTo(target, spring); } } @@ -808,7 +989,7 @@ class _FloatingNavigationBarState extends State } _cursor = event.localPosition; _showHover(); - _settleSwell(); + _settleSwell(_settleSpring); } void _handleExit(PointerExitEvent event) { @@ -816,7 +997,7 @@ class _FloatingNavigationBarState extends State _hoverShow.springTo(0, _fadeSpring); _hoverAt.value = null; _leanLens(); - _settleSwell(); + _settleSwell(_settleSpring); } void _handlePointerDown(PointerDownEvent event) { @@ -828,13 +1009,18 @@ class _FloatingNavigationBarState extends State ..addPosition(event.timeStamp, event.localPosition); _hoverShow.springTo(0, _fadeSpring); _hoverAt.value = null; - _swell.springTo(1, _liftSpring); + _swell.springTo(1, _pressSpring); + _glowAt = event.localPosition; + _glowSpread.jumpTo(0); + _glow.springTo(1, _glowInSpring); _press(event.localPosition); } void _handlePointerMove(PointerMoveEvent event) { if (event.pointer == _pointer) { _tracker?.addPosition(event.timeStamp, event.localPosition); + _glowAt = event.localPosition; + _shine(); _slide(event.localPosition); } } @@ -850,12 +1036,14 @@ class _FloatingNavigationBarState extends State _fling(tracker.getVelocity().pixelsPerSecond.dx); } _release(commit: event is PointerUpEvent); - _stretch.springTo(0, _settleSpring); + _glow.springTo(0, _glowOutSpring); + _glowSpread.springTo(1, _glowOutSpring); + _stretch.springTo(0, _pressSpring); if (_cursor != null) { _cursor = event.localPosition; _showHover(); } - _settleSwell(); + _settleSwell(_pressSpring); } void _press(Offset localPosition) { @@ -866,8 +1054,9 @@ class _FloatingNavigationBarState extends State _pressedIndex = index; _pressX = localPosition.dx; _dragging = false; + _dropping = false; _lift.springTo(1, _liftSpring); - _lens.springTo(index.toDouble(), _settleSpring); + _lens.springTo(index.toDouble(), _travelSpring); } void _slide(Offset localPosition) { @@ -888,7 +1077,7 @@ class _FloatingNavigationBarState extends State _trackSpring, ); final position = _positionAt(localPosition); - _lens.springTo(position, _trackSpring); + _lens.springTo(position, _followSpring); final index = _indexAt(position); if (index != _pressedIndex) { HapticFeedback.selectionClick(); @@ -903,8 +1092,7 @@ class _FloatingNavigationBarState extends State if (!_dragging || pressed == null) { return; } - final extent = - (context.size!.width - _barPadding * 2) / widget.destinations.length; + final extent = _layout.pitch; final direction = Directionality.of(context) == TextDirection.ltr ? 1 : -1; final lead = velocityX / @@ -929,13 +1117,10 @@ class _FloatingNavigationBarState extends State } _pressedIndex = null; _dragging = false; - _lift.springTo(0, _settleSpring); - if (!commit) { - _lens.springTo(_selectedIndex.toDouble(), _settleSpring); - return; - } - _lens.springTo(index.toDouble(), _settleSpring); - if (index != widget.selectedIndex) { + _dropping = true; + _lens.springTo((commit ? index : _selectedIndex).toDouble(), _travelSpring); + _dropLift(); + if (commit && index != widget.selectedIndex) { widget.onSelected(index); } } @@ -943,10 +1128,11 @@ class _FloatingNavigationBarState extends State @override Widget build(BuildContext context) { final colorScheme = context.colorScheme; - final labelStyle = context.textTheme.labelSmall?.copyWith( - fontSize: _labelSize, - fontWeight: FontWeight.w500, - letterSpacing: 0, + final labelStyle = AppTextStyle.custom( + context, + size: _labelSize, + leading: _labelLeading, + weight: FontWeight.w500, ); final labels = [ for (final destination in widget.destinations) @@ -960,13 +1146,18 @@ class _FloatingNavigationBarState extends State 0.0, (height, label) => math.max(height, label.height), ); - final bar = DecoratedBox( - decoration: ShapeDecoration( - shape: AppShape.full, - shadows: _dockShadows(colorScheme), - ), + final glass = LiquidGlassScope.of(context); + final ultra = + LiquidGlassScope.qualityOf(context) == LiquidGlassQuality.ultra; + final bar = _DockSurface( + glass: glass, + style: LiquidGlassStyle.pill(colorScheme), + shadows: ultra ? dockGlassShadows(colorScheme) : const [], + glow: _touchGlow, child: Material( - color: colorScheme.surfaceContainer, + color: glass + ? Colors.transparent + : SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme), shape: AppShape.full, // Raw pointers rather than recognizers: a drag has nothing to win the // arena from, and a tap waiting on one lifts the lens late. @@ -986,13 +1177,53 @@ class _FloatingNavigationBarState extends State child: RepaintBoundary( child: LayoutBuilder( builder: (context, constraints) { - final extent = - constraints.maxWidth / - math.max(1, widget.destinations.length); - final room = extent - _labelInset * 2; + final layout = _dockLayout( + constraints.maxWidth, + widget.destinations.length, + ); + final room = layout.pitch - _labelInset * 2; final labelScale = widest <= room ? 1.0 : math.max(room / widest, _minLabelSize / _labelSize); + Rect lensRect() => _lensRect( + position: _lens.value, + stretch: _jelly.value, + pitch: layout.pitch, + width: layout.lens, + height: constraints.maxHeight, + lift: _lift.value, + ).shift(Offset(layout.inset, 0)); + Widget items(_LensPart part) => Padding( + padding: EdgeInsets.symmetric(horizontal: layout.inset), + child: Row( + children: [ + for (final (index, destination) + in widget.destinations.indexed) + Expanded( + child: _FloatingBarItem( + destination: destination, + selected: index == _selectedIndex, + index: index, + part: part, + lens: _lens, + lensWidth: layout.lens, + jelly: _jelly, + parallax: _parallax[index], + lift: _lift, + labelStyle: labelStyle.copyWith( + fontSize: _labelSize * labelScale, + ), + labelHeight: lineHeight, + labelOverflows: + labels[index].width * labelScale > room, + onActivate: () => widget.onSelected(index), + ), + ), + ], + ), + ); + final lensed = + glass && _lifted && widget.destinations.isNotEmpty; return Stack( clipBehavior: Clip.none, children: [ @@ -1000,45 +1231,41 @@ class _FloatingNavigationBarState extends State AnimatedBuilder( animation: _motion, builder: (_, _) => _Lens( - position: _lens.value, - velocity: _lens.velocity, - extent: extent, - height: constraints.maxHeight, + rect: lensRect(), lift: _lift.value, + glass: glass, ), ), if (widget.destinations.isNotEmpty) AnimatedBuilder( animation: _hoverMotion, builder: (_, _) => _HoverHighlight( - position: _hover.value, - extent: extent, + start: + layout.inset + + (_hover.value + 0.5) * layout.pitch - + layout.lens / 2, + width: layout.lens, opacity: _hoverShow.value, ), ), - Row( - children: [ - for (final (index, destination) - in widget.destinations.indexed) - Expanded( - child: _FloatingBarItem( - destination: destination, - selected: index == _selectedIndex, - index: index, - lens: _lens, - hoverAt: _hoverAt, - lift: _lift, - labelStyle: labelStyle?.copyWith( - fontSize: _labelSize * labelScale, - ), - labelHeight: lineHeight, - labelOverflows: - labels[index].width * labelScale > room, - onActivate: () => widget.onSelected(index), - ), - ), - ], - ), + if (glass && widget.destinations.isNotEmpty) + AnimatedBuilder( + animation: _motion, + builder: (_, _) => + _GlassLens(rect: lensRect(), lift: _lift.value), + ), + items(lensed ? _LensPart.outside : _LensPart.both), + if (lensed) + AnimatedBuilder( + animation: _motion, + builder: (_, child) => _LensContent( + rect: lensRect(), + lift: _lift.value, + warp: ultra, + child: child!, + ), + child: items(_LensPart.inside), + ), ], ); }, @@ -1066,13 +1293,13 @@ class _FloatingNavigationBarState extends State /// under it follows the cursor half as far. class _HoverHighlight extends StatelessWidget { const _HoverHighlight({ - required this.position, - required this.extent, + required this.start, + required this.width, required this.opacity, }); - final double position; - final double extent; + final double start; + final double width; final double opacity; @override @@ -1083,10 +1310,10 @@ class _HoverHighlight extends StatelessWidget { } return Positioned.directional( textDirection: Directionality.of(context), - start: position * extent, + start: start, top: 0, bottom: 0, - width: extent, + width: width, child: DecoratedBox( decoration: ShapeDecoration( color: context.colorScheme.onSurface.withValues(alpha: alpha), @@ -1098,30 +1325,23 @@ class _HoverHighlight extends StatelessWidget { } class _Lens extends StatelessWidget { - const _Lens({ - required this.position, - required this.velocity, - required this.extent, - required this.height, - required this.lift, - }); + const _Lens({required this.rect, required this.lift, required this.glass}); - final double position; - final double velocity; - final double extent; - final double height; + final Rect rect; final double lift; + /// On glass the lens rests as a platter in the secondary hue, sunk in dark + /// mode as iOS's tab bar marks its selection, and clears as it lifts into + /// the [_GlassLens]. + final bool glass; + @override Widget build(BuildContext context) { final colorScheme = context.colorScheme; - final rect = _lensRect( - position: position, - velocity: velocity, - extent: extent, - height: height, - lift: lift, - ); + final lifted = lift.clamp(0.0, 1.0); + final tone = colorScheme.brightness == Brightness.dark ? 4.0 : 90.0; + final hct = Hct.fromInt(colorScheme.secondary.toARGB32())..tone = tone; + final platter = Color(hct.toInt()).withValues(alpha: 0.85); return PositionedDirectional( start: rect.left, top: rect.top, @@ -1129,12 +1349,14 @@ class _Lens extends StatelessWidget { height: rect.height, child: DecoratedBox( decoration: ShapeDecoration( - color: Color.alphaBlend( - colorScheme.onSecondaryContainer.withValues( - alpha: 0.08 * lift.clamp(0.0, 1.0), - ), - colorScheme.secondaryContainer, - ), + color: glass + ? platter.withValues(alpha: platter.a * (1 - lifted)) + : Color.alphaBlend( + colorScheme.onSurface.withValues(alpha: 0.08 * lifted), + SurfaceLevel.floatingOf( + colorScheme, + ).next.colorOf(colorScheme), + ), shape: AppShape.full, ), ), @@ -1142,13 +1364,144 @@ class _Lens extends StatelessWidget { } } +/// The clear lens a lifted selection becomes on glass. The destinations stand +/// on it, as iOS draws them over its lens, so it bends only the bar. +class _GlassLens extends StatelessWidget { + const _GlassLens({required this.rect, required this.lift}); + + final Rect rect; + final double lift; + + @override + Widget build(BuildContext context) { + if (lift <= 0) { + return const SizedBox.shrink(); + } + final colorScheme = context.colorScheme; + return Positioned.directional( + textDirection: Directionality.of(context), + start: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + child: IgnorePointer( + child: LiquidGlass( + style: LiquidGlassStyle.lens(colorScheme).scale(lift), + shadows: _lensShadows(colorScheme, lift), + ), + ), + ); + } +} + +List _lensShadows(ColorScheme colorScheme, double lift) { + final strength = colorScheme.brightness == Brightness.dark ? 3.0 : 1.0; + return [ + BoxShadow( + color: colorScheme.shadow.withValues( + alpha: 0.055 * strength * lift.clamp(0.0, 1.0), + ), + blurRadius: 5, + offset: const Offset(0, 8), + ), + ]; +} + +/// What a lifted lens on glass shows of the destinations under it, over the +/// [_GlassLens], with a pane of its own that warps only them: they are drawn +/// offscreen, so that pane reads them and not the bar. +class _LensContent extends StatelessWidget { + const _LensContent({ + required this.rect, + required this.lift, + required this.warp, + required this.child, + }); + + final Rect rect; + final double lift; + final bool warp; + final Widget child; + + @override + Widget build(BuildContext context) { + final textDirection = Directionality.of(context); + final clipper = _LensClip(rect, textDirection); + if (!warp) { + return Positioned.fill( + child: IgnorePointer( + child: ExcludeSemantics( + child: ClipPath(clipper: clipper, child: child), + ), + ), + ); + } + return Positioned.fill( + child: IgnorePointer( + child: ExcludeSemantics( + child: ClipPath( + clipper: clipper, + clipBehavior: Clip.antiAliasWithSaveLayer, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill(child: child), + Positioned.directional( + textDirection: textDirection, + start: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + child: LiquidGlass( + style: LiquidGlassStyle.lensContent( + context.colorScheme, + ).scale(lift), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _LensClip extends CustomClipper { + const _LensClip(this.rect, this.textDirection); + + final Rect rect; + final TextDirection textDirection; + + @override + Path getClip(Size size) => AppShape.full.getOuterPath( + textDirection == TextDirection.ltr + ? rect + : Rect.fromLTRB( + size.width - rect.right, + rect.top, + size.width - rect.left, + rect.bottom, + ), + ); + + @override + bool shouldReclip(_LensClip oldClipper) => + rect != oldClipper.rect || textDirection != oldClipper.textDirection; +} + +enum _LensPart { both, outside, inside } + class _FloatingBarItem extends StatefulWidget { const _FloatingBarItem({ required this.destination, required this.selected, required this.index, + required this.part, required this.lens, - required this.hoverAt, + required this.lensWidth, + required this.jelly, + required this.parallax, required this.lift, required this.labelStyle, required this.labelHeight, @@ -1159,10 +1512,14 @@ class _FloatingBarItem extends StatefulWidget { final NavigationDockDestination destination; final bool selected; final int index; - final _Spring lens; - /// Where the pointer hovers, in destinations from the first one's center. - final ValueListenable hoverAt; + /// Only [_LensPart.inside] is drawn bare: it stands over the lens, while + /// the destination it copies takes the presses and the focus. + final _LensPart part; + final _Spring lens; + final double lensWidth; + final ValueListenable jelly; + final ValueListenable parallax; final ValueListenable lift; final TextStyle? labelStyle; final double labelHeight; @@ -1173,8 +1530,7 @@ class _FloatingBarItem extends StatefulWidget { State<_FloatingBarItem> createState() => _FloatingBarItemState(); } -class _FloatingBarItemState extends State<_FloatingBarItem> - with SingleTickerProviderStateMixin { +class _FloatingBarItemState extends State<_FloatingBarItem> { late final Map> _actions = { ActivateIntent: CallbackAction( onInvoke: (_) { @@ -1183,53 +1539,33 @@ class _FloatingBarItemState extends State<_FloatingBarItem> }, ), }; - late final _Spring _parallax = _Spring(this, 0); bool _focused = false; - @override - void initState() { - super.initState(); - widget.hoverAt.addListener(_followPointer); - } - - @override - void didUpdateWidget(covariant _FloatingBarItem oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.hoverAt != widget.hoverAt) { - oldWidget.hoverAt.removeListener(_followPointer); - widget.hoverAt.addListener(_followPointer); - } - _followPointer(); - } - - @override - void dispose() { - widget.hoverAt.removeListener(_followPointer); - _parallax.dispose(); - super.dispose(); - } - - void _followPointer() { - final hoverAt = widget.hoverAt.value; - final offset = hoverAt == null ? 0.0 : hoverAt - widget.index; - final target = offset.abs() > 0.5 - ? 0.0 - : offset * _hoverMagnet * _hoverParallax; - if (target != _parallax.target) { - _parallax.springTo(target, _hoverSpring); - } - } - @override Widget build(BuildContext context) { final colorScheme = context.colorScheme; final color = colorScheme.onSurfaceVariant; + var tint = colorScheme.primary; + if (widget.part == _LensPart.inside && + LiquidGlassScope.qualityOf(context) == LiquidGlassQuality.standard) { + // Without a pane of its own the copy takes the light that one adds. + final brightness = LiquidGlassStyle.lensContent(colorScheme).brightness; + tint = Color.from( + alpha: tint.a, + red: math.min(tint.r * brightness, 1), + green: math.min(tint.g * brightness, 1), + blue: math.min(tint.b * brightness, 1), + ); + } final content = _LensTint( + part: widget.part, lens: widget.lens, + lensWidth: widget.lensWidth, + jelly: widget.jelly, lift: widget.lift, - parallax: _parallax, + parallax: widget.parallax, index: widget.index, - color: colorScheme.primary, + color: tint, textDirection: Directionality.of(context), child: Padding( padding: const EdgeInsets.symmetric(horizontal: _labelInset), @@ -1259,6 +1595,9 @@ class _FloatingBarItemState extends State<_FloatingBarItem> ), ), ); + if (widget.part == _LensPart.inside) { + return content; + } return FocusableActionDetector( actions: _actions, mouseCursor: SystemMouseCursors.click, @@ -1276,9 +1615,7 @@ class _FloatingBarItemState extends State<_FloatingBarItem> ? colorScheme.onSurface.withValues(alpha: 0.1) : Colors.transparent, shape: AppShape.full.copyWith( - side: _focused - ? BorderSide(color: colorScheme.secondary, width: 2) - : BorderSide.none, + side: _focused ? focusRingSide(colorScheme) : BorderSide.none, ), ), child: widget.labelOverflows @@ -1290,13 +1627,16 @@ class _FloatingBarItemState extends State<_FloatingBarItem> } } -/// Paints what lies under the lens in [color] and the rest as it is, cut to -/// the lens's own shape so a destination it passes over turns part by part. -/// The content swells under a lifted lens and leans after the [parallax], in +/// Paints what lies under the lens in [color], magnified as the lens lifts, +/// and the rest as it is, cut to the lens's own shape so a destination it +/// passes over turns part by part. The content leans after the [parallax], in /// destinations, while layout and hit testing see it at rest. class _LensTint extends SingleChildRenderObjectWidget { const _LensTint({ + required this.part, required this.lens, + required this.lensWidth, + required this.jelly, required this.lift, required this.parallax, required this.index, @@ -1305,7 +1645,10 @@ class _LensTint extends SingleChildRenderObjectWidget { super.child, }); + final _LensPart part; final _Spring lens; + final double lensWidth; + final ValueListenable jelly; final ValueListenable lift; final ValueListenable parallax; final int index; @@ -1315,7 +1658,10 @@ class _LensTint extends SingleChildRenderObjectWidget { @override RenderObject createRenderObject(BuildContext context) { return _RenderLensTint( + part: part, lens: lens, + lensWidth: lensWidth, + jelly: jelly, lift: lift, parallax: parallax, index: index, @@ -1327,7 +1673,10 @@ class _LensTint extends SingleChildRenderObjectWidget { @override void updateRenderObject(BuildContext context, _RenderLensTint renderObject) { renderObject + ..part = part ..lens = lens + ..lensWidth = lensWidth + ..jelly = jelly ..lift = lift ..parallax = parallax ..index = index @@ -1338,13 +1687,19 @@ class _LensTint extends SingleChildRenderObjectWidget { class _RenderLensTint extends RenderProxyBox { _RenderLensTint({ + required _LensPart part, required _Spring lens, + required double lensWidth, + required ValueListenable jelly, required ValueListenable lift, required ValueListenable parallax, required int index, required Color color, required TextDirection textDirection, - }) : _lens = lens, + }) : _part = part, + _lens = lens, + _lensWidth = lensWidth, + _jelly = jelly, _lift = lift, _parallax = parallax, _index = index, @@ -1357,6 +1712,15 @@ class _RenderLensTint extends RenderProxyBox { final _outsideMotion = LayerHandle(); final _insideMotion = LayerHandle(); + _LensPart _part; + set part(_LensPart value) { + if (value == _part) { + return; + } + _part = value; + markNeedsPaint(); + } + _Spring _lens; set lens(_Spring value) { if (value == _lens) { @@ -1370,6 +1734,28 @@ class _RenderLensTint extends RenderProxyBox { markNeedsPaint(); } + double _lensWidth; + set lensWidth(double value) { + if (value == _lensWidth) { + return; + } + _lensWidth = value; + markNeedsPaint(); + } + + ValueListenable _jelly; + set jelly(ValueListenable value) { + if (value == _jelly) { + return; + } + if (attached) { + _jelly.removeListener(markNeedsPaint); + value.addListener(markNeedsPaint); + } + _jelly = value; + markNeedsPaint(); + } + ValueListenable _lift; set lift(ValueListenable value) { if (value == _lift) { @@ -1427,6 +1813,7 @@ class _RenderLensTint extends RenderProxyBox { void attach(PipelineOwner owner) { super.attach(owner); _lens.addListener(markNeedsPaint); + _jelly.addListener(markNeedsPaint); _lift.addListener(markNeedsPaint); _parallax.addListener(markNeedsPaint); } @@ -1434,6 +1821,7 @@ class _RenderLensTint extends RenderProxyBox { @override void detach() { _lens.removeListener(markNeedsPaint); + _jelly.removeListener(markNeedsPaint); _lift.removeListener(markNeedsPaint); _parallax.removeListener(markNeedsPaint); super.detach(); @@ -1452,8 +1840,9 @@ class _RenderLensTint extends RenderProxyBox { Rect get _lensBounds { final rect = _lensRect( position: _lens.value - _index, - velocity: _lens.velocity, - extent: size.width, + stretch: _jelly.value, + pitch: size.width, + width: _lensWidth, height: size.height, lift: _lift.value, ); @@ -1467,9 +1856,7 @@ class _RenderLensTint extends RenderProxyBox { ); } - Matrix4? get _motion { - final emphasis = (1 - (_lens.value - _index).abs()).clamp(0.0, 1.0); - final scale = 1 + _lensMagnify * emphasis * _lift.value; + Matrix4? _motion(double scale) { final direction = _textDirection == TextDirection.ltr ? 1 : -1; final shift = _parallax.value * size.width * direction; if (scale == 1 && shift == 0) { @@ -1485,7 +1872,7 @@ class _RenderLensTint extends RenderProxyBox { @override void applyPaintTransform(RenderBox child, Matrix4 transform) { - final motion = _motion; + final motion = _motion(1); if (motion != null) { transform.multiply(motion); } @@ -1516,43 +1903,67 @@ class _RenderLensTint extends RenderProxyBox { if (child == null) { return; } - final motion = _motion; + final rest = _motion(1); + final magnified = _motion(1 + _lensMagnify * _lift.value); final bounds = Offset.zero & size; - final content = motion == null - ? bounds - : MatrixUtils.transformRect(motion, bounds); + Rect moved(Matrix4? motion) => + motion == null ? bounds : MatrixUtils.transformRect(motion, bounds); final lensBounds = _lensBounds; - // Touching edges do not overlap, so a lens at rest on a neighbour leaves - // this destination unclipped. - if (!lensBounds.overlaps(content)) { + // The lens rests [_barPadding] past its destination, over the edges of + // the neighbours where nothing is drawn. + final reached = Rect.fromLTRB( + lensBounds.left + _barPadding, + lensBounds.top, + lensBounds.right - _barPadding, + lensBounds.bottom, + ); + final outside = _part != _LensPart.inside; + final inside = _part != _LensPart.outside; + if (!reached.overlaps(moved(rest)) && !reached.overlaps(moved(magnified))) { _outside.layer = null; _inside.layer = null; _tint.layer = null; _insideMotion.layer = null; - _paintMoved(context, offset, motion, _outsideMotion); + if (outside) { + _paintMoved(context, offset, rest, _outsideMotion); + } else { + _outsideMotion.layer = null; + } return; } - final reach = content.expandToInclude(bounds).inflate(_labelInset); + final reach = moved(rest).expandToInclude(bounds).inflate(_labelInset); final lens = AppShape.full.getOuterPath(lensBounds); - _outside.layer = context.pushClipPath( - needsCompositing, - offset, - reach, - Path() - ..fillType = PathFillType.evenOdd - ..addRect(reach) - ..addPath(lens, Offset.zero), - (context, offset) => _paintMoved(context, offset, motion, _outsideMotion), - oldLayer: _outside.layer, - ); - _inside.layer = context.pushClipPath( - needsCompositing, - offset, - lensBounds, - lens, - (context, offset) => _paintTinted(context, offset, lensBounds, motion), - oldLayer: _inside.layer, - ); + if (outside) { + _outside.layer = context.pushClipPath( + needsCompositing, + offset, + reach, + Path() + ..fillType = PathFillType.evenOdd + ..addRect(reach) + ..addPath(lens, Offset.zero), + (context, offset) => _paintMoved(context, offset, rest, _outsideMotion), + oldLayer: _outside.layer, + ); + } else { + _outside.layer = null; + _outsideMotion.layer = null; + } + if (inside) { + _inside.layer = context.pushClipPath( + needsCompositing, + offset, + lensBounds, + lens, + (context, offset) => + _paintTinted(context, offset, lensBounds, magnified), + oldLayer: _inside.layer, + ); + } else { + _inside.layer = null; + _tint.layer = null; + _insideMotion.layer = null; + } } void _paintTinted( diff --git a/lib/ui/pop_scope.dart b/lib/ui/pop_scope.dart index 8521648e..a1e2f218 100644 --- a/lib/ui/pop_scope.dart +++ b/lib/ui/pop_scope.dart @@ -31,6 +31,12 @@ class CommonPopScope extends StatelessWidget { if (didPop) { return; } + // BackLayerScope adds its entry a frame after this builds, which + // rebuilds nothing here on a route with another below it. + if (route?.willHandlePopInternally == true) { + Navigator.of(context).pop(); + return; + } final res = await onPop!(context); if (!context.mounted) { return; diff --git a/lib/ui/popup.dart b/lib/ui/popup.dart new file mode 100644 index 00000000..3b775cbf --- /dev/null +++ b/lib/ui/popup.dart @@ -0,0 +1,2703 @@ +import 'dart:async' show Timer, scheduleMicrotask, unawaited; +import 'dart:math' as math; +import 'dart:ui' as ui show Image; +import 'dart:ui' show ImageFilter, lerpDouble; + +import 'package:fl_clash/ui/color.dart'; +import 'package:fl_clash/ui/context.dart'; +import 'package:fl_clash/ui/icons/icons.dart'; +import 'package:fl_clash/ui/metrics.dart'; +import 'package:fl_clash/ui/motion.dart'; +import 'package:fl_clash/ui/shape.dart'; +import 'package:fl_clash/ui/surface.dart'; +import 'package:fl_clash/ui/typography.dart'; +import 'package:flutter/foundation.dart' show ValueListenable, listEquals; +import 'package:flutter/gestures.dart'; +import 'package:flutter/physics.dart' show SpringSimulation; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart' show HapticFeedback; +import 'package:material_ui/material_ui.dart'; + +import 'liquid_glass.dart'; +import 'navigation_dock.dart' show ElasticPress, ElasticPressHandle; +import 'text.dart'; + +typedef PopupAnchorResolver = Rect? Function(); + +typedef PopupOpen = void Function(); + +enum PopupPlacement { overAnchorEnd, overAnchorStart, belowPoint } + +const _screenMargin = 16.0; + +const _anchorOverlap = 8.0; + +const _avoidGap = 8.0; + +const _cardInset = 8.0; + +const _itemRadius = AppCorner.md; + +const _cardRadius = _itemRadius + _cardInset; + +const _itemIconSize = 20.0; +const _submenuArrowSize = 16.0; + +const _itemPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 12); + +const _itemArrowPadding = EdgeInsets.only( + left: 12, + top: 12, + bottom: 12, + right: 8, +); + +const _dividerHeight = _cardInset * 2 + 1; + +// Soft and wide like HyperOS menus rather than a Material elevation. +List _cardShadows(ColorScheme colorScheme, [double depth = 1]) { + final strength = colorScheme.brightness == Brightness.dark ? 3.0 : 1.0; + final alpha = strength * depth; + return [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.05 * alpha), + blurRadius: 10, + offset: const Offset(0, 2), + ), + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.04 * alpha), + blurRadius: 30, + offset: const Offset(0, 4), + ), + ]; +} + +const _maxContentBlur = 8.0; + +const _overshootPerLongestSide = 0.1; + +const _openDuration = Duration(milliseconds: 750); + +const _dismissDuration = Duration(milliseconds: 300); + +const _shrinkDuration = Duration(milliseconds: 200); + +/// How far a popup with no control to return into shrinks toward its anchor +/// as it fades, as an iOS context menu does. +const _dismissScale = 0.8; + +double _secondsOf(Duration duration) => + duration.inMicroseconds / Duration.microsecondsPerSecond; + +final _openSeconds = _secondsOf(_openDuration); + +final _dismissSeconds = _secondsOf(_dismissDuration); + +const _contentOut = Interval(0, 0.5, curve: Curves.easeOut); + +final _returnSpring = SpringCurve( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 260), + ), + seconds: _dismissSeconds, +); + +final _returnXSpring = SpringCurve( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 220), + ), + seconds: _dismissSeconds, +); + +final _returnYSpring = SpringCurve( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 290), + ), + seconds: _dismissSeconds, +); + +final _growSpring = SpringCurve( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 500), + bounce: 0.3, + ), + seconds: _openSeconds, +); + +final _moveYSpring = SpringCurve( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 340), + bounce: 0.12, + ), + seconds: _openSeconds, +); + +final _moveXSpring = SpringCurve( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 520), + bounce: 0.2, + ), + seconds: _openSeconds, +); + +const _dwellDuration = Duration(milliseconds: 400); + +final _unfoldSpring = SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 420), + bounce: 0.15, +); + +final _foldSpring = SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 340), +); + +class CommonPopupRoute extends PopupRoute { + CommonPopupRoute({ + required this.builder, + required this.anchorOf, + required this.barrierLabel, + this.placement = PopupPlacement.overAnchorEnd, + this.elevated = false, + this.sourceColor = Colors.transparent, + this.sourceImage, + this.modal = true, + this.avoid, + }) : super(requestFocus: modal ? null : false); + + final WidgetBuilder builder; + + /// Resolves the rect of the control the popup grows out of. + final PopupAnchorResolver anchorOf; + final PopupPlacement placement; + + /// Whether the popup opens from a sheet's [ElevatedSurfaceTheme], which + /// lies below the navigator the popup is pushed on. + final bool elevated; + + /// The background of the source control, which the platter starts from. + final Color sourceColor; + + /// A snapshot of the source control, drawn inside the platter while the + /// control itself is hidden; null while it is still being taken. + final ValueListenable? sourceImage; + + /// Off, focus stays on the page and touches outside pass through to it. + final bool modal; + + /// A rect the popup keeps clear of when there is room above or below it. + final Rect? avoid; + + @override + final String? barrierLabel; + + @override + Color? get barrierColor => null; + + @override + bool get barrierDismissible => true; + + @override + Duration get transitionDuration => _openDuration; + + @override + Duration get reverseTransitionDuration => + sourceImage == null ? _shrinkDuration : _dismissDuration; + + late final CurvedAnimation _grow = CurvedAnimation( + parent: animation!, + curve: _growSpring, + ); + + late final CurvedAnimation _moveX = CurvedAnimation( + parent: animation!, + curve: _moveXSpring, + ); + + late final CurvedAnimation _moveY = CurvedAnimation( + parent: animation!, + curve: _moveYSpring, + ); + + late final CurvedAnimation _content = CurvedAnimation( + parent: animation!, + curve: const Interval(0.04, 0.45, curve: Curves.easeOutCubic), + ); + + final _unfolded = ValueNotifier(false); + + /// Whether the platter shows rather than the cards, which on glass stand + /// bare meanwhile so that one pane, the platter's, bends what lies behind. + final _platter = ValueNotifier(true); + + final _pin = _PopupPin(); + + final _press = _CarriedPress(); + + @override + void install() { + super.install(); + animation! + ..addListener(_updatePlatter) + ..addStatusListener(_handleStatusChanged); + _unfolded.addListener(_updatePlatter); + } + + void _handleStatusChanged(AnimationStatus status) => _updatePlatter(); + + void _updatePlatter() { + final animation = this.animation!; + // A popup with a control to return into does so on the platter; one + // without dissolves as it stood when it began to close. + if (animation.status == AnimationStatus.reverse) { + if (sourceImage != null) { + _platter.value = true; + } + return; + } + _platter.value = + !animation.isCompleted && _content.value < 1 && !_unfolded.value; + } + + @override + void dispose() { + animation + ?..removeListener(_updatePlatter) + ..removeStatusListener(_handleStatusChanged); + _grow.dispose(); + _moveX.dispose(); + _moveY.dispose(); + _content.dispose(); + _unfolded.dispose(); + _platter.dispose(); + super.dispose(); + } + + void _handleDismiss() { + if (isCurrent) { + navigator?.pop(); + } + } + + /// A popup that returns into its control runs a picked item once landed, + /// so nothing the item rebuilds changes under the returning platter. + void _runPicked(VoidCallback action) { + if (sourceImage == null) { + action(); + return; + } + unawaited(completed.whenComplete(action)); + } + + @override + Widget buildModalBarrier() { + return modal ? super.buildModalBarrier() : const SizedBox.shrink(); + } + + Widget _buildDismissLayer() { + if (modal) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: _handleDismiss, + ); + } + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _handleDismiss(), + onPointerPanZoomStart: (_) => _handleDismiss(), + onPointerSignal: (_) => _handleDismiss(), + ); + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return SurfaceScope( + level: SurfaceLevel.floatingOf(context.colorScheme), + child: _PopupScope( + platter: _platter, + press: _press, + runPicked: _runPicked, + child: builder(context), + ), + ); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (elevated) { + return ElevatedSurfaceTheme( + child: Builder( + builder: (context) => _buildPopup(context, animation, child), + ), + ); + } + return _buildPopup(context, animation, child); + } + + Widget _buildPopup( + BuildContext context, + Animation animation, + Widget child, + ) { + final colorScheme = context.colorScheme; + final glass = LiquidGlassScope.of(context); + return Stack( + children: [ + Positioned.fill(child: _buildDismissLayer()), + _PopupAnchorTracker( + anchorOf: anchorOf, + builder: (anchor, safeInsets, child) => CustomSingleChildLayout( + delegate: _PopupLayoutDelegate( + anchor: anchor, + safeInsets: safeInsets, + placement: placement, + avoid: avoid, + pin: _pin, + ), + child: _PopupMorph( + anchor: anchor, + animation: animation, + grow: _grow, + moveX: _moveX, + moveY: _moveY, + content: _content, + platter: _platter, + sourceColor: sourceColor, + sourceImage: sourceImage, + surfaceColor: SurfaceLevel.floatingOf( + colorScheme, + ).colorOf(colorScheme), + shadows: glass + ? glassMenuShadows(colorScheme) + : _cardShadows(colorScheme), + glass: glass + ? ( + from: LiquidGlassStyle.regular(colorScheme), + to: LiquidGlassStyle.menu(colorScheme), + rim: LiquidGlassRim.menu(colorScheme), + ) + : null, + child: child, + ), + ), + child: NotificationListener<_PopupUnfoldNotification>( + onNotification: (_) { + _unfolded.value = true; + return true; + }, + child: child, + ), + ), + ], + ); + } +} + +class _PopupUnfoldNotification extends Notification { + const _PopupUnfoldNotification(); +} + +typedef _PlatterGlass = ({ + LiquidGlassStyle from, + LiquidGlassStyle to, + LiquidGlassRim rim, +}); + +/// The press that opened a popup on its control's pointer down. Its events +/// keep reaching the control, which hands them on for the menu to follow. +class _CarriedPress { + ValueSetter? onMove; + ValueSetter? onRelease; + VoidCallback? onCancel; +} + +class _PopupScope extends InheritedWidget { + const _PopupScope({ + required this.platter, + required this.press, + required this.runPicked, + required super.child, + }); + + final ValueListenable platter; + final _CarriedPress press; + final ValueSetter runPicked; + + static _PopupScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_PopupScope>(); + + @override + bool updateShouldNotify(_PopupScope oldWidget) => + platter != oldWidget.platter || + press != oldWidget.press || + runPicked != oldWidget.runPicked; +} + +class _PopupMorph extends SingleChildRenderObjectWidget { + const _PopupMorph({ + required this.anchor, + required this.animation, + required this.grow, + required this.moveX, + required this.moveY, + required this.content, + required this.platter, + required this.sourceColor, + required this.sourceImage, + required this.surfaceColor, + required this.shadows, + required this.glass, + super.child, + }); + + final Rect anchor; + final Animation animation; + final Animation grow; + final Animation moveX; + final Animation moveY; + final Animation content; + final ValueListenable platter; + final Color sourceColor; + final ValueListenable? sourceImage; + final Color surfaceColor; + final List shadows; + + /// The platter's glass as it leaves the anchor and as it lands on the card, + /// or null for a [surfaceColor] fill. + final _PlatterGlass? glass; + + @override + _RenderPopupMorph createRenderObject(BuildContext context) { + return _RenderPopupMorph( + anchor: anchor, + animation: animation, + grow: grow, + moveX: moveX, + moveY: moveY, + content: content, + platter: platter, + sourceColor: sourceColor, + sourceImage: sourceImage, + surfaceColor: surfaceColor, + shadows: shadows, + glass: glass, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderPopupMorph renderObject, + ) { + renderObject + ..anchor = anchor + ..animation = animation + ..grow = grow + ..moveX = moveX + ..moveY = moveY + ..content = content + ..platter = platter + ..sourceColor = sourceColor + ..sourceImage = sourceImage + ..surfaceColor = surfaceColor + ..shadows = shadows + ..glass = glass; + } +} + +typedef _MorphFrame = ({ + double grow, + double moveX, + double moveY, + double reveal, + bool platter, +}); + +/// Grows the popup out of its anchor the way iOS 26 menus leave their button: +/// a platter flies from the anchor's capsule to the card's center on an arc, +/// growing on its own spring, while the content rides inside it, scaling, +/// fading and sharpening in. Closing returns it into its control the same way, +/// the content blurring out first; with no control to return into, the popup +/// shrinks toward its anchor as it fades. +class _RenderPopupMorph extends RenderProxyBox { + _RenderPopupMorph({ + required Rect anchor, + required Animation animation, + required this.grow, + required this.moveX, + required this.moveY, + required this.content, + required ValueListenable platter, + required Color sourceColor, + required ValueListenable? sourceImage, + required Color surfaceColor, + required List shadows, + required _PlatterGlass? glass, + }) : _anchor = anchor, + _animation = animation, + _platter = platter, + _sourceColor = sourceColor, + _sourceImage = sourceImage, + _surfaceColor = surfaceColor, + _shadows = shadows, + _glass = glass; + + late final _pane = LiquidGlassPane(markNeedsPaint); + + Animation grow; + Animation moveX; + Animation moveY; + Animation content; + + Rect _anchor; + + set anchor(Rect value) { + if (value == _anchor) { + return; + } + _anchor = value; + markNeedsPaint(); + } + + Animation _animation; + + set animation(Animation value) { + if (value == _animation) { + return; + } + if (attached) { + _animation + ..removeListener(markNeedsPaint) + ..removeStatusListener(_handleStatusChanged); + value + ..addListener(markNeedsPaint) + ..addStatusListener(_handleStatusChanged); + } + _animation = value; + markNeedsPaint(); + } + + _MorphFrame? _closingFrame; + double _closingFrom = 1; + + void _handleStatusChanged(AnimationStatus status) { + if (status != AnimationStatus.reverse) { + return; + } + _closingFrom = _animation.value; + _closingFrame = _frame(); + } + + /// Where the opening stands, or null once it has settled. + _MorphFrame? _frame() { + if (_animation.value >= 1) { + return null; + } + return ( + grow: grow.value, + moveX: moveX.value, + moveY: moveY.value, + reveal: content.value, + platter: _platter.value, + ); + } + + ValueListenable _platter; + + set platter(ValueListenable value) { + if (value == _platter) { + return; + } + if (attached) { + _platter.removeListener(markNeedsPaint); + value.addListener(markNeedsPaint); + } + _platter = value; + markNeedsPaint(); + } + + Color _sourceColor; + + set sourceColor(Color value) { + if (value == _sourceColor) { + return; + } + _sourceColor = value; + markNeedsPaint(); + } + + ValueListenable? _sourceImage; + + set sourceImage(ValueListenable? value) { + if (value == _sourceImage) { + return; + } + if (attached) { + _sourceImage?.removeListener(markNeedsPaint); + value?.addListener(markNeedsPaint); + } + _sourceImage = value; + markNeedsPaint(); + } + + Color _surfaceColor; + + set surfaceColor(Color value) { + if (value == _surfaceColor) { + return; + } + _surfaceColor = value; + markNeedsPaint(); + } + + List _shadows; + + set shadows(List value) { + if (listEquals(value, _shadows)) { + return; + } + _shadows = value; + markNeedsPaint(); + } + + _PlatterGlass? _glass; + + set glass(_PlatterGlass? value) { + if (value == _glass) { + return; + } + _glass = value; + markNeedsPaint(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _animation + ..addListener(markNeedsPaint) + ..addStatusListener(_handleStatusChanged); + _platter.addListener(markNeedsPaint); + _sourceImage?.addListener(markNeedsPaint); + } + + @override + void detach() { + _animation + ..removeListener(markNeedsPaint) + ..removeStatusListener(_handleStatusChanged); + _platter.removeListener(markNeedsPaint); + _sourceImage?.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void dispose() { + _pane.dispose(); + super.dispose(); + } + + @override + bool get alwaysNeedsCompositing => child != null; + + /// The anchor in this box's coordinates; it comes in those of the layout + /// box that places this one. + Rect get _localAnchor => + _anchor.shift(-(parentData! as BoxParentData).offset); + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) { + return; + } + if (_animation.status != AnimationStatus.reverse) { + _paintFrame(context, offset, _frame()); + return; + } + final frame = _closingFrame; + final from = _closingFrom; + final dismissal = from <= 0 + ? 1.0 + : (1 - _animation.value / from).clamp(0.0, 1.0); + if (_sourceImage != null) { + final start = + frame ?? + (grow: 1.0, moveX: 1.0, moveY: 1.0, reveal: 1.0, platter: true); + _paintFrame(context, offset, ( + grow: start.grow * (1 - _returnSpring.transform(dismissal)), + moveX: start.moveX * (1 - _returnXSpring.transform(dismissal)), + moveY: start.moveY * (1 - _returnYSpring.transform(dismissal)), + reveal: start.reveal * (1 - _contentOut.transform(dismissal)), + platter: true, + ), returning: true); + return; + } + final shrink = Curves.easeOut.transform(dismissal); + final scale = lerpDouble(1, _dismissScale, shrink)!; + final anchor = _localAnchor.center; + final origin = Offset( + anchor.dx.clamp(0.0, size.width), + anchor.dy.clamp(0.0, size.height), + ); + final transform = Matrix4.translationValues(origin.dx, origin.dy, 0) + ..scaleByDouble(scale, scale, 1, 1) + ..translateByDouble(-origin.dx, -origin.dy, 0, 1); + context.pushTransform( + needsCompositing, + offset, + transform, + (context, offset) => LiquidGlassFade.push( + context, + offset, + Color.getAlphaFromOpacity(1 - shrink), + (context, offset) => _paintFrame(context, offset, frame), + ), + ); + } + + void _paintFrame( + PaintingContext context, + Offset offset, + _MorphFrame? frame, { + bool returning = false, + }) { + final child = this.child!; + if (frame == null) { + _pane.clear(); + context.paintChild(child, offset); + return; + } + final from = _localAnchor; + final bounds = Offset.zero & size; + final progress = frame.grow; + final extent = Size.lerp(from.size, bounds.size, progress)!; + final rect = Rect.fromCenter( + center: Offset( + lerpDouble(from.center.dx, bounds.center.dx, frame.moveX)!, + lerpDouble(from.center.dy, bounds.center.dy, frame.moveY)!, + ), + width: math.max(0.0, extent.width), + height: math.max(0.0, extent.height), + ); + final radius = lerpDouble(from.shortestSide / 2, _cardRadius, progress)!; + final clip = AppShape.all(math.max(0.0, radius)).getOuterPath(rect); + final sourceImage = _sourceImage; + final image = sourceImage?.value; + // The source control stays visible until its snapshot exists, so the + // platter would only cover it. + if (sourceImage != null && image == null) { + return; + } + final reveal = frame.reveal; + // The card paints the same surface and shadow as the platter, so once it + // is revealed, or a sub menu leaves the root's bounds, the platter steps + // aside instead of clipping the levels until the spring settles. + if (!frame.platter && !size.isEmpty) { + _pane.clear(); + context.pushTransform( + needsCompositing, + offset, + _contentTransform(rect), + (context, offset) => context.paintChild(child, offset), + ); + return; + } + final glass = _glass; + final settled = progress.clamp(0.0, 1.0); + final rim = glass?.rim.scale(settled); + // Returning cards keep their own fill and shadow as they fade into the + // platter's; on glass they stand bare and the platter holds throughout. + final platter = returning && glass == null ? 1 - reveal : 1.0; + final path = clip.shift(offset); + if (glass != null) { + // Frosted as the card and shadowed after, so one capture serves the morph. + _pane.paint( + context, + rect.shift(offset), + shape: LiquidGlassCorners.circular(math.max(0.0, radius)), + style: LiquidGlassStyle.lerp( + glass.from, + glass.to, + settled, + ).copyWith(blur: glass.to.blur), + hold: bounds + .expandToInclude(from) + .inflate(size.longestSide * _overshootPerLongestSide) + .shift(offset), + rim: rim, + ); + } + final canvas = context.canvas; + final reach = _shadows.fold( + 0.0, + (reach, shadow) => + math.max(reach, shadow.blurSigma * 3 + shadow.offset.distance), + ); + // The blur shades under the platter too; keep that out of fading content. + canvas + ..save() + ..clipPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRect(path.getBounds().inflate(reach)) + ..addPath(path, Offset.zero), + ); + final corners = AppRadius.all(math.max(0.0, radius)); + for (final shadow in _shadows) { + canvas.drawRSuperellipse( + corners.toRSuperellipse(rect.shift(offset + shadow.offset)), + shadow.toPaint() + ..color = shadow.color.withValues( + alpha: shadow.color.a * settled * platter, + ), + ); + } + canvas.restore(); + final Color fill; + if (glass == null) { + final surface = Color.lerp(_sourceColor, _surfaceColor, settled)!; + fill = surface.withValues(alpha: surface.a * platter); + } else { + fill = _sourceColor.withValues(alpha: _sourceColor.a * (1 - settled)); + } + canvas.drawPath(path, Paint()..color = fill); + rim?.paintLine(context.canvas, rect.shift(offset), math.max(0.0, radius)); + final glyph = 1 - reveal; + if (image != null && glyph > 0) { + final source = from.shift(rect.center - from.center + offset); + context.canvas.drawImageRect( + image, + Offset.zero & Size(image.width.toDouble(), image.height.toDouble()), + source, + Paint() + ..color = Color.fromRGBO(0, 0, 0, glyph) + ..filterQuality = FilterQuality.medium, + ); + } + if (reveal <= 0 || size.isEmpty) { + return; + } + // Opening, the content fills the platter and is cut to it; returning, it + // shrinks whole inside it. + final transform = _contentTransform(rect, fit: returning); + final sigma = _maxContentBlur * (1 - reveal); + void paintContent(PaintingContext context, Offset offset) => + context.pushOpacity( + offset, + Color.getAlphaFromOpacity(reveal), + (context, offset) => context.pushLayer( + ImageFilterLayer( + imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + ), + (context, offset) => context.pushTransform( + needsCompositing, + offset, + transform, + (context, offset) => context.paintChild(child, offset), + ), + offset, + ), + ); + if (returning) { + paintContent(context, offset); + return; + } + context.pushClipPath(needsCompositing, offset, bounds, clip, paintContent); + } + + Matrix4 _contentTransform(Rect rect, {bool fit = false}) { + final scale = (fit ? math.min : math.max)( + rect.width / size.width, + rect.height / size.height, + ); + return Matrix4.translationValues(rect.center.dx, rect.center.dy, 0) + ..scaleByDouble(scale, scale, 1, 1) + ..translateByDouble(-size.width / 2, -size.height / 2, 0, 1); + } +} + +class _PopupAnchorTracker extends StatefulWidget { + const _PopupAnchorTracker({ + required this.anchorOf, + required this.builder, + required this.child, + }); + + final PopupAnchorResolver anchorOf; + final Widget Function(Rect anchor, EdgeInsets safeInsets, Widget child) + builder; + final Widget child; + + @override + State<_PopupAnchorTracker> createState() => _PopupAnchorTrackerState(); +} + +class _PopupAnchorTrackerState extends State<_PopupAnchorTracker> { + Rect? _anchor; + bool _syncScheduled = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scheduleSync(); + } + + void _scheduleSync() { + if (_syncScheduled) { + return; + } + _syncScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _syncScheduled = false; + if (!mounted) { + return; + } + final anchor = widget.anchorOf(); + if (anchor == null || anchor == _anchor) { + return; + } + setState(() { + _anchor = anchor; + }); + }); + } + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final anchor = _anchor ??= widget.anchorOf() ?? Rect.zero; + // padding already gives up the part of the bottom inset the keyboard + // covers, so the sum keeps clear of the taller of the two. + return widget.builder( + anchor, + mediaQuery.padding + mediaQuery.viewInsets, + widget.child, + ); + } +} + +class _PopupLayoutDelegate extends SingleChildLayoutDelegate { + const _PopupLayoutDelegate({ + required this.anchor, + required this.safeInsets, + required this.placement, + required this.pin, + this.avoid, + }); + + final Rect anchor; + final EdgeInsets safeInsets; + final PopupPlacement placement; + final Rect? avoid; + final _PopupPin pin; + + EdgeInsets get _insets => safeInsets + const EdgeInsets.all(_screenMargin); + + @override + Size getSize(BoxConstraints constraints) => constraints.biggest; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + final insets = _insets; + return BoxConstraints.loose( + Size( + math.max(0.0, constraints.maxWidth - insets.horizontal), + math.max(0.0, constraints.maxHeight - insets.vertical), + ), + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final insets = _insets; + final maxX = size.width - insets.right - childSize.width; + final maxY = size.height - insets.bottom - childSize.height; + final pinned = pin.topRightFor(this, size); + final (x, y) = pinned != null + ? (pinned.dx - childSize.width, pinned.dy) + : switch (placement) { + PopupPlacement.overAnchorEnd => ( + anchor.right - childSize.width, + anchor.top - _anchorOverlap, + ), + PopupPlacement.overAnchorStart => ( + anchor.left, + anchor.top - _anchorOverlap, + ), + PopupPlacement.belowPoint => ( + anchor.left, + _yBelowPoint(childSize.height, insets.top, maxY), + ), + }; + final minY = placement == PopupPlacement.belowPoint + ? insets.top + : math.max(safeInsets.top, math.min(insets.top, y)); + final position = Offset( + x.clamp(insets.left, math.max(insets.left, maxX)), + y.clamp(minY, math.max(minY, maxY)), + ); + if (pinned == null) { + pin.hold(this, size, position.translate(childSize.width, 0)); + } + return position; + } + + double _yBelowPoint(double height, double minY, double maxY) { + final avoid = this.avoid; + if (avoid != null) { + final above = avoid.top - _avoidGap - height; + if (above >= minY) { + return above; + } + final below = avoid.bottom + _avoidGap; + if (below <= maxY) { + return below; + } + } + if (anchor.bottom > maxY && anchor.top - height >= minY) { + return anchor.top - height; + } + return anchor.bottom; + } + + bool _placesLike(_PopupLayoutDelegate other) { + return other.anchor == anchor && + other.safeInsets == safeInsets && + other.placement == placement && + other.avoid == avoid; + } + + @override + bool shouldRelayout(_PopupLayoutDelegate oldDelegate) { + return !_placesLike(oldDelegate) || oldDelegate.pin != pin; + } +} + +/// Keeps the top right corner the menu levels stack from while a sub menu +/// unfolds, so the card neither slides nor flips around what it avoids. +class _PopupPin { + _PopupLayoutDelegate? _placement; + Size _size = Size.zero; + Offset _topRight = Offset.zero; + + Offset? topRightFor(_PopupLayoutDelegate placement, Size size) { + final held = _placement; + if (held == null || size != _size || !held._placesLike(placement)) { + return null; + } + return _topRight; + } + + void hold(_PopupLayoutDelegate placement, Size size, Offset topRight) { + _placement = placement; + _size = size; + _topRight = topRight; + } +} + +/// Opens a popup out of the button [targetBuilder] builds. A press on the +/// button opens it at once, as iOS menus do, so the finger can slide on into +/// the menu and let go on an item. +class CommonPopupBox extends StatefulWidget { + const CommonPopupBox({ + super.key, + required this.targetBuilder, + required this.popupBuilder, + this.placement = PopupPlacement.overAnchorEnd, + }); + + final Widget Function(PopupOpen open) targetBuilder; + + final WidgetBuilder popupBuilder; + + final PopupPlacement placement; + + @override + State createState() => _CommonPopupBoxState(); +} + +class _CommonPopupBoxState extends State { + final _sourceKey = GlobalKey(); + final _sourceHidden = ValueNotifier(false); + CommonPopupRoute? _route; + int? _pointer; + Offset _pressOrigin = Offset.zero; + bool _pressMoved = false; + + @override + void dispose() { + _sourceHidden.dispose(); + super.dispose(); + } + + Rect? _sourceRect() { + if (!mounted) { + return null; + } + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.attached || !renderBox.hasSize) { + return null; + } + final navigatorBox = + Navigator.maybeOf(context)?.context.findRenderObject() as RenderBox?; + final origin = renderBox.localToGlobal(Offset.zero, ancestor: navigatorBox); + return origin & renderBox.size; + } + + Color _sourceColor() { + Color? color; + void visit(Element element) { + if (color != null) { + return; + } + if (element.widget case final Material material) { + color = material.type == MaterialType.transparency + ? Colors.transparent + : material.color ?? Colors.transparent; + return; + } + element.visitChildren(visit); + } + + context.visitChildElements(visit); + final resolved = color; + if (resolved != null && resolved.a > 0) { + return resolved; + } + if (LiquidGlassScope.of(context)) { + return Colors.transparent; + } + final colorScheme = context.colorScheme; + return SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme); + } + + void _captureSource( + CommonPopupRoute route, + ValueNotifier snapshot, + ) { + if (!mounted || !route.isActive) { + return; + } + final boundary = _sourceKey.currentContext?.findRenderObject(); + if (boundary is! RenderRepaintBoundary || !boundary.hasSize) { + return; + } + final pixelRatio = MediaQuery.devicePixelRatioOf(context); + snapshot.value = LiquidGlass.withoutPanes( + () => boundary.toImageSync(pixelRatio: pixelRatio), + ); + _sourceHidden.value = true; + } + + /// Whether the button takes taps, as the first ink response in it tells. + bool _targetEnabled() { + bool? enabled; + void visit(Element element) { + if (enabled != null) { + return; + } + if (element.widget case final InkResponse response) { + enabled = response.onTap != null; + return; + } + element.visitChildren(visit); + } + + context.visitChildElements(visit); + return enabled ?? false; + } + + bool _canOpen() => _pointer == null && _route == null && _targetEnabled(); + + void _handlePress(PointerDownEvent event) { + _pointer = event.pointer; + _pressOrigin = event.position; + _pressMoved = false; + _releaseFromNavigator(event.pointer); + _open(); + // A press around the button may hear this down after the box does. + scheduleMicrotask(() { + if (mounted) { + ElasticPress.maybeOf(context)?.release(); + } + }); + } + + // A navigator cancels the presses it tracks as it navigates (flutter#4770). + void _releaseFromNavigator(int pointer) { + final listener = Navigator.maybeOf(context)?.context.findRenderObject(); + if (listener is RenderPointerListener) { + listener.onPointerCancel?.call(PointerCancelEvent(pointer: pointer)); + } + } + + // The popup covers the button, so a press let go where it went down leaves + // the popup open rather than picking what now lies under it. + void _handlePointerMove(PointerMoveEvent event) { + if (event.pointer != _pointer) { + return; + } + final slop = computeHitSlop( + event.kind, + MediaQuery.maybeGestureSettingsOf(context), + ); + _pressMoved = + _pressMoved || (event.position - _pressOrigin).distance > slop; + if (_pressMoved) { + _route?._press.onMove?.call(event.position); + } + } + + void _handlePointerUp(PointerUpEvent event) { + if (event.pointer != _pointer) { + return; + } + _pointer = null; + if (_pressMoved) { + _route?._press.onRelease?.call(event.position); + } + } + + void _handlePressCancel() { + if (_pointer == null) { + return; + } + _pointer = null; + _route?._press.onCancel?.call(); + } + + void _open() { + if (_route != null) { + return; + } + final snapshot = ValueNotifier(null); + final route = _route = CommonPopupRoute( + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + builder: (context) => widget.popupBuilder(context), + anchorOf: _sourceRect, + placement: widget.placement, + elevated: ElevatedSurfaceTheme.isIn(context), + sourceColor: _sourceColor(), + sourceImage: snapshot, + ); + Navigator.of(context).push(route); + void reveal() { + if (mounted) { + _sourceHidden.value = false; + } + } + + route.animation!.addStatusListener((status) { + if (status.isDismissed) { + reveal(); + } + }); + route.completed.whenComplete(() { + if (identical(_route, route)) { + _route = null; + } + reveal(); + snapshot.value?.dispose(); + snapshot.dispose(); + }); + // The control repaints its ink as it is tapped, so it can only be + // captured once that frame has been painted. + WidgetsBinding.instance.addPostFrameCallback( + (_) => _captureSource(route, snapshot), + ); + } + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + excludeFromSemantics: true, + gestures: { + _PopupPressRecognizer: + GestureRecognizerFactoryWithHandlers<_PopupPressRecognizer>( + () => _PopupPressRecognizer(debugOwner: this), + (recognizer) => recognizer + ..delay = Scrollable.maybeOf(context) == null + ? Duration.zero + : kPressTimeout + ..canOpen = _canOpen + ..onPress = _handlePress + ..onMove = _handlePointerMove + ..onUp = _handlePointerUp + ..onCancel = _handlePressCancel, + ), + }, + child: ValueListenableBuilder( + valueListenable: _sourceHidden, + builder: (_, hidden, child) => + Opacity(opacity: hidden ? 0 : 1, child: child), + child: RepaintBoundary( + key: _sourceKey, + child: widget.targetBuilder(_open), + ), + ), + ); + } +} + +const _valueBlur = 4.0; + +class PopupValueText extends StatelessWidget { + const PopupValueText( + this.text, { + super.key, + this.alignment = AlignmentDirectional.center, + }); + + final String text; + final AlignmentGeometry alignment; + + static Widget _transition(Widget child, Animation animation) { + return FadeTransition( + opacity: animation, + child: AnimatedBuilder( + animation: animation, + builder: (_, child) { + final sigma = _valueBlur * (1 - animation.value); + return ImageFiltered( + enabled: sigma > 0, + imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: child, + ); + }, + child: child, + ), + ); + } + + @override + Widget build(BuildContext context) { + final duration = context.motionDuration(commonDuration); + return AnimatedSize( + duration: duration, + curve: Curves.easeOutCubic, + alignment: alignment, + clipBehavior: Clip.none, + child: AnimatedSwitcher( + duration: duration, + switchInCurve: const Interval(0.3, 1, curve: Curves.easeOut), + switchOutCurve: const Interval(0.6, 1, curve: Curves.easeIn), + transitionBuilder: _transition, + // The old value takes no room, so the button sizes to the new one + // and AnimatedSize eases it there while both are showing. + layoutBuilder: (current, previous) => Stack( + alignment: alignment, + clipBehavior: Clip.none, + children: [ + for (final child in previous) + SizedOverflowBox( + size: Size.zero, + alignment: alignment, + child: child, + ), + ?current, + ], + ), + child: Text( + text, + key: ValueKey(text), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } +} + +/// Opens a popup on its button's pointer down and carries the press on into +/// it, claiming the pointer so the button's tap and tooltip stand down. In a +/// scroll view it waits [delay] first, so a drag scrolls instead. +class _PopupPressRecognizer extends OneSequenceGestureRecognizer { + _PopupPressRecognizer({super.debugOwner}); + + Duration delay = Duration.zero; + bool Function()? canOpen; + ValueSetter? onPress; + ValueSetter? onMove; + ValueSetter? onUp; + VoidCallback? onCancel; + + PointerDownEvent? _down; + Timer? _timer; + bool _pressed = false; + + @override + bool isPointerAllowed(PointerDownEvent event) => + _down == null && + event.buttons == kPrimaryButton && + super.isPointerAllowed(event); + + @override + void addAllowedPointer(PointerDownEvent event) { + if (!(canOpen?.call() ?? false)) { + return; + } + _down = event; + _pressed = false; + startTrackingPointer(event.pointer, event.transform); + if (delay == Duration.zero) { + _press(); + } else { + _timer = Timer(delay, _press); + } + } + + void _press() { + _timer?.cancel(); + _timer = null; + final down = _down; + if (down == null || _pressed) { + return; + } + _pressed = true; + resolve(GestureDisposition.accepted); + onPress?.call(down); + } + + @override + void handleEvent(PointerEvent event) { + final down = _down; + if (down == null) { + return; + } + if (event is PointerMoveEvent) { + if (_pressed) { + onMove?.call(event); + } else if ((event.position - down.position).distance > + computeHitSlop(event.kind, gestureSettings)) { + resolve(GestureDisposition.rejected); + _reset(event.pointer); + } + } else if (event is PointerUpEvent) { + _press(); + onUp?.call(event); + _reset(event.pointer); + } else if (event is PointerCancelEvent) { + if (_pressed) { + onCancel?.call(); + } else { + resolve(GestureDisposition.rejected); + } + _reset(event.pointer); + } + } + + @override + void rejectGesture(int pointer) { + final pressed = _pressed; + _reset(pointer); + if (pressed) { + onCancel?.call(); + } + } + + void _reset(int pointer) { + _timer?.cancel(); + _timer = null; + _down = null; + _pressed = false; + stopTrackingPointer(pointer); + } + + @override + void didStopTrackingLastPointer(int pointer) {} + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + String get debugDescription => 'popup press'; +} + +class CommonPopupMenuItem { + const CommonPopupMenuItem({ + required this.label, + this.glyph, + this.onPressed, + this.danger = false, + this.checked, + this.subItems = const [], + }); + + final String label; + final Glyph? glyph; + final VoidCallback? onPressed; + final bool danger; + final bool? checked; + final List subItems; +} + +const _menuMinWidth = 160.0; +const _menuMaxWidth = 280.0; +const _defaultMaxVisibleItems = 7; + +class CommonPopupMenu extends StatefulWidget { + const CommonPopupMenu({super.key, required this.items, this.maxVisibleItems}); + + final List items; + + /// Half of the next row shows past these, so the cut tells the menu scrolls. + /// Setting them builds only the rows in view, which needs rows of one height. + final int? maxVisibleItems; + + @override + State createState() => _CommonPopupMenuState(); +} + +class _MenuStep { + const _MenuStep({ + required this.index, + required this.itemTop, + required this.itemHeight, + required this.cardWidth, + }); + + final int index; + final double itemTop; + final double itemHeight; + final double cardWidth; +} + +class _MenuLevel { + const _MenuLevel({ + required this.items, + required this.top, + required this.fromWidth, + required this.minWidth, + required this.maxWidth, + this.owner, + this.step, + }); + + final List items; + final CommonPopupMenuItem? owner; + + /// The step that opened this level from an item of the one below. + final _MenuStep? step; + final double top; + final double fromWidth; + final double minWidth; + final double maxWidth; +} + +/// [begin] to [end] of a value that may overshoot either, as 0 to 1. +class _Span extends Animatable { + const _Span(this.begin, this.end); + + final double begin; + final double end; + + @override + double transform(double t) => ((t - begin) / (end - begin)).clamp(0.0, 1.0); +} + +class _CommonPopupMenuState extends State + with SingleTickerProviderStateMixin { + static const _headerIndex = -1; + static const _levelWidthScale = 1.12; + static const _levelScaleStep = 0.05; + static const _levelFadeStep = 0.2; + static const _levelShadowStep = 1 / 3; + static const _minShadow = 1 / 6; + static const _widthSpan = 0.3; + static const _glassSpan = _Span(0, 0.35); + static final _arrowTurns = const _Span( + 0, + 1, + ).chain(Tween(begin: 0.0, end: 0.25)); + + /// Where the top sub menu stands, from its item at 0 to its card at 1; one + /// spring drives every part of the move, so a fold caught mid-unfold turns + /// around with the speed it had. + late final _unfold = AnimationController.unbounded(vsync: this, value: 1); + late final Animation _items = _unfold.drive(const _Span(0.05, 0.4)); + late final Animation _arrow = _unfold.drive(_arrowTurns); + + ValueListenable? _platter; + late Listenable _motion = _unfold; + _CarriedPress? _carried; + ValueSetter? _runPicked; + + final List<_MenuStep> _path = []; + bool _folding = false; + final _menuKey = GlobalKey(); + final _checkedKey = GlobalKey(); + ScrollController? _lazyController; + + /// The row of the top level under a finger that pressed one, or + /// [_headerIndex] for its header. + int? _pressed; + Timer? _dwell; + + /// Whether the press rests where a sub menu it opened or folded left it, + /// the header standing on its item, so letting go there changes nothing. + bool _landed = false; + bool _carrying = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _revealChecked()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final scope = _PopupScope.maybeOf(context); + final platter = scope?.platter; + if (platter != _platter) { + _platter = platter; + _motion = Listenable.merge([_unfold, ?platter]); + } + _runPicked = scope?.runPicked; + _carried = scope?.press + ?..onMove = _slideCarried + ..onRelease = _releaseCarried + ..onCancel = _cancelCarried; + } + + @override + void dispose() { + _carried + ?..onMove = null + ..onRelease = null + ..onCancel = null; + _dwell?.cancel(); + _unfold.dispose(); + _lazyController?.dispose(); + super.dispose(); + } + + /// A menu given its own cap builds only the rows in view, since building + /// them all costs its first frame a row each; its rows share one height. + bool _buildsLazily(_MenuLevel level) => + widget.maxVisibleItems != null && + level.owner == null && + level.items.every((item) => item.subItems.isEmpty); + + void _revealChecked() { + final row = _checkedKey.currentContext; + if (!mounted || row == null) { + return; + } + // Whole rows above, not centring, keep the half row a label cut at the foot. + final visible = _maxVisibleItems; + final alignment = (visible ~/ 2) / (visible - 0.5); + Scrollable.maybeOf( + row, + )?.position.ensureVisible(row.findRenderObject()!, alignment: alignment); + } + + double _rowExtentOf(BuildContext context) => + _itemPadding.vertical + + MediaQuery.textScalerOf(context).scale(AppTextStyle.subheadline.leading); + + int get _maxVisibleItems => widget.maxVisibleItems ?? _defaultMaxVisibleItems; + + double _maxHeightOf(BuildContext context, _MenuLevel level) { + if (level.owner != null) { + return double.infinity; + } + return 2 * _cardInset + (_maxVisibleItems + 0.5) * _rowExtentOf(context); + } + + static final _labelWidths = <(String, TextStyle, TextScaler), double>{}; + + /// What the rows would take at their intrinsic width, measured from the + /// labels as the rows lay them out. + double _lazyWidthOf(BuildContext context, List items) { + final style = AppTextStyle.subheadline.of(context); + final root = DefaultTextStyle.of(context).style.merge(style); + final textScaler = MediaQuery.textScalerOf(context); + final textDirection = Directionality.of(context); + final locale = Localizations.maybeLocaleOf(context); + const limit = _menuMaxWidth - 2 * _cardInset; + var width = 0.0; + for (final item in items) { + if (_labelWidths.length >= 1024) { + _labelWidths.clear(); + } + final label = _labelWidths[(item.label, root, textScaler)] ??= () { + final painter = TextPainter( + text: TextSpan( + style: root, + children: [EmojiText.spanOf(item.label, style)], + ), + textDirection: textDirection, + textScaler: textScaler, + locale: locale, + maxLines: 1, + )..layout(); + try { + return painter.maxIntrinsicWidth; + } finally { + painter.dispose(); + } + }(); + final checked = item.checked == true; + final row = + (checked ? _itemArrowPadding : _itemPadding).horizontal + + (item.glyph != null ? _itemIconSize + 12 : 0) + + label + + (checked ? 8 + _submenuArrowSize : 0); + width = math.max(width, row); + if (width >= limit) { + break; + } + } + return width; + } + + Widget _buildLazyContent(BuildContext context, _MenuLevel level) { + final items = level.items; + final extent = _rowExtentOf(context); + final visible = _maxVisibleItems; + final height = math.min(items.length, visible + 0.5) * extent; + final controller = _lazyController ??= () { + final checked = items.indexWhere((item) => item.checked == true); + // Whole rows above, as _revealChecked leaves them. + return ScrollController( + initialScrollOffset: ((checked - visible ~/ 2) * extent).clamp( + 0.0, + math.max(0.0, items.length * extent - height), + ), + ); + }(); + return SizedBox( + width: _lazyWidthOf(context, items), + height: height, + child: ListView.builder( + controller: controller, + padding: EdgeInsets.zero, + physics: const ClampingScrollPhysics(), + itemExtent: extent, + itemCount: items.length, + itemBuilder: (context, index) => + _buildItem(context, items[index], index, active: true), + ), + ); + } + + List<_MenuLevel> _resolveLevels() { + final levels = [ + _MenuLevel( + items: widget.items, + top: 0, + fromWidth: _menuMinWidth, + minWidth: _menuMinWidth, + maxWidth: _menuMaxWidth, + ), + ]; + for (final step in _path) { + final parent = levels.last; + if (step.index >= parent.items.length) { + break; + } + final item = parent.items[step.index]; + if (item.subItems.isEmpty) { + break; + } + final fromWidth = math.max(step.cardWidth, parent.minWidth); + final minWidth = fromWidth * _levelWidthScale; + levels.add( + _MenuLevel( + items: item.subItems, + owner: item, + step: step, + top: math.max(0, step.itemTop - _cardInset), + fromWidth: fromWidth, + minWidth: minWidth, + maxWidth: math.max(minWidth, _menuMaxWidth), + ), + ); + } + return levels; + } + + void _push(BuildContext itemContext, int index) { + final itemBox = itemContext.findRenderObject() as RenderBox?; + final stackBox = context.findRenderObject() as RenderBox?; + final placed = + itemBox != null && + stackBox != null && + itemBox.hasSize && + stackBox.hasSize; + const _PopupUnfoldNotification().dispatch(context); + setState(() { + _folding = false; + _path.add( + _MenuStep( + index: index, + itemTop: placed + ? itemBox.localToGlobal(Offset.zero, ancestor: stackBox).dy + : 0, + itemHeight: placed ? itemBox.size.height : 0, + cardWidth: placed + ? itemBox.size.width + 2 * _cardInset + : _menuMinWidth, + ), + ); + }); + _unfold.value = 0; + _unfoldTop(); + } + + TickerFuture _springTo(double target, SpringDescription spring) { + return _unfold.animateWith( + SpringSimulation(spring, _unfold.value, target, _unfold.velocity), + ); + } + + void _unfoldTop() { + // A spring stops within its tolerance; the card lands on its bounds. + _springTo(1, _unfoldSpring).then((_) { + if (mounted && !_folding) { + _unfold.value = 1; + } + }); + } + + void _pop() { + if (_path.isEmpty || _folding) { + return; + } + setState(() => _folding = true); + _springTo(0, _foldSpring).then((_) { + if (!mounted || !_folding) { + return; + } + setState(() { + _folding = false; + final step = _path.removeLast(); + // The header stood on the item that opened its sub menu. + if (_pressed == _headerIndex) { + _pressed = step.index; + } + _unfold.value = 1; + }); + }); + } + + /// The header stands on the item that opened its sub menu, so tapping it + /// mid-fold reads as tapping that item again. + void _toggleTop() { + if (!_folding) { + _pop(); + return; + } + setState(() => _folding = false); + _unfoldTop(); + } + + void _select(VoidCallback onPressed) { + Navigator.of(context).pop(); + final runPicked = _runPicked; + if (runPicked == null) { + onPressed(); + } else { + runPicked(onPressed); + } + } + + /// Hit tests where the menu paints, which a press swells or stretches. + _MenuRow? _rowAt(Offset position) { + final box = _menuKey.currentContext?.findRenderObject(); + if (box is! RenderBox || !box.hasSize || box.size.isEmpty) { + return null; + } + final painted = ElasticPress.paintBoundsOf(box); + final local = box.globalToLocal(position); + final result = BoxHitTestResult(); + box.hitTest( + result, + position: Offset( + (local.dx - painted.left) * box.size.width / painted.width, + (local.dy - painted.top) * box.size.height / painted.height, + ), + ); + for (final entry in result.path) { + if (entry.target case RenderMetaData(metaData: final _MenuRow row)) { + return row; + } + } + return null; + } + + bool _startPress(Offset position) { + final row = _rowAt(position); + if (row == null) { + return false; + } + _hold(row); + return true; + } + + void _slidePress(Offset position) { + final row = _rowAt(position); + final index = row?.onTap == null ? null : row!.index; + if (index == _pressed) { + return; + } + if (index != null) { + HapticFeedback.selectionClick(); + } + _hold(row); + } + + void _hold(_MenuRow? row) { + _dwell?.cancel(); + _landed = false; + final onTap = row?.onTap; + final index = onTap == null ? null : row!.index; + setState(() => _pressed = index); + if (onTap == null || !row!.togglesSubMenu) { + return; + } + _dwell = Timer(_dwellDuration, () { + if (!mounted || _pressed != index) { + return; + } + onTap(); + HapticFeedback.selectionClick(); + setState(() { + _pressed = _headerIndex; + _landed = true; + }); + }); + } + + void _endPress(Offset position) { + final onTap = _landed ? null : _rowAt(position)?.onTap; + _cancelPress(); + onTap?.call(); + } + + void _cancelPress() { + _dwell?.cancel(); + _landed = false; + if (mounted && _pressed != null) { + setState(() => _pressed = null); + } + } + + ElasticPressHandle? get _elastic { + final menu = _menuKey.currentContext; + return menu == null ? null : ElasticPress.maybeOf(menu); + } + + bool get _platterShows => _platter?.value ?? false; + + void _slideCarried(Offset position) { + if (!_carrying && !_platterShows) { + _carrying = true; + _elastic?.press(position); + } + if (_carrying) { + _elastic?.drag(position); + } + _slidePress(position); + } + + void _releaseCarried(Offset position) { + _carrying = false; + _elastic?.release(); + _endPress(position); + } + + void _cancelCarried() { + _carrying = false; + _elastic?.release(); + _cancelPress(); + } + + Widget _buildRow( + BuildContext context, { + required CommonPopupMenuItem item, + required int index, + required VoidCallback? onTap, + bool pressed = false, + Animation? arrowTurns, + }) { + final colorScheme = context.colorScheme; + final enabled = onTap != null; + final color = item.danger ? colorScheme.error : colorScheme.onSurface; + final foregroundColor = enabled ? color : color.opacity30; + Widget? arrow; + if (item.subItems.isNotEmpty) { + arrow = GlyphIcon( + AppGlyphs.chevronForward, + size: _submenuArrowSize, + color: foregroundColor, + ); + if (arrowTurns != null) { + arrow = RotationTransition(turns: arrowTurns, child: arrow); + } + } else if (item.checked == true) { + arrow = GlyphIcon( + AppGlyphs.check, + size: _submenuArrowSize, + color: foregroundColor, + ); + } + // A press is the menu's to follow across the rows, so the row's own ink + // only shows hover and focus. + final child = InkWell( + customBorder: AppShape.all(_itemRadius), + onTap: onTap, + splashFactory: NoSplash.splashFactory, + highlightColor: Colors.transparent, + hoverColor: item.danger ? colorScheme.error.opacity10 : null, + child: DecoratedBox( + decoration: ShapeDecoration( + color: pressed ? Theme.of(context).highlightColor : null, + shape: AppShape.all(_itemRadius), + ), + child: Padding( + padding: arrow != null ? _itemArrowPadding : _itemPadding, + child: Row( + children: [ + if (item.glyph case final glyph?) ...[ + GlyphIcon(glyph, size: _itemIconSize, color: foregroundColor), + const SizedBox(width: 12), + ], + Expanded( + child: EmojiText( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyle.subheadline + .of(context) + .copyWith(color: foregroundColor), + ), + ), + if (arrow != null) ...[const SizedBox(width: 8), arrow], + ], + ), + ), + ), + ); + return MetaData( + metaData: _MenuRow( + index, + onTap, + togglesSubMenu: + item.subItems.isNotEmpty && !(index == _headerIndex && _folding), + ), + behavior: HitTestBehavior.opaque, + child: Semantics( + button: true, + enabled: enabled, + checked: item.checked, + child: child, + ), + ); + } + + Widget _buildItem( + BuildContext context, + CommonPopupMenuItem item, + int index, { + required bool active, + }) { + final pressed = active && _pressed == index; + if (item.subItems.isNotEmpty) { + return Builder( + builder: (itemContext) => _buildRow( + itemContext, + item: item, + index: index, + pressed: pressed, + onTap: () => _push(itemContext, index), + ), + ); + } + final onPressed = item.onPressed; + return _buildRow( + context, + item: item, + index: index, + pressed: pressed, + onTap: onPressed == null ? null : () => _select(onPressed), + ); + } + + Widget _buildContent( + BuildContext context, + _MenuLevel level, { + required bool active, + required int? covered, + }) { + final owner = level.owner; + final checked = owner == null + ? level.items.indexWhere((item) => item.checked == true) + : -1; + final items = [ + for (var index = 0; index < level.items.length; index++) + // The item a sub menu stands on hides under its header, which draws + // it again; on glass the two would show through each other. + Opacity( + key: index == checked ? _checkedKey : null, + opacity: index == covered ? 0 : 1, + child: _buildItem(context, level.items[index], index, active: active), + ), + ]; + if (owner == null) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: items, + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildRow( + context, + item: owner, + index: _headerIndex, + pressed: active && _pressed == _headerIndex, + onTap: _toggleTop, + arrowTurns: active ? _arrow : const AlwaysStoppedAnimation(0.25), + ), + IgnorePointer( + ignoring: active && _folding, + child: FadeTransition( + opacity: active ? _items : kAlwaysCompleteAnimation, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Divider(height: _dividerHeight, thickness: 1), + ...items, + ], + ), + ), + ), + ], + ); + } + + Widget _buildLevel( + BuildContext context, + _MenuLevel level, { + required int depth, + required double pivot, + required int? covered, + }) { + final receding = depth > 0; + final step = receding ? null : level.step; + final colorScheme = context.colorScheme; + final glass = LiquidGlassScope.of(context) + ? LiquidGlassStyle.menu(colorScheme) + : null; + final rim = glass == null ? null : LiquidGlassRim.menu(colorScheme); + final fill = SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme); + // A card behind a sub menu takes a tap as one outside that sub menu. + return GestureDetector( + excludeFromSemantics: true, + onTap: receding ? _pop : null, + child: ExcludeSemantics( + excluding: receding, + child: RepaintBoundary( + child: AnimatedBuilder( + animation: _motion, + builder: (context, child) { + final unfold = _unfold.value; + final settled = unfold.clamp(0.0, 1.0); + final width = step == null + ? 1.0 + : (settled / _widthSpan).clamp(0.0, 1.0); + final distance = receding ? depth - 1 + settled : 0.0; + final scale = math.max(0.0, 1 - _levelScaleStep * distance); + final shadow = receding + ? math.max(_minShadow, 1 - distance * _levelShadowStep) + : step == null + ? 1.0 + : settled; + return Transform( + transform: Matrix4.diagonal3Values(scale, scale, 1), + alignment: Alignment.topRight, + origin: Offset(0, pivot), + child: PopupMenuCard( + grow: step == null ? 1 : unfold, + origin: step == null + ? null + : ( + top: step.itemTop - level.top, + height: step.itemHeight, + ), + shadows: glass == null + ? _cardShadows(colorScheme, shadow) + : glassMenuShadows(colorScheme, shadow), + fill: fill, + dim: colorScheme.surface.withValues( + alpha: math.min(0.4, _levelFadeStep * distance), + ), + glass: step == null + ? glass + : glass?.scale(_glassSpan.transform(unfold)), + rim: step == null + ? rim + : rim?.scale(_glassSpan.transform(unfold)), + bare: _platter?.value ?? false, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: lerpDouble( + level.fromWidth, + level.minWidth, + width, + )!, + maxWidth: lerpDouble( + level.fromWidth, + level.maxWidth, + width, + )!, + maxHeight: _maxHeightOf(context, level), + ), + child: child, + ), + ), + ); + }, + child: IgnorePointer( + ignoring: receding, + child: Material( + type: MaterialType.transparency, + child: Padding( + padding: const EdgeInsets.all(_cardInset), + child: _buildsLazily(level) + ? _buildLazyContent(context, level) + : SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: IntrinsicWidth( + child: _buildContent( + context, + level, + active: !receding, + covered: covered, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final levels = _resolveLevels(); + final topIndex = levels.length - 1; + return PopScope( + canPop: topIndex == 0, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + _pop(); + } + }, + // The platter clips the menu while it opens and does not swell with it, + // so a swell then would cut the menu's edges until the platter left. + child: ValueListenableBuilder( + valueListenable: _platter ?? const AlwaysStoppedAnimation(false), + builder: (_, platter, child) => + ElasticPress(enabled: !platter, pullPastEdges: true, child: child!), + child: RawGestureDetector( + key: _menuKey, + gestures: { + _RowPressRecognizer: + GestureRecognizerFactoryWithHandlers<_RowPressRecognizer>( + () => _RowPressRecognizer(debugOwner: this), + (recognizer) => recognizer + ..onStart = _startPress + ..onUpdate = _slidePress + ..onEnd = _endPress + ..onCancel = _cancelPress, + ), + }, + child: Stack( + alignment: Alignment.topRight, + children: [ + for (var index = 0; index <= topIndex; index++) + Padding( + key: ValueKey(index), + padding: EdgeInsets.only(top: levels[index].top), + child: _buildLevel( + context, + levels[index], + depth: topIndex - index, + pivot: index < topIndex + ? _path[index].itemTop - levels[index].top + : 0, + covered: index < topIndex ? _path[index].index : null, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _MenuRow { + const _MenuRow(this.index, this.onTap, {required this.togglesSubMenu}); + + final int index; + final VoidCallback? onTap; + final bool togglesSubMenu; +} + +/// Follows a press on a row across the rows and picks the one it is let go +/// on, as iOS menus do. It claims the pointer only then, so a menu that +/// scrolls takes a drag first, and a row's own tap never fires on a press. +class _RowPressRecognizer extends OneSequenceGestureRecognizer { + _RowPressRecognizer({super.debugOwner}); + + bool Function(Offset position)? onStart; + ValueSetter? onUpdate; + ValueSetter? onEnd; + VoidCallback? onCancel; + + int? _pointer; + Offset? _release; + bool _accepted = false; + + @override + bool isPointerAllowed(PointerDownEvent event) => + _pointer == null && + event.buttons == kPrimaryButton && + super.isPointerAllowed(event); + + @override + void addAllowedPointer(PointerDownEvent event) { + if (!(onStart?.call(event.position) ?? false)) { + return; + } + _pointer = event.pointer; + _release = null; + _accepted = false; + startTrackingPointer(event.pointer, event.transform); + } + + @override + void handleEvent(PointerEvent event) { + if (event is PointerMoveEvent) { + onUpdate?.call(event.position); + } else if (event is PointerUpEvent) { + _release = event.position; + stopTrackingPointer(event.pointer); + if (_accepted) { + _finish(); + } else { + resolve(GestureDisposition.accepted); + } + } else if (event is PointerCancelEvent) { + resolve(GestureDisposition.rejected); + } + } + + @override + void acceptGesture(int pointer) { + _accepted = true; + if (pointer == _pointer && _release != null) { + _finish(); + } + } + + @override + void rejectGesture(int pointer) { + stopTrackingPointer(pointer); + if (pointer != _pointer) { + return; + } + _pointer = null; + _release = null; + onCancel?.call(); + } + + void _finish() { + final release = _release!; + _pointer = null; + _release = null; + onEnd?.call(release); + } + + @override + void didStopTrackingLastPointer(int pointer) {} + + @override + String get debugDescription => 'menu row press'; +} + +/// A menu card, which a sub menu grows out of the item that opens it: its +/// surface and clip move from that item at [grow] 0 to its own bounds at 1, +/// over content laid out at full height. +@visibleForTesting +class PopupMenuCard extends SingleChildRenderObjectWidget { + const PopupMenuCard({ + super.key, + required this.grow, + required this.origin, + required this.shadows, + required this.fill, + required this.dim, + required this.glass, + this.rim, + required this.bare, + super.child, + }); + + final double grow; + + /// The top and height of the item a sub menu grows out of, which spans the + /// card's current width since the content is laid out to it. + final ({double top, double height})? origin; + + final List shadows; + final Color fill; + + /// Laid over the content, as a card recedes under the one above it. + final Color dim; + + final LiquidGlassStyle? glass; + final LiquidGlassRim? rim; + + /// Leaves the glass out while the popup's platter bends the backdrop. + final bool bare; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderPopupMenuCard( + grow: grow, + origin: origin, + shadows: shadows, + fill: fill, + dim: dim, + glass: glass, + rim: rim, + bare: bare, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderObject renderObject) { + (renderObject as _RenderPopupMenuCard) + ..grow = grow + ..origin = origin + ..shadows = shadows + ..fill = fill + ..dim = dim + ..glass = glass + ..rim = rim + ..bare = bare; + } +} + +class _RenderPopupMenuCard extends RenderProxyBox { + _RenderPopupMenuCard({ + required double grow, + required ({double top, double height})? origin, + required List shadows, + required Color fill, + required Color dim, + required LiquidGlassStyle? glass, + required LiquidGlassRim? rim, + required bool bare, + }) : _grow = grow, + _origin = origin, + _shadows = shadows, + _fill = fill, + _dim = dim, + _glass = glass, + _rim = rim, + _bare = bare; + + late final _pane = LiquidGlassPane(markNeedsPaint); + final _clip = LayerHandle(); + + double _grow; + set grow(double value) { + if (value == _grow) { + return; + } + _grow = value; + markNeedsPaint(); + } + + ({double top, double height})? _origin; + set origin(({double top, double height})? value) { + if (value == _origin) { + return; + } + _origin = value; + markNeedsPaint(); + } + + List _shadows; + set shadows(List value) { + if (listEquals(value, _shadows)) { + return; + } + _shadows = value; + markNeedsPaint(); + } + + Color _fill; + set fill(Color value) { + if (value == _fill) { + return; + } + _fill = value; + markNeedsPaint(); + } + + Color _dim; + set dim(Color value) { + if (value == _dim) { + return; + } + _dim = value; + markNeedsPaint(); + } + + LiquidGlassStyle? _glass; + set glass(LiquidGlassStyle? value) { + if (value == _glass) { + return; + } + if ((value == null) != (_glass == null)) { + markNeedsCompositingBitsUpdate(); + } + _glass = value; + markNeedsPaint(); + } + + LiquidGlassRim? _rim; + set rim(LiquidGlassRim? value) { + if (value == _rim) { + return; + } + _rim = value; + markNeedsPaint(); + } + + bool _bare; + set bare(bool value) { + if (value == _bare) { + return; + } + _bare = value; + markNeedsPaint(); + } + + @override + bool get alwaysNeedsCompositing => _glass != null; + + Rect get _rect { + final bounds = Offset.zero & size; + final origin = _origin; + if (origin == null) { + return bounds; + } + final item = Rect.fromLTRB( + _cardInset, + origin.top, + size.width - _cardInset, + origin.top + origin.height, + ); + return Rect.lerp(item, bounds, _grow)!; + } + + double get _radius => _origin == null + ? _cardRadius + : math.max(0.0, lerpDouble(_itemRadius, _cardRadius, _grow)!); + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (!_rect.contains(position)) { + return false; + } + return super.hitTest(result, position: position); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + void dispose() { + _pane.dispose(); + _clip.layer = null; + super.dispose(); + } + + @override + void paint(PaintingContext context, Offset offset) { + final child = this.child; + final rect = _rect; + if (child == null || rect.isEmpty) { + _pane.clear(); + _clip.layer = null; + return; + } + final radius = _radius; + final shape = AppShape.all(radius).getOuterPath(rect); + final glass = _glass; + if (glass == null) { + final canvas = context.canvas; + final surface = shape.shift(offset); + final corners = AppRadius.all(radius); + for (final shadow in _shadows) { + canvas.drawRSuperellipse( + corners.toRSuperellipse(rect.shift(offset + shadow.offset)), + shadow.toPaint(), + ); + } + canvas.drawPath(surface, Paint()..color = _fill); + } else if (_bare) { + _pane.clear(); + } else { + _pane.paint( + context, + rect.shift(offset), + shape: LiquidGlassCorners.circular(radius), + style: glass, + shadows: _shadows, + hold: (Offset.zero & size).shift(offset), + rim: _rim, + ); + _rim?.paintLine(context.canvas, rect.shift(offset), radius); + } + _clip.layer = context.pushClipPath( + needsCompositing, + offset, + Offset.zero & size, + shape, + (context, offset) { + context.paintChild(child, offset); + if (_dim.a > 0) { + context.canvas.drawPath(shape.shift(offset), Paint()..color = _dim); + } + }, + oldLayer: _clip.layer, + ); + } +} diff --git a/lib/ui/scaffold.dart b/lib/ui/scaffold.dart index e294b23a..c9577ce0 100644 --- a/lib/ui/scaffold.dart +++ b/lib/ui/scaffold.dart @@ -20,6 +20,7 @@ import 'package:flutter/services.dart'; import 'button.dart'; import 'chip.dart'; +import 'dialog.dart'; import 'inherited.dart'; import 'liquid_glass.dart'; import 'loading.dart'; @@ -41,6 +42,7 @@ class CommonScaffold extends StatefulWidget { final Color? backgroundColor; final String? title; final Widget? titleWidget; + final List titleMenuItems; final bool isLoading; final List? actions; @@ -51,6 +53,10 @@ class CommonScaffold extends StatefulWidget { final List iconActions; final List menuItems; + /// Answers a form: last in the bar, and in a form sheet the filled default + /// at its foot beside cancel, where SwiftUI sets one on macOS. + final IconButtonData? confirmationAction; + /// Folds [primaryAction] ahead of [iconActions], so a bar without search /// keeps the first of them in sight instead. final bool foldPrimaryAction; @@ -73,6 +79,7 @@ class CommonScaffold extends StatefulWidget { this.backgroundColor, this.title, this.titleWidget, + this.titleMenuItems = const [], this.actions, this.backAction, this.isLoading = false, @@ -82,6 +89,7 @@ class CommonScaffold extends StatefulWidget { this.primaryAction, this.iconActions = const [], this.menuItems = const [], + this.confirmationAction, this.foldPrimaryAction = false, this.onKeywordsUpdate, this.resizeToAvoidBottomInset, @@ -95,7 +103,6 @@ class CommonScaffold extends StatefulWidget { class CommonScaffoldState extends State { late final ValueNotifier _appBarState; final ValueNotifier _loadingNotifier = ValueNotifier(false); - final ValueNotifier _isFabExtendedNotifier = ValueNotifier(true); final ValueNotifier> _keywordsNotifier = ValueNotifier([]); final _textController = TextEditingController(); final _searchFocusNode = FocusNode(); @@ -109,6 +116,7 @@ class CommonScaffoldState extends State { return widget.canSort || widget.primaryAction != null || widget.iconActions.isNotEmpty || + widget.confirmationAction != null || widget.menuItems.isNotEmpty || widget.actions?.isNotEmpty == true; } @@ -147,7 +155,7 @@ class CommonScaffoldState extends State { !_isFrontmost) { return false; } - if (_isSearch || context.isInBottomSheet) { + if (_isSearch || context.isInCompactSheet) { _searchFocusNode.requestFocus(); _textController.selection = TextSelection( baseOffset: 0, @@ -214,8 +222,9 @@ class CommonScaffoldState extends State { void didUpdateWidget(CommonScaffold oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.searchState != widget.searchState) { + final query = _appBarState.value.searchState?.query; _appBarState.value = _appBarState.value.copyWith( - searchState: widget.searchState, + searchState: widget.searchState?.copyWith(query: query), ); } if (oldWidget.isLoading != widget.isLoading) { @@ -269,7 +278,6 @@ class CommonScaffoldState extends State { _appBarState.dispose(); _textController.dispose(); _searchFocusNode.dispose(); - _isFabExtendedNotifier.dispose(); _loadingNotifier.dispose(); _keywordsNotifier.dispose(); super.dispose(); @@ -293,7 +301,6 @@ class CommonScaffoldState extends State { Widget _buildDockedSearch(AppBarSearchState searchState) { final colorScheme = context.colorScheme; final glass = LiquidGlassScope.of(context); - final shadows = dockShadows(colorScheme); final pill = AppShape.input.copyWith(borderRadius: AppRadius.full); final fieldPadding = (BottomInsetScope.dockedSearchHeight - @@ -330,19 +337,22 @@ class CommonScaffoldState extends State { child: glass ? LiquidGlass( style: LiquidGlassStyle.pill(colorScheme), - shadows: shadows, + shadows: dockGlassShadows(colorScheme), child: field, ) : DecoratedBox( decoration: ShapeDecoration( shape: AppShape.full, - shadows: shadows, + shadows: dockShadows(colorScheme), ), child: field, ), ); } + VoidCallback get _closeSheet => + SheetProvider.of(context)?.onClose ?? () => context.safeNestedPop(); + Widget _buildSheetPopButton( VoidCallback? backAction, { required bool useCloseIcon, @@ -352,9 +362,7 @@ class CommonScaffoldState extends State { data: useCloseIcon ? IconButtonData( glyph: AppGlyphs.close, - onPressed: - SheetProvider.of(context)?.onClose ?? - () => context.safeNestedPop(), + onPressed: _closeSheet, tooltip: localizations.close, ) : IconButtonData( @@ -401,7 +409,7 @@ class CommonScaffoldState extends State { ); } if (pop != null) { - return pop.asSuffix + return pop.asSuffix || pop.atFoot ? null : _buildSheetPopButton(backAction, useCloseIcon: pop.useCloseIcon); } @@ -424,7 +432,7 @@ class CommonScaffoldState extends State { : const BackButton(); } - Widget _buildTitle(AppBarSearchState? startState) { + Widget _buildTitle(AppBarSearchState? startState, {required bool compact}) { final localizations = UiLocalizations.of(context); return _isSearch ? TextField( @@ -441,7 +449,19 @@ class CommonScaffoldState extends State { }, decoration: InputDecoration(hintText: localizations.search), ) - : widget.titleWidget ?? Text(widget.title!); + : widget.titleWidget ?? + (widget.titleMenuItems.isEmpty + ? Text(widget.title!) + : CommonPopupBox( + placement: PopupPlacement.overAnchorStart, + targetBuilder: (open) => _TitleMenuButton( + title: widget.title!, + compact: compact, + onPressed: open, + ), + popupBuilder: (_) => + CommonPopupMenu(items: widget.titleMenuItems), + )); } List _buildActions( @@ -489,6 +509,7 @@ class CommonScaffoldState extends State { (selection.isEmpty ? 0 : 1) + selectionSingles.length; final popAsSuffix = !_isSearch && pop?.asSuffix == true; final pairedPrimary = popAsSuffix ? primaryAction : null; + final atFoot = pop?.atFoot == true; final fold = _isSearch ? _foldBarActions( hasLead: true, @@ -508,6 +529,7 @@ class CommonScaffoldState extends State { ), ...widget.iconActions, ], + confirmation: atFoot ? null : widget.confirmationAction, widgetCount: widgets.length + selectionCount + @@ -559,13 +581,31 @@ class CommonScaffoldState extends State { ], edge: AppBarActionEdge.container); } - Widget _buildAppBarWrap(Widget child) { - final appBar = LiquidGlassGroup( - child: TonalButtonTheme( - glass: LiquidGlassScope.of(context), - child: _isSearch ? _buildSearchingAppBarTheme(child) : child, + Widget _buildFootActions(IconButtonData confirmation) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: AlertActions( + actions: [ + TextButton( + onPressed: _closeSheet, + child: Text(UiLocalizations.of(context).cancel), + ), + TextButton( + onPressed: confirmation.isLoading ? null : confirmation.onPressed, + child: confirmation.isLoading + ? CommonCircleLoading(size: TonalButtonSize.bar.icon) + : Text(confirmation.tooltip!), + ), + ], ), ); + } + + Widget _buildAppBarWrap(Widget child) { + final appBar = TonalButtonTheme( + glass: LiquidGlassScope.of(context), + child: _isSearch ? _buildSearchingAppBarTheme(child) : child, + ); if (_isSearch || _sorting) { return BackLayerScope(onBack: _handleExitAppBarLayer, child: appBar); } @@ -584,25 +624,23 @@ class CommonScaffoldState extends State { ); } - PreferredSizeWidget _buildAppBar( - VoidCallback? backAction, - _SheetForm form, { - required IconButtonData? primaryAction, - }) { - final isBottomSheet = form.isBottomSheet; - // A pushed page on a phone centres a small title between its buttons, as - // iOS does, so a long one is not squeezed against the back button. + PreferredSizeWidget _buildAppBar(VoidCallback? backAction, _SheetForm form) { + final isCompact = form.isCompact; + final isMobileView = ViewScope.isMobileOf(context); final pushed = form.pop == null && widget.titleWidget == null && - ViewScope.isMobileOf(context) && + isMobileView && ModalRoute.of(context)?.impliesAppBarDismissal == true; - final centered = isBottomSheet || pushed; + final compactTitle = isCompact || pushed; + final titleEndInset = appBarActionInset( + _isCompactBar(context, isMobileView), + ); // Under a sheet the bar drops its actions, so none reads as the sheet's. final uncovered = ReverseAnimation(sheetCoverOf(context)); return PreferredSize( preferredSize: Size.fromHeight( - isBottomSheet ? sheetToolbarHeight : pageToolbarHeight, + isCompact ? sheetToolbarHeight : pageToolbarHeight, ), child: Stack( alignment: Alignment.bottomCenter, @@ -618,22 +656,32 @@ class CommonScaffoldState extends State { automaticallyImplyLeading: false, animateColor: true, // A searching bar carries its own opaque theme. - forceMaterialTransparency: isBottomSheet || !_isSearch, - toolbarHeight: isBottomSheet ? sheetToolbarHeight : null, - centerTitle: isBottomSheet || pushed && !_isSearch, + forceMaterialTransparency: isCompact || !_isSearch, + toolbarHeight: isCompact ? sheetToolbarHeight : null, + centerTitle: isCompact, titleTextStyle: _isSearch ? null - : centered + : compactTitle ? AppTextStyle.headline.of(context) : AppTextStyle.pageTitle.of(context), leading: leading, // The leading slot already trails its button by a margin. titleSpacing: leading == null ? null : 0, - title: _buildTitle(state.searchState), + title: leading == null || isCompact || _isSearch + ? _buildTitle(state.searchState, compact: compactTitle) + : Padding( + padding: EdgeInsetsDirectional.only( + end: titleEndInset, + ), + child: _buildTitle( + state.searchState, + compact: compactTitle, + ), + ), actions: [ for (final action in _buildActions( state.searchState != null, - primaryAction, + widget.primaryAction, backAction, form, )) @@ -641,7 +689,7 @@ class CommonScaffoldState extends State { ], ), ); - return isBottomSheet + return isCompact ? appBar : _buildFloatingHeader( _WindowControlsClearance(child: appBar), @@ -670,25 +718,16 @@ class CommonScaffoldState extends State { searchState: widget.searchState, hasActions: _hasActions, hasPrimaryAction: widget.primaryAction != null, + hasConfirmationAction: widget.confirmationAction != null, ); - final isBottomSheet = form.isBottomSheet; + final isCompact = form.isCompact; final bottomInset = BottomInsetScope.of(context); - final primaryAction = widget.primaryAction; - final actionInBar = - form.isBottomSheet || PrimaryActionInBarScope.of(context); - final fabSlot = actionInBar - ? null - : primaryAction == null - ? null - : _PrimaryActionFab(data: primaryAction); - final hasFab = fabSlot != null; // A detent sheet fills the height its detent gives it and clips its own // corners; any other bottom sheet sizes to its content and brings both. - final hugsContent = - isBottomSheet && !SheetOverhangScope.fillsDetentOf(context); + final hugsContent = isCompact && !SheetOverhangScope.fillsDetentOf(context); // The page's bar floats over its body, which leaves the bar's height at // its top through MediaQuery padding, as extendBodyBehindAppBar does. - final barFloats = !isBottomSheet; + final barFloats = !isCompact; final sortableBody = SortModeScope(sorting: _sorting, child: widget.body); final body = SafeArea( top: !barFloats, @@ -704,7 +743,7 @@ class CommonScaffoldState extends State { final barInset = MediaQuery.paddingOf(context).top; final overlap = barFloats ? barInset - : isBottomSheet + : isCompact ? sheetAppBarHeight : 0.0; final content = Column( @@ -717,7 +756,7 @@ class CommonScaffoldState extends State { padding: EdgeInsets.only( left: 16, right: 16, - top: isBottomSheet ? sheetAppBarHeight : 16, + top: isCompact ? sheetAppBarHeight : 16, bottom: 16, ), child: Wrap( @@ -760,65 +799,21 @@ class CommonScaffoldState extends State { }, ), ); - final fabChild = ValueListenableBuilder( - valueListenable: _isFabExtendedNotifier, - builder: (_, isExtended, child) { - return CommonScaffoldFabExtendedProvider( - isExtended: isExtended, - child: child!, - ); - }, - child: fabSlot, - ); - final appBar = _buildAppBar( - backAction, - form, - primaryAction: actionInBar ? primaryAction : null, - ); - final insetBody = form.hasDockedSearch + final appBar = _buildAppBar(backAction, form); + final content = form.hasDockedSearch ? BottomInsetScope( inset: bottomInset + BottomInsetScope.dockedSearchInset, child: body, ) : body; - final content = NotificationListener( - child: PrimaryActionInBarScope( - inBar: true, - child: hasFab - ? BottomInsetScope( - inset: bottomInset + BottomInsetScope.floatingActionButtonInset, - child: insetBody, - ) - : insetBody, - ), - onNotification: (notification) { - if (notification.direction == ScrollDirection.reverse) { - _isFabExtendedNotifier.value = false; - } else if (notification.direction == ScrollDirection.forward) { - _isFabExtendedNotifier.value = true; - } - return true; - }, - ); - final fab = hasFab && !_sorting - ? SheetOverhangLift( - child: bottomInset > 0 - ? Padding( - padding: EdgeInsets.only(bottom: bottomInset), - child: fabChild, - ) - : fabChild, - ) - : null; - if (!isBottomSheet) { + if (!isCompact) { return AppBarInsetScope( child: Scaffold( appBar: appBar, extendBodyBehindAppBar: barFloats, - body: content, + body: _BodyScope(child: content), resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset, backgroundColor: widget.backgroundColor, - floatingActionButton: fab, ), ); } @@ -826,30 +821,44 @@ class CommonScaffoldState extends State { backgroundColor: widget.backgroundColor ?? SurfaceLevel.page.colorOf(context.colorScheme), - header: SheetToolBar(appBar: appBar), + header: SheetToolBar(appBar: appBar, dragHandle: form.hasDragHandle), footer: form.dockedSearch == null || _sorting ? null : _buildDockedSearch(form.dockedSearch!), body: content, ); + final footed = form.pop?.atFoot == true + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + fit: hugsContent ? FlexFit.loose : FlexFit.tight, + child: sheetBody, + ), + _buildFootActions(widget.confirmationAction!), + ], + ) + : sheetBody; if (!hugsContent) { return AppBarInsetScope( child: Scaffold( - body: sheetBody, + body: footed, resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset, backgroundColor: widget.backgroundColor, - floatingActionButton: fab, ), ); } + final hugging = Column( + mainAxisSize: MainAxisSize.min, + children: [Flexible(child: footed)], + ); return AppBarInsetScope( - child: ClipRSuperellipse( - borderRadius: AppRadius.top(AppCorner.xxl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [Flexible(child: sheetBody)], - ), - ), + child: form.hasDragHandle + ? ClipRSuperellipse( + borderRadius: AppRadius.top(AppCorner.xxl), + child: hugging, + ) + : hugging, ); } } @@ -873,6 +882,42 @@ class AppBarClearance extends StatelessWidget { } } +/// Keeps rebuilds in the body to its own layer (.agents/architecture.md). +class _BodyScope extends StatelessWidget { + const _BodyScope({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return _BodyBoundary(child: LayoutBuilder(builder: (_, _) => child)); + } +} + +class _BodyBoundary extends SingleChildRenderObjectWidget { + const _BodyBoundary({super.child}); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderBodyBoundary(); +} + +class _RenderBodyBoundary extends RenderProxyBox { + @override + bool get isRepaintBoundary => true; + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) => constraints.biggest; + + @override + void performLayout() { + child?.layout(constraints); + } +} + /// Flutter reveals a focused control against the viewport alone, which runs /// under a floating bar, so keyboard and remote traversal would park it there. class _FocusClearOfBar extends StatefulWidget { @@ -947,57 +992,6 @@ class _FocusClearOfBarState extends State<_FocusClearOfBar> { Widget build(BuildContext context) => widget.child; } -/// Only a home page above the fixed navigation bar floats its action button. -class PrimaryActionInBarScope extends InheritedWidget { - const PrimaryActionInBarScope({ - super.key, - required this.inBar, - required super.child, - }); - - final bool inBar; - - static bool of(BuildContext context) => - context - .dependOnInheritedWidgetOfExactType() - ?.inBar ?? - true; - - @override - bool updateShouldNotify(PrimaryActionInBarScope oldWidget) => - inBar != oldWidget.inBar; -} - -class _PrimaryActionFab extends StatelessWidget { - const _PrimaryActionFab({required this.data}); - - static const _duration = Duration(milliseconds: 400); - - final IconButtonData data; - - @override - Widget build(BuildContext context) { - final isLoading = data.isLoading; - return IgnorePointer( - ignoring: isLoading, - child: AnimatedScale( - scale: isLoading ? 0 : 1, - duration: _duration, - curve: Curves.easeInOutBack, - child: AnimatedOpacity( - opacity: isLoading ? 0 : 1, - duration: _duration, - child: CommonFloatingActionButton( - onPressed: data.onPressed, - icon: GlyphIcon(data.glyph, fill: 1), - label: data.tooltip ?? '', - ), - ), - ), - ); - } -} - const _maxBarButtons = 3; const _maxGroupedActions = 2; @@ -1007,13 +1001,15 @@ _foldBarActions({ IconButtonData? primary, bool foldPrimary = false, List icons = const [], + IconButtonData? confirmation, int widgetCount = 0, List menuItems = const [], }) { final foldable = [?primary, ...icons]; - final fixed = (hasLead ? 1 : 0) + widgetCount; + final fixed = + (hasLead ? 1 : 0) + widgetCount + (confirmation == null ? 0 : 1); if (fixed + foldable.length + (menuItems.isEmpty ? 0 : 1) <= _maxBarButtons) { - return (shown: foldable, overflow: menuItems); + return (shown: [...foldable, ?confirmation], overflow: menuItems); } final slots = _maxBarButtons - 1 - fixed; final kept = (foldPrimary ? [...icons, ?primary] : foldable) @@ -1024,6 +1020,7 @@ _foldBarActions({ shown: [ for (final data in foldable) if (isKept(data)) data, + ?confirmation, ], overflow: [ for (final data in foldable) @@ -1047,7 +1044,6 @@ class _OverflowMenuButton extends StatelessWidget { @override Widget build(BuildContext context) { return CommonPopupBox( - anchorShift: Offset(0, context.isMobileView ? 0 : 20), targetBuilder: (open) => IconButton( tooltip: UiLocalizations.of(context).more, onPressed: open, @@ -1094,7 +1090,8 @@ class AppBarActionButton extends StatelessWidget { /// foot instead of a button that takes the bar over. class _SheetForm { const _SheetForm({ - required this.isBottomSheet, + required this.isCompact, + required this.hasDragHandle, required this.dockedSearch, required this.pop, }); @@ -1104,26 +1101,31 @@ class _SheetForm { required AppBarSearchState? searchState, required bool hasActions, required bool hasPrimaryAction, + required bool hasConfirmationAction, }) { final provider = SheetProvider.of(context); final isModal = provider != null && provider.type != SheetType.page; - final isBottomSheet = provider?.type == SheetType.bottomSheet; + final isCompact = provider?.type.isCompact ?? false; return _SheetForm( - isBottomSheet: isBottomSheet, - dockedSearch: isBottomSheet ? searchState : null, + isCompact: isCompact, + hasDragHandle: provider?.type == SheetType.bottomSheet, + dockedSearch: isCompact ? searchState : null, pop: !isModal ? null : _SheetPop.of( context, provider, // A bottom sheet docks its search at its foot, not in the bar. - hasActions: hasActions || searchState != null && !isBottomSheet, + hasActions: hasActions || searchState != null && !isCompact, hasPrimaryAction: hasPrimaryAction, + hasConfirmationAction: + hasConfirmationAction && searchState == null, ), ); } - final bool isBottomSheet; + final bool isCompact; + final bool hasDragHandle; final AppBarSearchState? dockedSearch; final _SheetPop? pop; @@ -1133,25 +1135,36 @@ class _SheetForm { /// How a sheet's own pop button reads: [useCloseIcon] where it dismisses the /// sheet, and [asSuffix] where it ends the bar, alone or with a primary action. class _SheetPop { - const _SheetPop({required this.useCloseIcon, required this.asSuffix}); + const _SheetPop({ + required this.useCloseIcon, + required this.asSuffix, + required this.atFoot, + }); factory _SheetPop.of( BuildContext context, SheetProvider provider, { required bool hasActions, required bool hasPrimaryAction, + required bool hasConfirmationAction, }) { final useCloseIcon = provider.nestedNavigatorPop == null || ModalRoute.of(context)?.impliesAppBarDismissal == false; + final atFoot = + useCloseIcon && + hasConfirmationAction && + provider.type == SheetType.formSheet; return _SheetPop( useCloseIcon: useCloseIcon, - asSuffix: useCloseIcon && (!hasActions || hasPrimaryAction), + asSuffix: !atFoot && useCloseIcon && (!hasActions || hasPrimaryAction), + atFoot: atFoot, ); } final bool useCloseIcon; final bool asSuffix; + final bool atFoot; } const double appBarActionSpace = 4; @@ -1242,6 +1255,43 @@ class AppBarInsetScope extends StatelessWidget { /// A route over the home page on the root navigator fills the window, with /// neither the window header nor the sidebar keeping it off the buttons. +class _TitleMenuButton extends StatelessWidget { + const _TitleMenuButton({ + required this.title, + required this.compact, + required this.onPressed, + }); + + final String title; + final bool compact; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + // No ink: the padding it needs would move the title off its place in the + // bar, and a press turns the title into its menu at once. + return InkWell( + onTap: onPressed, + customBorder: AppShape.sm, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: compact ? 2 : 4, + children: [ + Flexible(child: Text(title)), + GlyphIcon( + AppGlyphs.chevronDown, + size: compact ? 16 : 20, + color: context.colorScheme.onSurfaceVariant, + ), + ], + ), + ); + } +} + class _WindowControlsClearance extends StatelessWidget { const _WindowControlsClearance({required this.child}); diff --git a/lib/ui/shape.dart b/lib/ui/shape.dart index 659cdd4c..ad6d3627 100644 --- a/lib/ui/shape.dart +++ b/lib/ui/shape.dart @@ -11,6 +11,9 @@ abstract final class AppCorner { static const double lg = 20; static const double xl = 24; static const double xxl = 28; + + /// UIAlertController's corner on iOS 27, rounder than a sheet's. + static const double alert = 34; static const double full = 1000; static double fit(double shortestSide) { @@ -201,7 +204,7 @@ class AppInputBorder extends InputBorder { }) { final outline = rect.deflate(borderSide.width / 2); if (gapStart == null || gapExtent <= 0.0 || gapPercentage == 0.0) { - _shape.paint(canvas, outline, textDirection: textDirection); + _shape.paint(canvas, rect, textDirection: textDirection); return; } final extent = lerpDouble( @@ -228,7 +231,7 @@ class AppInputBorder extends InputBorder { ), clipOp: ClipOp.difference, ); - _shape.paint(canvas, outline, textDirection: textDirection); + _shape.paint(canvas, rect, textDirection: textDirection); canvas.restore(); } @@ -247,13 +250,10 @@ class AppInputBorder extends InputBorder { int get hashCode => Object.hash(borderSide, borderRadius, gapPadding); } -// UIAlertController's corner on iOS 27, rounder than a sheet's. -const double _dialogCorner = 34; - extension AppShapeThemeExt on ThemeData { ThemeData get withAppShapes => copyWith( cardTheme: cardTheme.copyWith(shape: AppShape.md), - dialogTheme: dialogTheme.copyWith(shape: AppShape.all(_dialogCorner)), + dialogTheme: dialogTheme.copyWith(shape: AppShape.all(AppCorner.alert)), bottomSheetTheme: bottomSheetTheme.copyWith( shape: AppShape.top(AppCorner.xxl), ), diff --git a/lib/ui/surface.dart b/lib/ui/surface.dart index 766179e2..738db4b3 100644 --- a/lib/ui/surface.dart +++ b/lib/ui/surface.dart @@ -114,12 +114,24 @@ class ElevatedSurfaceTheme extends StatelessWidget { final Widget child; + static bool isIn(BuildContext context) => + context.getInheritedWidgetOfExactType<_ElevatedSurfaceScope>() != null; + @override Widget build(BuildContext context) { - return Theme(data: Theme.of(context).elevated, child: child); + return _ElevatedSurfaceScope( + child: Theme(data: Theme.of(context).elevated, child: child), + ); } } +class _ElevatedSurfaceScope extends InheritedWidget { + const _ElevatedSurfaceScope({required super.child}); + + @override + bool updateShouldNotify(_ElevatedSurfaceScope oldWidget) => false; +} + extension AppSurfaceThemeExt on ThemeData { ThemeData get elevated { final scheme = extension()?.colorScheme; diff --git a/lib/ui/text.dart b/lib/ui/text.dart index 11fa47ef..5e8eeadf 100644 --- a/lib/ui/text.dart +++ b/lib/ui/text.dart @@ -64,7 +64,8 @@ class EmojiText extends StatelessWidget { this.style, }); - List _buildTextSpans(String emojis) { + /// The spans [EmojiText] lays [text] out in, emoji in their own font. + static TextSpan spanOf(String text, TextStyle? style) { final List spans = []; final matches = emojiRegex().allMatches(text); @@ -92,13 +93,14 @@ class EmojiText extends StatelessWidget { spans.add(TextSpan(text: text.substring(lastMatchEnd), style: style)); } - return spans; + return TextSpan(children: spans); } @override Widget build(BuildContext context) { return Text.rich( - TextSpan(children: _buildTextSpans(text)), + spanOf(text, style), + style: style, maxLines: maxLines, overflow: overflow ?? TextOverflow.clip, ); diff --git a/lib/views/config/icon_sets.dart b/lib/views/config/icon_sets.dart index 421239ac..7a5b6b40 100644 --- a/lib/views/config/icon_sets.dart +++ b/lib/views/config/icon_sets.dart @@ -8,6 +8,7 @@ import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; import 'package:fl_clash/ui/icons/icons.dart'; import 'package:fl_clash/views/profiles/custom/icon.dart'; +import 'package:fl_clash/features/form/form.dart'; import 'package:fl_clash/widgets/widgets.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -33,7 +34,8 @@ class _IconSetsViewState extends ConsumerState { } Future _handleImportFromUrl() async { - final res = await dialogs.showNamedUrlInput( + final res = await showNamedUrlForm( + context, title: context.appLocalizations.importUrl, ); if (res == null || !mounted) { @@ -80,7 +82,8 @@ class _IconSetsViewState extends ConsumerState { } return; } - final res = await dialogs.showNamedUrlInput( + final res = await showNamedUrlForm( + context, title: appLocalizations.options, label: iconSet.name, url: iconSet.url, @@ -104,6 +107,8 @@ class _IconSetsViewState extends ConsumerState { final res = await dialogs.showMessage( title: appLocalizations.tip, message: TextSpan(text: appLocalizations.deleteTip(iconSet.name)), + destructive: true, + confirmText: appLocalizations.delete, ); if (res == true && mounted) { ref.read(iconSetsProvider.notifier).del(iconSet.id); @@ -220,7 +225,6 @@ class _IconSetsViewState extends ConsumerState { isLoading: ref.watch(loadingProvider(LoadingTag.iconSets)), actions: [ CommonPopupBox( - anchorShift: Offset(0, ref.watch(isMobileViewProvider) ? 0 : 20), targetBuilder: (open) { return IconButton( tooltip: appLocalizations.add, diff --git a/lib/views/config/providers.dart b/lib/views/config/providers.dart index d0c52219..eee56265 100644 --- a/lib/views/config/providers.dart +++ b/lib/views/config/providers.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/enum/enum.dart'; +import 'package:fl_clash/features/form/form.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/pages/editor.dart'; import 'package:fl_clash/providers/providers.dart'; @@ -10,7 +11,6 @@ import 'package:fl_clash/state.dart'; import 'package:fl_clash/ui/icons/icons.dart'; import 'package:fl_clash/widgets/widgets.dart'; import 'package:material_ui/material_ui.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class ClashProvidersView extends ConsumerStatefulWidget { @@ -82,7 +82,8 @@ class _ClashProvidersViewState extends ConsumerState { Future _handleImportFromUrl() async { final appLocalizations = context.appLocalizations; - final res = await dialogs.showNamedUrlsInput( + final res = await showNamedUrlsForm( + context, title: appLocalizations.importUrl, labelValidator: (value) => _validateLabel(value, optional: true), urlValidator: _validateProviderUrl, @@ -240,7 +241,7 @@ class _ClashProvidersViewState extends ConsumerState { } Future _handleEditOptions(ClashProvider provider) => - _showClashProviderOptions(ref, provider); + _showClashProviderOptions(context, ref, provider); Future _handleDelete(ClashProvider provider) async { final appLocalizations = context.appLocalizations; @@ -255,6 +256,8 @@ class _ClashProvidersViewState extends ConsumerState { users.map((profile) => profile.realLabel).join(', '), ), ), + destructive: true, + confirmText: appLocalizations.delete, ); if (res != true) { return; @@ -385,7 +388,6 @@ class _ClashProvidersViewState extends ConsumerState { isLoading: ref.watch(loadingProvider(LoadingTag.ruleProviders)), actions: [ CommonPopupBox( - anchorShift: Offset(0, ref.watch(isMobileViewProvider) ? 0 : 20), targetBuilder: (open) { return IconButton( tooltip: appLocalizations.add, @@ -467,11 +469,13 @@ class _RuleSetUpdateTimeState extends ConsumerState<_RuleSetUpdateTime> { } Future _showClashProviderOptions( + BuildContext context, WidgetRef ref, ClashProvider provider, ) async { - final res = await dialogs.showCommonDialog( - child: _ProviderDialog( + final res = await showFormSheet( + context: context, + builder: (_) => _ProviderForm( provider: provider, reservedLabels: { for (final item @@ -506,20 +510,22 @@ String? _validateProviderUrl(String? value) { return null; } -class _ProviderDialog extends StatefulWidget { - const _ProviderDialog({required this.provider, required this.reservedLabels}); +enum _ProviderField { label, url } + +class _ProviderForm extends StatefulWidget { + const _ProviderForm({required this.provider, required this.reservedLabels}); final ClashProvider provider; final Set reservedLabels; @override - State<_ProviderDialog> createState() => _ProviderDialogState(); + State<_ProviderForm> createState() => _ProviderFormState(); } -class _ProviderDialogState extends State<_ProviderDialog> { - final _formKey = GlobalKey(); +class _ProviderFormState extends State<_ProviderForm> { late final TextEditingController _labelController; late final TextEditingController _urlController; + bool _attempted = false; ClashProvider get _provider => widget.provider; @@ -537,20 +543,40 @@ class _ProviderDialogState extends State<_ProviderDialog> { super.dispose(); } - String? _validateLabel(String? value) { + ({_ProviderField field, String message})? _issue() { final appLocalizations = context.appLocalizations; - final label = value?.trim() ?? ''; + final label = _labelController.text.trim(); if (label.isEmpty) { - return appLocalizations.emptyTip(appLocalizations.name); + return ( + field: _ProviderField.label, + message: appLocalizations.emptyTip(appLocalizations.name), + ); } if (widget.reservedLabels.contains(label)) { - return appLocalizations.existsTip(appLocalizations.name); + return ( + field: _ProviderField.label, + message: appLocalizations.existsTip(appLocalizations.name), + ); + } + if (_provider.isRemote) { + if (_validateProviderUrl(_urlController.text) case final message?) { + return (field: _ProviderField.url, message: message); + } } return null; } + void _handleChanged(String _) { + if (_attempted) { + setState(() {}); + } + } + void _handleSubmit() { - if (_formKey.currentState?.validate() == false) { + if (_issue() != null) { + setState(() { + _attempted = true; + }); return; } Navigator.of(context).pop( @@ -561,61 +587,51 @@ class _ProviderDialogState extends State<_ProviderDialog> { ); } - Widget _buildField({ - required TextEditingController controller, - required String label, - FormFieldValidator? validator, - TextInputType? keyboardType, - List? inputFormatters, - }) { - return TextFormField( - controller: controller, - validator: validator, - keyboardType: keyboardType, - inputFormatters: inputFormatters, - decoration: InputDecoration(border: AppShape.input, labelText: label), - ); - } - @override Widget build(BuildContext context) { final appLocalizations = context.appLocalizations; - return CommonDialog( + final issue = _attempted ? _issue() : null; + return FormPage( title: appLocalizations.options, - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(appLocalizations.cancel), - ), - TextButton( - onPressed: _handleSubmit, - child: Text(appLocalizations.confirm), - ), - ], - child: Form( - key: _formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Wrap( - runSpacing: 16, - children: [ - _buildField( - controller: _labelController, - label: appLocalizations.name, - validator: _validateLabel, - inputFormatters: TextInputLimits.limit(TextInputLimits.name), + onSave: _handleSubmit, + error: issue?.message, + children: [ + generateSection( + items: [ + FormRow( + title: appLocalizations.name, + invalid: issue?.field == _ProviderField.label, + trailing: FormRowTextField( + controller: _labelController, + hintText: appLocalizations.required, + textInputAction: _provider.isRemote + ? TextInputAction.next + : TextInputAction.done, + inputFormatters: TextInputLimits.limit(TextInputLimits.name), + onChanged: _handleChanged, + onFieldSubmitted: _provider.isRemote + ? null + : (_) => _handleSubmit(), + ), ), if (_provider.isRemote) - _buildField( - controller: _urlController, - label: appLocalizations.url, - validator: _validateProviderUrl, - keyboardType: TextInputType.url, + FormRow( + title: appLocalizations.url, + invalid: issue?.field == _ProviderField.url, + trailing: FormRowTextField( + controller: _urlController, + hintText: appLocalizations.required, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + autocorrect: false, + enableSuggestions: false, + onChanged: _handleChanged, + onFieldSubmitted: (_) => _handleSubmit(), + ), ), ], ), - ), + ], ); } } diff --git a/lib/views/config/scripts.dart b/lib/views/config/scripts.dart index 2aa3399b..fd06ebca 100644 --- a/lib/views/config/scripts.dart +++ b/lib/views/config/scripts.dart @@ -39,6 +39,8 @@ class _ScriptsViewState extends ConsumerState { message: TextSpan( text: appLocalizations.deleteTip(appLocalizations.script), ), + destructive: true, + confirmText: appLocalizations.delete, ); if (res != true || !mounted) { return; @@ -345,7 +347,6 @@ class _ScriptsViewState extends ConsumerState { isLoading: isLoading, actions: [ CommonPopupBox( - anchorShift: Offset(0, ref.watch(isMobileViewProvider) ? 0 : 20), targetBuilder: (open) { return IconButton( tooltip: appLocalizations.add, diff --git a/lib/views/theme.dart b/lib/views/theme.dart index 1a1700ff..fd7a20e8 100644 --- a/lib/views/theme.dart +++ b/lib/views/theme.dart @@ -1,9 +1,9 @@ import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/enum/enum.dart'; -import 'package:fl_clash/icons/icons.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/providers/config.dart'; import 'package:fl_clash/providers/state.dart'; +import 'package:fl_clash/ui/icons/icons.dart'; import 'package:fl_clash/views/font_family.dart'; import 'package:fl_clash/views/theme_preview.dart'; import 'package:fl_clash/widgets/widgets.dart'; @@ -34,6 +34,8 @@ class ThemeView extends StatelessWidget { const SliverToBoxAdapter(child: SizedBox(height: 16)), const _TabAnimationItem(), const SliverToBoxAdapter(child: SizedBox(height: 16)), + const _LiquidGlassItem(), + const SliverToBoxAdapter(child: SizedBox(height: 16)), const _SidebarBlurItem(), const _FontFamilyItem(), const SliverToBoxAdapter(child: SizedBox(height: 16)), @@ -81,13 +83,16 @@ class _ThemeModeItem extends ConsumerWidget { themeSettingProvider.select((state) => state.themeMode), ); final floatingBar = ref.watch(_floatingBarProvider); + final liquidGlass = ref.watch(_liquidGlassProvider); final light = MiniScreen( colorScheme: ref.watch(genColorSchemeProvider(Brightness.light)), floatingBar: floatingBar, + liquidGlass: liquidGlass, ); final dark = MiniScreen( colorScheme: ref.watch(genColorSchemeProvider(Brightness.dark)), floatingBar: floatingBar, + liquidGlass: liquidGlass, ); return SliverToBoxAdapter( child: PreviewChoiceGroup( @@ -167,7 +172,7 @@ class _PrimaryColorItemState extends ConsumerState<_PrimaryColorItem> { text: appLocalizations.deleteTip(appLocalizations.colorSchemes), ), ); - if (res != true) { + if (res != true || !mounted) { return; } ref.read(themeSettingProvider.notifier).update((state) { @@ -196,13 +201,13 @@ class _PrimaryColorItemState extends ConsumerState<_PrimaryColorItem> { final res = await dialogs.showCommonDialog( child: const _PaletteDialog(), ); - if (res == null) { + if (res == null || !mounted) { return; } final isExists = ref.read( themeSettingProvider.select((state) => state.primaryColors.contains(res)), ); - if (isExists && mounted) { + if (isExists) { context.showNotifier( appLocalizations.existsTip(appLocalizations.colorSchemes), level: MessageLevel.warning, @@ -216,21 +221,7 @@ class _PrimaryColorItemState extends ConsumerState<_PrimaryColorItem> { }); } - Future _handleChangeSchemeVariant() async { - final schemeVariant = ref.read( - themeSettingProvider.select((state) => state.schemeVariant), - ); - final value = await dialogs.showCommonDialog( - child: OptionsDialog( - title: context.appLocalizations.colorSchemes, - options: DynamicSchemeVariant.values, - textBuilder: (item) => item.label, - value: schemeVariant, - ), - ); - if (value == null) { - return; - } + void _handleChangeSchemeVariant(DynamicSchemeVariant value) { ref.read(themeSettingProvider.notifier).update((state) { return state.copyWith(schemeVariant: value); }); @@ -277,10 +268,22 @@ class _PrimaryColorItemState extends ConsumerState<_PrimaryColorItem> { actions: [ if (_removablePrimaryColor == null) ElasticButton( - child: FilledButton.tonal( - style: _headerButtonStyle, - onPressed: _handleChangeSchemeVariant, - child: Text(schemeVariant.label), + child: CommonPopupBox( + targetBuilder: (open) => FilledButton.tonal( + style: _headerButtonStyle, + onPressed: open, + child: PopupValueText(schemeVariant.label), + ), + popupBuilder: (_) => CommonPopupMenu( + items: [ + for (final variant in DynamicSchemeVariant.values) + CommonPopupMenuItem( + label: variant.label, + checked: variant == schemeVariant, + onPressed: () => _handleChangeSchemeVariant(variant), + ), + ], + ), ), ), if (_removablePrimaryColor != null) @@ -456,6 +459,10 @@ final _floatingBarProvider = appSettingProvider.select( (state) => state.floatingNavigationBar, ); +final _liquidGlassProvider = themeSettingProvider.select( + (state) => state.liquidGlass, +); + class _PureBlackItem extends ConsumerWidget { const _PureBlackItem(); @@ -466,12 +473,14 @@ class _PureBlackItem extends ConsumerWidget { themeSettingProvider.select((state) => state.pureBlack), ); final floatingBar = ref.watch(_floatingBarProvider); + final liquidGlass = ref.watch(_liquidGlassProvider); Widget preview(bool pureBlack) => MiniScreenThumb( screen: MiniScreen( colorScheme: ref.watch( genColorSchemeProvider(Brightness.dark, pureBlack: pureBlack), ), floatingBar: floatingBar, + liquidGlass: liquidGlass, ), ); return SliverToBoxAdapter( @@ -510,6 +519,7 @@ class _NavigationBarItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final appLocalizations = context.appLocalizations; final floatingBar = ref.watch(_floatingBarProvider); + final liquidGlass = ref.watch(_liquidGlassProvider); final colorScheme = context.colorScheme; return SliverToBoxAdapter( child: PreviewChoiceGroup( @@ -523,7 +533,11 @@ class _NavigationBarItem extends ConsumerWidget { value: true, label: appLocalizations.floating, pictogram: MiniScreenThumb( - screen: MiniScreen(colorScheme: colorScheme, floatingBar: true), + screen: MiniScreen( + colorScheme: colorScheme, + floatingBar: true, + liquidGlass: liquidGlass, + ), ), ), PreviewChoice( @@ -554,11 +568,13 @@ class _TabAnimationItem extends ConsumerWidget { appSettingProvider.select((state) => state.tabAnimation), ); final floatingBar = ref.watch(_floatingBarProvider); + final liquidGlass = ref.watch(_liquidGlassProvider); final colorScheme = context.colorScheme; Widget switching(TabAnimation tabAnimation) => MiniScreenThumb( screen: MiniScreen( colorScheme: colorScheme, floatingBar: floatingBar, + liquidGlass: liquidGlass, selected: 1, previous: 0, progress: 0.55, @@ -594,6 +610,53 @@ class _TabAnimationItem extends ConsumerWidget { } } +class _LiquidGlassItem extends ConsumerWidget { + const _LiquidGlassItem(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appLocalizations = context.appLocalizations; + final liquidGlass = ref.watch(_liquidGlassProvider); + final colorScheme = context.colorScheme; + // Floating either way, as a docked bar stays a fill, over the list page, + // whose rows run on under the bar where glass shows them. + Widget preview(bool liquidGlass) => MiniScreenThumb( + screen: MiniScreen( + colorScheme: colorScheme, + floatingBar: true, + liquidGlass: liquidGlass, + selected: 1, + ), + ); + return SliverToBoxAdapter( + child: PreviewChoiceGroup( + info: Info( + label: appLocalizations.liquidGlass, + glyph: AppGlyphs.liquidGlass, + ), + value: liquidGlass, + choices: [ + PreviewChoice( + value: false, + label: appLocalizations.standard, + pictogram: preview(false), + ), + PreviewChoice( + value: true, + label: appLocalizations.liquidGlass, + pictogram: preview(true), + ), + ], + onChanged: (value) { + ref + .read(themeSettingProvider.notifier) + .update((state) => state.copyWith(liquidGlass: value)); + }, + ), + ); + } +} + class _SidebarBlurItem extends ConsumerWidget { const _SidebarBlurItem(); @@ -612,9 +675,9 @@ class _SidebarBlurItem extends ConsumerWidget { horizontalTitleGap: 12, title: Text( appLocalizations.sidebarBlur, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: context.colorScheme.onSurfaceVariant, - ), + style: AppTextStyle.subheadline + .of(context, emphasized: true) + .copyWith(color: context.colorScheme.onSurfaceVariant), ), subtitle: Text(appLocalizations.sidebarBlurDesc), value: sidebarBlur, @@ -635,7 +698,6 @@ class _FontFamilyItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final appLocalizations = context.appLocalizations; final colorScheme = context.colorScheme; - final textTheme = context.textTheme; final fontFamily = ref.watch( themeSettingProvider.select((state) => state.fontFamily), ); @@ -646,17 +708,21 @@ class _FontFamilyItem extends ConsumerWidget { children: [ Text( 'Aa', - style: textTheme.titleMedium?.copyWith(fontFamily: fontFamily), + style: AppTextStyle.headline + .of(context) + .copyWith(fontFamily: fontFamily), ), Expanded( child: Text( fontFamily ?? appLocalizations.defaultText, maxLines: 1, overflow: TextOverflow.ellipsis, - style: textTheme.bodyMedium?.copyWith( - fontFamily: fontFamily, - color: colorScheme.onSurfaceVariant, - ), + style: AppTextStyle.subheadline + .of(context) + .copyWith( + fontFamily: fontFamily, + color: colorScheme.onSurfaceVariant, + ), ), ), GlyphIcon( @@ -667,8 +733,9 @@ class _FontFamilyItem extends ConsumerWidget { ], ), ); + final cardColor = context.raisedSurfaceLevel.colorOf(colorScheme); Widget card(VoidCallback onTap) => Material( - color: colorScheme.surfaceContainerLow, + color: cardColor, shape: AppShape.lg, clipBehavior: Clip.antiAlias, child: InkWell(customBorder: AppShape.lg, onTap: onTap, child: preview), @@ -687,9 +754,8 @@ class _FontFamilyItem extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: context.isMobileView ? OpenContainer( - tappable: false, closedShape: AppShape.lg, - closedColor: colorScheme.surfaceContainerLow, + closedColor: cardColor, closedBuilder: (_, open) => card(open), openBuilder: (_, _) => const FontFamilyView(), ) @@ -738,6 +804,7 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> { ).clamp(minTextScale, maxTextScale).toDouble(); final scale = _draft ?? (textScale.enable ? textScale.scale : systemScale); final percent = '${(scale * 100).round()}%'; + final level = context.raisedSurfaceLevel; final divisions = ((maxTextScale - minTextScale) / _TextScaleFactorItem._step).round(); return SliverToBoxAdapter( @@ -783,7 +850,7 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> { Container( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), decoration: ShapeDecoration( - color: colorScheme.surfaceContainerLow, + color: level.colorOf(colorScheme), shape: AppShape.lg, ), child: Column( @@ -808,12 +875,13 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> { children: [ Text( 'Aa', - style: context.textTheme.titleLarge, + style: AppTextStyle.title3.of(context), ), Text( appLocalizations.textScalePreview, - style: context.textTheme.bodyMedium - ?.copyWith( + style: AppTextStyle.subheadline + .of(context) + .copyWith( color: colorScheme.onSurfaceVariant, ), ), @@ -828,17 +896,19 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> { vertical: 4, ), decoration: ShapeDecoration( - color: colorScheme.secondaryContainer, + color: level.next.colorOf(colorScheme), shape: AppShape.full, ), child: Text( percent, - style: context.textTheme.labelLarge?.copyWith( - color: colorScheme.onSecondaryContainer, - fontFeatures: const [ - FontFeature.tabularFigures(), - ], - ), + style: AppTextStyle.subheadline + .of(context, emphasized: true) + .copyWith( + color: colorScheme.onSurfaceVariant, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), ), ), ], @@ -851,10 +921,11 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> { ExcludeSemantics( child: Text( 'A', - style: TextStyle( - fontSize: 13, - color: colorScheme.onSurfaceVariant, - ), + style: AppTextStyle.footnote + .of(context) + .copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), Expanded( @@ -880,11 +951,11 @@ class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> { ExcludeSemantics( child: Text( 'A', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.w500, - color: colorScheme.onSurfaceVariant, - ), + style: AppTextStyle.title2 + .of(context) + .copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), ], @@ -925,7 +996,7 @@ class _SegmentedToggle extends StatelessWidget { height: _height, padding: const EdgeInsets.all(_inset), decoration: ShapeDecoration( - color: colorScheme.surfaceContainerHigh, + color: context.raisedSurfaceLevel.colorOf(colorScheme), shape: AppShape.full, ), child: Stack( @@ -941,7 +1012,7 @@ class _SegmentedToggle extends StatelessWidget { heightFactor: 1, child: DecoratedBox( decoration: ShapeDecoration( - color: colorScheme.secondaryContainer, + color: colorScheme.primary, shape: AppShape.full, ), ), @@ -967,14 +1038,13 @@ class _SegmentedToggle extends StatelessWidget { child: Center( child: AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 200), - style: context.textTheme.labelLarge!.copyWith( - color: option == value - ? colorScheme.onSecondaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: option == value - ? FontWeight.w600 - : FontWeight.w500, - ), + style: AppTextStyle.subheadline + .of(context, emphasized: option == value) + .copyWith( + color: option == value + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + ), child: Text( label, maxLines: 1, diff --git a/lib/views/theme_preview.dart b/lib/views/theme_preview.dart index 4ebd6323..2db1681e 100644 --- a/lib/views/theme_preview.dart +++ b/lib/views/theme_preview.dart @@ -1,4 +1,5 @@ -import 'package:fl_clash/common/common.dart'; +import 'dart:ui' show ImageFilter; + import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/providers/config.dart'; import 'package:fl_clash/providers/state.dart'; @@ -40,16 +41,17 @@ class PreviewChoiceGroup extends StatelessWidget { InfoHeader(info: info), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ + child: generateSection( + items: [ for (final choice in choices) - _ChoiceButton( - label: choice.label, + DecorationListItem( + leading: ExcludeSemantics(child: choice.pictogram), + title: Text(choice.label), isSelected: choice.value == value, + trailing: choice.value == value + ? const SelectionCheck() + : null, onPressed: () => onChanged(choice.value), - pictogram: choice.pictogram, ), ], ), @@ -59,52 +61,6 @@ class PreviewChoiceGroup extends StatelessWidget { } } -class _ChoiceButton extends StatelessWidget { - const _ChoiceButton({ - required this.label, - required this.isSelected, - required this.onPressed, - required this.pictogram, - }); - - static const double _height = 48; - - final String label; - final bool isSelected; - final VoidCallback onPressed; - final Widget pictogram; - - @override - Widget build(BuildContext context) { - return Semantics( - selected: isSelected, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: _height), - child: CommonCard( - isSelected: isSelected, - onPressed: onPressed, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - ExcludeSemantics(child: pictogram), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - ), - ), - ], - ), - ), - ), - ); - } -} - /// The foot of a [MiniScreen], where the options differ, drawn at the size of /// an icon. class MiniScreenThumb extends StatelessWidget { @@ -118,28 +74,21 @@ class MiniScreenThumb extends StatelessWidget { @override Widget build(BuildContext context) { - final shape = AppShape.sm.copyWith( - side: BorderSide(color: context.colorScheme.outlineVariant), - ); return SizedBox( height: _height, width: _height * _screenSize.width / _shownHeight, - child: DecoratedBox( - position: DecorationPosition.foreground, - decoration: ShapeDecoration(shape: shape), - child: ClipRSuperellipse( - borderRadius: AppRadius.sm, - child: FittedBox( - child: SizedBox( - width: _screenSize.width, - height: _shownHeight, - child: ClipRect( - child: OverflowBox( - alignment: Alignment.bottomCenter, - minHeight: _screenSize.height, - maxHeight: _screenSize.height, - child: screen, - ), + child: ClipRSuperellipse( + borderRadius: AppRadius.sm, + child: FittedBox( + child: SizedBox( + width: _screenSize.width, + height: _shownHeight, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.bottomCenter, + minHeight: _screenSize.height, + maxHeight: _screenSize.height, + child: screen, ), ), ), @@ -232,45 +181,41 @@ class _ThemeLivePreviewState extends ConsumerState final tabAnimation = ref.watch( appSettingProvider.select((state) => state.tabAnimation), ); + final liquidGlass = ref.watch( + themeSettingProvider.select((state) => state.liquidGlass), + ); final corner = AppCorner.fit(_phoneWidth); return Container( margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 24), alignment: Alignment.center, decoration: ShapeDecoration( - color: context.colorScheme.surfaceContainerLow, + color: context.raisedSurfaceLevel.colorOf(context.colorScheme), shape: AppShape.xxl, ), child: SizedBox( width: _phoneWidth, child: AspectRatio( aspectRatio: 9 / 17, - child: DecoratedBox( - position: DecorationPosition.foreground, - decoration: ShapeDecoration( - shape: AppShape.all(corner).copyWith( - side: BorderSide(color: context.colorScheme.outlineVariant), - ), - ), - child: ClipRSuperellipse( - borderRadius: AppRadius.all(corner), - child: TweenAnimationBuilder( - tween: _ColorSchemeTween(end: colorScheme), + child: ClipRSuperellipse( + borderRadius: AppRadius.all(corner), + child: TweenAnimationBuilder( + tween: _ColorSchemeTween(end: colorScheme), + duration: _duration, + builder: (_, colorScheme, _) => AnimatedSwitcher( duration: _duration, - builder: (_, colorScheme, _) => AnimatedSwitcher( - duration: _duration, - child: AnimatedBuilder( - key: ValueKey(floatingBar), - animation: _slideCurve, - builder: (_, _) => MiniScreen( - colorScheme: colorScheme, - floatingBar: floatingBar, - selected: _selected, - previous: _previous, - progress: _previous == null ? 1 : _slideCurve.value, - tabAnimation: tabAnimation, - onSelect: _select, - ), + child: AnimatedBuilder( + key: ValueKey((floatingBar, liquidGlass)), + animation: _slideCurve, + builder: (_, _) => MiniScreen( + colorScheme: colorScheme, + floatingBar: floatingBar, + liquidGlass: liquidGlass, + selected: _selected, + previous: _previous, + progress: _previous == null ? 1 : _slideCurve.value, + tabAnimation: tabAnimation, + onSelect: _select, ), ), ), @@ -293,6 +238,7 @@ class MiniScreen extends StatelessWidget { super.key, required this.colorScheme, required this.floatingBar, + this.liquidGlass = false, this.selected = 0, this.previous, this.progress = 1, @@ -304,6 +250,11 @@ class MiniScreen extends StatelessWidget { final ColorScheme colorScheme; final bool floatingBar; + + /// Draws a floating bar as glass; a docked one stays a fill, as the app's + /// does. + final bool liquidGlass; + final int selected; final int? previous; final double progress; @@ -322,6 +273,7 @@ class MiniScreen extends StatelessWidget { painter: _MiniScreenPainter( colorScheme: colorScheme, floatingBar: floatingBar, + liquidGlass: liquidGlass, selected: selected, previous: previous, progress: progress, @@ -447,6 +399,7 @@ class _MiniScreenPainter extends CustomPainter { const _MiniScreenPainter({ required this.colorScheme, required this.floatingBar, + required this.liquidGlass, required this.selected, required this.previous, required this.progress, @@ -456,12 +409,15 @@ class _MiniScreenPainter extends CustomPainter { final ColorScheme colorScheme; final bool floatingBar; + final bool liquidGlass; final int selected; final int? previous; final double progress; final TabAnimation tabAnimation; final bool hasFab; + bool get _glass => floatingBar && liquidGlass; + static _MiniPage _pageOf(int index) => index.isEven ? _MiniPage.cards : _MiniPage.list; @@ -488,7 +444,41 @@ class _MiniScreenPainter extends CustomPainter { floatingBar: floatingBar, hasFab: hasFab, ); - canvas.drawRect(Offset.zero & size, Paint()..color = colorScheme.surface); + _paintPages(canvas, geometry); + final unit = geometry.unit; + final dock = SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme); + if (hasFab && !floatingBar) { + _block( + canvas, + geometry.fab, + colorScheme.primaryContainer, + geometry.fabSize, + ); + } + final shadow = BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.12), + blurRadius: unit, + offset: Offset(0, unit * 0.3), + ); + if (_glass) { + _paintGlassBar(canvas, geometry, shadow); + } else if (floatingBar) { + _fill(canvas, geometry.bar, dock, AppShape.full, shadows: [shadow]); + } else { + canvas.drawRect(geometry.bar, Paint()..color = dock); + } + _paintDestinations(canvas, geometry.bar, unit); + if (hasFab && floatingBar) { + _fill(canvas, geometry.fab, colorScheme.primaryContainer, AppShape.full); + } + } + + void _paintPages(Canvas canvas, _MiniGeometry geometry) { + final size = geometry.size; + canvas.drawRect( + Offset.zero & size, + Paint()..color = SurfaceLevel.page.colorOf(colorScheme), + ); canvas.save(); canvas.clipRect(Offset.zero & size, doAntiAlias: false); final previous = this.previous; @@ -513,39 +503,53 @@ class _MiniScreenPainter extends CustomPainter { ); } canvas.restore(); + } + + /// The page frosted under a tint, ringed with light and casting its shadow + /// only outside, after what [LiquidGlass] does at full size. + void _paintGlassBar(Canvas canvas, _MiniGeometry geometry, BoxShadow shadow) { + final bar = geometry.bar; final unit = geometry.unit; - if (hasFab && !floatingBar) { - _block( - canvas, - geometry.fab, - colorScheme.primaryContainer, - geometry.fabSize, + final path = AppShape.full.getOuterPath(bar); + final style = LiquidGlassStyle.pill(colorScheme); + final blur = unit * 0.4; + canvas + ..save() + ..clipPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRect(bar.inflate(unit * 3)) + ..addPath(path, Offset.zero), + ) + ..drawPath(path.shift(shadow.offset), shadow.toPaint()) + ..restore() + ..save() + ..clipPath(path) + ..saveLayer( + bar, + Paint()..imageFilter = ImageFilter.blur(sigmaX: blur, sigmaY: blur), ); - } - if (floatingBar) { - _fill( - canvas, - geometry.bar, - colorScheme.surfaceContainer, - AppShape.full, - shadows: [ - BoxShadow( - color: colorScheme.shadow.withValues(alpha: 0.12), - blurRadius: unit, - offset: Offset(0, unit * 0.3), - ), - ], - ); - } else { - canvas.drawRect( - geometry.bar, - Paint()..color = colorScheme.surfaceContainer, - ); - } - _paintDestinations(canvas, geometry.bar, unit); - if (hasFab && floatingBar) { - _fill(canvas, geometry.fab, colorScheme.primaryContainer, AppShape.full); - } + _paintPages(canvas, geometry); + final rimWidth = unit * 0.15; + canvas + ..restore() + ..drawPath(path, Paint()..color = style.tint) + ..drawPath( + AppShape.full.getOuterPath(bar.deflate(rimWidth / 2)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = rimWidth + ..shader = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white.withValues(alpha: style.rim), + Colors.white.withValues(alpha: style.rim * 0.15), + Colors.white.withValues(alpha: style.rim * 0.5), + ], + ).createShader(bar), + ) + ..restore(); } void _paintPage( @@ -576,7 +580,7 @@ class _MiniScreenPainter extends CustomPainter { canvas.clipRect(content, doAntiAlias: false); final left = content.left; final width = content.width; - final card = colorScheme.surfaceContainer; + final card = SurfaceLevel.card.colorOf(colorScheme); final line = colorScheme.onSurfaceVariant.withValues(alpha: 0.4); var top = content.top; _fill( @@ -632,14 +636,19 @@ class _MiniScreenPainter extends CustomPainter { card, unit * 4, ); - _block( + final chosen = Rect.fromLTWH(left + half + unit, top, half, unit * 4); + _block(canvas, chosen, card, unit * 4); + _fill( canvas, - Rect.fromLTWH(left + half + unit, top, half, unit * 4), - colorScheme.secondaryContainer, - unit * 4, + Rect.fromCircle( + center: chosen.topRight + Offset(-unit * 1.3, unit * 1.3), + radius: unit * 0.7, + ), + colorScheme.primary, + AppShape.circle, ); top += unit * 4 + unit; - for (var i = 0; i < 3; i++) { + for (var i = 0; i < 4; i++) { _block( canvas, Rect.fromLTWH(left, top, width, unit * 4.5), @@ -650,7 +659,7 @@ class _MiniScreenPainter extends CustomPainter { } case _MiniPage.list: final lineLeft = left + unit * 2.2 + unit; - for (var i = 0; i < 7; i++) { + for (var i = 0; i < 9; i++) { _fill( canvas, Rect.fromLTWH( @@ -659,9 +668,7 @@ class _MiniScreenPainter extends CustomPainter { unit * 2.2, unit * 2.2, ), - i == 0 - ? colorScheme.tertiaryContainer - : colorScheme.secondaryContainer, + SurfaceLevel.inset.colorOf(colorScheme), AppShape.circle, ); final lineTop = @@ -707,13 +714,16 @@ class _MiniScreenPainter extends CustomPainter { _fill(canvas, dot, colorScheme.onSurfaceVariant, AppShape.circle); continue; } + final dark = colorScheme.brightness == Brightness.dark; _fill( canvas, Rect.fromCenter(center: center, width: unit * 2.8, height: unit * 1.6), - colorScheme.secondaryContainer, + _glass + ? colorScheme.onSurface.withValues(alpha: dark ? 0.14 : 0.08) + : SurfaceLevel.floatingOf(colorScheme).next.colorOf(colorScheme), AppShape.full, ); - _fill(canvas, dot, colorScheme.onSecondaryContainer, AppShape.circle); + _fill(canvas, dot, colorScheme.primary, AppShape.circle); } } @@ -721,6 +731,7 @@ class _MiniScreenPainter extends CustomPainter { bool shouldRepaint(_MiniScreenPainter oldDelegate) { return oldDelegate.colorScheme != colorScheme || oldDelegate.floatingBar != floatingBar || + oldDelegate.liquidGlass != liquidGlass || oldDelegate.selected != selected || oldDelegate.previous != previous || oldDelegate.progress != progress || diff --git a/lib/widgets/popup.dart b/lib/widgets/popup.dart deleted file mode 100644 index 2a01295d..00000000 --- a/lib/widgets/popup.dart +++ /dev/null @@ -1,1388 +0,0 @@ -import 'dart:math' as math; -import 'dart:ui' as ui show Image; -import 'dart:ui' show ImageFilter, lerpDouble; - -import 'package:fl_clash/common/common.dart'; -import 'package:fl_clash/icons/icons.dart'; -import 'package:flutter/foundation.dart' show ValueListenable, listEquals; -import 'package:flutter/rendering.dart'; -import 'package:material_ui/material_ui.dart'; - -typedef PopupAnchorResolver = Rect? Function(); - -typedef PopupOpen = void Function({Offset offset}); - -enum PopupPlacement { overAnchorEnd, belowPoint } - -const _screenMargin = 16.0; - -const _anchorOverlap = 8.0; - -const _avoidGap = 8.0; - -const _cardInset = 8.0; - -const _itemRadius = AppCorner.md; - -const _cardRadius = _itemRadius + _cardInset; - -const _itemIconSize = 20.0; -const _submenuArrowSize = 16.0; - -const _itemPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 12); - -const _itemArrowPadding = EdgeInsets.only( - left: 12, - top: 12, - bottom: 12, - right: 8, -); - -const _dividerHeight = _cardInset * 2 + 1; - -// Soft and wide like HyperOS menus rather than a Material elevation. -List _cardShadows(ColorScheme colorScheme, [double depth = 1]) { - final strength = colorScheme.brightness == Brightness.dark ? 3.0 : 1.0; - final alpha = strength * depth; - return [ - BoxShadow( - color: colorScheme.shadow.withValues(alpha: 0.05 * alpha), - blurRadius: 10, - offset: const Offset(0, 2), - ), - BoxShadow( - color: colorScheme.shadow.withValues(alpha: 0.04 * alpha), - blurRadius: 30, - offset: const Offset(0, 4), - ), - ]; -} - -const _maxContentBlur = 8.0; - -const _openDuration = Duration(milliseconds: 750); - -const _closeDuration = Duration(milliseconds: 400); - -const _unfoldDuration = Duration(milliseconds: 480); - -const _frameSpan = 0.3; - -double _secondsOf(Duration duration) => - duration.inMicroseconds / Duration.microsecondsPerSecond; - -final _openSeconds = _secondsOf(_openDuration); - -final _closeSeconds = _secondsOf(_closeDuration); - -final _growSpring = SpringCurve( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 500), - bounce: 0.3, - ), - seconds: _openSeconds, -); - -final _moveYSpring = SpringCurve( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 340), - bounce: 0.12, - ), - seconds: _openSeconds, -); - -final _moveXSpring = SpringCurve( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 520), - bounce: 0.2, - ), - seconds: _openSeconds, -); - -final _shrinkSpring = SpringCurve( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 340), - ), - seconds: _closeSeconds, -).flipped; - -final _returnXSpring = SpringCurve( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 300), - ), - seconds: _closeSeconds, -).flipped; - -final _returnYSpring = SpringCurve( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 440), - ), - seconds: _closeSeconds, -).flipped; - -final _unfoldSpring = SpringCurve( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 420), - bounce: 0.2, - ), - seconds: _secondsOf(_unfoldDuration), -); - -final _frameSpring = SpringCurve( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 150), - ), - seconds: _secondsOf(_unfoldDuration) * _frameSpan, -); - -class CommonPopupRoute extends PopupRoute { - CommonPopupRoute({ - required this.builder, - required this.anchorOf, - required this.barrierLabel, - this.placement = PopupPlacement.overAnchorEnd, - this.anchorShift = Offset.zero, - this.sourceColor = Colors.transparent, - this.sourceImage, - this.modal = true, - this.avoid, - }) : super(requestFocus: modal ? null : false); - - final WidgetBuilder builder; - - /// Resolves the rect of the control the popup grows out of. - final PopupAnchorResolver anchorOf; - final PopupPlacement placement; - - /// Moves where the popup is placed without moving where it grows from. - final Offset anchorShift; - - /// The background of the source control, which the platter starts from. - final Color sourceColor; - - /// A snapshot of the source control, drawn inside the platter while the - /// control itself is hidden; null while it is still being taken. - final ValueListenable? sourceImage; - - /// Off, focus stays on the page and touches outside pass through to it. - final bool modal; - - /// A rect the popup keeps clear of when there is room above or below it. - final Rect? avoid; - - @override - final String? barrierLabel; - - @override - Color? get barrierColor => null; - - @override - bool get barrierDismissible => true; - - @override - Duration get transitionDuration => _openDuration; - - @override - Duration get reverseTransitionDuration => _closeDuration; - - late final CurvedAnimation _grow = CurvedAnimation( - parent: animation!, - curve: _growSpring, - reverseCurve: _shrinkSpring, - ); - - late final CurvedAnimation _moveX = CurvedAnimation( - parent: animation!, - curve: _moveXSpring, - reverseCurve: _returnXSpring, - ); - - late final CurvedAnimation _moveY = CurvedAnimation( - parent: animation!, - curve: _moveYSpring, - reverseCurve: _returnYSpring, - ); - - late final CurvedAnimation _content = CurvedAnimation( - parent: animation!, - curve: const Interval(0.04, 0.45, curve: Curves.easeOutCubic), - reverseCurve: const Interval(0.5, 1, curve: Curves.easeIn), - ); - - final _unfolded = ValueNotifier(false); - - final _pin = _PopupPin(); - - @override - void dispose() { - _grow.dispose(); - _moveX.dispose(); - _moveY.dispose(); - _content.dispose(); - _unfolded.dispose(); - super.dispose(); - } - - void _handleDismiss() { - if (isCurrent) { - navigator?.pop(); - } - } - - @override - Widget buildModalBarrier() { - return modal ? super.buildModalBarrier() : const SizedBox.shrink(); - } - - Widget _buildDismissLayer() { - if (modal) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - excludeFromSemantics: true, - onTap: _handleDismiss, - ); - } - return Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) => _handleDismiss(), - onPointerPanZoomStart: (_) => _handleDismiss(), - onPointerSignal: (_) => _handleDismiss(), - ); - } - - @override - Widget buildPage( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - ) { - return builder(context); - } - - @override - Widget buildTransitions( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) { - final colorScheme = context.colorScheme; - return Stack( - children: [ - Positioned.fill(child: _buildDismissLayer()), - _PopupAnchorTracker( - anchorOf: anchorOf, - builder: (anchor, safeInsets, child) => CustomSingleChildLayout( - delegate: _PopupLayoutDelegate( - anchor: anchor.shift(anchorShift), - safeInsets: safeInsets, - placement: placement, - avoid: avoid, - pin: _pin, - ), - child: _PopupMorph( - anchor: anchor, - animation: animation, - grow: _grow, - moveX: _moveX, - moveY: _moveY, - content: _content, - unfolded: _unfolded, - sourceColor: sourceColor, - sourceImage: sourceImage, - surfaceColor: colorScheme.surfaceContainer, - shadows: _cardShadows(colorScheme), - child: child, - ), - ), - child: NotificationListener<_PopupUnfoldNotification>( - onNotification: (_) { - _unfolded.value = true; - return true; - }, - child: child, - ), - ), - ], - ); - } -} - -class _PopupUnfoldNotification extends Notification { - const _PopupUnfoldNotification(); -} - -class _PopupMorph extends SingleChildRenderObjectWidget { - const _PopupMorph({ - required this.anchor, - required this.animation, - required this.grow, - required this.moveX, - required this.moveY, - required this.content, - required this.unfolded, - required this.sourceColor, - required this.sourceImage, - required this.surfaceColor, - required this.shadows, - super.child, - }); - - final Rect anchor; - final Animation animation; - final Animation grow; - final Animation moveX; - final Animation moveY; - final Animation content; - final ValueListenable unfolded; - final Color sourceColor; - final ValueListenable? sourceImage; - final Color surfaceColor; - final List shadows; - - @override - _RenderPopupMorph createRenderObject(BuildContext context) { - return _RenderPopupMorph( - anchor: anchor, - animation: animation, - grow: grow, - moveX: moveX, - moveY: moveY, - content: content, - unfolded: unfolded, - sourceColor: sourceColor, - sourceImage: sourceImage, - surfaceColor: surfaceColor, - shadows: shadows, - ); - } - - @override - void updateRenderObject( - BuildContext context, - _RenderPopupMorph renderObject, - ) { - renderObject - ..anchor = anchor - ..animation = animation - ..grow = grow - ..moveX = moveX - ..moveY = moveY - ..content = content - ..unfolded = unfolded - ..sourceColor = sourceColor - ..sourceImage = sourceImage - ..surfaceColor = surfaceColor - ..shadows = shadows; - } -} - -/// Grows the popup out of its anchor the way iOS 26 menus leave their button: -/// a platter flies from the anchor's capsule to the card's center on an arc, -/// growing on its own spring, while the content rides inside it, scaling, -/// fading and sharpening in; closing shrinks it first and flies it back. -class _RenderPopupMorph extends RenderProxyBox { - _RenderPopupMorph({ - required Rect anchor, - required Animation animation, - required this.grow, - required this.moveX, - required this.moveY, - required this.content, - required ValueListenable unfolded, - required Color sourceColor, - required ValueListenable? sourceImage, - required Color surfaceColor, - required List shadows, - }) : _anchor = anchor, - _animation = animation, - _unfolded = unfolded, - _sourceColor = sourceColor, - _sourceImage = sourceImage, - _surfaceColor = surfaceColor, - _shadows = shadows; - - Animation grow; - Animation moveX; - Animation moveY; - Animation content; - - Rect _anchor; - - set anchor(Rect value) { - if (value == _anchor) { - return; - } - _anchor = value; - markNeedsPaint(); - } - - Animation _animation; - - set animation(Animation value) { - if (value == _animation) { - return; - } - if (attached) { - _animation.removeListener(markNeedsPaint); - value.addListener(markNeedsPaint); - } - _animation = value; - markNeedsPaint(); - } - - ValueListenable _unfolded; - - set unfolded(ValueListenable value) { - if (value == _unfolded) { - return; - } - if (attached) { - _unfolded.removeListener(markNeedsPaint); - value.addListener(markNeedsPaint); - } - _unfolded = value; - markNeedsPaint(); - } - - Color _sourceColor; - - set sourceColor(Color value) { - if (value == _sourceColor) { - return; - } - _sourceColor = value; - markNeedsPaint(); - } - - ValueListenable? _sourceImage; - - set sourceImage(ValueListenable? value) { - if (value == _sourceImage) { - return; - } - if (attached) { - _sourceImage?.removeListener(markNeedsPaint); - value?.addListener(markNeedsPaint); - } - _sourceImage = value; - markNeedsPaint(); - } - - Color _surfaceColor; - - set surfaceColor(Color value) { - if (value == _surfaceColor) { - return; - } - _surfaceColor = value; - markNeedsPaint(); - } - - List _shadows; - - set shadows(List value) { - if (listEquals(value, _shadows)) { - return; - } - _shadows = value; - markNeedsPaint(); - } - - @override - void attach(PipelineOwner owner) { - super.attach(owner); - _animation.addListener(markNeedsPaint); - _unfolded.addListener(markNeedsPaint); - _sourceImage?.addListener(markNeedsPaint); - } - - @override - void detach() { - _animation.removeListener(markNeedsPaint); - _unfolded.removeListener(markNeedsPaint); - _sourceImage?.removeListener(markNeedsPaint); - super.detach(); - } - - @override - bool get alwaysNeedsCompositing => child != null; - - bool get _closing => _animation.status == AnimationStatus.reverse; - - @override - void paint(PaintingContext context, Offset offset) { - final child = this.child; - if (child == null) { - return; - } - if (_animation.isCompleted) { - context.paintChild(child, offset); - return; - } - // The anchor comes in the coordinates of the layout box that places this - // one, so the placement offset turns it local. - final placement = (parentData! as BoxParentData).offset; - final from = _anchor.shift(-placement); - final bounds = Offset.zero & size; - final progress = grow.value; - final extent = Size.lerp(from.size, bounds.size, progress)!; - final rect = Rect.fromCenter( - center: Offset( - lerpDouble(from.center.dx, bounds.center.dx, moveX.value)!, - lerpDouble(from.center.dy, bounds.center.dy, moveY.value)!, - ), - width: math.max(0.0, extent.width), - height: math.max(0.0, extent.height), - ); - final radius = lerpDouble(from.shortestSide / 2, _cardRadius, progress)!; - final clip = AppShape.all(math.max(0.0, radius)).getOuterPath(rect); - final sourceImage = _sourceImage; - final image = sourceImage?.value; - // The source control stays visible until its snapshot exists, so the - // platter would only cover it. - if (sourceImage != null && image == null && !_closing) { - return; - } - final reveal = content.value; - // The card paints the same surface and shadow as the platter, so once it - // is revealed, or a sub menu leaves the root's bounds, the platter steps - // aside instead of clipping the levels until the spring settles. - if ((reveal >= 1 || _unfolded.value) && !_closing && !size.isEmpty) { - context.pushTransform( - needsCompositing, - offset, - _contentTransform(rect), - (context, offset) => context.paintChild(child, offset), - ); - return; - } - final closing = _closing; - // Closing levels keep their own shadows as they fade into the platter. - final platter = closing ? 1 - reveal : 1.0; - final settled = progress.clamp(0.0, 1.0); - final path = clip.shift(offset); - final canvas = context.canvas; - final depth = settled * platter; - final reach = _shadows.fold( - 0.0, - (reach, shadow) => - math.max(reach, shadow.blurSigma * 3 + shadow.offset.distance), - ); - // The blur shades under the platter too; keep that out of fading content. - canvas - ..save() - ..clipPath( - Path() - ..fillType = PathFillType.evenOdd - ..addRect(path.getBounds().inflate(reach)) - ..addPath(path, Offset.zero), - ); - for (final shadow in _shadows) { - canvas.drawPath( - path.shift(shadow.offset), - shadow.toPaint() - ..color = shadow.color.withValues(alpha: shadow.color.a * depth), - ); - } - canvas.restore(); - final fill = Color.lerp(_sourceColor, _surfaceColor, settled)!; - canvas.drawPath( - path, - Paint()..color = fill.withValues(alpha: fill.a * platter), - ); - final glyph = 1 - reveal; - if (image != null && glyph > 0) { - final source = from.shift(rect.center - from.center + offset); - canvas.drawImageRect( - image, - Offset.zero & Size(image.width.toDouble(), image.height.toDouble()), - source, - Paint() - ..color = Color.fromRGBO(0, 0, 0, glyph) - ..filterQuality = FilterQuality.medium, - ); - } - if (reveal <= 0 || size.isEmpty) { - return; - } - final transform = _contentTransform(rect, fit: closing); - final sigma = _maxContentBlur * (1 - reveal); - void paintContent(PaintingContext context, Offset offset) => - context.pushOpacity( - offset, - Color.getAlphaFromOpacity(reveal), - (context, offset) => context.pushLayer( - ImageFilterLayer( - imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), - ), - (context, offset) => context.pushTransform( - needsCompositing, - offset, - transform, - (context, offset) => context.paintChild(child, offset), - ), - offset, - ), - ); - if (closing) { - paintContent(context, offset); - return; - } - context.pushClipPath(needsCompositing, offset, bounds, clip, paintContent); - } - - Matrix4 _contentTransform(Rect rect, {bool fit = false}) { - final scale = (fit ? math.min : math.max)( - rect.width / size.width, - rect.height / size.height, - ); - return Matrix4.translationValues(rect.center.dx, rect.center.dy, 0) - ..scaleByDouble(scale, scale, 1, 1) - ..translateByDouble(-size.width / 2, -size.height / 2, 0, 1); - } -} - -class _PopupAnchorTracker extends StatefulWidget { - const _PopupAnchorTracker({ - required this.anchorOf, - required this.builder, - required this.child, - }); - - final PopupAnchorResolver anchorOf; - final Widget Function(Rect anchor, EdgeInsets safeInsets, Widget child) - builder; - final Widget child; - - @override - State<_PopupAnchorTracker> createState() => _PopupAnchorTrackerState(); -} - -class _PopupAnchorTrackerState extends State<_PopupAnchorTracker> { - Rect? _anchor; - bool _syncScheduled = false; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _scheduleSync(); - } - - void _scheduleSync() { - if (_syncScheduled) { - return; - } - _syncScheduled = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - _syncScheduled = false; - if (!mounted) { - return; - } - final anchor = widget.anchorOf(); - if (anchor == null || anchor == _anchor) { - return; - } - setState(() { - _anchor = anchor; - }); - }); - } - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - final anchor = _anchor ??= widget.anchorOf() ?? Rect.zero; - // padding already gives up the part of the bottom inset the keyboard - // covers, so the sum keeps clear of the taller of the two. - return widget.builder( - anchor, - mediaQuery.padding + mediaQuery.viewInsets, - widget.child, - ); - } -} - -class _PopupLayoutDelegate extends SingleChildLayoutDelegate { - const _PopupLayoutDelegate({ - required this.anchor, - required this.safeInsets, - required this.placement, - required this.pin, - this.avoid, - }); - - final Rect anchor; - final EdgeInsets safeInsets; - final PopupPlacement placement; - final Rect? avoid; - final _PopupPin pin; - - EdgeInsets get _insets => safeInsets + const EdgeInsets.all(_screenMargin); - - @override - Size getSize(BoxConstraints constraints) => constraints.biggest; - - @override - BoxConstraints getConstraintsForChild(BoxConstraints constraints) { - final insets = _insets; - return BoxConstraints.loose( - Size( - math.max(0.0, constraints.maxWidth - insets.horizontal), - math.max(0.0, constraints.maxHeight - insets.vertical), - ), - ); - } - - @override - Offset getPositionForChild(Size size, Size childSize) { - final insets = _insets; - final maxX = size.width - insets.right - childSize.width; - final maxY = size.height - insets.bottom - childSize.height; - final pinned = pin.topRightFor(this, size); - final (x, y) = pinned != null - ? (pinned.dx - childSize.width, pinned.dy) - : switch (placement) { - PopupPlacement.overAnchorEnd => ( - anchor.right - childSize.width, - anchor.top - _anchorOverlap, - ), - PopupPlacement.belowPoint => ( - anchor.left, - _yBelowPoint(childSize.height, insets.top, maxY), - ), - }; - final position = Offset( - x.clamp(insets.left, math.max(insets.left, maxX)), - y.clamp(insets.top, math.max(insets.top, maxY)), - ); - if (pinned == null) { - pin.hold(this, size, position.translate(childSize.width, 0)); - } - return position; - } - - double _yBelowPoint(double height, double minY, double maxY) { - final avoid = this.avoid; - if (avoid != null) { - final above = avoid.top - _avoidGap - height; - if (above >= minY) { - return above; - } - final below = avoid.bottom + _avoidGap; - if (below <= maxY) { - return below; - } - } - if (anchor.bottom > maxY && anchor.top - height >= minY) { - return anchor.top - height; - } - return anchor.bottom; - } - - bool _placesLike(_PopupLayoutDelegate other) { - return other.anchor == anchor && - other.safeInsets == safeInsets && - other.placement == placement && - other.avoid == avoid; - } - - @override - bool shouldRelayout(_PopupLayoutDelegate oldDelegate) { - return !_placesLike(oldDelegate) || oldDelegate.pin != pin; - } -} - -/// Keeps the top right corner the menu levels stack from while a sub menu -/// unfolds, so the card neither slides nor flips around what it avoids. -class _PopupPin { - _PopupLayoutDelegate? _placement; - Size _size = Size.zero; - Offset _topRight = Offset.zero; - - Offset? topRightFor(_PopupLayoutDelegate placement, Size size) { - final held = _placement; - if (held == null || size != _size || !held._placesLike(placement)) { - return null; - } - return _topRight; - } - - void hold(_PopupLayoutDelegate placement, Size size, Offset topRight) { - _placement = placement; - _size = size; - _topRight = topRight; - } -} - -class CommonPopupBox extends StatefulWidget { - const CommonPopupBox({ - super.key, - required this.targetBuilder, - required this.popupBuilder, - }); - - final Widget Function(PopupOpen open) targetBuilder; - - final WidgetBuilder popupBuilder; - - @override - State createState() => _CommonPopupBoxState(); -} - -class _CommonPopupBoxState extends State { - final _sourceKey = GlobalKey(); - final _sourceHidden = ValueNotifier(false); - - @override - void dispose() { - _sourceHidden.dispose(); - super.dispose(); - } - - Rect? _sourceRect() { - if (!mounted) { - return null; - } - final renderBox = context.findRenderObject() as RenderBox?; - if (renderBox == null || !renderBox.attached || !renderBox.hasSize) { - return null; - } - final navigatorBox = - Navigator.maybeOf(context)?.context.findRenderObject() as RenderBox?; - final origin = renderBox.localToGlobal(Offset.zero, ancestor: navigatorBox); - return origin & renderBox.size; - } - - Color _sourceColor() { - Color? color; - void visit(Element element) { - if (color != null) { - return; - } - if (element.widget case final Material material) { - color = material.type == MaterialType.transparency - ? Colors.transparent - : material.color ?? Colors.transparent; - return; - } - element.visitChildren(visit); - } - - context.visitChildElements(visit); - final resolved = color; - if (resolved == null || resolved.a == 0) { - return context.colorScheme.surfaceContainer; - } - return resolved; - } - - void _captureSource( - CommonPopupRoute route, - ValueNotifier snapshot, - ) { - if (!mounted || !route.isActive) { - return; - } - final boundary = _sourceKey.currentContext?.findRenderObject(); - if (boundary is! RenderRepaintBoundary || !boundary.hasSize) { - return; - } - snapshot.value = boundary.toImageSync( - pixelRatio: MediaQuery.devicePixelRatioOf(context), - ); - _sourceHidden.value = true; - } - - void _open({Offset offset = Offset.zero}) { - final snapshot = ValueNotifier(null); - final route = CommonPopupRoute( - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, - builder: (context) => widget.popupBuilder(context), - anchorOf: _sourceRect, - anchorShift: offset, - sourceColor: _sourceColor(), - sourceImage: snapshot, - ); - Navigator.of(context).push(route); - void reveal() { - if (mounted) { - _sourceHidden.value = false; - } - } - - route.animation!.addStatusListener((status) { - if (status.isDismissed) { - reveal(); - } - }); - route.completed.whenComplete(() { - reveal(); - snapshot.value?.dispose(); - snapshot.dispose(); - }); - // The control repaints its ink as it is tapped, so it can only be - // captured once that frame has been painted. - WidgetsBinding.instance.addPostFrameCallback( - (_) => _captureSource(route, snapshot), - ); - } - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _sourceHidden, - builder: (_, hidden, child) => - Opacity(opacity: hidden ? 0 : 1, child: child), - child: RepaintBoundary( - key: _sourceKey, - child: widget.targetBuilder(_open), - ), - ); - } -} - -class CommonPopupMenuItem { - const CommonPopupMenuItem({ - required this.label, - this.glyph, - this.onPressed, - this.danger = false, - this.checked, - this.subItems = const [], - }); - - final String label; - final Glyph? glyph; - final VoidCallback? onPressed; - final bool danger; - final bool? checked; - final List subItems; -} - -class CommonPopupMenu extends StatefulWidget { - const CommonPopupMenu({ - super.key, - required this.items, - this.minWidth = 160, - this.maxWidth = 280, - }); - - final List items; - final double minWidth; - final double maxWidth; - - @override - State createState() => _CommonPopupMenuState(); -} - -class _MenuStep { - const _MenuStep({ - required this.index, - required this.itemTop, - required this.cardWidth, - }); - - final int index; - final double itemTop; - final double cardWidth; -} - -class _MenuLevel { - const _MenuLevel({ - required this.items, - required this.top, - required this.fromWidth, - required this.minWidth, - required this.maxWidth, - this.owner, - }); - - final List items; - final CommonPopupMenuItem? owner; - final double top; - final double fromWidth; - final double minWidth; - final double maxWidth; -} - -class _CommonPopupMenuState extends State - with SingleTickerProviderStateMixin { - static const _levelWidthScale = 1.12; - static const _levelScaleStep = 0.05; - static const _levelFadeStep = 0.2; - static const _levelShadowStep = 1 / 3; - static const _minShadow = 1 / 6; - static final _arrowTween = Tween(begin: 0.0, end: 0.25); - - late final AnimationController _controller = AnimationController( - vsync: this, - duration: _unfoldDuration, - reverseDuration: _closeDuration, - value: 1, - ); - - late final CurvedAnimation _frame = CurvedAnimation( - parent: _controller, - curve: Interval(0, _frameSpan, curve: _frameSpring), - reverseCurve: _returnXSpring, - ); - late final CurvedAnimation _reveal = CurvedAnimation( - parent: _controller, - curve: const Interval(0, 0.6, curve: Curves.easeOutCubic), - reverseCurve: const Interval(0.5, 1, curve: Curves.easeIn), - ); - late final CurvedAnimation _unfold = CurvedAnimation( - parent: _controller, - curve: _unfoldSpring, - reverseCurve: _shrinkSpring, - ); - - final List<_MenuStep> _path = []; - bool _folding = false; - - @override - void initState() { - super.initState(); - _controller.addStatusListener(_handleStatusChanged); - } - - @override - void dispose() { - _frame.dispose(); - _reveal.dispose(); - _unfold.dispose(); - _controller.dispose(); - super.dispose(); - } - - void _handleStatusChanged(AnimationStatus status) { - if (status != AnimationStatus.dismissed || !_folding || _path.isEmpty) { - return; - } - _folding = false; - setState(() { - _path.removeLast(); - _controller.value = 1; - }); - } - - List<_MenuLevel> _resolveLevels() { - final rootWidth = math.min(widget.minWidth, widget.maxWidth); - final levels = [ - _MenuLevel( - items: widget.items, - top: 0, - fromWidth: rootWidth, - minWidth: rootWidth, - maxWidth: widget.maxWidth, - ), - ]; - for (final step in _path) { - final parent = levels.last; - if (step.index >= parent.items.length) { - break; - } - final item = parent.items[step.index]; - if (item.subItems.isEmpty) { - break; - } - final fromWidth = math.max(step.cardWidth, parent.minWidth); - final minWidth = fromWidth * _levelWidthScale; - levels.add( - _MenuLevel( - items: item.subItems, - owner: item, - top: math.max(0, step.itemTop - _cardInset), - fromWidth: fromWidth, - minWidth: minWidth, - maxWidth: math.max(minWidth, widget.maxWidth), - ), - ); - } - return levels; - } - - void _push(BuildContext itemContext, int index) { - final itemBox = itemContext.findRenderObject() as RenderBox?; - final stackBox = context.findRenderObject() as RenderBox?; - final placed = - itemBox != null && - stackBox != null && - itemBox.hasSize && - stackBox.hasSize; - _folding = false; - const _PopupUnfoldNotification().dispatch(context); - setState(() { - _path.add( - _MenuStep( - index: index, - itemTop: placed - ? itemBox.localToGlobal(Offset.zero, ancestor: stackBox).dy - : 0, - cardWidth: placed - ? itemBox.size.width + 2 * _cardInset - : widget.minWidth, - ), - ); - }); - _controller.forward(from: 0); - } - - void _pop() { - if (_path.isEmpty || _folding) { - return; - } - _folding = true; - _controller.reverse(); - } - - void _select(VoidCallback onPressed) { - Navigator.of(context).pop(); - onPressed(); - } - - Widget _buildRow( - BuildContext context, { - required CommonPopupMenuItem item, - required VoidCallback? onTap, - Animation? arrowTurns, - }) { - final colorScheme = context.colorScheme; - final enabled = onTap != null; - final color = item.danger ? colorScheme.error : colorScheme.onSurface; - final foregroundColor = enabled ? color : color.opacity30; - Widget? arrow; - if (item.subItems.isNotEmpty) { - arrow = GlyphIcon( - AppGlyphs.chevronForward, - size: _submenuArrowSize, - color: foregroundColor, - ); - if (arrowTurns != null) { - arrow = RotationTransition(turns: arrowTurns, child: arrow); - } - } else if (item.checked == true) { - arrow = GlyphIcon( - AppGlyphs.check, - size: _submenuArrowSize, - color: foregroundColor, - ); - } - final child = InkWell( - customBorder: const RoundedSuperellipseBorder( - borderRadius: BorderRadius.all(Radius.circular(_itemRadius)), - ), - onTap: onTap, - splashColor: Colors.transparent, - hoverColor: item.danger ? colorScheme.error.opacity10 : null, - child: Padding( - padding: arrow != null ? _itemArrowPadding : _itemPadding, - child: Row( - children: [ - if (item.glyph case final glyph?) ...[ - GlyphIcon(glyph, size: _itemIconSize, color: foregroundColor), - const SizedBox(width: 12), - ], - Expanded( - child: Text( - item.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: foregroundColor, - ), - ), - ), - if (arrow != null) ...[const SizedBox(width: 8), arrow], - ], - ), - ), - ); - return Semantics( - button: true, - enabled: enabled, - checked: item.checked, - child: child, - ); - } - - Widget _buildItem(BuildContext context, CommonPopupMenuItem item, int index) { - if (item.subItems.isNotEmpty) { - return Builder( - builder: (itemContext) => _buildRow( - itemContext, - item: item, - onTap: () => _push(itemContext, index), - ), - ); - } - final onPressed = item.onPressed; - return _buildRow( - context, - item: item, - onTap: onPressed == null ? null : () => _select(onPressed), - ); - } - - Widget _buildContent( - BuildContext context, - _MenuLevel level, { - required bool active, - }) { - final items = [ - for (var index = 0; index < level.items.length; index++) - _buildItem(context, level.items[index], index), - ]; - final owner = level.owner; - if (owner == null) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: items, - ); - } - final unfold = active ? _unfold : kAlwaysCompleteAnimation; - final reveal = active ? _reveal : kAlwaysCompleteAnimation; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildRow( - context, - item: owner, - onTap: _pop, - arrowTurns: unfold.drive(_arrowTween), - ), - SizeTransition( - sizeFactor: unfold, - alignment: Alignment.topCenter, - child: FadeTransition( - opacity: reveal, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Divider(height: _dividerHeight, thickness: 1), - ...items, - ], - ), - ), - ), - ], - ); - } - - Widget _buildLevel( - BuildContext context, - _MenuLevel level, { - required int depth, - required double pivot, - }) { - final receding = depth > 0; - final colorScheme = context.colorScheme; - final shape = AppShape.all(_cardRadius); - return IgnorePointer( - ignoring: receding, - child: ExcludeSemantics( - excluding: receding, - child: RepaintBoundary( - child: AnimatedBuilder( - animation: _frame, - builder: (context, child) { - final frame = receding ? 1.0 : _frame.value; - final distance = receding ? depth - 1 + _frame.value : 0.0; - final scale = math.max(0.0, 1 - _levelScaleStep * distance); - final shadow = receding - ? math.max(_minShadow, 1 - distance * _levelShadowStep) - : frame; - return Transform( - transform: Matrix4.diagonal3Values(scale, scale, 1), - alignment: Alignment.topRight, - origin: Offset(0, pivot), - child: DecoratedBox( - position: DecorationPosition.foreground, - decoration: ShapeDecoration( - color: colorScheme.surface.withValues( - alpha: math.min(0.4, _levelFadeStep * distance), - ), - shape: shape, - ), - child: DecoratedBox( - decoration: ShapeDecoration( - shape: shape, - shadows: _cardShadows(colorScheme, shadow), - ), - child: Material( - type: MaterialType.card, - animationDuration: Duration.zero, - color: colorScheme.surfaceContainer, - clipBehavior: Clip.antiAlias, - shape: shape, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: lerpDouble( - level.fromWidth, - level.minWidth, - frame, - )!, - maxWidth: lerpDouble( - level.fromWidth, - level.maxWidth, - frame, - )!, - ), - child: child, - ), - ), - ), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.all(_cardInset), - child: SingleChildScrollView( - physics: const ClampingScrollPhysics(), - child: IntrinsicWidth( - child: _buildContent(context, level, active: !receding), - ), - ), - ), - ), - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final levels = _resolveLevels(); - final topIndex = levels.length - 1; - return PopScope( - canPop: topIndex == 0, - onPopInvokedWithResult: (didPop, _) { - if (!didPop) { - _pop(); - } - }, - child: Stack( - alignment: Alignment.topRight, - children: [ - for (var index = 0; index <= topIndex; index++) - Padding( - key: ValueKey(index), - padding: EdgeInsets.only(top: levels[index].top), - child: _buildLevel( - context, - levels[index], - depth: topIndex - index, - pivot: index < topIndex - ? _path[index].itemTop - levels[index].top - : 0, - ), - ), - ], - ), - ); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 6f83dfb6..7dccd437 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -107,6 +107,8 @@ flutter: platforms: [ macos ] - path: assets/images/tray/windows/ platforms: [ windows ] + shaders: + - shaders/liquid_glass.frag fonts: - family: JetBrainsMono fonts: diff --git a/shaders/liquid_glass.frag b/shaders/liquid_glass.frag new file mode 100644 index 00000000..74afcec1 --- /dev/null +++ b/shaders/liquid_glass.frag @@ -0,0 +1,477 @@ +#version 460 core + +#include + +precision highp float; + +// The texture's size; the order of the rest is the order in which +// _LiquidGlassLayer in lib/ui/liquid_glass.dart writes them. +uniform vec2 uSize; +// Glass coordinates, in dp from its top left, of FlutterFragCoord: an origin, +// then a scale per unit. +uniform vec4 uFrame; +// Glass coordinates of the texture's pixels, as uFrame. +uniform vec4 uSample; +// The first and last pixel centers the pane may read, as a capture holds the +// backdrops of other panes beside its own. +uniform vec4 uClamp; +// Dp per device pixel, and the frost sigma in its own pixels the texture +// already holds, as a capture is blurred before any pane reads it. +uniform vec2 uTexels; +// As uSample and uClamp, for a capture blurred by the style's haze, and the +// share of the backdrop seen through it. +uniform vec4 uHazeSample; +uniform vec4 uHazeClamp; +uniform float uHaze; +// Width, height, bevel width, frost sigma. +uniform vec4 uShape; +// Corner radii: top left, top right, bottom right, bottom left. +uniform vec4 uCorners; +// The superellipse corners the pane is clipped to, three to a corner in the +// order of uCorners: n, J and the arc's radius for the octant along the top or +// bottom, the same for the one along the side, then both arcs' centers. A +// sharp corner has n 0. +uniform vec4 uCurves[12]; +// Refraction depth at the rim, saturation, brightness. A negative depth makes +// a raised lens, which bends outward on UIKit's profile. +uniform vec3 uOptics; +// Straight alpha. +uniform vec4 uTint; +// Highlight along the top and bottom of the rim, the dp it fades over, how +// much a lens's rounded ends darken. +uniform vec3 uLight; +// Glow center, glow strength, how far the tint thickens over content much +// darker than it, or when negative much lighter. +uniform vec4 uGlow; +// How far the glow reaches, and the share of it spread over the whole pane. +uniform vec2 uGlowSpread; +// How much the dark line around the rim darkens the backdrop, how far apart in +// dp the color channels part along the rim, and the frost sigma the rim adds +// at its edge, easing off across the bevel. +uniform vec3 uEdge; +// The highlight's share in a faint tail kRimTail long, how far its fall +// round rounded ends goes from the tab bar's to iOS 27 controls', and its +// share added as light rather than screened; then the line at the sides. +uniform vec4 uRim; +// Above 0.5 the pane takes the outline of uProfile in place of its corners. +uniform float uOutlined; +// The outline's distance from the center along 64 even turns, the first +// toward +x and the rest toward +y, four to a vector. +uniform vec4 uProfile[16]; +// A menu's highlight (LiquidGlassRim): light added at eight turns from the +// vertical through the center, rising from 0 to a right angle, and across +// bands inward from the rim, where each ends in dp and its share. +uniform vec4 uShineTurns[2]; +uniform vec4 uShineLit[2]; +uniform vec4 uShineEnds[3]; +uniform vec4 uShineShares[3]; +uniform sampler2D uTexture; + +out vec4 fragColor; + +const vec3 kLuma = vec3(0.2126, 0.7152, 0.0722); +const float kTau = 6.28318530718; +const float kTurns = 64.0; +// Fewer taps leave a swirl over sharp text; 64 leave a faint one at 3x, too +// fine to see. A count taken from a uniform costs as much as 96 constant taps, +// so a narrower frost takes a constant count of its own, its taps no further +// apart than 64 leave those of the widest. +const int kFrostTaps = 64; +const float kGoldenAngle = 2.39996322973; +// 1.2 times the engine's kernel reach of (sigma - 0.5) * sqrt(3) pixels, which +// on a Vogel disc fits its blur best. +const float kFrostReach = 2.07846096908; +// Measured on iOS 27: the width of the dark line in dp and the most it takes +// off a channel of the backdrop. +const float kLine = 0.67; +const float kLineCap = 0.298; +// The most the engine's superellipse corner lies inside a circular one, as a +// share of the radius, with room to spare over the 1.8% it reaches. +const float kCornerGap = 0.03; +// How far in dp a lens's ends darken in from the rim. +const float kEndShade = 3.0; +// How far in dp from the rim the color channels part, and where they start +// to close: iOS parts them nearly as far across a lens's band as at its rim. +const float kPartReach = 10.0; +const float kPartHold = 7.0; +// Measured on iOS 27 controls: how far in dp the highlight's tail reaches. +const float kRimTail = 4.5; +// A frost this wide in dp reads the backdrop's luma once, as a haze does. +const float kSteadyFrost = 8.0; + +vec4 texel(vec2 pixel) { + return texture( + uTexture, + clamp(pixel, uClamp.xy, min(uClamp.zw, uSize - 0.5)) / uSize + ); +} + +vec4 sampleGlass(vec2 point) { + return texel((point - uSample.xy) / uSample.zw); +} + +vec4 sampleHaze(vec2 point) { + vec2 pixel = (point - uHazeSample.xy) / uHazeSample.zw; + return texture( + uTexture, + clamp(pixel, uHazeClamp.xy, min(uHazeClamp.zw, uSize - 0.5)) / uSize + ); +} + +#define FROST_DISC(taps) \ + float decay = exp(-falloff / float(taps)); \ + float weight = sqrt(decay); \ + vec2 turn = vec2(cos(kGoldenAngle), sin(kGoldenAngle)); \ + vec2 ray = vec2(1.0, 0.0); \ + vec4 sum = vec4(0.0); \ + float total = 0.0; \ + for (int i = 0; i < taps; i++) { \ + float spread = sqrt((float(i) + 0.5) / float(taps)); \ + sum += texel(center + ray * reach * spread) * weight; \ + total += weight; \ + weight *= decay; \ + ray = vec2(ray.x * turn.x - ray.y * turn.y, ray.x * turn.y + ray.y * turn.x); \ + } \ + return sum / total; + +vec4 frostDisc16(vec2 center, vec2 reach, float falloff) { + FROST_DISC(16) +} + +vec4 frostDisc32(vec2 center, vec2 reach, float falloff) { + FROST_DISC(32) +} + +vec4 frostDisc64(vec2 center, vec2 reach, float falloff) { + FROST_DISC(kFrostTaps) +} + +// The engine's Gaussian of a frost sigma in device pixels, past what the +// texture holds, read on a Vogel disc. +vec4 sampleFrosted(vec2 point, float frost) { + vec2 sigma = frost / uSample.zw; + sigma = sqrt(max(sigma * sigma - uTexels.y * uTexels.y, vec2(0.0))); + vec2 reach = max(sigma - 0.5, 0.0) * kFrostReach; + if (reach.x <= 0.0 || reach.y <= 0.0) { + return sampleGlass(point); + } + vec2 ratio = reach / sigma; + float falloff = 0.5 * ratio.x * ratio.y; + vec2 center = (point - uSample.xy) / uSample.zw; + float widest = max(sigma.x, sigma.y); + if (widest <= 2.0) { + return frostDisc16(center, reach, falloff); + } + if (widest <= 3.5) { + return frostDisc32(center, reach, falloff); + } + return frostDisc64(center, reach, falloff); +} + +// Its taps sit a frost sigma apart, so that a glyph passing under one point +// does not flicker the tint; a haze or a wide frost is blurred enough to read +// once. +vec4 sampleBehind(vec2 point) { + if (uHaze > 0.0) { + return sampleHaze(point); + } + if (uShape.w >= kSteadyFrost) { + return sampleGlass(point); + } + vec2 spread = vec2(uShape.w, -uShape.w); + return 0.25 * (sampleGlass(point + spread.xx) + + sampleGlass(point + spread.yy) + + sampleGlass(point + spread.xy) + + sampleGlass(point + spread.yx)); +} + +float roundedBoxDistance(vec2 p, vec2 halfSize, float radius) { + vec2 q = abs(p) - halfSize + radius; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; +} + +vec2 roundedBoxNormal(vec2 p, vec2 halfSize, float radius) { + vec2 q = abs(p) - halfSize + radius; + vec2 n = q.x > 0.0 && q.y > 0.0 + ? normalize(q) + : (q.x > q.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0)); + return n * vec2(p.x < 0.0 ? -1.0 : 1.0, p.y < 0.0 ? -1.0 : 1.0); +} + +// How far inside one octant of the engine's rounded superellipse a point lies, +// in the octant's frame (impeller/geometry/round_superellipse_param.cc): a +// superellipse of half side a up to J, then a circular arc. Close to exact +// only within a few dp of the curve. +float octantInset(vec2 point, float a, vec4 curve, vec2 center) { + vec2 fromCenter = point - center; + vec2 toJoint = curve.yz - center; + if (toJoint.x * fromCenter.y < toJoint.y * fromCenter.x) { + return curve.w - length(fromCenter); + } + float n = curve.x; + vec2 s = max(point, 0.0) / a; + float major = max(max(s.x, s.y), 1e-4); + float norm = major * pow(1.0 + pow(min(s.x, s.y) / major, n), 1.0 / n); + vec2 slope = pow(s / norm, vec2(n - 1.0)); + return a * (1.0 - norm) / max(length(slope), 1e-4); +} + +// A corner's quadrant lies about the pane's center, as the engine lays it when +// all corners are alike, and its octants part along the diagonal through where +// their arcs meet, half the difference of the sides off center. +float cornerInset(vec2 p, vec2 halfSize) { + vec2 side = step(0.0, p); + vec4 along = mix( + mix(uCurves[0], uCurves[3], side.x), + mix(uCurves[9], uCurves[6], side.x), + side.y + ); + vec4 across = mix( + mix(uCurves[1], uCurves[4], side.x), + mix(uCurves[10], uCurves[7], side.x), + side.y + ); + vec4 centers = mix( + mix(uCurves[2], uCurves[5], side.x), + mix(uCurves[11], uCurves[8], side.x), + side.y + ); + vec2 q = abs(p); + if (along.x < 2.0) { + return min(halfSize.x - q.x, halfSize.y - q.y); + } + float c = halfSize.x - halfSize.y; + if (q.y + c >= q.x) { + return octantInset(vec2(q.x, q.y + c), halfSize.x, along, centers.xy); + } + return octantInset(vec2(q.y, q.x - c), halfSize.y, across, centers.zw); +} + +// Adds one sample of the profile to the radius at turn u and its slope, by +// the Catmull-Rom (Keys) kernel, which crosses the samples smoothly. +void addTurn(float u, float index, float radius, inout vec2 sum) { + float x = u - index; + x -= kTurns * floor(x / kTurns + 0.5); + float d = abs(x); + if (d >= 2.0) { + return; + } + vec2 weight = d < 1.0 + ? vec2((1.5 * d - 2.5) * d * d + 1.0, (4.5 * d - 5.0) * d) + : vec2(((-0.5 * d + 2.5) * d - 4.0) * d + 2.0, (-1.5 * d + 5.0) * d - 4.0); + sum += radius * vec2(weight.x, weight.y * sign(x)); +} + +// The distance inward from a star-shaped outline and its outward normal, +// both taken at the outline where the point's direction from the center +// meets it, so they hold steady all the way in. +void outlineGeometry(vec2 p, out float inset, out vec2 normal) { + float len = max(length(p), 1e-3); + float turn = atan(p.y, p.x) / kTau; + float u = (turn - floor(turn)) * kTurns; + vec2 sum = vec2(0.0); + for (int i = 0; i < 16; i++) { + vec4 radii = uProfile[i]; + float index = float(i) * 4.0; + addTurn(u, index, radii.x, sum); + addTurn(u, index + 1.0, radii.y, sum); + addTurn(u, index + 2.0, radii.z, sum); + addTurn(u, index + 3.0, radii.w, sum); + } + float reach = max(sum.x, 1e-3); + float slope = sum.y * kTurns / kTau / reach; + vec2 radial = p / len; + vec2 gradient = radial - slope * vec2(-radial.y, radial.x); + float stretch = length(gradient); + inset = max((reach - len) / stretch, 0.0); + normal = gradient / stretch; +} + +// The highlight's bands over depths a to b in from the rim, on average. +float shineAcross(float a, float b) { + float sum = 0.0; + float start = 0.0; + for (int i = 0; i < 3; i++) { + vec4 ends = uShineEnds[i]; + vec4 starts = vec4(start, ends.xyz); + sum += dot( + uShineShares[i], + max(min(vec4(b), ends) - max(vec4(a), starts), 0.0) + ); + start = ends.w; + } + return sum / max(b - a, 1e-4); +} + +// The highlight's brightness a turn from the vertical, as the sweep the +// fallback draws it with lays it out. +float shineAround(float turn) { + vec4 startTurns = uShineTurns[0]; + vec4 lastTurns = uShineTurns[1]; + vec4 startLit = uShineLit[0]; + vec4 lastLit = uShineLit[1]; + vec4 firstEnds = vec4(startTurns.yzw, lastTurns.x); + vec3 lastEnds = lastTurns.yzw; + vec4 firstRise = vec4(startLit.yzw, lastLit.x) - startLit; + vec3 lastRise = lastLit.yzw - lastLit.xyz; + return startLit.x + + dot(firstRise, clamp( + (turn - startTurns) / max(firstEnds - startTurns, 1e-5), 0.0, 1.0)) + + dot(lastRise, clamp( + (turn - lastTurns.xyz) / max(lastEnds - lastTurns.xyz, 1e-5), + 0.0, + 1.0 + )); +} + +void main() { + vec2 point = uFrame.xy + FlutterFragCoord().xy * uFrame.zw; + vec2 halfSize = uShape.xy * 0.5; + float halfMinor = min(halfSize.x, halfSize.y); + vec2 centered = point - halfSize; + float inset; + vec2 normal; + // The inset of the line and the highlight. A pane is clipped to the engine's + // superellipse, up to half a dp inside its circular corners where a long side + // runs into them, and they keep to it; the optics read deeper than its inset + // holds true, so they keep the circles. Only the band the line and the + // highlight can reach works it out. + float edge; + if (uOutlined > 0.5) { + outlineGeometry(centered, inset, normal); + edge = inset; + } else { + vec2 side = step(0.0, centered); + float radius = min( + mix( + mix(uCorners.x, uCorners.y, side.x), + mix(uCorners.w, uCorners.z, side.x), + side.y + ), + halfMinor + ); + inset = max(-roundedBoxDistance(centered, halfSize, radius), 0.0); + normal = roundedBoxNormal(centered, halfSize, radius); + edge = inset; + bool lit = uEdge.x > 0.0 || uRim.w > 0.0 || uLight.x > 0.0; + float traced = max( + lit ? kLine + uLight.y : 0.0, + uShineLit[0].x > 0.0 ? uShineEnds[2].w : 0.0 + ); + if (traced > 0.0 && inset < traced + uTexels.x + kCornerGap * radius) { + edge = max(cornerInset(centered, halfSize), 0.0); + } + } + + bool lens = uOptics.x < 0.0; + float bevel = max(min(uShape.z, halfMinor * 0.5), 1e-3); + float rise = 1.0 - clamp(inset / bevel, 0.0, 1.0); + vec2 shift; + if (lens) { + // As measured on iOS 27, the raised lens reads outward along its normal + // all around, by nothing at the rim and its full depth a bevel in, so its + // outer band draws out what lies right under the rim; the depth then eases + // off toward the middle. + shift = normal * -uOptics.x * smoothstep(0.0, bevel, inset) + * (1.0 - smoothstep(bevel, halfMinor, inset)); + } else { + // A bevel of circular profile, fitted to small panes: flat where it meets + // the face and steepening toward the rim, which reads from so far inside + // that its outermost band shows the content squeezed and mirrored. + float depth = min(uOptics.x, halfMinor) * (1.0 - sqrt(1.0 - rise * rise)); + shift = -normal * depth; + } + + float frost = uShape.w + uEdge.z * rise * rise; + vec4 base = sampleFrosted(point + shift, frost); + if (uEdge.y > 0.0 && inset < kPartReach) { + // iOS 27 parts red from blue along the normal's mirror image, across it + // at the sides and along it at the corners. Along the top and bottom the + // parting narrows from the rim to what it holds around the ends. Over + // clear glass a channel shows past the others, so the cover is the widest + // of them. + vec2 part = normal.yx * 0.5 * uEdge.y + * (0.6 + 0.4 * abs(normal.y) * max(1.0 - inset / kPartHold, 0.0)) + * (1.0 - smoothstep(kPartHold, kPartReach, inset)); + vec4 red = sampleFrosted(point + shift + part, frost); + vec4 blue = sampleFrosted(point + shift - part, frost); + base = vec4(red.r, base.g, blue.b, max(base.a, max(red.a, blue.a))); + } + if (uHaze > 0.0) { + base = mix(base, sampleHaze(point + shift), uHaze); + } + float alpha = base.a; + vec3 color = base.rgb / max(alpha, 1e-4); + color = mix(vec3(dot(color, kLuma)), color, uOptics.y) * uOptics.z; + color *= 1.0 - uLight.z * abs(normal.x) * exp(-inset / kEndShade); + + float clash = 0.0; + if (uGlow.w != 0.0) { + vec4 behind = sampleBehind(halfSize) + + sampleBehind(halfSize * vec2(0.4, 0.6)) + + sampleBehind(halfSize * vec2(1.6, 0.6)) + + sampleBehind(halfSize * vec2(0.4, 1.4)) + + sampleBehind(halfSize * vec2(1.6, 1.4)); + float behindLuma = dot(behind.rgb / max(behind.a, 1e-4), kLuma); + clash = clamp( + (sign(uGlow.w) * (dot(uTint.rgb, kLuma) - behindLuma) - 0.3) / 0.5, + 0.0, + 1.0 + ); + } + float cover = uTint.a + (1.0 - uTint.a) * clash * abs(uGlow.w); + float coverage = alpha * (1.0 - cover) + cover; + vec3 glass = color * alpha * (1.0 - cover) + uTint.rgb * cover; + + // iOS lights the top and bottom of its glass, just inside the line, and on + // its tab bar fades that around the rounded ends with the normal's upward + // part squared, out only at their tips; its controls dim far faster. All + // screened toward white, a pale tint would clip into a flat white ring. + // The tail reads the circular inset, as it reaches past where the edge's is + // worked out, and moves too slowly for the difference to show. + float within = max(edge - kLine, 0.0); + float facing; + if (lens) { + facing = smoothstep(0.65, 0.95, abs(normal.y)); + } else { + float rise2 = normal.y * normal.y; + float rise4 = rise2 * rise2; + float rise16 = rise4 * rise4 * rise4 * rise4; + facing = mix(rise2, 0.5 * rise2 * abs(normal.y) * (1.0 + rise16), uRim.y); + } + float band = clamp(1.0 - within / max(uLight.y, 1e-3), 0.0, 1.0); + float tail = clamp(1.0 - max(inset - kLine, 0.0) / kRimTail, 0.0, 1.0); + float shine = min(uLight.x * facing * mix(band, tail, uRim.x), 1.0); + glass += (vec3(coverage) - (1.0 - uRim.z) * glass) * shine; + + // iOS 27 outlines its glass with a dark line, which UIKit draws just outside + // the bounds, over the backdrop, where the pane cannot draw, so it takes the + // outermost pixels here. iOS draws it darkest at the sides; the dock keeps + // it as faint all around as along the top, as its darker sides read as + // brackets. + float line = clamp((kLine - edge) / uTexels.x + 0.5, 0.0, 1.0); + float across = abs(normal.x); + float darken = mix(uEdge.x, uRim.w, across * across * across); + if ((uEdge.x > 0.0 || uRim.w > 0.0) && line > 0.0) { + vec4 under = sampleGlass(point); + vec3 dark = max(under.rgb - kLineCap * under.a, 0.22 * under.rgb); + glass = mix(glass, mix(under.rgb, dark, darken), line); + coverage = mix(coverage, under.a, line); + } + + vec2 toGlow = point - uGlow.xy; + float reach = max(uGlowSpread.x * uGlowSpread.x, 1.0); + glass += uGlow.z * coverage + * mix(exp(-dot(toGlow, toGlow) / reach), 1.0, uGlowSpread.y); + + // A pixel the rim crosses takes the bands it covers, as the rasterizer + // weighs the rest by its coverage. + if (uShineLit[0].x > 0.0 && edge < uShineEnds[2].w + uTexels.x) { + float turn = atan(abs(centered.x), abs(centered.y)); + float pixel = 0.5 * uTexels.x; + glass += coverage * shineAround(turn) + * shineAcross(max(edge - pixel, 0.0), edge + pixel); + } + + fragColor = vec4(min(glass, vec3(coverage)), coverage); +} diff --git a/test/lint/sheet_scroll_controller_test.dart b/test/lint/sheet_scroll_controller_test.dart index 4a5f3388..6ca6f49b 100644 --- a/test/lint/sheet_scroll_controller_test.dart +++ b/test/lint/sheet_scroll_controller_test.dart @@ -11,6 +11,8 @@ const _allowed = { 'lib/ui/inherited.dart', // A drag on the editor's completion overlay must never move a sheet. 'lib/features/editor/completion_popup.dart', + // Nor a drag on a popup menu, which stands over the sheet it opened from. + 'lib/ui/popup.dart', }; int _lineOf(String source, int offset) => diff --git a/test/pages/home_test.dart b/test/pages/home_test.dart index 7886eb92..7fe53541 100644 --- a/test/pages/home_test.dart +++ b/test/pages/home_test.dart @@ -803,7 +803,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('a non-floating bottom bar leaves primary actions floating', ( + testWidgets('a non-floating bottom bar leaves the page no bottom inset', ( tester, ) async { tester.view.physicalSize = const Size(500, 800); @@ -813,10 +813,8 @@ void main() { Widget page(String label) { return Builder( - builder: (context) => Text( - 'page:$label inBar:${PrimaryActionInBarScope.of(context)} ' - 'inset:${BottomInsetScope.of(context)}', - ), + builder: (context) => + Text('page:$label inset:${BottomInsetScope.of(context)}'), ); } @@ -856,7 +854,7 @@ void main() { await tester.pump(); expect(find.byType(FloatingNavigationBar), findsNothing); expect(find.byType(NavigationBar), findsOneWidget); - expect(find.text('page:dashboard inBar:false inset:0.0'), findsOneWidget); + expect(find.text('page:dashboard inset:0.0'), findsOneWidget); await tester.tap(_glyph(AppGlyphs.tools)); await tester.pumpAndSettle(); @@ -872,7 +870,8 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(NavigationBar), findsNothing); expect(find.byType(FloatingNavigationBar), findsOneWidget); - expect(find.textContaining('page:tools inBar:true'), findsOneWidget); + expect(find.textContaining('page:tools inset:'), findsOneWidget); + expect(find.text('page:tools inset:0.0'), findsNothing); expect(tester.takeException(), isNull); }); diff --git a/test/views/theme_test.dart b/test/views/theme_test.dart index cea8f61b..c2e5e5d9 100644 --- a/test/views/theme_test.dart +++ b/test/views/theme_test.dart @@ -52,10 +52,10 @@ void main() { ThemeProps readTheme() => container.read(themeSettingProvider); group('theme mode', () { - testWidgets('defaults to the dark theme', (tester) async { + testWidgets('defaults to following the system', (tester) async { await pumpThemeView(tester); - expect(readTheme().themeMode, ThemeMode.dark); + expect(readTheme().themeMode, ThemeMode.system); }); testWidgets('switches to light and back to dark', (tester) async { @@ -126,6 +126,28 @@ void main() { }); }); + group('liquid glass', () { + testWidgets('is off until chosen, and switches back', (tester) async { + await pumpThemeView(tester); + final group = find.ancestor( + of: find.text('Liquid Glass').first, + matching: find.byType(PreviewChoiceGroup), + ); + Finder choice(String label) => + find.descendant(of: group, matching: find.text(label)).last; + + expect(readTheme().liquidGlass, isFalse); + + await tester.tap(choice('Liquid Glass')); + await tester.pumpAndSettle(); + expect(readTheme().liquidGlass, isTrue); + + await tester.tap(choice('Standard')); + await tester.pumpAndSettle(); + expect(readTheme().liquidGlass, isFalse); + }); + }); + group('pure black', () { testWidgets('switches both ways', (tester) async { await pumpThemeView(tester); @@ -136,7 +158,7 @@ void main() { await tester.pumpAndSettle(); expect(readTheme().pureBlack, isTrue); - await tester.tap(find.text('Standard')); + await tester.tap(find.text('Standard').first); await tester.pumpAndSettle(); expect(readTheme().pureBlack, isFalse); }); diff --git a/test/widgets/common_dialog_test.dart b/test/widgets/common_dialog_test.dart index d23a0c3c..7d8da334 100644 --- a/test/widgets/common_dialog_test.dart +++ b/test/widgets/common_dialog_test.dart @@ -1,5 +1,6 @@ import 'package:fl_clash/providers/app.dart'; import 'package:fl_clash/ui/ui.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_ui/material_ui.dart'; @@ -14,6 +15,7 @@ Future _pumpDialog( List labels, { TextDirection textDirection = TextDirection.ltr, ValueChanged? onPressed, + bool destructive = false, }) async { await tester.pumpWidget( TestApp( @@ -24,6 +26,7 @@ Future _pumpDialog( body: Center( child: CommonDialog( title: 'Title', + destructive: destructive, actions: [ for (final label in labels) TextButton( @@ -43,6 +46,18 @@ Future _pumpDialog( Rect _rectOf(WidgetTester tester, String label) => tester.getRect(find.widgetWithText(TextButton, label)); +Color? _labelColor(WidgetTester tester, String label) => + tester.renderObject(find.text(label)).text.style?.color; + +Color? _buttonFill(WidgetTester tester, String label) => tester + .widget( + find.descendant( + of: find.widgetWithText(TextButton, label), + matching: find.byType(Material), + ), + ) + .color; + Future _pumpLauncher(WidgetTester tester) async { await tester.pumpWidget( TestApp( @@ -75,11 +90,11 @@ double _dialogScale(WidgetTester tester) => tester .entry(0, 0); double _dialogOpacity(WidgetTester tester) => tester - .widget( + .widget( find .ancestor( of: find.byType(CommonDialog), - matching: find.byType(FadeTransition), + matching: find.byType(LiquidGlassFade), ) .first, ) @@ -99,6 +114,20 @@ void main() { expect(no.right, lessThan(yes.left)); }); + testWidgets('a destructive last action is red on the plain fill', ( + tester, + ) async { + await _pumpDialog(tester, ['Cancel', 'Delete'], destructive: true); + final colorScheme = Theme.of( + tester.element(find.text('Delete')), + ).colorScheme; + + expect(_labelColor(tester, 'Delete'), colorScheme.destructive); + expect(_labelColor(tester, 'Cancel'), colorScheme.onSurface); + expect(_buttonFill(tester, 'Delete'), _buttonFill(tester, 'Cancel')); + expect(_buttonFill(tester, 'Delete'), isNot(colorScheme.primary)); + }); + testWidgets('a row follows the reading direction', (tester) async { await _pumpDialog(tester, ['No', 'Yes'], textDirection: TextDirection.rtl); @@ -163,6 +192,48 @@ void main() { expect(find.byType(CommonDialog), findsNothing); }); + testWidgets('on glass a pane lies under the surface and fades with it', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + builder: (_, child) => LiquidGlassScope( + enabled: true, + child: LiquidGlassHost(enabled: true, child: child!), + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showFloatingDialog( + context: context, + child: const CommonDialog(title: 'Title', child: Text('Body')), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(_dialogOpacity(tester), inExclusiveRange(0, 1)); + expect(LiquidGlassHost.debugLastCapture!.panes, hasLength(1)); + + await tester.pumpAndSettle(); + final surface = find + .descendant( + of: find.byType(AlertDialog), + matching: find.byType(Material), + ) + .first; + expect( + LiquidGlassHost.debugLastCapture!.panes.single.pane, + rectMoreOrLessEquals(tester.getRect(surface)), + ); + }); + testWidgets('a tap on the scrim dismisses a dismissible dialog', ( tester, ) async { diff --git a/test/widgets/liquid_glass_host_test.dart b/test/widgets/liquid_glass_host_test.dart new file mode 100644 index 00000000..d6fb2040 --- /dev/null +++ b/test/widgets/liquid_glass_host_test.dart @@ -0,0 +1,444 @@ +import 'dart:ui' as ui; + +import 'package:fl_clash/widgets/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +const _red = Color(0xFFFF0000); +const _green = Color(0xFF00FF00); +const _blue = Color(0xFF0000FF); +const _clear = LiquidGlassStyle(tint: Colors.transparent, blur: 0); + +typedef _Pane = ({ + Rect pane, + Rect slot, + Rect place, + double scale, + int level, + ui.Image atlas, +}); + +Widget _app( + Widget child, { + bool enabled = true, + LiquidGlassQuality quality = LiquidGlassQuality.ultra, +}) => MaterialApp( + home: LiquidGlassScope( + enabled: true, + quality: quality, + child: LiquidGlassHost(enabled: enabled, child: child), + ), +); + +Widget _pane(Rect rect, {LiquidGlassStyle style = _clear, Widget? child}) => + Positioned.fromRect( + rect: rect, + child: LiquidGlass(style: style, child: child), + ); + +LiquidGlassCapture _capture() => LiquidGlassHost.debugLastCapture!; + +class _HeldPane extends LeafRenderObjectWidget { + const _HeldPane(this.rect, this.hold); + + final Rect rect; + final Rect hold; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderHeldPane(rect, hold); + + @override + void updateRenderObject(BuildContext context, _RenderHeldPane renderObject) { + renderObject + ..rect = rect + ..hold = hold + ..markNeedsPaint(); + } +} + +class _RenderHeldPane extends RenderBox { + _RenderHeldPane(this.rect, this.hold); + + Rect rect; + Rect hold; + late final _pane = LiquidGlassPane(markNeedsPaint); + + @override + bool get sizedByParent => true; + + @override + bool get alwaysNeedsCompositing => true; + + @override + Size computeDryLayout(BoxConstraints constraints) => constraints.biggest; + + @override + void dispose() { + _pane.dispose(); + super.dispose(); + } + + @override + void paint(PaintingContext context, Offset offset) { + _pane.paint( + context, + rect.shift(offset), + shape: LiquidGlassCorners.circular(0), + style: _clear, + hold: hold.shift(offset), + ); + } +} + +Future _behind(WidgetTester tester, _Pane pane, Offset point) async { + final texel = pane.place.topLeft + (point - pane.slot.topLeft) * pane.scale; + final bytes = (await tester.runAsync(pane.atlas.toByteData))!; + final offset = (texel.dy.floor() * pane.atlas.width + texel.dx.floor()) * 4; + return Color.fromARGB( + bytes.getUint8(offset + 3), + bytes.getUint8(offset), + bytes.getUint8(offset + 1), + bytes.getUint8(offset + 2), + ); +} + +void main() { + setUp(() => LiquidGlassHost.debugLastCapture = null); + + testWidgets('a pane reads what lies behind it and not what it holds', ( + tester, + ) async { + await tester.pumpWidget( + _app( + Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: _red)), + _pane( + const Rect.fromLTWH(100, 100, 200, 100), + child: const ColoredBox(color: _blue), + ), + ], + ), + ), + ); + + final pane = _capture().panes.single; + expect(pane.pane, const Rect.fromLTWH(100, 100, 200, 100)); + expect(pane.level, 0); + expect(await _behind(tester, pane, const Offset(200, 150)), _red); + }); + + testWidgets('a frame that leaves the backdrop as it was keeps the capture', ( + tester, + ) async { + Widget scene(Color behind) => _app( + Stack( + children: [ + Positioned.fill(child: ColoredBox(color: behind)), + _pane(const Rect.fromLTWH(100, 100, 200, 100)), + ], + ), + ); + + await tester.pumpWidget(scene(_red)); + final first = _capture().panes.single.atlas; + expect(_capture().snapshots, 1); + + tester.binding.scheduleFrame(); + await tester.pump(); + expect(_capture().snapshots, 0); + expect(_capture().panes.single.atlas, same(first)); + + await tester.pumpWidget(scene(_green)); + expect(_capture().snapshots, 1); + final pane = _capture().panes.single; + expect(await _behind(tester, pane, const Offset(200, 150)), _green); + }); + + testWidgets('a pane moving within its hold keeps one capture', ( + tester, + ) async { + const hold = Rect.fromLTWH(50, 50, 400, 300); + Widget scene(Rect rect) => _app( + Stack( + children: [ + const Positioned.fill( + child: RepaintBoundary(child: ColoredBox(color: _red)), + ), + Positioned.fill(child: _HeldPane(rect, hold)), + ], + ), + ); + + await tester.pumpWidget(scene(const Rect.fromLTWH(100, 100, 50, 50))); + expect(_capture().snapshots, 1); + final first = _capture().panes.single; + expect(first.slot.contains(hold.bottomRight - const Offset(1, 1)), isTrue); + + await tester.pumpWidget(scene(const Rect.fromLTWH(200, 150, 200, 150))); + expect(_capture().snapshots, 0); + expect(_capture().panes.single.slot, first.slot); + expect(_capture().panes.single.atlas, same(first.atlas)); + + await tester.pumpWidget(scene(const Rect.fromLTWH(500, 500, 50, 50))); + expect(_capture().snapshots, 1); + }); + + testWidgets('a pane over another reads a capture with that one drawn in', ( + tester, + ) async { + await tester.pumpWidget( + _app( + Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: _red)), + _pane( + const Rect.fromLTWH(100, 100, 200, 100), + style: const LiquidGlassStyle(tint: _blue, blur: 0), + ), + _pane(const Rect.fromLTWH(250, 150, 100, 100)), + _pane(const Rect.fromLTWH(500, 400, 100, 100)), + ], + ), + ), + ); + + final [lower, upper, apart] = _capture().panes; + expect([lower.level, upper.level, apart.level], [0, 1, 0]); + expect(upper.atlas, isNot(same(lower.atlas))); + expect(apart.atlas, same(lower.atlas)); + final over = await _behind(tester, upper, const Offset(275, 175)); + expect(over.b, greaterThan(over.r)); + expect(await _behind(tester, upper, const Offset(325, 225)), _red); + }); + + testWidgets('a pane stays on its level beside one past its frost', ( + tester, + ) async { + const frosted = LiquidGlassStyle(tint: Colors.transparent); + Future levelBeside(double gap) async { + await tester.pumpWidget( + _app( + Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: _red)), + _pane(const Rect.fromLTWH(100, 100, 200, 100), style: frosted), + _pane(Rect.fromLTWH(300 + gap, 100, 100, 100), style: frosted), + ], + ), + ), + ); + return _capture().panes.last.level; + } + + expect(await levelBeside(8), 0); + expect(await levelBeside(4), 1); + }); + + Widget bandsScene(Color changing) => _app( + Stack( + children: [ + Positioned( + left: 0, + top: 0, + width: 400, + height: 300, + child: RepaintBoundary(child: ColoredBox(color: changing)), + ), + const Positioned( + left: 0, + top: 300, + width: 400, + height: 300, + child: RepaintBoundary(child: ColoredBox(color: _blue)), + ), + _pane(const Rect.fromLTWH(100, 100, 200, 100)), + _pane( + const Rect.fromLTWH(100, 420, 200, 100), + style: const LiquidGlassStyle(tint: Colors.transparent, blur: 12), + ), + ], + ), + ); + + testWidgets('a level redraws only the bands whose backdrop changed', ( + tester, + ) async { + LiquidGlassHost.debugCarryBandsOverride = true; + addTearDown(() => LiquidGlassHost.debugCarryBandsOverride = null); + + await tester.pumpWidget(bandsScene(_red)); + expect(_capture().snapshots, 1); + expect(_capture().keptBands, 0); + + await tester.pumpWidget(bandsScene(_green)); + expect(_capture().snapshots, 1); + expect(_capture().keptBands, 1); + final [changed, kept] = _capture().panes; + expect(await _behind(tester, changed, const Offset(200, 150)), _green); + expect(await _behind(tester, kept, const Offset(200, 470)), _blue); + }); + + testWidgets( + 'without Impeller a level redraws every band, copying from no capture', + (tester) async { + await tester.pumpWidget(bandsScene(_red)); + await tester.pumpWidget(bandsScene(_green)); + expect(_capture().snapshots, 1); + expect(_capture().keptBands, 0); + final [changed, kept] = _capture().panes; + expect(await _behind(tester, changed, const Offset(200, 150)), _green); + expect(await _behind(tester, kept, const Offset(200, 470)), _blue); + }, + skip: ui.ImageFilter.isShaderFilterSupported, + ); + + testWidgets('the standard tier frosts a hazed pane through its haze alone', ( + tester, + ) async { + const hazed = LiquidGlassStyle( + tint: Colors.transparent, + blur: 0, + haze: 0.72, + hazeBlur: 15.6, + ); + Future scaleAt(LiquidGlassQuality quality) async { + await tester.pumpWidget( + _app( + Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: _red)), + _pane(const Rect.fromLTWH(100, 100, 200, 100), style: hazed), + ], + ), + quality: quality, + ), + ); + return _capture().panes.single.scale; + } + + expect( + await scaleAt(LiquidGlassQuality.ultra), + tester.view.devicePixelRatio, + ); + expect(await scaleAt(LiquidGlassQuality.standard), lessThan(1)); + }); + + testWidgets('a pane under a layer of its own reads only that layer', ( + tester, + ) async { + await tester.pumpWidget( + _app( + Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: _red)), + Positioned.fill( + child: Opacity( + opacity: 0.5, + child: Stack( + children: [ + const Positioned( + left: 100, + top: 100, + width: 100, + height: 100, + child: ColoredBox(color: _green), + ), + _pane(const Rect.fromLTWH(100, 100, 200, 100)), + ], + ), + ), + ), + ], + ), + ), + ); + + final pane = _capture().panes.single; + expect(await _behind(tester, pane, const Offset(150, 150)), _green); + expect((await _behind(tester, pane, const Offset(250, 150))).a, 0); + }); + + testWidgets('a pane under a glass fade reads through it', (tester) async { + await tester.pumpWidget( + _app( + Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: _red)), + Positioned.fill( + child: LiquidGlassFade( + opacity: const AlwaysStoppedAnimation(0.5), + child: Stack( + children: [ + const Positioned( + left: 100, + top: 100, + width: 100, + height: 100, + child: ColoredBox(color: _green), + ), + _pane(const Rect.fromLTWH(100, 100, 200, 100)), + ], + ), + ), + ), + ], + ), + ), + ); + + final pane = _capture().panes.single; + final faded = await _behind(tester, pane, const Offset(150, 150)); + expect(faded.r, inExclusiveRange(0, 1)); + expect(faded.g, inExclusiveRange(0, 1)); + expect(await _behind(tester, pane, const Offset(250, 150)), _red); + }); + + testWidgets('a frosted pane reads a capture that holds its blur', ( + tester, + ) async { + await tester.pumpWidget( + _app( + Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: _red)), + const Positioned( + left: 200, + top: 0, + right: 0, + bottom: 0, + child: ColoredBox(color: _blue), + ), + _pane( + const Rect.fromLTWH(100, 100, 200, 100), + style: const LiquidGlassStyle(tint: Colors.transparent, blur: 12), + ), + ], + ), + ), + ); + + final pane = _capture().panes.single; + expect(pane.scale, lessThan(1)); + final near = await _behind(tester, pane, const Offset(194, 150)); + expect(near.b, inExclusiveRange(0.15, 0.5)); + expect(await _behind(tester, pane, const Offset(120, 150)), _red); + }); + + testWidgets('a host turned off captures nothing', (tester) async { + await tester.pumpWidget( + _app( + Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: _red)), + _pane(const Rect.fromLTWH(100, 100, 200, 100)), + ], + ), + enabled: false, + ), + ); + + expect(LiquidGlassHost.debugLastCapture, isNull); + }); +} diff --git a/test/widgets/liquid_glass_test.dart b/test/widgets/liquid_glass_test.dart new file mode 100644 index 00000000..d6c13412 --- /dev/null +++ b/test/widgets/liquid_glass_test.dart @@ -0,0 +1,169 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:fl_clash/widgets/widgets.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + test('a profile reads how far an outline reaches each way', () { + final box = liquidGlassProfile( + Path()..addRect(const Rect.fromLTWH(0, 0, 40, 20)), + const Offset(20, 10), + ); + expect(box[0], moreOrLessEquals(20, epsilon: 1e-6)); + expect(box[16], moreOrLessEquals(10, epsilon: 1e-6)); + expect(box[32], moreOrLessEquals(20, epsilon: 1e-6)); + expect(box[48], moreOrLessEquals(10, epsilon: 1e-6)); + expect(box[8], moreOrLessEquals(10 * math.sqrt2, epsilon: 1e-6)); + + final round = liquidGlassProfile( + Path()..addOval(Rect.fromCircle(center: Offset.zero, radius: 30)), + Offset.zero, + ); + expect(round, everyElement(moreOrLessEquals(30, epsilon: 0.05))); + }); + + test('corner curves lie on the superellipse the engine clips to', () { + for (final (size, radius) in [ + (const Size(338, 62), 31.0), + (const Size(84, 54), 27.0), + (const Size(44, 44), 22.0), + (const Size(270, 160), 34.0), + (const Size(60, 200), 30.0), + ]) { + final corners = BorderRadius.circular(radius); + final path = Path() + ..addRSuperellipse(corners.toRSuperellipse(Offset.zero & size)); + final outline = [ + for (final metric in path.computeMetrics()) + for (var at = 0.0; at < metric.length; at += 0.02) + metric.getTangentForOffset(at)!.position, + ]; + double offPath(Offset point) => + outline.map((on) => (on - point).distance).reduce(math.min); + + final curves = liquidGlassCornerCurves(size, corners); + final half = size.center(Offset.zero); + final shift = half.dx - half.dy; + final octants = [ + ( + a: half.dx, + curve: curves.sublist(0, 4), + center: Offset(curves[8], curves[9]), + place: (Offset w) => half - Offset(w.dx, w.dy - shift), + ), + ( + a: half.dy, + curve: curves.sublist(4, 8), + center: Offset(curves[10], curves[11]), + place: (Offset w) => half - Offset(w.dy + shift, w.dx), + ), + ]; + for (final (:a, :curve, :center, :place) in octants) { + final [n, jx, jy, arc] = curve; + for (var x = 0.0; x <= jx; x += jx / 16) { + final y = math.pow(1 - math.pow(x / a, n), 1 / n) * a; + expect(offPath(place(Offset(x, y))), lessThan(0.06)); + } + final from = (Offset(jx, jy) - center).direction; + for (var step = 0; step <= 8; step++) { + final turn = from - (from - math.pi / 4) * step / 8; + final point = center + Offset.fromDirection(turn, arc); + expect(offPath(place(point)), lessThan(0.06)); + } + } + } + expect( + liquidGlassCornerCurves(const Size(40, 20), BorderRadius.zero), + everyElement(0), + ); + }); + + testWidgets('a pane shaped by a clipper keeps to its path', (tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RepaintBoundary( + key: key, + child: const SizedBox.square( + dimension: 40, + child: LiquidGlass( + style: LiquidGlassStyle(tint: Colors.blue), + clipper: _Diamond(), + ), + ), + ), + ), + ), + ); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = boundary.toImageSync(); + addTearDown(image.dispose); + + await tester.runAsync(() async { + final bytes = (await image.toByteData())!; + int alphaAt(int x, int y) => + bytes.getUint8((y * image.width + x) * 4 + 3); + expect(alphaAt(5, 5), 0); + expect(alphaAt(34, 34), 0); + expect(alphaAt(20, 20), greaterThan(0)); + expect(alphaAt(20, 3), greaterThan(0)); + }); + }); + + testWidgets('a snapshot taken without panes holds only what stands on them', ( + tester, + ) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RepaintBoundary( + key: key, + child: const SizedBox.square( + dimension: 40, + child: LiquidGlass(style: LiquidGlassStyle(tint: Colors.blue)), + ), + ), + ), + ), + ); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final bare = LiquidGlass.withoutPanes(boundary.toImageSync); + final glass = boundary.toImageSync(); + addTearDown(bare.dispose); + addTearDown(glass.dispose); + + Future centerAlpha(ui.Image image) async { + final bytes = (await image.toByteData())!; + return bytes.getUint8( + ((image.height ~/ 2) * image.width + image.width ~/ 2) * 4 + 3, + ); + } + + await tester.runAsync(() async { + expect(await centerAlpha(bare), 0); + expect(await centerAlpha(glass), greaterThan(0)); + }); + }); +} + +class _Diamond extends CustomClipper { + const _Diamond(); + + @override + Path getClip(Size size) => Path() + ..moveTo(size.width / 2, 0) + ..lineTo(size.width, size.height / 2) + ..lineTo(size.width / 2, size.height) + ..lineTo(0, size.height / 2) + ..close(); + + @override + bool shouldReclip(_Diamond oldClipper) => false; +} diff --git a/test/widgets/navigation_dock_test.dart b/test/widgets/navigation_dock_test.dart index 88116dd1..c78d430e 100644 --- a/test/widgets/navigation_dock_test.dart +++ b/test/widgets/navigation_dock_test.dart @@ -1,13 +1,13 @@ +import 'dart:math' as math; import 'dart:typed_data'; import 'package:fl_clash/common/app_ports.dart'; import 'package:fl_clash/enum/enum.dart'; -import 'package:fl_clash/icons/icons.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/pages/home.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; -import 'package:fl_clash/views/dashboard/widgets/start_button.dart'; +import 'package:fl_clash/ui/icons/icons.dart'; import 'package:fl_clash/views/navigation.dart'; import 'package:fl_clash/widgets/widgets.dart'; import 'package:flutter/gestures.dart'; @@ -27,12 +27,11 @@ final _lens = find.descendant( ); const _addLabel = 'Add'; -IconButtonData _addAction({bool isLoading = false}) { +IconButtonData _addAction() { return IconButtonData( glyph: AppGlyphs.add, onPressed: () {}, tooltip: _addLabel, - isLoading: isLoading, ); } @@ -124,19 +123,13 @@ void main() { addTearDown(() => navigationPort = null); }); - testWidgets('the start button holds the end of the dock on the dashboard', ( - tester, - ) async { + testWidgets('the dock stays centred alone on every page, the dashboard ' + 'included', (tester) async { final container = await _pumpHome(tester); final bar = tester.getRect(find.byType(FloatingNavigationBar)); - final button = tester.getRect(find.byType(FloatingActionButton)); - expect(find.byType(StartButton), findsOneWidget); - expect(button.width, button.height); - expect(button.height, bar.height); - expect(button.center.dy, bar.center.dy); - expect(button.left - bar.right, 8); - expect((bar.left + button.right) / 2, _mobileSize.width / 2); + expect(find.byType(FloatingActionButton), findsNothing); + expect(bar.center.dx, _mobileSize.width / 2); await tester.drag( find.byType(FloatingNavigationBar), @@ -145,7 +138,6 @@ void main() { ); await tester.pumpAndSettle(); expect(container.read(currentPageLabelProvider), PageLabel.tools); - expect(find.byType(StartButton), findsNothing); final alone = tester.getRect(find.byType(FloatingNavigationBar)); expect(alone.center.dx, _mobileSize.width / 2); expect(alone.width, lessThan(_mobileSize.width - 40)); @@ -194,6 +186,35 @@ void main() { await tester.pumpAndSettle(); }); + testWidgets('the lens moving repaints the dock and not the page', ( + tester, + ) async { + await _pumpHome(tester); + final page = tester.renderObject( + find + .ancestor( + of: find.byType(HomePage), + matching: find.byType(RepaintBoundary), + ) + .first, + ); + final bar = tester.getRect(find.byType(FloatingNavigationBar)); + final extent = (bar.width - 8) / 3; + var finger = Offset(bar.left + 4 + extent / 2, bar.center.dy); + final gesture = await tester.startGesture(finger); + await tester.pump(const Duration(milliseconds: 16)); + page.debugResetMetrics(); + for (var i = 0; i < 10; i++) { + finger += Offset(extent / 10, 0); + await gesture.moveTo(finger); + await tester.pump(const Duration(milliseconds: 16)); + } + + expect(page.debugSymmetricPaintCount + page.debugAsymmetricPaintCount, 0); + await gesture.up(); + await tester.pumpAndSettle(); + }); + testWidgets('a flick carries the lens on to the next item', (tester) async { final container = await _pumpHome(tester); final bar = tester.getRect(find.byType(FloatingNavigationBar)); @@ -231,43 +252,79 @@ void main() { expect(tester.getRect(_lens), resting); }); - testWidgets( - 'the docked start button swells and follows a drag like the lens', - (tester) async { - await _pumpHome(tester); - final press = find.ancestor( - of: find.byType(FloatingActionButton), - matching: find.byType(ElasticPress), - ); - final resting = tester.getRect(press); - Rect painted() => _painted( - tester, - tester.renderObject(press).child!, - resting, - ); + testWidgets('a quick tap lifts the lens all the way before it drops', ( + tester, + ) async { + await _pumpHome(tester); + final resting = tester.getRect(_lens); + final hold = await tester.startGesture(resting.center); + await tester.pumpAndSettle(); + final held = tester.getRect(_lens).height - resting.height; + await hold.up(); + await tester.pumpAndSettle(); - final gesture = await tester.startGesture(resting.center); - await tester.pumpAndSettle(); - final pressed = painted(); - expect(tester.getRect(press), resting); - expect(pressed.center, resting.center); - expect(pressed.width, greaterThan(resting.width + 8)); - expect( - pressed.width / pressed.height, - moreOrLessEquals(resting.width / resting.height), - ); + final tap = await tester.startGesture(resting.center); + await tester.pump(const Duration(milliseconds: 40)); + await tap.up(); + var lifted = 0.0; + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + lifted = math.max(lifted, tester.getRect(_lens).height - resting.height); + } + expect(lifted, greaterThan(held * 0.97)); - await gesture.moveBy(const Offset(-60, 0)); + await tester.pumpAndSettle(); + expect(tester.getRect(_lens), resting); + }); + + for (final (quality, lifted) in [ + (LiquidGlassQuality.standard, 2), + (LiquidGlassQuality.ultra, 3), + ]) { + testWidgets('on ${quality.name} glass the dock and a lifted lens are panes ' + 'of glass', (tester) async { + tester.view.physicalSize = _mobileSize; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + TestApp( + wrapInProviderScope: true, + homeBuilder: (child) => Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: LiquidGlassScope( + enabled: true, + quality: quality, + child: child, + ), + ), + ), + child: NavigationDock( + destinations: const [ + NavigationDockDestination(glyph: AppGlyphs.dashboard, label: 'a'), + NavigationDockDestination(glyph: AppGlyphs.tools, label: 'b'), + ], + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + ); await tester.pumpAndSettle(); - final pulled = painted(); - expect(pulled.center.dx, lessThan(resting.center.dx - 4)); - expect(pulled.width / pulled.height, greaterThan(1)); + final glass = find.byType(LiquidGlass); + expect(glass, findsOneWidget); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('b')), + ); + await tester.pumpAndSettle(); + expect(glass, findsNWidgets(lifted)); await gesture.up(); await tester.pumpAndSettle(); - expect(painted(), resting); - }, - ); + expect(glass, findsOneWidget); + expect(tester.takeException(), isNull); + }); + } testWidgets('the bar swells under a pointer and stretches past its end', ( tester, @@ -341,8 +398,8 @@ void main() { expect(drawn, moreOrLessEquals(highlight / 2, epsilon: 0.5)); expect(highlight, lessThan(30)); expect( - tester.getCenter(positioned.first).dx, - moreOrLessEquals(tester.getCenter(positioned.last).dx), + tester.getRect(positioned.first), + rectMoreOrLessEquals(tester.getRect(positioned.last)), ); await mouse.moveTo(resting.center - const Offset(80, 0)); @@ -397,93 +454,31 @@ void main() { expect(positioned, findsOneWidget); }); - testWidgets('a phone page shows its primary action in its bar', ( - tester, - ) async { - final container = await _pumpHome(tester); - container - .read(currentPageLabelProvider.notifier) - .toPage(PageLabel.profiles); - await tester.pumpAndSettle(); + for (final floating in [true, false]) { + testWidgets('a phone page shows its primary action in its bar above a ' + '${floating ? 'floating' : 'fixed'} navigation bar', (tester) async { + final container = await _pumpHome(tester); + container + .read(appSettingProvider.notifier) + .update((state) => state.copyWith(floatingNavigationBar: floating)); + container + .read(currentPageLabelProvider.notifier) + .toPage(PageLabel.profiles); + await tester.pumpAndSettle(); - final action = find.byWidgetPredicate( - (widget) => - widget is AppBarActionButton && widget.data.tooltip == _addLabel, - ); - expect(action, findsOneWidget); - expect( - find.ancestor(of: action, matching: find.byType(AppBar)), - findsOneWidget, - ); - expect( - find.descendant( - of: find.byType(CommonScaffold), - matching: find.byType(FloatingActionButton), - ), - findsNothing, - ); - expect(tester.takeException(), isNull); - }); - - testWidgets( - 'the docked start button stays an icon-only circle while it runs', - (tester) async { - await _pumpHome( - tester, - settle: false, - overrides: [ - runTimeProvider.overrideWithBuild( - (_, _) => const Duration( - hours: 2, - minutes: 13, - seconds: 8, - ).inMilliseconds, - ), - ], + final action = find.byWidgetPredicate( + (widget) => + widget is AppBarActionButton && widget.data.tooltip == _addLabel, ); - - final bar = tester.getRect(find.byType(FloatingNavigationBar)); - final button = tester.getRect(find.byType(FloatingActionButton)); - expect(button.width, button.height); - expect(button.height, bar.height); - expect(find.text('02:13:08'), findsNothing); - expect(find.byType(RunTimeText), findsNothing); + expect(action, findsOneWidget); + expect( + find.ancestor(of: action, matching: find.byType(AppBar)), + findsOneWidget, + ); + expect(find.byType(FloatingActionButton), findsNothing); expect(tester.takeException(), isNull); - }, - ); - - testWidgets('a page off the dock shows its primary action as a FAB', ( - tester, - ) async { - await tester.pumpWidget( - TestApp( - wrapInProviderScope: true, - child: _page('page', primaryAction: _addAction()), - ), - ); - - final fab = find.byType(FloatingActionButton); - expect(fab, findsOneWidget); - expect(find.text(_addLabel), findsOneWidget); - expect(find.byType(AppBarActionButton), findsNothing); - - await tester.pumpWidget( - TestApp( - wrapInProviderScope: true, - child: _page('page', primaryAction: _addAction(isLoading: true)), - ), - ); - await tester.pumpAndSettle(); - expect( - tester - .widget( - find.ancestor(of: fab, matching: find.byType(AnimatedScale)), - ) - .scale, - 0, - ); - expect(tester.takeException(), isNull); - }); + }); + } testWidgets('four destinations keep to their width and five fill the row', ( tester, @@ -528,7 +523,7 @@ void main() { testWidgets('a long label shrinks to fit, then ellipsizes behind a tooltip', ( tester, ) async { - tester.view.physicalSize = const Size(360, 800); + tester.view.physicalSize = const Size(340, 800); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -580,6 +575,39 @@ void main() { expect(tester.widget(tooltips).message, 'Configuration'); }); + testWidgets('a quick tap swells an elastic press all the way', ( + tester, + ) async { + await tester.pumpWidget( + const TestApp( + child: Center( + child: ElasticPress( + child: ColoredBox( + color: Colors.blue, + child: SizedBox.square(dimension: 40), + ), + ), + ), + ), + ); + final press = find.byType(ElasticPress); + final resting = tester.getRect(press); + Rect painted() => _painted(tester, tester.renderObject(press), resting); + + final gesture = await tester.startGesture(resting.center); + await tester.pump(const Duration(milliseconds: 40)); + await gesture.up(); + var swollen = resting.width; + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + swollen = math.max(swollen, painted().width); + } + expect(swollen, greaterThan(resting.width * 1.24)); + + await tester.pumpAndSettle(); + expect(painted(), resting); + }); + testWidgets( 'an elastic button leaves a press to the swell and keeps the icon theme', (tester) async { diff --git a/test/widgets/popup_test.dart b/test/widgets/popup_test.dart index 48f071d1..56c3a673 100644 --- a/test/widgets/popup_test.dart +++ b/test/widgets/popup_test.dart @@ -1,6 +1,13 @@ -import 'package:fl_clash/icons/icons.dart'; -import 'package:fl_clash/widgets/popup.dart'; +import 'dart:typed_data'; + +import 'package:fl_clash/ui/icons/icons.dart'; +import 'package:fl_clash/ui/liquid_glass.dart'; +import 'package:fl_clash/ui/navigation_dock.dart'; +import 'package:fl_clash/ui/popup.dart'; +import 'package:fl_clash/ui/surface.dart'; +import 'package:flutter/gestures.dart' show PointerDeviceKind, kPressTimeout; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,27 +17,15 @@ import '../helpers/test_app.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final surfaces = find.byWidgetPredicate( - (widget) => widget is Material && widget.type == MaterialType.card, - ); + final surfaces = find.byType(PopupMenuCard); - double shadowOf(WidgetTester tester, Finder surface) { - final box = tester.widget( - find - .ancestor( - of: surface, - matching: find.byWidgetPredicate( - (widget) => - widget is DecoratedBox && - widget.decoration is ShapeDecoration && - (widget.decoration as ShapeDecoration).shadows != null, - ), - ) - .first, - ); - final shadows = (box.decoration as ShapeDecoration).shadows!; - return shadows.fold(0.0, (sum, shadow) => sum + shadow.color.a); - } + PopupMenuCard cardOf(WidgetTester tester, Finder surface) => + tester.widget(surface); + + double shadowOf(WidgetTester tester, Finder surface) => cardOf( + tester, + surface, + ).shadows.fold(0.0, (sum, shadow) => sum + shadow.color.a); Rect shrinkingContent(WidgetTester tester) { final content = tester.layers.whereType().last; @@ -42,6 +37,27 @@ void main() { ).shift(popup.topLeft); } + Rect painted(WidgetTester tester, Finder press) { + final resting = tester.getRect(press); + Matrix4? transform; + expect( + tester.renderObject(press).child, + paints..everything((method, arguments) { + if (method == #transform) { + transform ??= Matrix4.fromFloat64List( + arguments.single as Float64List, + ); + } + return true; + }), + ); + final local = Offset.zero & resting.size; + return (transform == null + ? local + : MatrixUtils.transformRect(transform!, local)) + .shift(resting.topLeft); + } + Future pumpBox(WidgetTester tester) async { late PopupOpen open; await tester.pumpWidget( @@ -65,8 +81,9 @@ void main() { Future pumpMenu( WidgetTester tester, - List items, - ) async { + List items, { + int? maxVisibleItems, + }) async { late PopupOpen open; await tester.pumpWidget( TestApp( @@ -76,7 +93,8 @@ void main() { open = value; return const SizedBox(width: 40, height: 40); }, - popupBuilder: (_) => CommonPopupMenu(items: items), + popupBuilder: (_) => + CommonPopupMenu(items: items, maxVisibleItems: maxVisibleItems), ), ), ); @@ -92,6 +110,77 @@ void main() { expect(find.byKey(const Key('popup')), findsOneWidget); }); + testWidgets('a menu over a button at the top covers the bar it stands in', ( + tester, + ) async { + late PopupOpen open; + await tester.pumpWidget( + TestApp( + homeBuilder: (child) => Scaffold(body: child), + child: Padding( + padding: const EdgeInsets.only(top: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 40, height: 40, key: Key('neighbour')), + CommonPopupBox( + targetBuilder: (value) { + open = value; + return const SizedBox(width: 40, height: 40); + }, + popupBuilder: (_) => CommonPopupMenu( + items: [CommonPopupMenuItem(label: 'one', onPressed: () {})], + ), + ), + ], + ), + ), + ), + ); + + open(); + await tester.pumpAndSettle(); + + expect( + tester.getRect(find.byType(PopupMenuCard)).top, + lessThan(tester.getRect(find.byKey(const Key('neighbour'))).top), + ); + }); + + testWidgets('a popup fills from the elevated surface it opened on', ( + tester, + ) async { + const lifted = Color(0xFF505050); + late PopupOpen open; + await tester.pumpWidget( + TestApp( + theme: ThemeData( + extensions: const [ + ElevatedColorScheme(ColorScheme.dark(surfaceContainerHigh: lifted)), + ], + ), + homeBuilder: (child) => Scaffold(body: Center(child: child)), + child: ElevatedSurfaceTheme( + child: CommonPopupBox( + targetBuilder: (value) { + open = value; + return const SizedBox(width: 40, height: 40); + }, + popupBuilder: (_) => CommonPopupMenu( + items: [CommonPopupMenuItem(label: 'one', onPressed: () {})], + ), + ), + ), + ), + ); + + open(); + await tester.pumpAndSettle(); + + expect(cardOf(tester, find.byType(PopupMenuCard)).fill, lifted); + }); + testWidgets('the popup is only built once it opens', (tester) async { var builds = 0; await tester.pumpWidget( @@ -416,6 +505,64 @@ void main() { await tester.pumpAndSettle(); }); + testWidgets('a popup from a point shrinks toward it as it fades', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (value) { + context = value; + return const SizedBox.expand(); + }, + ), + ), + ); + const point = Offset(100, 120); + Navigator.of(context).push( + CommonPopupRoute( + barrierLabel: 'Dismiss', + placement: PopupPlacement.belowPoint, + anchorOf: () => point & Size.zero, + builder: (_) => + const SizedBox(width: 80, height: 80, key: Key('popup')), + ), + ); + await tester.pumpAndSettle(); + + Navigator.of(context).pop(); + await tester.pump(); + var alpha = 255; + var faded = false; + while (find.byKey(const Key('popup')).evaluate().isNotEmpty) { + await tester.pump(const Duration(milliseconds: 16)); + final opacity = tester.layers + .whereType() + .where((layer) => layer.parent is TransformLayer) + .lastOrNull; + if (opacity == null) { + continue; + } + final transform = (opacity.parent! as TransformLayer).transform!; + final scale = transform.entry(0, 0); + expect(scale, greaterThanOrEqualTo(0.8)); + expect( + MatrixUtils.transformPoint(transform, point), + offsetMoreOrLessEquals(point), + ); + expect( + 1 - opacity.alpha! / 255, + moreOrLessEquals((1 - scale) / 0.2, epsilon: 0.01), + ); + expect(tester.layers.whereType(), isEmpty); + expect(opacity.alpha!, lessThanOrEqualTo(alpha)); + alpha = opacity.alpha!; + faded = faded || alpha < 64; + } + expect(faded, isTrue); + }); + testWidgets('dismissing the barrier closes the popup', (tester) async { final open = await pumpBox(tester); open(); @@ -644,6 +791,738 @@ void main() { expect(find.text('run'), findsNothing); }); + testWidgets('a menu swells under a press and stretches only past its edges', ( + tester, + ) async { + var pressed = 0; + final open = await pumpMenu(tester, [ + CommonPopupMenuItem(label: 'one', onPressed: () => pressed++), + CommonPopupMenuItem(label: 'two', onPressed: () => pressed++), + ]); + open(); + await tester.pumpAndSettle(); + final press = find.descendant( + of: find.byType(CommonPopupMenu), + matching: find.byType(ElasticPress), + ); + final resting = tester.getRect(press); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('one')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + final swollen = painted(tester, press); + expect(swollen.width, greaterThan(resting.width + 4)); + expect(swollen.center.dx, moreOrLessEquals(resting.center.dx)); + + await gesture.moveTo(tester.getCenter(find.text('two'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect( + painted(tester, press).center.dx, + moreOrLessEquals(swollen.center.dx), + ); + expect( + painted(tester, press).center.dy, + moreOrLessEquals(swollen.center.dy), + ); + + await gesture.moveTo(Offset(resting.left - 80, resting.center.dy)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + final pulled = painted(tester, press); + expect(pulled.center.dx, lessThan(resting.center.dx - 4)); + expect( + pulled.width / pulled.height, + greaterThan(resting.width / resting.height), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(painted(tester, press), resting); + expect(pressed, 0); + expect(find.text('one'), findsOneWidget); + }); + + bool pressedRow(WidgetTester tester, String label) { + final box = tester.widget( + find + .ancestor(of: find.text(label), matching: find.byType(DecoratedBox)) + .first, + ); + return (box.decoration as ShapeDecoration).color != null; + } + + List recordHaptics(WidgetTester tester) { + final haptics = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'HapticFeedback.vibrate') { + haptics.add(call.arguments); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + return haptics; + } + + testWidgets('a press follows the finger and picks the item let go on', ( + tester, + ) async { + final clicks = recordHaptics(tester); + final picked = []; + final open = await pumpMenu(tester, [ + for (final label in ['one', 'two', 'three']) + CommonPopupMenuItem(label: label, onPressed: () => picked.add(label)), + ]); + open(); + await tester.pumpAndSettle(); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('one')), + ); + await tester.pump(); + expect(pressedRow(tester, 'one'), isTrue); + + await gesture.moveTo(tester.getCenter(find.text('three'))); + await tester.pump(); + expect(pressedRow(tester, 'one'), isFalse); + expect(pressedRow(tester, 'three'), isTrue); + expect(clicks, ['HapticFeedbackType.selectionClick']); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(picked, ['three']); + expect(find.text('one'), findsNothing); + }); + + testWidgets('a press let go off the items picks nothing', (tester) async { + final picked = []; + final open = await pumpMenu(tester, [ + for (final label in ['one', 'two']) + CommonPopupMenuItem(label: label, onPressed: () => picked.add(label)), + ]); + open(); + await tester.pumpAndSettle(); + final menu = tester.getRect(find.byType(CommonPopupMenu)); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('one')), + ); + await tester.pump(); + await gesture.moveTo(Offset(menu.left - 60, menu.center.dy)); + await tester.pump(); + expect(pressedRow(tester, 'one'), isFalse); + expect(pressedRow(tester, 'two'), isFalse); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(picked, isEmpty); + expect(find.text('one'), findsOneWidget); + }); + + testWidgets('a drag that scrolls a long menu lets go of the press', ( + tester, + ) async { + final picked = []; + final open = await pumpMenu(tester, [ + for (var index = 0; index < 30; index++) + CommonPopupMenuItem( + label: 'item $index', + onPressed: () => picked.add('item $index'), + ), + ]); + open(); + await tester.pumpAndSettle(); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('item 3')), + ); + await tester.pump(); + for (var step = 0; step < 5; step++) { + await gesture.moveBy(const Offset(0, -20)); + await tester.pump(); + } + expect(pressedRow(tester, 'item 3'), isFalse); + expect( + tester.state(find.byType(Scrollable)).position.pixels, + greaterThan(0), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(picked, isEmpty); + expect(find.text('item 3'), findsOneWidget); + }); + + Future> pumpButtonMenu( + WidgetTester tester, { + bool enabled = true, + }) async { + final picked = []; + await tester.pumpWidget( + TestApp( + homeBuilder: (child) => Scaffold(body: Center(child: child)), + child: CommonPopupBox( + targetBuilder: (open) => TextButton( + onPressed: enabled ? open : null, + child: const Text('menu'), + ), + popupBuilder: (_) => CommonPopupMenu( + items: [ + for (final label in ['one', 'two', 'three']) + CommonPopupMenuItem( + label: label, + onPressed: () => picked.add(label), + ), + ], + ), + ), + ), + ); + return picked; + } + + testWidgets('a press on the button opens its menu to slide on into', ( + tester, + ) async { + final picked = await pumpButtonMenu(tester); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('menu')), + ); + await tester.pumpAndSettle(); + expect(find.byType(CommonPopupMenu), findsOneWidget); + + await gesture.moveTo(tester.getCenter(find.text('two'))); + await tester.pump(); + expect(pressedRow(tester, 'two'), isTrue); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(picked, ['two']); + expect(find.byType(CommonPopupMenu), findsNothing); + }); + + testWidgets('a press let go where it went down leaves the menu open', ( + tester, + ) async { + final picked = await pumpButtonMenu(tester); + + await tester.tap(find.text('menu')); + await tester.pumpAndSettle(); + + expect(find.byType(CommonPopupMenu), findsOneWidget); + expect(picked, isEmpty); + }); + + testWidgets('a pick runs once the popup has returned into its button', ( + tester, + ) async { + final picked = await pumpButtonMenu(tester); + await tester.tap(find.text('menu')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('two')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(CommonPopupMenu), findsOneWidget); + expect(picked, isEmpty); + + await tester.pumpAndSettle(); + expect(picked, ['two']); + }); + + testWidgets('a pick from a popup at a point runs at once', (tester) async { + late BuildContext context; + final picked = []; + await tester.pumpWidget( + TestApp( + child: Builder( + builder: (value) { + context = value; + return const SizedBox.expand(); + }, + ), + ), + ); + Navigator.of(context).push( + CommonPopupRoute( + barrierLabel: 'Dismiss', + placement: PopupPlacement.belowPoint, + anchorOf: () => const Offset(100, 120) & Size.zero, + builder: (_) => CommonPopupMenu( + items: [ + CommonPopupMenuItem( + label: 'one', + onPressed: () => picked.add('one'), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('one')); + await tester.pump(); + expect(picked, ['one']); + await tester.pumpAndSettle(); + }); + + testWidgets('a popup value fades across as its width eases over', ( + tester, + ) async { + Widget value(String text) => Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: PopupValueText(text, alignment: AlignmentDirectional.centerEnd), + ), + ); + await tester.pumpWidget(value('short')); + final from = tester.getSize(find.byType(PopupValueText)).width; + + await tester.pumpWidget(value('a much longer value')); + final to = tester + .getSize(find.text('a much longer value', skipOffstage: false)) + .width; + await tester.pump(const Duration(milliseconds: 100)); + final easing = tester.getSize(find.byType(PopupValueText)).width; + expect(easing, greaterThan(from)); + expect(easing, lessThan(to)); + expect(find.text('short'), findsOneWidget); + expect( + tester + .widgetList(find.byType(ImageFiltered)) + .where((filter) => filter.enabled), + hasLength(2), + ); + + await tester.pumpAndSettle(); + expect(find.text('short'), findsNothing); + expect(tester.getSize(find.byType(PopupValueText)).width, to); + expect( + tester + .widgetList(find.byType(ImageFiltered)) + .where((filter) => filter.enabled), + isEmpty, + ); + }); + + testWidgets('a disabled button opens nothing under a press', (tester) async { + await pumpButtonMenu(tester, enabled: false); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('menu')), + ); + await tester.pumpAndSettle(); + expect(find.byType(CommonPopupMenu), findsNothing); + await gesture.up(); + }); + + testWidgets('a press resting on an item opens its sub menu to slide into', ( + tester, + ) async { + final clicks = recordHaptics(tester); + final picked = []; + final open = await pumpMenu(tester, [ + CommonPopupMenuItem(label: 'other', onPressed: () => picked.add('other')), + CommonPopupMenuItem( + label: 'parent', + subItems: [ + CommonPopupMenuItem( + label: 'child', + onPressed: () => picked.add('child'), + ), + ], + ), + ]); + open(); + await tester.pumpAndSettle(); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('other')), + ); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.text('parent'))); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.text('child'), findsNothing); + expect(clicks, hasLength(1)); + + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(find.text('child'), findsOneWidget); + expect(clicks, hasLength(2)); + + await gesture.moveTo(tester.getCenter(find.text('child'))); + await tester.pump(); + expect(clicks, hasLength(3)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(picked, ['child']); + }); + + testWidgets('a press resting on the header folds its sub menu back', ( + tester, + ) async { + final clicks = recordHaptics(tester); + final picked = []; + final open = await pumpMenu(tester, [ + CommonPopupMenuItem( + label: 'parent', + subItems: [ + CommonPopupMenuItem( + label: 'child', + onPressed: () => picked.add('child'), + ), + ], + ), + ]); + open(); + await tester.pumpAndSettle(); + + final header = tester.getCenter(find.text('parent')); + final gesture = await tester.startGesture(header); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + await gesture.moveTo(tester.getCenter(find.text('child'))); + await tester.pump(); + await gesture.moveTo(header); + await tester.pump(); + expect(clicks, hasLength(3)); + + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + expect(find.text('child'), findsNothing); + expect(clicks, hasLength(4)); + expect(pressedRow(tester, 'parent'), isTrue); + + await tester.pump(const Duration(seconds: 1)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('child'), findsNothing); + expect(find.text('parent'), findsOneWidget); + expect(picked, isEmpty); + }); + + testWidgets('a tap that folds a sub menu back gives no click', ( + tester, + ) async { + final clicks = recordHaptics(tester); + final open = await pumpMenu(tester, [ + const CommonPopupMenuItem( + label: 'parent', + subItems: [CommonPopupMenuItem(label: 'child')], + ), + ]); + open(); + await tester.pumpAndSettle(); + + await tester.tap(find.text('parent')); + await tester.pumpAndSettle(); + await tester.tap(find.text('parent').last); + await tester.pumpAndSettle(); + + expect(find.text('child'), findsNothing); + expect(clicks, isEmpty); + }); + + testWidgets('a press let go where its sub menu opened keeps it open', ( + tester, + ) async { + final open = await pumpMenu(tester, [ + const CommonPopupMenuItem( + label: 'parent', + subItems: [CommonPopupMenuItem(label: 'child')], + ), + ]); + open(); + await tester.pumpAndSettle(); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('parent')), + ); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + expect(find.text('child'), findsOneWidget); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('child'), findsOneWidget); + }); + + testWidgets('a press held on the button keeps its tooltip down', ( + tester, + ) async { + await tester.pumpWidget( + TestApp( + homeBuilder: (child) => Scaffold(body: Center(child: child)), + child: CommonPopupBox( + targetBuilder: (open) => IconButton( + tooltip: 'tip', + onPressed: open, + icon: const GlyphIcon(AppGlyphs.more), + ), + popupBuilder: (_) => CommonPopupMenu( + items: [CommonPopupMenuItem(label: 'one', onPressed: () {})], + ), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(IconButton)), + ); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(find.text('one'), findsOneWidget); + await gesture.up(); + await tester.pump(); + + expect(find.text('tip'), findsNothing); + }); + + Future> pumpScrollingButtonMenu(WidgetTester tester) async { + final picked = []; + await tester.pumpWidget( + TestApp( + homeBuilder: (child) => Scaffold(body: child), + child: ListView( + children: [ + const SizedBox(height: 200), + Center( + child: CommonPopupBox( + targetBuilder: (open) => + TextButton(onPressed: open, child: const Text('menu')), + popupBuilder: (_) => CommonPopupMenu( + items: [ + for (final label in ['one', 'two', 'three']) + CommonPopupMenuItem( + label: label, + onPressed: () => picked.add(label), + ), + ], + ), + ), + ), + const SizedBox(height: 2000), + ], + ), + ), + ); + return picked; + } + + testWidgets('in a scroll view a drag from the button scrolls instead', ( + tester, + ) async { + await pumpScrollingButtonMenu(tester); + final scrollable = tester.state(find.byType(Scrollable)); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('menu')), + ); + for (var step = 0; step < 4; step++) { + await gesture.moveBy(const Offset(0, -20)); + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, greaterThan(0)); + expect(find.byType(CommonPopupMenu), findsNothing); + }); + + testWidgets('in a scroll view a press held on the button opens its menu', ( + tester, + ) async { + await pumpScrollingButtonMenu(tester); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('menu')), + ); + await tester.pump(kPressTimeout ~/ 2); + expect(find.byType(CommonPopupMenu), findsNothing); + await tester.pump(kPressTimeout); + await tester.pumpAndSettle(); + expect(find.byType(CommonPopupMenu), findsOneWidget); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byType(CommonPopupMenu), findsOneWidget); + }); + + for (final kind in [PointerDeviceKind.touch, PointerDeviceKind.mouse]) { + testWidgets('in a scroll view a ${kind.name} press held on the button ' + 'slides on into its menu', (tester) async { + final picked = await pumpScrollingButtonMenu(tester); + + final gesture = await tester.startGesture( + tester.getCenter(find.text('menu')), + kind: kind, + ); + await tester.pump(kPressTimeout); + await tester.pumpAndSettle(); + await gesture.moveTo(tester.getCenter(find.text('two'))); + await tester.pump(); + expect(pressedRow(tester, 'two'), isTrue); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(picked, ['two']); + expect(find.byType(CommonPopupMenu), findsNothing); + }); + } + + testWidgets('in a scroll view a tap on the button opens its menu', ( + tester, + ) async { + await pumpScrollingButtonMenu(tester); + + await tester.tap(find.text('menu')); + await tester.pumpAndSettle(); + + expect(find.byType(CommonPopupMenu), findsOneWidget); + }); + + testWidgets('a press carried in from the button stretches the menu', ( + tester, + ) async { + await pumpButtonMenu(tester); + final gesture = await tester.startGesture( + tester.getCenter(find.text('menu')), + ); + await tester.pumpAndSettle(); + final press = find.descendant( + of: find.byType(CommonPopupMenu), + matching: find.byType(ElasticPress), + ); + final resting = tester.getRect(press); + + await gesture.moveTo(Offset(resting.left - 80, resting.center.dy)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + final pulled = painted(tester, press); + expect(pulled.center.dx, lessThan(resting.center.dx - 4)); + expect(pulled.width, greaterThan(resting.width)); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(painted(tester, press), resting); + }); + + testWidgets('a press carried in while the menu opens stretches it only ' + 'once the platter steps aside', (tester) async { + await pumpButtonMenu(tester); + final gesture = await tester.startGesture( + tester.getCenter(find.text('menu')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + final press = find.descendant( + of: find.byType(CommonPopupMenu), + matching: find.byType(ElasticPress), + ); + final resting = tester.getRect(press); + + await gesture.moveTo(Offset(resting.left - 80, resting.center.dy)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(painted(tester, press), resting); + + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(-1, 0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(painted(tester, press).center.dx, lessThan(resting.center.dx - 4)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('a capped menu opens on its checked item and cuts a row at ' + 'its foot', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final open = await pumpMenu(tester, [ + for (var i = 0; i < 30; i++) + CommonPopupMenuItem( + label: 'Item $i', + checked: i == 20, + onPressed: () {}, + ), + ], maxVisibleItems: 5); + open(); + await tester.pumpAndSettle(); + + final menu = tester.getRect(find.byType(PopupMenuCard)); + final checked = tester.getRect(find.text('Item 20')); + final cut = tester.getRect(find.text('Item 23')); + expect(checked.top, greaterThan(menu.top)); + expect(checked.bottom, lessThan(menu.bottom)); + expect( + tester.getRect(find.text('Item 17', skipOffstage: false)).bottom, + lessThan(menu.top), + ); + expect(cut.top, lessThan(menu.bottom)); + expect(cut.bottom, greaterThan(menu.bottom)); + }); + + testWidgets('a long menu caps itself well short of a tall window', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final open = await pumpMenu(tester, [ + for (var i = 0; i < 30; i++) + CommonPopupMenuItem(label: 'Item $i', onPressed: () {}), + ]); + open(); + await tester.pumpAndSettle(); + + final menu = tester.getRect(find.byType(PopupMenuCard)); + final cut = tester.getRect(find.text('Item 7')); + expect(menu.height, lessThan(800)); + expect(cut.top, lessThan(menu.bottom)); + expect(cut.bottom, greaterThan(menu.bottom)); + }); + + testWidgets('a capped menu is as wide as its rows laid out in full', ( + tester, + ) async { + final items = [ + CommonPopupMenuItem(label: '🇭🇰 HK', onPressed: () {}), + CommonPopupMenuItem(label: 'Fallback', checked: true, onPressed: () {}), + for (var i = 0; i < 12; i++) + CommonPopupMenuItem(label: 'Group $i', onPressed: () {}), + ]; + Future widthOf(int? maxVisibleItems) async { + final open = await pumpMenu( + tester, + items, + maxVisibleItems: maxVisibleItems, + ); + open(); + await tester.pumpAndSettle(); + final width = tester.getSize(find.byType(PopupMenuCard)).width; + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + return width; + } + + final full = await widthOf(null); + expect(full, inExclusiveRange(160, 280)); + expect(await widthOf(5), moreOrLessEquals(full, epsilon: 0.5)); + }); + testWidgets('a danger item keeps the error color off its background', ( tester, ) async { @@ -815,13 +1694,58 @@ void main() { await tester.pump(const Duration(milliseconds: 150)); final active = surfaces.last; - final midSize = tester.getSize(active); - expect(midSize.width, greaterThanOrEqualTo(parentWidth * 1.12)); + final midWidth = tester.getSize(active).width; + expect(midWidth, greaterThanOrEqualTo(parentWidth * 1.12)); + expect(cardOf(tester, active).grow, lessThan(1)); await tester.pumpAndSettle(); - final settledSize = tester.getSize(active); - expect(settledSize.width, midSize.width); - expect(settledSize.height, greaterThan(midSize.height)); + expect(tester.getSize(active).width, midWidth); + expect(cardOf(tester, active).grow, 1); + }); + + testWidgets('a sub menu widening as it unfolds keeps its rows unclipped', ( + tester, + ) async { + final open = await pumpMenu(tester, [ + const CommonPopupMenuItem( + label: 'a label long enough to widen the parent menu', + ), + const CommonPopupMenuItem( + label: 'parent', + subItems: [CommonPopupMenuItem(label: 'child')], + ), + ]); + open(); + await tester.pumpAndSettle(); + + Rect clipOf(Finder surface) { + Rect? clip; + expect( + tester.renderObject(surface), + paints..everything((method, arguments) { + if (method == #clipPath) { + clip ??= (arguments.first as Path).getBounds(); + } + return true; + }), + ); + return clip!.shift(tester.getTopLeft(surface)); + } + + Rect rowOf(Finder label) => tester.getRect( + find.ancestor(of: label, matching: find.byType(InkWell)).first, + ); + + await tester.tap(find.text('parent')); + await tester.pump(); + final active = surfaces.last; + for (var frame = 0; frame < 20; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + final left = clipOf(active).left; + expect(left, lessThanOrEqualTo(rowOf(find.text('parent').last).left)); + expect(left, lessThanOrEqualTo(rowOf(find.text('child')).left)); + } + await tester.pumpAndSettle(); }); testWidgets('folding back lands on the item without a jump at the end', ( @@ -842,24 +1766,148 @@ void main() { await tester.tap(find.text('parent')); await tester.pump(); - final foldedSize = tester.getSize(surfaces.last); + final foldedWidth = tester.getSize(surfaces.last).width; await tester.pumpAndSettle(); await tester.tap(find.text('parent').last); - Size? lastSize; + late PopupMenuCard last; + late double lastWidth; var frames = 0; while (surfaces.evaluate().length > 1) { - lastSize = tester.getSize(surfaces.last); + last = cardOf(tester, surfaces.last); + lastWidth = tester.getSize(surfaces.last).width; await tester.pump(const Duration(milliseconds: 16)); frames++; expect(frames, lessThan(60)); } - expect(lastSize!.width, closeTo(foldedSize.width, 1)); - expect(lastSize.height, closeTo(foldedSize.height, 1)); + expect(lastWidth, closeTo(foldedWidth, 1)); + expect(last.grow, closeTo(0, 0.01)); + expect(shadowOf(tester, surfaces), greaterThan(0)); await tester.pumpAndSettle(); }); + testWidgets('a fold caught mid-unfold turns back from where it stands', ( + tester, + ) async { + final open = await pumpMenu(tester, [ + const CommonPopupMenuItem( + label: 'parent', + subItems: [ + CommonPopupMenuItem(label: 'first child'), + CommonPopupMenuItem(label: 'second child'), + ], + ), + ]); + open(); + await tester.pumpAndSettle(); + await tester.tap(find.text('parent')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + final caught = cardOf(tester, surfaces.last).grow; + expect(caught, inExclusiveRange(0, 1)); + + await tester.tap(find.text('parent').last, warnIfMissed: false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + expect(cardOf(tester, surfaces.last).grow, closeTo(caught, 0.15)); + + await tester.pumpAndSettle(); + expect(surfaces, findsOneWidget); + }); + + testWidgets('the item tapped again while its sub menu folds opens it back', ( + tester, + ) async { + final open = await pumpMenu(tester, [ + const CommonPopupMenuItem( + label: 'parent', + subItems: [ + CommonPopupMenuItem(label: 'first child'), + CommonPopupMenuItem(label: 'second child'), + ], + ), + ]); + open(); + await tester.pumpAndSettle(); + await tester.tap(find.text('parent')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('parent').last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + await tester.tap(find.text('parent').last); + await tester.pumpAndSettle(); + + expect(find.text('first child'), findsOneWidget); + expect(surfaces, findsNWidgets(2)); + expect(cardOf(tester, surfaces.last).grow, 1); + }); + + testWidgets('a tap on the menu behind a sub menu folds it back', ( + tester, + ) async { + final open = await pumpMenu(tester, [ + const CommonPopupMenuItem(label: 'plain item'), + const CommonPopupMenuItem( + label: 'parent', + subItems: [CommonPopupMenuItem(label: 'child')], + ), + ]); + open(); + await tester.pumpAndSettle(); + await tester.tap(find.text('parent')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('plain item'), warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(find.text('child'), findsNothing); + expect(find.text('plain item'), findsOneWidget); + }); + + testWidgets('a tap on a card between its items keeps the menu open', ( + tester, + ) async { + final open = await pumpMenu(tester, [ + CommonPopupMenuItem(label: 'item', onPressed: () {}), + ]); + open(); + await tester.pumpAndSettle(); + + await tester.tapAt(tester.getTopLeft(surfaces) + const Offset(4, 4)); + await tester.pumpAndSettle(); + + expect(find.text('item'), findsOneWidget); + }); + + testWidgets('the item a sub menu stands on hides under its header', ( + tester, + ) async { + final open = await pumpMenu(tester, [ + const CommonPopupMenuItem( + label: 'parent', + subItems: [CommonPopupMenuItem(label: 'child')], + ), + ]); + double opacityOf(Finder label) => tester + .widget( + find.ancestor(of: label, matching: find.byType(Opacity)).first, + ) + .opacity; + open(); + await tester.pumpAndSettle(); + expect(opacityOf(find.text('parent')), 1); + + await tester.tap(find.text('parent')); + await tester.pumpAndSettle(); + expect(opacityOf(find.text('parent').first), 0); + + await tester.tap(find.text('parent').last); + await tester.pumpAndSettle(); + expect(opacityOf(find.text('parent')), 1); + }); + testWidgets('a sub menu unfolds without moving the menu it opens from', ( tester, ) async { @@ -913,27 +1961,6 @@ void main() { expect(tester.getSize(menu).height, greaterThan(foldedHeight)); }); - testWidgets('every level draws its shadow on the frame it is asked for', ( - tester, - ) async { - final open = await pumpMenu(tester, [ - const CommonPopupMenuItem( - label: 'parent', - subItems: [CommonPopupMenuItem(label: 'child')], - ), - ]); - open(); - await tester.pumpAndSettle(); - await tester.tap(find.text('parent')); - await tester.pumpAndSettle(); - - final durations = tester - .widgetList(surfaces) - .map((surface) => surface.animationDuration) - .toSet(); - expect(durations, {Duration.zero}); - }); - testWidgets('the parent hands its shadow over without a step', ( tester, ) async { @@ -1001,4 +2028,41 @@ void main() { ); expect(scrollable.position.pixels, 100); }); + + testWidgets('on glass a card stands bare while the platter carries it', ( + tester, + ) async { + late PopupOpen open; + await tester.pumpWidget( + MaterialApp( + builder: (_, child) => LiquidGlassScope(enabled: true, child: child!), + home: Scaffold( + body: Center( + child: CommonPopupBox( + targetBuilder: (value) { + open = value; + return const SizedBox(width: 40, height: 40); + }, + popupBuilder: (_) => const CommonPopupMenu( + items: [CommonPopupMenuItem(label: 'item')], + ), + ), + ), + ), + ), + ); + bool bare() => cardOf(tester, surfaces).bare; + + open(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(bare(), isTrue); + + await tester.pumpAndSettle(); + expect(bare(), isFalse); + + await tester.tapAt(Offset.zero); + await tester.pump(); + expect(bare(), isTrue); + }); } diff --git a/test/widgets/popup_theme_test.dart b/test/widgets/popup_theme_test.dart new file mode 100644 index 00000000..47681892 --- /dev/null +++ b/test/widgets/popup_theme_test.dart @@ -0,0 +1,112 @@ +import 'package:fl_clash/providers/app.dart'; +import 'package:fl_clash/ui/ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../helpers/test_app.dart'; + +ThemeData _theme(Brightness brightness) { + final colorScheme = ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: brightness, + ); + return ThemeData( + colorScheme: colorScheme, + extensions: [ + if (brightness == Brightness.dark) + ElevatedColorScheme(colorScheme.elevated), + ], + ); +} + +Color _floatingFill(ColorScheme colorScheme) => + SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme); + +Future> _pumpPage( + WidgetTester tester, { + required WidgetBuilder body, +}) async { + const size = Size(400, 800); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final mode = ValueNotifier(ThemeMode.light); + addTearDown(mode.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [viewSizeProvider.overrideWithBuild((_, _) => size)], + child: ValueListenableBuilder( + valueListenable: mode, + builder: (_, themeMode, _) => TestApp( + theme: _theme(Brightness.light), + darkTheme: _theme(Brightness.dark), + themeMode: themeMode, + child: CommonScaffold( + title: 'Page', + body: Builder(builder: body), + ), + ), + ), + ), + ); + return mode; +} + +void main() { + late PopupOpen open; + final popupBox = CommonPopupBox( + targetBuilder: (value) { + open = value; + return const SizedBox(width: 40, height: 40); + }, + popupBuilder: (_) => + const CommonPopupMenu(items: [CommonPopupMenuItem(label: 'item')]), + ); + + Color fillOf(WidgetTester tester) => + tester.widget(find.byType(PopupMenuCard)).fill; + + final light = _theme(Brightness.light); + final dark = _theme(Brightness.dark); + + testWidgets('an open popup takes a new app theme', (tester) async { + final mode = await _pumpPage(tester, body: (_) => Center(child: popupBox)); + open(); + await tester.pumpAndSettle(); + expect(fillOf(tester), _floatingFill(light.colorScheme)); + + mode.value = ThemeMode.dark; + await tester.pumpAndSettle(); + + expect(fillOf(tester), _floatingFill(dark.colorScheme)); + }); + + testWidgets('a popup opened in a sheet keeps its lift in a new app theme', ( + tester, + ) async { + final mode = await _pumpPage( + tester, + body: (context) => TextButton( + onPressed: () => showSheet( + context: context, + builder: (_) => Center(child: popupBox), + ), + child: const Text('open'), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + open(); + await tester.pumpAndSettle(); + expect(fillOf(tester), _floatingFill(light.colorScheme)); + + mode.value = ThemeMode.dark; + await tester.pumpAndSettle(); + + final elevated = dark.extension()!.colorScheme; + expect(_floatingFill(elevated), isNot(_floatingFill(dark.colorScheme))); + expect(fillOf(tester), _floatingFill(elevated)); + }); +} diff --git a/test/widgets/scaffold_actions_test.dart b/test/widgets/scaffold_actions_test.dart index c92ccd38..a5966c55 100644 --- a/test/widgets/scaffold_actions_test.dart +++ b/test/widgets/scaffold_actions_test.dart @@ -278,32 +278,58 @@ void main() { }); } - testWidgets('only a page above the fixed navigation bar floats its primary ' - 'action', (tester) async { - Future pump({required bool inBar}) => tester.pumpWidget( + testWidgets('a page keeps its primary action in its bar', (tester) async { + await tester.pumpWidget( TestApp( wrapInProviderScope: true, - child: PrimaryActionInBarScope( - inBar: inBar, - child: CommonScaffold( - title: 'Page', - primaryAction: IconButtonData( - glyph: AppGlyphs.add, - onPressed: () {}, - tooltip: 'Add', - ), - body: const SizedBox.expand(), + child: CommonScaffold( + title: 'Page', + primaryAction: IconButtonData( + glyph: AppGlyphs.add, + onPressed: () {}, + tooltip: 'Add', ), + body: const SizedBox.expand(), ), ), ); - await pump(inBar: true); expect(_barButtons(tester), ['Add']); expect(find.byType(FloatingActionButton), findsNothing); + }); - await pump(inBar: false); + testWidgets('a title menu opens from the title start and runs its items', ( + tester, + ) async { + final taps = []; + await tester.pumpWidget( + TestApp( + wrapInProviderScope: true, + child: CommonScaffold( + title: 'Page', + titleMenuItems: [ + CommonPopupMenuItem( + label: 'Rename', + onPressed: () => taps.add('Rename'), + ), + ], + body: const SizedBox.expand(), + ), + ), + ); + expect(_inBar(find.byGlyph(AppGlyphs.chevronDown)), findsOneWidget); expect(_barButtons(tester), isEmpty); - expect(find.byType(FloatingActionButton), findsOneWidget); + final title = tester.getRect(_inBar(find.text('Page'))); + + await tester.tap(_inBar(find.text('Page'))); + await tester.pumpAndSettle(); + expect( + tester.getRect(find.byType(PopupMenuCard)).left, + moreOrLessEquals(title.left), + ); + + await tester.tap(find.text('Rename')); + await tester.pumpAndSettle(); + expect(taps, ['Rename']); }); } diff --git a/test/widgets/scaffold_repaint_test.dart b/test/widgets/scaffold_repaint_test.dart new file mode 100644 index 00000000..e64fa6ca --- /dev/null +++ b/test/widgets/scaffold_repaint_test.dart @@ -0,0 +1,46 @@ +import 'package:fl_clash/widgets/widgets.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../helpers/test_app.dart'; + +void main() { + testWidgets('a rebuild in the body repaints neither the bar nor the page', ( + tester, + ) async { + final count = ValueNotifier(0); + addTearDown(count.dispose); + const pageKey = Key('page'); + await tester.pumpWidget( + TestApp( + wrapInProviderScope: true, + child: RepaintBoundary( + key: pageKey, + child: CommonScaffold( + title: 'Page', + body: Column( + children: [ + ValueListenableBuilder( + valueListenable: count, + builder: (_, value, _) => + RepaintBoundary(child: Text('$value')), + ), + ], + ), + ), + ), + ), + ); + final page = tester.renderObject( + find.byKey(pageKey), + ); + page.debugResetMetrics(); + + count.value++; + await tester.pump(); + + expect(find.text('1'), findsOneWidget); + expect(page.debugSymmetricPaintCount + page.debugAsymmetricPaintCount, 0); + }); +} diff --git a/test/widgets/scaffold_search_test.dart b/test/widgets/scaffold_search_test.dart index e045d16b..1ccd52a0 100644 --- a/test/widgets/scaffold_search_test.dart +++ b/test/widgets/scaffold_search_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fl_clash/ui/icons/icons.dart'; import 'package:fl_clash/widgets/widgets.dart'; import 'package:flutter/services.dart'; @@ -136,4 +138,77 @@ void main() { expect(find.text('page action'), findsNothing); expect(find.byTooltip('search action'), findsOneWidget); }); + + testWidgets('system back exits search on a pushed page before its own ' + 'back handler', (tester) async { + var pageBackCount = 0; + await tester.pumpWidget( + const TestApp(wrapInProviderScope: true, child: SizedBox.shrink()), + ); + unawaited( + tester + .state(find.byType(Navigator)) + .push( + MaterialPageRoute( + builder: (_) => CommonPopScope( + onPop: (_) { + pageBackCount++; + return false; + }, + child: _page(onSearch: (_) {}), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byGlyph(AppGlyphs.search)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsNothing); + expect(pageBackCount, 0); + expect(find.text('Page'), findsOneWidget); + }); + + testWidgets('a fresh search callback each build keeps search open', ( + tester, + ) async { + await tester.pumpWidget( + const TestApp(wrapInProviderScope: true, child: _RebuildingSearchPage()), + ); + + await tester.tap(find.byGlyph(AppGlyphs.search)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'a'); + await tester.pumpAndSettle(); + + expect(find.text('query=a'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + }); +} + +class _RebuildingSearchPage extends StatefulWidget { + const _RebuildingSearchPage(); + + @override + State<_RebuildingSearchPage> createState() => _RebuildingSearchPageState(); +} + +class _RebuildingSearchPageState extends State<_RebuildingSearchPage> { + String _query = ''; + + @override + Widget build(BuildContext context) { + return CommonScaffold( + title: 'Page', + searchState: AppBarSearchState( + onSearch: (value) => setState(() => _query = value), + ), + body: Text('query=$_query'), + ); + } } diff --git a/test/widgets/sortable_context_menu_test.dart b/test/widgets/sortable_context_menu_test.dart index b9f39aa0..5162ab99 100644 --- a/test/widgets/sortable_context_menu_test.dart +++ b/test/widgets/sortable_context_menu_test.dart @@ -82,6 +82,36 @@ Future> _pump( return pressed; } +/// A row whose sync withdraws its menu, as a row does while its item updates, +/// rebuilding the app above the navigator with it. +Future> _pumpSyncing(WidgetTester tester) async { + final syncing = ValueNotifier(false); + addTearDown(syncing.dispose); + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: syncing, + builder: (_, value, _) => TestApp( + child: Scaffold( + body: ContextMenuRegion( + menuItems: value + ? null + : [ + CommonPopupMenuItem( + glyph: AppGlyphs.sync, + label: 'Sync', + onPressed: () => syncing.value = true, + ), + ], + child: const DecorationListItem(title: Text('a')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return syncing; +} + List _order(WidgetTester tester, List items) => [...items]..sort( (a, b) => tester @@ -120,6 +150,30 @@ void main() { expect(pressed, isEmpty); }); + testWidgets('an item that withdraws the menu lets the menu close', ( + tester, + ) async { + await _pumpSyncing(tester); + await _rightClick(tester, 'a'); + + await tester.tap(find.text('Sync')); + await tester.pumpAndSettle(); + + expect(find.text('Sync'), findsNothing); + }); + + testWidgets('a row that withdraws its menu closes the open one', ( + tester, + ) async { + final syncing = await _pumpSyncing(tester); + await _rightClick(tester, 'a'); + + syncing.value = true; + await tester.pumpAndSettle(); + + expect(find.text('Sync'), findsNothing); + }); + testWidgets('a mouse long press opens nothing', (tester) async { await _pump(tester);