feat(learn,todo,train,meditate,ui): the navigation bar flush with the foot, a fade above it

Below medium windows the frame's bar is Material 3 Expressive's again,
flush with the window's foot (decided 7 Nov 2026 by the developer): the
theme's NavigationBar across the whole width, 64 dp in its own colour,
running on beneath the system's bar as Material's does. Right above it a
fade 32 dp high, from the background of the screen in view (the
ScreenTint's, the theme's surface before a screen claims one) made
see-through to that background, so what scrolls on beneath the bar fades
out rather than being cut off; taps pass through it. The rail of wider
windows stays as it was.

halkyon_ui's FloatingNavigationBar becomes FlushNavigationBar, its side
margin, lift and rounded card gone. Its coverOf is now the whole room
content leaves at its end, the system's bar included (the body's bottom
padding: bar, fade and system's bar inside the frame, the system's bar
alone outside it), and floating buttons rise by buttonLiftOf, the same
without the system's bar, which a Scaffold keeps them clear of itself.
Lists, Todo's hour grid and the Plan's sheet keep using coverOf; the
Profile's list no longer adds the system's bar a second time.

Whats-New: The navigation bar sits at the bottom edge again, with a soft fade above it.
Whats-New-De: Die Navigationsleiste sitzt wieder am unteren Rand, mit einem sanften Übergang darüber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-08 00:04:18 +01:00
commit 05db5e2f45
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 425 additions and 217 deletions

View file

@ -72,14 +72,12 @@ class const _Mine({required final List<Widget> sections})
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: ListView(
// Room at the end for the navigation bar floating over the foot.
// Room at the end for the navigation bar and its fade at the foot.
padding: EdgeInsets.fromLTRB(
12,
12,
12,
12 +
FloatingNavigationBar.coverOf(context) +
MediaQuery.paddingOf(context).bottom,
12 + FlushNavigationBar.coverOf(context),
),
children: [
SectionCard(

View file

@ -27,9 +27,9 @@ LeanCode's lints.
`readableContentMaxWidth`.
- **Widgets** (`lib/src/widget/`): `showAdaptiveSheet` with `SheetScaffold`;
`TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen`
and `TabVisibility`; `FloatingNavigationBar`, and `NavigationFrame`, every
app's frame around its tabs (the rail with the app's icon, the floating
bar, a tick on every choice, a status such as a recording under way above
and `TabVisibility`; `FlushNavigationBar`, and `NavigationFrame`, every
app's frame around its tabs (the rail with the app's icon, the bar flush
with the foot with a fade above it, a tick on every choice, a status such as a recording under way above
the bar), whichever router drives it;
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
`SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`,

View file

@ -25,7 +25,7 @@ export 'src/widget/day_calendar_icon.dart';
export 'src/widget/empty_list_message.dart';
export 'src/widget/explained_title.dart';
export 'src/widget/field_label.dart';
export 'src/widget/floating_navigation_bar.dart';
export 'src/widget/flush_navigation_bar.dart';
export 'src/widget/folding_tabs.dart';
export 'src/widget/found_by_id.dart';
export 'src/widget/help_button.dart';

View file

@ -65,10 +65,8 @@ ThemeData _baseTheme(
),
// Floating, as Material 3 places snack bars: above the navigation bar
// rather than across the window's foot beneath it, in Material 3's
// inverse colours and corners (4 dp); in from the sides and above the bar
// as far as the floating bar stands in and above the system's
// (FloatingNavigationBar.sideMargin and lift). Flutter's default is the
// fixed snack bar of Material 2.
// inverse colours and corners (4 dp); 16 in from the sides and 8 above
// the bar. Flutter's default is the fixed snack bar of Material 2.
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
insetPadding: EdgeInsets.fromLTRB(16, 8, 16, 8),
@ -174,8 +172,8 @@ ColorScheme _darkScheme() =>
);
/// Snack bars no wider than [maxWidth] from medium windows on, centred,
/// rather than across the whole window; below, the theme's margins line
/// them up with the floating navigation bar. Around the app's navigator
/// rather than across the whole window; below, the theme's margins keep
/// them in from the window's sides. Around the app's navigator
/// (`MaterialApp.builder`), since the theme can't know the window.
class const FittedSnackBars({required final Widget child, super.key})
extends StatelessWidget {

View file

@ -1,59 +0,0 @@
import 'dart:math' as math;
import 'package:material_ui/material_ui.dart';
/// A navigation bar detached from the window's bottom edge, as iOS 26's tab
/// bar floats: [bar] in a rounded bar [sideMargin] in from the sides and
/// [lift] above the system's own bar, lifted as the navigation bar is
/// (elevation level 2). Material 3 has no floating navigation bar for
/// phones (only in its guidelines for headsets); this one keeps the
/// Expressive bar's height and colours. Around it shows the scaffold's
/// background, which the app's shell takes from the screen in view.
class const FloatingNavigationBar({
/// The bar: a [NavigationBar] without a background or elevation of its
/// own.
required final Widget bar,
super.key,
}) extends StatelessWidget {
/// How far in from the window's sides the bar stands.
static const sideMargin = 16.0;
/// How far above the system's own bar (a phone's gesture bar or buttons)
/// the bar floats.
static const lift = 8.0;
/// How much of the window's foot the bar covers beyond the system's own
/// bar, for what [context] shows beneath it; none without it. A floating
/// button keeps clear of the system's bar by itself, but not of this.
static double coverOf(BuildContext context) => math.max(
0,
MediaQuery.paddingOf(context).bottom -
MediaQuery.viewPaddingOf(context).bottom,
);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.fromLTRB(
sideMargin,
0,
sideMargin,
MediaQuery.paddingOf(context).bottom + lift,
),
child: Material(
color: scheme.surfaceContainer,
elevation: 3,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
// The bar's own safe area would keep it clear of the system's bar
// a second time.
child: MediaQuery.removePadding(
context: context,
removeBottom: true,
child: bar,
),
),
);
}
}

