feat(train): workouts written to Health Connect (ADR 0030, D31)

A Health Connect row in the account sheet opens its screen: Write my
workouts, off until turned on, Health Connect asking as it is. Then
every workout this phone holds is written there (its session with its
pauses and route, heart rate, speed and the rower's watts as samples,
distance and elevation gained), and written again or deleted as it
changes, here or through a sync, and as the app opens. Client IDs come
from the workout's ID, a version counts up with each change; what was
written is kept in the person's health_connect.json, never synced. A
permission taken back in Health Connect turns writing off and says so;
Health Connect's link to Train's rationale opens a screen of its own.

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-28 17:06:31 +01:00
commit 09f61c3b0a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 1513 additions and 8 deletions

View file

@ -0,0 +1,198 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.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 '../../support/fake_health_platform.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_workout_gateway.dart';
/// Train's workouts written to Health Connect (ADR 0030, D31): off until the
/// person turns it on, then every workout this phone holds, each written
/// again as it changes and deleted with it, what was written remembered in
/// the person's folder.
void main() {
final start = DateTime.utc(2026, 10, 28, 7, 30);
late Directory folder;
late TrainKeys keys;
late WorkoutRepository workouts;
late FakeHealthPlatform platform;
final opened = <HealthConnect>[];
WorkoutContent run(int day, {String? name}) => WorkoutContent(
activity: Activity.running,
startedAt: start.add(Duration(days: day)),
endedAt: start.add(Duration(days: day, minutes: 30)),
name: name,
distanceMetres: 5000,
);
final heartRate = chunksOf(
SeriesKind.heartRate,
start: start,
times: [0, 1000],
columns: {
'bpm': [120, 121],
},
);
HealthConnect open() {
final health = HealthConnect(
platform,
HealthStore(File('${folder.path}/health_connect.json')),
workouts,
)..start();
opened.add(health);
return health;
}
/// Once the workouts' changes reached it and nothing is being written.
Future<void> settled(HealthConnect health) async {
await pumpEventQueue();
await health.idle;
}
HealthSession? sessionOf(String id) => platform.written[id] as HealthSession?;
setUp(() {
folder = Directory.systemTemp.createTempSync('health');
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
platform = FakeHealthPlatform();
});
tearDown(() async {
for (final health in opened) {
await health.dispose();
}
opened.clear();
await workouts.dispose();
keys.dispose();
folder.deleteSync(recursive: true);
});
test('nothing is written until the person turns it on', () async {
await workouts.save(run(0), heartRate);
final health = open();
await settled(health);
expect(health.settings.writes, isFalse);
expect(platform.written, isEmpty);
expect(platform.asked, isEmpty);
});
test('turned on, it asks to write, then writes every workout, earlier '
'ones too', () async {
final first = await workouts.save(run(0, name: 'First'), heartRate);
final second = await workouts.save(run(1), const []);
final health = open();
expect(await health.setWriting(on: true), isTrue);
await settled(health);
expect(platform.asked, [
{HealthAccess.write},
]);
expect(health.settings.writes, isTrue);
expect(sessionOf(first)?.title, 'First');
expect(sessionOf(second), isNotNull);
expect(platform.written, contains('$first/heartRate/0'));
});
test('refused, it stays off and writes nothing', () async {
platform = FakeHealthPlatform(grants: {});
await workouts.save(run(0), const []);
final health = open();
expect(await health.setWriting(on: true), isFalse);
await settled(health);
expect(health.settings.writes, isFalse);
expect(platform.written, isEmpty);
});
test('a workout saved, edited or deleted is written so', () async {
final health = open();
await health.setWriting(on: true);
await settled(health);
final id = await workouts.save(run(0), heartRate);
await settled(health);
expect(sessionOf(id)?.version, 1);
await workouts.update(id, run(0).withText(name: 'Renamed'));
await settled(health);
expect(sessionOf(id)?.title, 'Renamed');
expect(sessionOf(id)?.version, 2);
await workouts.delete(id);
await settled(health);
expect(platform.written, isEmpty);
});
test('a workout written as it is now is not written again', () async {
await workouts.save(run(0), const []);
final health = open();
await health.setWriting(on: true);
await settled(health);
final writes = platform.writes;
await workouts.save(run(1), const []);
await settled(health);
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();
await before.setWriting(on: true);
await settled(before);
await before.dispose();
opened.remove(before);
final writes = platform.writes;
final after = open();
await settled(after);
expect(after.settings.writes, isTrue);
expect(platform.writes, writes);
});
test('taken back in Health Connect, writing turns itself off', () async {
final health = open();
await health.setWriting(on: true);
await settled(health);
platform.grantedNow.clear();
await workouts.save(run(0), const []);
await settled(health);
expect(health.settings.writes, isFalse);
expect(health.settings.writeRefused, isTrue);
});
test('turned off, nothing more is written, what was stays', () async {
final id = await workouts.save(run(0), const []);
final health = open();
await health.setWriting(on: true);
await settled(health);
await health.setWriting(on: false);
await workouts.save(run(1), const []);
await settled(health);
expect(platform.written.keys, [id, '$id/distance']);
});
}

View file

