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
4 changed files with 789 additions and 14 deletions

View file

@ -33,6 +33,7 @@ export 'src/widget/keyboard_back_on_resume.dart';
export 'src/widget/launch_screen.dart';
export 'src/widget/lift_while_dragged.dart';
export 'src/widget/more_info_sheet.dart';
export 'src/widget/navigation_bar_style.dart';
export 'src/widget/navigation_frame.dart';
export 'src/widget/palette_color_picker.dart';
export 'src/widget/reorder_grip.dart';

View file

@ -1,22 +1,48 @@
import 'dart:math' as math;
import 'dart:ui' show lerpDouble;
import 'package:material_ui/material_ui.dart';
/// A wave over the navigation bar: which destination it spreads from, and
/// which tap sent it (`serial` counts them, so a tap on the same destination
/// as the last sends another).
typedef NavigationWave = ({int index, int serial});
/// The navigation bar at the window's foot, flush with its edges, as
/// Material 3 Expressive keeps it (decided 7 Nov 2026 by the developer, in
/// place of a bar floating above the foot): [bar] across the whole width,
/// in the theme's own colour and height (64 dp, `appTheme`), running on
/// beneath the system's bar as Material's does. Right above it, a fade
/// [fadeHeight] high into [background], the screen's own, so that what
/// on the theme's own colour and in its height (64 dp, `appTheme`), running
/// on beneath the system's bar as Material's does, its top corners rounded
/// by [cornerRadius], the screen showing through them. Right above it, a
/// fade [fadeHeight] high into [background], the screen's own, so that what
/// scrolls on beneath the bar fades out rather than being cut off; taps
/// pass through it to the screen.
///
/// A [wave] in [accent] spreads over the bar from the destination tapped
/// and fades, over [waveDuration], behind the destinations.
class const FlushNavigationBar({
/// The bar: a [NavigationBar] as the theme draws it.
/// The bar: a [NavigationBar] without a background or elevation of its
/// own, which this paints beneath the wave.
required final Widget bar,
/// The background of the screen in view, which the fade ends in.
required final Color background,
/// The app's colour, the wave's.
required final Color accent,
/// How many destinations the bar has, for where the wave starts.
required final int destinationCount,
/// The latest wave; none while null.
final NavigationWave? wave,
/// How long a wave takes.
final Duration waveDuration = const Duration(milliseconds: 1200),
/// How round the bar's top corners are.
final double cornerRadius = 0,
/// What stands above the bar while it matters (Train's recording under
/// way), on [background], below the fade; none while null.
final Widget? status,
@ -28,6 +54,12 @@ class const FlushNavigationBar({
/// The fade above the bar, for tests to find.
static const fadeKey = Key('flush-navigation-bar-fade');
/// The bar's clip, which rounds its corners, for tests to find.
static const barKey = Key('flush-navigation-bar');
/// The wave while it runs, for tests to find.
static const waveKey = Key('flush-navigation-bar-wave');
/// How much of the window's foot the content of [context] leaves room
/// for at its end: the system's own bar (a phone's gesture bar or
/// buttons), and the navigation bar with its fade above it where the
@ -48,6 +80,7 @@ class const FlushNavigationBar({
@override
Widget build(BuildContext context) {
final status = this.status;
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -75,8 +108,163 @@ class const FlushNavigationBar({
child: status,
),
),
bar,
ClipRRect(
key: barKey,
borderRadius: cornerRadius == 0
? BorderRadius.zero
: BorderRadius.vertical(top: Radius.circular(cornerRadius)),
child: Stack(
children: [
Positioned.fill(
child: ColoredBox(
// Material 3's bar colour, as the bar would paint itself.
color:
NavigationBarTheme.of(context).backgroundColor ??
theme.colorScheme.surfaceContainer,
),
),
Positioned.fill(
child: _WaveLayer(
wave: wave,
color: accent,
duration: waveDuration,
count: destinationCount,
),
),
bar,
],
),
),
],
);
}
}
/// The wave while it runs, painted over the bar's colour and beneath its
/// destinations; nothing between waves.
class const _WaveLayer({
required final NavigationWave? wave,
required final Color color,
required final Duration duration,
required final int count,
}) extends StatefulWidget {
@override
State<_WaveLayer> createState() => _WaveLayerState();
}
class _WaveLayerState()
extends State<_WaveLayer>
with SingleTickerProviderStateMixin {
late final _controller = AnimationController(
vsync: this,
duration: widget.duration,
)..addStatusListener((_) => setState(() {}));
@override
void didUpdateWidget(_WaveLayer oldWidget) {
super.didUpdateWidget(oldWidget);
_controller.duration = widget.duration;
if (widget.wave != null && widget.wave != oldWidget.wave) {
_controller.forward(from: 0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final wave = widget.wave;
if (wave == null || !_controller.isAnimating) {
return const SizedBox.shrink();
}
final padding = MediaQuery.paddingOf(context);
return IgnorePointer(
child: CustomPaint(
key: FlushNavigationBar.waveKey,
painter: _WavePainter(
progress: _controller,
color: widget.color,
dark: Theme.of(context).brightness == Brightness.dark,
index: wave.index,
count: widget.count,
// The bar's own height, above the system's bar.
barHeight: NavigationBarTheme.of(context).height ?? 80,
left: padding.left,
right: padding.right,
),
child: const SizedBox.expand(),
),
);
}
}
/// A radial glow from the centre of destination [index] of [count]: [color]
/// solid to 35 % of its radius, gone at 70 %, growing to cover the bar
/// (easing out, `cubic(.2, .7, .2, 1)`) while it fades, from half its
/// strength in dark mode and a third in light.
class _WavePainter({
/// How far the wave has come, 0 to 1.
required final Animation<double> progress,
required final Color color,
required final bool dark,
required final int index,
required final int count,
required final double barHeight,
required final double left,
required final double right,
}) extends CustomPainter {
this : super(repaint: progress);
/// How the wave grows.
static const growth = Cubic(0.2, 0.7, 0.2, 1);
/// How much of the radius is solid, and where it is gone.
static const solid = 0.35;
static const gone = 0.7;
@override
void paint(Canvas canvas, Size size) {
final t = progress.value;
final width = size.width - left - right;
final centre = Offset(
left + width * (index + 0.5) / math.max(count, 1),
math.min(barHeight, size.height) / 2,
);
// Far enough that its edge, at [gone], passes the bar's far corner.
final far = [
Offset.zero,
Offset(size.width, 0),
Offset(0, size.height),
Offset(size.width, size.height),
].map((corner) => (corner - centre).distance).reduce(math.max);
final radius = growth.transform(t) * far / gone;
if (radius <= 0) {
return;
}
final (start, middle) = dark ? (0.5, 0.32) : (0.35, 0.22);
final opacity = t < 0.45
? lerpDouble(start, middle, t / 0.45)!
: lerpDouble(middle, 0, (t - 0.45) / 0.55)!;
final shown = color.withValues(alpha: opacity);
final paint = Paint()
..shader = RadialGradient(
colors: [shown, shown, color.withValues(alpha: 0)],
stops: const [0, solid, gone],
).createShader(Rect.fromCircle(center: centre, radius: radius));
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(_WavePainter oldDelegate) =>
oldDelegate.color != color ||
oldDelegate.dark != dark ||
oldDelegate.index != index ||
oldDelegate.count != count ||
oldDelegate.barHeight != barHeight ||
oldDelegate.left != left ||
oldDelegate.right != right;
}

View file

@ -0,0 +1,172 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_ui/src/widget/section_card.dart';
import 'package:material_ui/material_ui.dart';
/// How the navigation bar below medium windows answers a tap (the variants
/// the developer tried on the phone, 7 Nov 2026, by their letters).
enum NavigationBarVariant(
/// Its letter in the mockup and the debug section.
final String letter,
) {
/// A: Material's bar as it is, its ripple and its indicator growing.
material('A'),
/// C: a wave in the app's colour from the destination tapped, the
/// destination chosen in that colour, Material's ripple and growing
/// indicator kept.
wave('C'),
/// E: the wave and the colour without the fill: no ripple, the indicator
/// there at once.
waveWithoutFill('E'),
/// F: as E, without the indicator.
waveWithoutIndicator('F');
/// Whether a tap sends a wave and the destination chosen takes the app's
/// colour.
bool get waves => this != material;
}
/// The navigation bar's look: its variant, how long the wave takes, how
/// round the bar's top corners are.
typedef NavigationBarStyle = ({
NavigationBarVariant variant,
Duration waveDuration,
double cornerRadius,
});
/// The look every release build has: the wave without the fill, 1.2 s,
/// corners of 20 dp (the developer's choice, 7 Nov 2026).
const NavigationBarStyle shippingNavigationBarStyle = (
variant: NavigationBarVariant.waveWithoutFill,
waveDuration: Duration(milliseconds: 1200),
cornerRadius: 20,
);
/// The look a debug build shows, switched live in Settings' debug section
/// (`NavigationBarDebugSection`); kept in memory alone, the shipping look at
/// every start. Release builds never read it.
final debugNavigationBarStyle = ValueNotifier<NavigationBarStyle>(
shippingNavigationBarStyle,
);
/// The look the frame shows: in a [debug] build the debug section's, else
/// the shipping one.
NavigationBarStyle effectiveNavigationBarStyle({bool debug = kDebugMode}) =>
debug ? debugNavigationBarStyle.value : shippingNavigationBarStyle;
/// Settings' section switching the navigation bar's look live while testing
/// on a phone: variant, the wave's time, the corners. In debug builds
/// alone, and in English alone, as no release ever shows it. Every app puts
/// it before About; to remove it, delete the section from the apps' Settings
/// and [debugNavigationBarStyle], and let the frame take
/// [shippingNavigationBarStyle].
class const NavigationBarDebugSection({
/// Whether this is a debug build; tests pass false for a release's.
@visibleForTesting final bool debug = kDebugMode,
super.key,
}) extends StatelessWidget {
/// The wave's times on offer.
static const durations = [700, 900, 1200, 1600, 2000];
/// The corners on offer.
static const corners = [0.0, 12.0, 20.0, 28.0];
@override
Widget build(BuildContext context) {
if (!debug) {
return const SizedBox.shrink();
}
return ValueListenableBuilder(
valueListenable: debugNavigationBarStyle,
builder: (context, style, _) {
void set(NavigationBarStyle next) =>
debugNavigationBarStyle.value = next;
String seconds(int ms) =>
'${(ms / 1000).toString().replaceAll(RegExp(r'\.0$'), '')} s';
return Padding(
padding: const EdgeInsets.only(top: 12),
child: SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Navigation bar (debug)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
_Choice(
label: 'Variant',
values: NavigationBarVariant.values,
selected: style.variant,
name: (variant) => variant.letter,
onSelected: (variant) => set((
variant: variant,
waveDuration: style.waveDuration,
cornerRadius: style.cornerRadius,
)),
),
_Choice(
label: 'Wave',
values: durations,
selected: style.waveDuration.inMilliseconds,
name: seconds,
onSelected: (ms) => set((
variant: style.variant,
waveDuration: Duration(milliseconds: ms),
cornerRadius: style.cornerRadius,
)),
),
_Choice(
label: 'Corners',
values: corners,
selected: style.cornerRadius,
name: (radius) => radius.toStringAsFixed(0),
onSelected: (radius) => set((
variant: style.variant,
waveDuration: style.waveDuration,
cornerRadius: radius,
)),
),
],
),
),
);
},
);
}
}
/// A row of chips choosing one of [values].
class const _Choice<T>({
required final String label,
required final List<T> values,
required final T selected,
required final String Function(T value) name,
required final ValueChanged<T> onSelected,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
SizedBox(width: 72, child: Text(label)),
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final value in values)
ChoiceChip(
label: Text(name(value)),
selected: value == selected,
onSelected: (_) => onSelected(value),
),
],
),
),
],
),
);
}

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.