feat(train): a recording follows a route, its turns told (ADR 0030, D45)

The Record sheet offers the routes with a way planned; the recorder's
service follows the one chosen offline (RouteGuide.kt): turns told 50 m
before them (100 m on a bike) after a tone, off the route past 40 m for
10 s a cue with the way back, told again on rejoining, the end told.
The recording file keeps the route and the guidance; the recording
screen shows the next turn or the way back, and swipes to a map of the
route, the track and the position, north or the heading up.

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-29 08:27:27 +01:00
commit b0c0eeb3d5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 2153 additions and 171 deletions

View file

@ -1091,6 +1091,7 @@ void main() {
WidgetTester tester, {
PolarLoop? loop,
PlanRepository? plans,
RouteRepository? routes,
}) => start(
tester,
usedBefore: true,
@ -1103,6 +1104,7 @@ void main() {
workouts: workouts,
loop: loop,
plans: plans,
routes: routes,
);
/// Lets [seconds] pass, the screen following.
@ -1365,6 +1367,89 @@ void main() {
});
});
testWidgets('a route chosen on the Record sheet chooses its activity and '
'goes to the recorder with its turns spoken; the next turn shows, '
'then the way back off it, and the map is swiped to (D45)', (
tester,
) async {
final routes = RouteRepository(MemoryRouteGateway(), keys);
addTearDown(routes.dispose);
await tester.runAsync(
() => routes.save(
const RouteContent(
name: 'River',
activity: Activity.cycling,
profile: RouteProfile.trekkingBike,
waypoints: [LatLng(50, 10), LatLng(50.005, 10.01)],
planned: RouteGeometry(
points: [
LatLng(50, 10),
LatLng(50.005, 10),
LatLng(50.005, 10.01),
],
distanceMetres: 1271,
hints: [TurnHint(index: 1, kind: TurnKind.right, metres: 715)],
),
),
),
);
await withClock(Clock(() => at), () async {
await startRecording(tester, routes: routes);
await tester.runAsync(pumpEventQueue);
await tester.tap(find.text('Record'));
await tester.pumpAndSettle();
await tester.tap(find.text('Running'));
await tester.pump();
await tester.tap(find.text('Route'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
await tester.tap(find.text('River'));
await tester.pumpAndSettle();
expect(find.text('River'), findsOneWidget);
await tester.tap(find.text('Start'));
await tester.runAsync(pumpEventQueue);
await pass(tester, 2);
final started = platform.started.single;
expect(started.activity, Activity.cycling);
expect(started.route!['lead'], 100);
expect(started.route!['turns'], [
{'index': 1, 'say': 'In 100 metres: Turn right'},
]);
platform
..write('route @ ${started.route!['route']}')
..write('p @ 500000000 100000000 500 -');
await pass(tester);
expect(find.text('Following River'), findsOneWidget);
platform.write('guide @ next 0');
await pass(tester);
expect(find.text('Turn right'), findsOneWidget);
expect(find.text('In 560 m'), findsOneWidget);
platform.write('guide @ off 80 45');
await pass(tester);
expect(
find.text('Off the route: 80 m to the north-east'),
findsOneWidget,
);
await tester.drag(find.byType(PageView), const Offset(-500, 0));
await tester.pumpAndSettle();
expect(find.byType(FlutterMap), findsOneWidget);
expect(
find.text('Off the route: 80 m to the north-east'),
findsWidgets,
);
await tester.tap(find.text('North up'));
await tester.pump();
expect(find.text('Heading up'), findsOneWidget);
platform.write('guide @ on');
await pass(tester);
expect(find.text('Turn right'), findsWidgets);
expect(tester.takeException(), isNull);
});
});
group('with a strap (D27)', () {
const polar = FoundDevice(
address: 'A0:9E:1A:00:00:01',

View file

@ -3,7 +3,9 @@ import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
/// What the recording screen shows as it records (ADR 0030, D21).
void main() {
@ -327,4 +329,82 @@ void main() {
);
});
});
group('following a route (D45)', () {
const left = TurnHint(index: 1, kind: TurnKind.left, metres: 700);
const right = TurnHint(index: 2, kind: TurnKind.right, metres: 0);
const route = RouteContent(
name: 'Park',
profile: RouteProfile.run,
waypoints: [LatLng(0, 0), LatLng(0.009, 0.009)],
planned: RouteGeometry(
// North for 1 km, then east.
points: [LatLng(0, 0), LatLng(0.0089932, 0), LatLng(0.0089932, 0.009)],
distanceMetres: 2000,
hints: [left, right],
),
);
Recording following(List<GuideMark> guidance) => Recording(
activity: Activity.running,
startedAt: start,
fixes: [north(0, 0), north(60, 300)],
route: route,
guidance: guidance,
);
test('it shows the next turn, how far it is as the crow flies, and the '
'track so far with the heading', () {
final live = liveOf(
following([GuideMark(time: at(1), kind: GuideKind.next)]),
at(60),
);
expect(
live.route,
const LiveRoute(route: route, next: left, metresToNext: 700),
);
expect(live.track, hasLength(2));
expect(live.heading, closeTo(0, 0.5));
});
test('off the route, the way back shows until it rejoins; past the last '
'turn none shows; at its end, it says so', () {
final off = liveOf(
following([
GuideMark(time: at(1), kind: GuideKind.next),
GuideMark(time: at(30), kind: GuideKind.off, metres: 80, bearing: 45),
]),
at(60),
).route!;
expect((off.offMetres, off.offBearing), (80, 45));
final back = liveOf(
following([
GuideMark(time: at(30), kind: GuideKind.off, metres: 80, bearing: 45),
GuideMark(time: at(50), kind: GuideKind.on),
GuideMark(time: at(51), kind: GuideKind.next, index: 2),
GuideMark(time: at(55), kind: GuideKind.arrived),
]),
at(60),
).route!;
expect(back.offMetres, isNull);
expect(back.next, isNull);
expect(back.arrived, isTrue);
});
test('without a route there is none, the track shown all the same', () {
final live = liveOf(
Recording(
activity: Activity.running,
startedAt: start,
fixes: [north(0, 0), north(10, 50, accuracyCm: 5000), north(60, 300)],
),
at(60),
);
expect(live.route, isNull);
// The fix too imprecise to count is left out (D20).
expect(live.track, hasLength(2));
});
});
}

View file

@ -10,9 +10,11 @@ import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
import '../../support/fake_recorder_platform.dart';
import '../../support/memory_content_key_gateway.dart';
@ -70,6 +72,10 @@ void main() {
ticks: ticks.stream,
loop: () => loop,
planOf: (plan) async => {'name': plan.name},
routeOf: (route, activity) => {
'name': route.name,
'activity': activity.name,
},
)..add(const RecordingEventOpened());
opened.add(bloc);
await bloc.stream.firstWhere((s) => s.phase != RecordingPhase.loading);
@ -484,6 +490,39 @@ void main() {
});
});
group('a route (D45)', () {
const route = RouteContent(
name: 'Park',
profile: RouteProfile.run,
waypoints: [LatLng(50, 10), LatLng(50.005, 10)],
planned: RouteGeometry(
points: [LatLng(50, 10), LatLng(50.005, 10)],
distanceMetres: 556,
),
);
test('one chosen goes to the recorder as it takes it, for the activity '
'recorded; none, nothing goes', () async {
final bloc = await open();
bloc.add(const RecordingEventStartRequested(Activity.running));
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
bloc.add(const RecordingEventStopRequested());
await bloc.stream.firstWhere((s) => s.phase != RecordingPhase.recording);
bloc.add(const RecordingEventDiscardRequested());
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.idle);
bloc.add(
const RecordingEventStartRequested(Activity.cycling, route: route),
);
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
expect(platform.started.map((s) => s.route), [
null,
{'name': 'Park', 'activity': 'cycling'},
]);
});
});
group('the rower (D28)', () {
const rower = KnownDevice(address: 'C4:00:00:00:00:01', name: 'MRK-950R');

View file

@ -1,8 +1,13 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recording_file.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_payload.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
/// The file the recorder appends to (ADR 0030, D18): a sample a line, read
/// back whole, a line cut short dropped.
@ -232,4 +237,51 @@ void main() {
expect(recording.plan, isNull);
expect(recording.planSteps, isEmpty);
});
test('a route followed reads with its guidance (D45); a mark it cannot '
'read is skipped', () {
const route = RouteContent(
name: 'Park',
profile: RouteProfile.run,
waypoints: [LatLng(50, 10), LatLng(50.005, 10)],
planned: RouteGeometry(
points: [LatLng(50, 10), LatLng(50.005, 10)],
distanceMetres: 556,
hints: [TurnHint(index: 1, kind: TurnKind.left, metres: 0)],
),
);
final payload = base64Encode(encodeRoute(route).buffer.asUint8List());
final recording = readRecording(
'$header'
'route ${at(0)} $payload\n'
'guide ${at(1)} next 0\n'
'guide ${at(2)} turn 0\n'
'guide ${at(3)} off 60 90\n'
'guide ${at(4)} off far\n'
'guide ${at(5)} on\n'
'guide ${at(6)} arrived\n'
'guide ${at(7)} lost\n',
)!;
DateTime time(int seconds) => start.add(Duration(seconds: seconds));
expect(recording.route, route);
expect(recording.guidance, [
GuideMark(time: time(1), kind: GuideKind.next),
GuideMark(time: time(2), kind: GuideKind.turn),
GuideMark(time: time(3), kind: GuideKind.off, metres: 60, bearing: 90),
GuideMark(time: time(5), kind: GuideKind.on),
GuideMark(time: time(6), kind: GuideKind.arrived),
]);
});
test('a recording without a route has none, nor guidance', () {
final recording = readRecording(
'$header'
'route ${at(0)} not-base64!\n'
'end ${at(5)}\n',
)!;
expect(recording.route, isNull);
expect(recording.guidance, isEmpty);
});
}

