feat(train): the heart-rate strap, recorded by the service itself (ADR 0030, D25-D27)
The Record sheet gains a Heart rate row: a sheet looks for straps advertising the Heart Rate Service, the nearest first, once nearby devices are allowed, and the one chosen is remembered in the person's folder; it records with every workout unless switched off for one. The recorder service connects to it with Android's own Bluetooth API, no package (D26), parses Heart Rate Measurement in Kotlin (tested on the JVM), writes each rate and the strap's state to the recording file, and asks for a lost strap again for as long as it records. The recording screen shows the heart rate, and "Strap lost" while it is gone; a workout's page shows the heart's average and highest rate, each split's average and its chart. iOS's recording waits for a Mac (D25), after the rower. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
405577050d
commit
de5fd43e06
7 changed files with 1782 additions and 42 deletions
|
|
@ -12,6 +12,7 @@ import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
|
||||
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/data/strap_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
|
@ -323,7 +324,11 @@ void main() {
|
|||
Future<void> startRecording(WidgetTester tester) => start(
|
||||
tester,
|
||||
usedBefore: true,
|
||||
recording: (platform: platform, file: file()),
|
||||
recording: (
|
||||
platform: platform,
|
||||
file: file(),
|
||||
straps: StrapStore(File('${folder.path}/strap.json')),
|
||||
),
|
||||
workouts: workouts,
|
||||
);
|
||||
|
||||
|
|
@ -489,6 +494,137 @@ void main() {
|
|||
expect(platform.started, isEmpty);
|
||||
});
|
||||
});
|
||||
group('with a strap (D27)', () {
|
||||
const polar = FoundStrap(
|
||||
address: 'A0:9E:1A:00:00:01',
|
||||
name: 'Polar H10 1234',
|
||||
rssi: -50,
|
||||
);
|
||||
|
||||
StrapStore straps() => StrapStore(File('${folder.path}/strap.json'));
|
||||
|
||||
testWidgets('one is chosen from the Record sheet, nearby devices asked '
|
||||
'first, and used from then on', (tester) async {
|
||||
platform.nearby = [
|
||||
const FoundStrap(address: 'B', name: 'HRM-Dual', rssi: -80),
|
||||
polar,
|
||||
];
|
||||
await withClock(Clock.fixed(at), () async {
|
||||
await startRecording(tester);
|
||||
await tester.tap(find.text('Record'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No strap'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Heart rate'));
|
||||
await pass(tester, 2);
|
||||
expect(platform.asked.last, {RecorderPermission.bluetooth});
|
||||
expect(find.text('Heart-rate strap'), findsOneWidget);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Polar H10 1234')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('HRM-Dual')).dy),
|
||||
);
|
||||
await tester.tap(find.text('Polar H10 1234'));
|
||||
await pass(tester, 2);
|
||||
|
||||
expect(find.text('Heart-rate strap'), findsNothing);
|
||||
expect(find.text('Polar H10 1234'), findsOneWidget);
|
||||
expect(straps().read()?.address, polar.address);
|
||||
await tester.tap(find.text('Start'));
|
||||
await pass(tester, 2);
|
||||
expect(platform.started.single.strap, polar.address);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('switched off on the sheet, a workout records without it', (
|
||||
tester,
|
||||
) async {
|
||||
straps().save(Strap(address: polar.address, name: polar.name));
|
||||
await withClock(Clock.fixed(at), () async {
|
||||
await startRecording(tester);
|
||||
await tester.tap(find.text('Record'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'Heart rate'),
|
||||
matching: find.byType(Switch),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await pass(tester, 2);
|
||||
|
||||
expect(platform.started.single.strap, isNull);
|
||||
expect(find.text('heart rate'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('forgotten on its sheet, none is used', (tester) async {
|
||||
straps().save(Strap(address: polar.address, name: polar.name));
|
||||
await withClock(Clock.fixed(at), () async {
|
||||
await startRecording(tester);
|
||||
await tester.tap(find.text('Record'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Heart rate'));
|
||||
await pass(tester, 2);
|
||||
await tester.tap(find.text('Forget strap'));
|
||||
await pass(tester, 2);
|
||||
|
||||
expect(find.text('No strap'), findsOneWidget);
|
||||
expect(straps().read(), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('refused nearby devices, the sheet says why', (tester) async {
|
||||
platform.allows = {RecorderPermission.location};
|
||||
await startRecording(tester);
|
||||
await tester.tap(find.text('Record'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Heart rate'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'Train needs nearby devices to find your strap. You can allow it '
|
||||
"in the system's settings.",
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the recording shows the heart rate, and says when the '
|
||||
'strap is lost', (tester) async {
|
||||
straps().save(Strap(address: polar.address, name: polar.name));
|
||||
var now = at;
|
||||
await withClock(Clock(() => now), () async {
|
||||
await startRecording(tester);
|
||||
await tester.tap(find.text('Record'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Start'));
|
||||
await pass(tester, 2);
|
||||
|
||||
platform.write('strap @ connecting');
|
||||
await pass(tester);
|
||||
expect(find.text('heart rate'), findsOneWidget);
|
||||
|
||||
platform
|
||||
..now = at.add(const Duration(seconds: 3))
|
||||
..write('strap @ connected')
|
||||
..write('h @ 142');
|
||||
now = at.add(const Duration(seconds: 3));
|
||||
await pass(tester);
|
||||
expect(find.text('142'), findsOneWidget);
|
||||
|
||||
platform
|
||||
..now = at.add(const Duration(seconds: 4))
|
||||
..write('strap @ lost');
|
||||
now = at.add(const Duration(seconds: 4));
|
||||
await pass(tester);
|
||||
expect(find.text('142'), findsNothing);
|
||||
expect(find.text('Strap lost'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('workouts (ADR 0030, D24)', () {
|
||||
|
|
@ -514,6 +650,7 @@ void main() {
|
|||
DateTime startedAt, {
|
||||
double km = 2.5,
|
||||
String? name,
|
||||
bool strap = false,
|
||||
}) {
|
||||
final start = startedAt.toUtc();
|
||||
final seconds = (km * 300).round();
|
||||
|
|
@ -531,6 +668,14 @@ void main() {
|
|||
altitudeCm: s < seconds / 2 ? 10000 : 12000,
|
||||
),
|
||||
],
|
||||
heartRates: [
|
||||
if (strap)
|
||||
for (var s = 0; s <= seconds; s += 5)
|
||||
HeartRate(
|
||||
time: start.add(Duration(seconds: s)),
|
||||
bpm: s < seconds / 2 ? 140 : 160,
|
||||
),
|
||||
],
|
||||
endedAt: start.add(Duration(seconds: seconds)),
|
||||
),
|
||||
);
|
||||
|
|
@ -611,6 +756,36 @@ void main() {
|
|||
expect(find.text('Pace'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a workout with a strap shows the heart's average and highest "
|
||||
'rate, by split too, and its chart', (tester) async {
|
||||
await saveRun(DateTime(2026, 10, 27, 7, 30), strap: true);
|
||||
await startWith(tester);
|
||||
|
||||
await tester.tap(find.text('Morning run'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.scrollUntilVisible(find.text('highest heart rate'), 100);
|
||||
expect(find.text('average heart rate'), findsOneWidget);
|
||||
expect(find.text('highest heart rate'), findsOneWidget);
|
||||
expect(find.text('160 bpm'), findsWidgets);
|
||||
await tester.scrollUntilVisible(find.text('Heart rate'), 200);
|
||||
expect(find.text('Heart rate'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a workout without a strap shows no heart rate', (
|
||||
tester,
|
||||
) async {
|
||||
await saveRun(DateTime(2026, 10, 27, 7, 30));
|
||||
await startWith(tester);
|
||||
|
||||
await tester.tap(find.text('Morning run'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('average heart rate'), findsNothing);
|
||||
await tester.scrollUntilVisible(find.text('Elevation'), 200);
|
||||
expect(find.text('Heart rate'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a workout without GPS has no map, splits nor charts', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
|
|
@ -97,4 +97,54 @@ void main() {
|
|||
expect(live.waitingForGps, isFalse);
|
||||
expect(live.distanceMetres, isNull);
|
||||
});
|
||||
|
||||
group('with a strap (D27)', () {
|
||||
Recording withStrap(List<StrapMark> straps, List<HeartRate> rates) =>
|
||||
Recording(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
straps: straps,
|
||||
heartRates: rates,
|
||||
);
|
||||
|
||||
test('connected, it shows the last heart rate while it is fresh', () {
|
||||
final recording = withStrap(
|
||||
[
|
||||
StrapMark(time: at(0), state: StrapState.connecting),
|
||||
StrapMark(time: at(2), state: StrapState.connected),
|
||||
],
|
||||
[HeartRate(time: at(9), bpm: 131), HeartRate(time: at(10), bpm: 134)],
|
||||
);
|
||||
|
||||
expect(liveOf(recording, at(11)).strap, StrapState.connected);
|
||||
expect(liveOf(recording, at(11)).bpm, 134);
|
||||
// Nothing for more than 5 s: no figure rather than an old one.
|
||||
expect(liveOf(recording, at(16)).bpm, isNull);
|
||||
});
|
||||
|
||||
test('connecting or lost, it shows no heart rate', () {
|
||||
final connecting = withStrap([
|
||||
StrapMark(time: at(0), state: StrapState.connecting),
|
||||
], const []);
|
||||
final lost = withStrap(
|
||||
[
|
||||
StrapMark(time: at(0), state: StrapState.connected),
|
||||
StrapMark(time: at(10), state: StrapState.lost),
|
||||
],
|
||||
[HeartRate(time: at(10), bpm: 140)],
|
||||
);
|
||||
|
||||
expect(liveOf(connecting, at(5)).strap, StrapState.connecting);
|
||||
expect(liveOf(connecting, at(5)).bpm, isNull);
|
||||
expect(liveOf(lost, at(10)).strap, StrapState.lost);
|
||||
expect(liveOf(lost, at(10)).bpm, isNull);
|
||||
});
|
||||
|
||||
test('without one, it has no strap', () {
|
||||
final live = liveOf(withStrap(const [], const []), at(5));
|
||||
|
||||
expect(live.strap, isNull);
|
||||
expect(live.bpm, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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/recording/bloc/recording_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.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';
|
||||
|
|
@ -59,6 +60,7 @@ void main() {
|
|||
platform,
|
||||
workouts,
|
||||
file,
|
||||
StrapStore(File('${folder.path}/strap.json')),
|
||||
notice: notice,
|
||||
ticks: ticks.stream,
|
||||
)..add(const RecordingEventOpened());
|
||||
|
|
@ -329,4 +331,53 @@ void main() {
|
|||
|
||||
expect((await open()).state.lastActivity, Activity.hiking);
|
||||
});
|
||||
|
||||
group('the strap (D27)', () {
|
||||
const polar = Strap(address: 'A0:9E:1A:00:00:01', name: 'Polar H10 1234');
|
||||
|
||||
test("one chosen is remembered in the person's folder, and used as a "
|
||||
'recording starts', () async {
|
||||
final bloc = await open();
|
||||
expect(bloc.state.strap, isNull);
|
||||
bloc.add(const RecordingEventStrapChosen(polar));
|
||||
await bloc.stream.firstWhere((s) => s.strap == polar);
|
||||
|
||||
expect((await open()).state.strap, polar);
|
||||
bloc.add(const RecordingEventStartRequested(Activity.running));
|
||||
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
|
||||
expect(platform.started.single.strap, polar.address);
|
||||
});
|
||||
|
||||
test(
|
||||
'switched off for a workout, it is left out of that one alone',
|
||||
() async {
|
||||
final bloc = await open();
|
||||
bloc.add(const RecordingEventStrapChosen(polar));
|
||||
await bloc.stream.firstWhere((s) => s.strap == polar);
|
||||
|
||||
bloc.add(
|
||||
const RecordingEventStartRequested(Activity.running, useStrap: false),
|
||||
);
|
||||
await bloc.stream.firstWhere(
|
||||
(s) => s.phase == RecordingPhase.recording,
|
||||
);
|
||||
|
||||
expect(platform.started.single.strap, isNull);
|
||||
expect(bloc.state.strap, polar);
|
||||
},
|
||||
);
|
||||
|
||||
test('forgotten, none is used', () async {
|
||||
final bloc = await open();
|
||||
bloc.add(const RecordingEventStrapChosen(polar));
|
||||
await bloc.stream.firstWhere((s) => s.strap == polar);
|
||||
bloc.add(const RecordingEventStrapForgotten());
|
||||
await bloc.stream.firstWhere((s) => s.strap == null);
|
||||
|
||||
expect((await open()).state.strap, isNull);
|
||||
bloc.add(const RecordingEventStartRequested(Activity.cycling));
|
||||
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
|
||||
expect(platform.started.single.strap, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,4 +120,23 @@ void main() {
|
|||
expect(recording.fixes, isEmpty);
|
||||
expect(recording.lastSampleAt, start);
|
||||
});
|
||||
|
||||
test("the strap's state is read beside its heart rates (D27)", () {
|
||||
final recording = readRecording(
|
||||
'$header'
|
||||
'strap ${at(0)} connecting\n'
|
||||
'strap ${at(3)} connected\n'
|
||||
'h ${at(4)} 128\n'
|
||||
'strap ${at(9)} lost\n'
|
||||
'strap ${at(9)} sideways\n',
|
||||
)!;
|
||||
|
||||
DateTime time(int seconds) => start.add(Duration(seconds: seconds));
|
||||
expect(recording.straps, [
|
||||
StrapMark(time: time(0), state: StrapState.connecting),
|
||||
StrapMark(time: time(3), state: StrapState.connected),
|
||||
StrapMark(time: time(9), state: StrapState.lost),
|
||||
]);
|
||||
expect(recording.heartRates.single.bpm, 128);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/strap_search_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
|
||||
|
||||
import '../../support/fake_recorder_platform.dart';
|
||||
|
||||
/// The heart-rate strap (ADR 0030, D27): remembered on the device, in the
|
||||
/// person's folder, and looked for nearby.
|
||||
void main() {
|
||||
late Directory folder;
|
||||
|
||||
setUp(() => folder = Directory.systemTemp.createTempSync('strap'));
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
group('StrapStore', () {
|
||||
test('a strap saved is read back, and gone once forgotten', () {
|
||||
final store = StrapStore(File('${folder.path}/strap.json'));
|
||||
expect(store.read(), isNull);
|
||||
|
||||
store.save(const Strap(address: 'A0:9E:1A:00:00:01', name: 'H10'));
|
||||
expect(
|
||||
StrapStore(File('${folder.path}/strap.json')).read(),
|
||||
const Strap(address: 'A0:9E:1A:00:00:01', name: 'H10'),
|
||||
);
|
||||
|
||||
store.forget();
|
||||
expect(store.read(), isNull);
|
||||
});
|
||||
|
||||
test("a file it can't read is no strap", () {
|
||||
final file = File('${folder.path}/strap.json')..writeAsStringSync('{');
|
||||
|
||||
expect(StrapStore(file).read(), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('StrapSearchCubit', () {
|
||||
late FakeRecorderPlatform platform;
|
||||
|
||||
setUp(() => platform = FakeRecorderPlatform());
|
||||
|
||||
const near = FoundStrap(address: 'A', name: 'Polar H10', rssi: -50);
|
||||
const far = FoundStrap(address: 'B', name: 'HRM-Pro', rssi: -85);
|
||||
|
||||
test('it asks for nearby devices, then lists the straps found, the '
|
||||
'nearest first, each once', () async {
|
||||
platform.nearby = [far, near, const FoundStrap(address: 'B', rssi: -70)];
|
||||
final cubit = StrapSearchCubit(platform);
|
||||
addTearDown(cubit.close);
|
||||
|
||||
await cubit.open();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(platform.asked.single, {RecorderPermission.bluetooth});
|
||||
expect(cubit.state.phase, StrapSearchPhase.searching);
|
||||
expect(cubit.state.found.map((s) => (s.address, s.rssi)), [
|
||||
('A', -50),
|
||||
('B', -70),
|
||||
]);
|
||||
// A name seen before is kept where a later sighting has none.
|
||||
expect(cubit.state.found.last.name, 'HRM-Pro');
|
||||
});
|
||||
|
||||
test('refused, it looks for none and says so', () async {
|
||||
platform
|
||||
..allows = {RecorderPermission.location}
|
||||
..nearby = [near];
|
||||
final cubit = StrapSearchCubit(platform);
|
||||
addTearDown(cubit.close);
|
||||
|
||||
await cubit.open();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(cubit.state.phase, StrapSearchPhase.refused);
|
||||
expect(cubit.state.found, isEmpty);
|
||||
expect(platform.scans, 0);
|
||||
});
|
||||
|
||||
test('with Bluetooth off it says so', () async {
|
||||
platform.bluetoothOn = false;
|
||||
final cubit = StrapSearchCubit(platform);
|
||||
addTearDown(cubit.close);
|
||||
|
||||
await cubit.open();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(cubit.state.phase, StrapSearchPhase.bluetoothOff);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -30,12 +30,14 @@ void main() {
|
|||
Recording run(
|
||||
List<Fix> fixes, {
|
||||
List<PauseMark> pauses = const [],
|
||||
List<HeartRate> heartRates = const [],
|
||||
Activity activity = Activity.running,
|
||||
}) => Recording(
|
||||
activity: activity,
|
||||
startedAt: start,
|
||||
fixes: fixes,
|
||||
pauses: pauses,
|
||||
heartRates: heartRates,
|
||||
endedAt: fixes.isEmpty ? at(600) : fixes.last.time,
|
||||
);
|
||||
|
||||
|
|
@ -148,7 +150,7 @@ void main() {
|
|||
|
||||
expect(foot.chart, isNotEmpty);
|
||||
expect(foot.chart.length, lessThanOrEqualTo(101));
|
||||
expect(foot.chart.last.metres, closeTo(2000, 10));
|
||||
expect(foot.chart.last.x, closeTo(2000, 10));
|
||||
for (final point in foot.chart) {
|
||||
// Seconds a kilometre takes.
|
||||
expect(point.value, closeTo(300, 5));
|
||||
|
|
@ -167,4 +169,90 @@ void main() {
|
|||
expect(analysis.chart, isEmpty);
|
||||
expect(analysis.elevationGainMetres, isNull);
|
||||
});
|
||||
|
||||
group('heart rate (D27)', () {
|
||||
HeartRate bpm(int seconds, int bpm) =>
|
||||
HeartRate(time: at(seconds), bpm: bpm);
|
||||
|
||||
test('its average and highest are of the moving time, pauses left out', () {
|
||||
final analysis = analysed(
|
||||
run(
|
||||
[for (var s = 0; s <= 600; s += 10) north(s, s * 10 / 3)],
|
||||
pauses: [
|
||||
PauseMark(time: at(151), auto: true),
|
||||
PauseMark(time: at(269), paused: false, auto: true),
|
||||
],
|
||||
heartRates: [
|
||||
for (var s = 0; s <= 600; s += 10)
|
||||
bpm(s, switch (s) {
|
||||
> 151 && < 269 => 190,
|
||||
< 300 => 140,
|
||||
_ => 160,
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// 19 samples at 140 and 31 at 160; the pause's 190s count for
|
||||
// nothing.
|
||||
expect(analysis.averageBpm, 152);
|
||||
expect(analysis.maxBpm, 160);
|
||||
});
|
||||
|
||||
test('its chart runs over the distance with GPS', () {
|
||||
final analysis = analysed(
|
||||
run(
|
||||
[for (var s = 0; s <= 600; s += 10) north(s, s * 10 / 3)],
|
||||
heartRates: [for (var s = 0; s <= 600; s += 2) bpm(s, 150)],
|
||||
),
|
||||
);
|
||||
|
||||
expect(analysis.heartRateByDistance, isTrue);
|
||||
expect(analysis.heartRate, isNotEmpty);
|
||||
expect(analysis.heartRate.last.x, closeTo(2000, 30));
|
||||
for (final point in analysis.heartRate) {
|
||||
expect(point.value, 150);
|
||||
}
|
||||
});
|
||||
|
||||
test('its chart runs over the moving time without GPS', () {
|
||||
final analysis = analysed(
|
||||
run(
|
||||
const [],
|
||||
activity: Activity.indoorRowing,
|
||||
heartRates: [for (var s = 0; s <= 600; s += 1) bpm(s, 120)],
|
||||
),
|
||||
);
|
||||
|
||||
expect(analysis.heartRateByDistance, isFalse);
|
||||
expect(analysis.heartRate.length, lessThanOrEqualTo(101));
|
||||
expect(analysis.heartRate.last.x, closeTo(600, 10));
|
||||
expect(analysis.averageBpm, 120);
|
||||
});
|
||||
|
||||
test('each split has its own average', () {
|
||||
final analysis = analysed(
|
||||
run(
|
||||
[for (var s = 0; s <= 600; s += 10) north(s, s * 10 / 3)],
|
||||
heartRates: [
|
||||
for (var s = 0; s <= 600; s += 5) bpm(s, s < 300 ? 140 : 160),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
analysis.splits.map((s) => s.averageBpm),
|
||||
[closeTo(140, 1), closeTo(160, 1)],
|
||||
);
|
||||
});
|
||||
|
||||
test('without a strap there is none', () {
|
||||
final analysis = analysed(run([north(0, 0), north(60, 200)]));
|
||||
|
||||
expect(analysis.averageBpm, isNull);
|
||||
expect(analysis.maxBpm, isNull);
|
||||
expect(analysis.heartRate, isEmpty);
|
||||
expect(analysis.splits.single.averageBpm, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class FakeRecorderPlatform({
|
|||
RecorderPermission.location,
|
||||
RecorderPermission.notifications,
|
||||
RecorderPermission.activity,
|
||||
RecorderPermission.bluetooth,
|
||||
},
|
||||
}) implements RecorderPlatform {
|
||||
/// What was asked for, each time.
|
||||
|
|
@ -19,7 +20,22 @@ class FakeRecorderPlatform({
|
|||
|
||||
/// How each recording was started.
|
||||
final started =
|
||||
<({String path, Activity activity, AutoPauseBy? autoPause})>[];
|
||||
<
|
||||
({
|
||||
String path,
|
||||
Activity activity,
|
||||
AutoPauseBy? autoPause,
|
||||
String? strap,
|
||||
})
|
||||
>[];
|
||||
|
||||
/// The straps nearby, seen as soon as a scan starts, and whether
|
||||
/// Bluetooth is on.
|
||||
List<FoundStrap> nearby = [];
|
||||
bool bluetoothOn = true;
|
||||
|
||||
/// How many scans were started.
|
||||
int scans = 0;
|
||||
|
||||
/// The notices shown, the first one the start's.
|
||||
final notices = <RecorderNotice>[];
|
||||
|
|
@ -55,8 +71,14 @@ class FakeRecorderPlatform({
|
|||
required Activity activity,
|
||||
required AutoPauseBy? autoPause,
|
||||
required RecorderNotice notice,
|
||||
String? strap,
|
||||
}) async {
|
||||
started.add((path: path, activity: activity, autoPause: autoPause));
|
||||
started.add((
|
||||
path: path,
|
||||
activity: activity,
|
||||
autoPause: autoPause,
|
||||
strap: strap,
|
||||
));
|
||||
notices.add(notice);
|
||||
_file = File(path)
|
||||
..writeAsStringSync(
|
||||
|
|
@ -102,4 +124,14 @@ class FakeRecorderPlatform({
|
|||
_file!.readAsLinesSync().forEach(controller.add);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<FoundStrap> scanStraps() {
|
||||
scans++;
|
||||
if (!bluetoothOn) {
|
||||
return Stream.error(BluetoothOff());
|
||||
}
|
||||
// Open until cancelled, as a scan is.
|
||||
return Stream.multi((listener) => nearby.forEach(listener.add));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue