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:
parent
030cb9b976
commit
09f61c3b0a
1 changed files with 1513 additions and 8 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue