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:
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+1120
-56
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user