feat(train): Days, the Loop's days and nights kept sealed (ADR 0030, D37, D38)

What the band read brings, from the bridge or the background job's
file (`LoopLines`), is kept by `DayRepository`: one sealed `day` and
`night` a date and the day's heart rate in chunks of three hours, every
ID made from the person and the date, a later read replacing what it
brought and keeping the rest, nothing rewritten unchanged; versioned,
gzipped JSON (`day_payload.dart`). `PolarLoop` reads the band as each
connection is ready (every half hour at most, or when asked), takes in
the background file as the app opens, and remembers up to which day
the days are sealed, which the next read then deletes on the band;
it keeps the background job on the band and from the right day, and
`Let the band go` stops it.

Home gains a Days tab, each day with its steps, active time, sleep and
Nightly Recharge, pulled to read the band; a day's page shows its
figures, its time in each class, its heart rate, the night's phases
and rating, Nightly Recharge and skin temperature.

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 19:53:12 +01:00
commit 5250f32329
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
35 changed files with 4079 additions and 28 deletions

View file

@ -3,10 +3,10 @@
What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Train needs the same thing.
- **A shell on the shared packages**, Todo's copied and renamed (decided 2026-10-28, ADR 0030, D12; what Train is to do: ADR 0030): signing in, the instances, the account and the look work, and since step 5a (28 Oct 2026) a person's workouts are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0030, D7, D14, D15), and on Android a workout is recorded (5b: ADR 0030, D13, D16, D18 to D21), and since 5c home lists them and each has its page with its map (D22 to D24). Packages: `halkyon_train_server`, `halkyon_train_client` (generated, never edited), `halkyon_train_flutter`; app ID `app.halkyon.train` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
- **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, then `profile` and `days` (Train's `day`, `day_series` and `night`), each purely additive. **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), `profile` (the body's figures, one row a person), `day`, `day_series` (a day's 24/7 heart rate in chunks of three hours, deleted with it) and `night` (one row a person and date each, ADR 0030, D37), 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); 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 Polar Loop** (ADR 0030, D32 to D36) is Android's (`TrainPerson.loop` null elsewhere): `LoopBridge.kt` on Polar's BLE SDK 8.3.0 (from JitPack, a repository asked for Polar's group alone, its files pinned by SHA-256 in `android/gradle/verification-metadata.xml`; a new version needs its checksums there) over `app.halkyon.train/loop`, `…/loop/state` and `…/loop/search`, the first-time use's figures mapped and checked in `LoopSetup.kt` (tested on the JVM); `LoopPlatform` in Dart (`lib/src/feature/loop/`), `PolarLoop` per person: the band remembered in the person's `loop.json` (`LoopStore`, never synced) with what it was set up with and what it told, connected while the app is open, its clock set as each connection is ready, a changed profile offered on the band's screen and sent only when the person says (D35), *Let the band go* a factory reset after asking; `loopFiguresOf` fills Polar's defaults for the band alone (`loop_figures.dart`), the setup sheet asks for what the profile lacks with `ProfileForm(only: …)`. The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (`halkyon_train_flutter/LICENSE-polar-exception.md`), its notices are in the licence list (`registerPolarLicense`, on Android), and an F-Droid listing of Train declares `NonFreeDep`. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
- **The Polar Loop** (ADR 0030, D32 to D36) is Android's (`TrainPerson.loop` null elsewhere): `LoopBridge.kt` on Polar's BLE SDK 8.3.0 (from JitPack, a repository asked for Polar's group alone, its files pinned by SHA-256 in `android/gradle/verification-metadata.xml`; a new version needs its checksums there) over `app.halkyon.train/loop`, `…/loop/state` and `…/loop/search`, the first-time use's figures mapped and checked in `LoopSetup.kt` (tested on the JVM); `LoopPlatform` in Dart (`lib/src/feature/loop/`), `PolarLoop` per person: the band remembered in the person's `loop.json` (`LoopStore`, never synced) with what it was set up with and what it told, connected while the app is open, its clock set as each connection is ready, a changed profile offered on the band's screen and sent only when the person says (D35), *Let the band go* a factory reset after asking; its days and nights (D37, D38, `lib/src/feature/day/`) read as each connection is ready (every half hour at most, or when the person pulls *Days*) and by a WorkManager job every four hours while the app is closed (`LoopWorker.kt`, into the person's `loop_days.txt`, which the app takes in as it opens, `LoopLines`), `LoopDays.kt` turning the SDK's answers into lines of JSON (tested on the JVM), `DayRepository` sealing them as `day`, `night` and `day_series` rows (payloads in `day_payload.dart`), and what is sealed, up to yesterday, deleted on the band by the next read (`KnownLoop.keptUntil`), never today; home's *Days* tab and a day's page (`DaysCubit`, `DayCubit`); `loopFiguresOf` fills Polar's defaults for the band alone (`loop_figures.dart`), the setup sheet asks for what the profile lacks with `ProfileForm(only: …)`. The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (`halkyon_train_flutter/LICENSE-polar-exception.md`), its notices are in the licence list (`registerPolarLicense`, on Android), and an F-Droid listing of Train declares `NonFreeDep`. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
- **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.

View file

@ -220,5 +220,47 @@
"loopSetUpDone": "Das Band ist eingerichtet.",
"loopFormTitle": "Für dein Band",
"loopFormIntro": "Dein Band braucht diese Angaben. Sie bleiben in „Dein Körper“, mit deinem Schlüssel versiegelt. Für einen Ruhe- oder Maximalpuls oder eine VO2max, die du nicht angegeben hast, nimmt das Band Polars Standardwerte, und „Dein Körper“ bleibt, wie er ist.",
"loopFormSave": "Band einrichten"
"loopFormSave": "Band einrichten",
"homeWorkouts": "Workouts",
"homeDays": "Tage",
"daysEmptyTitle": "Noch keine Tage",
"daysEmptyHint": "Die Tage und Nächte, die dein Polar Loop zählt, erscheinen hier.",
"daysSteps": "{steps, plural, =1{1 Schritt} other{{steps} Schritte}}",
"daysActive": "{time} aktiv",
"daysAsleep": "{time} geschlafen",
"daysRecharge": "Erholung {level} von 6",
"dayHoursMinutes": "{hours} h {minutes} min",
"dayMinutes": "{minutes} min",
"dayCalories": "{calories} kcal",
"dayGoal": "{percent} % des Tagesziels",
"dayNothing": "Das Band hat für diesen Tag noch nichts geliefert.",
"dayTimes": "Die Zeit des Tages",
"dayTimeNonWear": "Nicht getragen",
"dayTimeSleep": "Geschlafen",
"dayTimeSedentary": "Still gesessen",
"dayTimeLight": "Leichte Aktivität",
"dayTimeModerate": "Mittlere Aktivität",
"dayTimeVigorous": "Intensive Aktivität",
"dayHeartRate": "Herzfrequenz",
"dayBpm": "{bpm} S/min",
"dayNight": "Die Nacht",
"dayNightSpan": "{from} – {to}",
"daySleepWake": "Wach",
"daySleepRem": "REM",
"daySleepLight": "Leicht",
"daySleepDeep": "Tief",
"dayRatingPoorly": "Du hast schlecht geschlafen",
"dayRatingSomewhatPoorly": "Du hast eher schlecht geschlafen",
"dayRatingNeither": "Du hast weder schlecht noch gut geschlafen",
"dayRatingSomewhatWell": "Du hast eher gut geschlafen",
"dayRatingWell": "Du hast gut geschlafen",
"dayRecharge": "Nightly Recharge",
"dayRechargeLevel": "Erholung {level} von 6",
"dayAnsStatus": "Autonomes Nervensystem {value}, von −10 bis +10",
"dayNightHeart": "Herzfrequenz {bpm} S/min, sonst {usual} S/min",
"dayHrv": "Herzfrequenzvariabilität {ms} ms, sonst {usual} ms",
"dayBreathing": "{rate} Atemzüge pro Minute",
"daySkin": "Hauttemperatur",
"dayCelsius": "{value} °C",
"daySkinDeviation": "{value} °C gegenüber deinen Nächten sonst"
}

View file

@ -276,5 +276,64 @@
"loopSetUpDone": "The band is set up.",
"loopFormTitle": "For your band",
"loopFormIntro": "Your band needs these. They're kept in Your body, sealed with your key. For a resting or highest heart rate or a VO2max you haven't given, the band takes Polar's defaults, and Your body stays as it is.",
"loopFormSave": "Set up the band"
"loopFormSave": "Set up the band",
"homeWorkouts": "Workouts",
"homeDays": "Days",
"daysEmptyTitle": "No days yet",
"daysEmptyHint": "The days and nights your Polar Loop counts show here.",
"daysSteps": "{steps, plural, =1{1 step} other{{steps} steps}}",
"@daysSteps": {"placeholders": {"steps": {"type": "int"}}},
"daysActive": "{time} active",
"@daysActive": {"placeholders": {"time": {"type": "String", "example": "1 h 20 min"}}},
"daysAsleep": "{time} asleep",
"@daysAsleep": {"placeholders": {"time": {"type": "String", "example": "7 h 10 min"}}},
"daysRecharge": "Recharge {level} of 6",
"@daysRecharge": {"placeholders": {"level": {"type": "int"}}},
"dayHoursMinutes": "{hours} h {minutes} min",
"@dayHoursMinutes": {"placeholders": {"hours": {"type": "int"}, "minutes": {"type": "int"}}},
"dayMinutes": "{minutes} min",
"@dayMinutes": {"placeholders": {"minutes": {"type": "int"}}},
"dayCalories": "{calories} kcal",
"@dayCalories": {"placeholders": {"calories": {"type": "int"}}},
"dayGoal": "{percent} % of the day's goal",
"@dayGoal": {"placeholders": {"percent": {"type": "int"}}},
"dayNothing": "The band has given nothing for this day yet.",
"dayTimes": "The day's time",
"dayTimeNonWear": "Not worn",
"dayTimeSleep": "Asleep",
"dayTimeSedentary": "Sitting still",
"dayTimeLight": "Light activity",
"dayTimeModerate": "Moderate activity",
"dayTimeVigorous": "Vigorous activity",
"dayHeartRate": "Heart rate",
"dayBpm": "{bpm} bpm",
"@dayBpm": {"placeholders": {"bpm": {"type": "int"}}},
"dayNight": "The night",
"dayNightSpan": "{from} – {to}",
"@dayNightSpan": {"placeholders": {"from": {"type": "String", "example": "23:15"}, "to": {"type": "String", "example": "07:05"}}},
"daySleepWake": "Awake",
"daySleepRem": "REM",
"daySleepLight": "Light",
"daySleepDeep": "Deep",
"dayRatingPoorly": "You slept poorly",
"dayRatingSomewhatPoorly": "You slept somewhat poorly",
"dayRatingNeither": "You slept neither poorly nor well",
"dayRatingSomewhatWell": "You slept somewhat well",
"dayRatingWell": "You slept well",
"dayRecharge": "Nightly Recharge",
"dayRechargeLevel": "Recovery {level} of 6",
"@dayRechargeLevel": {"placeholders": {"level": {"type": "int"}}},
"dayAnsStatus": "Autonomic nervous system {value}, from −10 to +10",
"@dayAnsStatus": {"placeholders": {"value": {"type": "String", "example": "+2.5"}}},
"dayNightHeart": "Heart rate {bpm} bpm, your usual {usual} bpm",
"@dayNightHeart": {"placeholders": {"bpm": {"type": "int"}, "usual": {"type": "int"}}},
"dayHrv": "Heart rate variability {ms} ms, your usual {usual} ms",
"@dayHrv": {"placeholders": {"ms": {"type": "int"}, "usual": {"type": "int"}}},
"dayBreathing": "Breathing {rate} a minute",
"@dayBreathing": {"placeholders": {"rate": {"type": "String", "example": "14.5"}}},
"daySkin": "Skin temperature",
"dayCelsius": "{value} °C",
"@dayCelsius": {"placeholders": {"value": {"type": "String", "example": "33.4"}}},
"daySkinDeviation": "{value} °C from your usual at night",
"@daySkinDeviation": {"placeholders": {"value": {"type": "String", "example": "−0.2"}}}
}

