feat(train): a ride's watts, cadences and wheel to Health Connect, and auto-pause by the wheel (ADR 0030, D51)

Health Connect gets the power meter's watts, the crank's cadence
(CyclingPedalingCadenceRecord), a run's step cadence (StepsCadenceRecord)
and, where the wheel measured the ride, the wheel's speed. The cadences'
permissions are asked with writing's and each record is left out where
its permission isn't granted; healthRecordsVersion in the fingerprint
writes every workout once more. On a bike, the wheel's speed decides
auto-pause while its sensor sends, GPS's once it has been silent 5 s.

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 17:53:54 +01:00
commit 64366d5420
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 400 additions and 29 deletions

View file

@ -173,6 +173,30 @@ void main() {
expect(platform.writes, writes + 1);
});
test('written by an earlier Train, which wrote less of it, a workout is '
'written again as the app opens (D51)', () async {
await workouts.save(run(0), const []);
final before = open();
await before.setWriting(on: true);
await settled(before);
await before.dispose();
opened.remove(before);
// What Train before D51 kept: another fingerprint for the same workout.
final file = File('${folder.path}/health_connect.json');
file.writeAsStringSync(
file.readAsStringSync().replaceAllMapped(
RegExp(r'"fingerprint":(\d+)'),
(match) => '"fingerprint":${int.parse(match[1]!) ^ 1}',
),
);
final writes = platform.writes;
final after = open();
await settled(after);
expect(platform.writes, writes + 1);
});
test('what was written is remembered when the app opens again', () async {
await workouts.save(run(0), const []);
final before = open();

View file

@ -5,9 +5,9 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workou
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What a workout becomes in Health Connect (ADR 0030, D31): its session
/// with its pauses and route, its heart rate, speed and the rower's watts
/// as samples, its distance and elevation gained as totals, each record's
/// What a workout becomes in Health Connect (ADR 0030, D31, D51): its
/// session with its pauses and route, its heart rate, speed, watts and
/// cadences as samples, its distance and elevation gained as totals, each record's
/// client ID made from the workout's.
void main() {
final start = DateTime.utc(2026, 10, 28, 7, 30);
@ -192,4 +192,62 @@ void main() {
HealthRecordKind.distance: ['w1/distance'],
});
});
test("a ride's power meter, crank and wheel: watts, pedalling, and the "
"wheel's speed where GPS stood still (D51)", () {
final trainer = Recording(
activity: Activity.cycling,
startedAt: start,
fixes: [for (var s = 0; s <= 60; s += 10) north(s, 0)],
wheel: [for (var s = 0; s <= 60; s++) Reading(time: at(s), value: s * 4)],
wheelMm: 2100,
pedalling: [
for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 90),
],
power: [for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 210)],
endedAt: at(60),
);
final records = recordsOf(trainer);
final power = records.whereType<HealthSamples>().singleWhere(
(r) => r.kind == HealthRecordKind.power,
);
expect(power.samples.first.$2, 210);
expect(power.clientId, 'w1/power/0');
final pedalling = records.whereType<HealthSamples>().singleWhere(
(r) => r.kind == HealthRecordKind.pedalling,
);
expect(pedalling.samples, hasLength(61));
expect(pedalling.samples.first.$2, 90);
final speed = records.whereType<HealthSamples>().singleWhere(
(r) => r.kind == HealthRecordKind.speed,
);
// Four turns of 2.1 m a second.
expect(speed.samples, hasLength(12));
for (final (_, metresPerSecond) in speed.samples) {
expect(metresPerSecond, closeTo(8.4, 0.01));
}
});
test("a run's steps go as its cadence (D51)", () {
final runner = Recording(
activity: Activity.running,
startedAt: start,
fixes: [for (var s = 0; s <= 60; s += 10) north(s, s * 3)],
steps: [
for (var i = 0; i < 170; i++)
at(0).add(Duration(milliseconds: i * 353)),
],
endedAt: at(60),
);
final cadence = recordsOf(runner)
.whereType<HealthSamples>()
.singleWhere((r) => r.kind == HealthRecordKind.stepsCadence);
expect(cadence.samples, isNotEmpty);
for (final (_, spm) in cadence.samples) {
expect(spm, closeTo(170, 6));
}
expect(cadence.clientId, 'w1/stepsCadence/0');
});
}