The developer's review of 8 Nov 2026: the bar follows the screen in view rather than one colour per app, so a Todo project of its own colour gives the bar that colour while it shows. The wave, the chosen destination's icon and label and its indicator take ScreenTint.color, the colour the screen in view claims through its TopGradient; the frame's accent, the app's colour, stays only for the moment before any screen claims one, since ScreenTint itself starts at cobalt, which would be wrong for Todo, Train and Meditate. Learn's tabs claim cobalt, their wash, so its bar is cobalt there now, not purple. A colour changing on the same tab (a project opening) passes into the next over 300 ms, at once with animations off. A wave paints in that colour as it goes: it starts in the colour of the screen being left, as the arriving one claims its own only once it shows, a frame later, and passes into it with the rest of the bar. A person's colours may be light, so the chosen destination's colour is made legible (readableTone, halkyon_ui): a palette colour starts from its own text tone as Todo's dates take it (its outline in light mode, its fill in dark), any other colour from itself, then darkens towards black in light mode and lightens towards white in dark, only until it reads at 4.5:1 on the bar and on its indicator. Every palette colour is tested in both themes, on the neutral and the tinted surface. This also lifts Learn's label, which read at about 3:1. The debug section gains Bar surface, neutral or tinted: tinted mixes 6 % of the screen's colour into the bar's own surface in light mode and 8 % in dark, changing with it. Neutral by default, and in the shipping style. Whats-New: The navigation bar takes the colour of the screen you're on, such as a project's own. Whats-New-De: Die Navigationsleiste nimmt die Farbe des Bildschirms an, auf dem du bist, etwa die eines Projekts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
317 lines
11 KiB
Dart
317 lines
11 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 on a neutral surface, which the debug section starts '
|
||
'from', (tester) async {
|
||
expect(shippingNavigationBarStyle, (
|
||
variant: NavigationBarVariant.waveWithoutFill,
|
||
waveDuration: const Duration(milliseconds: 1200),
|
||
cornerRadius: 20.0,
|
||
tintedSurface: false,
|
||
));
|
||
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,
|
||
tintedSurface: false,
|
||
);
|
||
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,
|
||
tintedSurface: false,
|
||
);
|
||
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, legible, 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;
|
||
// The app colour as legible on the bar and on its indicator.
|
||
Color tone() {
|
||
final surface = Theme.of(
|
||
tester.element(find.byType(NavigationBar)),
|
||
).colorScheme.surfaceContainer;
|
||
return readableTone(
|
||
accent,
|
||
brightness: Brightness.light,
|
||
on: [
|
||
surface,
|
||
Color.alphaBlend(accent.withValues(alpha: 0.22), surface),
|
||
],
|
||
);
|
||
}
|
||
|
||
debugNavigationBarStyle.value = (
|
||
variant: NavigationBarVariant.wave,
|
||
waveDuration: const Duration(milliseconds: 1200),
|
||
cornerRadius: 20.0,
|
||
tintedSurface: false,
|
||
);
|
||
await tester.pumpWidget(frame(selected: 1));
|
||
await tester.pumpAndSettle();
|
||
expect(iconColor(Icons.map), tone());
|
||
expect(iconColor(Icons.home), isNot(tone()));
|
||
expect(
|
||
tester.widget<Text>(find.text('Maps')).style?.color ??
|
||
DefaultTextStyle.of(tester.element(find.text('Maps'))).style.color,
|
||
tone(),
|
||
);
|
||
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), tone());
|
||
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,
|
||
tintedSurface: false,
|
||
);
|
||
await tester.pumpAndSettle();
|
||
expect(iconColor(Icons.map), tone());
|
||
expect(bar().indicatorColor, Colors.transparent);
|
||
|
||
debugNavigationBarStyle.value = (
|
||
variant: NavigationBarVariant.material,
|
||
waveDuration: const Duration(milliseconds: 1200),
|
||
cornerRadius: 20.0,
|
||
tintedSurface: false,
|
||
);
|
||
await tester.pumpAndSettle();
|
||
expect(iconColor(Icons.map), isNot(tone()));
|
||
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,
|
||
tintedSurface: false,
|
||
);
|
||
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();
|
||
await tester.tap(find.text('Tinted'));
|
||
await tester.pump();
|
||
expect(debugNavigationBarStyle.value, (
|
||
variant: NavigationBarVariant.waveWithoutIndicator,
|
||
waveDuration: const Duration(seconds: 2),
|
||
cornerRadius: 28.0,
|
||
tintedSurface: true,
|
||
));
|
||
|
||
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,
|
||
tintedSurface: false,
|
||
);
|
||
expect(
|
||
effectiveNavigationBarStyle(debug: false),
|
||
shippingNavigationBarStyle,
|
||
);
|
||
// Tests run as a debug build.
|
||
expect(effectiveNavigationBarStyle(), debugNavigationBarStyle.value);
|
||
});
|
||
}
|