halkyon/packages/halkyon_ui/test/widget/navigation_bar_wave_test.dart
Tizian 「ティツィアーン」 45fd6595e1
feat(ui,learn,todo,train,meditate): a wave in the app's colour over the navigation bar
The developer's choice of 7 Nov 2026 (the mockup's variant E): a tap on
another destination sends a radial glow in the app's colour over the bar
from that destination's centre, solid to 35 % of its radius and gone at
70 %, growing to cover the bar (cubic(.2, .7, .2, 1)) while it fades from
half its strength in dark mode and a third in light, over 1.2 s, behind
the destinations and clipped to the bar. None for the destination already
chosen, none with animations off, and none for a tab changed without a
tap (a link, back): the wave answers the finger. The destination chosen
takes the app's colour, its indicator tinted 22 % with it, without
Material's ripple or the indicator growing; the bar's top corners are
rounded, 20 dp, the screen showing through them.

Each app gives the frame its colour (NavigationFrame's accent, from
PaletteColor): Learn's purple, its icon's last colour, Todo's strawberry,
Train's carrot and Meditate's fern, their screens' wash.

For trying it on a phone, a debug build's Settings has a section
Navigation bar (debug), halkyon_ui's NavigationBarDebugSection, switching
live, in memory alone: the variant (A Material only, C the wave with
Material's fill, E without it, F without the indicator too), the wave's
time (0.7 to 2 s) and the corners (0 to 28 dp). Release builds never read
it and show shippingNavigationBarStyle, the debug section's start: to
remove it, delete the section from the four Settings screens and
debugNavigationBarStyle, and let effectiveNavigationBarStyle return the
shipping style.

Whats-New: Tapping a tab sends a soft wave in the app's colour across the navigation bar, now with rounded corners.
Whats-New-De: Ein Tipp auf einen Tab schickt eine sanfte Welle in der Farbe der App über die Navigationsleiste, jetzt mit abgerundeten Ecken.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-08 11:48:37 +01:00

288 lines
9.9 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';
/// The bar's look below medium windows (the developer's choice of 7 Nov
/// 2026): a wave in the app's colour spreading from the destination tapped,
/// the destination chosen in that colour, no fill, rounded top corners; and
/// the section switching them while testing, in debug builds alone.
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,
]),
);
const accent = Color(0xFFEC3E7C);
tearDown(() => debugNavigationBarStyle.value = shippingNavigationBarStyle);
/// The frame on tab [selected], its choices passed to [onSelected];
/// animations off where [disableAnimations].
Widget frame({
required int selected,
ValueChanged<int>? onSelected,
bool disableAnimations = false,
}) => testApp(
Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
child: NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: accent,
selectedIndex: selected,
onSelected: onSelected ?? (_) {},
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')),
),
),
),
);
/// A frame whose tab follows the taps on its bar.
Widget following({bool disableAnimations = false}) {
var selected = 0;
return StatefulBuilder(
builder: (context, setState) => frame(
selected: selected,
disableAnimations: disableAnimations,
onSelected: (index) => setState(() => selected = index),
),
);
}
Future<void> phone(WidgetTester tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
}
final wave = find.byKey(FlushNavigationBar.waveKey);
testWidgets('the shipping look is the wave without the fill, 1.2 s, with '
'corners of 20, which the debug section starts from', (tester) async {
expect(shippingNavigationBarStyle, (
variant: NavigationBarVariant.waveWithoutFill,
waveDuration: const Duration(milliseconds: 1200),
cornerRadius: 20.0,
));
expect(debugNavigationBarStyle.value, shippingNavigationBarStyle);
});
testWidgets('a tap on another destination sends a wave over the bar, gone '
'once its time is up', (tester) async {
await phone(tester);
await tester.pumpWidget(following());
expect(wave, findsNothing);
await tester.tap(find.text('Profile'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(wave, findsOneWidget);
final bar = tester.getRect(find.byType(NavigationBar));
expect(
tester.getRect(wave),
bar,
reason: 'it covers the bar, clipped to it',
);
await tester.pump(const Duration(milliseconds: 1000));
expect(wave, findsNothing);
});
testWidgets('the wave takes as long as the style says', (tester) async {
await phone(tester);
debugNavigationBarStyle.value = (
variant: NavigationBarVariant.waveWithoutFill,
waveDuration: const Duration(seconds: 2),
cornerRadius: 20.0,
);
await tester.pumpWidget(following());
await tester.tap(find.text('Maps'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 1500));
expect(wave, findsOneWidget);
await tester.pump(const Duration(milliseconds: 600));
expect(wave, findsNothing);
});
testWidgets('no wave for the destination already chosen, nor with '
'animations off', (tester) async {
await phone(tester);
await tester.pumpWidget(following());
await tester.tap(find.text('Home'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(wave, findsNothing, reason: 'the tab already chosen');
await tester.pumpWidget(following(disableAnimations: true));
await tester.tap(find.text('Maps'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(wave, findsNothing, reason: 'animations off');
});
testWidgets('a tab changed without a tap (a link, back) sends no wave: '
'the wave answers the finger', (tester) async {
await phone(tester);
await tester.pumpWidget(frame(selected: 0));
await tester.pumpWidget(frame(selected: 2));
await tester.pump(const Duration(milliseconds: 100));
expect(wave, findsNothing);
});
testWidgets('Material alone sends no wave', (tester) async {
await phone(tester);
debugNavigationBarStyle.value = (
variant: NavigationBarVariant.material,
waveDuration: const Duration(milliseconds: 1200),
cornerRadius: 20.0,
);
await tester.pumpWidget(following());
await tester.tap(find.text('Maps'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(wave, findsNothing);
});
testWidgets('the destination chosen is in the app colour, its indicator '
'tinted with it; without the fill no ripple and no growing indicator; '
'the last variant without the indicator', (tester) async {
await phone(tester);
NavigationBar bar() => tester.widget(find.byType(NavigationBar));
Color? iconColor(IconData data) => IconTheme.of(
tester.element(find.byIcon(data)),
).color;
debugNavigationBarStyle.value = (
variant: NavigationBarVariant.wave,
waveDuration: const Duration(milliseconds: 1200),
cornerRadius: 20.0,
);
await tester.pumpWidget(frame(selected: 1));
await tester.pumpAndSettle();
expect(iconColor(Icons.map), accent);
expect(iconColor(Icons.home), isNot(accent));
expect(
tester.widget<Text>(find.text('Maps')).style?.color ??
DefaultTextStyle.of(tester.element(find.text('Maps'))).style.color,
accent,
);
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
expect(bar().overlayColor, isNull, reason: 'with the fill');
debugNavigationBarStyle.value = shippingNavigationBarStyle;
await tester.pumpAndSettle();
expect(iconColor(Icons.map), accent);
expect(bar().indicatorColor, accent.withValues(alpha: 0.22));
expect(
bar().overlayColor?.resolve({WidgetState.pressed}),
Colors.transparent,
);
expect(bar().animationDuration, Duration.zero);
debugNavigationBarStyle.value = (
variant: NavigationBarVariant.waveWithoutIndicator,
waveDuration: const Duration(milliseconds: 1200),
cornerRadius: 20.0,
);
await tester.pumpAndSettle();
expect(iconColor(Icons.map), accent);
expect(bar().indicatorColor, Colors.transparent);
debugNavigationBarStyle.value = (
variant: NavigationBarVariant.material,
waveDuration: const Duration(milliseconds: 1200),
cornerRadius: 20.0,
);
await tester.pumpAndSettle();
expect(iconColor(Icons.map), isNot(accent));
expect(bar().indicatorColor, isNull);
});
testWidgets("the bar's top corners are rounded as the style says, the "
'screen showing through them', (tester) async {
await phone(tester);
BorderRadiusGeometry corners() => tester
.widget<ClipRRect>(find.byKey(FlushNavigationBar.barKey))
.borderRadius;
await tester.pumpWidget(frame(selected: 0));
expect(corners(), const BorderRadius.vertical(top: Radius.circular(20)));
debugNavigationBarStyle.value = (
variant: NavigationBarVariant.waveWithoutFill,
waveDuration: const Duration(milliseconds: 1200),
cornerRadius: 0.0,
);
await tester.pump();
expect(corners(), BorderRadius.zero);
});
testWidgets('the debug section switches the look live, and shows nothing '
'outside debug builds', (tester) async {
await phone(tester);
await tester.pumpWidget(
testApp(
const Scaffold(
body: SingleChildScrollView(child: NavigationBarDebugSection()),
),
),
);
expect(find.text('Navigation bar (debug)'), findsOneWidget);
await tester.tap(find.text('F'));
await tester.pump();
await tester.tap(find.text('2 s'));
await tester.pump();
await tester.tap(find.text('28'));
await tester.pump();
expect(debugNavigationBarStyle.value, (
variant: NavigationBarVariant.waveWithoutIndicator,
waveDuration: const Duration(seconds: 2),
cornerRadius: 28.0,
));
await tester.pumpWidget(
testApp(
const Scaffold(body: NavigationBarDebugSection(debug: false)),
),
);
expect(find.text('Navigation bar (debug)'), findsNothing);
});
testWidgets('outside debug builds the frame keeps the shipping look '
'whatever the debug section holds', (tester) async {
expect(
effectiveNavigationBarStyle(debug: false),
shippingNavigationBarStyle,
);
debugNavigationBarStyle.value = (
variant: NavigationBarVariant.material,
waveDuration: const Duration(seconds: 2),
cornerRadius: 0.0,
);
expect(
effectiveNavigationBarStyle(debug: false),
shippingNavigationBarStyle,
);
// Tests run as a debug build.
expect(effectiveNavigationBarStyle(), debugNavigationBarStyle.value);
});
}