View file

@ -1347,6 +1347,258 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Set up the band'**
String get loopFormSave;
/// No description provided for @homeWorkouts.
///
/// In en, this message translates to:
/// **'Workouts'**
String get homeWorkouts;
/// No description provided for @homeDays.
///
/// In en, this message translates to:
/// **'Days'**
String get homeDays;
/// No description provided for @daysEmptyTitle.
///
/// In en, this message translates to:
/// **'No days yet'**
String get daysEmptyTitle;
/// No description provided for @daysEmptyHint.
///
/// In en, this message translates to:
/// **'The days and nights your Polar Loop counts show here.'**
String get daysEmptyHint;
/// No description provided for @daysSteps.
///
/// In en, this message translates to:
/// **'{steps, plural, =1{1 step} other{{steps} steps}}'**
String daysSteps(int steps);
/// No description provided for @daysActive.
///
/// In en, this message translates to:
/// **'{time} active'**
String daysActive(String time);
/// No description provided for @daysAsleep.
///
/// In en, this message translates to:
/// **'{time} asleep'**
String daysAsleep(String time);
/// No description provided for @daysRecharge.
///
/// In en, this message translates to:
/// **'Recharge {level} of 6'**
String daysRecharge(int level);
/// No description provided for @dayHoursMinutes.
///
/// In en, this message translates to:
/// **'{hours} h {minutes} min'**
String dayHoursMinutes(int hours, int minutes);
/// No description provided for @dayMinutes.
///
/// In en, this message translates to:
/// **'{minutes} min'**
String dayMinutes(int minutes);
/// No description provided for @dayCalories.
///
/// In en, this message translates to:
/// **'{calories} kcal'**
String dayCalories(int calories);
/// No description provided for @dayGoal.
///
/// In en, this message translates to:
/// **'{percent} % of the day\'s goal'**
String dayGoal(int percent);
/// No description provided for @dayNothing.
///
/// In en, this message translates to:
/// **'The band has given nothing for this day yet.'**
String get dayNothing;
/// No description provided for @dayTimes.
///
/// In en, this message translates to:
/// **'The day\'s time'**
String get dayTimes;
/// No description provided for @dayTimeNonWear.
///
/// In en, this message translates to:
/// **'Not worn'**
String get dayTimeNonWear;
/// No description provided for @dayTimeSleep.
///
/// In en, this message translates to:
/// **'Asleep'**
String get dayTimeSleep;
/// No description provided for @dayTimeSedentary.
///
/// In en, this message translates to:
/// **'Sitting still'**
String get dayTimeSedentary;
/// No description provided for @dayTimeLight.
///
/// In en, this message translates to:
/// **'Light activity'**
String get dayTimeLight;
/// No description provided for @dayTimeModerate.
///
/// In en, this message translates to:
/// **'Moderate activity'**
String get dayTimeModerate;
/// No description provided for @dayTimeVigorous.
///
/// In en, this message translates to:
/// **'Vigorous activity'**
String get dayTimeVigorous;
/// No description provided for @dayHeartRate.
///
/// In en, this message translates to:
/// **'Heart rate'**
String get dayHeartRate;
/// No description provided for @dayBpm.
///
/// In en, this message translates to:
/// **'{bpm} bpm'**
String dayBpm(int bpm);
/// No description provided for @dayNight.
///
/// In en, this message translates to:
/// **'The night'**
String get dayNight;
/// No description provided for @dayNightSpan.
///
/// In en, this message translates to:
/// **'{from} – {to}'**
String dayNightSpan(String from, String to);
/// No description provided for @daySleepWake.
///
/// In en, this message translates to:
/// **'Awake'**
String get daySleepWake;
/// No description provided for @daySleepRem.
///
/// In en, this message translates to:
/// **'REM'**
String get daySleepRem;
/// No description provided for @daySleepLight.
///
/// In en, this message translates to:
/// **'Light'**
String get daySleepLight;
/// No description provided for @daySleepDeep.
///
/// In en, this message translates to:
/// **'Deep'**
String get daySleepDeep;
/// No description provided for @dayRatingPoorly.
///
/// In en, this message translates to:
/// **'You slept poorly'**
String get dayRatingPoorly;
/// No description provided for @dayRatingSomewhatPoorly.
///
/// In en, this message translates to:
/// **'You slept somewhat poorly'**
String get dayRatingSomewhatPoorly;
/// No description provided for @dayRatingNeither.
///
/// In en, this message translates to:
/// **'You slept neither poorly nor well'**
String get dayRatingNeither;
/// No description provided for @dayRatingSomewhatWell.
///
/// In en, this message translates to:
/// **'You slept somewhat well'**
String get dayRatingSomewhatWell;
/// No description provided for @dayRatingWell.
///
/// In en, this message translates to:
/// **'You slept well'**
String get dayRatingWell;
/// No description provided for @dayRecharge.
///
/// In en, this message translates to:
/// **'Nightly Recharge'**
String get dayRecharge;
/// No description provided for @dayRechargeLevel.
///
/// In en, this message translates to:
/// **'Recovery {level} of 6'**
String dayRechargeLevel(int level);
/// No description provided for @dayAnsStatus.
///
/// In en, this message translates to:
/// **'Autonomic nervous system {value}, from −10 to +10'**
String dayAnsStatus(String value);
/// No description provided for @dayNightHeart.
///
/// In en, this message translates to:
/// **'Heart rate {bpm} bpm, your usual {usual} bpm'**
String dayNightHeart(int bpm, int usual);
/// No description provided for @dayHrv.
///
/// In en, this message translates to:
/// **'Heart rate variability {ms} ms, your usual {usual} ms'**
String dayHrv(int ms, int usual);
/// No description provided for @dayBreathing.
///
/// In en, this message translates to:
/// **'Breathing {rate} a minute'**
String dayBreathing(String rate);
/// No description provided for @daySkin.
///
/// In en, this message translates to:
/// **'Skin temperature'**
String get daySkin;
/// No description provided for @dayCelsius.
///
/// In en, this message translates to:
/// **'{value} °C'**
String dayCelsius(String value);
/// No description provided for @daySkinDeviation.
///
/// In en, this message translates to:
/// **'{value} °C from your usual at night'**
String daySkinDeviation(String value);
}
class _AppLocalizationsDelegate

View file

@ -747,4 +747,171 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get loopFormSave => 'Band einrichten';
@override
String get homeWorkouts => 'Workouts';
@override
String get homeDays => 'Tage';
@override
String get daysEmptyTitle => 'Noch keine Tage';
@override
String get daysEmptyHint =>
'Die Tage und Nächte, die dein Polar Loop zählt, erscheinen hier.';
@override
String daysSteps(int steps) {
String _temp0 = intl.Intl.pluralLogic(
steps,
locale: localeName,
other: '$steps Schritte',
one: '1 Schritt',
);
return '$_temp0';
}
@override
String daysActive(String time) {
return '$time aktiv';
}
@override
String daysAsleep(String time) {
return '$time geschlafen';
}
@override
String daysRecharge(int level) {
return 'Erholung $level von 6';
}
@override
String dayHoursMinutes(int hours, int minutes) {
return '$hours h $minutes min';
}
@override
String dayMinutes(int minutes) {
return '$minutes min';
}
@override
String dayCalories(int calories) {
return '$calories kcal';
}
@override
String dayGoal(int percent) {
return '$percent % des Tagesziels';
}
@override
String get dayNothing => 'Das Band hat für diesen Tag noch nichts geliefert.';
@override
String get dayTimes => 'Die Zeit des Tages';
@override
String get dayTimeNonWear => 'Nicht getragen';
@override
String get dayTimeSleep => 'Geschlafen';
@override
String get dayTimeSedentary => 'Still gesessen';
@override
String get dayTimeLight => 'Leichte Aktivität';
@override
String get dayTimeModerate => 'Mittlere Aktivität';
@override
String get dayTimeVigorous => 'Intensive Aktivität';
@override
String get dayHeartRate => 'Herzfrequenz';
@override
String dayBpm(int bpm) {
return '$bpm S/min';
}
@override
String get dayNight => 'Die Nacht';
@override
String dayNightSpan(String from, String to) {
return '$from – $to';
}
@override
String get daySleepWake => 'Wach';
@override
String get daySleepRem => 'REM';
@override
String get daySleepLight => 'Leicht';
@override
String get daySleepDeep => 'Tief';
@override
String get dayRatingPoorly => 'Du hast schlecht geschlafen';
@override
String get dayRatingSomewhatPoorly => 'Du hast eher schlecht geschlafen';
@override
String get dayRatingNeither => 'Du hast weder schlecht noch gut geschlafen';
@override
String get dayRatingSomewhatWell => 'Du hast eher gut geschlafen';
@override
String get dayRatingWell => 'Du hast gut geschlafen';
@override
String get dayRecharge => 'Nightly Recharge';
@override
String dayRechargeLevel(int level) {
return 'Erholung $level von 6';
}
@override
String dayAnsStatus(String value) {
return 'Autonomes Nervensystem $value, von −10 bis +10';
}
@override
String dayNightHeart(int bpm, int usual) {
return 'Herzfrequenz $bpm S/min, sonst $usual S/min';
}
@override
String dayHrv(int ms, int usual) {
return 'Herzfrequenzvariabilität $ms ms, sonst $usual ms';
}
@override
String dayBreathing(String rate) {
return '$rate Atemzüge pro Minute';
}
@override
String get daySkin => 'Hauttemperatur';
@override
String dayCelsius(String value) {
return '$value °C';
}
@override
String daySkinDeviation(String value) {
return '$value °C gegenüber deinen Nächten sonst';
}
}

View file

@ -741,6 +741,173 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get loopFormSave => 'Set up the band';
@override
String get homeWorkouts => 'Workouts';
@override
String get homeDays => 'Days';
@override
String get daysEmptyTitle => 'No days yet';
@override
String get daysEmptyHint =>
'The days and nights your Polar Loop counts show here.';
@override
String daysSteps(int steps) {
String _temp0 = intl.Intl.pluralLogic(
steps,
locale: localeName,
other: '$steps steps',
one: '1 step',
);
return '$_temp0';
}
@override
String daysActive(String time) {
return '$time active';
}
@override
String daysAsleep(String time) {
return '$time asleep';
}
@override
String daysRecharge(int level) {
return 'Recharge $level of 6';
}
@override
String dayHoursMinutes(int hours, int minutes) {
return '$hours h $minutes min';
}
@override
String dayMinutes(int minutes) {
return '$minutes min';
}
@override
String dayCalories(int calories) {
return '$calories kcal';
}
@override
String dayGoal(int percent) {
return '$percent % of the day\'s goal';
}
@override
String get dayNothing => 'The band has given nothing for this day yet.';
@override
String get dayTimes => 'The day\'s time';
@override
String get dayTimeNonWear => 'Not worn';
@override
String get dayTimeSleep => 'Asleep';
@override
String get dayTimeSedentary => 'Sitting still';
@override
String get dayTimeLight => 'Light activity';
@override
String get dayTimeModerate => 'Moderate activity';
@override
String get dayTimeVigorous => 'Vigorous activity';
@override
String get dayHeartRate => 'Heart rate';
@override
String dayBpm(int bpm) {
return '$bpm bpm';
}
@override
String get dayNight => 'The night';
@override
String dayNightSpan(String from, String to) {
return '$from – $to';
}
@override
String get daySleepWake => 'Awake';
@override
String get daySleepRem => 'REM';
@override
String get daySleepLight => 'Light';
@override
String get daySleepDeep => 'Deep';
@override
String get dayRatingPoorly => 'You slept poorly';
@override
String get dayRatingSomewhatPoorly => 'You slept somewhat poorly';
@override
String get dayRatingNeither => 'You slept neither poorly nor well';
@override
String get dayRatingSomewhatWell => 'You slept somewhat well';
@override
String get dayRatingWell => 'You slept well';
@override
String get dayRecharge => 'Nightly Recharge';
@override
String dayRechargeLevel(int level) {
return 'Recovery $level of 6';
}
@override
String dayAnsStatus(String value) {
return 'Autonomic nervous system $value, from −10 to +10';
}
@override
String dayNightHeart(int bpm, int usual) {
return 'Heart rate $bpm bpm, your usual $usual bpm';
}
@override
String dayHrv(int ms, int usual) {
return 'Heart rate variability $ms ms, your usual $usual ms';
}
@override
String dayBreathing(String rate) {
return 'Breathing $rate a minute';
}
@override
String get daySkin => 'Skin temperature';
@override
String dayCelsius(String value) {
return '$value °C';
}
@override
String daySkinDeviation(String value) {
return '$value °C from your usual at night';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

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_frame.dart';
import 'package:halkyon_train_flutter/src/app/train_languages.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.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/widget/recording_texts.dart';
@ -84,6 +85,12 @@ class const TrainPersonApp({
return WorkoutsCubit(train.workouts, train.deletions)..open();
},
),
BlocProvider(
create: (context) {
final train = PersonScope.of<TrainPerson>(context);
return DaysCubit(train.days, train.loop)..open();
},
),
],
child: _Recording(child: child!),
),

View file

@ -8,6 +8,8 @@ import 'package:halkyon_train_client/halkyon_train_client.dart'
show Client, UuidValue;
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/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/sync_day_gateway.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';
@ -55,6 +57,10 @@ class TrainPerson(
/// The figures of their body (D33), sealed and synced.
required final ProfileRepository profile,
/// Their days and nights from the Loop (D37), sealed and synced; shown on
/// every device of theirs, read on the phone holding the band.
required final DayRepository days,
/// The instance's map source (D22).
required final MapSourceRepository maps,
@ -318,6 +324,11 @@ Future<TrainPerson> _openPersonData(
keys,
person: person.authUserId,
);
final days = DayRepository(
SyncDayGateway(db, sync),
keys,
person: person.authUserId,
);
// Writes what changed while the app was closed, as it opens (D31).
final health = Platform.isAndroid
? (HealthConnect(
@ -334,11 +345,14 @@ Future<TrainPerson> _openPersonData(
AndroidLoopPlatform(recorder),
LoopStore(File(p.join(folder.path, 'loop.json'))),
profile,
days: days,
backgroundLines: File(p.join(folder.path, 'loop_days.txt')),
)..start());
return TrainPerson(
person,
workouts: workouts,
profile: profile,
days: days,
maps: MapSourceRepository(ServerpodMapSourceGateway(client)),
recording: recorder != null
? (
@ -354,6 +368,7 @@ Future<TrainPerson> _openPersonData(
try {
await workouts.dispose();
await profile.dispose();
await days.dispose();
await sync.dispose();
await db.close();
} finally {

View file

@ -0,0 +1,51 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// One day's page (ADR 0030, D37): the day, the night that ended it and its
/// heart rate, read again whenever the days change.
class DayCubit(
final DayRepository _days,
/// Which day, as UTC midnight of the band's date.
final DateTime date,
) extends Cubit<DayState> {
this : super(const DayState());
StreamSubscription<void>? _changes;
/// Reads the day, and follows it.
Future<void> open() async {
_changes = _days.changes.listen((_) => unawaited(_read()));
await _read();
}
Future<void> _read() async {
final day = await _days.day(date);
final night = await _days.night(date);
final heart = await _days.heart(date);
if (!isClosed) {
emit(DayState(loaded: true, day: day, night: night, heart: heart));
}
}
@override
Future<void> close() async {
await _changes?.cancel();
await super.close();
}
}
/// What a day's page shows.
final class const DayState({
final bool loaded = false,
final DayContent? day,
final NightContent? night,
final List<HeartRun> heart = const [],
}) with Equatable {
@override
List<Object?> get props => [loaded, day, night, heart];
}

View file

@ -0,0 +1,72 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/stored_day.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
/// The person's days as home lists them (ADR 0030, D37): newest first, read
/// again whenever they change, here or on another device; read anew from
/// the band where this phone holds one ([PolarLoop]) when the person pulls.
class DaysCubit(
final DayRepository _days,
/// The person's Loop on this phone; null where it holds none.
final PolarLoop? _loop,
) extends Cubit<DaysState> {
this : super(DaysState(holdsLoop: _loop?.status.loop != null));
StreamSubscription<void>? _changes;
StreamSubscription<LoopStatus>? _status;
/// Reads the days, and follows them.
Future<void> open() async {
_changes = _days.changes.listen((_) => unawaited(_read()));
_status = _loop?.changes.listen((status) {
if (!isClosed) {
emit(state._with(holdsLoop: status.loop != null));
}
});
await _read();
}
/// Reads the band now, where this phone holds one; completes once what it
/// brought is kept, or the read failed.
Future<void> readBand() async {
await _loop?.readDays();
}
Future<void> _read() async {
final days = await _days.all();
if (!isClosed) {
emit(state._with(days: days));
}
}
@override
Future<void> close() async {
await _changes?.cancel();
await _status?.cancel();
await super.close();
}
}
/// The days home lists.
final class const DaysState({
/// Whether they were read once.
final bool loaded = false,
final List<StoredDay> days = const [],
/// Whether this phone holds a Loop, so that pulling reads it.
final bool holdsLoop = false,
}) with Equatable {
DaysState _with({List<StoredDay>? days, bool? holdsLoop}) => DaysState(
loaded: loaded || days != null,
days: days ?? this.days,
holdsLoop: holdsLoop ?? this.holdsLoop,
);
@override
List<Object?> get props => [loaded, days, holdsLoop];
}

View file

@ -0,0 +1,39 @@
import 'package:halkyon_app/halkyon_app.dart';
/// A `day` or `night` row as kept: its id and its envelope, sealed (ADR
/// 0030, D7, D37).
typedef DayRow = ({String id, Envelope envelope});
/// A `day_series` row as kept: its id, its day's and its envelope.
typedef DaySeriesRow = ({String id, String dayId, Envelope envelope});
/// Where the person's days and nights are kept: their synced `day`,
/// `day_series` and `night` rows. The repository seals and opens; this only
/// keeps.
abstract interface class DayGateway() {
/// Every day row.
Future<List<DayRow>> days();
/// The day row [id]; null where there is none.
Future<Envelope?> day(String id);
/// The series rows of the day [dayId].
Future<List<DaySeriesRow>> seriesOf(String dayId);
/// Every night row.
Future<List<DayRow>> nights();
/// The night row [id]; null where there is none.
Future<Envelope?> night(String id);
/// Keeps [day] in place of what was there, together with [series] where
/// that isn't null: the day's series rows not among them deleted, every
/// payload blanked first (ADR 0002, rule 2).
Future<void> writeDay(DayRow day, List<DaySeriesRow>? series);
/// Keeps [night] in place of what was there.
Future<void> writeNight(DayRow night);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,213 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// The schema of a day's, a night's and a day's heart rate's payloads this
/// app writes and reads.
const daySchemaVersion = 1;
/// [day] as its row's payload, before sealing (ADR 0030, D37): JSON, times
/// in seconds (a skin sample's in milliseconds, as the band gives it),
/// gzipped.
ByteData encodeDay(DayContent day) => _gzipped({
'schemaVersion': daySchemaVersion,
'date': dayKey(day.date),
'steps': ?day.steps,
'metres': ?day.metres,
'activityCalories': ?day.activityCalories,
'trainingCalories': ?day.trainingCalories,
'bmrCalories': ?day.bmrCalories,
'goal': ?day.goal,
'achieved': ?day.achieved,
if (day.times.isNotEmpty)
'times': {
for (final MapEntry(:key, :value) in day.times.entries)
key.name: value.inSeconds,
},
if (day.recharge case final recharge?)
'recharge': {
'ansStatus': ?recharge.ansStatus,
'recovery': ?recharge.recovery,
'recoverySubLevel': ?recharge.recoverySubLevel,
'ansRate': ?recharge.ansRate,
'rri': ?recharge.rri,
'rmssd': ?recharge.rmssd,
'breathInterval': ?recharge.breathInterval,
'baselineRri': ?recharge.baselineRri,
'baselineRriSd': ?recharge.baselineRriSd,
'baselineRmssd': ?recharge.baselineRmssd,
'baselineRmssdSd': ?recharge.baselineRmssdSd,
'baselineBreathInterval': ?recharge.baselineBreathInterval,
'baselineBreathIntervalSd': ?recharge.baselineBreathIntervalSd,
},
if (day.skin.isNotEmpty)
'skin': [
for (final (at, celsius) in day.skin) [at.inMilliseconds, celsius],
],
});
/// The day [payload] holds, opened; null for a blank one (a row being
/// deleted), one of a newer schema, or one it can't read.
DayContent? decodeDay(ByteData payload) => _read(payload, 'day', (json) {
final date = dateOfKey(json['date'])!;
return DayContent(
date: date,
steps: json['steps'] as int?,
metres: _double(json['metres']),
activityCalories: json['activityCalories'] as int?,
trainingCalories: json['trainingCalories'] as int?,
bmrCalories: json['bmrCalories'] as int?,
goal: _double(json['goal']),
achieved: _double(json['achieved']),
times: {
if (json['times'] case final Map<String, Object?> times)
for (final MapEntry(:key, :value) in times.entries)
if ((ActivityClass.values.asNameMap()[key], value) case (
final kind?,
final int seconds,
))
kind: Duration(seconds: seconds),
},
recharge: switch (json['recharge']) {
final Map<String, Object?> r => Recharge(
ansStatus: _double(r['ansStatus']),
recovery: r['recovery'] as int?,
recoverySubLevel: r['recoverySubLevel'] as int?,
ansRate: r['ansRate'] as int?,
rri: r['rri'] as int?,
rmssd: r['rmssd'] as int?,
breathInterval: r['breathInterval'] as int?,
baselineRri: r['baselineRri'] as int?,
baselineRriSd: r['baselineRriSd'] as int?,
baselineRmssd: r['baselineRmssd'] as int?,
baselineRmssdSd: r['baselineRmssdSd'] as int?,
baselineBreathInterval: r['baselineBreathInterval'] as int?,
baselineBreathIntervalSd: r['baselineBreathIntervalSd'] as int?,
),
_ => null,
},
skin: [
for (final sample in _list(json['skin']))
if (sample case [final int ms, final num celsius])
(Duration(milliseconds: ms), celsius.toDouble()),
],
);
});
/// [night] as its row's payload, before sealing: as [encodeDay], its start
/// and end in UTC milliseconds.
ByteData encodeNight(NightContent night) => _gzipped({
'schemaVersion': daySchemaVersion,
'date': dayKey(night.date),
'start': ?night.start?.toUtc().millisecondsSinceEpoch,
'end': ?night.end?.toUtc().millisecondsSinceEpoch,
'goalMinutes': ?night.goalMinutes,
if (night.phases.isNotEmpty)
'phases': [
for (final (at, state) in night.phases) [at.inSeconds, state.name],
],
if (night.cycles.isNotEmpty)
'cycles': [
for (final (at, depth) in night.cycles) [at.inSeconds, depth],
],
'rating': ?night.rating?.name,
'batteryRanOut': ?night.batteryRanOut,
'skinCelsius': ?night.skinCelsius,
'skinDeviation': ?night.skinDeviation,
});
/// The night [payload] holds, opened; null as for [decodeDay]. A state this
/// app doesn't know is [SleepState.unknown], a rating left out.
NightContent? decodeNight(ByteData payload) => _read(payload, 'night', (json) {
DateTime? time(String key) => switch (json[key]) {
final int ms => DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true),
_ => null,
};
return NightContent(
date: dateOfKey(json['date'])!,
start: time('start'),
end: time('end'),
goalMinutes: json['goalMinutes'] as int?,
phases: [
for (final phase in _list(json['phases']))
if (phase case [final int seconds, final String state])
(
Duration(seconds: seconds),
SleepState.values.asNameMap()[state] ?? SleepState.unknown,
),
],
cycles: [
for (final cycle in _list(json['cycles']))
if (cycle case [final int seconds, final num depth])
(Duration(seconds: seconds), depth.toDouble()),
],
rating: SleepRating.values.asNameMap()[json['rating']],
batteryRanOut: json['batteryRanOut'] as bool?,
skinCelsius: _double(json['skinCelsius']),
skinDeviation: _double(json['skinDeviation']),
);
});
/// A chunk of a day's 24/7 heart rate as its row's payload: each
/// measurement's start in seconds into the day, its trigger and samples.
ByteData encodeHeart(List<HeartRun> runs) => _gzipped({
'schemaVersion': daySchemaVersion,
'runs': [
for (final run in runs) [run.at.inSeconds, run.trigger.name, run.bpm],
],
});
/// The measurements [payload] holds, opened; null as for [decodeDay].
List<HeartRun>? decodeHeart(ByteData payload) => _read(
payload,
'heart',
(json) => [
for (final run in _list(json['runs']))
if (run case [
final int seconds,
final String trigger,
final List<Object?> bpm,
])
HeartRun(
at: Duration(seconds: seconds),
trigger:
HeartTrigger.values.asNameMap()[trigger] ?? HeartTrigger.unknown,
bpm: bpm.cast<int>(),
),
],
);
ByteData _gzipped(Map<String, Object?> json) => ByteData.sublistView(
Uint8List.fromList(GZipCodec(level: 9).encode(utf8.encode(jsonEncode(json)))),
);
T? _read<T>(
ByteData payload,
String what,
T Function(Map<String, Object?> json) read,
) {
if (payload.lengthInBytes == 0) {
return null;
}
try {
final json = jsonDecode(
utf8.decode(GZipCodec().decode(Uint8List.sublistView(payload))),
) as Map<String, Object?>;
final version = json['schemaVersion']! as int;
if (version > daySchemaVersion) {
log('A $what of schema $version skipped', name: 'Days');
return null;
}
return read(json);
} on Object catch (error) {
log('A $what not read: ${error.runtimeType}', name: 'Days');
return null;
}
}
List<Object?> _list(Object? value) => value is List ? value : const [];
double? _double(Object? value) => value is num ? value.toDouble() : null;

View file

@ -0,0 +1,264 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart'
show Namespace, Uuid;
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_payload.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/stored_day.dart';
/// How much of a day's heart rate one `day_series` row holds.
const _chunkHours = 3;
/// The person's days and nights from the Loop (ADR 0030, D37), sealed with
/// their key as their workouts are (D7): one `day` row a date, one `night`
/// row a date, a day's heart rate in rows of three hours sealed for their
/// day; every ID made from the person and the date (and the chunk's place),
/// so a day read again, here or on another phone, writes the same rows, the
/// last writer winning. A row that doesn't open here is left out: what is
/// sealed doesn't say its date.
class DayRepository(
final DayGateway _gateway,
final TrainKeys _keys, {
/// Whose days they are: their auth user ID.
required final String person,
}) {
late final _envelope = PayloadEnvelope(_keys.cipher);
StreamSubscription<void>? _remote;
late final StreamController<void> _changes = StreamController.broadcast(
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
onCancel: () => _remote?.cancel(),
);
/// Fires when the days changed, here or on another device.
Stream<void> get changes => _changes.stream;
String _dayId(DateTime date) => _id('day/$person/${dayKey(date)}');
String _nightId(DateTime date) => _id('night/$person/${dayKey(date)}');
String _chunkId(DateTime date, int index) =>
_id('day/$person/${dayKey(date)}/heart/$index');
String _id(String path) =>
const Uuid().v5(Namespace.url.value, 'https://halkyon.app/train/$path');
/// Every date with a day or a night that opens here, newest first.
Future<List<StoredDay>> all() async {
await _keys.refresh();
final days = <DateTime, DayContent>{
for (final row in await _gateway.days())
if (_open(row.envelope, table: 'day', id: row.id) case final payload?)
if (decodeDay(payload) case final day?)
if (_dayId(day.date) == row.id) day.date: day,
};
final nights = <DateTime, NightContent>{
for (final row in await _gateway.nights())
if (_open(row.envelope, table: 'night', id: row.id) case final payload?)
if (decodeNight(payload) case final night?)
if (_nightId(night.date) == row.id) night.date: night,
};
return [
for (final date in [
...{...days.keys, ...nights.keys},
]..sort((a, b) => b.compareTo(a)))
StoredDay(
id: _dayId(date),
date: date,
day: days[date],
night: nights[date],
),
];
}
/// The day of [date], where one opens here.
Future<DayContent?> day(DateTime date) async {
await _keys.refresh();
return await _day(date);
}
/// The night that ended [date], where one opens here.
Future<NightContent?> night(DateTime date) async {
await _keys.refresh();
final id = _nightId(date);
return switch (await _gateway.night(id)) {
final envelope? => switch (_open(envelope, table: 'night', id: id)) {
final payload? => decodeNight(payload),
null => null,
},
null => null,
};
}
/// The 24/7 heart rate of [date] that opens here, in time order.
Future<List<HeartRun>> heart(DateTime date) async {
await _keys.refresh();
return await _heart(date);
}
/// Keeps what a read of the Loop brought ([lines]): each date's summary,
/// Nightly Recharge, skin temperature and heart rate in place of what was
/// kept of them, what the read didn't bring as it was; each night in place
/// of the one before. Writes nothing where nothing changed; throws
/// [KeysMissing] where this device can't seal.
Future<void> take(LoopLines lines) async {
if (lines.isEmpty && lines.nights.isEmpty) {
return;
}
await _keys.refresh();
String? keyId;
Future<String> key() async => keyId ??= await _keys.keyToWrite();
var changed = false;
for (final date in lines.dates) {
final before = await _day(date) ?? DayContent(date: date);
final summary = lines.summaries[date];
final day = DayContent(
date: date,
steps: summary == null ? before.steps : summary.steps,
metres: summary == null ? before.metres : summary.metres,
activityCalories: summary == null
? before.activityCalories
: summary.activityCalories,
trainingCalories: summary == null
? before.trainingCalories
: summary.trainingCalories,
bmrCalories: summary == null ? before.bmrCalories : summary.bmrCalories,
goal: summary == null ? before.goal : summary.goal,
achieved: summary == null ? before.achieved : summary.achieved,
times: summary == null ? before.times : summary.times,
recharge: lines.recharges[date] ?? before.recharge,
skin: lines.skins[date] ?? before.skin,
);
final heart = lines.hearts[date];
final heartChanged =
heart != null && !listEquals(await _heart(date), _sorted(heart));
if (day == before && !heartChanged) {
continue;
}
final id = _dayId(date);
final keyId = await key();
await _gateway.writeDay(
(
id: id,
envelope: _envelope.seal(
encodeDay(day),
keyId: keyId,
table: 'day',
id: id,
),
),
heartChanged ? _chunks(date, id, heart, keyId) : null,
);
changed = true;
}
for (final night in lines.nights.values) {
final id = _nightId(night.date);
if (await this.night(night.date) == night) {
continue;
}
await _gateway.writeNight((
id: id,
envelope: _envelope.seal(
encodeNight(night),
keyId: await key(),
table: 'night',
id: id,
),
));
changed = true;
}
if (changed) {
_changes.add(null);
}
}
/// Closes what it holds open.
Future<void> dispose() => _changes.close();
Future<DayContent?> _day(DateTime date) async {
final id = _dayId(date);
return switch (await _gateway.day(id)) {
final envelope? => switch (_open(envelope, table: 'day', id: id)) {
final payload? => decodeDay(payload),
null => null,
},
null => null,
};
}
Future<List<HeartRun>> _heart(DateTime date) async {
final dayId = _dayId(date);
return _sorted([
for (final row in await _gateway.seriesOf(dayId))
if (_open(
row.envelope,
table: 'day_series',
id: row.id,
bound: 'day:$dayId',
)
case final payload?)
...?decodeHeart(payload),
]);
}
List<DaySeriesRow> _chunks(
DateTime date,
String dayId,
List<HeartRun> heart,
String keyId,
) => [
for (final MapEntry(:key, :value) in _byChunk(heart).entries)
if (_chunkId(date, key) case final id)
(
id: id,
dayId: dayId,
envelope: _envelope.seal(
encodeHeart(value),
keyId: keyId,
table: 'day_series',
id: id,
bound: 'day:$dayId',
),
),
];
/// The payload of [envelope], opened where it is sealed and opens for
/// where it is kept; null otherwise, a readable one included.
ByteData? _open(
Envelope envelope, {
required String table,
required String id,
String bound = '',
}) {
if (envelope.keyId == null) {
return null;
}
return switch (_envelope.open(
envelope,
table: table,
id: id,
bound: bound,
)) {
Readable(:final payload) => payload,
Locked() => null,
};
}
}
List<HeartRun> _sorted(List<HeartRun> runs) =>
[...runs]..sort((a, b) => a.at.compareTo(b.at));
/// [runs] in time order, by the chunk each falls in.
Map<int, List<HeartRun>> _byChunk(List<HeartRun> runs) {
final chunks = <int, List<HeartRun>>{};
for (final run in _sorted(runs)) {
(chunks[run.at.inHours ~/ _chunkHours] ??= []).add(run);
}
return chunks;
}

View file

@ -0,0 +1,197 @@
import 'dart:convert';
import 'dart:developer';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// What reads of the Loop brought (ADR 0030, D37): the lines `LoopDays.kt`
/// makes, from the method channel or the background job's file, sorted by
/// date; a later line of a kind and date in place of an earlier one.
final class LoopLines() {
/// The dates each read was made on, by the band's calendar.
final reads = <DateTime>[];
/// Each day's summary: a [DayContent] with nothing of its night yet.
final summaries = <DateTime, DayContent>{};
final hearts = <DateTime, List<HeartRun>>{};
final nights = <DateTime, NightContent>{};
final recharges = <DateTime, Recharge>{};
final skins = <DateTime, List<(Duration, double)>>{};
/// Whether nothing but reads came.
bool get isEmpty =>
summaries.isEmpty &&
hearts.isEmpty &&
nights.isEmpty &&
recharges.isEmpty &&
skins.isEmpty;
/// Every date something came for.
Set<DateTime> get dates => {
...summaries.keys,
...hearts.keys,
...recharges.keys,
...skins.keys,
};
/// The last date every one of whose days a read has seen whole: the day
/// before the latest read's; null without a read.
DateTime? get completeUntil => reads.isEmpty
? null
: reads
.reduce((a, b) => a.isAfter(b) ? a : b)
.subtract(const Duration(days: 1));
/// Takes [line], one of `LoopDays.kt`'s; one it can't read is skipped.
void add(Object? line) {
if (line is! Map) {
return;
}
final json = line.cast<String, Object?>();
if (json['kind'] == 'read') {
if (dateOfKey(json['on']) case final on?) {
reads.add(on);
}
return;
}
final date = dateOfKey(json['date']);
if (date == null) {
return;
}
switch (json['kind']) {
case 'summary':
summaries[date] = _summaryOf(date, json);
case 'heart':
hearts[date] = [
for (final run in _list(json['runs']))
if (run is Map && run['at'] is int)
HeartRun(
at: Duration(seconds: run['at']! as int),
trigger: _triggers[run['trigger']] ?? HeartTrigger.unknown,
bpm: [
for (final bpm in _list(run['bpm']))
if (bpm is int) bpm,
],
),
];
case 'sleep':
nights[date] = _nightOf(date, json);
case 'recharge':
recharges[date] = Recharge(
ansStatus: _double(json['ansStatus']),
recovery: _int(json['recovery']),
recoverySubLevel: _int(json['recoverySubLevel']),
ansRate: _int(json['ansRate']),
rri: _int(json['rri']),
rmssd: _int(json['rmssd']),
breathInterval: _int(json['breathInterval']),
baselineRri: _int(json['baselineRri']),
baselineRriSd: _int(json['baselineRriSd']),
baselineRmssd: _int(json['baselineRmssd']),
baselineRmssdSd: _int(json['baselineRmssdSd']),
baselineBreathInterval: _int(json['baselineBreathInterval']),
baselineBreathIntervalSd: _int(json['baselineBreathIntervalSd']),
);
case 'skin':
skins[date] = [
for (final sample in _list(json['samples']))
if (sample case [final num ms, final num celsius])
(Duration(milliseconds: ms.round()), celsius.toDouble()),
];
}
}
/// Takes the background job's file, a line of JSON each; a line it can't
/// read (one cut off as the phone died) is skipped.
void addText(String text) {
for (final line in const LineSplitter().convert(text)) {
if (line.trim().isEmpty) {
continue;
}
try {
add(jsonDecode(line));
} on FormatException {
log('A line of the Loop not read', name: 'Loop');
}
}
}
}
const _triggers = {
'timed': HeartTrigger.timed,
'low_activity': HeartTrigger.lowActivity,
'high_activity': HeartTrigger.highActivity,
'manual': HeartTrigger.manual,
};
const _states = {
'wake': SleepState.wake,
'rem': SleepState.rem,
'nonrem12': SleepState.light,
'nonrem3': SleepState.deep,
};
const _ratings = {
'poorly': SleepRating.poorly,
'somewhat_poorly': SleepRating.somewhatPoorly,
'neither_poorly_nor_well': SleepRating.neither,
'somewhat_well': SleepRating.somewhatWell,
'well': SleepRating.well,
};
const _classes = {
'nonWear': ActivityClass.nonWear,
'sleep': ActivityClass.sleep,
'sedentary': ActivityClass.sedentary,
'light': ActivityClass.light,
'continuousModerate': ActivityClass.continuousModerate,
'intermittentModerate': ActivityClass.intermittentModerate,
'continuousVigorous': ActivityClass.continuousVigorous,
'intermittentVigorous': ActivityClass.intermittentVigorous,
};
DayContent _summaryOf(DateTime date, Map<String, Object?> json) => DayContent(
date: date,
steps: _int(json['steps']),
metres: _double(json['metres']),
activityCalories: _int(json['activityCalories']),
trainingCalories: _int(json['trainingCalories']),
bmrCalories: _int(json['bmrCalories']),
goal: _double(json['goal']),
achieved: _double(json['achieved']),
times: {
if (json['times'] case final Map<Object?, Object?> times)
for (final MapEntry(:key, :value) in times.entries)
if ((_classes[key], value) case (final kind?, final int seconds))
kind: Duration(seconds: seconds),
},
);
NightContent _nightOf(DateTime date, Map<String, Object?> json) => NightContent(
date: date,
start: _time(json['start']),
end: _time(json['end']),
goalMinutes: _int(json['goalMinutes']),
phases: [
for (final phase in _list(json['phases']))
if (phase case [final int seconds, final String state])
(Duration(seconds: seconds), _states[state] ?? SleepState.unknown),
],
cycles: [
for (final cycle in _list(json['cycles']))
if (cycle case [final int seconds, final num depth])
(Duration(seconds: seconds), depth.toDouble()),
],
rating: _ratings[json['rating']],
batteryRanOut: json['batteryRanOut'] as bool?,
skinCelsius: _double(json['skinCelsius']),
skinDeviation: _double(json['skinDeviation']),
);
DateTime? _time(Object? value) =>
value is String ? DateTime.tryParse(value)?.toUtc() : null;
List<Object?> _list(Object? value) => value is List ? value : const [];
int? _int(Object? value) => value is num ? value.round() : null;
double? _double(Object? value) => value is num ? value.toDouble() : null;

View file

@ -0,0 +1,182 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/feature/day/data/day_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [DayGateway] on the person's local database, which the sync engine keeps
/// in step with their other devices.
class SyncDayGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements DayGateway {
@override
Future<List<DayRow>> days() async => [
for (final row in await protocol.Day.db.find(_db, orderBy: (t) => t.id))
(
id: row.id!.uuid,
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
),
];
@override
Future<Envelope?> day(String id) async => switch (await protocol.Day.db
.findById(_db, protocol.UuidValue.withValidation(id))) {
final row? => (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
null => null,
};
@override
Future<List<DaySeriesRow>> seriesOf(String dayId) async => [
for (final row in await protocol.DaySeries.db.find(
_db,
where: (t) => t.dayId.equals(protocol.UuidValue.withValidation(dayId)),
orderBy: (t) => t.id,
))
(
id: row.id!.uuid,
dayId: dayId,
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
),
];
@override
Future<List<DayRow>> nights() async => [
for (final row in await protocol.Night.db.find(_db, orderBy: (t) => t.id))
(
id: row.id!.uuid,
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
),
];
@override
Future<Envelope?> night(String id) async => switch (await protocol.Night.db
.findById(_db, protocol.UuidValue.withValidation(id))) {
final row? => (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
null => null,
};
@override
Future<void> writeDay(DayRow day, List<DaySeriesRow>? series) async {
final dayId = protocol.UuidValue.withValidation(day.id);
await _db.db.transaction((transaction) async {
final envelope = day.envelope;
if (await protocol.Day.db.findById(_db, dayId, transaction: transaction)
case final row?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.Day.db.updateRow(
_db,
row.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
} else {
await protocol.Day.db.insertRow(
_db,
protocol.Day(
id: dayId,
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
transaction: transaction,
);
}
if (series == null) {
return;
}
final kept = {
for (final row in await protocol.DaySeries.db.find(
_db,
where: (t) => t.dayId.equals(dayId),
transaction: transaction,
))
row.id!.uuid: row,
};
for (final row in series) {
final envelope = row.envelope;
if (kept.remove(row.id) case final before?) {
await protocol.DaySeries.db.updateRow(
_db,
before.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
} else {
await protocol.DaySeries.db.insertRow(
_db,
protocol.DaySeries(
id: protocol.UuidValue.withValidation(row.id),
dayId: dayId,
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
transaction: transaction,
);
}
}
// What the day no longer has: blanked first (ADR 0002, rule 2), as the
// instance keeps deleted rows as they last were.
for (final stale in kept.values) {
await protocol.DaySeries.db.updateRow(
_db,
protocol.DaySeries(
id: stale.id,
spaceId: stale.spaceId,
dayId: stale.dayId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
await protocol.DaySeries.db.deleteRow(
_db,
stale,
transaction: transaction,
);
}
});
_sync.requestSync();
}
@override
Future<void> writeNight(DayRow night) async {
final uuid = protocol.UuidValue.withValidation(night.id);
final envelope = night.envelope;
if (await protocol.Night.db.findById(_db, uuid) case final row?) {
await protocol.Night.db.updateRow(
_db,
row.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
} else {
await protocol.Night.db.insertRow(
_db,
protocol.Night(
id: uuid,
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
);
}
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,229 @@
import 'package:equatable/equatable.dart';
/// Polar's classes of the time of a day, from not worn to vigorous.
enum ActivityClass() {
nonWear,
sleep,
sedentary,
light,
continuousModerate,
intermittentModerate,
continuousVigorous,
intermittentVigorous,
}
/// Nightly Recharge (ADR 0030, D37): how the autonomic nervous system
/// settled during the first hours of sleep, against the person's own
/// baseline; each figure as Polar gives it, null where it gave none.
final class const Recharge({
/// From -10 (far below the person's usual) to +10.
final double? ansStatus,
/// Polar's recovery indicator, 1 to 6, with its sub-level.
final int? recovery,
final int? recoverySubLevel,
final int? ansRate,
/// The night's mean beat-to-beat interval and HRV (RMSSD), in ms, and the
/// mean breathing interval, in ms; with their baselines and deviations.
final int? rri,
final int? rmssd,
final int? breathInterval,
final int? baselineRri,
final int? baselineRriSd,
final int? baselineRmssd,
final int? baselineRmssdSd,
final int? baselineBreathInterval,
final int? baselineBreathIntervalSd,
}) with Equatable {
/// The night's mean heart rate, from its mean beat-to-beat interval.
int? get heartRate => switch (rri) {
final ms? when ms > 0 => (60000 / ms).round(),
_ => null,
};
@override
List<Object?> get props => [
ansStatus,
recovery,
recoverySubLevel,
ansRate,
rri,
rmssd,
breathInterval,
baselineRri,
baselineRriSd,
baselineRmssd,
baselineRmssdSd,
baselineBreathInterval,
baselineBreathIntervalSd,
];
}
/// A day as the Loop counted it (D37); what it didn't give is null.
final class const DayContent({
/// The band's date, as UTC midnight.
required final DateTime date,
final int? steps,
final double? metres,
final int? activityCalories,
final int? trainingCalories,
final int? bmrCalories,
/// The day's activity goal and how much of it was reached, in Polar's
/// units (the reached share is their quotient).
final double? goal,
final double? achieved,
final Map<ActivityClass, Duration> times = const {},
/// The night ending this day's Nightly Recharge.
final Recharge? recharge,
/// Skin temperature through the day: each sample's time from the day's
/// first and its degrees Celsius.
final List<(Duration, double)> skin = const [],
}) with Equatable {
/// The time moving: every class above sedentary.
Duration get activeTime => [
ActivityClass.light,
ActivityClass.continuousModerate,
ActivityClass.intermittentModerate,
ActivityClass.continuousVigorous,
ActivityClass.intermittentVigorous,
].fold(Duration.zero, (sum, kind) => sum + (times[kind] ?? Duration.zero));
/// All calories of the day.
int? get calories =>
activityCalories == null &&
trainingCalories == null &&
bmrCalories == null
? null
: (activityCalories ?? 0) + (trainingCalories ?? 0) + (bmrCalories ?? 0);
@override
List<Object?> get props => [
date,
steps,
metres,
activityCalories,
trainingCalories,
bmrCalories,
goal,
achieved,
times,
recharge,
skin,
];
}
/// What a 24/7 measurement was started by.
enum HeartTrigger() {
timed,
lowActivity,
highActivity,
manual,
unknown,
}
/// One 24/7 heart-rate measurement of the Loop's: when it began, in the
/// band's day, what started it and its samples, in beats per minute.
final class const HeartRun({
required final Duration at,
required final HeartTrigger trigger,
required final List<int> bpm,
}) with Equatable {
/// Its samples' mean, as the day's chart shows it; null without one.
int? get mean =>
bpm.isEmpty ? null : (bpm.reduce((a, b) => a + b) / bpm.length).round();
@override
List<Object?> get props => [at, trigger, bpm];
}
/// Polar's states of a night.
enum SleepState() {
wake,
rem,
light,
deep,
unknown,
}
/// How the person rated a night on the band.
enum SleepRating() {
poorly,
somewhatPoorly,
neither,
somewhatWell,
well,
}
/// A night as the Loop analysed it (D37), counted for the day it ended.
final class const NightContent({
/// The date it counts for, as UTC midnight.
required final DateTime date,
final DateTime? start,
final DateTime? end,
final int? goalMinutes,
/// Each phase's start, from the night's, and its state.
final List<(Duration, SleepState)> phases = const [],
/// Each cycle's start and the depth it began at.
final List<(Duration, double)> cycles = const [],
final SleepRating? rating,
final bool? batteryRanOut,
final double? skinCelsius,
final double? skinDeviation,
}) with Equatable {
/// How long it lasted, from falling asleep to waking.
Duration? get length => switch ((start, end)) {
(final from?, final to?) => to.difference(from),
_ => null,
};
/// How long of it was sleep: every phase but those awake.
Duration? get asleep {
final from = start;
final to = end;
if (from == null || to == null || phases.isEmpty) {
return null;
}
var sleep = Duration.zero;
for (var i = 0; i < phases.length; i++) {
final (begin, state) = phases[i];
final until = i + 1 < phases.length
? phases[i + 1].$1
: to.difference(from);
if (state != SleepState.wake && state != SleepState.unknown) {
sleep += until - begin;
}
}
return sleep;
}
@override
List<Object?> get props => [
date,
start,
end,
goalMinutes,
phases,
cycles,
rating,
batteryRanOut,
skinCelsius,
skinDeviation,
];
}
/// [date] as the band names its days: `YYYY-MM-DD`.
String dayKey(DateTime date) =>
'${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}';
/// A band's `YYYY-MM-DD` as UTC midnight; null for anything else.
DateTime? dateOfKey(Object? key) => key is String && key.length == 10
? DateTime.tryParse('${key}T00:00:00Z')
: null;

View file

@ -0,0 +1,16 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// A date of the person's as their devices keep it (ADR 0030, D37): the
/// day the Loop counted and the night that ended it, each null where there
/// is none that opens here.
final class const StoredDay({
/// The day row's ID, made from the person and the date.
required final String id,
required final DateTime date,
final DayContent? day,
final NightContent? night,
}) with Equatable {
@override
List<Object?> get props => [id, date, day, night];
}

View file

@ -0,0 +1,291 @@
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/day/bloc/day_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/day_texts.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/night_chart.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart';
/// Opens the page of the day [date].
Future<void> openDayScreen(BuildContext context, DateTime date) {
final train = PersonScope.of<TrainPerson>(context);
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => BlocProvider(
create: (context) => DayCubit(train.days, date)..open(),
child: DayScreen(date: date),
),
),
);
}
/// A day's page (ADR 0030, D37): its figures and its time in each class,
/// its heart rate as a chart, the night that ended it with its phases and
/// rating, its Nightly Recharge and its skin temperature.
class const DayScreen({required final DateTime date, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
return Scaffold(
appBar: AppBar(title: Text(dayTitle(locale, date))),
body: BlocBuilder<DayCubit, DayState>(
builder: (context, state) {
if (!state.loaded) {
return const Center(child: AppLoadingIndicator());
}
final day = state.day;
final night = state.night;
final sections = [
if (day != null) _Figures(day),
if (day != null && day.times.isNotEmpty) _Times(day),
if (state.heart.any((run) => run.mean != null)) _Heart(state.heart),
if (night != null) _Night(night),
if (day?.recharge case final recharge?) _Recharge(recharge),
if (day != null && day.skin.length > 1) _Skin(day.skin),
];
if (sections.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(l10n.dayNothing, textAlign: TextAlign.center),
),
);
}
return ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
for (final section in sections)
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: section,
),
),
),
],
);
},
),
);
}
}
/// A section's heading and its lines.
class const _Section({
required final String title,
required final List<Widget> children,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...children,
],
);
}
class const _Line(final String _text) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
_text,
style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()]),
),
);
}
class const _Figures(final DayContent _day) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final day = _day;
final goal = day.goal;
final achieved = day.achieved;
final figures = [
if (day.steps case final steps?) l10n.daysSteps(steps),
if (day.metres case final metres?)
distanceText(l10n, locale, metres.round()),
if (day.calories case final calories?) l10n.dayCalories(calories),
if (goal != null && goal > 0 && achieved != null)
l10n.dayGoal((achieved / goal * 100).round()),
];
return Wrap(
spacing: 24,
runSpacing: 8,
children: [
for (final figure in figures)
Text(
figure,
style: theme.textTheme.titleMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
);
}
}
/// The day's time in Polar's classes, the continuous and the intermittent
/// together.
class const _Times(final DayContent _day) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
Duration of(List<ActivityClass> kinds) => kinds.fold(
Duration.zero,
(sum, kind) => sum + (_day.times[kind] ?? Duration.zero),
);
final rows = [
(
l10n.dayTimeVigorous,
of(const [
ActivityClass.continuousVigorous,
ActivityClass.intermittentVigorous,
]),
),
(
l10n.dayTimeModerate,
of(const [
ActivityClass.continuousModerate,
ActivityClass.intermittentModerate,
]),
),
(l10n.dayTimeLight, of(const [ActivityClass.light])),
(l10n.dayTimeSedentary, of(const [ActivityClass.sedentary])),
(l10n.dayTimeSleep, of(const [ActivityClass.sleep])),
(l10n.dayTimeNonWear, of(const [ActivityClass.nonWear])),
];
return _Section(
title: l10n.dayTimes,
children: [
for (final (name, time) in rows)
if (time > Duration.zero)
Row(
children: [
Expanded(child: Text(name)),
_Line(hoursMinutes(l10n, time)),
],
),
],
);
}
}
/// The day's 24/7 heart rate: each measurement's mean at its start.
class const _Heart(final List<HeartRun> _runs) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return WorkoutChart(
title: l10n.dayHeartRate,
points: [
for (final run in _runs)
if (run.mean case final mean?)
(x: run.at.inSeconds.toDouble(), value: mean.toDouble()),
],
label: (value) => l10n.dayBpm(value.round()),
);
}
}
class const _Night(final NightContent _night) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final time = DateFormat.Hm(locale);
final night = _night;
final start = night.start;
final end = night.end;
return _Section(
title: l10n.dayNight,
children: [
if ((start, end) case (final from?, final to?))
_Line(
l10n.dayNightSpan(
time.format(from.toLocal()),
time.format(to.toLocal()),
),
),
if (night.asleep case final asleep?)
_Line(l10n.daysAsleep(hoursMinutes(l10n, asleep))),
if (night.rating case final rating?) _Line(ratingText(l10n, rating)),
if (night.length case final length?
when night.phases.isNotEmpty && length > Duration.zero)
Padding(
padding: const EdgeInsets.only(top: 12),
child: NightChart(phases: night.phases, length: length),
),
],
);
}
}
class const _Recharge(final Recharge _recharge) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final recharge = _recharge;
final usualHeart = switch (recharge.baselineRri) {
final ms? when ms > 0 => (60000 / ms).round(),
_ => null,
};
return _Section(
title: l10n.dayRecharge,
children: [
if (recharge.recovery case final level?)
_Line(l10n.dayRechargeLevel(level)),
if (recharge.ansStatus case final status?)
_Line(l10n.dayAnsStatus(decimal(locale, status, signed: true))),
if ((recharge.heartRate, usualHeart) case (
final bpm?,
final usual?,
))
_Line(l10n.dayNightHeart(bpm, usual)),
if ((recharge.rmssd, recharge.baselineRmssd) case (
final ms?,
final usual?,
))
_Line(l10n.dayHrv(ms, usual)),
if (recharge.breathInterval case final ms? when ms > 0)
_Line(l10n.dayBreathing(decimal(locale, 60000 / ms))),
],
);
}
}
/// Skin temperature through the day, by the time from its first sample.
class const _Skin(final List<(Duration, double)> _samples)
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
return WorkoutChart(
title: l10n.daySkin,
points: [
for (final (at, celsius) in _samples)
(x: at.inSeconds.toDouble(), value: celsius),
],
label: (value) => l10n.dayCelsius(decimal(locale, value)),
);
}
}

View file

@ -0,0 +1,42 @@
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:intl/intl.dart';
/// [time] in hours and minutes, or minutes alone under an hour.
String hoursMinutes(AppLocalizations l10n, Duration time) {
final minutes = time.inMinutes;
return minutes < 60
? l10n.dayMinutes(minutes)
: l10n.dayHoursMinutes(minutes ~/ 60, minutes % 60);
}
String sleepStateName(AppLocalizations l10n, SleepState state) =>
switch (state) {
SleepState.wake || SleepState.unknown => l10n.daySleepWake,
SleepState.rem => l10n.daySleepRem,
SleepState.light => l10n.daySleepLight,
SleepState.deep => l10n.daySleepDeep,
};
String ratingText(AppLocalizations l10n, SleepRating rating) =>
switch (rating) {
SleepRating.poorly => l10n.dayRatingPoorly,
SleepRating.somewhatPoorly => l10n.dayRatingSomewhatPoorly,
SleepRating.neither => l10n.dayRatingNeither,
SleepRating.somewhatWell => l10n.dayRatingSomewhatWell,
SleepRating.well => l10n.dayRatingWell,
};
/// The band's date [date] (UTC midnight) as the person reads it: its
/// weekday, day and month.
String dayTitle(String locale, DateTime date) =>
DateFormat.MMMMEEEEd(locale).format(date);
/// [value] with one decimal, a sign where [signed].
String decimal(String locale, double value, {bool signed = false}) {
final text = NumberFormat('0.0', locale).format(value.abs());
if (!signed || value == 0) {
return value < 0 ? '−$text' : text;
}
return value < 0 ? '−$text' : '+$text';
}

