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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-08 00:29:14 +01:00
commit 45fd6595e1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 789 additions and 14 deletions

View file

@ -0,0 +1,288 @@
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);
});
}

View file

@ -36,6 +36,7 @@ void main() {
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: selected,
onSelected: chosen.add,
destinations: const [
@ -97,8 +98,14 @@ void main() {
tester.getRect(bar),
const Rect.fromLTRB(0, 800 - 64 - 24, 400, 800),
);
expect(tester.widget<NavigationBar>(bar).backgroundColor, isNull);
expect(tester.widget<NavigationBar>(bar).elevation, isNull);
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 '
@ -115,6 +122,7 @@ void main() {
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
@ -162,6 +170,7 @@ void main() {
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
@ -198,6 +207,7 @@ void main() {
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
@ -329,6 +339,7 @@ void main() {
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
hidesNavigationBar: true,
@ -356,6 +367,7 @@ void main() {
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
status: const Text('Recording'),
@ -391,6 +403,7 @@ void main() {
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
destinations: const [