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:
parent
57262ebf73
commit
b45db6ea7c
2 changed files with 444 additions and 171 deletions
|
|
@ -1,26 +1,17 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The frame around the four tabs: a navigation rail at the side from medium
|
||||
/// windows on, a navigation bar floating above the bottom below that
|
||||
/// ([FloatingNavigationBar]), the background of the screen in view around
|
||||
/// it ([ScreenTint]). Every tab keeps its
|
||||
/// own stack of screens, and the navigation stays visible on all of them;
|
||||
/// screens that need the whole window are routes outside the shell.
|
||||
///
|
||||
/// From medium windows on, the window takes the colour of the screen in view
|
||||
/// ([ScreenTint], decided 2026-10-10, 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.
|
||||
/// The frame around the four tabs, every Halkyon app's ([NavigationFrame]):
|
||||
/// a navigation rail at the side from medium windows on, the kingfisher atop
|
||||
/// it, and a navigation bar floating above the bottom below that. Every tab
|
||||
/// keeps its own stack of screens, and the navigation stays visible on all
|
||||
/// of them; screens that need the whole window are routes outside the shell.
|
||||
///
|
||||
/// Choosing the tab that is already selected goes back to its first screen,
|
||||
/// as Material prescribes. Every choice ticks: 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.
|
||||
/// as Material prescribes.
|
||||
class const AppShell({
|
||||
/// The tabs' navigators, from `StatefulShellRoute`.
|
||||
required final StatefulNavigationShell navigationShell,
|
||||
|
|
@ -30,32 +21,17 @@ class const AppShell({
|
|||
final int Function(BuildContext context)? profileBadge,
|
||||
|
||||
/// Whether the navigation 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 (decided 2026-10-11, as Vaia does). The
|
||||
/// rail of wider windows stays.
|
||||
/// screen with a bar of its own at the bottom (decided 2026-10-11,
|
||||
/// [NavigationFrame.hidesNavigationBar]).
|
||||
final bool hidesNavigationBar = false,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState() extends State<AppShell> {
|
||||
final _tint = ScreenTint();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tint.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final navigationShell = widget.navigationShell;
|
||||
final destinations = _destinations(AppLocalizations.of(context));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final destinations = _destinations(l10n);
|
||||
// The Profile tab says how many things are new behind it.
|
||||
final unseen = widget.profileBadge?.call(context) ?? 0;
|
||||
final unseen = profileBadge?.call(context) ?? 0;
|
||||
Widget icon(_Destination destination, IconData data) =>
|
||||
identical(destination, destinations.last)
|
||||
? Badge.count(
|
||||
|
|
@ -64,116 +40,26 @@ class _AppShellState() extends State<AppShell> {
|
|||
child: Icon(data),
|
||||
)
|
||||
: Icon(data);
|
||||
if (context.windowSize.isAtLeastMedium) {
|
||||
return ScreenTintScope(
|
||||
notifier: _tint,
|
||||
child: TintFrame(
|
||||
tint: _tint,
|
||||
rail: NavigationRail(
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
onDestinationSelected: _select,
|
||||
leading: _RailLogo(onTap: () => _select(0)),
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationRailDestination(
|
||||
icon: icon(destination, destination.icon),
|
||||
selectedIcon: icon(destination, destination.selectedIcon),
|
||||
label: Text(destination.label),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: navigationShell,
|
||||
),
|
||||
);
|
||||
}
|
||||
final bar = FloatingNavigationBar(
|
||||
bar: NavigationBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: _select,
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationDestination(
|
||||
icon: icon(destination, destination.icon),
|
||||
selectedIcon: icon(destination, destination.selectedIcon),
|
||||
label: destination.label,
|
||||
),
|
||||
],
|
||||
return NavigationFrame(
|
||||
appName: l10n.appTitle,
|
||||
// The kingfisher has a white background, which the frame's rounded
|
||||
// tile keeps reading on light and dark rails alike.
|
||||
icon: const AssetImage('assets/icon/halkyon_icon.png'),
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onSelected: (index) => navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
),
|
||||
);
|
||||
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: navigationShell,
|
||||
// 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,
|
||||
hidesNavigationBar: hidesNavigationBar,
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
(
|
||||
label: destination.label,
|
||||
icon: icon(destination, destination.icon),
|
||||
selectedIcon: icon(destination, destination.selectedIcon),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _select(int index) {
|
||||
HapticFeedback.selectionClick();
|
||||
widget.navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == widget.navigationShell.currentIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The kingfisher atop the rail, leading Home ([onTap]) as a website's
|
||||
/// logo does. The icon has a white background, so it sits in a rounded tile
|
||||
/// that reads on light and dark rails alike.
|
||||
class const _RailLogo({required final VoidCallback onTap})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Tooltip(
|
||||
message: l10n.shellTabHome,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
child: Image.asset(
|
||||
'assets/icon/halkyon_icon.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
// The source is 1024 px square; a 40 dp logo needs no more
|
||||
// than this in memory, even at three times the density.
|
||||
cacheWidth: 120,
|
||||
semanticLabel: l10n.appTitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: navigationShell,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,27 +2,10 @@ import 'package:go_router/go_router.dart';
|
|||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The container of the tabs' navigators for a `StatefulShellRoute`, as
|
||||
/// go_router's indexed stack builds it: every tab stays alive, the hidden
|
||||
/// ones offstage with their animations paused. Each also learns from
|
||||
/// [TabVisibility] whether it shows.
|
||||
/// The container of the tabs' navigators for a `StatefulShellRoute`:
|
||||
/// halkyon_ui's [TabsStack], every tab alive and learning whether it shows.
|
||||
Widget tabsContainer(
|
||||
BuildContext context,
|
||||
StatefulNavigationShell shell,
|
||||
List<Widget> tabs,
|
||||
) => IndexedStack(
|
||||
index: shell.currentIndex,
|
||||
children: [
|
||||
for (final (index, tab) in tabs.indexed)
|
||||
Offstage(
|
||||
offstage: index != shell.currentIndex,
|
||||
child: TickerMode(
|
||||
enabled: index == shell.currentIndex,
|
||||
child: TabVisibility(
|
||||
visible: index == shell.currentIndex,
|
||||
child: tab,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
) => TabsStack(index: shell.currentIndex, children: tabs);
|
||||
|
|
|
|||
Loading…
Reference in a new issue