View file

@ -0,0 +1,75 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations_de.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations_en.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_payload.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_speech.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
/// What the recorder is handed to follow a route (ADR 0030, D45): its
/// points, its turns with what is said, how far ahead, and the words off it.
void main() {
final en = AppLocalizationsEn();
const route = RouteContent(
name: 'Park',
profile: RouteProfile.run,
waypoints: [LatLng(50, 10), LatLng(50.01, 10.01)],
planned: RouteGeometry(
points: [
LatLng(50, 10),
LatLng(50.005, 10),
LatLng(50.005, 10.005),
LatLng(50.01, 10.01),
],
distanceMetres: 1700,
hints: [
TurnHint(index: 1, kind: TurnKind.right, metres: 360),
TurnHint(index: 2, kind: TurnKind.beeline, metres: 600),
TurnHint(index: 3, kind: TurnKind.roundabout, exit: 2, metres: 0),
],
),
);
test('on foot, its points in turn, its turns told 50 m ahead, a beeline '
'not told; the route itself kept for the file', () {
final json = recorderRouteOf(en, 'en', route, Activity.running);
expect(json['points'], [50, 10, 50.005, 10, 50.005, 10.005, 50.01, 10.01]);
expect(json['lead'], 50);
expect(json['turns'], [
{'index': 1, 'say': 'In 50 metres: Turn right'},
{'index': 3, 'say': 'In 50 metres: At the roundabout, take exit 2'},
]);
expect(json['locale'], 'en');
expect(
decodeRoute(
ByteData.sublistView(base64Decode(json['route']! as String)),
),
route,
);
});
test('by bike, 100 m ahead; the words off the route keep their places '
'for the recorder, in the app language', () {
final json = recorderRouteOf(
AppLocalizationsDe(),
'de',
route,
Activity.cycling,
);
expect(json['lead'], 100);
expect(
(json['turns']! as List).first,
{'index': 1, 'say': 'In 100 Metern: Rechts abbiegen'},
);
final say = json['say']! as Map<String, Object?>;
expect(say['off'], contains('{metres}'));
expect(say['off'], contains('{direction}'));
expect(say['directions'], hasLength(8));
});
}

View file

@ -29,6 +29,7 @@ class FakeRecorderPlatform({
String? rower,
String? loop,
Map<String, Object?>? plan,
Map<String, Object?>? route,
})
>[];
@ -82,6 +83,7 @@ class FakeRecorderPlatform({
String? rower,
String? loop,
Map<String, Object?>? plan,
Map<String, Object?>? route,
}) async {
started.add((
path: path,
@ -91,6 +93,7 @@ class FakeRecorderPlatform({
rower: rower,
loop: loop,
plan: plan,
route: route,
));
notices.add(notice);
_file = File(path)