feat(train): other apps' workouts read from Health Connect (ADR 0030, D31)
Read from other apps, off until turned on, asks for reading with the history; the apps that wrote sessions are listed, each off. One turned on brings its sessions, earlier ones at once and later ones through the changes token as the app opens and comes to the front: each a workout of Train's own store, sealed and synced, its origin in its payload, its heart rate, pauses and a row's power as series, named by its app in the list and on its page. A double of a workout Train has brings at most its heart rate; one deleted in Train stays out; a track comes only when asked for on the page, Health Connect asking for consent. What came from Health Connect is never written back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
09f61c3b0a
commit
8fb2dbf35e
26 changed files with 1304 additions and 111 deletions
|
|
@ -6,6 +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 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.
|
||||
- **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.
|
||||
- **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); with reading on, the sessions of the apps the person turned on brought in as workouts through the changes token (`importedWorkout` in `health_import.dart`, the workout's `origin` in its payload; a double of one Train has brings at most its heart rate, `isDouble`; one deleted in Train is remembered so it stays out; a track only when asked for on its page, `bringInTrack`), as the app opens and comes to the front (`HealthConnectListener`, which opens the rationale too); 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.
|
||||
- **`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.
|
||||
|
|
|
|||
|
|
@ -137,12 +137,21 @@
|
|||
"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.",
|
||||
"healthTakenBack": "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."
|
||||
"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.",
|
||||
|
||||
"healthRead": "Von anderen Apps lesen",
|
||||
"healthReadHint": "Ihre Trainings kommen in Train, von den Apps, die du unten wählst",
|
||||
"healthApps": "Apps",
|
||||
"healthAppsEmpty": "Noch keine App hat Trainings in Health Connect geschrieben.",
|
||||
"workoutFromList": "von {app}",
|
||||
"workoutFrom": "Von {app}, über Health Connect",
|
||||
"workoutBringTrack": "Strecke holen",
|
||||
"workoutNoTrack": "Health Connect hat keine Strecke dazu gegeben."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,12 +183,25 @@
|
|||
"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.",
|
||||
"healthTakenBack": "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."
|
||||
"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.",
|
||||
|
||||
"healthRead": "Read from other apps",
|
||||
"healthReadHint": "Their workouts come into Train, from the apps you choose below",
|
||||
"healthApps": "Apps",
|
||||
"@healthApps": {"description": "Over the apps that wrote workouts to Health Connect, each with its switch."},
|
||||
"healthAppsEmpty": "No app has written workouts to Health Connect yet.",
|
||||
"workoutFromList": "from {app}",
|
||||
"@workoutFromList": {"description": "In the list, after when an imported workout was: the app it came from.", "placeholders": {"app": {"type": "String", "example": "Gadgetbridge"}}},
|
||||
"workoutFrom": "From {app}, through Health Connect",
|
||||
"@workoutFrom": {"description": "On an imported workout's page.", "placeholders": {"app": {"type": "String", "example": "Gadgetbridge"}}},
|
||||
"workoutBringTrack": "Bring in the track",
|
||||
"@workoutBringTrack": {"description": "On an imported workout's page: asks Health Connect for its route."},
|
||||
"workoutNoTrack": "Health Connect gave no track for it."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -862,11 +862,11 @@ abstract class AppLocalizations {
|
|||
/// **'Every workout on this phone, kept up to date as you edit or delete it'**
|
||||
String get healthWriteHint;
|
||||
|
||||
/// No description provided for @healthWriteRefused.
|
||||
/// No description provided for @healthTakenBack.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Health Connect took this back. Allow it there to turn it on again.'**
|
||||
String get healthWriteRefused;
|
||||
String get healthTakenBack;
|
||||
|
||||
/// No description provided for @healthRefused.
|
||||
///
|
||||
|
|
@ -909,6 +909,54 @@ abstract class AppLocalizations {
|
|||
/// 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;
|
||||
|
||||
/// No description provided for @healthRead.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Read from other apps'**
|
||||
String get healthRead;
|
||||
|
||||
/// No description provided for @healthReadHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Their workouts come into Train, from the apps you choose below'**
|
||||
String get healthReadHint;
|
||||
|
||||
/// Over the apps that wrote workouts to Health Connect, each with its switch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apps'**
|
||||
String get healthApps;
|
||||
|
||||
/// No description provided for @healthAppsEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No app has written workouts to Health Connect yet.'**
|
||||
String get healthAppsEmpty;
|
||||
|
||||
/// In the list, after when an imported workout was: the app it came from.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'from {app}'**
|
||||
String workoutFromList(String app);
|
||||
|
||||
/// On an imported workout's page.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'From {app}, through Health Connect'**
|
||||
String workoutFrom(String app);
|
||||
|
||||
/// On an imported workout's page: asks Health Connect for its route.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bring in the track'**
|
||||
String get workoutBringTrack;
|
||||
|
||||
/// No description provided for @workoutNoTrack.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Health Connect gave no track for it.'**
|
||||
String get workoutNoTrack;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -474,7 +474,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
'Jedes Training auf diesem Telefon, auf dem neuesten Stand, wenn du es bearbeitest oder löschst';
|
||||
|
||||
@override
|
||||
String get healthWriteRefused =>
|
||||
String get healthTakenBack =>
|
||||
'Health Connect hat das zurückgenommen. Erlaube es dort, um es wieder einzuschalten.';
|
||||
|
||||
@override
|
||||
|
|
@ -501,4 +501,34 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@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.';
|
||||
|
||||
@override
|
||||
String get healthRead => 'Von anderen Apps lesen';
|
||||
|
||||
@override
|
||||
String get healthReadHint =>
|
||||
'Ihre Trainings kommen in Train, von den Apps, die du unten wählst';
|
||||
|
||||
@override
|
||||
String get healthApps => 'Apps';
|
||||
|
||||
@override
|
||||
String get healthAppsEmpty =>
|
||||
'Noch keine App hat Trainings in Health Connect geschrieben.';
|
||||
|
||||
@override
|
||||
String workoutFromList(String app) {
|
||||
return 'von $app';
|
||||
}
|
||||
|
||||
@override
|
||||
String workoutFrom(String app) {
|
||||
return 'Von $app, über Health Connect';
|
||||
}
|
||||
|
||||
@override
|
||||
String get workoutBringTrack => 'Strecke holen';
|
||||
|
||||
@override
|
||||
String get workoutNoTrack => 'Health Connect hat keine Strecke dazu gegeben.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -470,7 +470,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
'Every workout on this phone, kept up to date as you edit or delete it';
|
||||
|
||||
@override
|
||||
String get healthWriteRefused =>
|
||||
String get healthTakenBack =>
|
||||
'Health Connect took this back. Allow it there to turn it on again.';
|
||||
|
||||
@override
|
||||
|
|
@ -497,6 +497,36 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@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.';
|
||||
|
||||
@override
|
||||
String get healthRead => 'Read from other apps';
|
||||
|
||||
@override
|
||||
String get healthReadHint =>
|
||||
'Their workouts come into Train, from the apps you choose below';
|
||||
|
||||
@override
|
||||
String get healthApps => 'Apps';
|
||||
|
||||
@override
|
||||
String get healthAppsEmpty =>
|
||||
'No app has written workouts to Health Connect yet.';
|
||||
|
||||
@override
|
||||
String workoutFromList(String app) {
|
||||
return 'from $app';
|
||||
}
|
||||
|
||||
@override
|
||||
String workoutFrom(String app) {
|
||||
return 'From $app, through Health Connect';
|
||||
}
|
||||
|
||||
@override
|
||||
String get workoutBringTrack => 'Bring in the track';
|
||||
|
||||
@override
|
||||
String get workoutNoTrack => 'Health Connect gave no track for it.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -88,8 +88,9 @@ class const TrainPersonApp({
|
|||
child: _Recording(child: child!),
|
||||
),
|
||||
),
|
||||
// Health Connect links to Train's rationale from its own screens (D31).
|
||||
home: const HealthRationaleListener(child: HomeScreen()),
|
||||
// Health Connect links to Train's rationale from its own screens, and
|
||||
// what other apps wrote there is read as Train comes to the front (D31).
|
||||
home: const HealthConnectListener(child: HomeScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,19 @@ class HealthCubit(final HealthConnect _health) extends Cubit<HealthState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Turns reading [on], Health Connect asking first.
|
||||
Future<void> setReading({required bool on}) async {
|
||||
emit(state.copyWith(asking: on, refused: false));
|
||||
final reads = await _health.setReading(on: on);
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(asking: false, refused: on && !reads));
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes [app]'s sessions, or no longer.
|
||||
Future<void> setApp(String app, {required bool on}) =>
|
||||
_health.setApp(app, on: on);
|
||||
|
||||
/// Opens Health Connect's settings.
|
||||
Future<void> openSettings() => _health.platform.openSettings();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,15 +3,24 @@ import 'dart:developer';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_core/halkyon_core.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_import.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';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
|
||||
|
||||
/// Since when Train asks for sessions read whole: as far back as Health
|
||||
/// Connect lets it.
|
||||
final _always = DateTime.utc(2000);
|
||||
|
||||
/// 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.
|
||||
/// with writing on, every workout of Train's written there, and written
|
||||
/// again or deleted as it changes; with reading on, the sessions of the
|
||||
/// apps the person chose brought in as workouts. Train's own store stays
|
||||
/// what counts.
|
||||
class HealthConnect(
|
||||
/// Health Connect itself.
|
||||
final HealthPlatform platform,
|
||||
|
|
@ -19,34 +28,45 @@ class HealthConnect(
|
|||
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,
|
||||
),
|
||||
late final _following = CoalescingRunner(
|
||||
_follow,
|
||||
onError: (error, stackTrace) => _failed('written', error, stackTrace),
|
||||
);
|
||||
late final _reading = CoalescingRunner(
|
||||
_read,
|
||||
onError: (error, stackTrace) => _failed('read', error, stackTrace),
|
||||
);
|
||||
final _changes = StreamController<HealthSettings>.broadcast();
|
||||
StreamSubscription<void>? _workoutChanges;
|
||||
|
||||
/// What is on, and what was written.
|
||||
/// Apps just turned on, whose earlier sessions the next read brings.
|
||||
final _newApps = <String>{};
|
||||
|
||||
/// What is on, what was written and what was read.
|
||||
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.
|
||||
/// Writes what changed while the app was closed and reads what other apps
|
||||
/// wrote meanwhile; then follows the workouts.
|
||||
void start() {
|
||||
_workoutChanges = _workouts.changes.listen(
|
||||
(_) => unawaited(_writing.run()),
|
||||
(_) => unawaited(_following.run()),
|
||||
);
|
||||
unawaited(_writing.run());
|
||||
unawaited(_following.run());
|
||||
unawaited(_reading.run());
|
||||
}
|
||||
|
||||
/// Completes once nothing is being written.
|
||||
Future<void> get idle => _writing.idle;
|
||||
/// Reads what other apps wrote since the last read (as the app comes to
|
||||
/// the front); nothing with reading off.
|
||||
Future<void> read() => _reading.run();
|
||||
|
||||
/// Completes once nothing is being written or read.
|
||||
Future<void> get idle async {
|
||||
await _following.idle;
|
||||
await _reading.idle;
|
||||
}
|
||||
|
||||
/// Turns writing [on], asking Health Connect first; whether it is on.
|
||||
Future<bool> setWriting({required bool on}) async {
|
||||
|
|
@ -58,15 +78,70 @@ class HealthConnect(
|
|||
final writes = granted.contains(HealthAccess.write);
|
||||
_update(_settings.copyWith(writes: writes, writeRefused: !writes));
|
||||
if (writes) {
|
||||
unawaited(_writing.run());
|
||||
unawaited(_following.run());
|
||||
}
|
||||
return writes;
|
||||
}
|
||||
|
||||
/// Stops listening; what is being written is waited for.
|
||||
/// Turns reading [on], asking Health Connect first for reading and for
|
||||
/// the history (which may be refused alone: then the last 30 days come);
|
||||
/// whether it is on.
|
||||
Future<bool> setReading({required bool on}) async {
|
||||
if (!on) {
|
||||
_update(_settings.copyWith(reads: false));
|
||||
return false;
|
||||
}
|
||||
final granted = await platform.ask({
|
||||
HealthAccess.read,
|
||||
HealthAccess.history,
|
||||
});
|
||||
final reads = granted.contains(HealthAccess.read);
|
||||
_update(_settings.copyWith(reads: reads, readRefused: !reads));
|
||||
if (reads) {
|
||||
await _reading.run();
|
||||
}
|
||||
return reads;
|
||||
}
|
||||
|
||||
/// Takes the sessions of [app], or no longer; turned on, those it wrote
|
||||
/// before come in too.
|
||||
Future<void> setApp(String app, {required bool on}) async {
|
||||
final known = _settings.apps[app] ?? const SourceApp();
|
||||
_update(
|
||||
_settings.copyWith(
|
||||
apps: {
|
||||
..._settings.apps,
|
||||
app: SourceApp(name: known.name, on: on),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (on && _settings.reads) {
|
||||
_newApps.add(app);
|
||||
await _reading.run();
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings in the track of the imported workout [id] from Health Connect,
|
||||
/// which may ask the person first; whether one came.
|
||||
Future<bool> bringInTrack(String id) async {
|
||||
final workout = (await _workouts.all()).where((w) => w.id == id).first;
|
||||
final content = workout.content;
|
||||
final origin = content?.origin;
|
||||
if (content == null || origin == null) {
|
||||
return false;
|
||||
}
|
||||
final fixes = await platform.route(origin.sessionId);
|
||||
if (fixes == null || fixes.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
await _workouts.addSeries(id, trackSeries(content, fixes));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Stops listening; what is being written or read is waited for.
|
||||
Future<void> dispose() async {
|
||||
await _workoutChanges?.cancel();
|
||||
await _writing.idle;
|
||||
await idle;
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
|
|
@ -81,61 +156,200 @@ class HealthConnect(
|
|||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
void _failed(String what, Object error, StackTrace stackTrace) => log(
|
||||
'Health Connect not $what: ${error.runtimeType}',
|
||||
name: 'Health',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
/// Remembers the imported workouts deleted, so they don't come back, and
|
||||
/// writes what changed.
|
||||
Future<void> _follow() async {
|
||||
final workouts = await _workouts.all();
|
||||
final kept = {for (final workout in workouts) workout.id};
|
||||
final gone = {
|
||||
for (final MapEntry(key: id, value: session)
|
||||
in _settings.imported.entries)
|
||||
if (!kept.contains(id)) id: session,
|
||||
};
|
||||
if (gone.isNotEmpty) {
|
||||
_update(
|
||||
_settings.copyWith(
|
||||
imported: {..._settings.imported}
|
||||
..removeWhere((id, _) => gone.containsKey(id)),
|
||||
dismissed: {..._settings.dismissed, ...gone.values},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_settings.writes) {
|
||||
try {
|
||||
await _write(workouts);
|
||||
} on HealthRefused {
|
||||
_update(_settings.copyWith(writes: false, writeRefused: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes each workout of Train's 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, one brought in from there is never written back.
|
||||
Future<void> _write(List<StoredWorkout> workouts) async {
|
||||
for (final workout in workouts) {
|
||||
final content = workout.content;
|
||||
if (content == null || content.origin != 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the sessions written since the last read, through the changes
|
||||
/// token; all of them where there is none yet, or it expired.
|
||||
Future<void> _read() => _guarded(() async {
|
||||
if (!_settings.reads) {
|
||||
return;
|
||||
}
|
||||
if (_newApps.isNotEmpty) {
|
||||
final apps = {..._newApps};
|
||||
_newApps.clear();
|
||||
await _take(await platform.sessions(from: _always, apps: apps));
|
||||
}
|
||||
final token = _settings.token;
|
||||
final changes = token == null ? null : await platform.changes(token);
|
||||
if (changes == null || changes.expired) {
|
||||
// The token first, so that nothing written during the read is missed;
|
||||
// a session read twice is known.
|
||||
final next = await platform.changesToken();
|
||||
await _take(await platform.sessions(from: _always));
|
||||
_update(_settings.copyWith(token: () => next));
|
||||
} else {
|
||||
await _take(changes.sessions);
|
||||
_update(_settings.copyWith(token: () => changes.token));
|
||||
}
|
||||
});
|
||||
|
||||
/// [action], reading turned off where Health Connect took it back.
|
||||
Future<void> _guarded(Future<void> Function() action) async {
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
await action();
|
||||
} on HealthRefused {
|
||||
_update(_settings.copyWith(writes: false, writeRefused: true));
|
||||
_update(_settings.copyWith(reads: false, readRefused: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes the apps [sessions] came from, and brings in those of the apps
|
||||
/// taken: each a workout, unless Train has it already, deleted it, can't
|
||||
/// record its kind, or recorded it itself (a double: its heart rate is
|
||||
/// added where Train's has none).
|
||||
Future<void> _take(List<OtherSession> sessions) async {
|
||||
final apps = {..._settings.apps};
|
||||
for (final session in sessions) {
|
||||
final known = apps[session.app];
|
||||
if (known == null || (known.name == null && session.appName != null)) {
|
||||
apps[session.app] = SourceApp(
|
||||
name: session.appName,
|
||||
on: known?.on ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
_update(_settings.copyWith(apps: apps));
|
||||
|
||||
final workouts = await _workouts.all();
|
||||
final taken = {
|
||||
..._settings.imported.values,
|
||||
for (final workout in workouts) ?workout.content?.origin?.sessionId,
|
||||
};
|
||||
for (final session in sessions) {
|
||||
if (!(_settings.apps[session.app]?.on ?? false) ||
|
||||
session.activity == null ||
|
||||
_settings.dismissed.contains(session.id) ||
|
||||
taken.contains(session.id)) {
|
||||
continue;
|
||||
}
|
||||
taken.add(session.id);
|
||||
final double = workouts
|
||||
.where(
|
||||
(workout) =>
|
||||
workout.content != null && isDouble(session, workout.content!),
|
||||
)
|
||||
.firstOrNull;
|
||||
if (double != null) {
|
||||
await _addHeartRate(double, session);
|
||||
continue;
|
||||
}
|
||||
final (content, series) = importedWorkout(
|
||||
session,
|
||||
await platform.samples(session),
|
||||
);
|
||||
final id = await _workouts.save(content, series);
|
||||
_update(
|
||||
_settings.copyWith(imported: {..._settings.imported, id: session.id}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [session]'s heart rate added to Train's own [workout] where it has
|
||||
/// none.
|
||||
Future<void> _addHeartRate(
|
||||
StoredWorkout workout,
|
||||
OtherSession session,
|
||||
) async {
|
||||
final content = workout.content!;
|
||||
if (content.origin != null) {
|
||||
return;
|
||||
}
|
||||
final series = await _workouts.series(workout.id);
|
||||
if (series.any((chunk) => chunk.kind == SeriesKind.heartRate)) {
|
||||
return;
|
||||
}
|
||||
final samples = await platform.samples(session);
|
||||
final rates = [
|
||||
for (final rate in samples.heartRates)
|
||||
if (!rate.time.isBefore(content.startedAt) &&
|
||||
!rate.time.isAfter(content.endedAt))
|
||||
rate,
|
||||
];
|
||||
if (rates.isNotEmpty) {
|
||||
await _workouts.addSeries(workout.id, heartRateSeries(content, rates));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
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/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// Another app's [session] as a workout of Train's (ADR 0030, D31), with
|
||||
/// [samples] as its series, saved as a recording is: its heart rate, its
|
||||
/// pauses, a row's power; its distance as the app summed it, its moving
|
||||
/// time without its pauses, and where it came from.
|
||||
(WorkoutContent, List<SeriesChunk>) importedWorkout(
|
||||
OtherSession session,
|
||||
OtherSamples samples,
|
||||
) {
|
||||
final activity = session.activity!;
|
||||
bool within(DateTime time) =>
|
||||
!time.isBefore(session.start) && !time.isAfter(session.end);
|
||||
final recording = Recording(
|
||||
activity: activity,
|
||||
startedAt: session.start,
|
||||
heartRates: [
|
||||
for (final rate in samples.heartRates)
|
||||
if (within(rate.time)) rate,
|
||||
],
|
||||
pauses: [
|
||||
for (final (from, to) in session.pauses) ...[
|
||||
PauseMark(time: from),
|
||||
PauseMark(time: to, paused: false),
|
||||
],
|
||||
],
|
||||
// Power has a series of its own only on the rower (D28); a ride's
|
||||
// waits for one.
|
||||
rowing: activity == Activity.indoorRowing
|
||||
? [
|
||||
for (final (time, watts) in samples.power)
|
||||
if (within(time)) RowingSample(time: time, watts: watts.round()),
|
||||
]
|
||||
: const [],
|
||||
endedAt: session.end,
|
||||
);
|
||||
String? text(String? value) =>
|
||||
value == null || value.trim().isEmpty ? null : value.trim();
|
||||
return (
|
||||
WorkoutContent(
|
||||
activity: activity,
|
||||
startedAt: session.start,
|
||||
endedAt: session.end,
|
||||
name: text(session.title),
|
||||
notes: text(session.notes),
|
||||
distanceMetres: samples.metres?.round(),
|
||||
movingTime: movingTimeOf(recording, session.end),
|
||||
origin: WorkoutOrigin(
|
||||
app: session.app,
|
||||
appName: session.appName,
|
||||
sessionId: session.id,
|
||||
),
|
||||
),
|
||||
seriesOf(recording),
|
||||
);
|
||||
}
|
||||
|
||||
/// [heartRates] as the heart-rate series of the workout [content], to add to
|
||||
/// it.
|
||||
List<SeriesChunk> heartRateSeries(
|
||||
WorkoutContent content,
|
||||
List<HeartRate> heartRates,
|
||||
) => seriesOf(
|
||||
Recording(
|
||||
activity: content.activity,
|
||||
startedAt: content.startedAt,
|
||||
heartRates: heartRates,
|
||||
),
|
||||
);
|
||||
|
||||
/// [fixes] as the position series of the workout [content], to add to it.
|
||||
List<SeriesChunk> trackSeries(WorkoutContent content, List<Fix> fixes) =>
|
||||
seriesOf(
|
||||
Recording(
|
||||
activity: content.activity,
|
||||
startedAt: content.startedAt,
|
||||
fixes: fixes,
|
||||
),
|
||||
);
|
||||
|
||||
/// Whether [session] is a double of [workout]: the same kind, overlapping
|
||||
/// for at least half the shorter of the two.
|
||||
bool isDouble(OtherSession session, WorkoutContent workout) {
|
||||
if (session.activity != workout.activity) {
|
||||
return false;
|
||||
}
|
||||
final from = session.start.isAfter(workout.startedAt)
|
||||
? session.start
|
||||
: workout.startedAt;
|
||||
final to = session.end.isBefore(workout.endedAt)
|
||||
? session.end
|
||||
: workout.endedAt;
|
||||
final overlap = to.difference(from);
|
||||
final shorter = [
|
||||
session.end.difference(session.start),
|
||||
workout.endedAt.difference(workout.startedAt),
|
||||
].reduce((a, b) => a < b ? a : b);
|
||||
return overlap > Duration.zero && overlap * 2 >= shorter;
|
||||
}
|
||||
|
|
@ -50,6 +50,10 @@ final class const HealthSettings({
|
|||
/// The apps seen writing sessions, by their package.
|
||||
final Map<String, SourceApp> apps = const {},
|
||||
|
||||
/// The workouts brought in on this phone: Health Connect's session ID by
|
||||
/// the workout's, so that one deleted is known for what it was.
|
||||
final Map<String, String> imported = 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 {},
|
||||
|
|
@ -62,6 +66,7 @@ final class const HealthSettings({
|
|||
Map<String, WrittenWorkout>? written,
|
||||
String? Function()? token,
|
||||
Map<String, SourceApp>? apps,
|
||||
Map<String, String>? imported,
|
||||
Set<String>? dismissed,
|
||||
}) => HealthSettings(
|
||||
writes: writes ?? this.writes,
|
||||
|
|
@ -71,6 +76,7 @@ final class const HealthSettings({
|
|||
written: written ?? this.written,
|
||||
token: token == null ? this.token : token(),
|
||||
apps: apps ?? this.apps,
|
||||
imported: imported ?? this.imported,
|
||||
dismissed: dismissed ?? this.dismissed,
|
||||
);
|
||||
|
||||
|
|
@ -83,6 +89,7 @@ final class const HealthSettings({
|
|||
written,
|
||||
token,
|
||||
apps,
|
||||
imported,
|
||||
dismissed,
|
||||
];
|
||||
}
|
||||
|
|
@ -127,6 +134,10 @@ class HealthStore(final File _file) {
|
|||
if (value case {'name': final String? name, 'on': final bool on})
|
||||
key: SourceApp(name: name, on: on),
|
||||
},
|
||||
imported: {
|
||||
...(json['imported'] as Map<String, Object?>? ?? const {})
|
||||
.cast<String, String>(),
|
||||
},
|
||||
dismissed: {
|
||||
...(json['dismissed'] as List<Object?>? ?? const []).cast<String>(),
|
||||
},
|
||||
|
|
@ -163,6 +174,7 @@ class HealthStore(final File _file) {
|
|||
for (final MapEntry(:key, :value) in settings.apps.entries)
|
||||
key: {'name': value.name, 'on': value.on},
|
||||
},
|
||||
'imported': settings.imported,
|
||||
'dismissed': settings.dismissed.toList(),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class const HealthScreen({super.key}) extends StatelessWidget {
|
|||
title: Text(l10n.healthWrite),
|
||||
subtitle: Text(
|
||||
settings.writeRefused && !settings.writes
|
||||
? l10n.healthWriteRefused
|
||||
? l10n.healthTakenBack
|
||||
: l10n.healthWriteHint,
|
||||
),
|
||||
value: settings.writes,
|
||||
|
|
@ -84,6 +84,42 @@ class const HealthScreen({super.key}) extends StatelessWidget {
|
|||
on: on,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.download),
|
||||
title: Text(l10n.healthRead),
|
||||
subtitle: Text(
|
||||
settings.readRefused && !settings.reads
|
||||
? l10n.healthTakenBack
|
||||
: l10n.healthReadHint,
|
||||
),
|
||||
value: settings.reads,
|
||||
onChanged: state.asking
|
||||
? null
|
||||
: (on) => context.read<HealthCubit>().setReading(
|
||||
on: on,
|
||||
),
|
||||
),
|
||||
if (settings.reads) ...[
|
||||
ListTile(
|
||||
title: Text(
|
||||
l10n.healthApps,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
if (settings.apps.isEmpty)
|
||||
ListTile(subtitle: Text(l10n.healthAppsEmpty))
|
||||
else
|
||||
for (final MapEntry(key: app, value: source)
|
||||
in settings.apps.entries)
|
||||
SwitchListTile(
|
||||
title: Text(source.name ?? app),
|
||||
subtitle: source.name == null ? null : Text(app),
|
||||
value: source.on,
|
||||
onChanged: (on) => context
|
||||
.read<HealthCubit>()
|
||||
.setApp(app, on: on),
|
||||
),
|
||||
],
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.settings),
|
||||
|
|
@ -151,37 +187,40 @@ class const HealthRationaleScreen({super.key}) extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Shows Train's rationale whenever Health Connect asks for it, over
|
||||
/// [child], which is under the navigator.
|
||||
class const HealthRationaleListener({
|
||||
/// Over [child], which is under the navigator: shows Train's rationale
|
||||
/// whenever Health Connect asks for it, and reads what other apps wrote
|
||||
/// whenever the app comes to the front (D31).
|
||||
class const HealthConnectListener({
|
||||
required final Widget child,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<HealthRationaleListener> createState() =>
|
||||
_HealthRationaleListenerState();
|
||||
State<HealthConnectListener> createState() => _HealthConnectListenerState();
|
||||
}
|
||||
|
||||
class _HealthRationaleListenerState() extends State<HealthRationaleListener> {
|
||||
class _HealthConnectListenerState() extends State<HealthConnectListener> {
|
||||
StreamSubscription<void>? _requests;
|
||||
AppLifecycleListener? _lifecycle;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_requests ??= PersonScope.of<TrainPerson>(context)
|
||||
.health
|
||||
?.platform
|
||||
.rationaleRequests
|
||||
.listen((_) {
|
||||
if (mounted) {
|
||||
unawaited(openHealthRationale(context));
|
||||
}
|
||||
});
|
||||
final health = PersonScope.of<TrainPerson>(context).health;
|
||||
if (health == null || _requests != null) {
|
||||
return;
|
||||
}
|
||||
_requests = health.platform.rationaleRequests.listen((_) {
|
||||
if (mounted) {
|
||||
unawaited(openHealthRationale(context));
|
||||
}
|
||||
});
|
||||
_lifecycle = AppLifecycleListener(onResume: () => unawaited(health.read()));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_requests?.cancel());
|
||||
_lifecycle?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const maxAccuracyCm = 2000;
|
|||
distanceMetres: distanceOf(recording),
|
||||
movingTime: movingTimeOf(recording, endedAt),
|
||||
),
|
||||
_series(recording),
|
||||
seriesOf(recording),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -145,8 +145,9 @@ double metresBetween(Fix a, Fix b) {
|
|||
return 2 * radius * asin(sqrt(h));
|
||||
}
|
||||
|
||||
/// Every sample as its series' chunks, a kind without samples left out.
|
||||
List<SeriesChunk> _series(Recording recording) {
|
||||
/// Every sample as its series' chunks, a kind without samples left out;
|
||||
/// a session brought in from Health Connect is saved so too (D31).
|
||||
List<SeriesChunk> seriesOf(Recording recording) {
|
||||
final start = recording.startedAt;
|
||||
List<int> times(Iterable<DateTime> of) => [
|
||||
for (final time in of) time.difference(start).inMilliseconds,
|
||||
|
|
|
|||
|
|
@ -68,21 +68,35 @@ class SyncWorkoutGateway(
|
|||
transaction: transaction,
|
||||
);
|
||||
if (series.isNotEmpty) {
|
||||
await protocol.WorkoutSeries.db.insert(_db, [
|
||||
for (final row in series)
|
||||
protocol.WorkoutSeries(
|
||||
id: protocol.UuidValue.withValidation(row.id),
|
||||
workoutId: protocol.UuidValue.withValidation(row.workoutId),
|
||||
keyId: row.envelope.keyId,
|
||||
nonce: row.envelope.nonce,
|
||||
payload: row.envelope.payload,
|
||||
),
|
||||
], transaction: transaction);
|
||||
await protocol.WorkoutSeries.db.insert(
|
||||
_db,
|
||||
_rowsOf(series),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertSeries(List<SeriesRow> series) async {
|
||||
if (series.isNotEmpty) {
|
||||
await protocol.WorkoutSeries.db.insert(_db, _rowsOf(series));
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
List<protocol.WorkoutSeries> _rowsOf(List<SeriesRow> series) => [
|
||||
for (final row in series)
|
||||
protocol.WorkoutSeries(
|
||||
id: protocol.UuidValue.withValidation(row.id),
|
||||
workoutId: protocol.UuidValue.withValidation(row.workoutId),
|
||||
keyId: row.envelope.keyId,
|
||||
nonce: row.envelope.nonce,
|
||||
payload: row.envelope.payload,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (await protocol.Workout.db.findById(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ abstract interface class WorkoutGateway() {
|
|||
/// Keeps a new workout with its series, together or not at all.
|
||||
Future<void> insert(WorkoutRow workout, List<SeriesRow> series);
|
||||
|
||||
/// Keeps more series of a workout kept already (one brought in later,
|
||||
/// D31).
|
||||
Future<void> insertSeries(List<SeriesRow> series);
|
||||
|
||||
/// Replaces the workout [id]'s envelope (an edit); nothing for one gone.
|
||||
Future<void> update(String id, Envelope envelope);
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ ByteData encodeWorkout(WorkoutContent content) => ByteData.sublistView(
|
|||
'notes': ?content.notes,
|
||||
'distanceMetres': ?content.distanceMetres,
|
||||
'movingMs': ?content.movingTime?.inMilliseconds,
|
||||
if (content.origin case final origin?)
|
||||
'origin': {
|
||||
'app': origin.app,
|
||||
'appName': ?origin.appName,
|
||||
'sessionId': origin.sessionId,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
@ -61,6 +67,16 @@ WorkoutContent? decodeWorkout(ByteData payload) {
|
|||
notes: json['notes'] as String?,
|
||||
distanceMetres: json['distanceMetres'] as int?,
|
||||
movingTime: movingMs == null ? null : Duration(milliseconds: movingMs),
|
||||
origin: switch (json['origin']) {
|
||||
{'app': final String app, 'sessionId': final String sessionId} &&
|
||||
final origin =>
|
||||
WorkoutOrigin(
|
||||
app: app,
|
||||
appName: origin['appName'] as String?,
|
||||
sessionId: sessionId,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('A workout not read: ${error.runtimeType}', name: 'Workouts');
|
||||
|
|
|
|||
|
|
@ -93,6 +93,17 @@ class WorkoutRepository(
|
|||
return id;
|
||||
}
|
||||
|
||||
/// Adds [series] to the workout [id] (a heart rate or a track brought in
|
||||
/// from Health Connect later, D31).
|
||||
Future<void> addSeries(String id, List<SeriesChunk> series) async {
|
||||
final keyId = await _keys.keyToWrite();
|
||||
await _gateway.insertSeries([
|
||||
for (final chunk in series)
|
||||
_sealedSeries(const Uuid().v7(), id, chunk, keyId),
|
||||
]);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Replaces what the workout [id] is (its name, notes, totals); its series
|
||||
/// stay as they are.
|
||||
Future<void> update(String id, WorkoutContent content) async {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ enum Activity() {
|
|||
indoorRowing,
|
||||
}
|
||||
|
||||
/// Where a workout came from, where Train didn't record it (D31): the app
|
||||
/// that wrote it to Health Connect, its package and its name where Android
|
||||
/// let Train see it, and Health Connect's ID of the session.
|
||||
final class const WorkoutOrigin({
|
||||
required final String app,
|
||||
final String? appName,
|
||||
required final String sessionId,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [app, appName, sessionId];
|
||||
}
|
||||
|
||||
/// What a workout is, as its sealed payload holds it (D14); its samples are
|
||||
/// its series.
|
||||
final class const WorkoutContent({
|
||||
|
|
@ -30,6 +42,9 @@ final class const WorkoutContent({
|
|||
|
||||
/// How long it moved, its pauses left out (D16).
|
||||
final Duration? movingTime,
|
||||
|
||||
/// Where it came from; null for one Train recorded.
|
||||
final WorkoutOrigin? origin,
|
||||
}) with Equatable {
|
||||
/// As it is, named [name] with [notes] (each null or blank: none).
|
||||
WorkoutContent withText({String? name, String? notes}) {
|
||||
|
|
@ -43,6 +58,7 @@ final class const WorkoutContent({
|
|||
notes: text(notes),
|
||||
distanceMetres: distanceMetres,
|
||||
movingTime: movingTime,
|
||||
origin: origin,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -55,5 +71,6 @@ final class const WorkoutContent({
|
|||
notes,
|
||||
distanceMetres,
|
||||
movingTime,
|
||||
origin,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,18 @@ class const WorkoutScreen({super.key}) extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Asks Health Connect for the track of the imported workout on the page;
|
||||
/// says so where none came.
|
||||
Future<void> _bringInTrack(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final id = context.read<WorkoutCubit>().id;
|
||||
final health = PersonScope.of<TrainPerson>(context).health!;
|
||||
if (!await health.bringInTrack(id)) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.workoutNoTrack)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The page under the app bar: the map fixed at the top, the rest scrolling
|
||||
/// beneath it.
|
||||
class const _Workout({
|
||||
|
|
@ -154,6 +166,26 @@ class const _Workout({
|
|||
),
|
||||
],
|
||||
),
|
||||
if (content.origin case final origin?) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.workoutFrom(origin.appName ?? origin.app),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
// Another app's route asks the person's consent for each session
|
||||
// (D31), so it comes only when asked for; a row has none.
|
||||
if (!hasTrack &&
|
||||
content.activity != Activity.indoorRowing &&
|
||||
PersonScope.of<TrainPerson>(context).health != null)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(LucideIcons.route),
|
||||
label: Text(l10n.workoutBringTrack),
|
||||
onPressed: () => _bringInTrack(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_Totals(content: content, analysis: analysis),
|
||||
if (content.notes case final notes?) ...[
|
||||
|
|
|
|||
|
|
@ -97,9 +97,12 @@ class const _WorkoutTile({
|
|||
final locale = Localizations.localeOf(context).toString();
|
||||
final theme = Theme.of(context);
|
||||
final started = content.startedAt.toLocal();
|
||||
final when =
|
||||
'${DateFormat.MMMEd(locale).format(started)} · '
|
||||
'${DateFormat.Hm(locale).format(started)}';
|
||||
final when = [
|
||||
DateFormat.MMMEd(locale).format(started),
|
||||
DateFormat.Hm(locale).format(started),
|
||||
if (content.origin case final origin?)
|
||||
l10n.workoutFromList(origin.appName ?? origin.app),
|
||||
].join(' · ');
|
||||
final metres = content.distanceMetres;
|
||||
final moving = content.movingTime;
|
||||
final figures = [
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue