feat(train): a recording runs an interval plan, with cues and Lap (ADR 0030, D42)
The plan chosen on the Record sheet goes to the recording service spelled out: each step's end, its target (zones as the heart rates they span for the profile's highest rate) and its spoken line in the app's language. PlanRunner.kt moves through the steps on the moving time and distance, counts a timed step's last seconds down and cues off target from a 30 s window; PlanCues.kt gives a tone, a vibration and the voice, ducking music. The file's plan and step lines bring it back, and the recording screen shows the step, what is left of it, its target, the next step and Lap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f406ddcc59
commit
d9f98449a8
6 changed files with 1886 additions and 26 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
|
|
@ -1067,19 +1068,23 @@ void main() {
|
|||
File file() => File('${folder.path}/recording.txt');
|
||||
|
||||
/// Train on a device that records, signed in, its clock at [now]'s.
|
||||
Future<void> startRecording(WidgetTester tester, {PolarLoop? loop}) =>
|
||||
start(
|
||||
tester,
|
||||
usedBefore: true,
|
||||
recording: (
|
||||
platform: platform,
|
||||
file: file(),
|
||||
straps: DeviceStore(File('${folder.path}/strap.json')),
|
||||
rowers: DeviceStore(File('${folder.path}/rower.json')),
|
||||
),
|
||||
workouts: workouts,
|
||||
loop: loop,
|
||||
);
|
||||
Future<void> startRecording(
|
||||
WidgetTester tester, {
|
||||
PolarLoop? loop,
|
||||
PlanRepository? plans,
|
||||
}) => start(
|
||||
tester,
|
||||
usedBefore: true,
|
||||
recording: (
|
||||
platform: platform,
|
||||
file: file(),
|
||||
straps: DeviceStore(File('${folder.path}/strap.json')),
|
||||
rowers: DeviceStore(File('${folder.path}/rower.json')),
|
||||
),
|
||||
workouts: workouts,
|
||||
loop: loop,
|
||||
plans: plans,
|
||||
);
|
||||
|
||||
/// Lets [seconds] pass, the screen following.
|
||||
Future<void> pass(WidgetTester tester, [int seconds = 1]) async {
|
||||
|
|
@ -1243,6 +1248,78 @@ void main() {
|
|||
expect(platform.started, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a plan chosen on the Record sheet chooses its activity, goes '
|
||||
'to the recorder spoken, and its step shows with Lap (D42)', (
|
||||
tester,
|
||||
) async {
|
||||
final plans = PlanRepository(MemoryPlanGateway(), keys);
|
||||
addTearDown(plans.dispose);
|
||||
await tester.runAsync(
|
||||
() => plans.save(
|
||||
const PlanContent(
|
||||
name: 'Sprints',
|
||||
activity: Activity.cycling,
|
||||
blocks: [
|
||||
PlanBlock(
|
||||
repeat: 2,
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(seconds: 30)),
|
||||
target: Target(TargetKind.speed, 30, 35),
|
||||
),
|
||||
PlanStep(kind: StepKind.recovery, end: EndWithLap()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await withClock(Clock(() => at), () async {
|
||||
await startRecording(tester, plans: plans);
|
||||
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('Plan'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sprints'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sprints'), 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);
|
||||
final steps = started.plan!['steps']! as List<Object?>;
|
||||
expect(steps, hasLength(4));
|
||||
expect(
|
||||
(steps.first! as Map<String, Object?>)['say'],
|
||||
'Work, 1 of 2, 30 seconds, 30 to 35 kilometres an hour',
|
||||
);
|
||||
|
||||
platform
|
||||
..write('plan @ ${jsonEncode(started.plan!['plan'])}')
|
||||
..write('step @ 0');
|
||||
await pass(tester);
|
||||
expect(find.text('Sprints · Step 1 of 4'), findsOneWidget);
|
||||
expect(find.text('Work · 1 of 2'), findsOneWidget);
|
||||
expect(find.text('30–35 km/h'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Next: Recovery · 1 of 2, until a lap tap'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Lap'));
|
||||
await pass(tester);
|
||||
expect(platform.laps, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('with a strap (D27)', () {
|
||||
const polar = FoundDevice(
|
||||
address: 'A0:9E:1A:00:00:01',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
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/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
|
||||
|
||||
/// What the recorder is handed to run a plan (ADR 0030, D42): the run
|
||||
/// spelled out, zones as heart rates, and what is said.
|
||||
void main() {
|
||||
final en = AppLocalizationsEn();
|
||||
const plan = PlanContent(
|
||||
name: '4×4',
|
||||
blocks: [
|
||||
PlanBlock(
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.warmUp,
|
||||
end: EndAfterTime(Duration(minutes: 10)),
|
||||
),
|
||||
],
|
||||
),
|
||||
PlanBlock(
|
||||
repeat: 2,
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(minutes: 4)),
|
||||
target: Target(TargetKind.heartZone, 4, 4),
|
||||
),
|
||||
PlanStep(
|
||||
kind: StepKind.recovery,
|
||||
end: EndAfterDistance(400),
|
||||
target: Target(TargetKind.pace, 330, 360),
|
||||
),
|
||||
],
|
||||
),
|
||||
PlanBlock(
|
||||
steps: [PlanStep(kind: StepKind.coolDown, end: EndWithLap())],
|
||||
),
|
||||
],
|
||||
offTargetCues: false,
|
||||
);
|
||||
|
||||
test('every round of every step goes, with its end, its target and what '
|
||||
'is said; zones as the heart rates they span', () {
|
||||
final json = recorderPlanOf(en, 'en-GB', plan, maxHeartRate: 190);
|
||||
|
||||
expect(json['offTargetCues'], isFalse);
|
||||
expect(json['locale'], 'en-GB');
|
||||
expect(json['steps'], [
|
||||
{'seconds': 600, 'say': 'Warm-up, 10 minutes'},
|
||||
{
|
||||
'seconds': 240,
|
||||
'target': {'kind': 'heartRate', 'low': 152, 'high': 171},
|
||||
'say': 'Work, 1 of 2, 4 minutes, zone 4',
|
||||
},
|
||||
{
|
||||
'metres': 400,
|
||||
'target': {'kind': 'pace', 'low': 330.0, 'high': 360.0},
|
||||
'say': 'Recovery, 1 of 2, 400 metres, 5:30 to 6:00 a kilometre',
|
||||
},
|
||||
{
|
||||
'seconds': 240,
|
||||
'target': {'kind': 'heartRate', 'low': 152, 'high': 171},
|
||||
'say': 'Work, 2 of 2, 4 minutes, zone 4',
|
||||
},
|
||||
{
|
||||
'metres': 400,
|
||||
'target': {'kind': 'pace', 'low': 330.0, 'high': 360.0},
|
||||
'say': 'Recovery, 2 of 2, 400 metres, 5:30 to 6:00 a kilometre',
|
||||
},
|
||||
{'say': 'Cool-down, until you tap Lap'},
|
||||
]);
|
||||
expect(json['say'], {
|
||||
'harder': 'Push a little',
|
||||
'easier': 'Ease off a little',
|
||||
'done': 'Plan done',
|
||||
});
|
||||
});
|
||||
|
||||
test('the plan itself goes too, as its payload, for the recording file', () {
|
||||
final json = recorderPlanOf(en, 'en-GB', plan, maxHeartRate: 190);
|
||||
|
||||
expect(json['plan'], containsPair('name', '4×4'));
|
||||
expect(json['plan'], containsPair('schemaVersion', 1));
|
||||
});
|
||||
|
||||
test("it is said in the app's language", () {
|
||||
const step = PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(seconds: 90)),
|
||||
target: Target(TargetKind.strokeRate, 24, 26),
|
||||
);
|
||||
|
||||
expect(
|
||||
spokenStep(AppLocalizationsDe(), 'de', (step: step, round: 3, rounds: 8)),
|
||||
'Belastung, 3 von 8, 1 Minute 30 Sekunden, '
|
||||
'24 bis 26 Ruderschläge pro Minute',
|
||||
);
|
||||
expect(
|
||||
spokenStep(en, 'en', (
|
||||
step: const PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterDistance(1500),
|
||||
),
|
||||
round: 1,
|
||||
rounds: 1,
|
||||
)),
|
||||
'Work, 1.5 kilometres',
|
||||
);
|
||||
});
|
||||
|
||||
test("zones are worked out from the profile's highest heart rate, else "
|
||||
'from the age, else from a guess', () {
|
||||
final today = DateTime.utc(2026, 10, 28);
|
||||
|
||||
expect(
|
||||
planMaxHeartRate(const ProfileContent(maxHeartRate: 185), today),
|
||||
185,
|
||||
);
|
||||
expect(
|
||||
planMaxHeartRate(
|
||||
ProfileContent(birthDate: DateTime.utc(1996, 3, 2)),
|
||||
today,
|
||||
),
|
||||
190,
|
||||
);
|
||||
expect(planMaxHeartRate(null, today), 180);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
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/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/workout/model/workout.dart';
|
||||
|
||||
|
|
@ -213,4 +215,116 @@ void main() {
|
|||
expect(live.watts, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('with a plan (D42)', () {
|
||||
const work = PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(minutes: 1)),
|
||||
);
|
||||
const recovery = PlanStep(
|
||||
kind: StepKind.recovery,
|
||||
end: EndAfterDistance(200),
|
||||
);
|
||||
const cooldown = PlanStep(kind: StepKind.coolDown, end: EndWithLap());
|
||||
const plan = PlanContent(
|
||||
name: 'Short',
|
||||
blocks: [
|
||||
PlanBlock(steps: [work, recovery], repeat: 2),
|
||||
PlanBlock(steps: [cooldown]),
|
||||
],
|
||||
);
|
||||
|
||||
test('a timed step shows its moving time left, pauses left out, and '
|
||||
'the step after it', () {
|
||||
final live = liveOf(
|
||||
Recording(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
plan: plan,
|
||||
planSteps: [StepMark(time: at(0), index: 0)],
|
||||
pauses: [
|
||||
PauseMark(time: at(10)),
|
||||
PauseMark(time: at(20), paused: false),
|
||||
],
|
||||
),
|
||||
at(40),
|
||||
);
|
||||
|
||||
expect(
|
||||
live.plan,
|
||||
const LivePlan(
|
||||
name: 'Short',
|
||||
step: (step: work, round: 1, rounds: 2),
|
||||
count: 5,
|
||||
timeLeft: Duration(seconds: 30),
|
||||
next: (step: recovery, round: 1, rounds: 2),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a measured step shows the metres left since it started', () {
|
||||
final live = liveOf(
|
||||
Recording(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
plan: plan,
|
||||
fixes: [north(55, 0), north(60, 20), north(80, 120)],
|
||||
planSteps: [
|
||||
StepMark(time: at(0), index: 0),
|
||||
StepMark(time: at(60), index: 1),
|
||||
],
|
||||
),
|
||||
at(80),
|
||||
).plan!;
|
||||
|
||||
expect(live.index, 1);
|
||||
expect(live.metresLeft, 100);
|
||||
expect(live.timeLeft, isNull);
|
||||
});
|
||||
|
||||
test('a step ending with a lap tap has nothing left to count, and the '
|
||||
'last has none after it', () {
|
||||
final live = liveOf(
|
||||
Recording(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
plan: plan,
|
||||
planSteps: [StepMark(time: at(300), index: 4)],
|
||||
),
|
||||
at(320),
|
||||
).plan!;
|
||||
|
||||
expect(live.step, (step: cooldown, round: 1, rounds: 1));
|
||||
expect((live.timeLeft, live.metresLeft, live.next), (null, null, null));
|
||||
});
|
||||
|
||||
test('done, it has no step; without a plan, or before its first step, '
|
||||
'there is none', () {
|
||||
Recording recording({
|
||||
PlanContent? plan,
|
||||
List<StepMark> steps = const [],
|
||||
}) => Recording(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
plan: plan,
|
||||
planSteps: steps,
|
||||
);
|
||||
|
||||
expect(
|
||||
liveOf(
|
||||
recording(
|
||||
plan: plan,
|
||||
steps: [StepMark(time: at(400))],
|
||||
),
|
||||
at(410),
|
||||
).plan,
|
||||
const LivePlan(name: 'Short', index: 5, count: 5),
|
||||
);
|
||||
expect(liveOf(recording(plan: plan), at(1)).plan, isNull);
|
||||
expect(
|
||||
liveOf(recording(steps: [StepMark(time: at(0), index: 0)]), at(1)).plan,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:clock/clock.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
|
|
@ -68,6 +69,7 @@ void main() {
|
|||
notice: notice,
|
||||
ticks: ticks.stream,
|
||||
loop: () => loop,
|
||||
planOf: (plan) async => {'name': plan.name},
|
||||
)..add(const RecordingEventOpened());
|
||||
opened.add(bloc);
|
||||
await bloc.stream.firstWhere((s) => s.phase != RecordingPhase.loading);
|
||||
|
|
@ -412,6 +414,76 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('a plan (D42)', () {
|
||||
const plan = PlanContent(
|
||||
name: 'Tabata',
|
||||
blocks: [
|
||||
PlanBlock(
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(seconds: 20)),
|
||||
),
|
||||
],
|
||||
repeat: 8,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
test('one chosen goes to the recorder as it takes it; 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.running, plan: plan),
|
||||
);
|
||||
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
|
||||
|
||||
expect(platform.started.map((s) => s.plan), [
|
||||
null,
|
||||
{'name': 'Tabata'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('Lap goes to the recorder', () async {
|
||||
final bloc = await open();
|
||||
bloc.add(
|
||||
const RecordingEventStartRequested(Activity.running, plan: plan),
|
||||
);
|
||||
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
|
||||
|
||||
bloc.add(const RecordingEventLapRequested());
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(platform.laps, 1);
|
||||
});
|
||||
|
||||
test('its steps show as the recorder writes them', () async {
|
||||
final bloc = await open();
|
||||
bloc.add(
|
||||
const RecordingEventStartRequested(Activity.running, plan: plan),
|
||||
);
|
||||
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
|
||||
|
||||
platform
|
||||
..write(
|
||||
'plan @ {"schemaVersion":1,"name":"Tabata","blocks":[{"repeat":8,'
|
||||
'"steps":[{"kind":"work","end":{"by":"time","seconds":20}}]}]}',
|
||||
)
|
||||
..write('step @ 0');
|
||||
final state = await tick(bloc);
|
||||
|
||||
expect(state.live?.plan?.index, 0);
|
||||
expect(state.live?.plan?.count, 8);
|
||||
});
|
||||
});
|
||||
|
||||
group('the rower (D28)', () {
|
||||
const rower = KnownDevice(address: 'C4:00:00:00:00:01', name: 'MRK-950R');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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/workout/model/workout.dart';
|
||||
|
|
@ -182,4 +183,53 @@ void main() {
|
|||
RowingSample(time: time(2), metres: 21),
|
||||
]);
|
||||
});
|
||||
|
||||
test("a plan run reads with its steps' starts and its end (D42); a step "
|
||||
'mark it cannot read is skipped', () {
|
||||
final recording = readRecording(
|
||||
'$header'
|
||||
'plan ${at(0)} {"schemaVersion":1,"name":"4 x 4","offTargetCues":true,'
|
||||
'"blocks":[{"repeat":2,"steps":[{"kind":"work","end":'
|
||||
'{"by":"time","seconds":240},"target":{"kind":"heartZone","low":4,'
|
||||
'"high":4}}]}]}\n'
|
||||
'step ${at(0)} 0\n'
|
||||
'step ${at(240)} 1\n'
|
||||
'step ${at(241)} soon\n'
|
||||
'step ${at(480)} done\n',
|
||||
)!;
|
||||
|
||||
expect(
|
||||
recording.plan,
|
||||
PlanContent(
|
||||
name: '4 x 4',
|
||||
blocks: [
|
||||
PlanBlock(
|
||||
repeat: 2,
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: const EndAfterTime(Duration(minutes: 4)),
|
||||
target: Target.zone(4),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(recording.planSteps, [
|
||||
StepMark(time: start, index: 0),
|
||||
StepMark(time: start.add(const Duration(seconds: 240)), index: 1),
|
||||
StepMark(time: start.add(const Duration(seconds: 480))),
|
||||
]);
|
||||
});
|
||||
|
||||
test('a recording without a plan has none', () {
|
||||
final recording = readRecording(
|
||||
'$header'
|
||||
'end ${at(5)}\n',
|
||||
)!;
|
||||
|
||||
expect(recording.plan, isNull);
|
||||
expect(recording.planSteps, isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,9 +28,13 @@ class FakeRecorderPlatform({
|
|||
String? strap,
|
||||
String? rower,
|
||||
String? loop,
|
||||
Map<String, Object?>? plan,
|
||||
})
|
||||
>[];
|
||||
|
||||
/// How often Lap was tapped.
|
||||
int laps = 0;
|
||||
|
||||
/// The straps and the rowers nearby, seen as soon as a scan starts, and
|
||||
/// whether Bluetooth is on.
|
||||
List<FoundDevice> nearby = [];
|
||||
|
|
@ -77,6 +81,7 @@ class FakeRecorderPlatform({
|
|||
String? strap,
|
||||
String? rower,
|
||||
String? loop,
|
||||
Map<String, Object?>? plan,
|
||||
}) async {
|
||||
started.add((
|
||||
path: path,
|
||||
|
|
@ -85,6 +90,7 @@ class FakeRecorderPlatform({
|
|||
strap: strap,
|
||||
rower: rower,
|
||||
loop: loop,
|
||||
plan: plan,
|
||||
));
|
||||
notices.add(notice);
|
||||
_file = File(path)
|
||||
|
|
@ -106,6 +112,9 @@ class FakeRecorderPlatform({
|
|||
write('resume @ manual');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> lap() async => laps++;
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
write('end @');
|
||||
|
|
|
|||
Loading…
Reference in a new issue