feat(ui): add Liquid Glass and slide-to-pick popup menus

Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
This commit is contained in:
chen08209
2026-10-16 22:42:17 +08:00
parent 9bd2286eb5
commit 7c9627fffb
42 changed files with 9864 additions and 2550 deletions
+1
View File
@@ -213,6 +213,7 @@
"layout": "Layout",
"tight": "Tight",
"standard": "Standard",
"ultra": "Ultra",
"loose": "Loose",
"profilesSort": "Sort profiles",
"start": "Start",
+1
View File
@@ -213,6 +213,7 @@
"layout": "レイアウト",
"tight": "コンパクト",
"standard": "標準",
"ultra": "最高",
"loose": "ゆったり",
"profilesSort": "プロファイルの並べ替え",
"start": "開始",
+1
View File
@@ -213,6 +213,7 @@
"layout": "Макет",
"tight": "Плотный",
"standard": "Стандартный",
"ultra": "Максимум",
"loose": "Свободный",
"profilesSort": "Сортировка профилей",
"start": "Старт",
+1
View File
@@ -213,6 +213,7 @@
"layout": "布局",
"tight": "紧凑",
"standard": "标准",
"ultra": "极致",
"loose": "宽松",
"profilesSort": "配置排序",
"start": "启动",
+4 -3
View File
@@ -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'),
+2 -1
View File
@@ -116,6 +116,8 @@ class _ListEditorPageState<T, K> extends ConsumerState<ListEditorPage<T, K>> {
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,
+5
View File
@@ -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: []);
+11 -4
View File
@@ -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,
);
}
}
+3 -12
View File
@@ -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),
+65 -15
View File
@@ -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<ContextMenuRegion> {
Timer? _holdTimer;
LogicalKeyboardKey? _heldKey;
CommonPopupRoute<void>? _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<ContextMenuRegion> {
box.getTransformTo(navigatorBox),
Offset.zero & box.size,
);
final menu = _menu = CommonPopupRoute<void>(
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<void>(
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<ContextMenuRegion> {
@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,
+357 -62
View File
@@ -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<T?> showFloatingDialog<T>({
}) {
return Navigator.of(context).push<T>(
_AlertRoute<T>(
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<BoxShadow> 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<BoxShadow> _shadows;
set shadows(List<BoxShadow> 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 <Widget>[];
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<RenderBox, MultiChildLayoutParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
_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<Widget> 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<Color?>.fromMap({
WidgetState.disabled: fill,
WidgetState.pressed: background.withValues(
alpha: background.a * pressedShare,
),
WidgetState.any: background,
}),
overlayColor: WidgetStateProperty<Color?>.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,
);
}
}
+5 -1
View File
@@ -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,
File diff suppressed because it is too large Load Diff
+967
View File
@@ -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 = <double>[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<double> 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<double> _opacity;
set opacity(Animation<double> 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<ContainerLayer> 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 = <Object, List<_Behind>>{};
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 = <ui.Picture>[];
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 = <Object?>[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<double> _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<Object?> 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<ContainerLayer> 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<Object?> drawn;
final _Behind entry;
final List<Matrix4> _toHost;
// A layer's effects are applied only once something under it draws.
final _entered = <(ContainerLayer, Matrix4?, bool)>[];
final _saves = <int>[];
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);
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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;
+2703
View File
File diff suppressed because it is too large Load Diff
+221 -171
View File
@@ -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<CommonPopupMenuItem> titleMenuItems;
final bool isLoading;
final List<Widget>? actions;
@@ -51,6 +53,10 @@ class CommonScaffold extends StatefulWidget {
final List<IconButtonData> iconActions;
final List<CommonPopupMenuItem> 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<CommonScaffold> {
late final ValueNotifier<AppBarState> _appBarState;
final ValueNotifier<bool> _loadingNotifier = ValueNotifier(false);
final ValueNotifier<bool> _isFabExtendedNotifier = ValueNotifier(true);
final ValueNotifier<List<String>> _keywordsNotifier = ValueNotifier([]);
final _textController = TextEditingController();
final _searchFocusNode = FocusNode();
@@ -109,6 +116,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
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<CommonScaffold> {
!_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<CommonScaffold> {
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<CommonScaffold> {
_appBarState.dispose();
_textController.dispose();
_searchFocusNode.dispose();
_isFabExtendedNotifier.dispose();
_loadingNotifier.dispose();
_keywordsNotifier.dispose();
super.dispose();
@@ -293,7 +301,6 @@ class CommonScaffoldState extends State<CommonScaffold> {
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<CommonScaffold> {
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<CommonScaffold> {
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<CommonScaffold> {
);
}
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<CommonScaffold> {
: 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<CommonScaffold> {
},
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<Widget> _buildActions(
@@ -489,6 +509,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
(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<CommonScaffold> {
),
...widget.iconActions,
],
confirmation: atFoot ? null : widget.confirmationAction,
widgetCount:
widgets.length +
selectionCount +
@@ -559,13 +581,31 @@ class CommonScaffoldState extends State<CommonScaffold> {
], 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<CommonScaffold> {
);
}
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<CommonScaffold> {
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<CommonScaffold> {
],
),
);
return isBottomSheet
return isCompact
? appBar
: _buildFloatingHeader(
_WindowControlsClearance(child: appBar),
@@ -670,25 +718,16 @@ class CommonScaffoldState extends State<CommonScaffold> {
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<CommonScaffold> {
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<CommonScaffold> {
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<CommonScaffold> {
},
),
);
final fabChild = ValueListenableBuilder<bool>(
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<UserScrollNotification>(
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<CommonScaffold> {
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<PrimaryActionInBarScope>()
?.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<IconButtonData> icons = const [],
IconButtonData? confirmation,
int widgetCount = 0,
List<CommonPopupMenuItem> 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});
+6 -6
View File
@@ -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),
),
+13 -1
View File
@@ -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<ElevatedColorScheme>()?.colorScheme;
+5 -3
View File
@@ -64,7 +64,8 @@ class EmojiText extends StatelessWidget {
this.style,
});
List<TextSpan> _buildTextSpans(String emojis) {
/// The spans [EmojiText] lays [text] out in, emoji in their own font.
static TextSpan spanOf(String text, TextStyle? style) {
final List<TextSpan> 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,
);
+7 -3
View File
@@ -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<IconSetsView> {
}
Future<void> _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<IconSetsView> {
}
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<IconSetsView> {
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<IconSetsView> {
isLoading: ref.watch(loadingProvider(LoadingTag.iconSets)),
actions: [
CommonPopupBox(
anchorShift: Offset(0, ref.watch(isMobileViewProvider) ? 0 : 20),
targetBuilder: (open) {
return IconButton(
tooltip: appLocalizations.add,
+78 -62
View File
@@ -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<ClashProvidersView> {
Future<void> _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<ClashProvidersView> {
}
Future<void> _handleEditOptions(ClashProvider provider) =>
_showClashProviderOptions(ref, provider);
_showClashProviderOptions(context, ref, provider);
Future<void> _handleDelete(ClashProvider provider) async {
final appLocalizations = context.appLocalizations;
@@ -255,6 +256,8 @@ class _ClashProvidersViewState extends ConsumerState<ClashProvidersView> {
users.map((profile) => profile.realLabel).join(', '),
),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res != true) {
return;
@@ -385,7 +388,6 @@ class _ClashProvidersViewState extends ConsumerState<ClashProvidersView> {
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<void> _showClashProviderOptions(
BuildContext context,
WidgetRef ref,
ClashProvider provider,
) async {
final res = await dialogs.showCommonDialog<ClashProvider>(
child: _ProviderDialog(
final res = await showFormSheet<ClashProvider>(
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<String> reservedLabels;
@override
State<_ProviderDialog> createState() => _ProviderDialogState();
State<_ProviderForm> createState() => _ProviderFormState();
}
class _ProviderDialogState extends State<_ProviderDialog> {
final _formKey = GlobalKey<FormState>();
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<String>? validator,
TextInputType? keyboardType,
List<TextInputFormatter>? 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(),
),
),
],
),
),
],
);
}
}
+2 -1
View File
@@ -39,6 +39,8 @@ class _ScriptsViewState extends ConsumerState<ScriptsView> {
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<ScriptsView> {
isLoading: isLoading,
actions: [
CommonPopupBox(
anchorShift: Offset(0, ref.watch(isMobileViewProvider) ? 0 : 20),
targetBuilder: (open) {
return IconButton(
tooltip: appLocalizations.add,
+136 -66
View File
@@ -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<ThemeMode>(
@@ -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<int>(
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<void> _handleChangeSchemeVariant() async {
final schemeVariant = ref.read(
themeSettingProvider.select((state) => state.schemeVariant),
);
final value = await dialogs.showCommonDialog<DynamicSchemeVariant>(
child: OptionsDialog<DynamicSchemeVariant>(
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<bool>(
@@ -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<bool>(
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<void>(
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,
+154 -143
View File
@@ -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<T> 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<T> 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<ThemeLivePreview>
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<ColorScheme>(
tween: _ColorSchemeTween(end: colorScheme),
child: ClipRSuperellipse(
borderRadius: AppRadius.all(corner),
child: TweenAnimationBuilder<ColorScheme>(
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 ||
File diff suppressed because it is too large Load Diff
+2
View File
@@ -107,6 +107,8 @@ flutter:
platforms: [ macos ]
- path: assets/images/tray/windows/
platforms: [ windows ]
shaders:
- shaders/liquid_glass.frag
fonts:
- family: JetBrainsMono
fonts:
+477
View File
@@ -0,0 +1,477 @@
#version 460 core
#include <flutter/runtime_effect.glsl>
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);
}
@@ -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) =>
+6 -7
View File
@@ -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);
});
+25 -3
View File
@@ -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<bool>),
);
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);
});
+73 -2
View File
@@ -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<void> _pumpDialog(
List<String> labels, {
TextDirection textDirection = TextDirection.ltr,
ValueChanged<String>? onPressed,
bool destructive = false,
}) async {
await tester.pumpWidget(
TestApp(
@@ -24,6 +26,7 @@ Future<void> _pumpDialog(
body: Center(
child: CommonDialog(
title: 'Title',
destructive: destructive,
actions: [
for (final label in labels)
TextButton(
@@ -43,6 +46,18 @@ Future<void> _pumpDialog(
Rect _rectOf(WidgetTester tester, String label) =>
tester.getRect(find.widgetWithText(TextButton, label));
Color? _labelColor(WidgetTester tester, String label) =>
tester.renderObject<RenderParagraph>(find.text(label)).text.style?.color;
Color? _buttonFill(WidgetTester tester, String label) => tester
.widget<Material>(
find.descendant(
of: find.widgetWithText(TextButton, label),
matching: find.byType(Material),
),
)
.color;
Future<void> _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<FadeTransition>(
.widget<LiquidGlassFade>(
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<void>(
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 {
+444
View File
@@ -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<Color> _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<int> 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<double> 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);
});
}
+169
View File
@@ -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<int> 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<Path> {
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;
}
+161 -133
View File
@@ -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<RenderRepaintBoundary>(
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<RenderProxyBox>(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<AnimatedScale>(
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<Tooltip>(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 {
File diff suppressed because it is too large Load Diff
+112
View File
@@ -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<ValueNotifier<ThemeMode>> _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<PopupMenuCard>(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<void>(
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<ElevatedColorScheme>()!.colorScheme;
expect(_floatingFill(elevated), isNot(_floatingFill(dark.colorScheme)));
expect(fillOf(tester), _floatingFill(elevated));
});
}
+42 -16
View File
@@ -278,32 +278,58 @@ void main() {
});
}
testWidgets('only a page above the fixed navigation bar floats its primary '
'action', (tester) async {
Future<void> 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 = <String>[];
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']);
});
}
+46
View File
@@ -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<RenderRepaintBoundary>(
find.byKey(pageKey),
);
page.debugResetMetrics();
count.value++;
await tester.pump();
expect(find.text('1'), findsOneWidget);
expect(page.debugSymmetricPaintCount + page.debugAsymmetricPaintCount, 0);
});
}
+75
View File
@@ -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<NavigatorState>(find.byType(Navigator))
.push(
MaterialPageRoute<void>(
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'),
);
}
}
@@ -82,6 +82,36 @@ Future<List<String>> _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<ValueNotifier<bool>> _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<String> _order(WidgetTester tester, List<String> 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);