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
11 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.",
"settingsTitle": "Einstellungen",
"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",
"settingsTraining": "Training",
"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:
/// **'Version {version} (build {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

View file

@ -2052,4 +2052,12 @@ class AppLocalizationsDe extends AppLocalizations {
String settingsVersion(String version, String 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) {
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`).

View file

@ -297,8 +297,9 @@ class RecordingBloc(
return;
}
final (content, series) = recordedWorkout(recording);
final String saved;
try {
await _workouts.save(content, series);
saved = await _workouts.save(content, series);
} on Object catch (error) {
// The file stays, to be saved once it can be.
log('A recording not saved: ${error.runtimeType}', name: 'Recording');
@ -311,6 +312,7 @@ class RecordingBloc(
phase: RecordingPhase.idle,
lastActivity: recording.activity,
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 rower (D27, D28), the bike's sensors (D49).
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 {
KnownDevice? get strap => devices[DeviceKind.strap];
KnownDevice? get rower => devices[DeviceKind.rower];
@ -525,6 +531,7 @@ final class const RecordingState({
bluetoothRefused,
rowerMissing,
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
/// 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
/// map of the track so far (D45); once stopped, Save or Discard. It closes
/// once the recording is saved or discarded; back leaves the recording
/// running.
/// map of the track so far (D45); once stopped, Save or Discard. Saved, the
/// workout's page takes its place; discarded, it closes. Back leaves the
/// recording running.
class const RecordingScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) =>
BlocConsumer<RecordingBloc, RecordingState>(
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(
appBar: AppBar(
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/model/recording.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/route/widget/route_import.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
/// 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
/// 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).
class const TrainShell({
/// The tabs' navigators, from `StatefulShellRoute`.
@ -35,7 +37,14 @@ class const TrainShell({
// where the device records.
final branches = [0, 1, if (records) 2, 3, 4];
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(
status: recording ? const RecordingBar() : null,
appName: l10n.appTitle,
icon: trainIcon,
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/recorder_platform.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/route_files.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
@ -1444,9 +1445,22 @@ void main() {
await pass(tester);
expect(find.text('Save this workout?'), findsOneWidget);
// Saved, its page opens, in Home's tab (D60).
await tester.tap(find.text('Save'));
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!;
expect(saved.activity, Activity.cycling);
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', (
tester,
) async {