feat(train): interval plans, synced and sealed, with an editor and templates (ADR 0030, D42)

Train's server gains the synced `plan` table, a sealed payload alone;
the migration `plans` only adds it, its space index and the foreign key
to its sync space, and a person's deletion takes it with their space.

The app keeps plans as `plan` rows sealed for their row: a name, what
they are for, whether a step off target is told, and blocks of steps
repeated, each ending after a time, a distance or a lap tap and aiming
at a pace, speed, heart rate or zone, or the rower's split, watts or
stroke rate. Home's Plans tab lists them, a new one starts from nothing
or from a template (4×4 minutes, Tabata, 10×400 m), the editor changes
steps on a sheet, and a deletion asks first and can be undone.

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 22:35:22 +01:00
commit f406ddcc59
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
48 changed files with 23608 additions and 10 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. **The Loop's firmware relay** (ADR 0030, D10, D40, `lib/src/firmware/`): the `firmware` endpoint asks Polar's firmware service in the person's place and hands on the file, kept in memory a day per hardware and version (`FirmwareRelay`); on by default, off where the operator sets `LOOP_FIRMWARE_RELAY=off`, the app then offering the direct way alone; tested on a fake Polar (`test/integration/firmware/`). 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 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. **The Loop's firmware relay** (ADR 0030, D10, D40, `lib/src/firmware/`): the `firmware` endpoint asks Polar's firmware service in the person's place and hands on the file, kept in memory a day per hardware and version (`FirmwareRelay`); on by default, off where the operator sets `LOOP_FIRMWARE_RELAY=off`, the app then offering the direct way alone; tested on a fake Polar (`test/integration/firmware/`). 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`, `days` (Train's `day`, `day_series` and `night`) and `plans` (`plan`, D42), 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) and `plan` (an interval plan, D42), 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; 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: …)`. 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). 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

@ -0,0 +1,770 @@
{
"__className__": "serverpod.DatabaseDefinition",
"schemaVersion": 2,
"moduleName": "halkyon_train",
"tables": [
{
"__className__": "serverpod.TableDefinition",
"name": "content_key",
"dartName": "ContentKey",
"module": "halkyon_train",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "dataKeyId",
"fieldName": "dataKeyId",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "sealedKey",
"fieldName": "sealedKey",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "content_key_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "content_key_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "day",
"dartName": "Day",
"module": "halkyon_train",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "day_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "day_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "day_series",
"dartName": "DaySeries",
"module": "halkyon_train",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "dayId",
"fieldName": "dayId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "day_series_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "day_series_fk_1",
"columns": [
"dayId"
],
"referenceTable": "day",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4,
"deferrable": "initiallyDeferred"
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "day_series_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "day_series_day_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "dayId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "night",
"dartName": "Night",
"module": "halkyon_train",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "night_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "night_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "plan",
"dartName": "Plan",
"module": "halkyon_train",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "plan_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "plan_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "profile",
"dartName": "Profile",
"module": "halkyon_train",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "profile_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "profile_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "workout",
"dartName": "Workout",
"module": "halkyon_train",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "workout_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "workout_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "workout_series",
"dartName": "WorkoutSeries",
"module": "halkyon_train",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "workoutId",
"fieldName": "workoutId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "workout_series_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "workout_series_fk_1",
"columns": [
"workoutId"
],
"referenceTable": "workout",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4,
"deferrable": "initiallyDeferred"
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "workout_series_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "workout_series_workout_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "workoutId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
],
"installedModules": [
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "halkyon_base",
"version": "20261026140329453-assistant-budgets"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod",
"version": "20260824182259319"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_idp",
"version": "20260924105404509"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_offline_sync",
"version": "20260914143806119"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "halkyon_oidc",
"version": "20261023180644988"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_core",
"version": "20260924105232991"
}
],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,94 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "plan",
"dartName": "Plan",
"module": "halkyon_train",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "plan_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "plan_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -7,6 +7,7 @@ import 'package:serverpod_database/serverpod_database.dart';
part '20261028101012641-workouts/migration.dart';
part '20261028164640000-profile/migration.dart';
part '20261028181405893-days/migration.dart';
part '20261028211508606-plans/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -16,6 +17,7 @@ class MigrationRegistry {
_Migration20261028101012641_workouts(),
_Migration20261028164640000_profile(),
_Migration20261028181405893_days(),
_Migration20261028211508606_plans(),
];
/// List of all client-side database migration versions.

View file

@ -0,0 +1,670 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import 'package:serverpod_database/serverpod_database.dart' as _isd;
/// An interval plan (ADR 0030, D42): its name and steps, each ending after a
/// time, a distance or a lap tap and aiming at a target, in its sealed
/// payload (D7); the instance sees that it exists, its size and when it
/// changed. Changed when edited, the last writer winning. Synced.
abstract class Plan
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {
Plan._({
this.id,
this.spaceId,
this.keyId,
required this.payload,
this.nonce,
});
factory Plan({
_isc.UuidValue? id,
int? spaceId,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
}) = _PlanImpl;
factory Plan.fromJson(Map<String, dynamic> jsonSerialization) {
return Plan(
id: jsonSerialization['id'] == null
? null
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
spaceId: jsonSerialization['spaceId'] as int?,
keyId: jsonSerialization['keyId'] as String?,
payload: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['payload'],
),
nonce: jsonSerialization['nonce'] == null
? null
: _isc.ByteDataJsonExtension.fromJson(jsonSerialization['nonce']),
);
}
static final t = PlanTable();
static const db = PlanRepository._();
@override
_isc.UuidValue? id;
/// The space owning this row. Maintained by the sync engine.
int? spaceId;
/// The key it is sealed with (ADR 0002's envelope); never null in Train.
String? keyId;
/// The content, sealed: UTF-8 JSON with a `schemaVersion` once opened.
_idt.ByteData payload;
/// The sealing's nonce (ADR 0002's envelope).
_idt.ByteData? nonce;
@override
_isd.Table<_isc.UuidValue?> get table => t;
/// Returns a shallow copy of this [Plan]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
Plan copyWith({
_isc.UuidValue? id,
int? spaceId,
String? keyId,
_idt.ByteData? payload,
_idt.ByteData? nonce,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Plan',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'Plan',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
};
}
static PlanInclude include() {
return PlanInclude._();
}
static PlanIncludeList includeList({
_isd.WhereExpressionBuilder<PlanTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<PlanTable>? orderBy,
_isd.OrderByListBuilder<PlanTable>? orderByList,
PlanInclude? include,
}) {
return PlanIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(Plan.t),
orderByList: orderByList?.call(Plan.t),
include: include,
);
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _PlanImpl extends Plan {
_PlanImpl({
_isc.UuidValue? id,
int? spaceId,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
}) : super._(
id: id,
spaceId: spaceId,
keyId: keyId,
payload: payload,
nonce: nonce,
);
/// Returns a shallow copy of this [Plan]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
Plan copyWith({
Object? id = _Undefined,
Object? spaceId = _Undefined,
Object? keyId = _Undefined,
_idt.ByteData? payload,
Object? nonce = _Undefined,
}) {
return Plan(
id: id is _isc.UuidValue? ? id : this.id,
spaceId: spaceId is int? ? spaceId : this.spaceId,
keyId: keyId is String? ? keyId : this.keyId,
payload: payload ?? this.payload.clone(),
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
);
}
}
class PlanUpdateTable extends _isd.UpdateTable<PlanTable> {
PlanUpdateTable(super.table);
_isd.ColumnValue<int, int> spaceId(int? value) => _isd.ColumnValue(
table.spaceId,
value,
);
_isd.ColumnValue<String, String> keyId(String? value) => _isd.ColumnValue(
table.keyId,
value,
);
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> payload(_idt.ByteData value) =>
_isd.ColumnValue(
table.payload,
value,
);
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> nonce(_idt.ByteData? value) =>
_isd.ColumnValue(
table.nonce,
value,
);
}
class PlanTable extends _isd.Table<_isc.UuidValue?> {
PlanTable({super.tableRelation}) : super(tableName: 'plan') {
updateTable = PlanUpdateTable(this);
spaceId = _isd.ColumnInt(
'spaceId',
this,
);
keyId = _isd.ColumnString(
'keyId',
this,
);
payload = _isd.ColumnByteData(
'payload',
this,
);
nonce = _isd.ColumnByteData(
'nonce',
this,
);
}
late final PlanUpdateTable updateTable;
/// The space owning this row. Maintained by the sync engine.
late final _isd.ColumnInt spaceId;
/// The key it is sealed with (ADR 0002's envelope); never null in Train.
late final _isd.ColumnString keyId;
/// The content, sealed: UTF-8 JSON with a `schemaVersion` once opened.
late final _isd.ColumnByteData payload;
/// The sealing's nonce (ADR 0002's envelope).
late final _isd.ColumnByteData nonce;
@override
List<_isd.Column> get columns => [
id,
spaceId,
keyId,
payload,
nonce,
];
}
class PlanInclude extends _isd.IncludeObject {
PlanInclude._();
@override
Map<String, _isd.Include?> get includes => {};
@override
_isd.Table<_isc.UuidValue?> get table => Plan.t;
}
class PlanIncludeList extends _isd.IncludeList {
PlanIncludeList._({
_isd.WhereExpressionBuilder<PlanTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(Plan.t);
}
@override
Map<String, _isd.Include?> get includes => include?.includes ?? {};
@override
_isd.Table<_isc.UuidValue?> get table => Plan.t;
}
class PlanRepository {
const PlanRepository._();
/// Returns a list of [Plan]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<Plan>> find(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<PlanTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<PlanTable>? orderBy,
_isd.OrderByListBuilder<PlanTable>? orderByList,
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.find<Plan>(
where: where?.call(Plan.t),
orderBy: orderBy?.call(Plan.t),
orderByList: orderByList?.call(Plan.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [Plan] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<Plan?> findFirstRow(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<PlanTable>? where,
int? offset,
_isd.OrderByBuilder<PlanTable>? orderBy,
_isd.OrderByListBuilder<PlanTable>? orderByList,
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<Plan>(
where: where?.call(Plan.t),
orderBy: orderBy?.call(Plan.t),
orderByList: orderByList?.call(Plan.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [Plan] by its [id] or null if no such row exists.
Future<Plan?> findById(
_isd.DatabaseSession session,
_isc.UuidValue id, {
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findById<Plan>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [Plan]s in the list and returns the inserted rows.
///
/// The returned [Plan]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<Plan>> insert(
_isd.DatabaseSession session,
List<Plan> rows, {
_isd.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<Plan>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [Plan] and returns the inserted row.
///
/// The returned [Plan] will have its `id` field set.
Future<Plan> insertRow(
_isd.DatabaseSession session,
Plan row, {
_isd.Transaction? transaction,
}) async {
return session.db.insertRow<Plan>(
row,
transaction: transaction,
);
}
/// Upserts all [Plan]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [Plan]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<Plan>> upsert(
_isd.DatabaseSession session,
List<Plan> rows, {
required _isd.ColumnSelections<PlanTable> conflictColumns,
_isd.ColumnSelections<PlanTable>? updateColumns,
_isd.WhereExpressionBuilder<PlanTable>? updateWhere,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<Plan>(
rows,
conflictColumns: conflictColumns(Plan.t),
updateColumns: updateColumns?.call(Plan.t),
updateWhere: updateWhere?.call(Plan.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [Plan] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [Plan] will have its `id` field set.
Future<Plan?> upsertRow(
_isd.DatabaseSession session,
Plan row, {
required _isd.ColumnSelections<PlanTable> conflictColumns,
_isd.ColumnSelections<PlanTable>? updateColumns,
_isd.WhereExpressionBuilder<PlanTable>? updateWhere,
_isd.Transaction? transaction,
}) async {
return session.db.upsertRow<Plan>(
row,
conflictColumns: conflictColumns(Plan.t),
updateColumns: updateColumns?.call(Plan.t),
updateWhere: updateWhere?.call(Plan.t),
transaction: transaction,
);
}
/// Updates all [Plan]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<Plan>> update(
_isd.DatabaseSession session,
List<Plan> rows, {
_isd.ColumnSelections<PlanTable>? columns,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<Plan>(
rows,
columns: columns?.call(Plan.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [Plan]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<Plan> updateRow(
_isd.DatabaseSession session,
Plan row, {
_isd.ColumnSelections<PlanTable>? columns,
_isd.Transaction? transaction,
}) async {
return session.db.updateRow<Plan>(
row,
columns: columns?.call(Plan.t),
transaction: transaction,
);
}
/// Updates a single [Plan] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<Plan?> updateById(
_isd.DatabaseSession session,
_isc.UuidValue id, {
required _isd.ColumnValueListBuilder<PlanUpdateTable> columnValues,
_isd.Transaction? transaction,
}) async {
return session.db.updateById<Plan>(
id,
columnValues: columnValues(Plan.t.updateTable),
transaction: transaction,
);
}
/// Updates all [Plan]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<Plan>> updateWhere(
_isd.DatabaseSession session, {
required _isd.ColumnValueListBuilder<PlanUpdateTable> columnValues,
required _isd.WhereExpressionBuilder<PlanTable> where,
int? limit,
int? offset,
_isd.OrderByBuilder<PlanTable>? orderBy,
_isd.OrderByListBuilder<PlanTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<Plan>(
columnValues: columnValues(Plan.t.updateTable),
where: where(Plan.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(Plan.t),
orderByList: orderByList?.call(Plan.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [Plan]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<Plan>> delete(
_isd.DatabaseSession session,
List<Plan> rows, {
_isd.OrderByBuilder<PlanTable>? orderBy,
_isd.OrderByListBuilder<PlanTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<Plan>(
rows,
orderBy: orderBy?.call(Plan.t),
orderByList: orderByList?.call(Plan.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [Plan].
Future<Plan> deleteRow(
_isd.DatabaseSession session,
Plan row, {
_isd.Transaction? transaction,
}) async {
return session.db.deleteRow<Plan>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<Plan>> deleteWhere(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<PlanTable> where,
_isd.OrderByBuilder<PlanTable>? orderBy,
_isd.OrderByListBuilder<PlanTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<Plan>(
where: where(Plan.t),
orderBy: orderBy?.call(Plan.t),
orderByList: orderByList?.call(Plan.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<PlanTable>? where,
int? limit,
_isd.Transaction? transaction,
}) async {
return session.db.count<Plan>(
where: where?.call(Plan.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [Plan] rows matching the [where] expression.
Future<void> lockRows(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<PlanTable> where,
required _isd.LockMode lockMode,
required _isd.Transaction transaction,
_isd.LockBehavior lockBehavior = _isd.LockBehavior.wait,
}) async {
return session.db.lockRows<Plan>(
where: where(Plan.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -27,6 +27,7 @@ import 'day/night.dart' as _i17cgxdf;
import 'firmware/loop_firmware.dart' as _i4qollgq;
import 'firmware/loop_firmware_refused.dart' as _ipcwdy0l;
import 'map/map_source.dart' as _ikw0qg8x;
import 'plan/plan.dart' as _i45prw73;
import 'privacy/content_key.dart' as _imed3x5d;
import 'profile/profile.dart' as _ia5j8fch;
import 'workout/workout.dart' as _i5b9b17z;
@ -37,6 +38,7 @@ export 'day/night.dart';
export 'firmware/loop_firmware.dart';
export 'firmware/loop_firmware_refused.dart';
export 'map/map_source.dart';
export 'plan/plan.dart';
export 'privacy/content_key.dart';
export 'profile/profile.dart';
export 'workout/workout.dart';
@ -344,6 +346,73 @@ class Protocol extends _isd.DatabaseSerializationManager {
],
managed: true,
),
_isd.TableDefinition(
name: 'plan',
dartName: 'Plan',
schema: 'public',
module: 'halkyon_train',
columns: [
_isd.ColumnDefinition(
name: 'id',
columnType: _isd.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue?',
columnDefault: 'random_v7',
),
_isd.ColumnDefinition(
name: 'spaceId',
columnType: _isd.ColumnType.bigint,
isNullable: true,
dartType: 'int?',
),
_isd.ColumnDefinition(
name: 'keyId',
columnType: _isd.ColumnType.text,
isNullable: true,
dartType: 'String?',
),
_isd.ColumnDefinition(
name: 'payload',
columnType: _isd.ColumnType.bytea,
isNullable: false,
dartType: 'dart:typed_data:ByteData',
),
_isd.ColumnDefinition(
name: 'nonce',
columnType: _isd.ColumnType.bytea,
isNullable: true,
dartType: 'dart:typed_data:ByteData?',
),
],
foreignKeys: [
_isd.ForeignKeyDefinition(
constraintName: 'plan_fk_0',
columns: ['spaceId'],
referenceTable: 'offline_sync_spaces',
referenceTableSchema: 'public',
referenceColumns: ['id'],
onUpdate: _isd.ForeignKeyAction.noAction,
onDelete: _isd.ForeignKeyAction.cascade,
matchType: null,
),
],
indexes: [
_isd.IndexDefinition(
indexName: 'plan_space_idx',
tableSpace: null,
elements: [
_isd.IndexElementDefinition(
type: _isd.IndexElementDefinitionType.column,
definition: 'spaceId',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
],
managed: true,
),
_isd.TableDefinition(
name: 'profile',
dartName: 'Profile',
@ -642,6 +711,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _ikw0qg8x.MapSource) {
return _ikw0qg8x.MapSource.fromJson(data) as T;
}
if (t == _i45prw73.Plan) {
return _i45prw73.Plan.fromJson(data) as T;
}
if (t == _imed3x5d.ContentKey) {
return _imed3x5d.ContentKey.fromJson(data) as T;
}
@ -675,6 +747,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_ikw0qg8x.MapSource?>()) {
return (data != null ? _ikw0qg8x.MapSource.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i45prw73.Plan?>()) {
return (data != null ? _i45prw73.Plan.fromJson(data) : null) as T;
}
if (t == _isc.getType<_imed3x5d.ContentKey?>()) {
return (data != null ? _imed3x5d.ContentKey.fromJson(data) : null) as T;
}
@ -714,6 +789,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
_i4qollgq.LoopFirmware => 'LoopFirmware',
_ipcwdy0l.LoopFirmwareRefused => 'LoopFirmwareRefused',
_ikw0qg8x.MapSource => 'MapSource',
_i45prw73.Plan => 'Plan',
_imed3x5d.ContentKey => 'ContentKey',
_ia5j8fch.Profile => 'Profile',
_i5b9b17z.Workout => 'Workout',
@ -747,6 +823,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'LoopFirmwareRefused';
case _ikw0qg8x.MapSource():
return 'MapSource';
case _i45prw73.Plan():
return 'Plan';
case _imed3x5d.ContentKey():
return 'ContentKey';
case _ia5j8fch.Profile():
@ -809,6 +887,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'MapSource') {
return deserialize<_ikw0qg8x.MapSource>(data['data']);
}
if (dataClassName == 'Plan') {
return deserialize<_i45prw73.Plan>(data['data']);
}
if (dataClassName == 'ContentKey') {
return deserialize<_imed3x5d.ContentKey>(data['data']);
}
@ -906,6 +987,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return _ir1ofny6.DaySeries.t;
case _i17cgxdf.Night:
return _i17cgxdf.Night.t;
case _i45prw73.Plan:
return _i45prw73.Plan.t;
case _imed3x5d.ContentKey:
return _imed3x5d.ContentKey.t;
case _ia5j8fch.Profile:

View file

@ -14,6 +14,7 @@ import 'package:serverpod_database/serverpod_database.dart' as _isd;
import 'day/day.dart' as _ikmv9f3y;
import 'day/day_series.dart' as _ir1ofny6;
import 'day/night.dart' as _i17cgxdf;
import 'plan/plan.dart' as _i45prw73;
import 'privacy/content_key.dart' as _imed3x5d;
import 'profile/profile.dart' as _ia5j8fch;
import 'workout/workout.dart' as _i5b9b17z;
@ -27,6 +28,7 @@ final List<_isd.Table> syncTables = [
_ikmv9f3y.Day.t,
_ir1ofny6.DaySeries.t,
_i17cgxdf.Night.t,
_i45prw73.Plan.t,
_ia5j8fch.Profile.t,
_i5b9b17z.Workout.t,
_i92mefto.WorkoutSeries.t,

View file

@ -241,6 +241,76 @@
"loopFormSave": "Band einrichten",
"homeWorkouts": "Workouts",
"homeDays": "Tage",
"homePlans": "Pläne",
"plansNew": "Neuer Plan",
"plansNewEmpty": "Leer",
"plansEmptyTitle": "Noch keine Pläne",
"plansEmptyHint": "Die Schritte eines Plans führen dich beim Aufzeichnen durch Intervalle, mit Ton, Vibration und Stimme bei jedem Wechsel. Beginne mit einer Vorlage oder leer.",
"planLocked": "Ein Plan, der sich hier nicht öffnet",
"planTemplateFourByFour": "4×4 Minuten",
"planTemplateTabata": "Tabata",
"planTemplateFourHundreds": "10×400 m",
"planTitle": "Plan",
"planNewTitle": "Neuer Plan",
"planName": "Name",
"planActivity": "Für",
"planActivityAny": "Jede Aktivität",
"planOffTarget": "Sagen, wenn ich vom Ziel abweiche",
"planOffTargetHint": "Ein kurzer Hinweis, höchstens alle 30 Sekunden.",
"planSave": "Speichern",
"planNotSaved": "Der Plan wurde nicht gespeichert.",
"planAddStep": "Schritt hinzufügen",
"planAddRepeat": "Wiederholung hinzufügen",
"planRepeat": "{count}×",
"planFewer": "Weniger Runden",
"planMore": "Mehr Runden",
"planBlockMenu": "Mehr",
"planMoveUp": "Nach oben",
"planMoveDown": "Nach unten",
"planRemove": "Entfernen",
"planDelete": "Löschen",
"planDeleteTitle": "Diesen Plan löschen?",
"planDeleteMessage": "Er wird auf all deinen Geräten gelöscht; Trainings, die mit ihm aufgezeichnet wurden, behalten ihre Schritte.",
"planDeleted": "Plan gelöscht",
"planSteps": "{count, plural, =1{1 Schritt} other{{count} Schritte}}",
"stepTitle": "Schritt",
"stepKind": "Art",
"stepWarmUp": "Aufwärmen",
"stepWork": "Belastung",
"stepRecovery": "Erholung",
"stepCoolDown": "Auslaufen",
"stepEnds": "Endet",
"stepEndTime": "Nach einer Zeit",
"stepEndDistance": "Nach einer Strecke",
"stepEndLap": "Mit der Rundentaste",
"stepTimeLabel": "Zeit (min:s)",
"stepMetresLabel": "Meter",
"stepTarget": "Ziel",
"stepZone": "Zone",
"stepDone": "Fertig",
"stepMinutes": "{minutes} min",
"stepSeconds": "{seconds} s",
"stepMinutesSeconds": "{minutes} min {seconds} s",
"stepMetres": "{metres} m",
"stepKilometres": "{km} km",
"stepUntilLap": "bis zur Rundentaste",
"targetNone": "Keins",
"targetFrom": "Von",
"targetTo": "Bis",
"targetKindPace": "Tempo (pro km)",
"targetKindSpeed": "Geschwindigkeit (km/h)",
"targetKindHeartRate": "Herzfrequenz (Schläge/min)",
"targetKindHeartZone": "Herzfrequenzzone",
"targetKindSplit": "Split (pro 500 m)",
"targetKindWatts": "Leistung (W)",
"targetKindStrokeRate": "Schlagzahl (pro min)",
"targetPace": "{from}–{to} pro km",
"targetSpeed": "{from}–{to} km/h",
"targetHeartRate": "{from}–{to} Schläge/min",
"targetZone": "Zone {zone}",
"targetSplit": "{from}–{to} pro 500 m",
"targetWatts": "{from}–{to} W",
"targetStrokeRate": "{from}–{to} Schläge/min",
"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}}",

View file

@ -300,6 +300,90 @@
"loopFormSave": "Set up the band",
"homeWorkouts": "Workouts",
"homeDays": "Days",
"homePlans": "Plans",
"plansNew": "New plan",
"plansNewEmpty": "From nothing",
"plansEmptyTitle": "No plans yet",
"plansEmptyHint": "A plan's steps lead you through intervals as you record, with a tone, a vibration and a voice at each change. Start from a template, or from nothing.",
"planLocked": "A plan that doesn't open here",
"planTemplateFourByFour": "4×4 minutes",
"planTemplateTabata": "Tabata",
"planTemplateFourHundreds": "10×400 m",
"planTitle": "Plan",
"planNewTitle": "New plan",
"planName": "Name",
"planActivity": "For",
"planActivityAny": "Any activity",
"planOffTarget": "Tell me when I'm off target",
"planOffTargetHint": "A short cue at most every 30 seconds.",
"planSave": "Save",
"planNotSaved": "The plan wasn't saved.",
"planAddStep": "Add a step",
"planAddRepeat": "Add a repeat",
"planRepeat": "{count}×",
"@planRepeat": {"placeholders": {"count": {"type": "int"}}},
"planFewer": "Fewer rounds",
"planMore": "More rounds",
"planBlockMenu": "More",
"planMoveUp": "Move up",
"planMoveDown": "Move down",
"planRemove": "Remove",
"planDelete": "Delete",
"planDeleteTitle": "Delete this plan?",
"planDeleteMessage": "It's deleted on all your devices; workouts recorded with it keep their steps.",
"planDeleted": "Plan deleted",
"planSteps": "{count, plural, =1{1 step} other{{count} steps}}",
"@planSteps": {"placeholders": {"count": {"type": "int"}}},
"stepTitle": "Step",
"stepKind": "Kind",
"stepWarmUp": "Warm-up",
"stepWork": "Work",
"stepRecovery": "Recovery",
"stepCoolDown": "Cool-down",
"stepEnds": "Ends",
"stepEndTime": "After a time",
"stepEndDistance": "After a distance",
"stepEndLap": "With a lap tap",
"stepTimeLabel": "Time (min:s)",
"stepMetresLabel": "Metres",
"stepTarget": "Target",
"stepZone": "Zone",
"stepDone": "Done",
"stepMinutes": "{minutes} min",
"@stepMinutes": {"placeholders": {"minutes": {"type": "int"}}},
"stepSeconds": "{seconds} s",
"@stepSeconds": {"placeholders": {"seconds": {"type": "int"}}},
"stepMinutesSeconds": "{minutes} min {seconds} s",
"@stepMinutesSeconds": {"placeholders": {"minutes": {"type": "int"}, "seconds": {"type": "int"}}},
"stepMetres": "{metres} m",
"@stepMetres": {"placeholders": {"metres": {"type": "int"}}},
"stepKilometres": "{km} km",
"@stepKilometres": {"placeholders": {"km": {"type": "String"}}},
"stepUntilLap": "until a lap tap",
"targetNone": "None",
"targetFrom": "From",
"targetTo": "To",
"targetKindPace": "Pace (per km)",
"targetKindSpeed": "Speed (km/h)",
"targetKindHeartRate": "Heart rate (bpm)",
"targetKindHeartZone": "Heart-rate zone",
"targetKindSplit": "Split (per 500 m)",
"targetKindWatts": "Power (W)",
"targetKindStrokeRate": "Stroke rate (per min)",
"targetPace": "{from}–{to} per km",
"@targetPace": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"targetSpeed": "{from}–{to} km/h",
"@targetSpeed": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"targetHeartRate": "{from}–{to} bpm",
"@targetHeartRate": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"targetZone": "Zone {zone}",
"@targetZone": {"placeholders": {"zone": {"type": "int"}}},
"targetSplit": "{from}–{to} per 500 m",
"@targetSplit": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"targetWatts": "{from}–{to} W",
"@targetWatts": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"targetStrokeRate": "{from}–{to} strokes/min",
"@targetStrokeRate": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"daysEmptyTitle": "No days yet",
"daysEmptyHint": "The days and nights your Polar Loop counts show here.",
"daysSteps": "{steps, plural, =1{1 step} other{{steps} steps}}",

View file

@ -1468,6 +1468,426 @@ abstract class AppLocalizations {
/// **'Days'**
String get homeDays;
/// No description provided for @homePlans.
///
/// In en, this message translates to:
/// **'Plans'**
String get homePlans;
/// No description provided for @plansNew.
///
/// In en, this message translates to:
/// **'New plan'**
String get plansNew;
/// No description provided for @plansNewEmpty.
///
/// In en, this message translates to:
/// **'From nothing'**
String get plansNewEmpty;
/// No description provided for @plansEmptyTitle.
///
/// In en, this message translates to:
/// **'No plans yet'**
String get plansEmptyTitle;
/// No description provided for @plansEmptyHint.
///
/// In en, this message translates to:
/// **'A plan\'s steps lead you through intervals as you record, with a tone, a vibration and a voice at each change. Start from a template, or from nothing.'**
String get plansEmptyHint;
/// No description provided for @planLocked.
///
/// In en, this message translates to:
/// **'A plan that doesn\'t open here'**
String get planLocked;
/// No description provided for @planTemplateFourByFour.
///
/// In en, this message translates to:
/// **'4×4 minutes'**
String get planTemplateFourByFour;
/// No description provided for @planTemplateTabata.
///
/// In en, this message translates to:
/// **'Tabata'**
String get planTemplateTabata;
/// No description provided for @planTemplateFourHundreds.
///
/// In en, this message translates to:
/// **'10×400 m'**
String get planTemplateFourHundreds;
/// No description provided for @planTitle.
///
/// In en, this message translates to:
/// **'Plan'**
String get planTitle;
/// No description provided for @planNewTitle.
///
/// In en, this message translates to:
/// **'New plan'**
String get planNewTitle;
/// No description provided for @planName.
///
/// In en, this message translates to:
/// **'Name'**
String get planName;
/// No description provided for @planActivity.
///
/// In en, this message translates to:
/// **'For'**
String get planActivity;
/// No description provided for @planActivityAny.
///
/// In en, this message translates to:
/// **'Any activity'**
String get planActivityAny;
/// No description provided for @planOffTarget.
///
/// In en, this message translates to:
/// **'Tell me when I\'m off target'**
String get planOffTarget;
/// No description provided for @planOffTargetHint.
///
/// In en, this message translates to:
/// **'A short cue at most every 30 seconds.'**
String get planOffTargetHint;
/// No description provided for @planSave.
///
/// In en, this message translates to:
/// **'Save'**
String get planSave;
/// No description provided for @planNotSaved.
///
/// In en, this message translates to:
/// **'The plan wasn\'t saved.'**
String get planNotSaved;
/// No description provided for @planAddStep.
///
/// In en, this message translates to:
/// **'Add a step'**
String get planAddStep;
/// No description provided for @planAddRepeat.
///
/// In en, this message translates to:
/// **'Add a repeat'**
String get planAddRepeat;
/// No description provided for @planRepeat.
///
/// In en, this message translates to:
/// **'{count}×'**
String planRepeat(int count);
/// No description provided for @planFewer.
///
/// In en, this message translates to:
/// **'Fewer rounds'**
String get planFewer;
/// No description provided for @planMore.
///
/// In en, this message translates to:
/// **'More rounds'**
String get planMore;
/// No description provided for @planBlockMenu.
///
/// In en, this message translates to:
/// **'More'**
String get planBlockMenu;
/// No description provided for @planMoveUp.
///
/// In en, this message translates to:
/// **'Move up'**
String get planMoveUp;
/// No description provided for @planMoveDown.
///
/// In en, this message translates to:
/// **'Move down'**
String get planMoveDown;
/// No description provided for @planRemove.
///
/// In en, this message translates to:
/// **'Remove'**
String get planRemove;
/// No description provided for @planDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get planDelete;
/// No description provided for @planDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete this plan?'**
String get planDeleteTitle;
/// No description provided for @planDeleteMessage.
///
/// In en, this message translates to:
/// **'It\'s deleted on all your devices; workouts recorded with it keep their steps.'**
String get planDeleteMessage;
/// No description provided for @planDeleted.
///
/// In en, this message translates to:
/// **'Plan deleted'**
String get planDeleted;
/// No description provided for @planSteps.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 step} other{{count} steps}}'**
String planSteps(int count);
/// No description provided for @stepTitle.
///
/// In en, this message translates to:
/// **'Step'**
String get stepTitle;
/// No description provided for @stepKind.
///
/// In en, this message translates to:
/// **'Kind'**
String get stepKind;
/// No description provided for @stepWarmUp.
///
/// In en, this message translates to:
/// **'Warm-up'**
String get stepWarmUp;
/// No description provided for @stepWork.
///
/// In en, this message translates to:
/// **'Work'**
String get stepWork;
/// No description provided for @stepRecovery.
///
/// In en, this message translates to:
/// **'Recovery'**
String get stepRecovery;
/// No description provided for @stepCoolDown.
///
/// In en, this message translates to:
/// **'Cool-down'**
String get stepCoolDown;
/// No description provided for @stepEnds.
///
/// In en, this message translates to:
/// **'Ends'**
String get stepEnds;
/// No description provided for @stepEndTime.
///
/// In en, this message translates to:
/// **'After a time'**
String get stepEndTime;
/// No description provided for @stepEndDistance.
///
/// In en, this message translates to:
/// **'After a distance'**
String get stepEndDistance;
/// No description provided for @stepEndLap.
///
/// In en, this message translates to:
/// **'With a lap tap'**
String get stepEndLap;
/// No description provided for @stepTimeLabel.
///
/// In en, this message translates to:
/// **'Time (min:s)'**
String get stepTimeLabel;
/// No description provided for @stepMetresLabel.
///
/// In en, this message translates to:
/// **'Metres'**
String get stepMetresLabel;
/// No description provided for @stepTarget.
///
/// In en, this message translates to:
/// **'Target'**
String get stepTarget;
/// No description provided for @stepZone.
///
/// In en, this message translates to:
/// **'Zone'**
String get stepZone;
/// No description provided for @stepDone.
///
/// In en, this message translates to:
/// **'Done'**
String get stepDone;
/// No description provided for @stepMinutes.
///
/// In en, this message translates to:
/// **'{minutes} min'**
String stepMinutes(int minutes);
/// No description provided for @stepSeconds.
///
/// In en, this message translates to:
/// **'{seconds} s'**
String stepSeconds(int seconds);
/// No description provided for @stepMinutesSeconds.
///
/// In en, this message translates to:
/// **'{minutes} min {seconds} s'**
String stepMinutesSeconds(int minutes, int seconds);
/// No description provided for @stepMetres.
///
/// In en, this message translates to:
/// **'{metres} m'**
String stepMetres(int metres);
/// No description provided for @stepKilometres.
///
/// In en, this message translates to:
/// **'{km} km'**
String stepKilometres(String km);
/// No description provided for @stepUntilLap.
///
/// In en, this message translates to:
/// **'until a lap tap'**
String get stepUntilLap;
/// No description provided for @targetNone.
///
/// In en, this message translates to:
/// **'None'**
String get targetNone;
/// No description provided for @targetFrom.
///
/// In en, this message translates to:
/// **'From'**
String get targetFrom;
/// No description provided for @targetTo.
///
/// In en, this message translates to:
/// **'To'**
String get targetTo;
/// No description provided for @targetKindPace.
///
/// In en, this message translates to:
/// **'Pace (per km)'**
String get targetKindPace;
/// No description provided for @targetKindSpeed.
///
/// In en, this message translates to:
/// **'Speed (km/h)'**
String get targetKindSpeed;
/// No description provided for @targetKindHeartRate.
///
/// In en, this message translates to:
/// **'Heart rate (bpm)'**
String get targetKindHeartRate;
/// No description provided for @targetKindHeartZone.
///
/// In en, this message translates to:
/// **'Heart-rate zone'**
String get targetKindHeartZone;
/// No description provided for @targetKindSplit.
///
/// In en, this message translates to:
/// **'Split (per 500 m)'**
String get targetKindSplit;
/// No description provided for @targetKindWatts.
///
/// In en, this message translates to:
/// **'Power (W)'**
String get targetKindWatts;
/// No description provided for @targetKindStrokeRate.
///
/// In en, this message translates to:
/// **'Stroke rate (per min)'**
String get targetKindStrokeRate;
/// No description provided for @targetPace.
///
/// In en, this message translates to:
/// **'{from}–{to} per km'**
String targetPace(String from, String to);
/// No description provided for @targetSpeed.
///
/// In en, this message translates to:
/// **'{from}–{to} km/h'**
String targetSpeed(String from, String to);
/// No description provided for @targetHeartRate.
///
/// In en, this message translates to:
/// **'{from}–{to} bpm'**
String targetHeartRate(String from, String to);
/// No description provided for @targetZone.
///
/// In en, this message translates to:
/// **'Zone {zone}'**
String targetZone(int zone);
/// No description provided for @targetSplit.
///
/// In en, this message translates to:
/// **'{from}–{to} per 500 m'**
String targetSplit(String from, String to);
/// No description provided for @targetWatts.
///
/// In en, this message translates to:
/// **'{from}–{to} W'**
String targetWatts(String from, String to);
/// No description provided for @targetStrokeRate.
///
/// In en, this message translates to:
/// **'{from}–{to} strokes/min'**
String targetStrokeRate(String from, String to);
/// No description provided for @daysEmptyTitle.
///
/// In en, this message translates to:

View file

@ -834,6 +834,253 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get homeDays => 'Tage';
@override
String get homePlans => 'Pläne';
@override
String get plansNew => 'Neuer Plan';
@override
String get plansNewEmpty => 'Leer';
@override
String get plansEmptyTitle => 'Noch keine Pläne';
@override
String get plansEmptyHint =>
'Die Schritte eines Plans führen dich beim Aufzeichnen durch Intervalle, mit Ton, Vibration und Stimme bei jedem Wechsel. Beginne mit einer Vorlage oder leer.';
@override
String get planLocked => 'Ein Plan, der sich hier nicht öffnet';
@override
String get planTemplateFourByFour => '4×4 Minuten';
@override
String get planTemplateTabata => 'Tabata';
@override
String get planTemplateFourHundreds => '10×400 m';
@override
String get planTitle => 'Plan';
@override
String get planNewTitle => 'Neuer Plan';
@override
String get planName => 'Name';
@override
String get planActivity => 'Für';
@override
String get planActivityAny => 'Jede Aktivität';
@override
String get planOffTarget => 'Sagen, wenn ich vom Ziel abweiche';
@override
String get planOffTargetHint =>
'Ein kurzer Hinweis, höchstens alle 30 Sekunden.';
@override
String get planSave => 'Speichern';
@override
String get planNotSaved => 'Der Plan wurde nicht gespeichert.';
@override
String get planAddStep => 'Schritt hinzufügen';
@override
String get planAddRepeat => 'Wiederholung hinzufügen';
@override
String planRepeat(int count) {
return '$count×';
}
@override
String get planFewer => 'Weniger Runden';
@override
String get planMore => 'Mehr Runden';
@override
String get planBlockMenu => 'Mehr';
@override
String get planMoveUp => 'Nach oben';
@override
String get planMoveDown => 'Nach unten';
@override
String get planRemove => 'Entfernen';
@override
String get planDelete => 'Löschen';
@override
String get planDeleteTitle => 'Diesen Plan löschen?';
@override
String get planDeleteMessage =>
'Er wird auf all deinen Geräten gelöscht; Trainings, die mit ihm aufgezeichnet wurden, behalten ihre Schritte.';
@override
String get planDeleted => 'Plan gelöscht';
@override
String planSteps(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Schritte',
one: '1 Schritt',
);
return '$_temp0';
}
@override
String get stepTitle => 'Schritt';
@override
String get stepKind => 'Art';
@override
String get stepWarmUp => 'Aufwärmen';
@override
String get stepWork => 'Belastung';
@override
String get stepRecovery => 'Erholung';
@override
String get stepCoolDown => 'Auslaufen';
@override
String get stepEnds => 'Endet';
@override
String get stepEndTime => 'Nach einer Zeit';
@override
String get stepEndDistance => 'Nach einer Strecke';
@override
String get stepEndLap => 'Mit der Rundentaste';
@override
String get stepTimeLabel => 'Zeit (min:s)';
@override
String get stepMetresLabel => 'Meter';
@override
String get stepTarget => 'Ziel';
@override
String get stepZone => 'Zone';
@override
String get stepDone => 'Fertig';
@override
String stepMinutes(int minutes) {
return '$minutes min';
}
@override
String stepSeconds(int seconds) {
return '$seconds s';
}
@override
String stepMinutesSeconds(int minutes, int seconds) {
return '$minutes min $seconds s';
}
@override
String stepMetres(int metres) {
return '$metres m';
}
@override
String stepKilometres(String km) {
return '$km km';
}
@override
String get stepUntilLap => 'bis zur Rundentaste';
@override
String get targetNone => 'Keins';
@override
String get targetFrom => 'Von';
@override
String get targetTo => 'Bis';
@override
String get targetKindPace => 'Tempo (pro km)';
@override
String get targetKindSpeed => 'Geschwindigkeit (km/h)';
@override
String get targetKindHeartRate => 'Herzfrequenz (Schläge/min)';
@override
String get targetKindHeartZone => 'Herzfrequenzzone';
@override
String get targetKindSplit => 'Split (pro 500 m)';
@override
String get targetKindWatts => 'Leistung (W)';
@override
String get targetKindStrokeRate => 'Schlagzahl (pro min)';
@override
String targetPace(String from, String to) {
return '$from–$to pro km';
}
@override
String targetSpeed(String from, String to) {
return '$from–$to km/h';
}
@override
String targetHeartRate(String from, String to) {
return '$from–$to Schläge/min';
}
@override
String targetZone(int zone) {
return 'Zone $zone';
}
@override
String targetSplit(String from, String to) {
return '$from–$to pro 500 m';
}
@override
String targetWatts(String from, String to) {
return '$from–$to W';
}
@override
String targetStrokeRate(String from, String to) {
return '$from–$to Schläge/min';
}
@override
String get daysEmptyTitle => 'Noch keine Tage';

View file

@ -828,6 +828,252 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get homeDays => 'Days';
@override
String get homePlans => 'Plans';
@override
String get plansNew => 'New plan';
@override
String get plansNewEmpty => 'From nothing';
@override
String get plansEmptyTitle => 'No plans yet';
@override
String get plansEmptyHint =>
'A plan\'s steps lead you through intervals as you record, with a tone, a vibration and a voice at each change. Start from a template, or from nothing.';
@override
String get planLocked => 'A plan that doesn\'t open here';
@override
String get planTemplateFourByFour => '4×4 minutes';
@override
String get planTemplateTabata => 'Tabata';
@override
String get planTemplateFourHundreds => '10×400 m';
@override
String get planTitle => 'Plan';
@override
String get planNewTitle => 'New plan';
@override
String get planName => 'Name';
@override
String get planActivity => 'For';
@override
String get planActivityAny => 'Any activity';
@override
String get planOffTarget => 'Tell me when I\'m off target';
@override
String get planOffTargetHint => 'A short cue at most every 30 seconds.';
@override
String get planSave => 'Save';
@override
String get planNotSaved => 'The plan wasn\'t saved.';
@override
String get planAddStep => 'Add a step';
@override
String get planAddRepeat => 'Add a repeat';
@override
String planRepeat(int count) {
return '$count×';
}
@override
String get planFewer => 'Fewer rounds';
@override
String get planMore => 'More rounds';
@override
String get planBlockMenu => 'More';
@override
String get planMoveUp => 'Move up';
@override
String get planMoveDown => 'Move down';
@override
String get planRemove => 'Remove';
@override
String get planDelete => 'Delete';
@override
String get planDeleteTitle => 'Delete this plan?';
@override
String get planDeleteMessage =>
'It\'s deleted on all your devices; workouts recorded with it keep their steps.';
@override
String get planDeleted => 'Plan deleted';
@override
String planSteps(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count steps',
one: '1 step',
);
return '$_temp0';
}
@override
String get stepTitle => 'Step';
@override
String get stepKind => 'Kind';
@override
String get stepWarmUp => 'Warm-up';
@override
String get stepWork => 'Work';
@override
String get stepRecovery => 'Recovery';
@override
String get stepCoolDown => 'Cool-down';
@override
String get stepEnds => 'Ends';
@override
String get stepEndTime => 'After a time';
@override
String get stepEndDistance => 'After a distance';
@override
String get stepEndLap => 'With a lap tap';
@override
String get stepTimeLabel => 'Time (min:s)';
@override
String get stepMetresLabel => 'Metres';
@override
String get stepTarget => 'Target';
@override
String get stepZone => 'Zone';
@override
String get stepDone => 'Done';
@override
String stepMinutes(int minutes) {
return '$minutes min';
}
@override
String stepSeconds(int seconds) {
return '$seconds s';
}
@override
String stepMinutesSeconds(int minutes, int seconds) {
return '$minutes min $seconds s';
}
@override
String stepMetres(int metres) {
return '$metres m';
}
@override
String stepKilometres(String km) {
return '$km km';
}
@override
String get stepUntilLap => 'until a lap tap';
@override
String get targetNone => 'None';
@override
String get targetFrom => 'From';
@override
String get targetTo => 'To';
@override
String get targetKindPace => 'Pace (per km)';
@override
String get targetKindSpeed => 'Speed (km/h)';
@override
String get targetKindHeartRate => 'Heart rate (bpm)';
@override
String get targetKindHeartZone => 'Heart-rate zone';
@override
String get targetKindSplit => 'Split (per 500 m)';
@override
String get targetKindWatts => 'Power (W)';
@override
String get targetKindStrokeRate => 'Stroke rate (per min)';
@override
String targetPace(String from, String to) {
return '$from–$to per km';
}
@override
String targetSpeed(String from, String to) {
return '$from–$to km/h';
}
@override
String targetHeartRate(String from, String to) {
return '$from–$to bpm';
}
@override
String targetZone(int zone) {
return 'Zone $zone';
}
@override
String targetSplit(String from, String to) {
return '$from–$to per 500 m';
}
@override
String targetWatts(String from, String to) {
return '$from–$to W';
}
@override
String targetStrokeRate(String from, String to) {
return '$from–$to strokes/min';
}
@override
String get daysEmptyTitle => 'No days yet';

View file

@ -6,6 +6,7 @@ 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/plan/bloc/plans_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
@ -91,6 +92,12 @@ class const TrainPersonApp({
return DaysCubit(train.days, train.loop)..open();
},
),
BlocProvider(
create: (context) {
final train = PersonScope.of<TrainPerson>(context);
return PlansCubit(train.plans, train.planDeletions)..open();
},
),
],
child: _Recording(child: child!),
),

View file

@ -19,6 +19,8 @@ import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
@ -62,6 +64,9 @@ class TrainPerson(
/// every device of theirs, read on the phone holding the band.
required final DayRepository days,
/// Their interval plans (D42), sealed and synced.
required final PlanRepository plans,
/// The instance's map source (D22).
required final MapSourceRepository maps,
@ -85,10 +90,14 @@ class TrainPerson(
/// person's data, the workouts then kept.
late final deletions = DelayedDeletions(workouts.delete, logName: 'Workouts');
/// Plans deleted, to be undone for a moment, as workouts are.
late final planDeletions = DelayedDeletions(plans.delete, logName: 'Plans');
/// Closes what was opened for the person.
@override
Future<void> dispose() async {
await deletions.dispose();
await planDeletions.dispose();
await health?.dispose();
await loop?.dispose();
await _onDispose?.call();
@ -330,6 +339,7 @@ Future<TrainPerson> _openPersonData(
keys,
person: person.authUserId,
);
final plans = PlanRepository(SyncPlanGateway(db, sync), keys);
// Writes what changed while the app was closed, as it opens (D31).
final health = Platform.isAndroid
? (HealthConnect(
@ -356,6 +366,7 @@ Future<TrainPerson> _openPersonData(
workouts: workouts,
profile: profile,
days: days,
plans: plans,
maps: MapSourceRepository(ServerpodMapSourceGateway(client)),
recording: recorder != null
? (
@ -372,6 +383,7 @@ Future<TrainPerson> _openPersonData(
await workouts.dispose();
await profile.dispose();
await days.dispose();
await plans.dispose();
await sync.dispose();
await db.close();
} finally {

View file

@ -0,0 +1,151 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What a new step is, and a new repeat: a minute's work, and four rounds
/// of a minute's work and a minute's recovery.
const _newStep = PlanStep(
kind: StepKind.work,
end: EndAfterTime(Duration(minutes: 1)),
);
const _newRepeat = PlanBlock(
repeat: 4,
steps: [
_newStep,
PlanStep(kind: StepKind.recovery, end: EndAfterTime(Duration(minutes: 1))),
],
);
/// A plan being made or changed (ADR 0030, D42): its name, what it is for,
/// its blocks and steps, kept once saved; [id] null for a new one.
class PlanEditorCubit(
final PlanRepository _plans, {
required final PlanContent plan,
final String? id,
}) extends Cubit<PlanEditorState> {
this : super(PlanEditorState(plan: plan, id: id));
void rename(String name) => _edit(
(plan) => _plan(plan, name: name),
);
void setActivity(Activity? activity) => _edit(
(plan) => PlanContent(
name: plan.name,
blocks: plan.blocks,
activity: activity,
offTargetCues: plan.offTargetCues,
),
);
void setOffTargetCues(bool on) =>
_edit((plan) => _plan(plan, offTargetCues: on));
/// Adds a step: at the end of block [block], or as a block of its own.
void addStep({int? block}) => _blocks((blocks) {
if (block == null) {
blocks.add(const PlanBlock(steps: [_newStep]));
} else {
blocks[block] = PlanBlock(
repeat: blocks[block].repeat,
steps: [...blocks[block].steps, _newStep],
);
}
});
void addRepeat() => _blocks((blocks) => blocks.add(_newRepeat));
void setStep(int block, int index, PlanStep step) => _blocks((blocks) {
blocks[block] = PlanBlock(
repeat: blocks[block].repeat,
steps: [...blocks[block].steps]..[index] = step,
);
});
/// Removes a step; its block goes with its last.
void removeStep(int block, int index) => _blocks((blocks) {
final steps = [...blocks[block].steps]..removeAt(index);
if (steps.isEmpty) {
blocks.removeAt(block);
} else {
blocks[block] = PlanBlock(repeat: blocks[block].repeat, steps: steps);
}
});
/// Sets how often block [block] runs, once at least.
void setRepeat(int block, int repeat) => _blocks((blocks) {
blocks[block] = PlanBlock(
repeat: repeat < 1 ? 1 : repeat,
steps: blocks[block].steps,
);
});
void removeBlock(int block) => _blocks((blocks) => blocks.removeAt(block));
/// Moves block [block] up or down by one.
void moveBlock(int block, {required bool up}) => _blocks((blocks) {
final to = up ? block - 1 : block + 1;
if (to >= 0 && to < blocks.length) {
blocks.insert(to, blocks.removeAt(block));
}
});
/// Keeps the plan; false where it can't be (no name, no step, or this
/// device can't seal).
Future<bool> save() async {
final plan = state.plan;
if (!state.canSave) {
return false;
}
final named = _plan(plan, name: plan.name.trim());
try {
if (state.id case final id?) {
await _plans.update(id, named);
} else {
emit(PlanEditorState(plan: named, id: await _plans.save(named)));
}
} on KeysMissing {
return false;
}
return true;
}
void _blocks(void Function(List<PlanBlock> blocks) change) => _edit((plan) {
final blocks = [...plan.blocks];
change(blocks);
return PlanContent(
name: plan.name,
blocks: blocks,
activity: plan.activity,
offTargetCues: plan.offTargetCues,
);
});
void _edit(PlanContent Function(PlanContent plan) change) =>
emit(PlanEditorState(plan: change(state.plan), id: state.id));
static PlanContent _plan(
PlanContent plan, {
String? name,
bool? offTargetCues,
}) => PlanContent(
name: name ?? plan.name,
blocks: plan.blocks,
activity: plan.activity,
offTargetCues: offTargetCues ?? plan.offTargetCues,
);
}
final class const PlanEditorState({
required final PlanContent plan,
final String? id,
}) with Equatable {
/// Whether it has what a plan needs: a name and a step.
bool get canSave => plan.name.trim().isNotEmpty && plan.blocks.isNotEmpty;
@override
List<Object?> get props => [plan, id];
}

View file

@ -0,0 +1,87 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
/// The person's plans as home lists them (ADR 0030, D42): by name, those
/// this device can't open last; read again whenever they change, here or on
/// another device. A deletion leaves the list at once and can be undone for
/// a moment ([DelayedDeletions]).
class PlansCubit(
final PlanRepository _plans,
final DelayedDeletions _deletions,
) extends Cubit<PlansState> {
this : super(const PlansState());
StreamSubscription<void>? _changes;
StreamSubscription<Set<String>>? _pending;
Future<void> open() async {
_changes = _plans.changes.listen((_) => unawaited(_read()));
_pending = _deletions.watchPending().listen((pending) {
if (!isClosed) {
emit(state._with(pending: pending));
}
});
await _read();
}
void delete(String id) => _deletions.delete(id);
void undo(String id) => _deletions.undo(id);
Future<void> _read() async {
final all = await _plans.all();
final opened =
[
for (final plan in all)
if (plan.content != null) plan,
]..sort(
(a, b) => a.content!.name.toLowerCase().compareTo(
b.content!.name.toLowerCase(),
),
);
if (!isClosed) {
emit(
state._with(
all: [
...opened,
for (final plan in all)
if (plan.content == null) plan,
],
),
);
}
}
@override
Future<void> close() async {
await _changes?.cancel();
await _pending?.cancel();
await super.close();
}
}
final class const PlansState({
final bool loaded = false,
final List<StoredPlan> _all = const [],
final Set<String> _pending = const {},
}) with Equatable {
/// The plans to list: all but those being deleted.
List<StoredPlan> get visible => [
for (final plan in _all)
if (!_pending.contains(plan.id)) plan,
];
PlansState _with({List<StoredPlan>? all, Set<String>? pending}) => PlansState(
loaded: loaded || all != null,
all: all ?? _all,
pending: pending ?? _pending,
);
@override
List<Object?> get props => [loaded, _all, _pending];
}

View file

@ -0,0 +1,21 @@
import 'package:halkyon_app/halkyon_app.dart';
/// A `plan` row as kept: its id and its envelope, sealed (ADR 0030, D7).
typedef PlanRow = ({String id, Envelope envelope});
/// Where the person's interval plans are kept: their synced `plan` rows
/// (D42). The repository seals and opens; this only keeps.
abstract interface class PlanGateway() {
Future<List<PlanRow>> plans();
Future<void> insert(PlanRow plan);
/// Replaces the plan [id]'s envelope (an edit); nothing for one gone.
Future<void> update(String id, Envelope envelope);
/// Deletes the plan [id], its payload blanked first (ADR 0002, rule 2).
Future<void> delete(String id);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,120 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The schema of a plan's payload this app writes and reads.
const planSchemaVersion = 1;
/// [content] as its row's payload, before sealing: UTF-8 JSON, a step's end
/// as `{by: time, seconds}`, `{by: distance, metres}` or `{by: lap}`.
ByteData encodePlan(PlanContent content) => ByteData.sublistView(
Uint8List.fromList(
utf8.encode(
jsonEncode({
'schemaVersion': planSchemaVersion,
'name': content.name,
'activity': ?content.activity?.name,
'offTargetCues': content.offTargetCues,
'blocks': [
for (final block in content.blocks)
{
'repeat': block.repeat,
'steps': [for (final step in block.steps) planStepJson(step)],
},
],
}),
),
),
);
/// [step] as JSON, as a plan's payload and the recorder take it.
Map<String, Object?> planStepJson(PlanStep step) => {
'kind': step.kind.name,
'end': switch (step.end) {
EndAfterTime(:final time) => {'by': 'time', 'seconds': time.inSeconds},
EndAfterDistance(:final metres) => {'by': 'distance', 'metres': metres},
EndWithLap() => {'by': 'lap'},
},
if (step.target case final target?)
'target': {
'kind': target.kind.name,
'low': target.low,
'high': target.high,
},
};
/// The plan [payload] holds, opened; null for one of a newer schema or one
/// it can't read. A step it can't read is left out; a target it doesn't
/// know is none.
PlanContent? decodePlan(ByteData payload) {
try {
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(payload)),
) as Map<String, Object?>;
final version = json['schemaVersion']! as int;
if (version > planSchemaVersion) {
log('A plan of schema $version skipped', name: 'Plan');
return null;
}
return PlanContent(
name: json['name']! as String,
activity: Activity.values.asNameMap()[json['activity']],
offTargetCues: json['offTargetCues'] as bool? ?? true,
blocks: [
for (final block in json['blocks']! as List<Object?>)
if (block
case {
'repeat': final int repeat,
'steps': final List<Object?> steps,
}
when repeat > 0)
PlanBlock(
repeat: repeat,
steps: [for (final step in steps) ?_stepOf(step)],
),
],
);
} on Object catch (error) {
log('A plan not read: ${error.runtimeType}', name: 'Plan');
return null;
}
}
PlanStep? _stepOf(Object? json) {
if (json is! Map<String, Object?>) {
return null;
}
final kind = StepKind.values.asNameMap()[json['kind']];
final end = switch (json['end']) {
{'by': 'time', 'seconds': final int seconds} when seconds > 0 =>
EndAfterTime(Duration(seconds: seconds)),
{'by': 'distance', 'metres': final int metres} when metres > 0 =>
EndAfterDistance(metres),
{'by': 'lap'} => const EndWithLap(),
_ => null,
};
if (kind == null || end == null) {
return null;
}
return PlanStep(
kind: kind,
end: end,
target: switch (json['target']) {
{
'kind': final String name,
'low': final num low,
'high': final num high,
}
when TargetKind.values.asNameMap()[name] != null =>
Target(
TargetKind.values.byName(name),
low.toDouble(),
high.toDouble(),
),
_ => null,
},
);
}

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