View file

@ -0,0 +1,102 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/stored_day.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/day_screen.dart';
import 'package:halkyon_train_flutter/src/feature/day/widget/day_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The person's days as home lists them (ADR 0030, D37): newest first, each
/// with its steps and active time, and the night that ended it with its
/// sleep and Nightly Recharge. Pulling reads the band, where this phone
/// holds one.
class const DaysList({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocBuilder<DaysCubit, DaysState>(
builder: (context, state) {
if (!state.loaded) {
return const Center(child: AppLoadingIndicator());
}
final list = state.days.isEmpty
? ListView(
children: [
Padding(
padding: const EdgeInsets.only(top: 64),
child: EmptyListMessage(
icon: LucideIcons.calendarDays,
title: l10n.daysEmptyTitle,
hint: l10n.daysEmptyHint,
),
),
],
)
: ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
for (final day in state.days)
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: _DayTile(day),
),
),
],
);
if (!state.holdsLoop) {
return list;
}
return RefreshIndicator(
onRefresh: () => context.read<DaysCubit>().readBand(),
child: list,
);
},
);
}
}
/// A day: its date, and its figures as far as the band gave them.
class const _DayTile(final StoredDay _stored) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final day = _stored.day;
final night = _stored.night;
final active = day?.activeTime;
final figures = [
if (day?.steps case final steps?) l10n.daysSteps(steps),
if (active != null && active > Duration.zero)
l10n.daysActive(hoursMinutes(l10n, active)),
if (night?.asleep case final asleep?)
l10n.daysAsleep(hoursMinutes(l10n, asleep)),
if (day?.recharge?.recovery case final level?) l10n.daysRecharge(level),
];
return ListTile(
leading: const Icon(LucideIcons.calendarDays),
title: Text(dayTitle(locale, _stored.date)),
subtitle: figures.isEmpty
? null
: Wrap(
spacing: 16,
children: [
for (final figure in figures)
Text(
figure,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
onTap: () => openDayScreen(context, _stored.date),
);
}
}

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