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
15 changed files with 301 additions and 15 deletions

File diff suppressed because one or more lines are too long

View file

@ -566,5 +566,7 @@
"groupsLaterHint": "Mit anderen trainieren, in euren eigenen Gruppen, kommt in einer späteren Version von Train.", "groupsLaterHint": "Mit anderen trainieren, in euren eigenen Gruppen, kommt in einer späteren Version von Train.",
"settingsTitle": "Einstellungen", "settingsTitle": "Einstellungen",
"settingsTraining": "Training", "settingsTraining": "Training",
"settingsVersion": "Version {version} (Build {build})" "settingsVersion": "Version {version} (Build {build})",
"recordingBarStopped": "Angehalten: speichern oder verwerfen",
"recordingBarDistance": "{km} km"
} }

View file

@ -714,5 +714,8 @@
"settingsTitle": "Settings", "settingsTitle": "Settings",
"settingsTraining": "Training", "settingsTraining": "Training",
"settingsVersion": "Version {version} (build {build})", "settingsVersion": "Version {version} (build {build})",
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}} "@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
"recordingBarStopped": "Stopped: save or discard it",
"recordingBarDistance": "{km} km",
"@recordingBarDistance": {"placeholders": {"km": {"type": "String"}}}
} }

View file

@ -3423,6 +3423,18 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Version {version} (build {build})'** /// **'Version {version} (build {build})'**
String settingsVersion(String version, String build); String settingsVersion(String version, String build);
/// No description provided for @recordingBarStopped.
///
/// In en, this message translates to:
/// **'Stopped: save or discard it'**
String get recordingBarStopped;
/// No description provided for @recordingBarDistance.
///
/// In en, this message translates to:
/// **'{km} km'**
String recordingBarDistance(String km);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -2052,4 +2052,12 @@ class AppLocalizationsDe extends AppLocalizations {
String settingsVersion(String version, String build) { String settingsVersion(String version, String build) {
return 'Version $version (Build $build)'; return 'Version $version (Build $build)';
} }
@override
String get recordingBarStopped => 'Angehalten: speichern oder verwerfen';
@override
String recordingBarDistance(String km) {
return '$km km';
}
} }

View file

@ -2040,6 +2040,14 @@ class AppLocalizationsEn extends AppLocalizations {
String settingsVersion(String version, String build) { String settingsVersion(String version, String build) {
return 'Version $version (build $build)'; return 'Version $version (build $build)';
} }
@override
String get recordingBarStopped => 'Stopped: save or discard it';
@override
String recordingBarDistance(String km) {
return '$km km';
}
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -297,8 +297,9 @@ class RecordingBloc(
return; return;
} }
final (content, series) = recordedWorkout(recording); final (content, series) = recordedWorkout(recording);
final String saved;
try { try {
await _workouts.save(content, series); saved = await _workouts.save(content, series);
} on Object catch (error) { } on Object catch (error) {
// The file stays, to be saved once it can be. // The file stays, to be saved once it can be.
log('A recording not saved: ${error.runtimeType}', name: 'Recording'); log('A recording not saved: ${error.runtimeType}', name: 'Recording');
@ -311,6 +312,7 @@ class RecordingBloc(
phase: RecordingPhase.idle, phase: RecordingPhase.idle,
lastActivity: recording.activity, lastActivity: recording.activity,
devices: _measured(recording), devices: _measured(recording),
saved: saved,
), ),
); );
} }
@ -473,6 +475,10 @@ final class const RecordingState({
/// The person's devices on this device, those they chose: the strap and /// The person's devices on this device, those they chose: the strap and
/// the rower (D27, D28), the bike's sensors (D49). /// the rower (D27, D28), the bike's sensors (D49).
final Map<DeviceKind, KnownDevice> devices = const {}, final Map<DeviceKind, KnownDevice> devices = const {},
/// The workout the recording just ended was saved as, for the screen
/// to open (D60); null otherwise.
final String? saved,
}) with Equatable { }) with Equatable {
KnownDevice? get strap => devices[DeviceKind.strap]; KnownDevice? get strap => devices[DeviceKind.strap];
KnownDevice? get rower => devices[DeviceKind.rower]; KnownDevice? get rower => devices[DeviceKind.rower];
@ -525,6 +531,7 @@ final class const RecordingState({
bluetoothRefused, bluetoothRefused,
rowerMissing, rowerMissing,
devices, devices,
saved,
]; ];
} }

View file

@ -0,0 +1,94 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Whether [state] has a recording the person may want back to: one under
/// way, or stopped and neither saved nor discarded.
bool showsRecordingBar(RecordingState state) =>
state.phase == RecordingPhase.recording ||
state.phase == RecordingPhase.stopped;
/// The recording under way above the navigation bar on every tab (ADR 0030,
/// D60, as Strava's): its activity, its moving time and distance, paused or
/// stopped, leading back to it.
class const RecordingBar({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final locale = Localizations.localeOf(context).toString();
return BlocBuilder<RecordingBloc, RecordingState>(
builder: (context, state) {
final live = state.live;
final activity =
live?.activity ?? state.recording?.activity ?? state.lastActivity;
final metres = live?.distanceMetres;
final detail = switch (state.phase) {
RecordingPhase.stopped => l10n.recordingBarStopped,
_ when live == null => '',
_ when live.paused => l10n.recordingPausedByHand,
_ => [
clockTime(live.movingTime),
if (metres != null)
l10n.recordingBarDistance(kilometres(locale, metres)),
].join(' · '),
};
return Material(
color: scheme.primaryContainer,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => openRecordingScreen(context),
child: Semantics(
button: true,
label: l10n.recordBack,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 12, 12),
child: Row(
children: [
Icon(
LucideIcons.circleDot,
size: 20,
color: state.phase == RecordingPhase.recording
? scheme.error
: scheme.onPrimaryContainer,
),
const SizedBox(width: 12),
Flexible(
child: Text(
activityName(l10n, activity),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onPrimaryContainer),
),
),
Icon(
LucideIcons.chevronRight,
color: scheme.onPrimaryContainer,
),
],
),
),
),
),
);
},
);
}
}

View file

