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

View file

@ -6,5 +6,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, and `deleteTrainData` (`lib/src/train_data.dart`) as its `deletePersonData` hook, which deletes the person's sync spaces and so, by the sync engine's cascades, every row of theirs (`test/integration/workout/person_deletion_test.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Train holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-28): an initial one with Serverpod's tables, its auth modules' and the module's as they stand (Todo's grew one by one with the module; Train starts where they ended), then `workouts`, purely additive: the sync engine's tables and Train's first three. **Synced models** (`database: sync`, the module `offline_sync`, `experimental_features: databaseSync` in `config/generator.yaml`): `content_key` (the person's key sealed to the account), `workout` and `workout_series` (a workout's series in chunks of at most ten minutes, deleted with it), each a sealed `payload` with its `keyId` and `nonce`, nothing else readable. Tests (`test/integration/train_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone. - **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, and `deleteTrainData` (`lib/src/train_data.dart`) as its `deletePersonData` hook, which deletes the person's sync spaces and so, by the sync engine's cascades, every row of theirs (`test/integration/workout/person_deletion_test.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Train holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-28): an initial one with Serverpod's tables, its auth modules' and the module's as they stand (Todo's grew one by one with the module; Train starts where they ended), then `workouts`, purely additive: the sync engine's tables and Train's first three. **Synced models** (`database: sync`, the module `offline_sync`, `experimental_features: databaseSync` in `config/generator.yaml`): `content_key` (the person's key sealed to the account), `workout` and `workout_series` (a workout's series in chunks of at most ten minutes, deleted with it), each a sealed `payload` with its `keyId` and `nonce`, nothing else readable. Tests (`test/integration/train_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app whose home lists their workouts (`WorkoutsCubit`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`). - **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app whose home lists their workouts (`WorkoutsCubit`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
- **Recording** (ADR 0030, D13, D16, D18 to D21, D26 to D28) is Android's alone for now (iOS waits for a Mac, D25) (`TrainPerson.recording` null elsewhere): `RecorderService` (Kotlin, `android/app/src/main/kotlin/app/halkyon/train/`), a foreground service of the types `location` and `health` started from the open app, takes fixes from LocationManager's GPS (no Play services), steps from the step detector the heart rate from the strap and a row from the rower the person chose (ADR 0030, D26 to D28: `NotifyLink.kt` on Android's own Bluetooth API, no package, `HeartRateMeasurement.kt` parsing 0x2A37 and `RowerData.kt` FTMS's Rower Data 0x2AD1; `DeviceScanner.kt` finds them for the sheets over `…/recorder/straps` and `…/recorder/rowers`; each remembered in the person's `strap.json` and `rower.json`, `DeviceStore`), decides auto-pause (`AutoPause.kt`) and appends each sample as a line to the person's `recording.txt` (format in `recording_file.dart`), flushed every 5 s; `MainActivity` asks the permissions (never background location) and bridges `app.halkyon.train/recorder` (methods) and `…/recorder/lines` (the file so far, then each line). The app (`lib/src/feature/recording/`) follows those lines with `RecordingBloc`, works out what the screen and the notification show (`liveOf`, the saved totals' functions), and saves a recording as a workout and its series (`recordedWorkout`) or throws it away; one cut off, or stopped and not saved, is found as the person's app opens. Tests: the Dart side with `test/support/fake_recorder_platform.dart`; the Kotlin auto-pause and the strap's and the rower's parsers on the JVM, `JAVA_HOME=<Android Studio's jbr> ./gradlew :app:testDebugUnitTest` in `android/` after a first `flutter build apk` (which writes `gradlew`), not part of `tool/check.dart`, as CI's runner has no Android SDK. - **Recording** (ADR 0030, D13, D16, D18 to D21, D26 to D28) is Android's alone for now (iOS waits for a Mac, D25) (`TrainPerson.recording` null elsewhere): `RecorderService` (Kotlin, `android/app/src/main/kotlin/app/halkyon/train/`), a foreground service of the types `location` and `health` started from the open app, takes fixes from LocationManager's GPS (no Play services), steps from the step detector the heart rate from the strap and a row from the rower the person chose (ADR 0030, D26 to D28: `NotifyLink.kt` on Android's own Bluetooth API, no package, `HeartRateMeasurement.kt` parsing 0x2A37 and `RowerData.kt` FTMS's Rower Data 0x2AD1; `DeviceScanner.kt` finds them for the sheets over `…/recorder/straps` and `…/recorder/rowers`; each remembered in the person's `strap.json` and `rower.json`, `DeviceStore`), decides auto-pause (`AutoPause.kt`) and appends each sample as a line to the person's `recording.txt` (format in `recording_file.dart`), flushed every 5 s; `MainActivity` asks the permissions (never background location) and bridges `app.halkyon.train/recorder` (methods) and `…/recorder/lines` (the file so far, then each line). The app (`lib/src/feature/recording/`) follows those lines with `RecordingBloc`, works out what the screen and the notification show (`liveOf`, the saved totals' functions), and saves a recording as a workout and its series (`recordedWorkout`) or throws it away; one cut off, or stopped and not saved, is found as the person's app opens. Tests: the Dart side with `test/support/fake_recorder_platform.dart`; the Kotlin auto-pause and the strap's and the rower's parsers on the JVM, `JAVA_HOME=<Android Studio's jbr> ./gradlew :app:testDebugUnitTest` in `android/` after a first `flutter build apk` (which writes `gradlew`), not part of `tool/check.dart`, as CI's runner has no Android SDK.
- **Health Connect** (ADR 0030, D30, D31) is Android's (`TrainPerson.health` null elsewhere): `HealthConnectBridge.kt` on AndroidX's `connect-client` 1.1.0 over `app.halkyon.train/health` (its records' mapping in `HealthRecords.kt`, tested on the JVM; the rationale's links from Health Connect in the manifest, `…/health/rationale` to the app), `HealthPlatform` in Dart (`lib/src/feature/health/`), `HealthConnect` per person: with writing on, every workout written as `healthRecordsOf` makes it (client IDs from the workout's ID, a version counted up as it changes, a fingerprint deciding whether it did), written again or deleted as the workouts change and as the app opens, what was written kept in the person's `health_connect.json` (`HealthStore`, never synced); the screen from the account sheet (`HealthScreen`, `HealthCubit`). Tests on `test/support/fake_health_platform.dart`. Train needs Android 8 (API 26) for it.
- **The icon** is Lucide's `bike` fading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py train` from the root): `assets/icon/halkyon_train_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts Lucide's notice in the licence list. - **The icon** is Lucide's `bike` fading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py train` from the root): `assets/icon/halkyon_train_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts Lucide's notice in the licence list.
- **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Train's development server has ports of its own (API 8180, Insights 8181, web 8182, PostgreSQL 8190; the app's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time. - **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Train's development server has ports of its own (API 8180, Insights 8181, web 8182, PostgreSQL 8190; the app's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time.

View file

@ -129,5 +129,20 @@
"workoutStrokeRateChart": "Schlagzahl", "workoutStrokeRateChart": "Schlagzahl",
"workoutPowerChart": "Leistung", "workoutPowerChart": "Leistung",
"recordAutoPauseRowingHint": "Wenn du aufhörst zu rudern, weiter mit dem nächsten Schlag", "recordAutoPauseRowingHint": "Wenn du aufhörst zu rudern, weiter mit dem nächsten Schlag",
"recordingMetres": "Meter" "recordingMetres": "Meter",
"healthTitle": "Health Connect",
"healthHint": "Trainings mit anderen Apps auf diesem Telefon teilen",
"healthUnavailable": "Health Connect ist auf diesem Telefon nicht da. Ab Android 14 ist es eingebaut, davor eine eigene App.",
"healthNeedsUpdate": "Aktualisiere die App Health Connect, um sie mit Train zu nutzen.",
"healthWrite": "Meine Trainings schreiben",
"healthWriteHint": "Jedes Training auf diesem Telefon, auf dem neuesten Stand, wenn du es bearbeitest oder löschst",
"healthWriteRefused": "Health Connect hat das zurückgenommen. Erlaube es dort, um es wieder einzuschalten.",
"healthRefused": "Health Connect hat es nicht erlaubt. Du kannst es in seinen Einstellungen erlauben.",
"healthOpenSettings": "Health Connect öffnen",
"healthWhy": "Warum Train fragt",
"healthRationaleTitle": "Train und Health Connect",
"healthRationaleWrite": "Train schreibt deine Trainings erst in Health Connect, wenn du es einschaltest: jedes mit Strecke, Herzfrequenz, Tempo, Leistung, Distanz und Anstieg, damit andere Apps auf diesem Telefon sie nutzen können. Bearbeitest oder löschst du ein Training in Train, geschieht das dort auch.",
"healthRationaleRead": "Train liest die Trainings anderer Apps erst, wenn du es einschaltest, und nur von den Apps, die du wählst: Ihre Herzfrequenz, Distanz und Leistung kommen als Trainings in Train, mit deinem Schlüssel versiegelt, bevor sie mit deiner Instanz abgeglichen werden, die sie nicht lesen kann.",
"healthRationaleNothingElse": "Was Train dort liest, wird dir gezeigt und niemandem sonst, und für nichts anderes genutzt. Schaltest du einen Schalter aus oder nimmst eine Erlaubnis in Health Connect zurück, hört es auf."
} }

View file

@ -174,5 +174,21 @@
"workoutStrokeRateChart": "Stroke rate", "workoutStrokeRateChart": "Stroke rate",
"workoutPowerChart": "Power", "workoutPowerChart": "Power",
"recordAutoPauseRowingHint": "When you stop rowing, going on with the next stroke", "recordAutoPauseRowingHint": "When you stop rowing, going on with the next stroke",
"recordingMetres": "metres" "recordingMetres": "metres",
"healthTitle": "Health Connect",
"healthHint": "Share workouts with other apps on this phone",
"@healthHint": {"description": "Under Health Connect in the account sheet."},
"healthUnavailable": "Health Connect isn't on this phone. Android 14 and later have it built in; before 14, it's an app of its own.",
"healthNeedsUpdate": "Update the Health Connect app to use it with Train.",
"healthWrite": "Write my workouts",
"healthWriteHint": "Every workout on this phone, kept up to date as you edit or delete it",
"healthWriteRefused": "Health Connect took this back. Allow it there to turn it on again.",
"healthRefused": "Health Connect didn't allow it. You can allow it in its settings.",
"healthOpenSettings": "Open Health Connect",
"healthWhy": "Why Train asks",
"healthRationaleTitle": "Train and Health Connect",
"healthRationaleWrite": "Train writes your workouts to Health Connect only once you turn it on: each one with its route, heart rate, speed, power, distance and climb, so that other apps on this phone can use them. A workout edited or deleted in Train is edited or deleted there too.",
"healthRationaleRead": "Train reads other apps' workouts only once you turn it on, and only from the apps you choose: their heart rate, distance and power come into Train as workouts, sealed with your key before they're synced to your instance, which can't read them.",
"healthRationaleNothingElse": "What Train reads there is shown to you and nobody else, and isn't used for anything else. Turning a switch off, or taking a permission back in Health Connect, stops it."
} }

View file

@ -825,6 +825,90 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'metres'** /// **'metres'**
String get recordingMetres; String get recordingMetres;
/// No description provided for @healthTitle.
///
/// In en, this message translates to:
/// **'Health Connect'**
String get healthTitle;
/// Under Health Connect in the account sheet.
///
/// In en, this message translates to:
/// **'Share workouts with other apps on this phone'**
String get healthHint;
/// No description provided for @healthUnavailable.
///
/// In en, this message translates to:
/// **'Health Connect isn\'t on this phone. Android 14 and later have it built in; before 14, it\'s an app of its own.'**
String get healthUnavailable;
/// No description provided for @healthNeedsUpdate.
///
/// In en, this message translates to:
/// **'Update the Health Connect app to use it with Train.'**
String get healthNeedsUpdate;
/// No description provided for @healthWrite.
///
/// In en, this message translates to:
/// **'Write my workouts'**
String get healthWrite;
/// No description provided for @healthWriteHint.
///
/// In en, this message translates to:
/// **'Every workout on this phone, kept up to date as you edit or delete it'**
String get healthWriteHint;
/// No description provided for @healthWriteRefused.
///
/// In en, this message translates to:
/// **'Health Connect took this back. Allow it there to turn it on again.'**
String get healthWriteRefused;
/// No description provided for @healthRefused.
///
/// In en, this message translates to:
/// **'Health Connect didn\'t allow it. You can allow it in its settings.'**
String get healthRefused;
/// No description provided for @healthOpenSettings.
///
/// In en, this message translates to:
/// **'Open Health Connect'**
String get healthOpenSettings;
/// No description provided for @healthWhy.
///
/// In en, this message translates to:
/// **'Why Train asks'**
String get healthWhy;
/// No description provided for @healthRationaleTitle.
///
/// In en, this message translates to:
/// **'Train and Health Connect'**
String get healthRationaleTitle;
/// No description provided for @healthRationaleWrite.
///
/// In en, this message translates to:
/// **'Train writes your workouts to Health Connect only once you turn it on: each one with its route, heart rate, speed, power, distance and climb, so that other apps on this phone can use them. A workout edited or deleted in Train is edited or deleted there too.'**
String get healthRationaleWrite;
/// No description provided for @healthRationaleRead.
///
/// In en, this message translates to:
/// **'Train reads other apps\' workouts only once you turn it on, and only from the apps you choose: their heart rate, distance and power come into Train as workouts, sealed with your key before they\'re synced to your instance, which can\'t read them.'**
String get healthRationaleRead;
/// No description provided for @healthRationaleNothingElse.
///
/// In en, this message translates to:
/// **'What Train reads there is shown to you and nobody else, and isn\'t used for anything else. Turning a switch off, or taking a permission back in Health Connect, stops it.'**
String get healthRationaleNothingElse;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -450,4 +450,55 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get recordingMetres => 'Meter'; String get recordingMetres => 'Meter';
@override
String get healthTitle => 'Health Connect';
@override
String get healthHint =>
'Trainings mit anderen Apps auf diesem Telefon teilen';
@override
String get healthUnavailable =>
'Health Connect ist auf diesem Telefon nicht da. Ab Android 14 ist es eingebaut, davor eine eigene App.';
@override
String get healthNeedsUpdate =>
'Aktualisiere die App Health Connect, um sie mit Train zu nutzen.';
@override
String get healthWrite => 'Meine Trainings schreiben';
@override
String get healthWriteHint =>
'Jedes Training auf diesem Telefon, auf dem neuesten Stand, wenn du es bearbeitest oder löschst';
@override
String get healthWriteRefused =>
'Health Connect hat das zurückgenommen. Erlaube es dort, um es wieder einzuschalten.';
@override
String get healthRefused =>
'Health Connect hat es nicht erlaubt. Du kannst es in seinen Einstellungen erlauben.';
@override
String get healthOpenSettings => 'Health Connect öffnen';
@override
String get healthWhy => 'Warum Train fragt';
@override
String get healthRationaleTitle => 'Train und Health Connect';
@override
String get healthRationaleWrite =>
'Train schreibt deine Trainings erst in Health Connect, wenn du es einschaltest: jedes mit Strecke, Herzfrequenz, Tempo, Leistung, Distanz und Anstieg, damit andere Apps auf diesem Telefon sie nutzen können. Bearbeitest oder löschst du ein Training in Train, geschieht das dort auch.';
@override
String get healthRationaleRead =>
'Train liest die Trainings anderer Apps erst, wenn du es einschaltest, und nur von den Apps, die du wählst: Ihre Herzfrequenz, Distanz und Leistung kommen als Trainings in Train, mit deinem Schlüssel versiegelt, bevor sie mit deiner Instanz abgeglichen werden, die sie nicht lesen kann.';
@override
String get healthRationaleNothingElse =>
'Was Train dort liest, wird dir gezeigt und niemandem sonst, und für nichts anderes genutzt. Schaltest du einen Schalter aus oder nimmst eine Erlaubnis in Health Connect zurück, hört es auf.';
} }

