halkyon/apps/train/AGENTS.md
Tizian 「ティツィアーン」 a2997e87af
feat(train): a route's map kept offline on request (ADR 0030, D45)
A saved route's page has Keep the map offline: the tiles within 2 km of
its way at zooms 10 to 16 (never above the source's own), shared between
routes, kept again as the route changes and let go with it. The style,
its sprites and the instance's map source are kept as last read, and
every map reads a kept tile before the network, so a map draws with no
network at all.

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

18 KiB

Halkyon Train

What holds for every app and package of this repository is in the root 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/). 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. 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) and routes (route, D44), 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) and route (a planned route, D44; its model SavedRoute, since Flutter has a Route), 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: …). Interval plans (D42, lib/src/feature/plan/): home's Plans tab (PlansCubit, deletions undone for a moment as workouts' are), a plan's editor (PlanEditorCubit, PlanScreen, a step on showStepSheet) and three templates (plan_templates.dart); PlanRepository seals each as a plan row (payload in plan_payload.dart, the step's JSON shared with the recorder). A recording runs the plan chosen on the Record sheet in its service: recorderPlanOf (plan_speech.dart) spells out its run with zones as heart rates and the spoken lines, PlanRunner.kt runs it (tested on the JVM), 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/): home's Routes tab (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 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; 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. 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). Tests on test/support/fake_routing_gateway.dart, the kept maps' on package:http/testing.dart's MockClient. 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.