feat(train): the workouts listed, and each one's page with its map (ADR 0030, D22-D24)

Home lists the person's workouts newest first under their months, each
with its activity's icon, its name (else the time of day's and the
activity's), when it started, its distance, moving time and pace or
speed; those this device can't open come last, locked. A workout's page
shows its track on the instance's map source (flutter_map with
vector_map_tiles, pinned at 9.0.0-beta.13), in pieces between its
pauses, start and end marked; its totals with the elevation gained;
its splits per kilometre on foot, per 5 km cycling; pace or speed and
elevation over the distance, drawn by the app's own painter; and what
the map's source sees. Its name and notes are edited there; deleting
asks first and can be undone for a moment from home (halkyon_app's
DelayedDeletions, TrainPerson.deletions).

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 14:29:39 +01:00
commit 405577050d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 2672 additions and 45 deletions

View file

@ -2,9 +2,9 @@
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), though no screen lists or shows them yet (5c). 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`).
- **A shell on the shared packages**, Todo's copied and renamed (decided 2026-10-28, ADR 0030, D12; what Train is to do: ADR 0030): signing in, the instances, the account and the look work, and since step 5a (28 Oct 2026) a person's workouts are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0030, D7, D14, D15), and on Android a workout is recorded (5b: ADR 0030, D13, D16, D18 to D21), and since 5c home lists them and each has its page with its map (D22 to D24). Packages: `halkyon_train_server`, `halkyon_train_client` (generated, never edited), `halkyon_train_flutter`; app ID `app.halkyon.train` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, and `deleteTrainData` (`lib/src/train_data.dart`) as its `deletePersonData` hook, which deletes the person's sync spaces and so, by the sync engine's cascades, every row of theirs (`test/integration/workout/person_deletion_test.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Train holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-28): an initial one with Serverpod's tables, its auth modules' and the module's as they stand (Todo's grew one by one with the module; Train starts where they ended), then `workouts`, purely additive: the sync engine's tables and Train's first three. **Synced models** (`database: sync`, the module `offline_sync`, `experimental_features: databaseSync` in `config/generator.yaml`): `content_key` (the person's key sealed to the account), `workout` and `workout_series` (a workout's series in chunks of at most ten minutes, deleted with it), each a sealed `payload` with its `keyId` and `nonce`, nothing else readable. Tests (`test/integration/train_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
- **The 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 with an empty home and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app whose home lists their workouts (`WorkoutsCubit`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
- **Recording** (ADR 0030, D13, D16, D18 to D21) is Android's alone for now (`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) and steps from the step detector, 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 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.
- **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

@ -6,15 +6,15 @@
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Trainings liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.",
"homeEmptyTitle": "Hier ist noch nichts",
"homeEmptyHint": "Trainings kommen mit einer späteren Version von Halkyon Train.",
"homeEmptyTitle": "Noch keine Trainings",
"homeEmptyHint": "Trainings, die du auf deinem Telefon aufzeichnest, erscheinen hier.",
"accountTitle": "Konto",
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
"aboutTitle": "Über",
"homeRecordHint": "Zeichne mit dem Knopf unten ein Training auf. Die Liste deiner Trainings kommt mit der nächsten Version von Halkyon Train.",
"homeRecordHint": "Zeichne eins mit dem Knopf unten auf.",
"recordAction": "Aufzeichnen",
"recordBack": "Zurück zur Aufzeichnung",
"recordSheetTitle": "Training aufzeichnen",
@ -52,5 +52,35 @@
"recordingCutOffTitle": "Eine Aufzeichnung wurde abgebrochen",
"recordingCutOffMessage": "Eine Aufzeichnung von {start} wurde abgebrochen. Bis {end} speichern oder verwerfen?",
"recordingNotificationChannel": "Aufzeichnung",
"recordingNotificationPaused": "{activity} · pausiert"
"recordingNotificationPaused": "{activity} · pausiert",
"workoutName": "{when, select, morning_running{Morgenlauf} morning_walking{Morgenspaziergang} morning_hiking{Morgenwanderung} morning_cycling{Morgenradfahrt} morning_indoorRowing{Rudern am Morgen} afternoon_running{Nachmittagslauf} afternoon_walking{Nachmittagsspaziergang} afternoon_hiking{Nachmittagswanderung} afternoon_cycling{Nachmittagsradfahrt} afternoon_indoorRowing{Rudern am Nachmittag} evening_running{Abendlauf} evening_walking{Abendspaziergang} evening_hiking{Abendwanderung} evening_cycling{Abendradfahrt} evening_indoorRowing{Rudern am Abend} night_running{Nachtlauf} night_walking{Nachtspaziergang} night_hiking{Nachtwanderung} night_cycling{Nachtradfahrt} night_indoorRowing{Rudern in der Nacht} other{Training}}",
"workoutLocked": "Gesperrtes Training",
"workoutLockedHint": "Dieses Gerät hat den Schlüssel nicht, mit dem es versiegelt ist.",
"workoutEdit": "Bearbeiten",
"workoutEditTitle": "Training bearbeiten",
"workoutNameLabel": "Name",
"workoutNotesLabel": "Notizen",
"workoutSave": "Speichern",
"workoutDelete": "Löschen",
"workoutDeleteTitle": "Dieses Training löschen?",
"workoutDeleteMessage": "Es wird auf all deinen Geräten gelöscht.",
"workoutDeleted": "Training gelöscht",
"workoutMovingTime": "Zeit in Bewegung",
"workoutTotalTime": "ganze Zeit",
"workoutDistance": "Strecke",
"workoutPace": "Tempo",
"workoutSpeed": "Geschwindigkeit",
"workoutClimb": "Aufstieg",
"workoutClimbValue": "{metres} m",
"workoutSplits": "Abschnitte",
"workoutSplitKm": "km {number}",
"workoutSplitFiveKm": "{from}–{to} km",
"workoutPaceChart": "Tempo",
"workoutSpeedChart": "Geschwindigkeit",
"workoutElevationChart": "Höhe",
"workoutMapSource": "Karte von {name}: sieht die Adresse deines Geräts und den gezeigten Ausschnitt, nicht deine Strecke.",
"workoutMapSourceAbout": "Was {name} speichert",
"workoutMapUnavailable": "Die Karte kann gerade nicht geladen werden.",
"workoutNotFound": "Dieses Training gibt es nicht mehr."
}

View file

@ -8,15 +8,15 @@
"instanceFirstStartSubtitle": "Choose the instance to keep your workouts on. Someone runs each one; you can add others later.",
"homeEmptyTitle": "Nothing here yet",
"homeEmptyHint": "Workouts come with a later version of Halkyon Train.",
"homeEmptyTitle": "No workouts yet",
"homeEmptyHint": "Workouts recorded on your phone show here.",
"accountTitle": "Account",
"accountDeleteMessage": "Your account is deleted on the server and on all your devices. This can't be undone.",
"aboutTitle": "About",
"homeRecordHint": "Record a workout with the button below. The list of your workouts comes with the next version of Halkyon Train.",
"homeRecordHint": "Record one with the button below.",
"recordAction": "Record",
"recordBack": "Back to the recording",
"recordSheetTitle": "Record a workout",
@ -65,5 +65,44 @@
"recordingNotificationChannel": "Recording",
"@recordingNotificationChannel": {"description": "The name of the recording's notification channel, in the system's settings."},
"recordingNotificationPaused": "{activity} · paused",
"@recordingNotificationPaused": {"description": "The notification's title while paused.", "placeholders": {"activity": {"type": "String", "example": "Running"}}}
"@recordingNotificationPaused": {"description": "The notification's title while paused.", "placeholders": {"activity": {"type": "String", "example": "Running"}}},
"workoutName": "{when, select, morning_running{Morning run} morning_walking{Morning walk} morning_hiking{Morning hike} morning_cycling{Morning ride} morning_indoorRowing{Morning row} afternoon_running{Afternoon run} afternoon_walking{Afternoon walk} afternoon_hiking{Afternoon hike} afternoon_cycling{Afternoon ride} afternoon_indoorRowing{Afternoon row} evening_running{Evening run} evening_walking{Evening walk} evening_hiking{Evening hike} evening_cycling{Evening ride} evening_indoorRowing{Evening row} night_running{Night run} night_walking{Night walk} night_hiking{Night hike} night_cycling{Night ride} night_indoorRowing{Night row} other{Workout}}",
"@workoutName": {"description": "A workout's name where the person gave none: the time of day it started (morning 5–12, afternoon 12–17, evening 17–22, night) and its activity, as Strava names them.", "placeholders": {"when": {"type": "String"}}},
"workoutLocked": "Locked workout",
"workoutLockedHint": "This device doesn't hold the key it's sealed with.",
"@workoutLockedHint": {"description": "Under a workout this device can't open (its key is missing here)."},
"workoutEdit": "Edit",
"workoutEditTitle": "Edit workout",
"workoutNameLabel": "Name",
"workoutNotesLabel": "Notes",
"workoutSave": "Save",
"workoutDelete": "Delete",
"workoutDeleteTitle": "Delete this workout?",
"workoutDeleteMessage": "It's deleted on all your devices.",
"workoutDeleted": "Workout deleted",
"workoutMovingTime": "moving time",
"workoutTotalTime": "whole time",
"@workoutTotalTime": {"description": "Lower case, under a figure: from start to end, pauses included."},
"workoutDistance": "distance",
"workoutPace": "pace",
"workoutSpeed": "speed",
"workoutClimb": "climbed",
"@workoutClimb": {"description": "Lower case, under a figure: the elevation gained."},
"workoutClimbValue": "{metres} m",
"@workoutClimbValue": {"placeholders": {"metres": {"type": "int"}}},
"workoutSplits": "Splits",
"workoutSplitKm": "km {number}",
"@workoutSplitKm": {"description": "A kilometre's split, its number.", "placeholders": {"number": {"type": "int"}}},
"workoutSplitFiveKm": "{from}–{to} km",
"@workoutSplitFiveKm": {"description": "A cycling split of 5 km: where it starts and ends.", "placeholders": {"from": {"type": "int"}, "to": {"type": "int"}}},
"workoutPaceChart": "Pace",
"workoutSpeedChart": "Speed",
"workoutElevationChart": "Elevation",
"workoutMapSource": "Map by {name}: it sees your device's address and the area shown, not your track.",
"@workoutMapSource": {"description": "Under a workout's map: what the map's source sees.", "placeholders": {"name": {"type": "String", "example": "OpenFreeMap"}}},
"workoutMapSourceAbout": "What {name} keeps",
"@workoutMapSourceAbout": {"description": "A link to the map source's page about what it keeps.", "placeholders": {"name": {"type": "String", "example": "OpenFreeMap"}}},
"workoutMapUnavailable": "The map can't be loaded now.",
"workoutNotFound": "This workout is gone."
}

View file