View file

@ -0,0 +1,82 @@
import 'dart:math' as math;
import 'package:material_ui/material_ui.dart';
/// The navigation bar at the window's foot, flush with its edges, as
/// Material 3 Expressive keeps it (decided 7 Nov 2026 by the developer, in
/// place of a bar floating above the foot): [bar] across the whole width,
/// in the theme's own colour and height (64 dp, `appTheme`), running on
/// beneath the system's bar as Material's does. Right above it, a fade
/// [fadeHeight] high into [background], the screen's own, so that what
/// scrolls on beneath the bar fades out rather than being cut off; taps
/// pass through it to the screen.
class const FlushNavigationBar({
/// The bar: a [NavigationBar] as the theme draws it.
required final Widget bar,
/// The background of the screen in view, which the fade ends in.
required final Color background,
/// What stands above the bar while it matters (Train's recording under
/// way), on [background], below the fade; none while null.
final Widget? status,
super.key,
}) extends StatelessWidget {
/// How high the fade above the bar is.
static const fadeHeight = 32.0;
/// The fade above the bar, for tests to find.
static const fadeKey = Key('flush-navigation-bar-fade');
/// How much of the window's foot the content of [context] leaves room
/// for at its end: the system's own bar (a phone's gesture bar or
/// buttons), and the navigation bar with its fade above it where the
/// frame's bar is (a Scaffold extending its body beneath its bar says so
/// in its body's bottom padding).
static double coverOf(BuildContext context) =>
MediaQuery.paddingOf(context).bottom;
/// How far a screen's floating button rises to stand clear of the
/// navigation bar and its fade: [coverOf] without the system's own bar,
/// which a Scaffold keeps its floating button clear of by itself.
static double buttonLiftOf(BuildContext context) => math.max(
0,
MediaQuery.paddingOf(context).bottom -
MediaQuery.viewPaddingOf(context).bottom,
);
@override
Widget build(BuildContext context) {
final status = this.status;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
IgnorePointer(
child: DecoratedBox(
key: fadeKey,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
// From the background itself, see-through: transparent black
// would grey the fade on its way.
colors: [background.withValues(alpha: 0), background],
),
),
child: const SizedBox(height: fadeHeight),
),
),
if (status != null)
ColoredBox(
color: background,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: status,
),
),
bar,
],
);
}
}

