Files
FlClash/test/widgets/navigation_dock_test.dart
T
chen08209 7c9627fffb feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
2026-10-17 17:21:43 +08:00

675 lines
22 KiB
Dart

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/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/ui/icons/icons.dart';
import 'package:fl_clash/views/navigation.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../helpers/test_app.dart';
const _mobileSize = Size(400, 800);
final _lens = find.descendant(
of: find.byType(FloatingNavigationBar),
matching: find.byType(PositionedDirectional),
);
const _addLabel = 'Add';
IconButtonData _addAction() {
return IconButtonData(
glyph: AppGlyphs.add,
onPressed: () {},
tooltip: _addLabel,
);
}
Widget _page(String title, {IconButtonData? primaryAction}) {
return CommonScaffold(
title: title,
primaryAction: primaryAction,
body: Text('page:$title'),
);
}
Future<ProviderContainer> _pumpHome(
WidgetTester tester, {
List<Override> overrides = const [],
bool settle = true,
}) async {
tester.view.physicalSize = _mobileSize;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWithValue([
const Profile(id: 1, autoUpdateDuration: Duration.zero),
]),
suspendProvider.overrideWithValue(false),
...overrides,
navigationItemsStateProvider.overrideWithValue(
NavigationItemsState(
value: [
NavigationItem(
glyph: AppGlyphs.dashboard,
label: PageLabel.dashboard,
builder: (_) => _page('dashboard'),
),
NavigationItem(
glyph: AppGlyphs.profiles,
label: PageLabel.profiles,
builder: (_) => _page('profiles', primaryAction: _addAction()),
),
NavigationItem(
glyph: AppGlyphs.tools,
label: PageLabel.tools,
builder: (_) => _page('tools'),
),
],
),
),
],
);
addTearDown(container.dispose);
globalState.container = container;
container.read(viewSizeProvider.notifier).value = _mobileSize;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
),
);
if (settle) {
await tester.pumpAndSettle();
} else {
await tester.pump(const Duration(seconds: 1));
}
return container;
}
// A press only paints, so its swell shows in the paint transform.
Rect _painted(WidgetTester tester, RenderObject box, Rect resting) {
Matrix4? transform;
expect(
box,
paints..everything((method, arguments) {
if (method == #transform) {
transform ??= Matrix4.fromFloat64List(arguments.single as Float64List);
}
return true;
}),
);
final local = Offset.zero & resting.size;
return (transform == null
? local
: MatrixUtils.transformRect(transform!, local))
.shift(resting.topLeft);
}
void main() {
setUp(() {
navigationPort = navigation;
addTearDown(() => navigationPort = null);
});
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));
expect(find.byType(FloatingActionButton), findsNothing);
expect(bar.center.dx, _mobileSize.width / 2);
await tester.drag(
find.byType(FloatingNavigationBar),
Offset(bar.width * 0.7, 0),
warnIfMissed: false,
);
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.tools);
final alone = tester.getRect(find.byType(FloatingNavigationBar));
expect(alone.center.dx, _mobileSize.width / 2);
expect(alone.width, lessThan(_mobileSize.width - 40));
expect(tester.takeException(), isNull);
});
testWidgets('a tap that just misses the bar still reaches it', (
tester,
) async {
final container = await _pumpHome(tester);
final bar = tester.getRect(find.byType(FloatingNavigationBar));
final extent = (bar.width - 8) / 3;
final tools = bar.left + 4 + extent * 2.5;
await tester.tapAt(Offset(tools, bar.bottom + 12));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
await tester.tapAt(Offset(tools, bar.bottom + 6));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.tools);
final alone = tester.getRect(find.byType(FloatingNavigationBar));
await tester.tapAt(Offset(alone.left - 6, alone.top - 6));
await tester.pumpAndSettle();
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
});
testWidgets('the lens keeps up with a finger that never pauses', (
tester,
) async {
await _pumpHome(tester);
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));
for (var i = 0; i < 30; i++) {
finger += Offset(extent * 2 / 30, 0);
await gesture.moveTo(finger);
await tester.pump(const Duration(milliseconds: 16));
}
expect(tester.getCenter(_lens).dx, closeTo(finger.dx, extent * 0.3));
await gesture.up();
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));
final extent = (bar.width - 8) / 3;
final start = Offset(bar.left + 4 + extent / 2, bar.center.dy);
Future<void> drag(Duration duration) async {
await tester.timedDragFrom(start, Offset(extent * 0.35, 0), duration);
await tester.pumpAndSettle();
}
await drag(const Duration(seconds: 1));
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
await drag(const Duration(milliseconds: 50));
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
});
testWidgets('a press lifts the lens past the bar until it is let go', (
tester,
) async {
await _pumpHome(tester);
final bar = tester.getRect(find.byType(FloatingNavigationBar));
final resting = tester.getRect(_lens);
expect(resting.height, bar.height - 8);
final gesture = await tester.startGesture(resting.center);
await tester.pumpAndSettle();
final lifted = tester.getRect(_lens);
expect(lifted.top, lessThan(bar.top));
expect(lifted.bottom, greaterThan(bar.bottom));
await gesture.up();
await tester.pumpAndSettle();
expect(tester.getRect(_lens), 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 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 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 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(glass, findsOneWidget);
expect(tester.takeException(), isNull);
});
}
testWidgets('the bar swells under a pointer and stretches past its end', (
tester,
) async {
await _pumpHome(tester);
final bar = find.byType(FloatingNavigationBar);
final resting = tester.getRect(bar);
Rect painted() => _painted(tester, tester.renderObject(bar), resting);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(mouse.removePointer);
await mouse.addPointer(location: Offset.zero);
await mouse.moveTo(resting.center);
await tester.pumpAndSettle();
final hovered = painted();
expect(hovered.center, resting.center);
expect(hovered.width, greaterThan(resting.width));
await mouse.down(resting.center);
await tester.pumpAndSettle();
final pressed = painted();
expect(tester.getRect(bar), resting);
expect(pressed.center, resting.center);
expect(pressed.width, greaterThan(hovered.width));
expect(pressed.height, greaterThan(resting.height));
await mouse.moveTo(resting.centerRight + const Offset(80, 0));
await tester.pumpAndSettle();
final pulled = painted();
expect(pulled.center.dx, greaterThan(resting.center.dx + 4));
expect(pulled.width, greaterThan(pressed.width));
await mouse.up();
await mouse.moveTo(Offset.zero);
await tester.pumpAndSettle();
expect(painted(), resting);
});
testWidgets(
'a hovering pointer highlights and draws only the item under it',
(tester) async {
await _pumpHome(
tester,
overrides: [
currentPageLabelProvider.overrideWithBuild((_, _) => PageLabel.tools),
],
);
final bar = find.byType(FloatingNavigationBar);
final tools = find
.descendant(of: bar, matching: find.byType(GlyphIcon))
.last;
final resting = tester.getRect(tools);
// The lens is drawn first, so the highlight is the last positioned child.
final positioned = find.descendant(
of: bar,
matching: find.byType(Positioned),
);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(mouse.removePointer);
await mouse.addPointer(location: Offset.zero);
await mouse.moveTo(resting.center + const Offset(30, 0));
await tester.pumpAndSettle();
final hovered = tester.getRect(tools);
expect(hovered.width, moreOrLessEquals(resting.width));
expect(positioned, findsNWidgets(2));
final highlight =
tester.getCenter(positioned.last).dx - resting.center.dx;
final drawn = hovered.center.dx - resting.center.dx;
expect(drawn, greaterThan(0.5));
expect(drawn, moreOrLessEquals(highlight / 2, epsilon: 0.5));
expect(highlight, lessThan(30));
expect(
tester.getRect(positioned.first),
rectMoreOrLessEquals(tester.getRect(positioned.last)),
);
await mouse.moveTo(resting.center - const Offset(80, 0));
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 16));
expect(
tester.getRect(tools).center.dx,
inInclusiveRange(resting.center.dx - 0.5, hovered.center.dx),
);
}
await tester.pumpAndSettle();
expect(tester.getCenter(tools).dx, moreOrLessEquals(resting.center.dx));
await mouse.moveTo(resting.center);
await tester.pumpAndSettle();
await mouse.down(resting.center);
await tester.pumpAndSettle();
expect(positioned, findsOneWidget);
await mouse.up();
await tester.pumpAndSettle();
expect(positioned, findsNWidgets(2));
await mouse.moveTo(Offset.zero);
await tester.pumpAndSettle();
expect(tester.getCenter(tools).dx, moreOrLessEquals(resting.center.dx));
expect(positioned, findsOneWidget);
},
);
testWidgets('a tap leaves no highlight behind', (tester) async {
await _pumpHome(tester);
final bar = find.byType(FloatingNavigationBar);
final tools = find
.descendant(of: bar, matching: find.byType(GlyphIcon))
.last;
final positioned = find.descendant(
of: bar,
matching: find.byType(Positioned),
);
final finger = await tester.createGesture();
addTearDown(finger.removePointer);
await finger.addPointer(location: Offset.zero);
await finger.moveTo(tester.getCenter(tools));
await tester.pumpAndSettle();
expect(positioned, findsOneWidget);
await finger.down(tester.getCenter(tools));
await finger.up();
await tester.pumpAndSettle();
expect(positioned, findsOneWidget);
});
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.byType(FloatingActionButton), findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('four destinations keep to their width and five fill the row', (
tester,
) async {
const size = Size(560, 800);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
Future<Rect> pumpBar(int count) async {
await tester.pumpWidget(
TestApp(
child: Scaffold(
body: Align(
alignment: Alignment.bottomCenter,
child: NavigationDock(
destinations: [
for (var index = 0; index < count; index++)
NavigationDockDestination(
glyph: AppGlyphs.tools,
label: '$index',
),
],
selectedIndex: 0,
onSelected: (_) {},
),
),
),
),
);
return tester.getRect(find.byType(FloatingNavigationBar));
}
final four = await pumpBar(4);
expect(four.center.dx, size.width / 2);
expect(four.width, lessThan(size.width - 42));
final five = await pumpBar(5);
expect(five.left, 21);
expect(five.right, size.width - 21);
});
testWidgets('a long label shrinks to fit, then ellipsizes behind a tooltip', (
tester,
) async {
tester.view.physicalSize = const Size(340, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
Future<List<Text>> pumpLabels(String label) async {
await tester.pumpWidget(
TestApp(
child: Scaffold(
body: Align(
alignment: Alignment.bottomCenter,
child: NavigationDock(
destinations: [
for (final text in ['Home', 'Apps', 'Logs', label])
NavigationDockDestination(
glyph: AppGlyphs.tools,
label: text,
),
],
selectedIndex: 0,
onSelected: (_) {},
),
),
),
),
);
return tester
.widgetList<Text>(
find.descendant(
of: find.byType(FloatingNavigationBar),
matching: find.byType(Text),
),
)
.toList();
}
final tooltips = find.descendant(
of: find.byType(FloatingNavigationBar),
matching: find.byType(Tooltip),
);
var labels = await pumpLabels('Profile');
final shrunk = labels.first.style!.fontSize!;
expect(shrunk, lessThan(10));
expect(shrunk, greaterThan(9));
expect(labels.map((text) => text.style!.fontSize).toSet(), {shrunk});
expect(tooltips, findsNothing);
labels = await pumpLabels('Configuration');
expect(labels.first.style!.fontSize, closeTo(9, 0.001));
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 {
await tester.pumpWidget(
TestApp(
child: Scaffold(
body: IconTheme.merge(
data: const IconThemeData(fill: 1),
child: Row(
children: [
ElasticButton(
child: IconButton.filled(
tooltip: _addLabel,
onPressed: () {},
icon: const GlyphIcon(AppGlyphs.add),
),
),
ElasticButton(
child: FilledButton(
onPressed: () {},
child: const Text(_addLabel),
),
),
ElasticButton(
child: FloatingActionButton(
heroTag: null,
onPressed: () {},
child: const GlyphIcon(AppGlyphs.copy),
),
),
],
),
),
),
),
);
InkWell inkOf(Type button) => tester.widget<InkWell>(
find.descendant(
of: find.byType(button),
matching: find.byType(InkWell),
),
);
for (final button in [IconButton, FilledButton]) {
final ink = inkOf(button);
expect(ink.splashFactory, NoSplash.splashFactory);
expect(
ink.overlayColor!.resolve({WidgetState.pressed}),
Colors.transparent,
);
}
final fabInk = find.descendant(
of: find.byType(FloatingActionButton),
matching: find.byType(InkWell),
);
final fabTheme = Theme.of(tester.element(fabInk));
expect(fabTheme.splashFactory, NoSplash.splashFactory);
expect(fabTheme.highlightColor, Colors.transparent);
expect(
IconTheme.of(tester.element(find.byType(GlyphIcon).first)).fill,
1,
);
},
);
}