View file

@ -447,6 +447,56 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get recordingMetres => 'metres'; String get recordingMetres => 'metres';
@override
String get healthTitle => 'Health Connect';
@override
String get healthHint => 'Share workouts with other apps on this phone';
@override
String get healthUnavailable =>
'Health Connect isn\'t on this phone. Android 14 and later have it built in; before 14, it\'s an app of its own.';
@override
String get healthNeedsUpdate =>
'Update the Health Connect app to use it with Train.';
@override
String get healthWrite => 'Write my workouts';
@override
String get healthWriteHint =>
'Every workout on this phone, kept up to date as you edit or delete it';
@override
String get healthWriteRefused =>
'Health Connect took this back. Allow it there to turn it on again.';
@override
String get healthRefused =>
'Health Connect didn\'t allow it. You can allow it in its settings.';
@override
String get healthOpenSettings => 'Open Health Connect';
@override
String get healthWhy => 'Why Train asks';
@override
String get healthRationaleTitle => 'Train and Health Connect';
@override
String get healthRationaleWrite =>
'Train writes your workouts to Health Connect only once you turn it on: each one with its route, heart rate, speed, power, distance and climb, so that other apps on this phone can use them. A workout edited or deleted in Train is edited or deleted there too.';
@override
String get healthRationaleRead =>
'Train reads other apps\' workouts only once you turn it on, and only from the apps you choose: their heart rate, distance and power come into Train as workouts, sealed with your key before they\'re synced to your instance, which can\'t read them.';
@override
String get healthRationaleNothingElse =>
'What Train reads there is shown to you and nobody else, and isn\'t used for anything else. Turning a switch off, or taking a permission back in Health Connect, stops it.';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -1,6 +1,8 @@
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart'; import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_frame.dart'; import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -41,6 +43,15 @@ class const _AccountSheet() extends StatelessWidget {
children: [SimpleLoginSection(), AccountSecuritySection()], children: [SimpleLoginSection(), AccountSecuritySection()],
), ),
), ),
// Health Connect on this phone (ADR 0030, D31), here until Train
// has settings.
if (PersonScope.of<TrainPerson>(context).health != null)
ListTile(
leading: const Icon(LucideIcons.heartPulse),
title: Text(l10n.healthTitle),
subtitle: Text(l10n.healthHint),
onTap: () => openHealthScreen(context),
),
ListTile( ListTile(
leading: const Icon(LucideIcons.info), leading: const Icon(LucideIcons.info),
title: Text(l10n.aboutTitle), title: Text(l10n.aboutTitle),

View file

@ -4,6 +4,7 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart'; import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_frame.dart'; import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/app/train_languages.dart'; import 'package:halkyon_train_flutter/src/app/train_languages.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart'; import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart'; import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart'; import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
@ -87,7 +88,8 @@ class const TrainPersonApp({
child: _Recording(child: child!), child: _Recording(child: child!),
), ),
), ),
home: const HomeScreen(), // Health Connect links to Train's rationale from its own screens (D31).
home: const HealthRationaleListener(child: HomeScreen()),
); );
} }

View file

@ -8,6 +8,9 @@ import 'package:halkyon_train_client/halkyon_train_client.dart'
show Client, UuidValue; show Client, UuidValue;
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart'; import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart'; import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/android_health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.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/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart'; import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
@ -53,6 +56,10 @@ class TrainPerson(
/// Where this device records; null where it doesn't (iOS until 5d, the /// Where this device records; null where it doesn't (iOS until 5d, the
/// desktop, which shows and plans, D17). /// desktop, which shows and plans, D17).
final RecordingDevice? recording, final RecordingDevice? recording,
/// Health Connect on this phone (D31); null where there is none (iOS
/// until Apple Health, the desktop).
final HealthConnect? health,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
}) implements Disposable { }) implements Disposable {
/// Workouts deleted, to be undone for a moment (D24); dropped with the /// Workouts deleted, to be undone for a moment (D24); dropped with the
@ -63,6 +70,7 @@ class TrainPerson(
@override @override
Future<void> dispose() async { Future<void> dispose() async {
await deletions.dispose(); await deletions.dispose();
await health?.dispose();
await _onDispose?.call(); await _onDispose?.call();
} }
} }
@ -292,6 +300,14 @@ Future<TrainPerson> _openPersonData(
); );
await keys.refresh(); await keys.refresh();
final workouts = WorkoutRepository(SyncWorkoutGateway(db, sync), keys); final workouts = WorkoutRepository(SyncWorkoutGateway(db, sync), keys);
// Writes what changed while the app was closed, as it opens (D31).
final health = Platform.isAndroid
? (HealthConnect(
AndroidHealthPlatform(),
HealthStore(File(p.join(folder.path, 'health_connect.json'))),
workouts,
)..start())
: null;
return TrainPerson( return TrainPerson(
person, person,
workouts: workouts, workouts: workouts,
@ -304,6 +320,7 @@ Future<TrainPerson> _openPersonData(
rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))), rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))),
) )
: null, : null,
health: health,
onDispose: () async { onDispose: () async {
try { try {
await workouts.dispose(); await workouts.dispose();

View file

@ -0,0 +1,71 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.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';
/// What the Health Connect screen shows (ADR 0030, D31).
final class const HealthState({
/// Whether this phone has it; null until asked.
final HealthAvailability? availability,
final HealthSettings settings = const HealthSettings(),
/// Whether Health Connect is asking the person now.
final bool asking = false,
/// Whether the person refused what was just asked for.
final bool refused = false,
}) with Equatable {
HealthState copyWith({
HealthAvailability? availability,
HealthSettings? settings,
bool? asking,
bool? refused,
}) => HealthState(
availability: availability ?? this.availability,
settings: settings ?? this.settings,
asking: asking ?? this.asking,
refused: refused ?? this.refused,
);
@override
List<Object?> get props => [availability, settings, asking, refused];
}
/// The Health Connect screen's switches.
class HealthCubit(final HealthConnect _health) extends Cubit<HealthState> {
this : super(HealthState(settings: _health.settings));
StreamSubscription<HealthSettings>? _changes;
/// Asks whether Health Connect is here, and follows the settings.
Future<void> open() async {
_changes = _health.changes.listen(
(settings) => emit(state.copyWith(settings: settings)),
);
final availability = await _health.platform.availability();
if (!isClosed) {
emit(state.copyWith(availability: availability));
}
}
/// Turns writing [on], Health Connect asking first.
Future<void> setWriting({required bool on}) async {
emit(state.copyWith(asking: on, refused: false));
final writes = await _health.setWriting(on: on);
if (!isClosed) {
emit(state.copyWith(asking: false, refused: on && !writes));
}
}
/// Opens Health Connect's settings.
Future<void> openSettings() => _health.platform.openSettings();
@override
Future<void> close() async {
await _changes?.cancel();
await super.close();
}
}

View file

@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_core/halkyon_core.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/health/data/health_store.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_payload.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
/// A person's workouts and Health Connect on this phone (ADR 0030, D31):
/// with writing on, every workout written there, and written again or
/// deleted as it changes; Train's own store stays what counts.
class HealthConnect(
/// Health Connect itself.
final HealthPlatform platform,
final HealthStore _store,
final WorkoutRepository _workouts,
) {
late HealthSettings _settings = _store.read();
late final _writing = CoalescingRunner(
_write,
onError: (error, stackTrace) => log(
'Workouts not written: ${error.runtimeType}',
name: 'Health',
error: error,
stackTrace: stackTrace,
),
);
final _changes = StreamController<HealthSettings>.broadcast();
StreamSubscription<void>? _workoutChanges;
/// What is on, and what was written.
HealthSettings get settings => _settings;
/// Fires with the settings whenever they change.
Stream<HealthSettings> get changes => _changes.stream;
/// Writes what changed while the app was closed, then whatever changes.
void start() {
_workoutChanges = _workouts.changes.listen(
(_) => unawaited(_writing.run()),
);
unawaited(_writing.run());
}
/// Completes once nothing is being written.
Future<void> get idle => _writing.idle;
/// Turns writing [on], asking Health Connect first; whether it is on.
Future<bool> setWriting({required bool on}) async {
if (!on) {
_update(_settings.copyWith(writes: false));
return false;
}
final granted = await platform.ask({HealthAccess.write});
final writes = granted.contains(HealthAccess.write);
_update(_settings.copyWith(writes: writes, writeRefused: !writes));
if (writes) {
unawaited(_writing.run());
}
return writes;
}
/// Stops listening; what is being written is waited for.
Future<void> dispose() async {
await _workoutChanges?.cancel();
await _writing.idle;
await _changes.close();
}
void _update(HealthSettings settings) {
if (settings == _settings) {
return;
}
_settings = settings;
_store.save(settings);
if (!_changes.isClosed) {
_changes.add(settings);
}
}
/// Writes each workout not written as it is now, and deletes there those
/// deleted here; one that doesn't open on this device is left as it is.
Future<void> _write() async {
if (!_settings.writes) {
return;
}
try {
final workouts = await _workouts.all();
for (final workout in workouts) {
final content = workout.content;
if (content == null || !_settings.writes) {
continue;
}
final fingerprint = _fingerprintOf(encodeWorkout(content));
final before = _settings.written[workout.id];
if (before?.fingerprint == fingerprint) {
continue;
}
final version = (before?.version ?? 0) + 1;
final records = healthRecordsOf(
workout.id,
content,
await _workouts.series(workout.id),
version: version,
);
await platform.write(records);
final clientIds = clientIdsOf(records);
if (before != null) {
await platform.delete(_without(before.clientIds, clientIds));
}
_update(
_settings.copyWith(
written: {
..._settings.written,
workout.id: WrittenWorkout(
version: version,
fingerprint: fingerprint,
clientIds: clientIds,
),
},
),
);
}
final kept = {for (final workout in workouts) workout.id};
for (final MapEntry(key: id, value: written)
in _settings.written.entries.toList()) {
if (!kept.contains(id)) {
await platform.delete(written.clientIds);
_update(
_settings.copyWith(written: {..._settings.written}..remove(id)),
);
}
}
} on HealthRefused {
_update(_settings.copyWith(writes: false, writeRefused: true));
}
}
}
/// The client IDs of [before] that [now] no longer has.
Map<HealthRecordKind, List<String>> _without(
Map<HealthRecordKind, List<String>> before,
Map<HealthRecordKind, List<String>> now,
) => {
for (final MapEntry(:key, :value) in before.entries)
if (value.where((id) => !(now[key]?.contains(id) ?? false)).toList()
case final gone when gone.isNotEmpty)
key: gone,
};
/// A fingerprint of [payload] that stays the same across runs (FNV-1a, 32
/// bits): only whether a workout changed since it was written, nothing to
/// keep secret.
int _fingerprintOf(ByteData payload) {
var hash = 0x811c9dc5;
for (var i = 0; i < payload.lengthInBytes; i++) {
hash = ((hash ^ payload.getUint8(i)) * 0x01000193) & 0xffffffff;
}
return hash;
}

View file

@ -94,7 +94,9 @@ final class const HealthSamples({
required final List<(DateTime, num)> samples, required final List<(DateTime, num)> samples,
}) extends HealthRecord { }) extends HealthRecord {
@override @override
List<Object?> get props => [...super.props, samples]; // The kind is in the base's props already; named again for the lint,
// which reads this class alone.
List<Object?> get props => [...super.props, kind, samples];
} }
/// A total over the workout: its distance or elevation gained, in metres. /// A total over the workout: its distance or elevation gained, in metres.
@ -107,7 +109,8 @@ final class const HealthTotal({
required final num metres, required final num metres,
}) extends HealthRecord { }) extends HealthRecord {
@override @override
List<Object?> get props => [...super.props, metres]; // As above.
List<Object?> get props => [...super.props, kind, metres];
} }
/// Whether another app's session has a route Train may read. /// Whether another app's session has a route Train may read.

