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

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, "