@ -33,15 +33,20 @@ Future<void> openRecordingScreen(BuildContext context) =>
/// distance and the pace now, GPS's state, the plan's step and Lap where it /// distance and the pace now, GPS's state, the plan's step and Lap where it
/// runs one (D42), the route's next turn or the way back to it where it /// runs one (D42), the route's next turn or the way back to it where it
/// follows one (D45), Pause or Resume and Stop held down, and swiped to, a /// follows one (D45), Pause or Resume and Stop held down, and swiped to, a
/// map of the track so far (D45); once stopped, Save or Discard. It closes /// map of the track so far (D45); once stopped, Save or Discard. Saved, the
/// once the recording is saved or discarded; back leaves the recording /// workout's page takes its place; discarded, it closes. Back leaves the
/// running. /// recording running.
class const RecordingScreen({super.key}) extends StatelessWidget { class const RecordingScreen({super.key}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Widget build(BuildContext context) =>
BlocConsumer<RecordingBloc, RecordingState>( BlocConsumer<RecordingBloc, RecordingState>(
listenWhen: (before, now) => now.phase == RecordingPhase.idle, listenWhen: (before, now) => now.phase == RecordingPhase.idle,
listener: (context, state) => Navigator.of(context).maybePop(), // Saved, the workout opens in Home in its place, as Strava shows the
// activity (D60); discarded, the screen closes.
listener: (context, state) => switch (state.saved) {
final id? => context.go(TrainPaths.workout(id)),
null => Navigator.of(context).maybePop(),
},
builder: (context, state) => Scaffold( builder: (context, state) => Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(

View file

@ -8,6 +8,7 @@ import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.da
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart'; import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart'; import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/record_sheet.dart'; import 'package:halkyon_train_flutter/src/feature/recording/widget/record_sheet.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_bar.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart'; import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart'; import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -20,7 +21,8 @@ import 'package:material_ui/material_ui.dart';
/// the device records), Groups and Profile. Every tab keeps its own stack of /// the device records), Groups and Profile. Every tab keeps its own stack of
/// screens; the editors and the recording cover them. A recording found cut /// screens; the editors and the recording cover them. A recording found cut
/// off as the app opens is offered to be saved or discarded, whichever tab /// off as the app opens is offered to be saved or discarded, whichever tab
/// shows; GPX files Android hands Train are brought in as routes (D45), and /// shows, and one under way stands above the bar on every tab
/// ([RecordingBar]); GPX files Android hands Train are brought in as routes (D45), and
/// Health Connect's links to Train's rationale are followed (D31). /// Health Connect's links to Train's rationale are followed (D31).
class const TrainShell({ class const TrainShell({
/// The tabs' navigators, from `StatefulShellRoute`. /// The tabs' navigators, from `StatefulShellRoute`.
@ -35,7 +37,14 @@ class const TrainShell({
// where the device records. // where the device records.
final branches = [0, 1, if (records) 2, 3, 4]; final branches = [0, 1, if (records) 2, 3, 4];
final selected = branches.indexOf(shell.currentIndex); final selected = branches.indexOf(shell.currentIndex);
// The recording under way on every tab, while there is one (D60).
final recording =
records &&
context.select<RecordingBloc, bool>(
(bloc) => showsRecordingBar(bloc.state),
);
final frame = NavigationFrame( final frame = NavigationFrame(
status: recording ? const RecordingBar() : null,
appName: l10n.appTitle, appName: l10n.appTitle,
icon: trainIcon, icon: trainIcon,
selectedIndex: selected < 0 ? 0 : selected, selectedIndex: selected < 0 ? 0 : selected,

View file

@ -35,6 +35,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.da
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart'; import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_bar.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart'; import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart'; import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart'; import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
@ -1444,9 +1445,22 @@ void main() {
await pass(tester); await pass(tester);
expect(find.text('Save this workout?'), findsOneWidget); expect(find.text('Save this workout?'), findsOneWidget);
// Saved, its page opens, in Home's tab (D60).
await tester.tap(find.text('Save')); await tester.tap(find.text('Save'));
await pass(tester, 2); await pass(tester, 2);
expect(recordButton(), findsOneWidget); final ride = find.textContaining(RegExp('ride', caseSensitive: false));
expect(
find.descendant(of: find.byType(AppBar), matching: ride),
findsOneWidget,
);
await tester.pageBack();
await tester.pumpAndSettle();
expect(
find.descendant(of: find.byType(AppBar), matching: ride),
findsNothing,
);
expect(ride, findsWidgets);
expect(recordButton(), findsNothing);
final saved = (await workouts.all()).single.content!; final saved = (await workouts.all()).single.content!;
expect(saved.activity, Activity.cycling); expect(saved.activity, Activity.cycling);
expect(saved.distanceMetres, 100); expect(saved.distanceMetres, 100);
@ -1476,6 +1490,52 @@ void main() {
}); });
}); });
testWidgets('left, the recording stands above the bar on every tab, and '
'leads back to it (D60)', (tester) async {
var now = at;
await withClock(Clock(() => now), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.pumpAndSettle();
await tester.tap(find.text('Start'));
await pass(tester, 2);
platform
..write('p @ 0 0 500 -')
..now = at.add(const Duration(seconds: 30))
..write('p @ ${(100 * 89.932).round()} 0 500 -');
now = at.add(const Duration(seconds: 30));
await pass(tester);
await tester.pageBack();
await tester.pumpAndSettle();
final bar = find.byType(RecordingBar);
expect(bar, findsOneWidget);
expect(
find.descendant(of: bar, matching: find.text('0:30 · 0.10 km')),
findsOneWidget,
);
for (final tab in ['Home', 'Maps', 'Groups', 'Profile']) {
await goTab(tester, tab);
expect(bar, findsOneWidget, reason: tab);
}
await tester.tap(bar);
await pass(tester, 2);
expect(find.text('Stop'), findsOneWidget);
expect(platform.started, hasLength(1));
// Paused, it says so.
await tester.tap(find.text('Pause'));
await pass(tester);
await tester.pageBack();
await pass(tester);
expect(
find.descendant(of: bar, matching: find.text('Paused')),
findsOneWidget,
);
});
});
testWidgets('without location it records nothing, and says why', ( testWidgets('without location it records nothing, and says why', (
tester, tester,
) async { ) async {

View file

@ -1491,6 +1491,12 @@ Strava. What was found, 27 Oct:
(31 Oct, the developer's), where an account sheet had held the account and the (31 Oct, the developer's), where an account sheet had held the account and the
training's screens: grouped in cards, the training's screens first, the account near training's screens: grouped in cards, the training's screens first, the account near
the foot, About Halkyon Train last and the version small below it. the foot, About Halkyon Train last and the version small below it.
A recording under way, left for another tab, stands above the bar on every tab
(below the rail's screen from medium windows on), as Strava's: its activity, its
moving time and distance, paused or stopped, a tap leading back to it
(`RecordingBar`, `NavigationFrame.status`); over the Record tab's button alone, which
had been the one way back. A workout just saved opens its page, in Home's tab, where
the recording had gone back to the tab it started from (both the developer's, 31 Oct).
## Consequences ## Consequences

View file

@ -29,7 +29,8 @@ LeanCode's lints.
`TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen` `TopGradient`, `ScreenTint`, `ScreenColor`, `TintFrame`, `FramedScreen`
and `TabVisibility`; `FloatingNavigationBar`, and `NavigationFrame`, every and `TabVisibility`; `FloatingNavigationBar`, and `NavigationFrame`, every
app's frame around its tabs (the rail with the app's icon, the floating 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`; `AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
`SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`, `SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`,
`TitleWithKicker`, `CollapsingRow`, `ScrollHidingFab`, `liftWhileDragged`, `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 /// 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. /// both at once (as Vaia does). The rail of wider windows stays.
final bool hidesNavigationBar = false, 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, super.key,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @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 // Without the bar, a strip as high as the system's own bar, in
// the screen's background, keeps every screen inside clear of // the screen's background, keeps every screen inside clear of
// it. // it.
bottomNavigationBar: widget.hidesNavigationBar bottomNavigationBar: switch (widget.status) {
? SizedBox(height: MediaQuery.paddingOf(context).bottom) final status? => Column(
: bar, 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(find.byType(FloatingNavigationBar), findsNothing);
expect(tester.getBottomLeft(find.byType(Scaffold).last).dy, 800 - 24); 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),
);
}
}
});
} }