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
@@ -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);