View file

@ -0,0 +1,117 @@
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The workout [id] as Health Connect's records (ADR 0030, D31), each at
/// [version]: its session (activity, name, notes, its pauses, its route as
/// its page draws it), its heart rate, speed and the rower's watts as
/// samples in records of [maxChunkLength] at most, its distance and
/// elevation gained as totals. Each record's client ID is the workout's,
/// with its kind and its stretch's number after it.
List<HealthRecord> healthRecordsOf(
String id,
WorkoutContent content,
List<SeriesChunk> series, {
required int version,
}) {
final recording = recordingOf(content, series);
final analysis = analyse(content, series);
final (start, end) = (content.startedAt, content.endedAt);
final pauses = <(DateTime, DateTime)>[];
DateTime? pausedAt;
for (final mark in recording.pauses) {
if (mark.paused) {
pausedAt ??= mark.time;
} else if (pausedAt != null) {
pauses.add((pausedAt, mark.time));
pausedAt = null;
}
}
if (pausedAt != null) {
pauses.add((pausedAt, end));
}
final speeds = [
for (final piece in analysis.track)
for (var i = 1; i < piece.length; i++)
if (piece[i].time.difference(piece[i - 1].time) case final gap
when gap > Duration.zero)
(
piece[i].time,
metresBetween(piece[i - 1], piece[i]) / (gap.inMilliseconds / 1000),
),
];
/// [samples] of [kind] in records of [maxChunkLength] at most, numbered
/// by their stretch from the start.
List<HealthSamples> stretched(
HealthRecordKind kind,
List<(DateTime, num)> samples,
) {
final byStretch = <int, List<(DateTime, num)>>{};
for (final sample in samples) {
final stretch =
sample.$1.difference(start).inMilliseconds ~/
maxChunkLength.inMilliseconds;
(byStretch[stretch] ??= []).add(sample);
}
return [
for (final MapEntry(key: stretch, value: samples) in byStretch.entries)
HealthSamples(
kind: kind,
clientId: '$id/${kind.name}/$stretch',
version: version,
start: samples.first.$1,
end: samples.last.$1,
samples: samples,
),
];
}
HealthTotal total(HealthRecordKind kind, num metres) => HealthTotal(
kind: kind,
clientId: '$id/${kind.name}',
version: version,
start: start,
end: end,
metres: metres,
);
return [
HealthSession(
clientId: id,
version: version,
start: start,
end: end,
activity: content.activity,
title: content.name,
notes: content.notes,
pauses: pauses,
route: [for (final piece in analysis.track) ...piece],
),
...stretched(HealthRecordKind.heartRate, [
for (final rate in recording.heartRates) (rate.time, rate.bpm),
]),
...stretched(HealthRecordKind.speed, speeds),
...stretched(HealthRecordKind.power, [
for (final sample in recording.rowing)
if (sample.watts case final watts?) (sample.time, watts),
]),
if (content.distanceMetres case final metres? when metres > 0)
total(HealthRecordKind.distance, metres),
if (analysis.elevationGainMetres case final metres? when metres > 0)
total(HealthRecordKind.elevation, metres),
];
}
/// [records]' client IDs by their kind, as a deletion names them.
Map<HealthRecordKind, List<String>> clientIdsOf(List<HealthRecord> records) {
final ids = <HealthRecordKind, List<String>>{};
for (final record in records) {
(ids[record.kind] ??= []).add(record.clientId);
}
return ids;
}