@ -121,13 +121,13 @@ abstract class AppLocalizations {
/// No description provided for @homeEmptyTitle.
///
/// In en, this message translates to:
/// **'Nothing here yet'**
/// **'No workouts yet'**
String get homeEmptyTitle;
/// No description provided for @homeEmptyHint.
///
/// In en, this message translates to:
/// **'Workouts come with a later version of Halkyon Train.'**
/// **'Workouts recorded on your phone show here.'**
String get homeEmptyHint;
/// No description provided for @accountTitle.
@ -151,7 +151,7 @@ abstract class AppLocalizations {
/// No description provided for @homeRecordHint.
///
/// In en, this message translates to:
/// **'Record a workout with the button below. The list of your workouts comes with the next version of Halkyon Train.'**
/// **'Record one with the button below.'**
String get homeRecordHint;
/// No description provided for @recordAction.
@ -381,6 +381,180 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{activity} · paused'**
String recordingNotificationPaused(String activity);
/// A workout's name where the person gave none: the time of day it started (morning 5–12, afternoon 12–17, evening 17–22, night) and its activity, as Strava names them.
///
/// In en, this message translates to:
/// **'{when, select, morning_running{Morning run} morning_walking{Morning walk} morning_hiking{Morning hike} morning_cycling{Morning ride} morning_indoorRowing{Morning row} afternoon_running{Afternoon run} afternoon_walking{Afternoon walk} afternoon_hiking{Afternoon hike} afternoon_cycling{Afternoon ride} afternoon_indoorRowing{Afternoon row} evening_running{Evening run} evening_walking{Evening walk} evening_hiking{Evening hike} evening_cycling{Evening ride} evening_indoorRowing{Evening row} night_running{Night run} night_walking{Night walk} night_hiking{Night hike} night_cycling{Night ride} night_indoorRowing{Night row} other{Workout}}'**
String workoutName(String when);
/// No description provided for @workoutLocked.
///
/// In en, this message translates to:
/// **'Locked workout'**
String get workoutLocked;
/// Under a workout this device can't open (its key is missing here).
///
/// In en, this message translates to:
/// **'This device doesn\'t hold the key it\'s sealed with.'**
String get workoutLockedHint;
/// No description provided for @workoutEdit.
///
/// In en, this message translates to:
/// **'Edit'**
String get workoutEdit;
/// No description provided for @workoutEditTitle.
///
/// In en, this message translates to:
/// **'Edit workout'**
String get workoutEditTitle;
/// No description provided for @workoutNameLabel.
///
/// In en, this message translates to:
/// **'Name'**
String get workoutNameLabel;
/// No description provided for @workoutNotesLabel.
///
/// In en, this message translates to:
/// **'Notes'**
String get workoutNotesLabel;
/// No description provided for @workoutSave.
///
/// In en, this message translates to:
/// **'Save'**
String get workoutSave;
/// No description provided for @workoutDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get workoutDelete;
/// No description provided for @workoutDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete this workout?'**
String get workoutDeleteTitle;
/// No description provided for @workoutDeleteMessage.
///
/// In en, this message translates to:
/// **'It\'s deleted on all your devices.'**
String get workoutDeleteMessage;
/// No description provided for @workoutDeleted.
///
/// In en, this message translates to:
/// **'Workout deleted'**
String get workoutDeleted;
/// No description provided for @workoutMovingTime.
///
/// In en, this message translates to:
/// **'moving time'**
String get workoutMovingTime;
/// Lower case, under a figure: from start to end, pauses included.
///
/// In en, this message translates to:
/// **'whole time'**
String get workoutTotalTime;
/// No description provided for @workoutDistance.
///
/// In en, this message translates to:
/// **'distance'**
String get workoutDistance;
/// No description provided for @workoutPace.
///
/// In en, this message translates to:
/// **'pace'**
String get workoutPace;
/// No description provided for @workoutSpeed.
///
/// In en, this message translates to:
/// **'speed'**
String get workoutSpeed;
/// Lower case, under a figure: the elevation gained.
///
/// In en, this message translates to:
/// **'climbed'**
String get workoutClimb;
/// No description provided for @workoutClimbValue.
///
/// In en, this message translates to:
/// **'{metres} m'**
String workoutClimbValue(int metres);
/// No description provided for @workoutSplits.
///
/// In en, this message translates to:
/// **'Splits'**
String get workoutSplits;
/// A kilometre's split, its number.
///
/// In en, this message translates to:
/// **'km {number}'**
String workoutSplitKm(int number);
/// A cycling split of 5 km: where it starts and ends.
///
/// In en, this message translates to:
/// **'{from}–{to} km'**
String workoutSplitFiveKm(int from, int to);
/// No description provided for @workoutPaceChart.
///
/// In en, this message translates to:
/// **'Pace'**
String get workoutPaceChart;
/// No description provided for @workoutSpeedChart.
///
/// In en, this message translates to:
/// **'Speed'**
String get workoutSpeedChart;
/// No description provided for @workoutElevationChart.
///
/// In en, this message translates to:
/// **'Elevation'**
String get workoutElevationChart;
/// Under a workout's map: what the map's source sees.
///
/// In en, this message translates to:
/// **'Map by {name}: it sees your device\'s address and the area shown, not your track.'**
String workoutMapSource(String name);
/// A link to the map source's page about what it keeps.
///
/// In en, this message translates to:
/// **'What {name} keeps'**
String workoutMapSourceAbout(String name);
/// No description provided for @workoutMapUnavailable.
///
/// In en, this message translates to:
/// **'The map can\'t be loaded now.'**
String get workoutMapUnavailable;
/// No description provided for @workoutNotFound.
///
/// In en, this message translates to:
/// **'This workout is gone.'**
String get workoutNotFound;
}
class _AppLocalizationsDelegate

View file

@ -20,11 +20,11 @@ class AppLocalizationsDe extends AppLocalizations {
'Wähle die Instanz, auf der deine Trainings liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.';
@override
String get homeEmptyTitle => 'Hier ist noch nichts';
String get homeEmptyTitle => 'Noch keine Trainings';
@override
String get homeEmptyHint =>
'Trainings kommen mit einer späteren Version von Halkyon Train.';
'Trainings, die du auf deinem Telefon aufzeichnest, erscheinen hier.';
@override
String get accountTitle => 'Konto';
@ -37,8 +37,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get aboutTitle => 'Über';
@override
String get homeRecordHint =>
'Zeichne mit dem Knopf unten ein Training auf. Die Liste deiner Trainings kommt mit der nächsten Version von Halkyon Train.';
String get homeRecordHint => 'Zeichne eins mit dem Knopf unten auf.';
@override
String get recordAction => 'Aufzeichnen';
@ -172,4 +171,131 @@ class AppLocalizationsDe extends AppLocalizations {
String recordingNotificationPaused(String activity) {
return '$activity · pausiert';
}
@override
String workoutName(String when) {
String _temp0 = intl.Intl.selectLogic(
when,
{
'morning_running': 'Morgenlauf',
'morning_walking': 'Morgenspaziergang',
'morning_hiking': 'Morgenwanderung',
'morning_cycling': 'Morgenradfahrt',
'morning_indoorRowing': 'Rudern am Morgen',
'afternoon_running': 'Nachmittagslauf',
'afternoon_walking': 'Nachmittagsspaziergang',
'afternoon_hiking': 'Nachmittagswanderung',
'afternoon_cycling': 'Nachmittagsradfahrt',
'afternoon_indoorRowing': 'Rudern am Nachmittag',
'evening_running': 'Abendlauf',
'evening_walking': 'Abendspaziergang',
'evening_hiking': 'Abendwanderung',
'evening_cycling': 'Abendradfahrt',
'evening_indoorRowing': 'Rudern am Abend',
'night_running': 'Nachtlauf',
'night_walking': 'Nachtspaziergang',
'night_hiking': 'Nachtwanderung',
'night_cycling': 'Nachtradfahrt',
'night_indoorRowing': 'Rudern in der Nacht',
'other': 'Training',
},
);
return '$_temp0';
}
@override
String get workoutLocked => 'Gesperrtes Training';
@override
String get workoutLockedHint =>
'Dieses Gerät hat den Schlüssel nicht, mit dem es versiegelt ist.';
@override
String get workoutEdit => 'Bearbeiten';
@override
String get workoutEditTitle => 'Training bearbeiten';
@override
String get workoutNameLabel => 'Name';
@override
String get workoutNotesLabel => 'Notizen';
@override
String get workoutSave => 'Speichern';
@override
String get workoutDelete => 'Löschen';
@override
String get workoutDeleteTitle => 'Dieses Training löschen?';
@override
String get workoutDeleteMessage => 'Es wird auf all deinen Geräten gelöscht.';
@override
String get workoutDeleted => 'Training gelöscht';
@override
String get workoutMovingTime => 'Zeit in Bewegung';
@override
String get workoutTotalTime => 'ganze Zeit';
@override
String get workoutDistance => 'Strecke';
@override
String get workoutPace => 'Tempo';
@override
String get workoutSpeed => 'Geschwindigkeit';
@override
String get workoutClimb => 'Aufstieg';
@override
String workoutClimbValue(int metres) {
return '$metres m';
}
@override
String get workoutSplits => 'Abschnitte';
@override
String workoutSplitKm(int number) {
return 'km $number';
}
@override
String workoutSplitFiveKm(int from, int to) {
return '$from–$to km';
}
@override
String get workoutPaceChart => 'Tempo';
@override
String get workoutSpeedChart => 'Geschwindigkeit';
@override
String get workoutElevationChart => 'Höhe';
@override
String workoutMapSource(String name) {
return 'Karte von $name: sieht die Adresse deines Geräts und den gezeigten Ausschnitt, nicht deine Strecke.';
}
@override
String workoutMapSourceAbout(String name) {
return 'Was $name speichert';
}
@override
String get workoutMapUnavailable =>
'Die Karte kann gerade nicht geladen werden.';
@override
String get workoutNotFound => 'Dieses Training gibt es nicht mehr.';
}

View file

@ -20,11 +20,10 @@ class AppLocalizationsEn extends AppLocalizations {
'Choose the instance to keep your workouts on. Someone runs each one; you can add others later.';
@override
String get homeEmptyTitle => 'Nothing here yet';
String get homeEmptyTitle => 'No workouts yet';
@override
String get homeEmptyHint =>
'Workouts come with a later version of Halkyon Train.';
String get homeEmptyHint => 'Workouts recorded on your phone show here.';
@override
String get accountTitle => 'Account';
@ -37,8 +36,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutTitle => 'About';
@override
String get homeRecordHint =>
'Record a workout with the button below. The list of your workouts comes with the next version of Halkyon Train.';
String get homeRecordHint => 'Record one with the button below.';
@override
String get recordAction => 'Record';
@ -171,6 +169,132 @@ class AppLocalizationsEn extends AppLocalizations {
String recordingNotificationPaused(String activity) {
return '$activity · paused';
}
@override
String workoutName(String when) {
String _temp0 = intl.Intl.selectLogic(
when,
{
'morning_running': 'Morning run',
'morning_walking': 'Morning walk',
'morning_hiking': 'Morning hike',
'morning_cycling': 'Morning ride',
'morning_indoorRowing': 'Morning row',
'afternoon_running': 'Afternoon run',
'afternoon_walking': 'Afternoon walk',
'afternoon_hiking': 'Afternoon hike',
'afternoon_cycling': 'Afternoon ride',
'afternoon_indoorRowing': 'Afternoon row',
'evening_running': 'Evening run',
'evening_walking': 'Evening walk',
'evening_hiking': 'Evening hike',
'evening_cycling': 'Evening ride',
'evening_indoorRowing': 'Evening row',
'night_running': 'Night run',
'night_walking': 'Night walk',
'night_hiking': 'Night hike',
'night_cycling': 'Night ride',
'night_indoorRowing': 'Night row',
'other': 'Workout',
},
);
return '$_temp0';
}
@override
String get workoutLocked => 'Locked workout';
@override
String get workoutLockedHint =>
'This device doesn\'t hold the key it\'s sealed with.';
@override
String get workoutEdit => 'Edit';
@override
String get workoutEditTitle => 'Edit workout';
@override
String get workoutNameLabel => 'Name';
@override
String get workoutNotesLabel => 'Notes';
@override
String get workoutSave => 'Save';
@override
String get workoutDelete => 'Delete';
@override
String get workoutDeleteTitle => 'Delete this workout?';
@override
String get workoutDeleteMessage => 'It\'s deleted on all your devices.';
@override
String get workoutDeleted => 'Workout deleted';
@override
String get workoutMovingTime => 'moving time';
@override
String get workoutTotalTime => 'whole time';
@override
String get workoutDistance => 'distance';
@override
String get workoutPace => 'pace';
@override
String get workoutSpeed => 'speed';
@override
String get workoutClimb => 'climbed';
@override
String workoutClimbValue(int metres) {
return '$metres m';
}
@override
String get workoutSplits => 'Splits';
@override
String workoutSplitKm(int number) {
return 'km $number';
}
@override
String workoutSplitFiveKm(int from, int to) {
return '$from–$to km';
}
@override
String get workoutPaceChart => 'Pace';
@override
String get workoutSpeedChart => 'Speed';
@override
String get workoutElevationChart => 'Elevation';
@override
String workoutMapSource(String name) {
return 'Map by $name: it sees your device\'s address and the area shown, not your track.';
}
@override
String workoutMapSourceAbout(String name) {
return 'What $name keeps';
}
@override
String get workoutMapUnavailable => 'The map can\'t be loaded now.';
@override
String get workoutNotFound => 'This workout is gone.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

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/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';
import 'package:halkyon_train_flutter/src/home/home_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -66,12 +67,23 @@ class const TrainPersonApp({
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: resolveAppLocale,
// How many accounts wait, for whoever operates the instance: the
// account sheet's tiles and the badge on its button.
// account sheet's tiles and the badge on its button. The workouts above
// the navigator, so home and a workout's page share their deletions.
builder: (context, child) => PersonScope(
person: person,
child: BlocProvider(
create: (context) =>
OperatingCubit(AppScope.of(context).instanceOperators),
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) =>
OperatingCubit(AppScope.of(context).instanceOperators),
),
BlocProvider(
create: (context) {
final train = PersonScope.of<TrainPerson>(context);
return WorkoutsCubit(train.workouts, train.deletions)..open();
},
),
],
child: _Recording(child: child!),
),
),

View file

@ -8,6 +8,8 @@ import 'package:halkyon_train_client/halkyon_train_client.dart'
show Client, UuidValue;
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/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/recording/data/android_recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
@ -25,9 +27,9 @@ typedef RecordingDevice = ({RecorderPlatform platform, File file});
/// A signed-in person's dependencies: who they are and their workouts, on
/// their own local database in their folder, which the sync engine keeps in
/// step with their other devices (ADR 0030, D7 and D14), and where the device
/// records. halkyon_app's `PersonScope` opens and disposes them with the
/// person.
/// step with their other devices (ADR 0030, D7 and D14), where the device
/// records, and the instance's map. halkyon_app's `PersonScope` opens and
/// disposes them with the person.
class TrainPerson(
/// Whose they are.
final SignedIn person, {
@ -35,14 +37,25 @@ class TrainPerson(
/// Their workouts, sealed and synced.
required final WorkoutRepository workouts,
/// The instance's map source (D22).
required final MapSourceRepository maps,
/// The map's tiles for that source: [vectorTiles] but in widget tests.
final MapTiles mapTiles = vectorTiles,
/// Where this device records; null where it doesn't (iOS until 5d, the
/// desktop, which shows and plans, D17).
final RecordingDevice? recording,
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// Workouts deleted, to be undone for a moment (D24); dropped with the
/// person's data, the workouts then kept.
late final deletions = DelayedDeletions(workouts.delete, logName: 'Workouts');
/// Closes what was opened for the person.
@override
Future<void> dispose() async {
await deletions.dispose();
await _onDispose?.call();
}
}
@ -275,6 +288,7 @@ Future<TrainPerson> _openPersonData(
return TrainPerson(
person,
workouts: workouts,
maps: MapSourceRepository(ServerpodMapSourceGateway(client)),
recording: Platform.isAndroid
? (
platform: AndroidRecorderPlatform(),

View file

@ -0,0 +1,35 @@
import 'dart:developer';
import 'package:halkyon_train_client/halkyon_train_client.dart'
show Client, MapSource;
/// Asks the instance for its map source (ADR 0030, D22).
abstract interface class MapSourceGateway() {
Future<MapSource> source();
}
/// [MapSourceGateway] on Train's server (`map.source`).
class ServerpodMapSourceGateway(final Client _client)
implements MapSourceGateway {
@override
Future<MapSource> source() => _client.map.source();
}
/// The instance's map source, asked once it is needed and kept while the
/// person's app runs; asked again after a failure (offline, say).
class MapSourceRepository(final MapSourceGateway _gateway) {
MapSource? _source;
/// The map source; null where the instance can't be asked now.
Future<MapSource?> source() async {
if (_source case final source?) {
return source;
}
try {
return _source = await _gateway.source();
} on Object catch (error) {
log('The map source not asked: ${error.runtimeType}', name: 'Map');
return null;
}
}
}

View file

@ -0,0 +1,36 @@
import 'package:flutter/widgets.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:vector_map_tiles/vector_map_tiles.dart';
/// Builds the map's tile layer for a source: [vectorTiles] in the app, none
/// in widget tests, which reach no network.
typedef MapTiles = Widget Function(MapSource source);
/// The source's vector tiles, drawn by its MapLibre style (ADR 0030, D11,
/// D23); nothing while the style loads, nor where it can't be read.
Widget vectorTiles(MapSource source) => _VectorTiles(source: source);
class const _VectorTiles({required final MapSource source})
extends StatefulWidget {
@override
State<_VectorTiles> createState() => _VectorTilesState();
}
class _VectorTilesState() extends State<_VectorTiles> {
late final Future<Style> _style = StyleReader(
uri: widget.source.styleUrl,
).read();
@override
Widget build(BuildContext context) => FutureBuilder(
future: _style,
builder: (context, snapshot) => switch (snapshot.data) {
final style? => VectorTileLayer(
theme: style.theme,
sprites: style.sprites,
tileProviders: style.providers,
),
null => const SizedBox.shrink(),
},
);
}

View file

@ -52,7 +52,7 @@ double? _metres(Recording recording) {
continue;
}
if (last != null) {
metres += _metresBetween(last, fix);
metres += metresBetween(last, fix);
}
last = fix;
}
@ -106,7 +106,7 @@ Duration movingTimeOf(Recording recording, DateTime endedAt) {
/// The great-circle distance between two fixes (haversine), on the mean
/// earth radius.
double _metresBetween(Fix a, Fix b) {
double metresBetween(Fix a, Fix b) {
const radius = 6371008.8;
const perUnit = pi / 180 / 1e7;
final (lat1, lat2) = (a.latE7 * perUnit, b.latE7 * perUnit);

View file

@ -0,0 +1,124 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// One workout's page (ADR 0030, D24): the workout and what its series show,
/// read again whenever the workouts change, and the instance's map source
/// where it has a track to show.
class WorkoutCubit(
final WorkoutRepository _workouts,
final MapSourceRepository _maps,
/// Which workout.
final String id,
) extends Cubit<WorkoutState> {
this : super(const WorkoutState());
StreamSubscription<void>? _changes;
/// Reads the workout, and follows it.
Future<void> open() async {
_changes = _workouts.changes.listen((_) => unawaited(_read()));
await _read();
}
/// Names it [name] with [notes] (blank: none).
Future<void> editText({String? name, String? notes}) async {
final content = state.content;
if (content == null) {
return;
}
await _workouts.update(id, content.withText(name: name, notes: notes));
}
Future<void> _read() async {
final stored = [
for (final workout in await _workouts.all())
if (workout.id == id) workout,
].firstOrNull;
final content = stored?.content;
if (content == null) {
// Deleted, here or elsewhere, or no longer opened here.
if (!isClosed) {
emit(const WorkoutState(phase: WorkoutPhase.gone));
}
return;
}
final analysis = analyse(content, await _workouts.series(id));
if (isClosed) {
return;
}
emit(
WorkoutState(
phase: WorkoutPhase.loaded,
content: content,
analysis: analysis,
map: state.map,
),
);
if (analysis.track.isNotEmpty && state.map == null) {
final source = await _maps.source();
if (!isClosed) {
emit(
state._withMap(
source == null ? const MapUnavailable() : MapFrom(source),
),
);
}
}
}
@override
Future<void> close() async {
await _changes?.cancel();
await super.close();
}
}
/// Where a workout's page stands.
enum WorkoutPhase() {
loading,
loaded,
gone,
}
/// The map a workout's page shows: from a source, or none for now.
sealed class const WorkoutMap() with Equatable;
/// The instance's map source.
final class const MapFrom(final MapSource source) extends WorkoutMap {
@override
List<Object?> get props => [source];
}
/// The instance couldn't be asked for its map source.
final class const MapUnavailable() extends WorkoutMap {
@override
List<Object?> get props => [];
}
/// What a workout's page shows.
final class const WorkoutState({
final WorkoutPhase phase = WorkoutPhase.loading,
final WorkoutContent? content,
final WorkoutAnalysis? analysis,
/// Null until asked, or where it has no track.
final WorkoutMap? map,
}) with Equatable {
WorkoutState _withMap(WorkoutMap map) => WorkoutState(
phase: phase,
content: content,
analysis: analysis,
map: map,
);
@override
List<Object?> get props => [phase, content, analysis, map];
}

View file

@ -0,0 +1,88 @@
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/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
/// The person's workouts as home lists them (ADR 0030, D24): newest first,
/// 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 WorkoutsCubit(
final WorkoutRepository _workouts,
final DelayedDeletions _deletions,
) extends Cubit<WorkoutsState> {
this : super(const WorkoutsState());
StreamSubscription<void>? _changes;
StreamSubscription<Set<String>>? _pending;
/// Reads the workouts, and follows them.
Future<void> open() async {
_changes = _workouts.changes.listen((_) => unawaited(_read()));
_pending = _deletions.watchPending().listen((pending) {
if (!isClosed) {
emit(state._with(pending: pending));
}
});
await _read();
}
/// Deletes the workout [id] once the moment to undo it is over.
void delete(String id) => _deletions.delete(id);
/// Keeps the workout [id] after all.
void undo(String id) => _deletions.undo(id);
Future<void> _read() async {
final all = await _workouts.all();
final opened = [
for (final workout in all)
if (workout.content != null) workout,
]..sort((a, b) => b.content!.startedAt.compareTo(a.content!.startedAt));
if (!isClosed) {
emit(
state._with(
all: [
...opened,
for (final workout in all)
if (workout.content == null) workout,
],
),
);
}
}
@override
Future<void> close() async {
await _changes?.cancel();
await _pending?.cancel();
await super.close();
}
}
/// The workouts home lists.
final class const WorkoutsState({
/// Whether they were read once.
final bool loaded = false,
final List<StoredWorkout> _all = const [],
final Set<String> _pending = const {},
}) with Equatable {
/// The workouts to list: all but those being deleted.
List<StoredWorkout> get visible => [
for (final workout in _all)
if (!_pending.contains(workout.id)) workout,
];
WorkoutsState _with({List<StoredWorkout>? all, Set<String>? pending}) =>
WorkoutsState(
loaded: loaded || all != null,
all: all ?? _all,
pending: pending ?? _pending,
);
@override
List<Object?> get props => [loaded, _all, _pending];
}

View file

@ -0,0 +1,264 @@
import 'dart:math';
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The climb, in metres, GPS's altitude must make before it counts (ADR 0030,
/// D24): its noise is a few metres.
const climbThresholdMetres = 3;
/// A stretch of a workout's distance and the time it moved over it.
final class const Split({
/// How long it is: the split's length, the last one as long as it went.
required final int metres,
required final Duration movingTime,
}) with Equatable {
@override
List<Object?> get props => [metres, movingTime];
}
/// A point of a chart over the distance: `metres` in, and its `value`.
typedef ChartPoint = ({double metres, double value});
/// What a workout's page shows of its series (D24).
final class const WorkoutAnalysis({
/// The track in pieces, one between each pause and the next, of the
/// fixes precise enough (D20): a pause is a gap, not a straight jump.
required final List<List<Fix>> track,
/// Per kilometre on foot, per 5 km cycling.
required final List<Split> splits,
/// The climbs above [climbThresholdMetres], added up; null without
/// altitudes.
required final int? elevationGainMetres,
/// The pace (seconds a kilometre takes) on foot, the speed (km/h)
/// cycling, over the distance.
required final List<ChartPoint> chart,
/// The altitude in metres over the distance; empty without altitudes.
required final List<ChartPoint> elevation,
});
/// How long a split is for [activity].
int splitMetresOf(Activity activity) =>
activity == Activity.cycling ? 5000 : 1000;
/// [content] and its [series] read for its page.
WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
final points = _moving(_recordingOf(content, series));
return WorkoutAnalysis(
track: [
for (final piece in _pieces(points)) [for (final p in piece) p.fix],
],
splits: _splits(points, splitMetresOf(content.activity)),
elevationGainMetres: _gain(points),
chart: _chart(points, pace: content.activity != Activity.cycling),
elevation: _elevation(points),
);
}
/// The recording [series] hold again, as it was saved.
Recording _recordingOf(WorkoutContent content, List<SeriesChunk> series) {
final fixes = <Fix>[];
final pauses = <PauseMark>[];
for (final chunk in series) {
final times = chunk.absoluteTimes;
final columns = chunk.columns;
switch (chunk.kind) {
case SeriesKind.position:
for (var i = 0; i < times.length; i++) {
final (lat, lon, acc) = (
columns['lat']?[i],
columns['lon']?[i],
columns['acc']?[i],
);
if (lat != null && lon != null && acc != null) {
fixes.add(
Fix(
time: times[i],
latE7: lat,
lonE7: lon,
accuracyCm: acc,
altitudeCm: columns['alt']?[i],
),
);
}
}
case SeriesKind.pauses:
for (var i = 0; i < times.length; i++) {
pauses.add(
PauseMark(
time: times[i],
paused: columns['paused']?[i] == 1,
auto: columns['auto']?[i] == 1,
),
);
}
case SeriesKind.heartRate:
break;
}
}
fixes.sort((a, b) => a.time.compareTo(b.time));
pauses.sort((a, b) => a.time.compareTo(b.time));
return Recording(
activity: content.activity,
startedAt: content.startedAt,
fixes: fixes,
pauses: pauses,
endedAt: content.endedAt,
);
}
/// A fix that counts, with how far and how long it had moved by then, and
/// whether a pause came before it since the fix before.
typedef _Point = ({Fix fix, double metres, Duration moving, bool afterPause});
/// The fixes precise enough taken while moving, in order, with the distance
/// and moving time up to each, as the saved totals count them.
List<_Point> _moving(Recording recording) {
final pauses = recording.pauses;
final points = <_Point>[];
var next = 0;
var paused = false;
var pausedFor = Duration.zero;
DateTime? pausedAt;
var broken = false;
var metres = 0.0;
for (final fix in recording.fixes) {
while (next < pauses.length && !pauses[next].time.isAfter(fix.time)) {
final mark = pauses[next++];
if (mark.paused && !paused) {
pausedAt = mark.time;
} else if (!mark.paused && paused && pausedAt != null) {
pausedFor += mark.time.difference(pausedAt);
}
paused = mark.paused;
broken = true;
}
if (paused || fix.accuracyCm > maxAccuracyCm) {
continue;
}
final last = points.lastOrNull;
if (last != null && !broken) {
metres += metresBetween(last.fix, fix);
}
points.add((
fix: fix,
metres: metres,
moving: fix.time.difference(recording.startedAt) - pausedFor,
afterPause: broken && last != null,
));
broken = false;
}
return points;
}
/// [points] cut where a pause came between two.
List<List<_Point>> _pieces(List<_Point> points) {
final pieces = <List<_Point>>[];
for (final point in points) {
if (pieces.isEmpty || point.afterPause) {
pieces.add([]);
}
pieces.last.add(point);
}
return pieces;
}
/// The moving time at [metres] in, between the points around it.
Duration _movingAt(List<_Point> points, double metres) {
for (var i = 1; i < points.length; i++) {
final (a, b) = (points[i - 1], points[i]);
if (b.metres >= metres) {
final span = b.metres - a.metres;
final share = span == 0 ? 1.0 : (metres - a.metres) / span;
final ms =
a.moving.inMilliseconds +
share * (b.moving - a.moving).inMilliseconds;
return Duration(milliseconds: ms.round());
}
}
return points.last.moving;
}
List<Split> _splits(List<_Point> points, int length) {
if (points.length < 2) {
return const [];
}
final total = points.last.metres;
final splits = <Split>[];
var before = points.first.moving;
for (var end = length.toDouble(); ; end += length) {
final last = end >= total;
final at = last ? total : end;
final moving = last ? points.last.moving : _movingAt(points, at);
final metres = (at - (end - length)).round();
if (metres > 0) {
splits.add(Split(metres: metres, movingTime: moving - before));
}
before = moving;
if (last) {
return splits;
}
}
}
int? _gain(List<_Point> points) {
final altitudes = [
for (final p in points)
if (p.fix.altitudeCm case final cm?) cm / 100,
];
if (altitudes.isEmpty) {
return null;
}
var reference = altitudes.first;
var gain = 0.0;
for (final altitude in altitudes.skip(1)) {
if (altitude > reference + climbThresholdMetres) {
gain += altitude - reference;
reference = altitude;
} else if (altitude < reference - climbThresholdMetres) {
reference = altitude;
}
}
return gain.round();
}
/// How long a stretch of the chart is: about a hundred over the distance,
/// none shorter than 50 m.
double _binMetres(List<_Point> points) => max(50, points.last.metres / 100);
List<ChartPoint> _chart(List<_Point> points, {required bool pace}) {
if (points.length < 2 || points.last.metres == 0) {
return const [];
}
final bin = _binMetres(points);
final chart = <ChartPoint>[];
var from = 0.0;
var before = points.first.moving;
while (from < points.last.metres) {
final to = min(from + bin, points.last.metres);
final moving = _movingAt(points, to);
final seconds = (moving - before).inMilliseconds / 1000;
final metres = to - from;
if (seconds > 0 && metres > 0) {
chart.add((
metres: to,
value: pace ? seconds / metres * 1000 : metres / seconds * 3.6,
));
}
from = to;
before = moving;
}
return chart;
}
List<ChartPoint> _elevation(List<_Point> points) => [
for (final p in points)
if (p.fix.altitudeCm case final cm?) (metres: p.metres, value: cm / 100),
];

View file

@ -31,6 +31,21 @@ final class const WorkoutContent({
/// How long it moved, its pauses left out (D16).
final Duration? movingTime,
}) with Equatable {
/// As it is, named [name] with [notes] (each null or blank: none).
WorkoutContent withText({String? name, String? notes}) {
String? text(String? value) =>
value == null || value.trim().isEmpty ? null : value.trim();
return WorkoutContent(
activity: activity,
startedAt: startedAt,
endedAt: endedAt,
name: text(name),
notes: text(notes),
distanceMetres: distanceMetres,
movingTime: movingTime,
);
}
@override
List<Object?> get props => [
activity,

View file

@ -0,0 +1,121 @@
import 'dart:math';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:material_ui/material_ui.dart';
/// A line over the distance (ADR 0030, D24, drawn by hand rather than with a
/// chart package): [points] scaled to the box, the lowest and the highest
/// value beside it as [label] writes them. [inverted] puts low values at the
/// top, as a faster pace is fewer seconds.
class const WorkoutChart({
required final String title,
required final List<ChartPoint> points,
required final String Function(double value) label,
final bool inverted = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final values = [for (final point in points) point.value];
final (low, high) = (values.reduce(min), values.reduce(max));
final caption = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
);
return Semantics(
label: '$title: ${label(low)} – ${label(high)}',
excludeSemantics: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 120,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: CustomPaint(
painter: _LinePainter(
points,
low: low,
high: high,
inverted: inverted,
line: theme.colorScheme.primary,
grid: theme.colorScheme.outlineVariant,
),
),
),
const SizedBox(width: 8),
Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(label(inverted ? low : high), style: caption),
Text(label(inverted ? high : low), style: caption),
],
),
],
),
),
],
),
);
}
}
class _LinePainter(
final List<ChartPoint> _points, {
required final double low,
required final double high,
required final bool inverted,
required final Color line,
required final Color grid,
}) extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final gridPaint = Paint()
..color = grid
..strokeWidth = 1;
canvas
..drawLine(Offset.zero, Offset(size.width, 0), gridPaint)
..drawLine(
Offset(0, size.height),
Offset(size.width, size.height),
gridPaint,
);
final first = _points.first.metres;
final span = max(_points.last.metres - first, 1);
// A flat line sits in the middle.
final range = high - low;
Offset at(ChartPoint point) {
final share = range == 0 ? 0.5 : (point.value - low) / range;
return Offset(
(point.metres - first) / span * size.width,
(inverted ? share : 1 - share) * size.height,
);
}
final path = Path()..moveTo(at(_points.first).dx, at(_points.first).dy);
for (final point in _points.skip(1)) {
final offset = at(point);
path.lineTo(offset.dx, offset.dy);
}
canvas.drawPath(
path,
Paint()
..color = line
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round,
);
}
@override
bool shouldRepaint(_LinePainter oldDelegate) =>
!identical(oldDelegate._points, _points) ||
oldDelegate.line != line ||
oldDelegate.grid != grid;
}

