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
3 changed files with 1513 additions and 8 deletions

View file

@ -9,6 +9,9 @@ import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/app/train_app.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.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/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
@ -19,6 +22,7 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../support/fake_health_platform.dart';
import '../support/fake_recorder_platform.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_workout_gateway.dart';
@ -49,6 +53,7 @@ void main() {
RecordingDevice? recording,
WorkoutRepository? workouts,
bool mapReachable = true,
HealthConnect? health,
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
@ -118,6 +123,7 @@ void main() {
),
),
recording: recording,
health: health,
maps: MapSourceRepository(
_FakeMapSourceGateway(mapReachable ? openFreeMap : null),
),
@ -295,6 +301,134 @@ void main() {
);
});
testWidgets('without Health Connect, the account sheet offers none', (
tester,
) async {
await start(tester, usedBefore: true);
await openAccount(tester);
expect(find.text('Health Connect'), findsNothing);
});
group('Health Connect (ADR 0030, D31)', () {
final at = DateTime.utc(2026, 10, 28, 7, 30);
late Directory folder;
late FakeHealthPlatform platform;
late WorkoutRepository workouts;
late TrainKeys keys;
setUp(() {
folder = Directory.systemTemp.createTempSync('train');
platform = FakeHealthPlatform();
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
});
tearDown(() {
keys.dispose();
folder.deleteSync(recursive: true);
});
/// Train signed in on a phone with [platform], its screen open.
Future<void> openHealth(WidgetTester tester) async {
await start(
tester,
usedBefore: true,
workouts: workouts,
health: HealthConnect(
platform,
HealthStore(File('${folder.path}/health_connect.json')),
workouts,
)..start(),
);
await openAccount(tester);
await tester.tap(find.text('Health Connect'));
await tester.pumpAndSettle();
}
Finder writeSwitch() =>
find.widgetWithText(SwitchListTile, 'Write my workouts');
testWidgets('writing is off until turned on; then Health Connect asks, '
'and every workout is written', (tester) async {
final id = await tester.runAsync(
() => workouts.save(
WorkoutContent(
activity: Activity.cycling,
startedAt: at,
endedAt: at.add(const Duration(hours: 1)),
distanceMetres: 24000,
),
const [],
),
);
await openHealth(tester);
expect(tester.widget<SwitchListTile>(writeSwitch()).value, isFalse);
expect(platform.written, isEmpty);
await tester.tap(writeSwitch());
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(platform.asked, [
{HealthAccess.write},
]);
expect(tester.widget<SwitchListTile>(writeSwitch()).value, isTrue);
expect(platform.written, contains(id));
});
testWidgets('refused, the switch stays off and says where to allow it', (
tester,
) async {
platform = FakeHealthPlatform(grants: {});
await openHealth(tester);
await tester.tap(writeSwitch());
await tester.pumpAndSettle();
expect(tester.widget<SwitchListTile>(writeSwitch()).value, isFalse);
expect(
find.text(
"Health Connect didn't allow it. You can allow it in its settings.",
),
findsOneWidget,
);
await tester.tap(find.text('Open Health Connect').last);
expect(platform.settingsOpened, 1);
});
testWidgets('a phone without it says what it needs', (tester) async {
platform = FakeHealthPlatform(available: HealthAvailability.unavailable);
await openHealth(tester);
expect(
find.textContaining("Health Connect isn't on this phone"),
findsOneWidget,
);
expect(writeSwitch(), findsNothing);
});
testWidgets("Health Connect's link to Train's rationale opens it", (
tester,
) async {
await openHealth(tester);
platform.askForRationale();
await tester.pumpAndSettle();
expect(find.text('Train and Health Connect'), findsOneWidget);
expect(
find.textContaining('Train writes your workouts to Health Connect'),
findsOneWidget,
);
});
});
group('recording (ADR 0030, D21)', () {
final at = DateTime.utc(2026, 10, 28, 7, 30);
late Directory folder;

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'],
});
});
}