View file

@ -0,0 +1,169 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
/// A workout as it was last written to Health Connect: the version written,
/// a fingerprint of what it was then, and its records' client IDs.
final class const WrittenWorkout({
required final int version,
required final int fingerprint,
required final Map<HealthRecordKind, List<String>> clientIds,
}) with Equatable {
@override
List<Object?> get props => [version, fingerprint, clientIds];
}
/// An app that wrote sessions to Health Connect (D31): its name where
/// Android lets Train see it, and whether Train takes its sessions.
final class const SourceApp({
final String? name,
final bool on = false,
}) with Equatable {
@override
List<Object?> get props => [name, on];
}
/// Health Connect on this phone, for one person (ADR 0030, D31): never
/// synced, as each phone's Health Connect is its own.
final class const HealthSettings({
/// Whether Train writes its workouts there.
final bool writes = false,
/// Whether Health Connect took writing back, which turned it off.
final bool writeRefused = false,
/// Whether Train reads other apps' sessions from there.
final bool reads = false,
/// Whether Health Connect took reading back, which turned it off.
final bool readRefused = false,
/// Each workout written, by its ID.
final Map<String, WrittenWorkout> written = const {},
/// The changes token reading goes on from; null before the first read.
final String? token,
/// The apps seen writing sessions, by their package.
final Map<String, SourceApp> apps = const {},
/// Sessions taken and then deleted in Train, by Health Connect's ID, so
/// that they don't come back.
final Set<String> dismissed = const {},
}) with Equatable {
HealthSettings copyWith({
bool? writes,
bool? writeRefused,
bool? reads,
bool? readRefused,
Map<String, WrittenWorkout>? written,
String? Function()? token,
Map<String, SourceApp>? apps,
Set<String>? dismissed,
}) => HealthSettings(
writes: writes ?? this.writes,
writeRefused: writeRefused ?? this.writeRefused,
reads: reads ?? this.reads,
readRefused: readRefused ?? this.readRefused,
written: written ?? this.written,
token: token == null ? this.token : token(),
apps: apps ?? this.apps,
dismissed: dismissed ?? this.dismissed,
);
@override
List<Object?> get props => [
writes,
writeRefused,
reads,
readRefused,
written,
token,
apps,
dismissed,
];
}
/// Keeps [HealthSettings] in a file of the person's folder
/// (`health_connect.json`); a file it can't read is none, so writing and
/// reading start off again.
class HealthStore(final File _file) {
HealthSettings read() {
if (!_file.existsSync()) {
return const HealthSettings();
}
try {
final json = jsonDecode(_file.readAsStringSync()) as Map<String, Object?>;
final kinds = HealthRecordKind.values.asNameMap();
return HealthSettings(
writes: json['writes'] == true,
writeRefused: json['writeRefused'] == true,
reads: json['reads'] == true,
readRefused: json['readRefused'] == true,
written: {
for (final MapEntry(:key, :value)
in (json['written'] as Map<String, Object?>? ?? const {}).entries)
if (value case {
'version': final int version,
'fingerprint': final int fingerprint,
'ids': final Map<String, Object?> ids,
})
key: WrittenWorkout(
version: version,
fingerprint: fingerprint,
clientIds: {
for (final MapEntry(key: kind, value: list) in ids.entries)
?kinds[kind]: (list! as List<Object?>).cast<String>(),
},
),
},
token: json['token'] as String?,
apps: {
for (final MapEntry(:key, :value)
in (json['apps'] as Map<String, Object?>? ?? const {}).entries)
if (value case {'name': final String? name, 'on': final bool on})
key: SourceApp(name: name, on: on),
},
dismissed: {
...(json['dismissed'] as List<Object?>? ?? const []).cast<String>(),
},
);
} on Object catch (error) {
log(
'Health Connect settings not read: ${error.runtimeType}',
name: 'Health',
);
return const HealthSettings();
}
}
void save(HealthSettings settings) => _file.writeAsStringSync(
jsonEncode({
'writes': settings.writes,
'writeRefused': settings.writeRefused,
'reads': settings.reads,
'readRefused': settings.readRefused,
'written': {
for (final MapEntry(:key, :value) in settings.written.entries)
key: {
'version': value.version,
'fingerprint': value.fingerprint,
'ids': {
for (final MapEntry(key: kind, value: ids)
in value.clientIds.entries)
kind.name: ids,
},
},
},
'token': settings.token,
'apps': {
for (final MapEntry(:key, :value) in settings.apps.entries)
key: {'name': value.name, 'on': value.on},
},
'dismissed': settings.dismissed.toList(),
}),
);
}

