halkyon/packages/halkyon_ui/test/widget/navigation_bar_wave_test.dart

288 lines
9.9 KiB
Dart
Raw Normal View History

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 00:29:14 +01:00
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);
});
}