View file

@ -0,0 +1,515 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workout_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart' hide Split;
import 'package:url_launcher/url_launcher.dart';
/// Opens the page of the workout [id] over [context]'s.
Future<void> openWorkoutScreen(BuildContext context, String id) {
final train = PersonScope.of<TrainPerson>(context);
final workouts = context.read<WorkoutsCubit>();
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MultiBlocProvider(
providers: [
BlocProvider.value(value: workouts),
BlocProvider(
create: (context) =>
WorkoutCubit(train.workouts, train.maps, id)..open(),
),
],
child: const WorkoutScreen(),
),
),
);
}
/// A workout's page (ADR 0030, D24): its track on the instance's map, top,
/// where it has one; below its name, when it was, its totals, its notes, its
/// splits and its charts. Edit names it and writes its notes; Delete asks
/// first, goes back home and offers to undo it there.
class const WorkoutScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocBuilder<WorkoutCubit, WorkoutState>(
builder: (context, state) {
final content = state.content;
final analysis = state.analysis;
return Scaffold(
appBar: AppBar(
title: Text(content == null ? '' : workoutTitle(l10n, content)),
actions: [
if (content != null) ...[
IconButton(
tooltip: l10n.workoutEdit,
icon: const Icon(LucideIcons.pencil),
onPressed: () => _edit(context, content),
),
IconButton(
tooltip: l10n.workoutDelete,
icon: const Icon(LucideIcons.trash2),
onPressed: () => _delete(context),
),
],
],
),
body: switch ((state.phase, content, analysis)) {
(WorkoutPhase.loaded, final content?, final analysis?) => _Workout(
content: content,
analysis: analysis,
map: state.map,
),
(WorkoutPhase.gone, _, _) => Center(
child: Text(l10n.workoutNotFound),
),
_ => const Center(child: AppLoadingIndicator()),
},
);
},
);
}
Future<void> _edit(BuildContext context, WorkoutContent content) async {
final cubit = context.read<WorkoutCubit>();
final text = await showAdaptiveSheet<({String name, String notes})>(
context,
builder: (context) => _EditSheet(content: content),
);
if (text != null) {
await cubit.editText(name: text.name, notes: text.notes);
}
}
Future<void> _delete(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final workouts = context.read<WorkoutsCubit>();
final id = context.read<WorkoutCubit>().id;
final navigator = Navigator.of(context);
if (!await confirmDestructiveAction(
context,
title: l10n.workoutDeleteTitle,
message: l10n.workoutDeleteMessage,
confirmLabel: l10n.workoutDelete,
)) {
return;
}
workouts.delete(id);
navigator.pop();
// The app's messenger, which shows it on home once the page is gone.
if (context.mounted) {
showUndoSnackBar(
context,
message: l10n.workoutDeleted,
onUndo: () => workouts.undo(id),
);
}
}
}
/// The page under the app bar: the map fixed at the top, the rest scrolling
/// beneath it.
class const _Workout({
required final WorkoutContent content,
required final WorkoutAnalysis analysis,
required final WorkoutMap? map,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final started = content.startedAt.toLocal();
final hasTrack = analysis.track.isNotEmpty;
final details = ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
if (hasTrack) ...[_MapNote(map: map), const SizedBox(height: 16)],
Row(
children: [
Icon(activityIcon(content.activity)),
const SizedBox(width: 8),
Expanded(
child: Text(
'${activityName(l10n, content.activity)} · '
'${DateFormat.yMMMEd(locale).format(started)} · '
'${DateFormat.Hm(locale).format(started)}',
style: theme.textTheme.bodyLarge,
),
),
],
),
const SizedBox(height: 16),
_Totals(content: content, analysis: analysis),
if (content.notes case final notes?) ...[
const SizedBox(height: 16),
Text(notes, style: theme.textTheme.bodyLarge),
],
if (analysis.splits.isNotEmpty) ...[
const SizedBox(height: 24),
_Splits(activity: content.activity, splits: analysis.splits),
],
if (analysis.chart.length > 1) ...[
const SizedBox(height: 24),
if (showsPace(content.activity))
WorkoutChart(
title: l10n.workoutPaceChart,
points: analysis.chart,
inverted: true,
label: (value) => l10n.recordingPaceValue(
clockTime(Duration(seconds: value.round())),
),
)
else
WorkoutChart(
title: l10n.workoutSpeedChart,
points: analysis.chart,
label: (value) => l10n.recordingSpeedValue(speed(locale, value)),
),
],
if (analysis.elevation.length > 1) ...[
const SizedBox(height: 24),
WorkoutChart(
title: l10n.workoutElevationChart,
points: analysis.elevation,
label: (value) => l10n.workoutClimbValue(value.round()),
),
],
],
);
return SafeArea(
top: false,
child: Column(
children: [
if (map case MapFrom(:final source) when hasTrack)
SizedBox(
height: MediaQuery.sizeOf(context).height * 0.4,
child: _TrackMap(track: analysis.track, source: source),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: details,
),
),
),
],
),
);
}
}
/// The track on the source's tiles, a gap at each pause, its start and end
/// marked, the source credited; the camera fits the track.
class const _TrackMap({
required final List<List<Fix>> track,
required final MapSource source,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final train = PersonScope.of<TrainPerson>(context);
LatLng at(Fix fix) => LatLng(fix.latE7 / 1e7, fix.lonE7 / 1e7);
final pieces = [
for (final piece in track) [for (final fix in piece) at(fix)],
];
final all = [for (final piece in pieces) ...piece];
final (start, end) = (all.first, all.last);
return FlutterMap(
options: MapOptions(
initialCameraFit: all.length > 1
? CameraFit.coordinates(
coordinates: all,
padding: const EdgeInsets.all(32),
maxZoom: 17,
)
: null,
initialCenter: start,
initialZoom: 15,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
),
),
children: [
train.mapTiles(source),
PolylineLayer(
polylines: [
for (final piece in pieces)
Polyline(
points: piece,
// Cobalt in either theme: the map's colours are the
// source's, not the app's.
color: PaletteColor.cobalt.background,
strokeWidth: 4,
),
],
),
MarkerLayer(
markers: [
_marker(start, PaletteColor.forest.background),
_marker(end, PaletteColor.cerise.background),
],
),
// Folded into a button, as the credit is longer than a phone is
// wide.
RichAttributionWidget(
attributions: [TextSourceAttribution(source.attribution)],
),
],
);
}
static Marker _marker(LatLng point, Color color) => Marker(
point: point,
width: 16,
height: 16,
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
);
}
/// What the map's source sees, and the way to its own page about that; or
/// that the map can't be loaded.
class const _MapNote({required final WorkoutMap? map}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final style = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return switch (map) {
null => const SizedBox.shrink(),
MapUnavailable() => Text(l10n.workoutMapUnavailable, style: style),
MapFrom(:final source) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.workoutMapSource(source.name), style: style),
if (source.aboutUrl case final url?)
TextButton.icon(
style: TextButton.styleFrom(padding: EdgeInsets.zero),
icon: const Icon(LucideIcons.externalLink, size: 16),
label: Text(l10n.workoutMapSourceAbout(source.name)),
onPressed: () => launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
),
),
],
),
};
}
}
/// The workout's figures, each with its label beneath.
class const _Totals({
required final WorkoutContent content,
required final WorkoutAnalysis analysis,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final metres = content.distanceMetres;
final moving = content.movingTime;
final pace = switch ((metres, moving)) {
(final metres?, final moving?) => paceText(
l10n,
locale,
content.activity,
metres,
moving,
),
_ => null,
};
return Wrap(
spacing: 32,
runSpacing: 16,
children: [
if (metres != null)
_Figure(
value: distanceText(l10n, locale, metres),
label: l10n.workoutDistance,
),
if (moving != null)
_Figure(value: clockTime(moving), label: l10n.workoutMovingTime),
if (pace != null)
_Figure(
value: pace,
label: showsPace(content.activity)
? l10n.workoutPace
: l10n.workoutSpeed,
),
_Figure(
value: clockTime(content.endedAt.difference(content.startedAt)),
label: l10n.workoutTotalTime,
),
if (analysis.elevationGainMetres case final gain?)
_Figure(
value: l10n.workoutClimbValue(gain),
label: l10n.workoutClimb,
),
],
);
}
}
/// The splits: each one's name, moving time, and pace or speed.
class const _Splits({
required final Activity activity,
required final List<Split> splits,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final length = splitMetresOf(activity);
final figures = theme.textTheme.bodyLarge?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.workoutSplits, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final (i, split) in splits.indexed)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
length == 1000
? l10n.workoutSplitKm(i + 1)
: l10n.workoutSplitFiveKm(i * 5, i * 5 + 5),
style: theme.textTheme.bodyLarge,
),
),
Expanded(
child: Text(clockTime(split.movingTime), style: figures),
),
Expanded(
child: Text(
paceText(
l10n,
locale,
activity,
split.metres,
split.movingTime,
) ??
'–',
style: figures,
textAlign: TextAlign.end,
),
),
],
),
),
],
);
}
}
/// A figure with its label beneath.
class const _Figure({required final String value, required final String label})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: theme.textTheme.headlineSmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
/// Names the workout and writes its notes.
class const _EditSheet({required final WorkoutContent content})
extends StatefulWidget {
@override
State<_EditSheet> createState() => _EditSheetState();
}
class _EditSheetState() extends State<_EditSheet> {
late final _name = TextEditingController(text: widget.content.name);
late final _notes = TextEditingController(text: widget.content.notes);
@override
void dispose() {
_name.dispose();
_notes.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SheetScaffold(
title: l10n.workoutEditTitle,
body: Column(
children: [
TextField(
controller: _name,
decoration: InputDecoration(
labelText: l10n.workoutNameLabel,
// The name it has while it has none of its own.
hintText: workoutTitle(l10n, widget.content.withText()),
),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 16),
TextField(
controller: _notes,
decoration: InputDecoration(labelText: l10n.workoutNotesLabel),
textCapitalization: TextCapitalization.sentences,
minLines: 3,
maxLines: 8,
),
],
),
footer: FilledButton(
onPressed: () => Navigator.of(
context,
).pop((name: _name.text, notes: _notes.text)),
child: Text(l10n.workoutSave),
),
);
}
}

