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>
288 lines
9.9 KiB
Dart
288 lines
9.9 KiB
Dart
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);
|
||
});
|
||
}
|