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

View file

@ -1,6 +1,7 @@
import 'package:flutter/services.dart';
import 'package:halkyon_ui/src/layout/window_size.dart';
import 'package:halkyon_ui/src/widget/flush_navigation_bar.dart';
import 'package:halkyon_ui/src/widget/navigation_bar_style.dart';
import 'package:halkyon_ui/src/widget/screen_tint.dart';
import 'package:halkyon_ui/src/widget/tinted_frame.dart';
import 'package:material_ui/material_ui.dart';
@ -31,6 +32,13 @@ typedef FrameDestination = ({
/// system's touch feedback is on. Choosing the tab already selected is
/// passed on too, for the app to go back to its first screen, as Material
/// prescribes.
///
/// The bar's look is [shippingNavigationBarStyle] (the developer's choice,
/// 7 Nov 2026), in debug builds the one Settings' debug section switches
/// to: a tap on another destination sends a wave in [accent] over the bar
/// from it, unless animations are off, and the destination chosen takes
/// [accent]; a tab changed otherwise (a link, back) sends none, as the wave
/// answers the finger.
class const NavigationFrame({
/// The tabs, in order.
required final List<FrameDestination> destinations,
@ -47,6 +55,11 @@ class const NavigationFrame({
/// The app's icon, square, atop the rail.
required final ImageProvider icon,
/// The app's colour, its icon's last and its screens' wash (Learn's
/// purple, Todo's strawberry, Train's carrot, Meditate's fern): the
/// wave's and the destination chosen's on the bar.
required final Color accent,
/// The tab's screens.
required final Widget child,
@ -70,14 +83,33 @@ class const NavigationFrame({
class _NavigationFrameState() extends State<NavigationFrame> {
final _tint = ScreenTint();
/// The latest wave sent by a tap; none before one.
NavigationWave? _wave;
@override
void initState() {
super.initState();
debugNavigationBarStyle.addListener(_restyle);
}
@override
void dispose() {
debugNavigationBarStyle.removeListener(_restyle);
_tint.dispose();
super.dispose();
}
void _restyle() => setState(() {});
void _select(int index) {
HapticFeedback.selectionClick();
if (index != widget.selectedIndex &&
effectiveNavigationBarStyle().variant.waves &&
!MediaQuery.disableAnimationsOf(context)) {
setState(
() => _wave = (index: index, serial: (_wave?.serial ?? 0) + 1),
);
}
widget.onSelected(index);
}
@ -121,9 +153,28 @@ class _NavigationFrameState() extends State<NavigationFrame> {
),
);
}
final bar = NavigationBar(
final style = effectiveNavigationBarStyle();
final variant = style.variant;
final accent = widget.accent;
final withoutFill =
variant == NavigationBarVariant.waveWithoutFill ||
variant == NavigationBarVariant.waveWithoutIndicator;
final Widget bar = NavigationBar(
// The flush bar paints the bar's colour beneath the wave.
backgroundColor: Colors.transparent,
elevation: 0,
selectedIndex: widget.selectedIndex,
onDestinationSelected: _select,
indicatorColor: switch (variant) {
NavigationBarVariant.material => null,
NavigationBarVariant.waveWithoutIndicator => Colors.transparent,
_ => accent.withValues(alpha: 0.22),
},
// Without the fill: no ripple, the indicator there at once.
overlayColor: withoutFill
? const WidgetStatePropertyAll(Colors.transparent)
: null,
animationDuration: withoutFill ? Duration.zero : null,
destinations: [
for (final destination in destinations)
NavigationDestination(
@ -172,8 +223,15 @@ class _NavigationFrameState() extends State<NavigationFrame> {
)
: FlushNavigationBar(
background: background ?? surface,
accent: accent,
destinationCount: destinations.length,
wave: _wave,
waveDuration: style.waveDuration,
cornerRadius: style.cornerRadius,
status: widget.status,
bar: bar,
bar: variant.waves
? _Chosen(accent: accent, child: bar)
: bar,
),
),
),
@ -182,6 +240,33 @@ class _NavigationFrameState() extends State<NavigationFrame> {
}
}
/// The bar's destination chosen in [accent], its icon and its label, the
/// others as the theme has them.
class const _Chosen({required final Color accent, required final Widget child})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = NavigationBarTheme.of(context);
return NavigationBarTheme(
data: theme.copyWith(
iconTheme: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? IconThemeData(color: accent, size: 24)
: theme.iconTheme?.resolve(states),
),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? (theme.labelTextStyle?.resolve(states) ??
Theme.of(context).textTheme.labelMedium)
?.copyWith(color: accent)
: theme.labelTextStyle?.resolve(states),
),
),
child: child,
);
}
}
/// The app's icon atop the rail, leading to the first tab ([onTap], named
/// by [first]) as a website's logo does. In a rounded tile, so that an icon
/// with a background of its own reads on light and dark rails alike.