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>
304 lines
11 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|