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:
parent
05db5e2f45
commit
45fd6595e1
2 changed files with 789 additions and 14 deletions
288
packages/halkyon_ui/test/widget/navigation_bar_wave_test.dart
Normal file
288
packages/halkyon_ui/test/widget/navigation_bar_wave_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 [
|
||||
|
|
|
|||
Loading…
Reference in a new issue