@ -0,0 +1,195 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_records.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
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
/// client ID made from the workout's.
void main() {
final start = DateTime.utc(2026, 10, 28, 7, 30);
DateTime at(int seconds) => start.add(Duration(seconds: seconds));
/// A fix [metres] north of where the equator crosses Greenwich.
Fix north(int seconds, num metres, {int? altitudeCm}) => Fix(
time: at(seconds),
latE7: (metres * 89.932).round(),
lonE7: 0,
accuracyCm: 500,
altitudeCm: altitudeCm,
);
List<HealthRecord> recordsOf(Recording recording, {int version = 1}) {
final (content, series) = recordedWorkout(recording);
return healthRecordsOf(
'w1',
content.withText(name: 'Morning run', notes: 'Easy'),
series,
version: version,
);
}
T only<T extends HealthRecord>(List<HealthRecord> records) =>
records.whereType<T>().single;
test('a run is a session with its pauses and route, and its distance', () {
final records = recordsOf(
Recording(
activity: Activity.running,
startedAt: start,
fixes: [for (var s = 0; s <= 120; s += 10) north(s, s * 3)],
pauses: [
PauseMark(time: at(40), auto: true),
PauseMark(time: at(50), paused: false, auto: true),
// Paused as it ended: the pause runs to the end.
PauseMark(time: at(110)),
],
endedAt: at(120),
),
version: 4,
);
final session = only<HealthSession>(records);
expect(session.clientId, 'w1');
expect(session.version, 4);
expect(session.activity, Activity.running);
expect(session.title, 'Morning run');
expect(session.notes, 'Easy');
expect((session.start, session.end), (at(0), at(120)));
expect(session.pauses, [(at(40), at(50)), (at(110), at(120))]);
expect(session.route.map((fix) => fix.time), contains(at(0)));
final distance = records.whereType<HealthTotal>().singleWhere(
(total) => total.kind == HealthRecordKind.distance,
);
expect(distance.clientId, 'w1/distance');
// The saved distance, as the page shows it: the paused stretches left
// out.
expect(distance.metres, 240);
expect(records.every((record) => record.version == 4), isTrue);
});
test('its speed is a sample at each fix after the first of a piece', () {
final records = recordsOf(
Recording(
activity: Activity.running,
startedAt: start,
fixes: [for (var s = 0; s <= 60; s += 10) north(s, s * 3)],
endedAt: at(60),
),
);
final speed = only<HealthSamples>(records);
expect(speed.kind, HealthRecordKind.speed);
expect(speed.clientId, 'w1/speed/0');
expect(speed.samples.first.$1, at(10));
expect(
speed.samples.map((sample) => sample.$2),
everyElement(closeTo(3, 0.05)),
);
});
test('the heart rate goes in records of ten minutes at most', () {
final records = recordsOf(
Recording(
activity: Activity.running,
startedAt: start,
heartRates: [
for (var s = 0; s < 1500; s += 60) HeartRate(time: at(s), bpm: 140),
],
endedAt: at(1500),
),
);
final hearts = [
for (final record in records.whereType<HealthSamples>())
if (record.kind == HealthRecordKind.heartRate) record,
];
expect(hearts.map((record) => record.clientId), [
'w1/heartRate/0',
'w1/heartRate/1',
'w1/heartRate/2',
]);
expect(hearts.first.samples, hasLength(10));
expect((hearts.last.start, hearts.last.end), (at(1200), at(1440)));
});
test('a climb is its elevation gained', () {
final records = recordsOf(
Recording(
activity: Activity.hiking,
startedAt: start,
fixes: [
for (var s = 0; s <= 600; s += 10)
north(s, s, altitudeCm: s < 300 ? 10000 : 15000),
],
endedAt: at(600),
),
);
final elevation = records.whereType<HealthTotal>().singleWhere(
(total) => total.kind == HealthRecordKind.elevation,
);
expect(elevation.clientId, 'w1/elevation');
expect(elevation.metres, 50);
});
test('a row has no route, its power as samples', () {
final records = recordsOf(
Recording(
activity: Activity.indoorRowing,
startedAt: start,
rowing: [
for (var s = 0; s <= 60; s += 2)
RowingSample(
time: at(s),
strokeRate: 24,
strokes: s ~/ 2,
metres: s * 4,
watts: s == 0 ? null : 150,
),
],
endedAt: at(60),
),
);
expect(only<HealthSession>(records).route, isEmpty);
final power = only<HealthSamples>(records);
expect(power.kind, HealthRecordKind.power);
expect(power.samples, hasLength(30));
expect(power.samples.first, (at(2), 150));
});
test('a workout without samples is its session alone', () {
final records = recordsOf(
Recording(
activity: Activity.indoorRowing,
startedAt: start,
endedAt: at(60),
),
);
expect(records, [isA<HealthSession>()]);
});
test('client IDs are by kind, so a deletion finds them all', () {
final records = recordsOf(
Recording(
activity: Activity.running,
startedAt: start,
fixes: [for (var s = 0; s <= 60; s += 10) north(s, s * 3)],
heartRates: [HeartRate(time: at(5), bpm: 120)],
endedAt: at(60),
),
);
expect(clientIdsOf(records), {
HealthRecordKind.session: ['w1'],
HealthRecordKind.heartRate: ['w1/heartRate/0'],
HealthRecordKind.speed: ['w1/speed/0'],
HealthRecordKind.distance: ['w1/distance'],
});
});
}