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 destinations, /// The tab showing. required final int selectedIndex, /// Called with the tab chosen, after the tick. required final ValueChanged 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 createState() => _NavigationFrameState(); } class _NavigationFrameState() extends State { 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( 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, ), ), ), ), ), ); }