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:
parent
aea99c68ca
commit
b0c0eeb3d5
6 changed files with 2153 additions and 171 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue