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

View file

@ -35,7 +35,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, halkyon_ui's `TabsStack`: go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them through halkyon_ui's `NavigationFrame`, every Halkyon app's (decided 2026-10-30), as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), flush with the bottom edge (`FlushNavigationBar`, decided 7 Nov 2026 by the developer in place of a bar floating above it: Material 3 Expressive keeps the bar flush, 64 dp, and a fade replaces the floating card), with a fade 32 dp high above it into the background of the screen in view (`ScreenTint`), which taps pass through: the tab screens run on beneath both (the shell's `extendBody`), every list ending with room for them (the body's bottom padding, `FlushNavigationBar.coverOf`: the bar, its fade and the system's bar) and floating buttons lifted by `FlushNavigationBar.buttonLiftOf` (the same without the system's bar, which a Scaffold keeps its button clear of itself); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, halkyon_ui's `TabsStack`: go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them through halkyon_ui's `NavigationFrame`, every Halkyon app's (decided 2026-10-30), as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), flush with the bottom edge (`FlushNavigationBar`, decided 7 Nov 2026 by the developer in place of a bar floating above it: Material 3 Expressive keeps the bar flush, 64 dp, and a fade replaces the floating card; its top corners rounded, 20 dp, a tap on another tab sending a wave in the app's colour over it, Learn's purple, the tab chosen in that colour, without Material's ripple or the indicator growing: `shippingNavigationBarStyle`, which a debug build's Settings switches live in its section Navigation bar (debug), halkyon_ui's `NavigationBarDebugSection`), with a fade 32 dp high above it into the background of the screen in view (`ScreenTint`), which taps pass through: the tab screens run on beneath both (the shell's `extendBody`), every list ending with room for them (the body's bottom padding, `FlushNavigationBar.coverOf`: the bar, its fade and the system's bar) and floating buttons lifted by `FlushNavigationBar.buttonLiftOf` (the same without the system's bar, which a Scaffold keeps its button clear of itself); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById`: a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme, every Halkyon app's**: the theme, its fonts, the window sizes and the shared widgets come from `halkyon_ui` (`packages/halkyon_ui`, decided 2026-10-18). Change them there, not here. `appTheme(brightness, extensions:)` with hand-written light and dark colour schemes around the brand's cobalt and copper, which Learn calls as `learnTheme` (`core/theme/`) with its own colours beside; `Theme.of(context).extras` (`ThemeExtras`) for the states Material has no role for, and `Theme.of(context).learnColors` (`LearnColors`) for Learn's own (the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), 16 in from the sides and 8 above the bar's fade, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen and Settings (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. halkyon_ui's `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface, and Learn's `learn_theme_test.dart` a starred star to 3:1 on the cards. **Fonts are bundled, never fetched** (halkyon_ui's `app_fonts.dart`; the CJK fonts are the app's own, `assets/fonts/ATTRIBUTION.md`, their licence registered by `core/theme/cjk_fonts.dart`, under the families halkyon_ui's fallback names): Noto Sans, from halkyon_ui, is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets come from halkyon_ui (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` and `BrandWordmark` ("Halkyon" over an app's word), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), `Shimmer` and `SkeletonBox` (what a list shows while it loads in place of a spinner, the Library's cards since 1 Nov 2026), and the sheets below), with texts of their own (`HalkyonUiLocalizations`); Learn's own are in `core/widget/` (`ConflictVersions`, the versions of a conflict each editor shows, with Keep, its save status beside it `SaveStatusLabel` in `core/editor/`; `ContentCountsLine`; `AppLaunchScreen`, what shows while the app opens: the kingfisher, "Halkyon" over "Learn", whose wordmark the first start shows too).
- **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`).

View file

@ -51,6 +51,8 @@ class const AppShell({
// The icon has a white background, which the frame's rounded tile
// keeps reading on light and dark rails alike.
icon: learnIcon,
// The bar's wave and chosen tab: Learn's purple, its icon's last colour.
accent: PaletteColor.purple.background,
selectedIndex: navigationShell.currentIndex,
onSelected: (index) {
// Another tab keeps the assistant's field as it is (D69); the

View file

@ -103,6 +103,9 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
],
),
),
// The navigation bar's looks to try on a phone, in debug
// builds alone (halkyon_ui's).
const NavigationBarDebugSection(),
const SizedBox(height: 12),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),

View file

@ -287,7 +287,9 @@ void main() {
});
testWidgets("the navigation bar is Material 3 Expressive's shorter one, "
'the label of the tab in view in the secondary colour', (tester) async {
"the tab in view in Learn's purple, its icon's last colour", (
tester,
) async {
await showApp(tester, width: 400);
expect(tester.getSize(find.byType(NavigationBar)).height, 64);
@ -302,7 +304,7 @@ void main() {
DefaultTextStyle.of(tester.element(text)).style.color;
}
expect(colorOf('Home'), scheme.secondary);
expect(colorOf('Home'), PaletteColor.purple.background);
expect(colorOf('Library'), scheme.onSurfaceVariant);
});

View file

@ -110,6 +110,9 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
],
),
),
// The navigation bar's looks to try on a phone, in debug
// builds alone (halkyon_ui's).
const NavigationBarDebugSection(),
const SizedBox(height: 12),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),

View file

@ -24,6 +24,8 @@ class const MeditateShell({
child: NavigationFrame(
appName: l10n.appTitle,
icon: meditateIcon,
// The bar's wave and chosen tab: Meditate's fern, its screens' wash.
accent: PaletteColor.fern.background,
selectedIndex: shell.currentIndex,
// A tab chosen again goes back to its first screen.
onSelected: (index) => shell.goBranch(

View file

@ -105,6 +105,9 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
],
),
),
// The navigation bar's looks to try on a phone, in debug
// builds alone (halkyon_ui's).
const NavigationBarDebugSection(),
const SizedBox(height: 12),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),

View file

@ -52,6 +52,8 @@ class _TodoShellState() extends State<TodoShell> {
child: NavigationFrame(
appName: l10n.appTitle,
icon: todoIcon,
// The bar's wave and chosen tab: Todo's strawberry, its screens' wash.
accent: PaletteColor.strawberry.background,
selectedIndex: shell.currentIndex,
hidesNavigationBar: _selection.active || _hold.held,
// A tab chosen again goes back to its first screen; choosing tasks

View file

@ -162,6 +162,9 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
],
),
),
// The navigation bar's looks to try on a phone, in debug
// builds alone (halkyon_ui's).
const NavigationBarDebugSection(),
const SizedBox(height: 12),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),

View file

@ -52,6 +52,8 @@ class const TrainShell({
status: recording ? const RecordingBar() : null,
appName: l10n.appTitle,
icon: trainIcon,
// The bar's wave and chosen tab: Train's carrot, its screens' wash.
accent: PaletteColor.carrot.background,
selectedIndex: selected < 0 ? 0 : selected,
onSelected: (index) => shell.goBranch(
branches[index],

View file

@ -1490,7 +1490,11 @@ Strava. What was found, 27 Oct:
keeps it, 64 dp in the theme's own colour across the whole width, and a fade 32 dp high
into the background of the screen in view above it replaces the floating card, so what
scrolls on beneath it fades out rather than being cut off; halkyon_ui's
`FlushNavigationBar`, the rail unchanged). Driven by
`FlushNavigationBar`, the rail unchanged; and, the same day, its top corners rounded,
20 dp, a tap on another destination sending a wave in the app's colour over the bar
from it, 1.2 s, the destination chosen in that colour, without Material's ripple or
the indicator growing, the variant E of the developer's mockup, which a debug build's
Settings switches live among the others). Driven by
go_router as Learn's (a `StatefulShellRoute`, each tab keeping its own stack): a
workout's page opens in Home and a day's in Profile with the tabs beside them, the
route and plan editors and the recording over them; over keeping `Navigator.push`

View file

@ -29,7 +29,9 @@ LeanCode's lints.
`TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen`
and `TabVisibility`; `FlushNavigationBar`, and `NavigationFrame`, every
app's frame around its tabs (the rail with the app's icon, the bar flush
with the foot with a fade above it, a tick on every choice, a status such as a recording under way above
with the foot with a fade above it, its rounded top corners and the
wave in the app's colour a tap sends over it, `shippingNavigationBarStyle`,
switched live in a debug build's `NavigationBarDebugSection`, a tick on every choice, a status such as a recording under way above
the bar), whichever router drives it;
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
`SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`,

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.

View 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);
});
}

View file

@ -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 [