View file

@ -0,0 +1,190 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/health/bloc/health_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens the Health Connect screen over [context]'s.
Future<void> openHealthScreen(BuildContext context) {
final health = PersonScope.of<TrainPerson>(context).health!;
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => BlocProvider(
create: (context) => HealthCubit(health)..open(),
child: const HealthScreen(),
),
),
);
}
/// Opens Train's rationale over [context]'s.
Future<void> openHealthRationale(BuildContext context) =>
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => const HealthRationaleScreen(),
),
);
/// Health Connect on this phone (ADR 0030, D31): whether Train writes its
/// workouts there, each switch asking Health Connect as it is turned on;
/// Health Connect's own settings; why Train asks.
class const HealthScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocConsumer<HealthCubit, HealthState>(
listenWhen: (before, now) => !before.refused && now.refused,
listener: (context, state) => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.healthRefused),
action: SnackBarAction(
label: l10n.healthOpenSettings,
onPressed: () => context.read<HealthCubit>().openSettings(),
),
),
),
builder: (context, state) {
final settings = state.settings;
return Scaffold(
appBar: AppBar(title: Text(l10n.healthTitle)),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: switch (state.availability) {
null => const AppLoadingIndicator(),
HealthAvailability.unavailable => _Message(
l10n.healthUnavailable,
),
HealthAvailability.needsUpdate => _Message(
l10n.healthNeedsUpdate,
),
HealthAvailability.available => ListView(
children: [
SwitchListTile(
secondary: const Icon(LucideIcons.upload),
title: Text(l10n.healthWrite),
subtitle: Text(
settings.writeRefused && !settings.writes
? l10n.healthWriteRefused
: l10n.healthWriteHint,
),
value: settings.writes,
onChanged: state.asking
? null
: (on) => context.read<HealthCubit>().setWriting(
on: on,
),
),
const Divider(),
ListTile(
leading: const Icon(LucideIcons.settings),
title: Text(l10n.healthOpenSettings),
onTap: () => context.read<HealthCubit>().openSettings(),
),
ListTile(
leading: const Icon(LucideIcons.info),
title: Text(l10n.healthWhy),
onTap: () => openHealthRationale(context),
),
],
),
},
),
),
),
);
},
);
}
}
class const _Message(final String _text) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(24),
child: Text(_text, textAlign: TextAlign.center),
);
}
/// Why Train asks for Health Connect's permissions (D31), which Health
/// Connect links to from its own screens.
class const HealthRationaleScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final text = Theme.of(context).textTheme.bodyLarge;
return Scaffold(
appBar: AppBar(title: Text(l10n.healthRationaleTitle)),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
for (final paragraph in [
l10n.healthRationaleWrite,
l10n.healthRationaleRead,
l10n.healthRationaleNothingElse,
])
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(paragraph, style: text),
),
],
),
),
),
),
);
}
}
/// Shows Train's rationale whenever Health Connect asks for it, over
/// [child], which is under the navigator.
class const HealthRationaleListener({
required final Widget child,
super.key,
}) extends StatefulWidget {
@override
State<HealthRationaleListener> createState() =>
_HealthRationaleListenerState();
}
class _HealthRationaleListenerState() extends State<HealthRationaleListener> {
StreamSubscription<void>? _requests;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_requests ??= PersonScope.of<TrainPerson>(context)
.health
?.platform
.rationaleRequests
.listen((_) {
if (mounted) {
unawaited(openHealthRationale(context));
}
});
}
@override
void dispose() {
unawaited(_requests?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}

View file

@ -87,7 +87,7 @@ int splitMetresOf(Activity activity) => switch (activity) {
/// [content] and its [series] read for its page. /// [content] and its [series] read for its page.
WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) { WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
final recording = _recordingOf(content, series); final recording = recordingOf(content, series);
final points = _moving(recording); final points = _moving(recording);
final rowed = recording.rowing.isNotEmpty; final rowed = recording.rowing.isNotEmpty;
// How far it had gone over its moving time: by the track, or the rower. // How far it had gone over its moving time: by the track, or the rower.
@ -286,8 +286,9 @@ List<ChartPoint> _binned(List<_Value> values, double bin) {
return chart; return chart;
} }
/// The recording [series] hold again, as it was saved. /// The recording [series] hold again, as it was saved; Health Connect's
Recording _recordingOf(WorkoutContent content, List<SeriesChunk> series) { /// records are made from it too (D31).
Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
final fixes = <Fix>[]; final fixes = <Fix>[];
final pauses = <PauseMark>[]; final pauses = <PauseMark>[];
final heartRates = <HeartRate>[]; final heartRates = <HeartRate>[];

View file

@ -30,6 +30,10 @@ dependencies:
# The device's half of signing in with OpenID Connect (ADR 0023): the # The device's half of signing in with OpenID Connect (ADR 0023): the
# browser that SimpleLogin's sign-in opens, and on Linux and Windows the # browser that SimpleLogin's sign-in opens, and on Linux and Windows the
# loopback it comes back to, which the app builds with its name. # loopback it comes back to, which the app builds with its name.
# Running Health Connect's writes and reads one at a time (a shared package,
# ADR 0030, D31).
halkyon_core:
path: ../../../packages/halkyon_core
halkyon_oidc_flutter: halkyon_oidc_flutter:
path: ../../../packages/halkyon_oidc_flutter path: ../../../packages/halkyon_oidc_flutter
halkyon_train_client: halkyon_train_client:

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_app.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.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/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/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/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.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:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import '../support/fake_health_platform.dart';
import '../support/fake_recorder_platform.dart'; import '../support/fake_recorder_platform.dart';
import '../support/memory_content_key_gateway.dart'; import '../support/memory_content_key_gateway.dart';
import '../support/memory_workout_gateway.dart'; import '../support/memory_workout_gateway.dart';
@ -49,6 +53,7 @@ void main() {
RecordingDevice? recording, RecordingDevice? recording,
WorkoutRepository? workouts, WorkoutRepository? workouts,
bool mapReachable = true, bool mapReachable = true,
HealthConnect? health,
}) async { }) async {
tester.view.physicalSize = const Size(412, 900); tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -118,6 +123,7 @@ void main() {
), ),
), ),
recording: recording, recording: recording,
health: health,
maps: MapSourceRepository( maps: MapSourceRepository(
_FakeMapSourceGateway(mapReachable ? openFreeMap : null), _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)', () { group('recording (ADR 0030, D21)', () {
final at = DateTime.utc(2026, 10, 28, 7, 30); final at = DateTime.utc(2026, 10, 28, 7, 30);
late Directory folder; 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'],
});
});
}

Some files were not shown because too many files have changed in this diff Show more