feat(train): a recording under way above the bar on every tab, and a saved one opens its page (ADR 0030, D60)

halkyon_ui's NavigationFrame takes a status, standing above the floating
bar below medium windows and at the foot of the screen's panel from there
on. Train's RecordingBar fills it while a recording runs or waits to be
saved: its activity, moving time and distance, paused or stopped, a tap
back to it. Saving now opens the workout's page in Home's tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-31 12:12:55 +01:00
commit 7473366f66
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 301 additions and 15 deletions

View file

@ -29,7 +29,8 @@ LeanCode's lints.
`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), whichever router drives it;
bar, 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`,
`TitleWithKicker`, `CollapsingRow`, `ScrollHidingFab`, `liftWhileDragged`,

View file

@ -54,6 +54,12 @@ class const NavigationFrame({
/// top-level screens and the actions of those below in a toolbar, never
/// both at once (as Vaia does). The rail of wider windows stays.
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
/// while null.
final Widget? status,
super.key,
}) extends StatefulWidget {
@override
@ -102,7 +108,15 @@ class _NavigationFrameState() extends State<NavigationFrame> {
),
],
),
child: widget.child,
child: switch (widget.status) {
final status? => Column(
children: [
Expanded(child: widget.child),
Padding(padding: const EdgeInsets.all(12), child: status),
],
),
null => widget.child,
},
),
);
}
@ -142,9 +156,30 @@ 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: widget.hidesNavigationBar
? SizedBox(height: MediaQuery.paddingOf(context).bottom)
: bar,
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,
),
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

@ -160,4 +160,40 @@ void main() {
expect(find.byType(FloatingNavigationBar), findsNothing);
expect(tester.getBottomLeft(find.byType(Scaffold).last).dy, 800 - 24);
});
testWidgets('a status stands above the bar below medium windows, and at '
"the foot of the screen's panel from there on", (tester) async {
for (final width in const [400.0, 800.0]) {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(
NavigationFrame(
appName: 'Halkyon Test',
icon: icon,
selectedIndex: 0,
onSelected: (_) {},
status: const Text('Recording'),
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 status = tester.getBottomLeft(find.text('Recording')).dy;
final tab = tester.getTopLeft(find.text('The tab')).dy;
expect(status, greaterThan(tab), reason: '$width');
if (width < 600) {
expect(
status,
lessThan(tester.getTopLeft(find.byType(NavigationBar)).dy),
);
}
}
});
}