halkyon/apps/train/AGENTS.md
Tizian 「ティツィアーン」 551a29445f
build(train): Train on the desktop as a snap, to the Store's edge channel with each release (ADR 0030, D62)
Built as Todo's in a clone of its own, from apps/train/snap/, for train.halkyon.eu; its
listing says the desktop shows, plans and keeps the statistics, and records nothing.
The snap tests now hold every snap built in a clone of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 12:16:05 +01:00

13 lines
34 KiB
Markdown

# Halkyon Train
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); since 30 Oct 2026 the app's tabs are Home, Maps, Record, Groups and Profile (D60). 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/`). **Routes** (ADR 0030, D11, D44, `lib/src/routing/`): the `routing` endpoint plans a route through the instance's BRouter (`BROUTER_URL`; none: `offered` is false and plans are refused) for the people signed in, at most 50 waypoints and 30 plans a person a minute, nothing kept or logged; BRouter's GeoJSON read in `brouter.dart` (points, elevations, climbs above 3 m, turn hints, stretches by surface), tested on its format (`test/unit/routing/`) and on a fake BRouter (`test/integration/routing/`). Train's running profile is `apps/train/brouter/halkyon-run.brf`, which the sidecar's profiles hold beside BRouter's own. **Deployed** (D46) beside learn.halkyon.eu's server once `deploy/.env` names `deploy/compose.train.yaml`: at train.halkyon.eu (`config/production.yaml`, the image `Dockerfile` as Learn's), a member of Learn's register, its database `halkyon_train` in the instance's PostgreSQL, BRouter's image `deploy/brouter/` (its segments kept current by `segments.sh`); `redeploy.sh` and `backup.sh` take Train's server and database in only where it is switched on, and the operator's steps are `deploy/sidecars/README.md`, *Train*. **The instance's own map** (D50, `lib/src/map/`) where `compose.maps.yaml` sets `MAP_TILES_URL`: Protomaps' tiles cut for Europe (`deploy/maps/`), drawn by Train's style (`train_style.dart`, served at `/map/style/light.json` and `dark.json`; `UPDATE_STYLE_FIXTURES=1 dart test test/unit/map` writes the app's copies after a change), for people signed in alone: `map.source` hands a key (`MapKeys`, under the password `mapKeySecret`) that Caddy has the web server check before each tile (`/map/tile-key`, `deploy/sidecars/train-maps.caddy`); the operator's steps are the README's *Train's map*. **Searching for places** (D50, `lib/src/search/`): the `search` endpoint asks the instance's Photon (`PHOTON_URL`, set by `compose.search.yaml`; none: `offered` is false and searches are refused) for people signed in, at most 60 searches a person a minute, nothing kept or logged; Photon's GeoJSON read in `photon.dart`, tested on its format (`test/unit/search/`) and on a fake Photon (`test/integration/search/`); the operator's steps are the README's *Train's search*. **Workouts' weather** (ADR 0036, `lib/src/weather/`): the `weather` endpoint asks Open-Meteo (`open_meteo.dart`: the forecast API, or the archive for spans ended more than seven days ago; the paid hosts with `OPEN_METEO_API_KEY`) for the hours of a place rounded to two decimals and a span of at most 48 hours, for people signed in, at most 30 requests a person a minute, nothing kept or logged; on unless the operator sets `WEATHER=off` (`offered` false, every request refused, `WeatherRefused`); Open-Meteo's answer read in `hoursOf`, tested on its format (`test/unit/weather/`) and on a fake Open-Meteo (`test/integration/weather/`). 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`), `plans` (`plan`, D42), `routes` (`route`, D44), `training-places` (`training_place`, ADR 0034) and `strength-plans` (`strength_plan`, ADR 0035), 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), `plan` (an interval plan, D42), `route` (a planned route, D44; its model `SavedRoute`, since Flutter has a `Route`), `training_place` (a place trained at with its equipment, ADR 0034) and `strength_plan` (a strength plan, ADR 0035), 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 (Profile is every Halkyon app's, halkyon_app's `HalkyonProfile`, since 2 Nov 2026: the person's own tab first, then Progress, Days and, where nothing records, Plans; Train's own `ProfileRepository` is the body's figures, not the public profile): 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 shows today from the band (`TodayCard`, D60) and sums up this week (`WeekCard`, `weekSummary`, D60) over 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, every map stopping at zoom 18, `mapMaxZoom`, beyond which vector_map_tiles' raster mode draws nothing, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and Settings (`lib/src/settings/settings_screen.dart`, over the tabs from the cog atop Home and Profile, `SettingsButton`, grouped in `SectionCard`s as Learn's: the operator's card, the training's screens, the account, About Halkyon Train last with what's new in the version above it (halkyon_app's `WhatsNewTile`, D61, the texts in `assets/whats_new/`, shown once after an update by `WhatsNewOnUpdate` around the tabs), the version small at the foot, `TrainDependencies.appVersion`; no Sync card yet, as `TrainPerson` doesn't hand its `SyncService` out): 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`. **Navigation is `go_router`** (D60), as Learn's: the routes in `lib/src/app/train_router.dart`, their paths in `train_paths.dart`; the five tabs (`lib/src/tabs/`) are the branches of a `StatefulShellRoute` in halkyon_ui's `TabsStack`, shown by `TrainShell` in halkyon_ui's `NavigationFrame` (a rail with Train's icon from medium windows on, the floating bar below, a tick on every choice), the Record tab left out where the device doesn't record (its branch stays, so the indices hold); a workout's page is a route of Home's branch and a day's of Profile's, so the tabs stay beside them; the route and plan editors (their content handed over as `extra`, a new one existing nowhere else yet) and the recording are top-level routes over the tabs. A recording under way, left, stands above the bar on every tab (`RecordingBar`, `NavigationFrame.status`), and a workout just saved opens its page in Home's tab (`RecordingState.saved`). The other screens (*Your body*, Health Connect, the Loop, music) are pushed from Settings. Every tab washes down from the top in carrot (`TabScreen`'s `TopGradient`, the app's `ScreenColor`), the warm end of Train's icon, as Learn's in cobalt. 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), `…/recorder/lines` (the file so far, then each line) and `…/recorder/opened` (the notification tapped, `RecordingOpenings.kt`); `TrainShell` opens the recording's screen as one starts and as the notification is tapped. Pages under the floating bar (a workout's, a day's) run on beneath it as the tabs do, their lists ending with the body's bottom padding (`MediaQuery.paddingOf`), never a `SafeArea` at the foot. 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, `healthRecordsVersion` folded in, counted up whenever Train writes more of a workout: D51's cadences, the cadences' permissions asked with writing's but not needed for it), 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 Settings (`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 the days down) 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`, their IDs keyed by the account so that none tells its date, D57, `TrainKeys.rowId`), 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; Profile's days, a tab beside Progress (*Your body*, the last 12 weeks and this year, D60: `BodyCard`, `WeeksCard`, `YearCard`; the tabs halkyon_ui's `FoldingTabs`), 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/`): the Record tab's plans, under a card for each activity whose Start opens the Record sheet for it (D60, `ActivityCard`), the sheet offering the plans and routes for that activity or for any (Profile's where the device doesn't record; `PlansCubit`, deletions undone for a moment as workouts' are), a plan's editor (`PlanEditorCubit`, `PlanScreen`, a step on `showStepSheet`) and three templates (`plan_templates.dart`); `PlanRepository` seals each as a `plan` row (payload in `plan_payload.dart`, the step's JSON shared with the recorder). A recording runs the plan chosen on the Record sheet in its service: `recorderPlanOf` (`plan_speech.dart`) spells out its run with zones as heart rates and the spoken lines, `PlanRunner.kt` runs it (tested on the JVM), `Cues.kt` gives the tone, vibration and voice; the file's `plan` and `step` lines bring it back (`Recording.plan`, `planSteps`), `LivePlan` is the step on the recording screen, and *Lap* ends a step (`RecorderPlatform.lap`). Saved, the steps are the workout's laps (D43, `WorkoutPlan` in its payload, worked out in `recordedWorkout`), and its page lists them with their figures and whether each held its target (`LapFigures` in `workout_analysis.dart`), the charts shading the work steps. **Routes** (D44, `lib/src/feature/route/`): the Maps tab's routes on a map (`RoutesMap`, above the list, beside it from expanded windows on, D60) and in a list (`RoutesCubit`, deletions undone for a moment as plans' are) and the planner (`RouteEditorCubit`, `RouteScreen`): the map tapped for waypoints, one held and dragged to move it, the line held and dragged to pull one out between the two around it, one tapped to take it off (D59, `insertionIndex` in `route_legs.dart`), the last undone, a new route's first where the phone is (`RecorderPlatform.position`, GPS through the recorder's channel), the way planned after each change by the instance (`RoutingGateway`, `ServerpodRoutingGateway` on the `routing` endpoint, BRouter's turn commands as `TurnKind`), an earlier change's answer dropped; a place searched for over the map (where the instance searches, D50: `PlaceSearchCubit`, `PlaceSearchGateway` on the `search` endpoint, tests on `test/support/fake_place_search_gateway.dart`) the next waypoint; its distance, climb, descent, elevation and surfaces under the map (`route_analysis.dart`); `RouteRepository` seals each as a `route` row (payload in `route_payload.dart`: delta-coded points and elevations, gzipped). A recording follows the route chosen on the Record sheet (D45): `recorderRouteOf` (`route_speech.dart`) hands the service its points, its turns with the lines spoken 50 m before them (100 m on a bike) and the words off the route, in a file (`route.json` in the app's no-backup folder: an intent carries less than a route's points); `RouteGuide.kt` follows it on each fix (tested on the JVM), `Cues.kt` gives the tone and the voice; the file's `route` (its payload, base64) and `guide` lines bring it back (`Recording.route`, `guidance`), `LiveRoute` is the next turn or the way back on the recording screen, which swipes to a map of the track (`LiveRecording.track`, `heading`), north or the heading up. Each kilometre on foot and each 5 km by bike is announced where the Record sheet's switch is on for the activity (D49, `announce.json`, `AnnounceStore`): the words from `recorderAnnouncementsOf` (`announce_speech.dart`), told by `Announcer.kt` (tested on the JVM) through `Cues.kt`. A workout with a track gets its weather as its page opens, once while it has none (ADR 0036, `lib/src/feature/weather/`: `WorkoutCubit` asks `WeatherGateway`, `ServerpodWeatherGateway` on the `weather` endpoint, the place rounded to two decimals before it leaves the phone), kept as `WorkoutWeather` in its payload and shown by `WorkoutWeatherView` with the link to Open-Meteo its licence asks for; Health Connect's fingerprint leaves it out, so keeping it writes nothing again; tests on `test/support/fake_weather_gateway.dart`. A workout goes out as GPX, TCX or FIT from its page (D49, `workout_export.dart`, the FIT writer `fit_encoder.dart`, tested with `test/support/fit_reader.dart`) through `RouteFiles.share`. The barometer's pressure (D49, `PressureAverage.kt`, the file's `b` lines, the `pressure` series) gives the fixes' altitudes (`withBarometer` in `workout_analysis.dart`) where it was kept. A ride reads the bike's speed and cadence sensor and power meter (D49, `CyclingSensors.kt` tested on the JVM, auto-pause by the wheel's speed while it sends, D51, `DeviceKind.speedSensor` and `powerMeter`, the file's `w`, `c` and `pw` lines, the series `wheel`, `pedalling` and `power`); `normalisedPowerOf` in `workout_analysis.dart`; the wheel's distance where GPS falls short (`usesWheel`, its circumference typed or learnt from rides GPS covered, D56, `KnownDevice.wheelMm`, `measuredWheelMm` in `recorded_workout.dart`). A saved route's map is kept offline on request (D45, `lib/src/feature/map/data/offline_maps.dart`: `OfflineMaps` in the person's `offline_maps/`, the tiles within 2 km of the way at zooms 10 to 16, `corridor.dart`, shared between routes, kept again as a route changes and let go with it; the style and the map source, `map_source.json`, kept as last read), and every map reads a kept tile before the network (`keptVectorTiles`, the style built with vector_tile_renderer's readers); each style kept in a folder of its own, the dark one in a dark theme and the instance's own key sent with its tiles (`map_style.dart`, D50). Routes go out and come in as GPX (D45, `gpx.dart`: `gpxOf`, `readGpx`, `drawnRoute`, `snappedRoute`): shared through `RouteFiles` (`route_files.dart`; Android's share sheet and the files Android hands Train through `RouteFiles.kt` over `app.halkyon.train/files` and `…/files/opened`, the FileProvider `${applicationId}.files`; elsewhere saved with halkyon_app's `FileSaver`), brought in from the Maps tab or by `RouteFilesListener` (`route_import.dart`), planned along the paths or kept as drawn; tests on `test/support/fake_route_files.dart`. Tests on `test/support/fake_routing_gateway.dart`, the kept maps' on `package:http/testing.dart`'s `MockClient`. **Music from Jellyfin** (D47, `lib/src/feature/music/`) is Android's (`TrainPerson.music` null elsewhere): `MusicLibrary` per person, signed in with Quick Connect or a password (`JellyfinClient`, over https alone but for a Jellyfin on the phone itself, `jellyfinInClear`, D54, as the client "Halkyon Train" with a device ID of its own made once), the token in Train's own store (D52, `JellyfinTokens.kt`: the no-backup folder's `jellyfin/`, sealed under a key of the Keystore's, read by the app over the music channel through `AndroidJellyfinTokenStore`, which moves one the keyring held over; others' forgotten as the library opens), the server and the playlists marked for running in the person's `music.json` (`MusicStore`, never synced); the music screen from Settings (`MusicCubit`, `MusicScreen`); the marked playlists' tracks kept in the person's `music/` by `MusicDownloads` (`tracks.json` beside them, `MusicTrack`): the playlists read as the app opens and resumes, the missing tracks handed to the music job (D52, `MusicPlatform.schedule`, `MusicWorker.kt`, a WorkManager job every 6 hours and once a list changes, on unmetered networks, the app open or not; `MusicFetcher.kt` downloads them, tested on the JVM, a line each in `fetched.txt`, which the app takes in as it opens and as `…/music/fetched` tells), their tempo from `MusicBridge.of` (`BpmTag.kt`, else `Tempo.kt` on a minute decoded with MediaCodec, tested on the JVM); lossless files above 256 kbps kept as Jellyfin's Opus at 128 kbps where *Smaller copies* is on, as by default (D52, `MusicCopy`, `JellyfinClient.audioPath`). A recording on foot plays what the Record sheet chose (D48: Music, Metronome or Off, `recorderMusicOf`): the recorder's `MusicConductor.kt` chooses tracks by the steps' cadence and retunes or crossfades them, `MusicPlayer.kt` plays them with Media3's ExoPlayer (its files pinned in `verification-metadata.xml` as Polar's are); a run starts at the cadence learnt from the person's runs (`cadence_model.dart`, the workouts' `cadence` series, `cadenceOf`), which also gives a plan's steps their cadence. Tests on `test/support/fake_jellyfin.dart` (with `FakeMusicPlatform`). The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). **Equipment** (ADR 0034, `lib/src/feature/equipment/`): the places the person trains at, each a sealed `training_place` row (`PlaceRepository`, payload in `place_payload.dart`, every weight in grams), with its equipment by kind (`EquipmentKind`: loadable bars and handles with their own weight, plates and what else is counted by weight, the rest by count, the person's own by name), a new place starting from what a home or a gym usually has (`PlaceContent.home`, `gym`); the place last chosen kept on the device alone (`ChosenPlaceStore`, the person's `place.json`, D4; never the device's location); the screens from Settings (`EquipmentScreen`, `PlaceScreen`, `showItemSheet`, `PlacesCubit`) and the plate calculator (`PlateCalculatorScreen`) on `plate_maths.dart`: `loadFor` the heaviest load not above a weight, both sides alike, fewest plates, heaviest inside; `loadSeries` the loads of a series making the fewest plate moves over it (a shortest path), `movesBetween` what comes off and goes on. **Strength plans** (ADR 0035, `lib/src/feature/strength/`) build on it: exercises from the catalogue (`exercise_catalogue.dart`, each by id, with what it needs, what it is done with and how it moves; the texts by id) or the person's own (written out in the plan); a plan's blocks, one exercise or several done in turn (a superset, a circuit), each with its sets, its rest after a round and between exercises (`StrengthPlanContent`), each plan a sealed `strength_plan` row (`StrengthPlanRepository`, payload in `strength_payload.dart`); the Record tab's Strength card and plans under the activities' cards (Profile's Plans tab where nothing records; `StrengthPlansCubit`, deletions undone for a moment), each marked with what the place chosen lacks (`missingAt`), the editor (`StrengthEditorCubit`, `StrengthPlanScreen`, `showExercisePicker`). A session runs on every device (`StrengthSessionCubit`, `StrengthSessionScreen`): set by set with the rest counted down, a swap offered among exercises moving the same way that the place allows (`swapsFor`), each weight made up from the place's equipment (`weight_up.dart`: plates on the bar or handles by `plate_maths.dart`, the plate changes between sets, fixed weights at or below), kept after every change in the person's `strength_session.json` (`StrengthSessionStore`, never synced) so one cut off is carried on, and saved as a workout of `Activity.strength` with its sets (`WorkoutStrength` in its payload, its page's `WorkoutStrengthView`; strength training in Health Connect and FIT, no GPX). **Moving more** (ADR 0037, `lib/src/feature/movement/`) is Android's (`TrainPerson.movement` null elsewhere): reminders to move and suggested workouts, each off until turned on in Settings (`MovementScreen`, `MovementCubit`; turning on asks for the activity and notification permissions through the recorder's channel), the person's settings in their `movement.json` (`MovingMore`, never synced), written with the notifications' words in their language into the job's configuration as their app opens (`TrainShell`) and as they change (`app.halkyon.train/movement`); `MovementWorker.kt`, a WorkManager job every 15 minutes, reads the step counter, keeps two hours of readings in `movement_state.json` and asks `Movement.kt` (tested on the JVM) whether to remind (under 50 steps over the idle time, within the hours, outside the break, on the days, never while recording or in a car: car mode or a car's Bluetooth audio, `inCar`) or to suggest (a walking or running cadence over the last 10 to 25 minutes, once an hour); a suggestion tapped (`MovementOpenings.kt`, `…/movement/opened`) opens the Record sheet for its activity (`recordActivity`). Tests on `test/support/fake_movement_platform.dart`. Nothing of the app's goes to Android's backup or to another phone (D53: `allowBackup` off and `res/xml/data_extraction_rules.xml` excluding every folder, `BackupRulesTest`), as what it keeps unsealed would go there unsealed. 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`. The band's own detection of training (ADR 0037, D2) is read on each connection and after its setup into `LoopStatus.detection` (`LoopPlatform.trainingDetection`, the SDK's `getUserDeviceSettings`) and switched on its screen with the shortest session it keeps (`setTrainingDetection`), its sensitivity left as the band has it, as the SDK documents no range. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
- **The icon** is Lucide's `dumbbell` (since 2 Nov 2026) fading from cobalt through violet and strawberry into carrot, its backdrop (ADR 0031, D5, D7), 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`, the app not launched with it (`auto_launch: false`: Serverpod's default device is the web server, which Train doesn't build for): run it with `flutter run -d <device> -t lib/driver.dart` in `halkyon_train_flutter`, on the emulator or a phone, `adb reverse tcp:8180 tcp:8180` letting it reach the server at `localhost:8180`; `.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.
- **Releases** (ADR 0030, D58) come from `dart run tool/release.dart train` on the developer's laptop, as Learn's (root AGENTS.md, Learn's *Releases*): `apps/train/release.dart`, the APK for train.halkyon.eu, on code.halkyon.app and in the project's F-Droid repository, and since D62 (3 Nov 2026) the desktop's snap (`apps/train/snap/`, built by the release tool in a clone of its own as Todo's, to the Store's edge channel) (`tool/release/fdroid/metadata/app.halkyon.train.yml`, `NonFreeDep` and `NonFreeNet`), signed with Train's own key (`~/.local/share/halkyon-train/android-release.p12`, alias `halkyon-train`, pinned in `tool/release/train-android-certificate.sha256`; `android/app/build.gradle.kts` signs with it when the release tool hands it over, else with the debug key).