feat(train): a workout keeps its plan's steps as laps, shown on its page (ADR 0030, D43)
As a recording with a plan is saved, each step run becomes a lap in the workout's sealed payload: the step, its round, when it started and ended, its moving time and distance, with the highest heart rate the zones ran with. The page lists the steps with their pace, speed or split, stroke rate, watts and heart rate, marks whether each held its target, and the charts shade the work steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
d9f98449a8
commit
e1304975d6
22 changed files with 840 additions and 38 deletions
|
|
@ -7,6 +7,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
|
||||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app whose home lists their workouts (`WorkoutsCubit`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
|
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app whose home lists their workouts (`WorkoutsCubit`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
|
||||||
- **Recording** (ADR 0030, D13, D16, D18 to D21, D26 to D28) is Android's alone for now (iOS waits for a Mac, D25) (`TrainPerson.recording` null elsewhere): `RecorderService` (Kotlin, `android/app/src/main/kotlin/app/halkyon/train/`), a foreground service of the types `location` and `health` started from the open app, takes fixes from LocationManager's GPS (no Play services), steps from the step detector the heart rate from the strap and a row from the rower the person chose (ADR 0030, D26 to D28: `NotifyLink.kt` on Android's own Bluetooth API, no package, `HeartRateMeasurement.kt` parsing 0x2A37 and `RowerData.kt` FTMS's Rower Data 0x2AD1; `DeviceScanner.kt` finds them for the sheets over `…/recorder/straps` and `…/recorder/rowers`; each remembered in the person's `strap.json` and `rower.json`, `DeviceStore`), decides auto-pause (`AutoPause.kt`) and appends each sample as a line to the person's `recording.txt` (format in `recording_file.dart`), flushed every 5 s; `MainActivity` asks the permissions (never background location) and bridges `app.halkyon.train/recorder` (methods) and `…/recorder/lines` (the file so far, then each line). The app (`lib/src/feature/recording/`) follows those lines with `RecordingBloc`, works out what the screen and the notification show (`liveOf`, the saved totals' functions), and saves a recording as a workout and its series (`recordedWorkout`) or throws it away; one cut off, or stopped and not saved, is found as the person's app opens. Tests: the Dart side with `test/support/fake_recorder_platform.dart`; the Kotlin auto-pause and the strap's and the rower's parsers on the JVM, `JAVA_HOME=<Android Studio's jbr> ./gradlew :app:testDebugUnitTest` in `android/` after a first `flutter build apk` (which writes `gradlew`), not part of `tool/check.dart`, as CI's runner has no Android SDK.
|
- **Recording** (ADR 0030, D13, D16, D18 to D21, D26 to D28) is Android's alone for now (iOS waits for a Mac, D25) (`TrainPerson.recording` null elsewhere): `RecorderService` (Kotlin, `android/app/src/main/kotlin/app/halkyon/train/`), a foreground service of the types `location` and `health` started from the open app, takes fixes from LocationManager's GPS (no Play services), steps from the step detector the heart rate from the strap and a row from the rower the person chose (ADR 0030, D26 to D28: `NotifyLink.kt` on Android's own Bluetooth API, no package, `HeartRateMeasurement.kt` parsing 0x2A37 and `RowerData.kt` FTMS's Rower Data 0x2AD1; `DeviceScanner.kt` finds them for the sheets over `…/recorder/straps` and `…/recorder/rowers`; each remembered in the person's `strap.json` and `rower.json`, `DeviceStore`), decides auto-pause (`AutoPause.kt`) and appends each sample as a line to the person's `recording.txt` (format in `recording_file.dart`), flushed every 5 s; `MainActivity` asks the permissions (never background location) and bridges `app.halkyon.train/recorder` (methods) and `…/recorder/lines` (the file so far, then each line). The app (`lib/src/feature/recording/`) follows those lines with `RecordingBloc`, works out what the screen and the notification show (`liveOf`, the saved totals' functions), and saves a recording as a workout and its series (`recordedWorkout`) or throws it away; one cut off, or stopped and not saved, is found as the person's app opens. Tests: the Dart side with `test/support/fake_recorder_platform.dart`; the Kotlin auto-pause and the strap's and the rower's parsers on the JVM, `JAVA_HOME=<Android Studio's jbr> ./gradlew :app:testDebugUnitTest` in `android/` after a first `flutter build apk` (which writes `gradlew`), not part of `tool/check.dart`, as CI's runner has no Android SDK.
|
||||||
- **Health Connect** (ADR 0030, D30, D31) is Android's (`TrainPerson.health` null elsewhere): `HealthConnectBridge.kt` on AndroidX's `connect-client` 1.1.0 over `app.halkyon.train/health` (its records' mapping in `HealthRecords.kt`, tested on the JVM; the rationale's links from Health Connect in the manifest, `…/health/rationale` to the app), `HealthPlatform` in Dart (`lib/src/feature/health/`), `HealthConnect` per person: with writing on, every workout written as `healthRecordsOf` makes it (client IDs from the workout's ID, a version counted up as it changes, a fingerprint deciding whether it did), written again or deleted as the workouts change and as the app opens, what was written kept in the person's `health_connect.json` (`HealthStore`, never synced); 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.
|
- **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; 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; its training sessions (D39) read with the days and kept as workouts of origin *Polar Loop* (`loop_workouts.dart`: `takeLoopSessions`, Polar's sport 1 and 2 as running and cycling, the rest as `Activity.other`, which the edit sheet changes; a double of a recorded workout gives it its heart rate), remembered in `loop.json` (`takenSessions`), written to Health Connect as Train's own; its firmware (D10, D40) asked for on the day's first connection and on the band's screen, from Polar directly (the SDK) or through the instance as the person chose (`KnownLoop.firmwareVia`, `FirmwareRelayGateway` on the server's `firmware` endpoint), updated only after asking, the band read and kept first and set up again after (`PolarLoop.updateFirmware`, `LoopFirmware.kt`); its live heart rate (D41) read by the recording service through the same SDK (`PolarSdk.kt`, one for the process, held by the bridge and by a recording; `LoopHeart.kt`) while no strap sends, the file's `loop` lines its state; 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: …)`. **Interval plans** (D42, `lib/src/feature/plan/`): home's *Plans* tab (`PlansCubit`, deletions undone for a moment as workouts' are), a plan's editor (`PlanEditorCubit`, `PlanScreen`, a step on `showStepSheet`) and three templates (`plan_templates.dart`); `PlanRepository` seals each as a `plan` row (payload in `plan_payload.dart`, the step's JSON shared with the recorder). A recording runs the plan chosen on the Record sheet in its service: `recorderPlanOf` (`plan_speech.dart`) spells out its run with zones as heart rates and the spoken lines, `PlanRunner.kt` runs it (tested on the JVM), `PlanCues.kt` gives the tone, vibration and voice; the file's `plan` and `step` lines bring it back (`Recording.plan`, `planSteps`), `LivePlan` is the step on the recording screen, and *Lap* ends a step (`RecorderPlatform.lap`). 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; its training sessions (D39) read with the days and kept as workouts of origin *Polar Loop* (`loop_workouts.dart`: `takeLoopSessions`, Polar's sport 1 and 2 as running and cycling, the rest as `Activity.other`, which the edit sheet changes; a double of a recorded workout gives it its heart rate), remembered in `loop.json` (`takenSessions`), written to Health Connect as Train's own; its firmware (D10, D40) asked for on the day's first connection and on the band's screen, from Polar directly (the SDK) or through the instance as the person chose (`KnownLoop.firmwareVia`, `FirmwareRelayGateway` on the server's `firmware` endpoint), updated only after asking, the band read and kept first and set up again after (`PolarLoop.updateFirmware`, `LoopFirmware.kt`); its live heart rate (D41) read by the recording service through the same SDK (`PolarSdk.kt`, one for the process, held by the bridge and by a recording; `LoopHeart.kt`) while no strap sends, the file's `loop` lines its state; 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: …)`. **Interval plans** (D42, `lib/src/feature/plan/`): home's *Plans* tab (`PlansCubit`, deletions undone for a moment as workouts' are), a plan's editor (`PlanEditorCubit`, `PlanScreen`, a step on `showStepSheet`) and three templates (`plan_templates.dart`); `PlanRepository` seals each as a `plan` row (payload in `plan_payload.dart`, the step's JSON shared with the recorder). A recording runs the plan chosen on the Record sheet in its service: `recorderPlanOf` (`plan_speech.dart`) spells out its run with zones as heart rates and the spoken lines, `PlanRunner.kt` runs it (tested on the JVM), `PlanCues.kt` gives the tone, vibration and voice; the file's `plan` and `step` lines bring it back (`Recording.plan`, `planSteps`), `LivePlan` is the step on the recording screen, and *Lap* ends a step (`RecorderPlatform.lap`). Saved, the steps are the workout's laps (D43, `WorkoutPlan` in its payload, worked out in `recordedWorkout`), and its page lists them with their figures and whether each held its target (`LapFigures` in `workout_analysis.dart`), the charts shading the work steps. 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.
|
- **The icon** is Lucide's `bike` fading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py train` from the root): `assets/icon/halkyon_train_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts Lucide's notice in the licence list.
|
||||||
- **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Train's development server has ports of its own (API 8180, Insights 8181, web 8182, PostgreSQL 8190; the app's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time.
|
- **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Train's development server has ports of its own (API 8180, Insights 8181, web 8182, PostgreSQL 8190; the app's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time.
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@
|
||||||
"workoutClimb": "Aufstieg",
|
"workoutClimb": "Aufstieg",
|
||||||
"workoutClimbValue": "{metres} m",
|
"workoutClimbValue": "{metres} m",
|
||||||
"workoutSplits": "Abschnitte",
|
"workoutSplits": "Abschnitte",
|
||||||
|
"workoutSteps": "Schritte aus {plan}",
|
||||||
|
"workoutOnTarget": "Im Ziel",
|
||||||
|
"workoutOffTarget": "Neben dem Ziel",
|
||||||
"workoutSplitKm": "km {number}",
|
"workoutSplitKm": "km {number}",
|
||||||
"workoutSplitFiveKm": "{from}–{to} km",
|
"workoutSplitFiveKm": "{from}–{to} km",
|
||||||
"workoutPaceChart": "Tempo",
|
"workoutPaceChart": "Tempo",
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,10 @@
|
||||||
"workoutClimbValue": "{metres} m",
|
"workoutClimbValue": "{metres} m",
|
||||||
"@workoutClimbValue": {"placeholders": {"metres": {"type": "int"}}},
|
"@workoutClimbValue": {"placeholders": {"metres": {"type": "int"}}},
|
||||||
"workoutSplits": "Splits",
|
"workoutSplits": "Splits",
|
||||||
|
"workoutSteps": "Steps of {plan}",
|
||||||
|
"@workoutSteps": {"placeholders": {"plan": {"type": "String"}}},
|
||||||
|
"workoutOnTarget": "On target",
|
||||||
|
"workoutOffTarget": "Off target",
|
||||||
"workoutSplitKm": "km {number}",
|
"workoutSplitKm": "km {number}",
|
||||||
"@workoutSplitKm": {"description": "A kilometre's split, its number.", "placeholders": {"number": {"type": "int"}}},
|
"@workoutSplitKm": {"description": "A kilometre's split, its number.", "placeholders": {"number": {"type": "int"}}},
|
||||||
"workoutSplitFiveKm": "{from}–{to} km",
|
"workoutSplitFiveKm": "{from}–{to} km",
|
||||||
|
|
|
||||||
|
|
@ -556,6 +556,24 @@ abstract class AppLocalizations {
|
||||||
/// **'Splits'**
|
/// **'Splits'**
|
||||||
String get workoutSplits;
|
String get workoutSplits;
|
||||||
|
|
||||||
|
/// No description provided for @workoutSteps.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Steps of {plan}'**
|
||||||
|
String workoutSteps(String plan);
|
||||||
|
|
||||||
|
/// No description provided for @workoutOnTarget.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'On target'**
|
||||||
|
String get workoutOnTarget;
|
||||||
|
|
||||||
|
/// No description provided for @workoutOffTarget.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Off target'**
|
||||||
|
String get workoutOffTarget;
|
||||||
|
|
||||||
/// A kilometre's split, its number.
|
/// A kilometre's split, its number.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get workoutSplits => 'Abschnitte';
|
String get workoutSplits => 'Abschnitte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String workoutSteps(String plan) {
|
||||||
|
return 'Schritte aus $plan';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workoutOnTarget => 'Im Ziel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workoutOffTarget => 'Neben dem Ziel';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String workoutSplitKm(int number) {
|
String workoutSplitKm(int number) {
|
||||||
return 'km $number';
|
return 'km $number';
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get workoutSplits => 'Splits';
|
String get workoutSplits => 'Splits';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String workoutSteps(String plan) {
|
||||||
|
return 'Steps of $plan';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workoutOnTarget => 'On target';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workoutOffTarget => 'Off target';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String workoutSplitKm(int number) {
|
String workoutSplitKm(int number) {
|
||||||
return 'km $number';
|
return 'km $number';
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ PlanContent? decodePlan(ByteData payload) {
|
||||||
when repeat > 0)
|
when repeat > 0)
|
||||||
PlanBlock(
|
PlanBlock(
|
||||||
repeat: repeat,
|
repeat: repeat,
|
||||||
steps: [for (final step in steps) ?_stepOf(step)],
|
steps: [for (final step in steps) ?planStepOf(step)],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -83,7 +83,9 @@ PlanContent? decodePlan(ByteData payload) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PlanStep? _stepOf(Object? json) {
|
/// The step [json] holds, as [planStepJson] writes it; null where it can't
|
||||||
|
/// read it.
|
||||||
|
PlanStep? planStepOf(Object? json) {
|
||||||
if (json is! Map<String, Object?>) {
|
if (json is! Map<String, Object?>) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ int planMaxHeartRate(ProfileContent? profile, DateTime today) =>
|
||||||
defaultMaxHeartRate(profile?.ageOn(today) ?? _guessedAge);
|
defaultMaxHeartRate(profile?.ageOn(today) ?? _guessedAge);
|
||||||
|
|
||||||
/// [plan] as the recorder runs it (ADR 0030, D42), in the app's language
|
/// [plan] as the recorder runs it (ADR 0030, D42), in the app's language
|
||||||
/// [locale] (a BCP 47 tag): `plan`, its payload, which the recording file
|
/// [locale] (a BCP 47 tag): `plan`, its payload and [maxHeartRate], which
|
||||||
/// keeps; `steps`, the run spelled out, each with its end (`seconds` or
|
/// the recording file keeps; `steps`, the run spelled out, each with its end (`seconds` or
|
||||||
/// `metres`, neither for a lap tap), its target, zones as the heart rates
|
/// `metres`, neither for a lap tap), its target, zones as the heart rates
|
||||||
/// they span for [maxHeartRate], and what is said as it starts; `say`, the
|
/// they span for [maxHeartRate], and what is said as it starts; `say`, the
|
||||||
/// words for off target and for the end.
|
/// words for off target and for the end.
|
||||||
|
|
@ -31,7 +31,12 @@ Map<String, Object?> recorderPlanOf(
|
||||||
PlanContent plan, {
|
PlanContent plan, {
|
||||||
required int maxHeartRate,
|
required int maxHeartRate,
|
||||||
}) => {
|
}) => {
|
||||||
'plan': jsonDecode(utf8.decode(Uint8List.sublistView(encodePlan(plan)))),
|
'plan': {
|
||||||
|
...jsonDecode(utf8.decode(Uint8List.sublistView(encodePlan(plan))))
|
||||||
|
as Map<String, Object?>,
|
||||||
|
// For the workout's laps, which say whether a zone was held (D43).
|
||||||
|
'maxHeartRate': maxHeartRate,
|
||||||
|
},
|
||||||
'offTargetCues': plan.offTargetCues,
|
'offTargetCues': plan.offTargetCues,
|
||||||
'locale': locale,
|
'locale': locale,
|
||||||
'steps': [
|
'steps': [
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ LivePlan? _planOf(Recording recording, DateTime now) {
|
||||||
return LivePlan(name: plan.name, index: run.length, count: run.length);
|
return LivePlan(name: plan.name, index: run.length, count: run.length);
|
||||||
}
|
}
|
||||||
final step = run[index];
|
final step = run[index];
|
||||||
final before = _until(recording, mark.time);
|
final before = recordingUntil(recording, mark.time);
|
||||||
return LivePlan(
|
return LivePlan(
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
step: step,
|
step: step,
|
||||||
|
|
@ -124,22 +124,3 @@ LivePlan? _planOf(Recording recording, DateTime now) {
|
||||||
}
|
}
|
||||||
|
|
||||||
Duration _atLeastZero(Duration time) => time.isNegative ? Duration.zero : time;
|
Duration _atLeastZero(Duration time) => time.isNegative ? Duration.zero : time;
|
||||||
|
|
||||||
/// [recording] as it stood at [time]: its fixes, frames and pauses until
|
|
||||||
/// then.
|
|
||||||
Recording _until(Recording recording, DateTime time) => Recording(
|
|
||||||
activity: recording.activity,
|
|
||||||
startedAt: recording.startedAt,
|
|
||||||
fixes: [
|
|
||||||
for (final fix in recording.fixes)
|
|
||||||
if (!fix.time.isAfter(time)) fix,
|
|
||||||
],
|
|
||||||
pauses: [
|
|
||||||
for (final mark in recording.pauses)
|
|
||||||
if (!mark.time.isAfter(time)) mark,
|
|
||||||
],
|
|
||||||
rowing: [
|
|
||||||
for (final sample in recording.rowing)
|
|
||||||
if (!sample.time.isAfter(time)) sample,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const maxAccuracyCm = 2000;
|
||||||
/// [recording] as the workout it saves (D14) and its series: every sample
|
/// [recording] as the workout it saves (D14) and its series: every sample
|
||||||
/// kept (D16, D20), the distance through the fixes precise enough and never
|
/// kept (D16, D20), the distance through the fixes precise enough and never
|
||||||
/// across a pause, the moving time without the pauses. One cut off ends at
|
/// across a pause, the moving time without the pauses. One cut off ends at
|
||||||
/// its last sample (D18).
|
/// its last sample (D18). A plan's steps are its laps (D43).
|
||||||
(WorkoutContent, List<SeriesChunk>) recordedWorkout(Recording recording) {
|
(WorkoutContent, List<SeriesChunk>) recordedWorkout(Recording recording) {
|
||||||
final endedAt = recording.endedAt ?? recording.lastSampleAt;
|
final endedAt = recording.endedAt ?? recording.lastSampleAt;
|
||||||
return (
|
return (
|
||||||
|
|
@ -21,11 +21,82 @@ const maxAccuracyCm = 2000;
|
||||||
endedAt: endedAt,
|
endedAt: endedAt,
|
||||||
distanceMetres: distanceOf(recording),
|
distanceMetres: distanceOf(recording),
|
||||||
movingTime: movingTimeOf(recording, endedAt),
|
movingTime: movingTimeOf(recording, endedAt),
|
||||||
|
plan: _planOf(recording, endedAt),
|
||||||
),
|
),
|
||||||
seriesOf(recording),
|
seriesOf(recording),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [recording]'s plan as it ran until [endedAt]: a lap from each step's
|
||||||
|
/// start to the next's, the plan's end or the recording's, its moving time
|
||||||
|
/// and distance counted as the workout's are.
|
||||||
|
WorkoutPlan? _planOf(Recording recording, DateTime endedAt) {
|
||||||
|
final plan = recording.plan;
|
||||||
|
if (plan == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final run = plan.run;
|
||||||
|
final marks = recording.planSteps;
|
||||||
|
final measured = distanceOf(recording) != null;
|
||||||
|
final laps = <WorkoutLap>[];
|
||||||
|
for (final (i, mark) in marks.indexed) {
|
||||||
|
final index = mark.index;
|
||||||
|
if (index == null || index >= run.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final next = i + 1 < marks.length ? marks[i + 1].time : endedAt;
|
||||||
|
final end = next.isAfter(endedAt) ? endedAt : next;
|
||||||
|
if (!end.isAfter(mark.time)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final (before, after) = (
|
||||||
|
recordingUntil(recording, mark.time),
|
||||||
|
recordingUntil(recording, end),
|
||||||
|
);
|
||||||
|
laps.add(
|
||||||
|
WorkoutLap(
|
||||||
|
step: run[index].step,
|
||||||
|
round: run[index].round,
|
||||||
|
rounds: run[index].rounds,
|
||||||
|
startedAt: mark.time,
|
||||||
|
endedAt: end,
|
||||||
|
movingTime: movingTimeOf(after, end) - movingTimeOf(before, mark.time),
|
||||||
|
metres: measured
|
||||||
|
? (distanceOf(after) ?? 0) - (distanceOf(before) ?? 0)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return WorkoutPlan(
|
||||||
|
name: plan.name,
|
||||||
|
laps: laps,
|
||||||
|
maxHeartRate: recording.planMaxHeartRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [recording] as it stood at [time]: its fixes, heart rates, frames and
|
||||||
|
/// pauses until then.
|
||||||
|
Recording recordingUntil(Recording recording, DateTime time) => Recording(
|
||||||
|
activity: recording.activity,
|
||||||
|
startedAt: recording.startedAt,
|
||||||
|
fixes: [
|
||||||
|
for (final fix in recording.fixes)
|
||||||
|
if (!fix.time.isAfter(time)) fix,
|
||||||
|
],
|
||||||
|
heartRates: [
|
||||||
|
for (final rate in recording.heartRates)
|
||||||
|
if (!rate.time.isAfter(time)) rate,
|
||||||
|
],
|
||||||
|
pauses: [
|
||||||
|
for (final mark in recording.pauses)
|
||||||
|
if (!mark.time.isAfter(time)) mark,
|
||||||
|
],
|
||||||
|
rowing: [
|
||||||
|
for (final sample in recording.rowing)
|
||||||
|
if (!sample.time.isAfter(time)) sample,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
/// How far it went in metres: on the rower, the metres it counted while
|
/// How far it went in metres: on the rower, the metres it counted while
|
||||||
/// moving (D28); else the track's length, null without fixes, from fix to fix
|
/// moving (D28); else the track's length, null without fixes, from fix to fix
|
||||||
/// while it moves, the imprecise ones left out, starting afresh after each
|
/// while it moves, the imprecise ones left out, starting afresh after each
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ Recording? readRecording(String text) {
|
||||||
final rowing = <RowingSample>[];
|
final rowing = <RowingSample>[];
|
||||||
final rowers = <LinkMark>[];
|
final rowers = <LinkMark>[];
|
||||||
PlanContent? plan;
|
PlanContent? plan;
|
||||||
|
int? planMaxHeartRate;
|
||||||
final planSteps = <StepMark>[];
|
final planSteps = <StepMark>[];
|
||||||
DateTime? endedAt;
|
DateTime? endedAt;
|
||||||
for (final line in lines.skip(1)) {
|
for (final line in lines.skip(1)) {
|
||||||
|
|
@ -75,11 +76,12 @@ Recording? readRecording(String text) {
|
||||||
}
|
}
|
||||||
if (fields.first == 'plan' && fields.length > 2) {
|
if (fields.first == 'plan' && fields.length > 2) {
|
||||||
// Its JSON may hold spaces: all after the time.
|
// Its JSON may hold spaces: all after the time.
|
||||||
plan = decodePlan(
|
final json = line.substring('plan '.length + fields[1].length + 1);
|
||||||
ByteData.sublistView(
|
plan = decodePlan(ByteData.sublistView(utf8.encode(json)));
|
||||||
utf8.encode(line.substring('plan '.length + fields[1].length + 1)),
|
planMaxHeartRate = switch (_jsonOf(json)) {
|
||||||
),
|
{'maxHeartRate': final int max} => max,
|
||||||
);
|
_ => null,
|
||||||
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
switch (fields) {
|
switch (fields) {
|
||||||
|
|
@ -154,12 +156,21 @@ Recording? readRecording(String text) {
|
||||||
rowers: rowers,
|
rowers: rowers,
|
||||||
plan: plan,
|
plan: plan,
|
||||||
planSteps: planSteps,
|
planSteps: planSteps,
|
||||||
|
planMaxHeartRate: planMaxHeartRate,
|
||||||
endedAt: endedAt,
|
endedAt: endedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object? _jsonOf(String text) {
|
||||||
|
try {
|
||||||
|
return jsonDecode(text);
|
||||||
|
} on FormatException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DateTime? _time(String field) => switch (int.tryParse(field)) {
|
DateTime? _time(String field) => switch (int.tryParse(field)) {
|
||||||
final ms? => DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true),
|
final ms? => DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true),
|
||||||
null => null,
|
null => null,
|
||||||
|
|
|
||||||
|
|
@ -115,9 +115,11 @@ final class const Recording({
|
||||||
final List<RowingSample> rowing = const [],
|
final List<RowingSample> rowing = const [],
|
||||||
final List<LinkMark> rowers = const [],
|
final List<LinkMark> rowers = const [],
|
||||||
|
|
||||||
/// The interval plan it ran, where it ran one, and its steps' starts (D42).
|
/// The interval plan it ran, where it ran one, its steps' starts, and
|
||||||
|
/// the highest heart rate its zones were worked out from (D42).
|
||||||
final PlanContent? plan,
|
final PlanContent? plan,
|
||||||
final List<StepMark> planSteps = const [],
|
final List<StepMark> planSteps = const [],
|
||||||
|
final int? planMaxHeartRate,
|
||||||
|
|
||||||
/// When the person stopped it; null for one cut off (D18).
|
/// When the person stopped it; null for one cut off (D18).
|
||||||
final DateTime? endedAt,
|
final DateTime? endedAt,
|
||||||
|
|
@ -150,6 +152,7 @@ final class const Recording({
|
||||||
rowers,
|
rowers,
|
||||||
plan,
|
plan,
|
||||||
planSteps,
|
planSteps,
|
||||||
|
planMaxHeartRate,
|
||||||
endedAt,
|
endedAt,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.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/series_chunk.dart';
|
||||||
|
|
@ -33,6 +34,23 @@ final class const Split({
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A plan's step as a workout ran it, with its figures (D43): the heart's
|
||||||
|
/// average rate, on the rower the average stroke rate and watts, and
|
||||||
|
/// whether it held its target (null without one, or without the figure).
|
||||||
|
final class const LapFigures({
|
||||||
|
required final WorkoutLap lap,
|
||||||
|
final int? averageBpm,
|
||||||
|
final double? strokeRate,
|
||||||
|
final int? watts,
|
||||||
|
final bool? held,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [lap, averageBpm, strokeRate, watts, held];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where on a chart's x a stretch runs, from `from` to `to`.
|
||||||
|
typedef ChartBand = ({double from, double to});
|
||||||
|
|
||||||
/// A point of a chart: `x` metres in (or seconds of moving time, where a
|
/// A point of a chart: `x` metres in (or seconds of moving time, where a
|
||||||
/// chart runs over the time), and its `value`.
|
/// chart runs over the time), and its `value`.
|
||||||
typedef ChartPoint = ({double x, double value});
|
typedef ChartPoint = ({double x, double value});
|
||||||
|
|
@ -76,6 +94,11 @@ final class const WorkoutAnalysis({
|
||||||
final int? averageWatts,
|
final int? averageWatts,
|
||||||
final int? maxWatts,
|
final int? maxWatts,
|
||||||
final int? strokes,
|
final int? strokes,
|
||||||
|
|
||||||
|
/// The plan's steps as run (D43), and where its work steps ran on the
|
||||||
|
/// charts, to shade.
|
||||||
|
final List<LapFigures> laps = const [],
|
||||||
|
final List<ChartBand> workBands = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
/// How long a split is for [activity].
|
/// How long a split is for [activity].
|
||||||
|
|
@ -156,9 +179,113 @@ WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
|
||||||
averageWatts: wattsAlone.isEmpty ? null : _mean(wattsAlone).round(),
|
averageWatts: wattsAlone.isEmpty ? null : _mean(wattsAlone).round(),
|
||||||
maxWatts: wattsAlone.isEmpty ? null : wattsAlone.reduce(max),
|
maxWatts: wattsAlone.isEmpty ? null : wattsAlone.reduce(max),
|
||||||
strokes: rowed ? _rowedStrokes(recording) : null,
|
strokes: rowed ? _rowedStrokes(recording) : null,
|
||||||
|
laps: [
|
||||||
|
for (final lap in content.plan?.laps ?? const <WorkoutLap>[])
|
||||||
|
_lapFigures(
|
||||||
|
lap,
|
||||||
|
recording,
|
||||||
|
frames,
|
||||||
|
maxHeartRate: content.plan?.maxHeartRate,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
workBands: [
|
||||||
|
for (final lap in content.plan?.laps ?? const <WorkoutLap>[])
|
||||||
|
if (lap.step.kind == StepKind.work)
|
||||||
|
(
|
||||||
|
from: xAt(_movingAtTime(recording, lap.startedAt)),
|
||||||
|
to: xAt(_movingAtTime(recording, lap.endedAt)),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long [recording] had moved at [time].
|
||||||
|
Duration _movingAtTime(Recording recording, DateTime time) =>
|
||||||
|
movingTimeOf(recordingUntil(recording, time), time);
|
||||||
|
|
||||||
|
/// [lap]'s figures from the samples taken while it ran and moved.
|
||||||
|
LapFigures _lapFigures(
|
||||||
|
WorkoutLap lap,
|
||||||
|
Recording recording,
|
||||||
|
List<(RowingSample, Duration)> frames, {
|
||||||
|
required int? maxHeartRate,
|
||||||
|
}) {
|
||||||
|
bool within(DateTime time) =>
|
||||||
|
!time.isBefore(lap.startedAt) && time.isBefore(lap.endedAt);
|
||||||
|
final bpms = [
|
||||||
|
for (final (rate, _) in _whileMoving(
|
||||||
|
recording,
|
||||||
|
recording.heartRates,
|
||||||
|
(rate) => rate.time,
|
||||||
|
))
|
||||||
|
if (within(rate.time)) rate.bpm,
|
||||||
|
];
|
||||||
|
final rowed = [
|
||||||
|
for (final (sample, _) in frames)
|
||||||
|
if (within(sample.time)) sample,
|
||||||
|
];
|
||||||
|
final rates = [for (final s in rowed) ?s.strokeRate];
|
||||||
|
final watts = [for (final s in rowed) ?s.watts];
|
||||||
|
final averageBpm = bpms.isEmpty ? null : _mean(bpms).round();
|
||||||
|
final strokeRate = rates.isEmpty ? null : _mean(rates);
|
||||||
|
final averageWatts = watts.isEmpty ? null : _mean(watts).round();
|
||||||
|
return LapFigures(
|
||||||
|
lap: lap,
|
||||||
|
averageBpm: averageBpm,
|
||||||
|
strokeRate: strokeRate,
|
||||||
|
watts: averageWatts,
|
||||||
|
held: switch (lap.step.target) {
|
||||||
|
final target? => _held(
|
||||||
|
target,
|
||||||
|
lap,
|
||||||
|
bpm: averageBpm,
|
||||||
|
strokeRate: strokeRate,
|
||||||
|
watts: averageWatts,
|
||||||
|
maxHeartRate: maxHeartRate,
|
||||||
|
),
|
||||||
|
null => null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [lap] held [target] on its averages: its pace, speed or split
|
||||||
|
/// over its moving time and distance, its heart rate (a zone's for
|
||||||
|
/// [maxHeartRate]), its stroke rate or watts; null without the figure.
|
||||||
|
bool? _held(
|
||||||
|
Target target,
|
||||||
|
WorkoutLap lap, {
|
||||||
|
required int? bpm,
|
||||||
|
required double? strokeRate,
|
||||||
|
required int? watts,
|
||||||
|
required int? maxHeartRate,
|
||||||
|
}) {
|
||||||
|
final seconds = lap.movingTime.inMilliseconds / 1000;
|
||||||
|
final metres = lap.metres ?? 0;
|
||||||
|
final moved = seconds > 0 && metres > 0;
|
||||||
|
final value = switch (target.kind) {
|
||||||
|
TargetKind.pace => moved ? seconds / metres * 1000 : null,
|
||||||
|
TargetKind.speed => moved ? metres / seconds * 3.6 : null,
|
||||||
|
TargetKind.split => moved ? seconds / metres * 500 : null,
|
||||||
|
TargetKind.heartRate || TargetKind.heartZone => bpm?.toDouble(),
|
||||||
|
TargetKind.strokeRate => strokeRate,
|
||||||
|
TargetKind.watts => watts?.toDouble(),
|
||||||
|
};
|
||||||
|
final range = switch (target.kind) {
|
||||||
|
TargetKind.heartZone => switch (maxHeartRate) {
|
||||||
|
final max? => () {
|
||||||
|
final zone = heartZone(target.low.round(), max);
|
||||||
|
return (zone.low.toDouble(), zone.high.toDouble());
|
||||||
|
}(),
|
||||||
|
null => null,
|
||||||
|
},
|
||||||
|
_ => (target.low, target.high),
|
||||||
|
};
|
||||||
|
if (value == null || range == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value >= range.$1 && value <= range.$2;
|
||||||
|
}
|
||||||
|
|
||||||
/// How far it had gone at a moving time: `metres` at `moving`.
|
/// How far it had gone at a moving time: `metres` at `moving`.
|
||||||
typedef _Progress = ({double metres, Duration moving});
|
typedef _Progress = ({double metres, Duration moving});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
|
||||||
/// The schema of a workout's payload this app writes and reads.
|
/// The schema of a workout's payload this app writes and reads.
|
||||||
const workoutSchemaVersion = 1;
|
const workoutSchemaVersion = 1;
|
||||||
|
|
||||||
/// [content] as its row's payload, before sealing: UTF-8 JSON, times in UTC
|
/// [content] as its row's payload, before sealing: UTF-8 JSON, times in UTC
|
||||||
/// milliseconds.
|
/// milliseconds; a plan's steps as a plan's payload writes them.
|
||||||
ByteData encodeWorkout(WorkoutContent content) => ByteData.sublistView(
|
ByteData encodeWorkout(WorkoutContent content) => ByteData.sublistView(
|
||||||
Uint8List.fromList(
|
Uint8List.fromList(
|
||||||
utf8.encode(
|
utf8.encode(
|
||||||
|
|
@ -27,6 +28,23 @@ ByteData encodeWorkout(WorkoutContent content) => ByteData.sublistView(
|
||||||
'appName': ?origin.appName,
|
'appName': ?origin.appName,
|
||||||
'sessionId': origin.sessionId,
|
'sessionId': origin.sessionId,
|
||||||
},
|
},
|
||||||
|
if (content.plan case final plan?)
|
||||||
|
'plan': {
|
||||||
|
'name': plan.name,
|
||||||
|
'maxHeartRate': ?plan.maxHeartRate,
|
||||||
|
'laps': [
|
||||||
|
for (final lap in plan.laps)
|
||||||
|
{
|
||||||
|
'step': planStepJson(lap.step),
|
||||||
|
'round': lap.round,
|
||||||
|
'rounds': lap.rounds,
|
||||||
|
'startedAt': lap.startedAt.toUtc().millisecondsSinceEpoch,
|
||||||
|
'endedAt': lap.endedAt.toUtc().millisecondsSinceEpoch,
|
||||||
|
'movingMs': lap.movingTime.inMilliseconds,
|
||||||
|
'metres': ?lap.metres,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -77,9 +95,44 @@ WorkoutContent? decodeWorkout(ByteData payload) {
|
||||||
),
|
),
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
plan: switch (json['plan']) {
|
||||||
|
{'name': final String name, 'laps': final List<Object?> laps} &&
|
||||||
|
final plan =>
|
||||||
|
WorkoutPlan(
|
||||||
|
name: name,
|
||||||
|
maxHeartRate: plan['maxHeartRate'] as int?,
|
||||||
|
laps: [for (final lap in laps) ?_lapOf(lap)],
|
||||||
|
),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
log('A workout not read: ${error.runtimeType}', name: 'Workouts');
|
log('A workout not read: ${error.runtimeType}', name: 'Workouts');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The lap [json] holds; null for one it can't read, which is left out.
|
||||||
|
WorkoutLap? _lapOf(Object? json) => switch (json) {
|
||||||
|
{
|
||||||
|
'step': final Object step,
|
||||||
|
'round': final int round,
|
||||||
|
'rounds': final int rounds,
|
||||||
|
'startedAt': final int startedAt,
|
||||||
|
'endedAt': final int endedAt,
|
||||||
|
'movingMs': final int movingMs,
|
||||||
|
} =>
|
||||||
|
switch (planStepOf(step)) {
|
||||||
|
final step? => WorkoutLap(
|
||||||
|
step: step,
|
||||||
|
round: round,
|
||||||
|
rounds: rounds,
|
||||||
|
startedAt: DateTime.fromMillisecondsSinceEpoch(startedAt, isUtc: true),
|
||||||
|
endedAt: DateTime.fromMillisecondsSinceEpoch(endedAt, isUtc: true),
|
||||||
|
movingTime: Duration(milliseconds: movingMs),
|
||||||
|
metres: json['metres'] as int?,
|
||||||
|
),
|
||||||
|
null => null,
|
||||||
|
},
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||||
|
|
||||||
/// What a workout was (ADR 0030, D17), each one of Health Connect's exercise
|
/// What a workout was (ADR 0030, D17), each one of Health Connect's exercise
|
||||||
/// types. Stored by name, so one this app doesn't know is skipped and new ones
|
/// types. Stored by name, so one this app doesn't know is skipped and new ones
|
||||||
|
|
@ -34,6 +35,42 @@ final class const WorkoutOrigin({
|
||||||
List<Object?> get props => [app, appName, sessionId];
|
List<Object?> get props => [app, appName, sessionId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A step of a plan as a workout ran it (D43): the step, which round of
|
||||||
|
/// how many, when it started and ended, how long it moved and how far it
|
||||||
|
/// went (null without a distance).
|
||||||
|
final class const WorkoutLap({
|
||||||
|
required final PlanStep step,
|
||||||
|
final int round = 1,
|
||||||
|
final int rounds = 1,
|
||||||
|
required final DateTime startedAt,
|
||||||
|
required final DateTime endedAt,
|
||||||
|
required final Duration movingTime,
|
||||||
|
final int? metres,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
step,
|
||||||
|
round,
|
||||||
|
rounds,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
movingTime,
|
||||||
|
metres,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The interval plan a workout ran (D42, D43): its name, the highest heart
|
||||||
|
/// rate its zones were worked out from, and its steps as run, in order;
|
||||||
|
/// those it didn't reach left out.
|
||||||
|
final class const WorkoutPlan({
|
||||||
|
required final String name,
|
||||||
|
required final List<WorkoutLap> laps,
|
||||||
|
final int? maxHeartRate,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [name, laps, maxHeartRate];
|
||||||
|
}
|
||||||
|
|
||||||
/// What a workout is, as its sealed payload holds it (D14); its samples are
|
/// What a workout is, as its sealed payload holds it (D14); its samples are
|
||||||
/// its series.
|
/// its series.
|
||||||
final class const WorkoutContent({
|
final class const WorkoutContent({
|
||||||
|
|
@ -56,6 +93,9 @@ final class const WorkoutContent({
|
||||||
|
|
||||||
/// Where it came from; null for one Train recorded.
|
/// Where it came from; null for one Train recorded.
|
||||||
final WorkoutOrigin? origin,
|
final WorkoutOrigin? origin,
|
||||||
|
|
||||||
|
/// The interval plan it ran; null without one.
|
||||||
|
final WorkoutPlan? plan,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// As it is, named [name] with [notes] (each null or blank: none).
|
/// As it is, named [name] with [notes] (each null or blank: none).
|
||||||
WorkoutContent withText({String? name, String? notes, Activity? activity}) {
|
WorkoutContent withText({String? name, String? notes, Activity? activity}) {
|
||||||
|
|
@ -70,6 +110,7 @@ final class const WorkoutContent({
|
||||||
distanceMetres: distanceMetres,
|
distanceMetres: distanceMetres,
|
||||||
movingTime: movingTime,
|
movingTime: movingTime,
|
||||||
origin: origin,
|
origin: origin,
|
||||||
|
plan: plan,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,5 +124,6 @@ final class const WorkoutContent({
|
||||||
distanceMetres,
|
distanceMetres,
|
||||||
movingTime,
|
movingTime,
|
||||||
origin,
|
origin,
|
||||||
|
plan,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// A line over the distance or the time (ADR 0030, D24, drawn by hand rather than with a
|
/// A line over the distance or the time (ADR 0030, D24, drawn by hand rather than with a
|
||||||
/// chart package): [points] scaled to the box, the lowest and the highest
|
/// chart package): [points] scaled to the box, the lowest and the highest
|
||||||
/// value beside it as [label] writes them. [inverted] puts low values at the
|
/// value beside it as [label] writes them. [inverted] puts low values at the
|
||||||
/// top, as a faster pace is fewer seconds.
|
/// top, as a faster pace is fewer seconds. [bands] are shaded behind the
|
||||||
|
/// line: a plan's work steps (D43).
|
||||||
class const WorkoutChart({
|
class const WorkoutChart({
|
||||||
required final String title,
|
required final String title,
|
||||||
required final List<ChartPoint> points,
|
required final List<ChartPoint> points,
|
||||||
required final String Function(double value) label,
|
required final String Function(double value) label,
|
||||||
final bool inverted = false,
|
final bool inverted = false,
|
||||||
|
final List<ChartBand> bands = const [],
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -40,6 +42,8 @@ class const WorkoutChart({
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _LinePainter(
|
painter: _LinePainter(
|
||||||
points,
|
points,
|
||||||
|
bands: bands,
|
||||||
|
shade: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||||
low: low,
|
low: low,
|
||||||
high: high,
|
high: high,
|
||||||
inverted: inverted,
|
inverted: inverted,
|
||||||
|
|
@ -68,6 +72,8 @@ class const WorkoutChart({
|
||||||
|
|
||||||
class _LinePainter(
|
class _LinePainter(
|
||||||
final List<ChartPoint> _points, {
|
final List<ChartPoint> _points, {
|
||||||
|
required final List<ChartBand> bands,
|
||||||
|
required final Color shade,
|
||||||
required final double low,
|
required final double low,
|
||||||
required final double high,
|
required final double high,
|
||||||
required final bool inverted,
|
required final bool inverted,
|
||||||
|
|
@ -88,6 +94,14 @@ class _LinePainter(
|
||||||
);
|
);
|
||||||
final first = _points.first.x;
|
final first = _points.first.x;
|
||||||
final span = max(_points.last.x - first, 1);
|
final span = max(_points.last.x - first, 1);
|
||||||
|
double xOf(double x) => ((x - first) / span).clamp(0, 1) * size.width;
|
||||||
|
final shading = Paint()..color = shade;
|
||||||
|
for (final band in bands) {
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTRB(xOf(band.from), 0, xOf(band.to), size.height),
|
||||||
|
shading,
|
||||||
|
);
|
||||||
|
}
|
||||||
// A flat line sits in the middle.
|
// A flat line sits in the middle.
|
||||||
final range = high - low;
|
final range = high - low;
|
||||||
Offset at(ChartPoint point) {
|
Offset at(ChartPoint point) {
|
||||||
|
|
@ -116,6 +130,8 @@ class _LinePainter(
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(_LinePainter oldDelegate) =>
|
bool shouldRepaint(_LinePainter oldDelegate) =>
|
||||||
!identical(oldDelegate._points, _points) ||
|
!identical(oldDelegate._points, _points) ||
|
||||||
|
!identical(oldDelegate.bands, bands) ||
|
||||||
|
oldDelegate.shade != shade ||
|
||||||
oldDelegate.line != line ||
|
oldDelegate.line != line ||
|
||||||
oldDelegate.grid != grid;
|
oldDelegate.grid != grid;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
|
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
|
||||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workout_cubit.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workout_cubit.dart';
|
||||||
|
|
@ -197,6 +198,14 @@ class const _Workout({
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(notes, style: theme.textTheme.bodyLarge),
|
Text(notes, style: theme.textTheme.bodyLarge),
|
||||||
],
|
],
|
||||||
|
if (content.plan case final plan? when analysis.laps.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_Laps(
|
||||||
|
activity: content.activity,
|
||||||
|
name: plan.name,
|
||||||
|
laps: analysis.laps,
|
||||||
|
),
|
||||||
|
],
|
||||||
if (analysis.splits.isNotEmpty) ...[
|
if (analysis.splits.isNotEmpty) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_Splits(activity: content.activity, splits: analysis.splits),
|
_Splits(activity: content.activity, splits: analysis.splits),
|
||||||
|
|
@ -205,6 +214,7 @@ class const _Workout({
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (content.activity == Activity.indoorRowing)
|
if (content.activity == Activity.indoorRowing)
|
||||||
WorkoutChart(
|
WorkoutChart(
|
||||||
|
bands: analysis.workBands,
|
||||||
title: l10n.workoutSplitChart,
|
title: l10n.workoutSplitChart,
|
||||||
points: analysis.chart,
|
points: analysis.chart,
|
||||||
inverted: true,
|
inverted: true,
|
||||||
|
|
@ -214,6 +224,7 @@ class const _Workout({
|
||||||
)
|
)
|
||||||
else if (showsPace(content.activity))
|
else if (showsPace(content.activity))
|
||||||
WorkoutChart(
|
WorkoutChart(
|
||||||
|
bands: analysis.workBands,
|
||||||
title: l10n.workoutPaceChart,
|
title: l10n.workoutPaceChart,
|
||||||
points: analysis.chart,
|
points: analysis.chart,
|
||||||
inverted: true,
|
inverted: true,
|
||||||
|
|
@ -223,6 +234,7 @@ class const _Workout({
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
WorkoutChart(
|
WorkoutChart(
|
||||||
|
bands: analysis.workBands,
|
||||||
title: l10n.workoutSpeedChart,
|
title: l10n.workoutSpeedChart,
|
||||||
points: analysis.chart,
|
points: analysis.chart,
|
||||||
label: (value) => l10n.recordingSpeedValue(speed(locale, value)),
|
label: (value) => l10n.recordingSpeedValue(speed(locale, value)),
|
||||||
|
|
@ -231,6 +243,7 @@ class const _Workout({
|
||||||
if (analysis.strokeRate.length > 1) ...[
|
if (analysis.strokeRate.length > 1) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
WorkoutChart(
|
WorkoutChart(
|
||||||
|
bands: analysis.workBands,
|
||||||
title: l10n.workoutStrokeRateChart,
|
title: l10n.workoutStrokeRateChart,
|
||||||
points: analysis.strokeRate,
|
points: analysis.strokeRate,
|
||||||
label: (value) =>
|
label: (value) =>
|
||||||
|
|
@ -240,6 +253,7 @@ class const _Workout({
|
||||||
if (analysis.watts.length > 1) ...[
|
if (analysis.watts.length > 1) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
WorkoutChart(
|
WorkoutChart(
|
||||||
|
bands: analysis.workBands,
|
||||||
title: l10n.workoutPowerChart,
|
title: l10n.workoutPowerChart,
|
||||||
points: analysis.watts,
|
points: analysis.watts,
|
||||||
label: (value) => l10n.workoutWattsValue(value.round()),
|
label: (value) => l10n.workoutWattsValue(value.round()),
|
||||||
|
|
@ -248,6 +262,7 @@ class const _Workout({
|
||||||
if (analysis.heartRate.length > 1) ...[
|
if (analysis.heartRate.length > 1) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
WorkoutChart(
|
WorkoutChart(
|
||||||
|
bands: analysis.workBands,
|
||||||
title: l10n.workoutHeartRateChart,
|
title: l10n.workoutHeartRateChart,
|
||||||
points: analysis.heartRate,
|
points: analysis.heartRate,
|
||||||
label: (value) => l10n.workoutBpmValue(value.round()),
|
label: (value) => l10n.workoutBpmValue(value.round()),
|
||||||
|
|
@ -256,6 +271,7 @@ class const _Workout({
|
||||||
if (analysis.elevation.length > 1) ...[
|
if (analysis.elevation.length > 1) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
WorkoutChart(
|
WorkoutChart(
|
||||||
|
bands: analysis.workBands,
|
||||||
title: l10n.workoutElevationChart,
|
title: l10n.workoutElevationChart,
|
||||||
points: analysis.elevation,
|
points: analysis.elevation,
|
||||||
label: (value) => l10n.workoutClimbValue(value.round()),
|
label: (value) => l10n.workoutClimbValue(value.round()),
|
||||||
|
|
@ -583,6 +599,142 @@ class const _Splits({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A plan's steps as run (D43): each with its target, moving time, pace,
|
||||||
|
/// speed or split, on the rower its stroke rate and watts, the heart's
|
||||||
|
/// average rate, and whether it held its target.
|
||||||
|
class const _Laps({
|
||||||
|
required final Activity activity,
|
||||||
|
required final String name,
|
||||||
|
required final List<LapFigures> laps,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final figures = theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
);
|
||||||
|
final muted = theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
final distance = laps.any((l) => (l.lap.metres ?? 0) > 0);
|
||||||
|
final heart = laps.any((l) => l.averageBpm != null);
|
||||||
|
final strokes = laps.any((l) => l.strokeRate != null);
|
||||||
|
final power = laps.any((l) => l.watts != null);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.workoutSteps(name), style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final figure in laps)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
[
|
||||||
|
stepKindName(l10n, figure.lap.step.kind),
|
||||||
|
if (figure.lap.rounds > 1)
|
||||||
|
l10n.planSayRound(
|
||||||
|
figure.lap.round,
|
||||||
|
figure.lap.rounds,
|
||||||
|
),
|
||||||
|
].join(' · '),
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (figure.lap.step.target case final target?)
|
||||||
|
Text(targetText(l10n, target), style: muted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
clockTime(figure.lap.movingTime),
|
||||||
|
style: figures,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (distance)
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
paceText(
|
||||||
|
l10n,
|
||||||
|
locale,
|
||||||
|
activity,
|
||||||
|
figure.lap.metres ?? 0,
|
||||||
|
figure.lap.movingTime,
|
||||||
|
) ??
|
||||||
|
'–',
|
||||||
|
style: figures,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (strokes)
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
switch (figure.strokeRate) {
|
||||||
|
final rate? => l10n.workoutStrokeRateValue(
|
||||||
|
strokeRateText(locale, rate),
|
||||||
|
),
|
||||||
|
null => '–',
|
||||||
|
},
|
||||||
|
style: figures,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (power)
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
switch (figure.watts) {
|
||||||
|
final watts? => l10n.workoutWattsValue(watts),
|
||||||
|
null => '–',
|
||||||
|
},
|
||||||
|
style: figures,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (heart)
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
switch (figure.averageBpm) {
|
||||||
|
final bpm? => l10n.workoutBpmValue(bpm),
|
||||||
|
null => '–',
|
||||||
|
},
|
||||||
|
style: figures,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 32,
|
||||||
|
child: switch (figure.held) {
|
||||||
|
true => Icon(
|
||||||
|
LucideIcons.circleCheck,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
semanticLabel: l10n.workoutOnTarget,
|
||||||
|
),
|
||||||
|
false => Icon(
|
||||||
|
LucideIcons.circleAlert,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.tertiary,
|
||||||
|
semanticLabel: l10n.workoutOffTarget,
|
||||||
|
),
|
||||||
|
null => null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A figure with its label beneath.
|
/// A figure with its label beneath.
|
||||||
class const _Figure({required final String value, required final String label})
|
class const _Figure({required final String value, required final String label})
|
||||||
extends StatelessWidget {
|
extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -1250,7 +1250,8 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('a plan chosen on the Record sheet chooses its activity, goes '
|
testWidgets('a plan chosen on the Record sheet chooses its activity, goes '
|
||||||
'to the recorder spoken, and its step shows with Lap (D42)', (
|
'to the recorder spoken, its step shows with Lap (D42), and the '
|
||||||
|
'workout keeps its steps (D43)', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final plans = PlanRepository(MemoryPlanGateway(), keys);
|
final plans = PlanRepository(MemoryPlanGateway(), keys);
|
||||||
|
|
@ -1317,6 +1318,31 @@ void main() {
|
||||||
await tester.tap(find.text('Lap'));
|
await tester.tap(find.text('Lap'));
|
||||||
await pass(tester);
|
await pass(tester);
|
||||||
expect(platform.laps, 1);
|
expect(platform.laps, 1);
|
||||||
|
|
||||||
|
// The service ends the step; saved, the workout keeps it (D43).
|
||||||
|
platform
|
||||||
|
..now = at.add(const Duration(seconds: 30))
|
||||||
|
..write('step @ 1')
|
||||||
|
..now = at.add(const Duration(seconds: 50));
|
||||||
|
await tester.longPress(find.text('Stop'));
|
||||||
|
await pass(tester);
|
||||||
|
await tester.tap(find.text('Save'));
|
||||||
|
await tester.runAsync(pumpEventQueue);
|
||||||
|
await pass(tester, 2);
|
||||||
|
final saved = (await tester.runAsync(workouts.all))!.single.content!;
|
||||||
|
expect(saved.plan?.name, 'Sprints');
|
||||||
|
expect(saved.plan?.laps.map((l) => l.step.kind), [
|
||||||
|
StepKind.work,
|
||||||
|
StepKind.recovery,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await tester.tap(find.text('0:50'));
|
||||||
|
await tester.runAsync(pumpEventQueue);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Steps of Sprints'), findsOneWidget);
|
||||||
|
expect(find.text('Work · 1 of 2'), findsOneWidget);
|
||||||
|
expect(find.text('30–35 km/h'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.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/series_chunk.dart';
|
||||||
|
|
@ -217,4 +218,92 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('with a plan (D43)', () {
|
||||||
|
const work = PlanStep(
|
||||||
|
kind: StepKind.work,
|
||||||
|
end: EndAfterTime(Duration(seconds: 60)),
|
||||||
|
);
|
||||||
|
const easy = PlanStep(kind: StepKind.recovery, end: EndWithLap());
|
||||||
|
const plan = PlanContent(
|
||||||
|
name: 'Twice',
|
||||||
|
blocks: [
|
||||||
|
PlanBlock(steps: [work, easy], repeat: 2),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
test('each step run is a lap, its moving time without its pauses and '
|
||||||
|
'its distance its own; a step not reached is none', () {
|
||||||
|
final (content, _) = recordedWorkout(
|
||||||
|
Recording(
|
||||||
|
activity: Activity.running,
|
||||||
|
startedAt: start,
|
||||||
|
fixes: [
|
||||||
|
north(0, 0),
|
||||||
|
north(19, 100),
|
||||||
|
north(31, 110),
|
||||||
|
north(70, 300),
|
||||||
|
north(100, 400),
|
||||||
|
],
|
||||||
|
pauses: [
|
||||||
|
PauseMark(time: at(20)),
|
||||||
|
PauseMark(time: at(30), paused: false),
|
||||||
|
],
|
||||||
|
plan: plan,
|
||||||
|
planMaxHeartRate: 185,
|
||||||
|
planSteps: [
|
||||||
|
StepMark(time: at(0), index: 0),
|
||||||
|
StepMark(time: at(70), index: 1),
|
||||||
|
],
|
||||||
|
endedAt: at(100),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
content.plan,
|
||||||
|
WorkoutPlan(
|
||||||
|
name: 'Twice',
|
||||||
|
maxHeartRate: 185,
|
||||||
|
laps: [
|
||||||
|
WorkoutLap(
|
||||||
|
step: work,
|
||||||
|
rounds: 2,
|
||||||
|
startedAt: at(0),
|
||||||
|
endedAt: at(70),
|
||||||
|
movingTime: const Duration(seconds: 60),
|
||||||
|
metres: 290,
|
||||||
|
),
|
||||||
|
WorkoutLap(
|
||||||
|
step: easy,
|
||||||
|
rounds: 2,
|
||||||
|
startedAt: at(70),
|
||||||
|
endedAt: at(100),
|
||||||
|
movingTime: const Duration(seconds: 30),
|
||||||
|
metres: 100,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plan done ends its last lap there; without a distance a lap has '
|
||||||
|
'none; without a plan there is none', () {
|
||||||
|
final (content, _) = recordedWorkout(
|
||||||
|
Recording(
|
||||||
|
activity: Activity.indoorRowing,
|
||||||
|
startedAt: start,
|
||||||
|
plan: plan,
|
||||||
|
planSteps: [
|
||||||
|
StepMark(time: at(0), index: 0),
|
||||||
|
StepMark(time: at(60)),
|
||||||
|
],
|
||||||
|
endedAt: at(90),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(content.plan?.laps.single.endedAt, at(60));
|
||||||
|
expect(content.plan?.laps.single.metres, isNull);
|
||||||
|
expect(recordedWorkout(run(endedAt: at(5))).$1.plan, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
|
||||||
|
|
@ -311,4 +312,89 @@ void main() {
|
||||||
expect(analysis.watts.last.value, 200);
|
expect(analysis.watts.last.value, 200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('a plan (D43)', () {
|
||||||
|
// 3 m/s (5:33 a km) for 600 s, the heart at 150, then 170 from 300 s.
|
||||||
|
final fixes = [for (var s = 0; s <= 600; s += 10) north(s, s * 3)];
|
||||||
|
final rates = [
|
||||||
|
for (var s = 0; s <= 600; s += 10)
|
||||||
|
HeartRate(time: at(s), bpm: s < 300 ? 150 : 170),
|
||||||
|
];
|
||||||
|
Recording intervals(List<PlanStep> steps, {int? maxHeartRate}) => Recording(
|
||||||
|
activity: Activity.running,
|
||||||
|
startedAt: start,
|
||||||
|
fixes: fixes,
|
||||||
|
heartRates: rates,
|
||||||
|
plan: PlanContent(
|
||||||
|
name: 'Two',
|
||||||
|
blocks: [PlanBlock(steps: steps)],
|
||||||
|
),
|
||||||
|
planMaxHeartRate: maxHeartRate,
|
||||||
|
planSteps: [
|
||||||
|
StepMark(time: at(0), index: 0),
|
||||||
|
StepMark(time: at(300), index: 1),
|
||||||
|
],
|
||||||
|
endedAt: at(600),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('each step has its own heart rate, and says whether it held its '
|
||||||
|
'target', () {
|
||||||
|
final analysis = analysed(
|
||||||
|
intervals([
|
||||||
|
const PlanStep(
|
||||||
|
kind: StepKind.recovery,
|
||||||
|
end: EndAfterTime(Duration(minutes: 5)),
|
||||||
|
target: Target(TargetKind.pace, 320, 340),
|
||||||
|
),
|
||||||
|
const PlanStep(
|
||||||
|
kind: StepKind.work,
|
||||||
|
end: EndAfterTime(Duration(minutes: 5)),
|
||||||
|
target: Target(TargetKind.pace, 270, 300),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(analysis.laps.map((l) => l.averageBpm), [150, 170]);
|
||||||
|
expect(analysis.laps.map((l) => l.held), [true, false]);
|
||||||
|
expect(analysis.laps.first.lap.metres, 900);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a zone is held on the heart rates it spans for the highest rate it '
|
||||||
|
'ran with; without that rate, or without a target, it says nothing',
|
||||||
|
() {
|
||||||
|
final zones = [
|
||||||
|
PlanStep(
|
||||||
|
kind: StepKind.work,
|
||||||
|
end: const EndAfterTime(Duration(minutes: 5)),
|
||||||
|
target: Target.zone(3),
|
||||||
|
),
|
||||||
|
const PlanStep(
|
||||||
|
kind: StepKind.work,
|
||||||
|
end: EndAfterTime(Duration(minutes: 5)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Zone 3 of 200 bpm: 140 to 160.
|
||||||
|
expect(
|
||||||
|
analysed(intervals(zones, maxHeartRate: 200)).laps.map((l) => l.held),
|
||||||
|
[true, null],
|
||||||
|
);
|
||||||
|
expect(analysed(intervals(zones)).laps.first.held, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('its work steps are where the charts shade, over the distance', () {
|
||||||
|
final analysis = analysed(
|
||||||
|
intervals(const [
|
||||||
|
PlanStep(kind: StepKind.warmUp, end: EndWithLap()),
|
||||||
|
PlanStep(kind: StepKind.work, end: EndWithLap()),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
final band = analysis.workBands.single;
|
||||||
|
expect(band.from, closeTo(900, 1));
|
||||||
|
expect(band.to, closeTo(1800, 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue