halkyon/packages/halkyon_ui/lib/src/widget/navigation_frame.dart
Tizian 「ティツィアーン」 45fd6595e1
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>
2026-11-08 11:48:37 +01:00

304 lines
11 KiB
Dart

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';
/// A tab of a [NavigationFrame]: its label and icons, built by the app (a
/// badge around one); its selected icon its icon where null.
typedef FrameDestination = ({
String label,
Widget icon,
Widget? selectedIcon,
});
/// The frame around an app's tabs, every Halkyon app's (decided 2026-10-30,
/// from Halkyon Learn's): a navigation rail at the side from medium windows
/// on, the app's [icon] atop it leading to the first tab as a website's logo
/// does, and below that a bar flush with the window's foot, the screens
/// fading out above it into the background of the one in view
/// ([FlushNavigationBar], [ScreenTint]; decided 7 Nov 2026 by the developer,
/// in place of a bar floating above the foot).
///
/// From medium windows on, the window takes the colour of the screen in view
/// ([ScreenTint], as Vaia does): the rail sits on it, and the screen is a
/// rounded panel inset on it, with its own wash at the top. Below that, each
/// screen washes its top itself.
///
/// Every choice ticks before [onSelected]: the lightest haptic the platform
/// has (a clock tick on Android), where the device has a motor and the
/// 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,
/// The tab showing.
required final int selectedIndex,
/// Called with the tab chosen, after the tick.
required final ValueChanged<int> onSelected,
/// The app's name, as its icon atop the rail is read out.
required final String appName,
/// 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,
/// Whether the bar below medium windows steps aside, for a screen with a
/// bar of its own at the bottom: Material 3 puts the navigation bar on the
/// top-level screens and the actions of those below in a toolbar, never
/// both at once (as Vaia does). The rail of wider windows stays.
final bool hidesNavigationBar = false,
/// What stands above the bar on every tab while it matters (Train's
/// recording under way, as Strava's): right above the bar below medium
/// windows, at the foot of the screen's panel from there on; none
/// while null.
final Widget? status,
super.key,
}) extends StatefulWidget {
@override
State<NavigationFrame> createState() => _NavigationFrameState();
}
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);
}
@override
Widget build(BuildContext context) {
final destinations = widget.destinations;
if (context.windowSize.isAtLeastMedium) {
return ScreenTintScope(
notifier: _tint,
child: TintFrame(
tint: _tint,
rail: NavigationRail(
backgroundColor: Colors.transparent,
selectedIndex: widget.selectedIndex,
labelType: NavigationRailLabelType.all,
onDestinationSelected: _select,
leading: _RailIcon(
icon: widget.icon,
appName: widget.appName,
first: destinations.first.label,
onTap: () => _select(0),
),
destinations: [
for (final destination in destinations)
NavigationRailDestination(
icon: destination.icon,
selectedIcon: destination.selectedIcon,
label: Text(destination.label),
),
],
),
child: switch (widget.status) {
final status? => Column(
children: [
Expanded(child: widget.child),
Padding(padding: const EdgeInsets.all(12), child: status),
],
),
null => widget.child,
},
),
);
}
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(
icon: destination.icon,
selectedIcon: destination.selectedIcon,
label: destination.label,
),
],
);
final surface = Theme.of(context).colorScheme.surface;
return ScreenTintScope(
notifier: _tint,
// Behind the screens and in the fade above the bar, the background of
// the screen in view, changing as the frame's colour does.
child: ListenableBuilder(
listenable: _tint,
builder: (context, _) => TweenAnimationBuilder<Color?>(
tween: ColorTween(end: _tint.background ?? surface),
duration: const Duration(milliseconds: 250),
builder: (context, background, _) => Scaffold(
backgroundColor: background,
// The screens run on beneath the bar's fade and the bar, fading
// out above it; their lists end with room for both (the body's
// bottom padding says how much, FlushNavigationBar.coverOf).
extendBody: !widget.hidesNavigationBar,
// The keyboard is each screen's own to make way for, its own
// scaffold resizing as it does by default: a screen with a field
// floating over it keeps its size and lifts the field instead
// (Halkyon Learn's ADR 0021, D69).
resizeToAvoidBottomInset: false,
body: widget.child,
// Without the bar, a strip as high as the system's own bar, in
// the screen's background, keeps every screen inside clear of
// it.
bottomNavigationBar: widget.hidesNavigationBar
? Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.status case final status?)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: status,
),
SizedBox(height: MediaQuery.paddingOf(context).bottom),
],
)
: FlushNavigationBar(
background: background ?? surface,
accent: accent,
destinationCount: destinations.length,
wave: _wave,
waveDuration: style.waveDuration,
cornerRadius: style.cornerRadius,
status: widget.status,
bar: variant.waves
? _Chosen(accent: accent, child: bar)
: bar,
),
),
),
),
);
}
}
/// 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.
class const _RailIcon({
required final ImageProvider icon,
required final String appName,
required final String first,
required final VoidCallback onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Tooltip(
message: first,
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(9),
child: ClipRRect(
borderRadius: BorderRadius.circular(9),
child: Image(
// The icons are 1024 px square; a 40 dp logo needs no more
// than this in memory, even at three times the density.
image: ResizeImage(icon, width: 120),
width: 40,
height: 40,
semanticLabel: appName,
),
),
),
),
),
);
}