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
6 changed files with 425 additions and 217 deletions

View file

@ -10,7 +10,7 @@ import 'package:material_ui/material_ui.dart';
/// The frame around the four tabs, every Halkyon app's ([NavigationFrame]):
/// a navigation rail at the side from medium windows on, the app's icon atop
/// it, and a navigation bar floating above the bottom below that. Every tab
/// it, and a navigation bar flush with 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.
///

View file

@ -502,7 +502,7 @@ class const _Results({required final ExploreState state})
),
),
),
// 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.
SliverToBoxAdapter(
child: SizedBox(
height: 12 + MediaQuery.paddingOf(context).bottom,

View file

@ -110,13 +110,13 @@ class _HomeState() extends State<_Home> {
// for the navigation bar.
extendBody: true,
// Folded into its button at the lower left, as in the Library (decided
// 2026-11-01), clear of the navigation bar floating over the foot.
// 2026-11-01), clear of the navigation bar and its fade at the foot.
floatingActionButtonLocation: FloatingActionButtonLocation.startFloat,
floatingActionButton: _arranging || !assistantOn(context)
? null
: Padding(
padding: EdgeInsets.only(
bottom: FloatingNavigationBar.coverOf(context),
bottom: FlushNavigationBar.buttonLiftOf(context),
),
child: const AssistantButton(topic: AssistantTopicLearning()),
),

View file

@ -86,7 +86,7 @@ class _StatisticsState() extends State<_Statistics> {
constraints: const BoxConstraints(maxWidth: StatisticsView.maxWidth),
child: LayoutBuilder(
builder: (context, constraints) => 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(
16,
16,

View file

@ -266,12 +266,12 @@ class _LibraryState() extends State<_Library> {
(true, null) => FloatingActionButtonLocation.startFloat,
(false, _) => FloatingActionButtonLocation.endFloat,
},
// Clear of the navigation bar floating over the foot on a phone.
// Clear of the navigation bar and its fade at the foot on a phone.
floatingActionButton: fab == null
? null
: Padding(
padding: EdgeInsets.only(
bottom: FloatingNavigationBar.coverOf(context),
bottom: FlushNavigationBar.buttonLiftOf(context),
),
child: fab,
),

View file

@ -118,40 +118,43 @@ void main() {
expect(find.byType(NavigationRail), findsNothing);
});
testWidgets('the navigation bar floats 16 in from the sides and 8 above '
"the system's bar, the background of the screen in view around it", (
tester,
) async {
testWidgets('the navigation bar is flush with the foot, as wide as the '
'window; above it the screens fade into the background of the one in '
'view', (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
addTearDown(tester.view.resetPadding);
addTearDown(tester.view.resetViewPadding);
await showApp(tester, width: 400);
final bar = find.byType(FloatingNavigationBar);
Rect pill() => tester.getRect(
find.descendant(of: bar, matching: find.byType(Material)).first,
);
Color? around() => tester
.widget<Scaffold>(
find.ancestor(of: bar, matching: find.byType(Scaffold)),
)
.backgroundColor;
final bar = find.byType(NavigationBar);
final fade = find.byKey(FlushNavigationBar.fadeKey);
Color fadesInto() =>
((tester.widget<DecoratedBox>(fade).decoration as BoxDecoration)
.gradient!
as LinearGradient)
.colors
.last;
final scheme = Theme.of(tester.element(bar)).colorScheme;
expect(
pill(),
const Rect.fromLTRB(16, 900 - 24 - 8 - 64, 400 - 16, 900 - 24 - 8),
tester.getRect(bar),
const Rect.fromLTRB(0, 900 - 24 - 64, 400, 900),
);
expect(around(), scheme.surface);
expect(tester.getRect(fade).bottom, 900 - 24 - 64);
expect(fadesInto(), scheme.surface);
await openTab(tester, 'Library');
expect(around(), scheme.surfaceContainerLow);
expect(fadesInto(), scheme.surfaceContainerLow);
});
testWidgets('a snack bar floats 8 above the navigation bar, as far in '
'from the sides; from medium windows on 560 wide, centred', (
testWidgets('a snack bar floats 8 above the navigation bar and its fade, '
'16 in from the sides; from medium windows on 560 wide, centred', (
tester,
) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
addTearDown(tester.view.resetPadding);
addTearDown(tester.view.resetViewPadding);
Future<Rect> snackBar() async {
await openTab(tester, 'Library');
ScaffoldMessenger.of(
@ -170,8 +173,11 @@ void main() {
final narrow = await snackBar();
expect(narrow.left, 16);
expect(narrow.right, 400 - 16);
// The bar's top, less 8.
expect(narrow.bottom, 900 - 24 - 8 - 64 - 8);
// The fade's top, less 8.
expect(
narrow.bottom,
900 - 24 - 64 - FlushNavigationBar.fadeHeight - 8,
);
await showApp(tester);
final wide = await snackBar();
@ -183,7 +189,9 @@ void main() {
'studyset and its notes, and comes back in the library; inside, the '
"screens keep clear of the system's bar", (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
addTearDown(tester.view.resetPadding);
addTearDown(tester.view.resetViewPadding);
await showApp(tester, width: 400);
await openJapanese(tester);
expect(find.byType(NavigationBar), findsNothing);
@ -227,8 +235,8 @@ void main() {
expect(find.byType(NavigationRail), findsOneWidget);
});
testWidgets('on a phone, the tab screens run on beneath the floating bar, '
'and their lists end with room to scroll out from under it', (
testWidgets('on a phone, the tab screens run on beneath the bar and its '
'fade, and their lists end with room to scroll out from under both', (
tester,
) async {
for (var i = 0; i < 12; i++) {
@ -239,18 +247,14 @@ void main() {
);
}
tester.view.padding = const FakeViewPadding(bottom: 24);
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
addTearDown(tester.view.resetPadding);
addTearDown(tester.view.resetViewPadding);
await showApp(tester, width: 400);
await openTab(tester, 'Library');
final pill = tester.getRect(
find
.descendant(
of: find.byType(FloatingNavigationBar),
matching: find.byType(Material),
)
.first,
);
// The fade's top: what ends above it is wholly in view.
final pill = tester.getRect(find.byKey(FlushNavigationBar.fadeKey));
expect(tester.getBottomLeft(find.byType(LibraryScreen)).dy, 900);
// Before scrolling, which slides it away.
expect(