feat(ui): every app's frame around its tabs, Learn's moved into halkyon_ui

NavigationFrame is Learn's AppShell without its router and its tabs: a
navigation rail from medium windows on with the app's icon on top,
leading to the first tab, a bar floating above the foot below, the
background of the screen in view around it, and a tick on every choice.
TabsStack is Learn's container of the tabs' navigators, every tab kept
alive and told whether it shows. Learn's shell is built on both, its
tests unchanged; Train's is next.

The rail's icon is drawn from 120 px at most in memory, as Learn's was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-30 23:52:39 +01:00
commit b45db6ea7c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 444 additions and 171 deletions

View file

@ -29,6 +29,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_frame.dart';
export 'src/widget/palette_color_picker.dart';
export 'src/widget/reorder_grip.dart';
export 'src/widget/reorder_toggle.dart';

View file

@ -0,0 +1,189 @@
import 'package:flutter/services.dart';
import 'package:halkyon_ui/src/layout/window_size.dart';
import 'package:halkyon_ui/src/widget/floating_navigation_bar.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 floating above the bottom
/// ([FloatingNavigationBar]), the background of the screen in view around it
/// ([ScreenTint]).
///
/// 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.
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 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,
super.key,
}) extends StatefulWidget {
@override
State<NavigationFrame> createState() => _NavigationFrameState();
}
class _NavigationFrameState() extends State<NavigationFrame> {
final _tint = ScreenTint();
@override
void dispose() {
_tint.dispose();
super.dispose();
}
void _select(int index) {
HapticFeedback.selectionClick();
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: widget.child,
),
);
}
final bar = FloatingNavigationBar(
bar: NavigationBar(
backgroundColor: Colors.transparent,
elevation: 0,
selectedIndex: widget.selectedIndex,
onDestinationSelected: _select,
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,
// Around 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 floating bar, showing around
// it, as iOS 26's tab bar floats over content; their lists end
// with room for it (the body's bottom padding says how much).
extendBody: !widget.hidesNavigationBar,
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
? SizedBox(height: MediaQuery.paddingOf(context).bottom)
: bar,
),
),
),
);
}
}
/// 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,
),
),
),
),
),
);
}

View file

@ -1,3 +1,5 @@
import 'package:halkyon_ui/src/widget/navigation_frame.dart'
show NavigationFrame;
import 'package:material_ui/material_ui.dart';
/// Whether the tab a screen is in is the one showing. An app whose tabs stay
@ -21,3 +23,31 @@ class const TabVisibility({
bool updateShouldNotify(TabVisibility oldWidget) =>
visible != oldWidget.visible;
}
/// The tabs of a [NavigationFrame]'s app, as go_router's indexed stack builds
/// them for a `StatefulShellRoute`: every tab stays alive, the hidden ones
/// offstage with their animations paused, and each learns from
/// [TabVisibility] whether it shows.
class const TabsStack({
/// The tab showing.
required final int index,
/// The tabs, in order.
required final List<Widget> children,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => IndexedStack(
index: index,
children: [
for (final (i, tab) in children.indexed)
Offstage(
offstage: i != index,
child: TickerMode(
enabled: i == index,
child: TabVisibility(visible: i == index, child: tab),
),
),
],
);
}