View file

@ -0,0 +1,54 @@
import 'package:flutter/widgets.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
/// [content]'s name: the person's, or the time of day it started and its
/// activity (ADR 0030, D24): morning from 5, afternoon from 12, evening from
/// 17, night from 22, on the device's clock.
String workoutTitle(AppLocalizations l10n, WorkoutContent content) {
if (content.name case final name?) {
return name;
}
final when = switch (content.startedAt.toLocal().hour) {
>= 5 && < 12 => 'morning',
>= 12 && < 17 => 'afternoon',
>= 17 && < 22 => 'evening',
_ => 'night',
};
return l10n.workoutName('${when}_${content.activity.name}');
}
/// [activity]'s icon.
IconData activityIcon(Activity activity) => switch (activity) {
Activity.running => LucideIcons.activity,
Activity.walking => LucideIcons.footprints,
Activity.hiking => LucideIcons.mountain,
Activity.cycling => LucideIcons.bike,
Activity.indoorRowing => LucideIcons.waves,
};
/// [metres] with their unit: `5.12 km`.
String distanceText(AppLocalizations l10n, String locale, int metres) =>
l10n.recordingDistanceValue(kilometres(locale, metres));
/// The pace (on foot) or the speed (cycling) over [metres] in [time], with
/// its unit; null where it went nowhere or took no time.
String? paceText(
AppLocalizations l10n,
String locale,
Activity activity,
int metres,
Duration time,
) {
if (metres <= 0 || time <= Duration.zero) {
return null;
}
final seconds = time.inMilliseconds / 1000;
return showsPace(activity)
? l10n.recordingPaceValue(
clockTime(Duration(seconds: (seconds * 1000 / metres).round())),
)
: l10n.recordingSpeedValue(speed(locale, metres / seconds * 3.6));
}