View file

@ -1,6 +1,6 @@
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/flush_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';
@ -16,9 +16,10 @@ typedef FrameDestination = ({
/// 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]).
/// 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
@ -56,8 +57,8 @@ class const NavigationFrame({
final bool hidesNavigationBar = false,
/// What stands above the bar on every tab while it matters (Train's
/// recording under way, as Strava's): floating above the bar below
/// medium windows, at the foot of the screen's panel from there on; none
/// 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,
@ -120,27 +121,23 @@ class _NavigationFrameState() extends State<NavigationFrame> {
),
);
}
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 bar = NavigationBar(
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.
// 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?>(
@ -148,9 +145,9 @@ class _NavigationFrameState() extends State<NavigationFrame> {
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).
// 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
@ -161,30 +158,23 @@ class _NavigationFrameState() extends State<NavigationFrame> {
// 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: switch (widget.status) {
final status? => Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
FloatingNavigationBar.sideMargin,
0,
FloatingNavigationBar.sideMargin,
FloatingNavigationBar.lift,
),
child: status,
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,
status: widget.status,
bar: bar,
),
if (widget.hidesNavigationBar)
SizedBox(height: MediaQuery.paddingOf(context).bottom)
else
bar,
],
),
null when widget.hidesNavigationBar => SizedBox(
height: MediaQuery.paddingOf(context).bottom,
),
null => bar,
},
),
),
),

View file

