feat(ui): on a phone, the floating button sits just above the navigation bar, its screen's fade under it

Each tab's screen paints the bar's fade beneath its own button
(FrameFade as its bottomNavigationBar, extendBody), so the button stands
16 pixels above the bar, as Material's, instead of above the fade; the
frame keeps its own fade for screens without one, and gets it back while
a page is pushed over the screen.

Whats-New: On a phone, the + button now sits just above the navigation bar.
Whats-New-De: Auf dem Handy sitzt der +-Knopf jetzt direkt über der Navigationsleiste.

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-10 00:18:44 +01:00
commit c5eceda383
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 466 additions and 122 deletions

View file

@ -42,6 +42,10 @@ class const FlushNavigationBar({
/// What stands above the bar while it matters (Train's recording under
/// way), on [background], below the fade; none while null.
final Widget? status,
/// Whether this paints the fade; where not, the screen in view paints it
/// itself beneath its floating button (`FrameFade`), and its room stays.
final bool fades = true,
super.key,
}) extends StatelessWidget {
/// How high the fade above the bar is.
@ -59,9 +63,27 @@ class const FlushNavigationBar({
NavigationBarTheme.of(context).backgroundColor ??
Theme.of(context).colorScheme.surfaceContainer;
/// The fade above the bar, for tests to find.
/// The fade above the bar, for tests to find, wherever it is painted.
static const fadeKey = Key('flush-navigation-bar-fade');
/// The fade, [fadeHeight] high, from see-through into [background]; taps
/// pass through it.
static Widget fadeInto(Color background) => 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),
),
);
/// The bar's clip, which rounds its corners, for tests to find.
static const barKey = Key('flush-navigation-bar');
@ -76,15 +98,6 @@ class const FlushNavigationBar({
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;
@ -92,21 +105,7 @@ class const FlushNavigationBar({
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 (fades) fadeInto(background) else const SizedBox(height: fadeHeight),
if (status != null)
ColoredBox(
color: background,

View file

@ -1,8 +1,10 @@
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:halkyon_ui/src/layout/window_size.dart';
import 'package:halkyon_ui/src/theme/readable_tone.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/tab_visibility.dart';
import 'package:halkyon_ui/src/widget/tinted_frame.dart';
import 'package:material_ui/material_ui.dart';
@ -85,6 +87,9 @@ class const NavigationFrame({
class _NavigationFrameState() extends State<NavigationFrame> {
final _tint = ScreenTint();
/// The screens in view painting the fade themselves ([FrameFade]).
final _fadeClaims = _FadeClaims();
/// The screens' own messenger below medium windows: their snack bars
/// show on the screens' own scaffolds, which lift them above their
/// floating buttons, as Material places them; on the frame's they lay
@ -100,6 +105,7 @@ class _NavigationFrameState() extends State<NavigationFrame> {
@override
void dispose() {
_tint.dispose();
_fadeClaims.dispose();
super.dispose();
}
@ -170,7 +176,7 @@ class _NavigationFrameState() extends State<NavigationFrame> {
// the colour of the screen in view (the app's until one claims its
// own), changing over 300 ms.
child: ListenableBuilder(
listenable: _tint,
listenable: Listenable.merge([_tint, _fadeClaims]),
builder: (context, _) {
final claimed = _tint.background != null;
final target = claimed ? _tint.color : widget.accent;
@ -234,7 +240,14 @@ class _NavigationFrameState() extends State<NavigationFrame> {
// it keeps its size and lifts the field instead (Halkyon Learn's ADR
// 0021, D69).
resizeToAvoidBottomInset: false,
body: ScaffoldMessenger(key: _phoneMessenger, child: widget.child),
body: _FrameFoot(
claims: _fadeClaims,
showsBar: !widget.hidesNavigationBar,
child: _FrameFadeColour(
background: background,
child: ScaffoldMessenger(key: _phoneMessenger, child: 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
@ -255,6 +268,7 @@ class _NavigationFrameState() extends State<NavigationFrame> {
destinationCount: destinations.length,
wave: _wave,
status: widget.status,
fades: !_fadeClaims.any,
bar: _Chosen(
// The colour as text on the bar and on its indicator (WCAG
// AA), a person's light colours darkened in light mode, dark
@ -333,3 +347,182 @@ class const _RailIcon({
),
);
}
/// The fade above the frame's bar painted by the screen itself, beneath its
/// floating button, so that the button stands over the fade, 16 above the
/// bar, as Material places it above a bar (the developer's review, 9 Nov
/// 2026): the frame paints the fade over its screens, their buttons too, so
/// a button had to keep above it, 48 above the bar. A screen's Scaffold takes
/// it as its `bottomNavigationBar`, with `extendBody` and its button placed
/// by [buttonLocation]: as high as the foot the frame covers (the fade, the
/// bar and what stands above it, the system's bar), so the content keeps its
/// room, the fade at its top; nothing from medium windows on, or where the
/// bar steps aside. While its screen is in view (its tab showing, no page
/// over it; a menu or dialog leaves it in view) the frame paints no fade of
/// its own.
class const FrameFade({super.key}) extends StatefulWidget {
/// Where a screen with a [FrameFade] places its floating button: [base],
/// lowered over the fade to 16 above the bar where the frame's bar shows.
static FloatingActionButtonLocation buttonLocation(
BuildContext context, [
FloatingActionButtonLocation base = FloatingActionButtonLocation.endFloat,
]) {
final foot = context.dependOnInheritedWidgetOfExactType<_FrameFoot>();
return foot != null && foot.showsBar ? _OverTheFade(base) : base;
}
@override
State<FrameFade> createState() => _FrameFadeState();
}
/// [base] lowered by the fade's height.
final class const _OverTheFade(final FloatingActionButtonLocation base)
extends FloatingActionButtonLocation {
@override
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) => base
.getOffset(scaffoldGeometry)
.translate(0, FlushNavigationBar.fadeHeight);
// Equal for the same base, so that a screen built again doesn't move its
// button as if to another place.
@override
bool operator ==(Object other) => other is _OverTheFade && other.base == base;
@override
int get hashCode => Object.hash(_OverTheFade, base);
}
class _FrameFadeState() extends State<FrameFade> {
_FrameFoot? _foot;
/// The foot's height as last seen without the keyboard, kept while it is
/// up: beneath it the Scaffold gives this slot no padding, and the
/// screen's button and content would move, behind a keyboard covering
/// the foot anyway.
double? _height;
Animation<double>? _covering;
bool _visible = true;
/// The claims this told last that it paints the fade, if it does.
_FadeClaims? _claimed;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final foot = context.dependOnInheritedWidgetOfExactType<_FrameFoot>();
_foot = foot != null && foot.showsBar ? foot : null;
// A page pushed over the route drives its secondary animation; a menu
// or a dialog doesn't, leaving the screen in view.
final covering = ModalRoute.of(context)?.secondaryAnimation;
if (covering != _covering) {
_covering?.removeStatusListener(_coveringChanged);
_covering = covering?..addStatusListener(_coveringChanged);
}
_visible = TabVisibility.of(context);
_claim();
}
void _coveringChanged(AnimationStatus _) => _claim();
/// Tells the frame whether this paints the fade, after the frame, as the
/// frame is built above it.
void _claim() {
final claims = _foot?.claims;
final inView = _visible && !(_covering?.isCompleted ?? false);
final before = _claimed;
_claimed = inView ? claims : null;
if (before == _claimed) {
return;
}
SchedulerBinding.instance
..addPostFrameCallback((_) {
before?.set(this, claims: false);
if (mounted && claims != null && _claimed == claims) {
claims.set(this, claims: true);
}
})
..ensureVisualUpdate();
}
@override
void dispose() {
_covering?.removeStatusListener(_coveringChanged);
if (_claimed case final claims?) {
SchedulerBinding.instance
..addPostFrameCallback((_) => claims.set(this, claims: false))
..ensureVisualUpdate();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_foot == null) {
return const SizedBox.shrink();
}
final background = context
.dependOnInheritedWidgetOfExactType<_FrameFadeColour>()!
.background;
final keyboard = MediaQuery.viewInsetsOf(context).bottom > 0;
final height = keyboard
? _height ?? MediaQuery.paddingOf(context).bottom
: _height = MediaQuery.paddingOf(context).bottom;
return SizedBox(
height: height,
child: Align(
alignment: Alignment.topCenter,
child: FlushNavigationBar.fadeInto(background),
),
);
}
}
/// The screens in view that paint the fade above the bar themselves
/// ([FrameFade]); the frame paints its own while none does.
class _FadeClaims() extends ChangeNotifier {
final _claims = <Object>{};
bool _disposed = false;
/// Whether a screen in view paints the fade.
bool get any => _claims.isNotEmpty;
/// Records whether [claimant] paints the fade; nothing once the frame has
/// gone, as screens going with it tell it after.
void set(Object claimant, {required bool claims}) {
if (_disposed) {
return;
}
if (claims ? _claims.add(claimant) : _claims.remove(claimant)) {
notifyListeners();
}
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
/// What a [FrameFade] below needs of the frame's bar: who paints the fade,
/// and whether the bar shows.
class const _FrameFoot({
required final _FadeClaims claims,
required final bool showsBar,
required super.child,
}) extends InheritedWidget {
@override
bool updateShouldNotify(_FrameFoot oldWidget) =>
claims != oldWidget.claims || showsBar != oldWidget.showsBar;
}
/// The background the fade ends in, apart from [_FrameFoot] as it changes
/// with every frame of a change of colour, which only the fade follows.
class const _FrameFadeColour({
required final Color background,
required super.child,
}) extends InheritedWidget {
@override
bool updateShouldNotify(_FrameFadeColour oldWidget) =>
background != oldWidget.background;
}

View file

@ -193,15 +193,208 @@ void main() {
}
});
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 {
/// A tab's screen with a floating button, which paints the fade above
/// the bar itself ([FrameFade]); its content's room in [cover].
Widget fadingScreen({
void Function(double cover)? cover,
Key? key,
bool resizes = true,
}) => Builder(
builder: (context) => Scaffold(
key: key,
extendBody: true,
resizeToAvoidBottomInset: resizes,
floatingActionButtonLocation: FrameFade.buttonLocation(context),
body: Builder(
builder: (context) {
cover?.call(FlushNavigationBar.coverOf(context));
return ListView(
key: const Key('tab'),
children: [
for (var i = 0; i < 30; i++)
SizedBox(height: 60, child: Text('Row $i')),
],
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
bottomNavigationBar: const FrameFade(),
),
);
/// The frame on a phone 400 × 800 with a system bar 24 high, around
/// [child].
Future<void> showPhone(WidgetTester tester, Widget child, {Widget? status}) {
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);
return tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
accent: const Color(0xFFEC3E7C),
selectedIndex: 0,
onSelected: (_) {},
status: status,
destinations: const [
(label: 'Home', icon: Icon(Icons.home), selectedIcon: null),
(label: 'Maps', icon: Icon(Icons.map), selectedIcon: null),
],
child: child,
),
),
);
}
/// The fades painting: the frame's, or the screens'.
Finder fades() => find.byKey(FlushNavigationBar.fadeKey);
/// Whether the fade painting is the screen's own, beneath its button.
bool screenFades(WidgetTester tester) => find
.descendant(of: find.byType(FrameFade), matching: fades())
.evaluate()
.isNotEmpty;
testWidgets("a tab's content leaves room for the bar, its fade and the "
"system's bar, running on beneath them; its floating button stands 16 "
'above the bar, over the fade, which the screen paints beneath it (the '
"developer's review, 9 Nov 2026)", (tester) async {
late double cover;
await showPhone(tester, fadingScreen(cover: (room) => cover = room));
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',
);
final bar = tester.getRect(find.byKey(FlushNavigationBar.barKey));
expect(
tester.getRect(find.byType(FloatingActionButton)).bottom,
bar.top - 16,
);
expect(fades(), findsOneWidget, reason: 'one fade, not two');
expect(screenFades(tester), isTrue);
expect(tester.getRect(fades()).bottom, bar.top);
expect(tester.getRect(fades()).height, FlushNavigationBar.fadeHeight);
});
testWidgets('the keyboard leaves the button where it stands on a screen '
"that doesn't make way for it (Todo's)", (tester) async {
await showPhone(tester, fadingScreen(resizes: false));
await tester.pumpAndSettle();
final before = tester.getRect(find.byType(FloatingActionButton));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
expect(tester.getRect(find.byType(FloatingActionButton)), before);
});
testWidgets("a screen without its own fade keeps the frame's", (
tester,
) async {
await showPhone(tester, const Scaffold(body: Text('The tab')));
await tester.pumpAndSettle();
expect(fades(), findsOneWidget);
expect(screenFades(tester), isFalse);
});
testWidgets('a menu or a dialog over the screen leaves its fade the '
"screen's, beneath its button", (tester) async {
await showPhone(
tester,
Builder(
builder: (context) => Scaffold(
extendBody: true,
body: Center(
child: TextButton(
onPressed: () => showDialog<void>(
context: context,
builder: (context) => const AlertDialog(content: Text('Hi')),
),
child: const Text('Ask'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
floatingActionButtonLocation: FrameFade.buttonLocation(context),
bottomNavigationBar: const FrameFade(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Ask'));
await tester.pumpAndSettle();
expect(find.text('Hi'), findsOneWidget);
expect(fades(), findsOneWidget);
expect(screenFades(tester), isTrue);
});
testWidgets('a page over the screen in its tab without a fade of its own '
"has the frame's, and back the screen's returns", (tester) async {
final tab = GlobalKey<NavigatorState>();
await showPhone(
tester,
Navigator(
key: tab,
onGenerateRoute: (_) =>
MaterialPageRoute<void>(builder: (context) => fadingScreen()),
),
);
await tester.pumpAndSettle();
expect(screenFades(tester), isTrue);
tab.currentState!.push(
MaterialPageRoute<void>(
builder: (context) => const Scaffold(body: Text('Inside')),
),
);
await tester.pumpAndSettle();
expect(find.text('Inside'), findsOneWidget);
expect(fades(), findsOneWidget);
expect(screenFades(tester), isFalse);
tab.currentState!.pop();
await tester.pumpAndSettle();
expect(fades(), findsOneWidget);
expect(screenFades(tester), isTrue);
});
testWidgets('with a status above the bar, the fade and the button stand '
'above it', (tester) async {
await showPhone(
tester,
fadingScreen(),
status: const SizedBox(height: 40, child: Text('Recording')),
);
await tester.pumpAndSettle();
final status = tester.getRect(find.text('Recording'));
expect(tester.getRect(fades()).bottom, lessThanOrEqualTo(status.top));
expect(
tester.getRect(find.byType(FloatingActionButton)).bottom,
lessThanOrEqualTo(tester.getRect(fades()).bottom - 16),
);
});
testWidgets('from medium windows on, a screen with its own fade paints '
'none, as there is no bar', (tester) async {
tester.view.physicalSize = const Size(1000, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(
NavigationFrame(
@ -214,40 +407,13 @@ void main() {
(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),
),
),
),
),
child: fadingScreen(),
),
),
);
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,
);
expect(fades(), findsNothing);
});
testWidgets("a screen's snack bar stands above its floating button, so "
@ -281,15 +447,13 @@ void main() {
child: const Text('Complete'),
),
),
floatingActionButton: Padding(
padding: EdgeInsets.only(
bottom: FlushNavigationBar.buttonLiftOf(context),
),
child: FloatingActionButton(
onPressed: () => added++,
child: const Icon(Icons.add),
),
extendBody: true,
floatingActionButtonLocation: FrameFade.buttonLocation(context),
floatingActionButton: FloatingActionButton(
onPressed: () => added++,
child: const Icon(Icons.add),
),
bottomNavigationBar: const FrameFade(),
),
),
),
@ -367,14 +531,12 @@ void main() {
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();
},
),
@ -383,7 +545,6 @@ void main() {
);
expect(cover, 24);
expect(lift, 0);
});
testWidgets("from medium windows on, a rail with the app's icon on top, "