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
4 changed files with 2153 additions and 171 deletions

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));
});
}