@ -6,7 +6,8 @@ import 'package:material_ui/material_ui.dart';
/// margin around the content panel show it, maroon beside something a
/// person made cerise, cobalt elsewhere. A screen with a `TopGradient` claims
/// its colour while it is the one in view; cobalt until one does. On phones,
/// its background shows around the floating navigation bar.
/// its background is the one the screens fade into above the navigation
/// bar (`FlushNavigationBar`).
class ScreenTint() extends ChangeNotifier {
Color _color = Brand.primary;
Color? _background;

View file

@ -10,8 +10,8 @@ class const SliverCentered({
/// What it shows.
required final Widget child,
/// The space kept clear around the room, e.g. for a bar floating over the
/// list's bottom.
/// The space kept clear around the room, e.g. for a bar over the list's
/// bottom.
final EdgeInsets padding = EdgeInsets.zero,
super.key,
}) extends StatelessWidget {

View file

@ -6,7 +6,7 @@ import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
/// Every Halkyon app's frame around its tabs: a rail with the app's icon
/// from medium windows on, a floating bar below, a tick on every choice.
/// from medium windows on, a flush bar below with a fade above it, a tick on every choice.
void main() {
/// A 1 × 1 PNG, the app's icon in these tests.
final icon = MemoryImage(
@ -74,16 +74,199 @@ void main() {
return haptics;
}
testWidgets('below medium windows, a bar floats above the foot', (
testWidgets('below medium windows, a bar stands at the foot', (
tester,
) async {
await show(tester, width: 400, chosen: []);
expect(find.byType(FloatingNavigationBar), findsOneWidget);
expect(find.byType(FlushNavigationBar), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
expect(find.text('The tab'), findsOneWidget);
});
testWidgets('below medium windows, the bar is flush with the foot: as wide '
"as the window, 64 high above the system's bar, in the theme's colour", (
tester,
) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
await show(tester, width: 400, chosen: []);
final bar = find.byType(NavigationBar);
expect(
tester.getRect(bar),
const Rect.fromLTRB(0, 800 - 64 - 24, 400, 800),
);
expect(tester.widget<NavigationBar>(bar).backgroundColor, isNull);
expect(tester.widget<NavigationBar>(bar).elevation, isNull);
});
testWidgets('right above the bar, a fade into the background of the screen '
'in view, which taps pass through to the screen', (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
var tapped = 0;
const background = Color(0xFFFFE4C4);
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: TopGradient(
background: background,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => tapped++,
child: const SizedBox.expand(),
),
),
),
),
);
await tester.pumpAndSettle();
final fade = find.byKey(FlushNavigationBar.fadeKey);
final bar = tester.getRect(find.byType(NavigationBar));
expect(
tester.getRect(fade),
Rect.fromLTRB(0, bar.top - FlushNavigationBar.fadeHeight, 400, bar.top),
);
final gradient =
(tester.widget<DecoratedBox>(fade).decoration as BoxDecoration)
.gradient!
as LinearGradient;
expect(gradient.colors, [background.withValues(alpha: 0), background]);
await tester.tapAt(tester.getCenter(fade));
expect(tapped, 1);
});
testWidgets('before a screen claims its background, the fade ends in the '
"theme's surface, light and dark", (tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
for (final brightness in Brightness.values) {
await tester.pumpWidget(
testApp(
brightness: brightness,
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: const Scaffold(body: Text('The tab')),
),
),
);
await tester.pumpAndSettle();
final fade = find.byKey(FlushNavigationBar.fadeKey);
final surface = Theme.of(tester.element(fade)).colorScheme.surface;
final gradient =
(tester.widget<DecoratedBox>(fade).decoration as BoxDecoration)
.gradient!
as LinearGradient;
expect(gradient.colors.last, surface, reason: '$brightness');
}
});
testWidgets("a tab's content leaves room for the bar, its fade and the "
"system's bar; its floating button rises above the fade, the Scaffold "
"keeping it clear of the system's bar itself", (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
late double cover;
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
selectedIndex: 0,
onSelected: (_) {},
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: Builder(
builder: (context) => Scaffold(
body: Builder(
builder: (context) {
cover = FlushNavigationBar.coverOf(context);
return const SizedBox.expand(key: Key('tab'));
},
),
floatingActionButton: Padding(
padding: EdgeInsets.only(
bottom: FlushNavigationBar.buttonLiftOf(context),
),
child: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(cover, FlushNavigationBar.fadeHeight + 64 + 24);
expect(
tester.getBottomLeft(find.byKey(const Key('tab'))).dy,
800,
reason: 'the content runs on beneath the fade and the bar',
);
expect(
tester.getRect(find.byType(FloatingActionButton)).bottom,
tester.getRect(find.byKey(FlushNavigationBar.fadeKey)).top - 16,
);
});
testWidgets("outside the frame, content leaves room for the system's bar "
'alone', (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
late double cover;
late double lift;
await tester.pumpWidget(
testApp(
Scaffold(
body: Builder(
builder: (context) {
cover = FlushNavigationBar.coverOf(context);
lift = FlushNavigationBar.buttonLiftOf(context);
return const SizedBox.expand();
},
),
),
),
);
expect(cover, 24);
expect(lift, 0);
});
testWidgets("from medium windows on, a rail with the app's icon on top, "
'which leads to the first tab', (tester) async {
final chosen = <int>[];
@ -130,7 +313,7 @@ void main() {
await tester.pumpAndSettle();
}
expect(find.byType(FloatingNavigationBar), findsOneWidget);
expect(find.byType(FlushNavigationBar), findsOneWidget);
expect(find.text('The tab'), findsOneWidget);
});
@ -139,6 +322,7 @@ void main() {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(
@ -157,7 +341,7 @@ void main() {
),
);
expect(find.byType(FloatingNavigationBar), findsNothing);
expect(find.byType(FlushNavigationBar), findsNothing);
expect(tester.getBottomLeft(find.byType(Scaffold).last).dy, 800 - 24);
});