halkyon/packages/halkyon_ui/test/widget/navigation_frame_test.dart
Tizian 「ティツィアーン」 560de2ed93
fix(ui): on a phone, a snack bar stands above the screen's floating button
Below medium windows the frame's scaffold showed the screens' snack bars,
under the floating button of the screen's own scaffold, so a tap on a
snack bar's Undo hit the button instead (found 9 Nov 2026, Todo's Undo
after deleting the completed tasks). The screens get a messenger of their
own there, and their scaffolds lift the snack bars above their buttons, as
Material places them. From medium windows on the frame's scaffold still
shows them, once: a messenger shows a snack bar on each of its scaffolds
not inside another, as two panes side by side are. As the window grows
past a phone's, the phone's snack bars go, since a scaffold whose
messenger changes keeps the one it showed for good.

Whats-New: On a phone, a message's Undo is no longer hidden under the + button.
Whats-New-De: Auf dem Handy liegt das Rückgängig einer Meldung nicht mehr unter der +-Schaltfläche.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-10 14:22:56 +01:00

543 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
/// Every Halkyon app's frame around its tabs: a rail with the app's icon
/// from medium windows on, a flush bar below with a fade above it, a tick on every choice.
void main() {
/// A 1 × 1 PNG, the app's icon in these tests.
final icon = MemoryImage(
Uint8List.fromList(const [
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, //
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00,
0x0B, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x60, 0x00, 0x02, 0x00,
0x00, 0x05, 0x00, 0x01, 0x7A, 0x5E, 0xAB, 0x3F, 0x00, 0x00, 0x00, 0x00,
0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
]),
);
/// The frame in a window [width] wide, on tab [selected]; the tabs chosen
/// go into [chosen].
Future<void> show(
WidgetTester tester, {
required double width,
required List<int> chosen,
int selected = 1,
}) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: selected,
onSelected: chosen.add,
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
(label: 'Profile', icon: Icon(Icons.person), selectedIcon: null),
],
child: const Scaffold(body: Text('The tab')),
),
),
);
}
/// The ticks the platform was asked for while [act] ran.
Future<List<Object?>> ticks(
WidgetTester tester,
Future<void> Function() act,
) async {
final haptics = <Object?>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'HapticFeedback.vibrate') {
haptics.add(call.arguments);
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
await act();
return haptics;
}
testWidgets('below medium windows, a bar stands at the foot', (
tester,
) async {
await show(tester, width: 400, chosen: []);
expect(find.byType(FlushNavigationBar), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
expect(find.text('The tab'), findsOneWidget);
});
testWidgets('below medium windows, the bar is flush with the foot: as wide '
"as the window, 64 high above the system's bar, in the theme's colour", (
tester,
) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
await show(tester, width: 400, chosen: []);
final bar = find.byType(NavigationBar);
expect(
tester.getRect(bar),
const Rect.fromLTRB(0, 800 - 64 - 24, 400, 800),
);
final colour = find.descendant(
of: find.byKey(FlushNavigationBar.barKey),
matching: find.byType(ColoredBox),
);
expect(
tester.widget<ColoredBox>(colour.first).color,
Theme.of(tester.element(bar)).colorScheme.surfaceContainer,
);
});
testWidgets('right above the bar, a fade into the background of the screen '
'in view, which taps pass through to the screen', (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
var tapped = 0;
const background = Color(0xFFFFE4C4);
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: TopGradient(
background: background,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => tapped++,
child: const SizedBox.expand(),
),
),
),
),
);
await tester.pumpAndSettle();
final fade = find.byKey(FlushNavigationBar.fadeKey);
final bar = tester.getRect(find.byType(NavigationBar));
expect(
tester.getRect(fade),
Rect.fromLTRB(0, bar.top - FlushNavigationBar.fadeHeight, 400, bar.top),
);
final gradient =
(tester.widget<DecoratedBox>(fade).decoration as BoxDecoration)
.gradient!
as LinearGradient;
expect(gradient.colors, [background.withValues(alpha: 0), background]);
await tester.tapAt(tester.getCenter(fade));
expect(tapped, 1);
});
testWidgets('before a screen claims its background, the fade ends in the '
"theme's surface, light and dark", (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
for (final brightness in Brightness.values) {
await tester.pumpWidget(
testApp(
brightness: brightness,
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: const Scaffold(body: Text('The tab')),
),
),
);
await tester.pumpAndSettle();
final fade = find.byKey(FlushNavigationBar.fadeKey);
final surface = Theme.of(tester.element(fade)).colorScheme.surface;
final gradient =
(tester.widget<DecoratedBox>(fade).decoration as BoxDecoration)
.gradient!
as LinearGradient;
expect(gradient.colors.last, surface, reason: '$brightness');
}
});
testWidgets("a tab's content leaves room for the bar, its fade and the "
"system's bar; its floating button rises above the fade, the Scaffold "
"keeping it clear of the system's bar itself", (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
late double cover;
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: Builder(
builder: (context) => Scaffold(
body: Builder(
builder: (context) {
cover = FlushNavigationBar.coverOf(context);
return const SizedBox.expand(key: Key('tab'));
},
),
floatingActionButton: Padding(
padding: EdgeInsets.only(
bottom: FlushNavigationBar.buttonLiftOf(context),
),
child: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(cover, FlushNavigationBar.fadeHeight + 64 + 24);
expect(
tester.getBottomLeft(find.byKey(const Key('tab'))).dy,
800,
reason: 'the content runs on beneath the fade and the bar',
);
expect(
tester.getRect(find.byType(FloatingActionButton)).bottom,
tester.getRect(find.byKey(FlushNavigationBar.fadeKey)).top - 16,
);
});
testWidgets("a screen's snack bar stands above its floating button, so "
'the button never takes its Undo (found 9 Nov 2026)', (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
var undone = 0;
var added = 0;
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: Builder(
builder: (context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => showUndoSnackBar(
context,
message: 'Task completed',
onUndo: () => undone++,
),
child: const Text('Complete'),
),
),
floatingActionButton: Padding(
padding: EdgeInsets.only(
bottom: FlushNavigationBar.buttonLiftOf(context),
),
child: FloatingActionButton(
onPressed: () => added++,
child: const Icon(Icons.add),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Complete'));
await tester.pumpAndSettle();
expect(
tester.getRect(find.text('Undo')).bottom,
lessThanOrEqualTo(
tester.getRect(find.byType(FloatingActionButton)).top,
),
);
await tester.tap(find.text('Undo'));
expect((undone, added), (1, 0));
});
testWidgets("a screen's snack bar shown on a phone doesn't stay once the "
'window grows past it', (tester) async {
final screens = GlobalKey();
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
// The screens keep their state as the frame's layout changes, as an
// app's router keeps its navigator by its key.
child: KeyedSubtree(
key: screens,
child: Builder(
builder: (context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Archived')),
),
child: const Text('Archive'),
),
),
),
),
),
),
),
);
await tester.tap(find.text('Archive'));
await tester.pumpAndSettle();
expect(find.text('Archived'), findsOneWidget);
tester.view.physicalSize = const Size(1200, 800);
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text('Archived'), findsNothing);
});
testWidgets("outside the frame, content leaves room for the system's bar "
'alone', (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
late double cover;
late double lift;
await tester.pumpWidget(
testApp(
Scaffold(
body: Builder(
builder: (context) {
cover = FlushNavigationBar.coverOf(context);
lift = FlushNavigationBar.buttonLiftOf(context);
return const SizedBox.expand();
},
),
),
),
);
expect(cover, 24);
expect(lift, 0);
});
testWidgets("from medium windows on, a rail with the app's icon on top, "
'which leads to the first tab', (tester) async {
final chosen = <int>[];
await show(tester, width: 800, chosen: chosen);
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
expect(find.bySemanticsLabel('Halkyon Test'), findsOneWidget);
await tester.tap(find.byTooltip('Home'));
await tester.pump();
expect(chosen, [0]);
});
testWidgets('every choice on the bar ticks, lightly', (tester) async {
final chosen = <int>[];
await show(tester, width: 400, chosen: chosen);
final haptics = await ticks(tester, () async {
await tester.tap(find.text('Profile'));
await tester.pump();
});
expect(haptics, ['HapticFeedbackType.selectionClick']);
expect(chosen, [2]);
});
testWidgets('every choice on the rail ticks, lightly', (tester) async {
final chosen = <int>[];
await show(tester, width: 800, chosen: chosen);
final haptics = await ticks(tester, () async {
await tester.tap(find.text('Maps'));
await tester.pump();
});
expect(haptics, ['HapticFeedbackType.selectionClick']);
expect(chosen, [1]);
});
testWidgets('the window crossing from compact to medium and back swaps '
'the bar for the rail', (tester) async {
await show(tester, width: 400, chosen: []);
for (final width in const [800.0, 400.0]) {
tester.view.physicalSize = Size(width, 800);
await tester.pumpAndSettle();
}
expect(find.byType(FlushNavigationBar), findsOneWidget);
expect(find.text('The tab'), findsOneWidget);
});
testWidgets('the bar steps aside where a screen has a bar of its own, '
"leaving the system's bar its room", (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
hidesNavigationBar: true,
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: const Scaffold(body: Text('The tab')),
),
),
);
expect(find.byType(FlushNavigationBar), findsNothing);
expect(tester.getBottomLeft(find.byType(Scaffold).last).dy, 800 - 24);
});
testWidgets('a status stands above the bar below medium windows, and at '
"the foot of the screen's panel from there on", (tester) async {
for (final width in const [400.0, 800.0]) {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
status: const Text('Recording'),
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: const Scaffold(body: Text('The tab')),
),
),
);
await tester.pumpAndSettle();
final status = tester.getBottomLeft(find.text('Recording')).dy;
final tab = tester.getTopLeft(find.text('The tab')).dy;
expect(status, greaterThan(tab), reason: '$width');
if (width < 600) {
expect(
status,
lessThan(tester.getTopLeft(find.byType(NavigationBar)).dy),
);
}
}
});
testWidgets("the keyboard leaves the frame's tab its size: the tab's own "
'screen decides whether it makes way, as one with a field floating '
"over it doesn't", (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
Widget frame({required bool screenResizes}) => testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: Scaffold(
resizeToAvoidBottomInset: screenResizes,
body: const SizedBox.expand(key: Key('tab')),
),
),
);
await tester.pumpWidget(frame(screenResizes: false));
final before = tester.getSize(find.byKey(const Key('tab')));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.resetViewInsets);
await tester.pump();
expect(tester.getSize(find.byKey(const Key('tab'))), before);
await tester.pumpWidget(frame(screenResizes: true));
expect(
tester.getSize(find.byKey(const Key('tab'))).height,
lessThan(before.height),
reason: 'a screen that resizes still makes way',
);
});
}