halkyon/packages/halkyon_ui/test/widget/navigation_frame_test.dart
Tizian 「ティツィアーン」 c5eceda383
feat(ui): on a phone, the floating button sits just above the navigation bar, its screen's fade under it
Each tab's screen paints the bar's fade beneath its own button
(FrameFade as its bottomNavigationBar, extendBody), so the button stands
16 pixels above the bar, as Material's, instead of above the fade; the
frame keeps its own fade for screens without one, and gets it back while
a page is pushed over the screen.

Whats-New: On a phone, the + button now sits just above the navigation bar.
Whats-New-De: Auf dem Handy sitzt der +-Knopf jetzt direkt über der Navigationsleiste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-10 14:23:00 +01:00

704 lines
23 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');
}
});
/// A tab's screen with a floating button, which paints the fade above
/// the bar itself ([FrameFade]); its content's room in [cover].
Widget fadingScreen({
void Function(double cover)? cover,
Key? key,
bool resizes = true,
}) => Builder(
builder: (context) => Scaffold(
key: key,
extendBody: true,
resizeToAvoidBottomInset: resizes,
floatingActionButtonLocation: FrameFade.buttonLocation(context),
body: Builder(
builder: (context) {
cover?.call(FlushNavigationBar.coverOf(context));
return ListView(
key: const Key('tab'),
children: [
for (var i = 0; i < 30; i++)
SizedBox(height: 60, child: Text('Row $i')),
],
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
bottomNavigationBar: const FrameFade(),
),
);
/// The frame on a phone 400 × 800 with a system bar 24 high, around
/// [child].
Future<void> showPhone(WidgetTester tester, Widget child, {Widget? status}) {
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);
return tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
status: status,
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: child,
),
),
);
}
/// The fades painting: the frame's, or the screens'.
Finder fades() => find.byKey(FlushNavigationBar.fadeKey);
/// Whether the fade painting is the screen's own, beneath its button.
bool screenFades(WidgetTester tester) => find
.descendant(of: find.byType(FrameFade), matching: fades())
.evaluate()
.isNotEmpty;
testWidgets("a tab's content leaves room for the bar, its fade and the "
"system's bar, running on beneath them; its floating button stands 16 "
'above the bar, over the fade, which the screen paints beneath it (the '
"developer's review, 9 Nov 2026)", (tester) async {
late double cover;
await showPhone(tester, fadingScreen(cover: (room) => cover = room));
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',
);
final bar = tester.getRect(find.byKey(FlushNavigationBar.barKey));
expect(
tester.getRect(find.byType(FloatingActionButton)).bottom,
bar.top - 16,
);
expect(fades(), findsOneWidget, reason: 'one fade, not two');
expect(screenFades(tester), isTrue);
expect(tester.getRect(fades()).bottom, bar.top);
expect(tester.getRect(fades()).height, FlushNavigationBar.fadeHeight);
});
testWidgets('the keyboard leaves the button where it stands on a screen '
"that doesn't make way for it (Todo's)", (tester) async {
await showPhone(tester, fadingScreen(resizes: false));
await tester.pumpAndSettle();
final before = tester.getRect(find.byType(FloatingActionButton));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
expect(tester.getRect(find.byType(FloatingActionButton)), before);
});
testWidgets("a screen without its own fade keeps the frame's", (
tester,
) async {
await showPhone(tester, const Scaffold(body: Text('The tab')));
await tester.pumpAndSettle();
expect(fades(), findsOneWidget);
expect(screenFades(tester), isFalse);
});
testWidgets('a menu or a dialog over the screen leaves its fade the '
"screen's, beneath its button", (tester) async {
await showPhone(
tester,
Builder(
builder: (context) => Scaffold(
extendBody: true,
body: Center(
child: TextButton(
onPressed: () => showDialog<void>(
context: context,
builder: (context) => const AlertDialog(content: Text('Hi')),
),
child: const Text('Ask'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
floatingActionButtonLocation: FrameFade.buttonLocation(context),
bottomNavigationBar: const FrameFade(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Ask'));
await tester.pumpAndSettle();
expect(find.text('Hi'), findsOneWidget);
expect(fades(), findsOneWidget);
expect(screenFades(tester), isTrue);
});
testWidgets('a page over the screen in its tab without a fade of its own '
"has the frame's, and back the screen's returns", (tester) async {
final tab = GlobalKey<NavigatorState>();
await showPhone(
tester,
Navigator(
key: tab,
onGenerateRoute: (_) =>
MaterialPageRoute<void>(builder: (context) => fadingScreen()),
),
);
await tester.pumpAndSettle();
expect(screenFades(tester), isTrue);
tab.currentState!.push(
MaterialPageRoute<void>(
builder: (context) => const Scaffold(body: Text('Inside')),
),
);
await tester.pumpAndSettle();
expect(find.text('Inside'), findsOneWidget);
expect(fades(), findsOneWidget);
expect(screenFades(tester), isFalse);
tab.currentState!.pop();
await tester.pumpAndSettle();
expect(fades(), findsOneWidget);
expect(screenFades(tester), isTrue);
});
testWidgets('with a status above the bar, the fade and the button stand '
'above it', (tester) async {
await showPhone(
tester,
fadingScreen(),
status: const SizedBox(height: 40, child: Text('Recording')),
);
await tester.pumpAndSettle();
final status = tester.getRect(find.text('Recording'));
expect(tester.getRect(fades()).bottom, lessThanOrEqualTo(status.top));
expect(
tester.getRect(find.byType(FloatingActionButton)).bottom,
lessThanOrEqualTo(tester.getRect(fades()).bottom - 16),
);
});
testWidgets('from medium windows on, a screen with its own fade paints '
'none, as there is no bar', (tester) async {
tester.view.physicalSize = const Size(1000, 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),
],
child: fadingScreen(),
),
),
);
await tester.pumpAndSettle();
expect(fades(), findsNothing);
});
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'),
),
),
extendBody: true,
floatingActionButtonLocation: FrameFade.buttonLocation(context),
floatingActionButton: FloatingActionButton(
onPressed: () => added++,
child: const Icon(Icons.add),
),
bottomNavigationBar: const FrameFade(),
),
),
),
),
);
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;
await tester.pumpWidget(
testApp(
Scaffold(
body: Builder(
builder: (context) {
cover = FlushNavigationBar.coverOf(context);
return const SizedBox.expand();
},
),
),
),
);
expect(cover, 24);
});
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',
);
});
}