View file

@ -0,0 +1,153 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.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';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_screen.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The person's workouts as home lists them (ADR 0030, D24): newest first
/// under the month each started in, those this device can't open last;
/// [empty] while there are none.
class const WorkoutsList({required final Widget empty, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context).toString();
final month = DateFormat.yMMMM(locale);
return BlocBuilder<WorkoutsCubit, WorkoutsState>(
builder: (context, state) {
if (!state.loaded) {
return const Center(child: AppLoadingIndicator());
}
final workouts = state.visible;
if (workouts.isEmpty) {
return empty;
}
final rows = <Widget>[];
String? heading;
for (final workout in workouts) {
final content = workout.content;
final name = content == null
? null
: month.format(content.startedAt.toLocal());
// The locked ones, last, come under no month: when they were is
// sealed too.
if (name != null && name != heading) {
heading = name;
rows.add(_MonthHeading(name));
} else if (name == null && heading != null) {
heading = null;
rows.add(const Divider());
}
rows.add(
content == null
? const _LockedTile()
: _WorkoutTile(id: workout.id, content: content),
);
}
return ListView(
// Room for the button to record over the last row.
padding: const EdgeInsets.only(bottom: 88),
children: [
for (final row in rows)
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: row,
),
),
],
);
},
);
}
}
class const _MonthHeading(final String name) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
child: Text(
name,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
);
}
}
/// A workout: its icon, name, when it started, and its figures.
class const _WorkoutTile({
required final String id,
required final WorkoutContent content,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final started = content.startedAt.toLocal();
final when =
'${DateFormat.MMMEd(locale).format(started)} · '
'${DateFormat.Hm(locale).format(started)}';
final metres = content.distanceMetres;
final moving = content.movingTime;
final figures = [
if (metres != null) distanceText(l10n, locale, metres),
if (moving != null) clockTime(moving),
if ((metres, moving) case (final metres?, final moving?))
?paceText(l10n, locale, content.activity, metres, moving),
];
return ListTile(
leading: Icon(activityIcon(content.activity)),
title: Text(workoutTitle(l10n, content)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(when),
if (figures.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 16,
children: [
for (final figure in figures)
Text(
figure,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
),
],
),
onTap: () => openWorkoutScreen(context, id),
);
}
}
/// A workout this device can't open: shown as locked, never half.
class const _LockedTile() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return ListTile(
leading: const Icon(LucideIcons.lock),
title: Text(l10n.workoutLocked),
subtitle: Text(l10n.workoutLockedHint),
);
}
}

View file

@ -7,14 +7,15 @@ import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/record_sheet.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workouts_list.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A signed-in person's home: the way to record a workout where the device
/// records (ADR 0030, D21), or back to the recording under way; the list of
/// workouts comes with 5c. The way to the account, and a reset of the
/// A signed-in person's home: their workouts (ADR 0030, D24), the way to
/// record one where the device records (D21), or back to the recording under
/// way. The way to the account, and a reset of the
/// person's keys waiting above it (`KeysResetBanner`). A recording found cut
/// off as it opens is offered to be saved or discarded.
class const HomeScreen({super.key}) extends StatelessWidget {
@ -39,11 +40,13 @@ class const HomeScreen({super.key}) extends StatelessWidget {
children: [
const KeysResetBanner(),
Expanded(
child: Center(
child: EmptyListMessage(
icon: LucideIcons.listChecks,
title: l10n.homeEmptyTitle,
hint: records ? l10n.homeRecordHint : l10n.homeEmptyHint,
child: WorkoutsList(
empty: Center(
child: EmptyListMessage(
icon: LucideIcons.activity,
title: l10n.homeEmptyTitle,
hint: records ? l10n.homeRecordHint : l10n.homeEmptyHint,
),
),
),
),

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