feat(train): a recording runs an interval plan, with cues and Lap (ADR 0030, D42)

The plan chosen on the Record sheet goes to the recording service spelled
out: each step's end, its target (zones as the heart rates they span for
the profile's highest rate) and its spoken line in the app's language.
PlanRunner.kt moves through the steps on the moving time and distance,
counts a timed step's last seconds down and cues off target from a 30 s
window; PlanCues.kt gives a tone, a vibration and the voice, ducking
music. The file's plan and step lines bring it back, and the recording
screen shows the step, what is left of it, its target, the next step and
Lap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 22:59:48 +01:00
commit d9f98449a8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
29 changed files with 1886 additions and 26 deletions

View file

@ -7,6 +7,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app whose home lists their workouts (`WorkoutsCubit`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
- **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). The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (`halkyon_train_flutter/LICENSE-polar-exception.md`), its notices are in the licence list (`registerPolarLicense`, on Android), and an F-Droid listing of Train declares `NonFreeDep`. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
- **The Polar Loop** (ADR 0030, D32 to D36) is Android's (`TrainPerson.loop` null elsewhere): `LoopBridge.kt` on Polar's BLE SDK 8.3.0 (from JitPack, a repository asked for Polar's group alone, its files pinned by SHA-256 in `android/gradle/verification-metadata.xml`; a new version needs its checksums there) over `app.halkyon.train/loop`, `…/loop/state` and `…/loop/search`, the first-time use's figures mapped and checked in `LoopSetup.kt` (tested on the JVM); `LoopPlatform` in Dart (`lib/src/feature/loop/`), `PolarLoop` per person: the band remembered in the person's `loop.json` (`LoopStore`, never synced) with what it was set up with and what it told, connected while the app is open, its clock set as each connection is ready, a changed profile offered on the band's screen and sent only when the person says (D35), *Let the band go* a factory reset after asking; its days and nights (D37, D38, `lib/src/feature/day/`) read as each connection is ready (every half hour at most, or when the person pulls *Days*) and by a WorkManager job every four hours while the app is closed (`LoopWorker.kt`, into the person's `loop_days.txt`, which the app takes in as it opens, `LoopLines`), `LoopDays.kt` turning the SDK's answers into lines of JSON (tested on the JVM), `DayRepository` sealing them as `day`, `night` and `day_series` rows (payloads in `day_payload.dart`), and what is sealed, up to yesterday, deleted on the band by the next read (`KnownLoop.keptUntil`), never today; its training sessions (D39) read with the days and kept as workouts of origin *Polar Loop* (`loop_workouts.dart`: `takeLoopSessions`, Polar's sport 1 and 2 as running and cycling, the rest as `Activity.other`, which the edit sheet changes; a double of a recorded workout gives it its heart rate), remembered in `loop.json` (`takenSessions`), written to Health Connect as Train's own; its firmware (D10, D40) asked for on the day's first connection and on the band's screen, from Polar directly (the SDK) or through the instance as the person chose (`KnownLoop.firmwareVia`, `FirmwareRelayGateway` on the server's `firmware` endpoint), updated only after asking, the band read and kept first and set up again after (`PolarLoop.updateFirmware`, `LoopFirmware.kt`); its live heart rate (D41) read by the recording service through the same SDK (`PolarSdk.kt`, one for the process, held by the bridge and by a recording; `LoopHeart.kt`) while no strap sends, the file's `loop` lines its state; home's *Days* tab and a day's page (`DaysCubit`, `DayCubit`); `loopFiguresOf` fills Polar's defaults for the band alone (`loop_figures.dart`), the setup sheet asks for what the profile lacks with `ProfileForm(only: …)`. **Interval plans** (D42, `lib/src/feature/plan/`): home's *Plans* tab (`PlansCubit`, deletions undone for a moment as workouts' are), a plan's editor (`PlanEditorCubit`, `PlanScreen`, a step on `showStepSheet`) and three templates (`plan_templates.dart`); `PlanRepository` seals each as a `plan` row (payload in `plan_payload.dart`, the step's JSON shared with the recorder). A recording runs the plan chosen on the Record sheet in its service: `recorderPlanOf` (`plan_speech.dart`) spells out its run with zones as heart rates and the spoken lines, `PlanRunner.kt` runs it (tested on the JVM), `PlanCues.kt` gives the tone, vibration and voice; the file's `plan` and `step` lines bring it back (`Recording.plan`, `planSteps`), `LivePlan` is the step on the recording screen, and *Lap* ends a step (`RecorderPlatform.lap`). The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (`halkyon_train_flutter/LICENSE-polar-exception.md`), its notices are in the licence list (`registerPolarLicense`, on Android), and an F-Droid listing of Train declares `NonFreeDep`. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
- **The icon** is Lucide's `bike` fading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py train` from the root): `assets/icon/halkyon_train_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts Lucide's notice in the licence list.
- **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Train's development server has ports of its own (API 8180, Insights 8181, web 8182, PostgreSQL 8190; the app's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time.

View file

@ -16,6 +16,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- A plan's cues (ADR 0030, D42). -->
<uses-permission android:name="android.permission.VIBRATE"/>
<!-- The heart-rate strap and the rower (ADR 0030, D26 to D28): nearby devices from Android
12 on, never used to locate; before 12, Bluetooth's own permissions,
and scanning needs location, which the app has for GPS anyway. -->
@ -112,6 +114,10 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<!-- A text-to-speech engine for a plan's spoken cues (D42). -->
<intent>
<action android:name="android.intent.action.TTS_SERVICE"/>
</intent>
<!-- Health Connect as an app, before Android 14. -->
<package android:name="com.google.android.apps.healthdata"/>
<!-- The apps writing workouts to Health Connect that Train names by

View file

@ -57,9 +57,11 @@ class MainActivity : FlutterActivity() {
strap = call.argument<String>("strap"),
rower = call.argument<String>("rower"),
loop = call.argument<String>("loop"),
plan = call.argument<String>("plan"),
)
result.success(null)
}
"lap" -> result.success(RecorderService.lap().let { null })
"pause" -> result.success(RecorderService.pause().let { null })
"resume" -> result.success(RecorderService.resume().let { null })
"stop" -> result.success(RecorderService.stop().let { null })

View file

@ -0,0 +1,98 @@
package app.halkyon.train
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.ToneGenerator
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import java.util.Locale
/**
* A plan's cues (ADR 0030, D42): a tone and a vibration as a step changes, a
* short tone for each of a timed step's last seconds, a double vibration off
* target, and a spoken line where the phone has a text-to-speech engine
* (GrapheneOS ships none; RHVoice or eSpeak give one), other sound ducked
* while it speaks.
*/
class PlanCues(context: Context, locale: String?) : TextToSpeech.OnInitListener {
private val audio = context.getSystemService(AudioManager::class.java)
private val tones = runCatching { ToneGenerator(AudioManager.STREAM_MUSIC, 90) }.getOrNull()
private val vibrator: Vibrator? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Vibrator::class.java)
}
private val language = locale?.let(Locale::forLanguageTag)
private val speech = TextToSpeech(context.applicationContext, this)
private var speaks = false
private val attributes =
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
private val focus =
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK).setAudioAttributes(attributes).build()
override fun onInit(status: Int) {
if (status != TextToSpeech.SUCCESS) return
val available = language?.let { speech.isLanguageAvailable(it) >= TextToSpeech.LANG_AVAILABLE } ?: true
if (language != null && available) speech.language = language
speech.setAudioAttributes(attributes)
speech.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {}
override fun onDone(utteranceId: String?) {
audio.abandonAudioFocusRequest(focus)
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
audio.abandonAudioFocusRequest(focus)
}
},
)
speaks = available
}
/** A step changed, or the plan is done; [say] spoken. */
fun change(say: String) {
tones?.startTone(ToneGenerator.TONE_PROP_BEEP2, 400)
vibrate(longArrayOf(0, 400))
speak(say)
}
fun countdown() {
tones?.startTone(ToneGenerator.TONE_PROP_BEEP, 150)
}
fun offTarget(say: String) {
vibrate(longArrayOf(0, 150, 120, 150))
speak(say)
}
fun close() {
speech.stop()
speech.shutdown()
tones?.release()
audio.abandonAudioFocusRequest(focus)
}
private fun speak(text: String) {
if (!speaks || text.isBlank()) return
audio.requestAudioFocus(focus)
speech.speak(text, TextToSpeech.QUEUE_FLUSH, null, "plan")
}
private fun vibrate(pattern: LongArray) {
vibrator?.takeIf { it.hasVibrator() }?.vibrate(VibrationEffect.createWaveform(pattern, -1))
}
}

View file

@ -0,0 +1,210 @@
package app.halkyon.train
import kotlin.math.asin
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/** A step as the recorder runs it (ADR 0030, D42): how it ends, its target, and what is said as it starts. */
data class RunStep(
val seconds: Int? = null,
val metres: Int? = null,
val target: RunTarget? = null,
val say: String = "",
)
/** A target's range, its kind one of `pace`, `speed`, `heartRate`, `split`, `watts`, `strokeRate` (zones come as heart rates). */
data class RunTarget(val kind: String, val low: Double, val high: Double) {
/** Whether a lower figure is the harder: a pace's, a split's. */
val lowerIsHarder: Boolean
get() = kind == "pace" || kind == "split"
}
/** What running a plan gives, for the recorder to write and cue. */
sealed interface PlanEvent {
data class Started(val index: Int) : PlanEvent
/** The last seconds of a timed step: 3, 2, 1. */
data class Countdown(val left: Int) : PlanEvent
/** Off target: [harder] where the person should push, else ease off. */
data class OffTarget(val harder: Boolean) : PlanEvent
data object Done : PlanEvent
}
/**
* Runs a plan's steps on a recording's moving time and distance (D42): a
* timed step ends once it has moved that long, a measured one once it went
* that far, any step with a lap tap; the last three seconds of a timed step
* are counted down; off target, a cue at most every [offTargetEveryMs],
* none in a step's first [graceMs], where [offTargetCues].
*/
class PlanRunner(
private val steps: List<RunStep>,
private val offTargetCues: Boolean,
private val graceMs: Long = 15_000,
private val offTargetEveryMs: Long = 30_000,
) {
var index = -1
private set
private var lastTick = 0L
private var wasPaused = false
private var stepMovingMs = 0L
private var stepFromMetres = 0.0
private var counted = mutableSetOf<Int>()
private var lastOffTarget: Long? = null
private var done = false
/** Starts the first step at [now], [metres] gone so far. */
fun start(now: Long, metres: Double): List<PlanEvent> {
lastTick = now
return next(metres)
}
/**
* Moves on to [now]: [paused] as the recording is now, [metres] gone in
* all, [measure] the figure a target's kind has now (null: none).
*/
fun tick(now: Long, paused: Boolean, metres: Double, measure: (String) -> Double?): List<PlanEvent> {
if (done || index < 0) return emptyList()
if (!wasPaused) stepMovingMs += now - lastTick
lastTick = now
wasPaused = paused
val step = steps[index]
val events = mutableListOf<PlanEvent>()
step.seconds?.let { seconds ->
val leftMs = seconds * 1000L - stepMovingMs
if (leftMs <= 0) return next(metres)
val left = ((leftMs + 999) / 1000).toInt()
if (left <= 3 && counted.add(left)) events += PlanEvent.Countdown(left)
}
step.metres?.let { if (metres - stepFromMetres >= it) return next(metres) }
val target = step.target
if (target != null && offTargetCues && !paused && stepMovingMs >= graceMs) {
val value = measure(target.kind)
val since = lastOffTarget
if (value != null && (since == null || now - since >= offTargetEveryMs)) {
val below = value < target.low
val above = value > target.high
if (below || above) {
lastOffTarget = now
events += PlanEvent.OffTarget(harder = if (target.lowerIsHarder) above else below)
}
}
}
return events
}
/** The person tapped Lap: the step ends, whatever it ends by. */
fun lap(metres: Double): List<PlanEvent> = if (done || index < 0) emptyList() else next(metres)
private fun next(metres: Double): List<PlanEvent> {
index++
stepMovingMs = 0
stepFromMetres = metres
counted = mutableSetOf()
lastOffTarget = null
if (index >= steps.size) {
done = true
return listOf(PlanEvent.Done)
}
return listOf(PlanEvent.Started(index))
}
}
/**
* What a plan's targets are measured by, as the recorder sees it: the
* distance through fixes precise enough and not paused (as the app counts
* it, D20), or the rower's metres; the pace and speed over the last 30 s;
* the last heart rate, stroke rate and watts, each fresh for 5 s.
*/
class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Long = 5_000) {
private data class At(val at: Long, val metres: Double)
private var lastLat: Double? = null
private var lastLon: Double? = null
private var paused = false
private var gpsMetres = 0.0
private var rowedMetres: Double? = null
private val recent = ArrayDeque<At>()
private var heart: Pair<Long, Int>? = null
private var rowing: Triple<Long, Double?, Int?>? = null
/** How far it went so far. */
val metres: Double
get() = rowedMetres ?: gpsMetres
fun fix(at: Long, lat: Double, lon: Double, accuracyCm: Long) {
if (paused || accuracyCm > MAX_ACCURACY_CM) return
val fromLat = lastLat
val fromLon = lastLon
if (fromLat != null && fromLon != null) gpsMetres += metresBetween(fromLat, fromLon, lat, lon)
lastLat = lat
lastLon = lon
remember(at)
}
fun pause(paused: Boolean) {
this.paused = paused
// A pause breaks the line, as the app's distance does.
lastLat = null
lastLon = null
recent.clear()
}
fun rowed(at: Long, metres: Int?, strokeRate: Double?, watts: Int?) {
metres?.let {
rowedMetres = it.toDouble()
remember(at)
}
rowing = Triple(at, strokeRate, watts)
}
fun heart(at: Long, bpm: Int) {
heart = at to bpm
}
/** The figure [kind] has at [now]; null where there is none. */
fun measure(kind: String, now: Long): Double? =
when (kind) {
"heartRate" -> heart?.takeIf { now - it.first <= freshMs }?.second?.toDouble()
"strokeRate" -> rowing?.takeIf { now - it.first <= freshMs }?.second
"watts" -> rowing?.takeIf { now - it.first <= freshMs }?.third?.toDouble()
else -> {
val first = recent.firstOrNull()
val last = recent.lastOrNull()
val seconds = if (first == null || last == null) 0.0 else (last.at - first.at) / 1000.0
val gone = if (first == null || last == null) 0.0 else last.metres - first.metres
if (seconds < 10 || gone < 10) {
null
} else {
when (kind) {
"pace" -> seconds * 1000 / gone
"speed" -> gone / seconds * 3.6
"split" -> seconds * 500 / gone
else -> null
}
}
}
}
private fun remember(at: Long) {
recent.addLast(At(at, metres))
while (recent.isNotEmpty() && at - recent.first().at > windowMs) recent.removeFirst()
}
companion object {
/** The app's limit for a fix counted (D20). */
const val MAX_ACCURACY_CM = 2000L
fun metresBetween(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val p1 = Math.toRadians(lat1)
val p2 = Math.toRadians(lat2)
val dp = p2 - p1
val dl = Math.toRadians(lon2 - lon1)
val h = sin(dp / 2) * sin(dp / 2) + cos(p1) * cos(p2) * sin(dl / 2) * sin(dl / 2)
return 2 * 6_371_008.8 * asin(sqrt(h))
}
}
}

View file

@ -60,6 +60,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private const val EXTRA_STRAP = "strap"
private const val EXTRA_ROWER = "rower"
private const val EXTRA_LOOP = "loop"
private const val EXTRA_PLAN = "plan"
private const val CHANNEL = "recording"
private const val NOTIFICATION_ID = 5301
private const val FLUSH_EVERY_MS = 5_000L
@ -88,6 +89,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
strap: String?,
rower: String?,
loop: String?,
plan: String?,
) {
ContextCompat.startForegroundService(
context,
@ -99,7 +101,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
.putExtra(EXTRA_NOTICE, notice)
.putExtra(EXTRA_STRAP, strap)
.putExtra(EXTRA_ROWER, rower)
.putExtra(EXTRA_LOOP, loop),
.putExtra(EXTRA_LOOP, loop)
.putExtra(EXTRA_PLAN, plan),
)
}
@ -108,6 +111,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
fun resume() = running?.byHand(paused = false)
/** The person tapped Lap: the plan's step ends (D42). */
fun lap() = running?.lapNow()
/** Ends the recording: its last line written, the file closed, the service gone. */
fun stop() = running?.finish()
@ -138,6 +144,13 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var strapSends = false
private val scope: CoroutineScope = MainScope()
/** The interval plan it runs (D42), what it is measured by, its cues and words. */
private var plan: PlanRunner? = null
private var planSteps: List<RunStep> = emptyList()
private var planWords: JSONObject? = null
private var cues: PlanCues? = null
private val measures = PlanMeasures()
/** The rower's strokes and metres, from its first frame on (D28). */
private val strokes = Counted()
private val metres = Counted()
@ -149,6 +162,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
override fun run() {
val now = System.currentTimeMillis()
autoPause?.tick(now)?.let { paused -> mark(paused, auto = true, at = now) }
plan?.let { runner ->
cue(runner.tick(now, isPaused(), measures.metres) { kind -> measures.measure(kind, now) }, now)
}
if (now - lastFlush >= FLUSH_EVERY_MS) {
writer?.flush()
lastFlush = now
@ -242,7 +258,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
write("strap ${System.currentTimeMillis()} $state")
},
onValue = { value ->
HeartRateMeasurement.bpmOf(value)?.let { bpm -> write("h ${System.currentTimeMillis()} $bpm") }
HeartRateMeasurement.bpmOf(value)?.let { bpm -> heart(System.currentTimeMillis(), bpm) }
},
).also { it.open() }
}
@ -259,10 +275,73 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
).also { it.open() }
}
loopAt?.let(::readLoop)
intent.getStringExtra(EXTRA_PLAN)?.let { runPlan(JSONObject(it), now) }
lastFlush = now
handler.postDelayed(ticker, 1_000)
}
/**
* Runs the plan [json] holds (D42): `plan`, its content as the app keeps
* it, written into the file; `steps`, each with its end, its target in
* bpm rather than zones and what is said; `say`, the words for off target
* and done; `locale`, the app's language.
*/
private fun runPlan(json: JSONObject, now: Long) {
json.optJSONObject("plan")?.let { write("plan $now $it") }
val steps = json.optJSONArray("steps") ?: return
planSteps =
(0 until steps.length()).map { i ->
val step = steps.getJSONObject(i)
RunStep(
seconds = step.optInt("seconds").takeIf { it > 0 },
metres = step.optInt("metres").takeIf { it > 0 },
target =
step.optJSONObject("target")?.let {
RunTarget(it.getString("kind"), it.getDouble("low"), it.getDouble("high"))
},
say = step.optString("say"),
)
}
planWords = json.optJSONObject("say")
cues = PlanCues(this, json.optString("locale").takeIf { it.isNotEmpty() })
plan = PlanRunner(planSteps, offTargetCues = json.optBoolean("offTargetCues", true)).also {
cue(it.start(now, measures.metres), now)
}
}
private fun lapNow() {
val runner = plan ?: return
cue(runner.lap(measures.metres), System.currentTimeMillis())
}
/** Writes what the plan did and gives its cues. */
private fun cue(events: List<PlanEvent>, at: Long) {
for (event in events) {
when (event) {
is PlanEvent.Started -> {
write("step $at ${event.index}")
cues?.change(planSteps[event.index].say)
}
is PlanEvent.Countdown -> cues?.countdown()
is PlanEvent.OffTarget ->
cues?.offTarget(planWords?.optString(if (event.harder) "harder" else "easier").orEmpty())
PlanEvent.Done -> {
write("step $at done")
cues?.change(planWords?.optString("done").orEmpty())
}
}
}
if (events.any { it is PlanEvent.Started || it == PlanEvent.Done }) writer?.flush()
}
private fun isPaused(): Boolean = pausedByHand || autoPause?.paused == true
/** A heart rate, written and measured. */
private fun heart(at: Long, bpm: Int) {
write("h $at $bpm")
measures.heart(at, bpm)
}
/**
* Reads the Loop [id]'s heart rate through Polar's SDK, held for the
* recording so that the band stays connected with the app gone (D41); a
@ -280,7 +359,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
LoopHeart(
stream = { api.startHrStreaming(id) },
onState = { state -> write("loop ${System.currentTimeMillis()} $state") },
onBpm = { bpm -> if (!strapSends) write("h ${System.currentTimeMillis()} $bpm") },
onBpm = { bpm -> if (!strapSends) heart(System.currentTimeMillis(), bpm) },
).run()
}
}
@ -318,7 +397,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
data.metres?.let { metres.add(it) }
fun field(value: Any?) = value?.toString() ?: "-"
write("r $at ${field(data.strokeRate)} ${field(strokes.total)} ${field(metres.total)} ${field(data.watts)}")
if (strap == null && loopId == null) data.bpm?.let { write("h $at $it") }
if (strap == null && loopId == null) data.bpm?.let { heart(at, it) }
measures.rowed(at, metres.total, data.strokeRate, data.watts)
}
/** A console's running total, counted from the first reading. */
@ -351,6 +431,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
"p $at ${(location.latitude * 1e7).roundToLong()} ${(location.longitude * 1e7).roundToLong()} " +
"$accuracy $altitude",
)
measures.fix(at, location.latitude, location.longitude, accuracy)
if (location.hasSpeed() && !pausedByHand) {
autoPause?.speed(at, location.speed.toDouble())?.let { paused -> mark(paused, auto = true, at = at) }
}
@ -378,6 +459,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
/** Writes a pause or its end, and shows it at once, the app there or not. */
private fun mark(paused: Boolean, auto: Boolean, at: Long) {
measures.pause(paused)
write("${if (paused) "pause" else "resume"} $at ${if (auto) "auto" else "manual"}")
writer?.flush()
repost()
@ -402,6 +484,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
rower = null
loopReading?.cancel()
loopReading = null
plan = null
cues?.close()
cues = null
loopId?.let { PolarSdk.reads(it, now = false) }
if (loopId != null) PolarSdk.release(this)
loopId = null

View file

@ -0,0 +1,114 @@
package app.halkyon.train
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Running an interval plan in the recorder (ADR 0030, D42). */
class PlanRunnerTest {
private val none: (String) -> Double? = { null }
@Test
fun `a timed step ends on moving time, its last seconds counted down, pauses left out`() {
val runner = PlanRunner(listOf(RunStep(seconds = 10), RunStep(seconds = 5)), offTargetCues = true)
val events = mutableListOf<PlanEvent>()
events += runner.start(0, 0.0)
for (s in 1..6) events += runner.tick(s * 1000L, paused = false, metres = 0.0, measure = none)
// Paused for 20 s: the step's clock stands.
events += runner.tick(7_000, paused = true, metres = 0.0, measure = none)
events += runner.tick(27_000, paused = false, metres = 0.0, measure = none)
for (s in 28..30) events += runner.tick(s * 1000L, paused = false, metres = 0.0, measure = none)
assertEquals(
listOf(
PlanEvent.Started(0),
PlanEvent.Countdown(3),
PlanEvent.Countdown(2),
PlanEvent.Countdown(1),
PlanEvent.Started(1),
),
events,
)
assertEquals(1, runner.index)
}
@Test
fun `a measured step ends once it went its distance, a lap tap ends any, and the last ends the plan`() {
val runner = PlanRunner(listOf(RunStep(metres = 400), RunStep(), RunStep(seconds = 60)), offTargetCues = true)
runner.start(0, 1_000.0)
assertEquals(emptyList<PlanEvent>(), runner.tick(1_000, false, 1_399.0, none))
assertEquals(listOf(PlanEvent.Started(1)), runner.tick(2_000, false, 1_400.0, none))
// Ends by lap alone.
assertEquals(emptyList<PlanEvent>(), runner.tick(90_000, false, 2_000.0, none))
assertEquals(listOf(PlanEvent.Started(2)), runner.lap(2_000.0))
assertEquals(listOf(PlanEvent.Done), runner.lap(2_000.0))
assertEquals(emptyList<PlanEvent>(), runner.lap(2_000.0))
}
@Test
fun `off target, a cue says which way, after the grace and at most every 30 s`() {
val pace = RunTarget("pace", 270.0, 290.0)
val runner = PlanRunner(listOf(RunStep(seconds = 600, target = pace)), offTargetCues = true)
runner.start(0, 0.0)
var value = 320.0 // slower than 4:50
val events = mutableListOf<PlanEvent>()
for (s in 1..50) {
if (s == 40) value = 250.0 // faster than 4:30
events += runner.tick(s * 1000L, false, 0.0) { value }
}
assertEquals(
listOf(PlanEvent.OffTarget(harder = true), PlanEvent.OffTarget(harder = false)),
events,
)
}
@Test
fun `a heart rate below its range asks for more, and none is cued where the plan says so`() {
val heart = RunTarget("heartRate", 150.0, 165.0)
val cued = PlanRunner(listOf(RunStep(seconds = 600, target = heart)), offTargetCues = true)
val quiet = PlanRunner(listOf(RunStep(seconds = 600, target = heart)), offTargetCues = false)
cued.start(0, 0.0)
quiet.start(0, 0.0)
val told = mutableListOf<PlanEvent>()
val untold = mutableListOf<PlanEvent>()
for (s in 1..20) {
told += cued.tick(s * 1000L, false, 0.0) { 140.0 }
untold += quiet.tick(s * 1000L, false, 0.0) { 140.0 }
}
assertEquals(listOf(PlanEvent.OffTarget(harder = true)), told)
assertEquals(emptyList<PlanEvent>(), untold)
}
@Test
fun `the distance counts fixes precise enough and not paused, the pace over the last 30 s`() {
val measures = PlanMeasures()
// About 11.1 m a step north, a fix a second.
for (s in 0..20) measures.fix(s * 1000L, 52.0 + s * 0.0001, 13.0, accuracyCm = 500)
measures.fix(21_000, 53.0, 13.0, accuracyCm = 5_000)
assertEquals(222.4, measures.metres, 0.5)
// 11.1 m/s: 90 s per km, 40 km/h.
assertEquals(89.9, measures.measure("pace", 20_000)!!, 0.5)
assertEquals(40.0, measures.measure("speed", 20_000)!!, 0.2)
measures.pause(true)
measures.fix(22_000, 52.1, 13.0, accuracyCm = 500)
measures.pause(false)
measures.fix(23_000, 52.2, 13.0, accuracyCm = 500)
assertEquals(222.4, measures.metres, 0.5)
assertNull(measures.measure("pace", 23_000))
}
@Test
fun `the rower's metres stand for the distance, its figures fresh for 5 s, the heart rate too`() {
val measures = PlanMeasures()
measures.rowed(0, metres = 0, strokeRate = 24.0, watts = 180)
measures.rowed(20_000, metres = 100, strokeRate = 26.0, watts = 190)
measures.heart(20_000, 150)
assertEquals(100.0, measures.metres, 0.0)
assertEquals(100.0, measures.measure("split", 20_000)!!, 0.1)
assertEquals(26.0, measures.measure("strokeRate", 24_000)!!, 0.0)
assertEquals(190.0, measures.measure("watts", 24_000)!!, 0.0)
assertEquals(150.0, measures.measure("heartRate", 25_000)!!, 0.0)
assertNull(measures.measure("watts", 26_000))
}
}

View file

@ -27,6 +27,13 @@
"recordAutoPause": "Von selbst pausieren",
"recordAutoPauseHint": "Wenn du stehst, und weiter, sobald du dich wieder bewegst",
"recordStart": "Start",
"recordPlan": "Plan",
"recordNoPlan": "Keiner",
"recordingLap": "Runde",
"recordingPlanDone": "Plan geschafft",
"recordingStepOf": "Schritt {index} von {count}",
"recordingNextStep": "Danach: {step}",
"recordingStepTapLap": "Bis du auf Runde tippst",
"recordLocationRefused": "Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.",
"recordingWaitingForGps": "Warte auf GPS",
"recordingWaitingForGpsAccuracy": "Warte auf GPS · ±{metres} m",
@ -273,6 +280,22 @@
"planDeleteMessage": "Er wird auf all deinen Geräten gelöscht; Trainings, die mit ihm aufgezeichnet wurden, behalten ihre Schritte.",
"planDeleted": "Plan gelöscht",
"planSteps": "{count, plural, =1{1 Schritt} other{{count} Schritte}}",
"planSayRound": "{round} von {rounds}",
"planSayMinutes": "{count, plural, =1{1 Minute} other{{count} Minuten}}",
"planSaySeconds": "{count, plural, =1{1 Sekunde} other{{count} Sekunden}}",
"planSayMetres": "{count, plural, =1{1 Meter} other{{count} Meter}}",
"planSayKilometres": "{km} Kilometer",
"planSayUntilLap": "bis du auf Runde tippst",
"planSayPace": "{from} bis {to} pro Kilometer",
"planSaySpeed": "{from} bis {to} Kilometer pro Stunde",
"planSayHeartRate": "{from} bis {to} Schläge pro Minute",
"planSayZone": "Zone {zone}",
"planSaySplit": "{from} bis {to} pro 500 Meter",
"planSayWatts": "{from} bis {to} Watt",
"planSayStrokeRate": "{from} bis {to} Ruderschläge pro Minute",
"planSayHarder": "Etwas mehr",
"planSayEasier": "Etwas ruhiger",
"planSayDone": "Plan geschafft",
"stepTitle": "Schritt",
"stepKind": "Art",
"stepWarmUp": "Aufwärmen",

View file

@ -30,6 +30,15 @@
"@recordAutoPause": {"description": "The switch for auto-pause, as the recording starts."},
"recordAutoPauseHint": "When you stand still, and going on as you move again",
"recordStart": "Start",
"recordPlan": "Plan",
"recordNoPlan": "None",
"recordingLap": "Lap",
"recordingPlanDone": "Plan done",
"recordingStepOf": "Step {index} of {count}",
"@recordingStepOf": {"placeholders": {"index": {"type": "int"}, "count": {"type": "int"}}},
"recordingNextStep": "Next: {step}",
"@recordingNextStep": {"placeholders": {"step": {"type": "String"}}},
"recordingStepTapLap": "Until you tap Lap",
"recordLocationRefused": "Train needs your location to record this. You can allow it in the system's settings.",
"recordingWaitingForGps": "Waiting for GPS",
"recordingWaitingForGpsAccuracy": "Waiting for GPS · ±{metres} m",
@ -334,6 +343,34 @@
"planDeleted": "Plan deleted",
"planSteps": "{count, plural, =1{1 step} other{{count} steps}}",
"@planSteps": {"placeholders": {"count": {"type": "int"}}},
"planSayRound": "{round} of {rounds}",
"@planSayRound": {"placeholders": {"round": {"type": "int"}, "rounds": {"type": "int"}}},
"planSayMinutes": "{count, plural, =1{1 minute} other{{count} minutes}}",
"@planSayMinutes": {"placeholders": {"count": {"type": "int"}}},
"planSaySeconds": "{count, plural, =1{1 second} other{{count} seconds}}",
"@planSaySeconds": {"placeholders": {"count": {"type": "int"}}},
"planSayMetres": "{count, plural, =1{1 metre} other{{count} metres}}",
"@planSayMetres": {"placeholders": {"count": {"type": "int"}}},
"planSayKilometres": "{km} kilometres",
"@planSayKilometres": {"placeholders": {"km": {"type": "String"}}},
"planSayUntilLap": "until you tap Lap",
"planSayPace": "{from} to {to} a kilometre",
"@planSayPace": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"planSaySpeed": "{from} to {to} kilometres an hour",
"@planSaySpeed": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"planSayHeartRate": "{from} to {to} beats a minute",
"@planSayHeartRate": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"planSayZone": "zone {zone}",
"@planSayZone": {"placeholders": {"zone": {"type": "int"}}},
"planSaySplit": "{from} to {to} per 500 metres",
"@planSaySplit": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"planSayWatts": "{from} to {to} watts",
"@planSayWatts": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"planSayStrokeRate": "{from} to {to} strokes a minute",
"@planSayStrokeRate": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
"planSayHarder": "Push a little",
"planSayEasier": "Ease off a little",
"planSayDone": "Plan done",
"stepTitle": "Step",
"stepKind": "Kind",
"stepWarmUp": "Warm-up",

View file

@ -226,6 +226,48 @@ abstract class AppLocalizations {
/// **'Start'**
String get recordStart;
/// No description provided for @recordPlan.
///
/// In en, this message translates to:
/// **'Plan'**
String get recordPlan;
/// No description provided for @recordNoPlan.
///
/// In en, this message translates to:
/// **'None'**
String get recordNoPlan;
/// No description provided for @recordingLap.
///
/// In en, this message translates to:
/// **'Lap'**
String get recordingLap;
/// No description provided for @recordingPlanDone.
///
/// In en, this message translates to:
/// **'Plan done'**
String get recordingPlanDone;
/// No description provided for @recordingStepOf.
///
/// In en, this message translates to:
/// **'Step {index} of {count}'**
String recordingStepOf(int index, int count);
/// No description provided for @recordingNextStep.
///
/// In en, this message translates to:
/// **'Next: {step}'**
String recordingNextStep(String step);
/// No description provided for @recordingStepTapLap.
///
/// In en, this message translates to:
/// **'Until you tap Lap'**
String get recordingStepTapLap;
/// No description provided for @recordLocationRefused.
///
/// In en, this message translates to:
@ -1660,6 +1702,102 @@ abstract class AppLocalizations {
/// **'{count, plural, =1{1 step} other{{count} steps}}'**
String planSteps(int count);
/// No description provided for @planSayRound.
///
/// In en, this message translates to:
/// **'{round} of {rounds}'**
String planSayRound(int round, int rounds);
/// No description provided for @planSayMinutes.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 minute} other{{count} minutes}}'**
String planSayMinutes(int count);
/// No description provided for @planSaySeconds.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 second} other{{count} seconds}}'**
String planSaySeconds(int count);
/// No description provided for @planSayMetres.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 metre} other{{count} metres}}'**
String planSayMetres(int count);
/// No description provided for @planSayKilometres.
///
/// In en, this message translates to:
/// **'{km} kilometres'**
String planSayKilometres(String km);
/// No description provided for @planSayUntilLap.
///
/// In en, this message translates to:
/// **'until you tap Lap'**
String get planSayUntilLap;
/// No description provided for @planSayPace.
///
/// In en, this message translates to:
/// **'{from} to {to} a kilometre'**
String planSayPace(String from, String to);
/// No description provided for @planSaySpeed.
///
/// In en, this message translates to:
/// **'{from} to {to} kilometres an hour'**
String planSaySpeed(String from, String to);
/// No description provided for @planSayHeartRate.
///
/// In en, this message translates to:
/// **'{from} to {to} beats a minute'**
String planSayHeartRate(String from, String to);
/// No description provided for @planSayZone.
///
/// In en, this message translates to:
/// **'zone {zone}'**
String planSayZone(int zone);
/// No description provided for @planSaySplit.
///
/// In en, this message translates to:
/// **'{from} to {to} per 500 metres'**
String planSaySplit(String from, String to);
/// No description provided for @planSayWatts.
///
/// In en, this message translates to:
/// **'{from} to {to} watts'**
String planSayWatts(String from, String to);
/// No description provided for @planSayStrokeRate.
///
/// In en, this message translates to:
/// **'{from} to {to} strokes a minute'**
String planSayStrokeRate(String from, String to);
/// No description provided for @planSayHarder.
///
/// In en, this message translates to:
/// **'Push a little'**
String get planSayHarder;
/// No description provided for @planSayEasier.
///
/// In en, this message translates to:
/// **'Ease off a little'**
String get planSayEasier;
/// No description provided for @planSayDone.
///
/// In en, this message translates to:
/// **'Plan done'**
String get planSayDone;
/// No description provided for @stepTitle.
///
/// In en, this message translates to:

View file

@ -76,6 +76,31 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recordStart => 'Start';
@override
String get recordPlan => 'Plan';
@override
String get recordNoPlan => 'Keiner';
@override
String get recordingLap => 'Runde';
@override
String get recordingPlanDone => 'Plan geschafft';
@override
String recordingStepOf(int index, int count) {
return 'Schritt $index von $count';
}
@override
String recordingNextStep(String step) {
return 'Danach: $step';
}
@override
String get recordingStepTapLap => 'Bis du auf Runde tippst';
@override
String get recordLocationRefused =>
'Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.';
@ -943,6 +968,96 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0';
}
@override
String planSayRound(int round, int rounds) {
return '$round von $rounds';
}
@override
String planSayMinutes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Minuten',
one: '1 Minute',
);
return '$_temp0';
}
@override
String planSaySeconds(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Sekunden',
one: '1 Sekunde',
);
return '$_temp0';
}
@override
String planSayMetres(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Meter',
one: '1 Meter',
);
return '$_temp0';
}
@override
String planSayKilometres(String km) {
return '$km Kilometer';
}
@override
String get planSayUntilLap => 'bis du auf Runde tippst';
@override
String planSayPace(String from, String to) {
return '$from bis $to pro Kilometer';
}
@override
String planSaySpeed(String from, String to) {
return '$from bis $to Kilometer pro Stunde';
}
@override
String planSayHeartRate(String from, String to) {
return '$from bis $to Schläge pro Minute';
}
@override
String planSayZone(int zone) {
return 'Zone $zone';
}
@override
String planSaySplit(String from, String to) {
return '$from bis $to pro 500 Meter';
}
@override
String planSayWatts(String from, String to) {
return '$from bis $to Watt';
}
@override
String planSayStrokeRate(String from, String to) {
return '$from bis $to Ruderschläge pro Minute';
}
@override
String get planSayHarder => 'Etwas mehr';
@override
String get planSayEasier => 'Etwas ruhiger';
@override
String get planSayDone => 'Plan geschafft';
@override
String get stepTitle => 'Schritt';

View file

@ -75,6 +75,31 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recordStart => 'Start';
@override
String get recordPlan => 'Plan';
@override
String get recordNoPlan => 'None';
@override
String get recordingLap => 'Lap';
@override
String get recordingPlanDone => 'Plan done';
@override
String recordingStepOf(int index, int count) {
return 'Step $index of $count';
}
@override
String recordingNextStep(String step) {
return 'Next: $step';
}
@override
String get recordingStepTapLap => 'Until you tap Lap';
@override
String get recordLocationRefused =>
'Train needs your location to record this. You can allow it in the system\'s settings.';
@ -936,6 +961,96 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0';
}
@override
String planSayRound(int round, int rounds) {
return '$round of $rounds';
}
@override
String planSayMinutes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes',
one: '1 minute',
);
return '$_temp0';
}
@override
String planSaySeconds(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count seconds',
one: '1 second',
);
return '$_temp0';
}
@override
String planSayMetres(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count metres',
one: '1 metre',
);
return '$_temp0';
}
@override
String planSayKilometres(String km) {
return '$km kilometres';
}
@override
String get planSayUntilLap => 'until you tap Lap';
@override
String planSayPace(String from, String to) {
return '$from to $to a kilometre';
}
@override
String planSaySpeed(String from, String to) {
return '$from to $to kilometres an hour';
}
@override
String planSayHeartRate(String from, String to) {
return '$from to $to beats a minute';
}
@override
String planSayZone(int zone) {
return 'zone $zone';
}
@override
String planSaySplit(String from, String to) {
return '$from to $to per 500 metres';
}
@override
String planSayWatts(String from, String to) {
return '$from to $to watts';
}
@override
String planSayStrokeRate(String from, String to) {
return '$from to $to strokes a minute';
}
@override
String get planSayHarder => 'Push a little';
@override
String get planSayEasier => 'Ease off a little';
@override
String get planSayDone => 'Plan done';
@override
String get stepTitle => 'Step';

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
@ -7,6 +8,7 @@ import 'package:halkyon_train_flutter/src/app/train_languages.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.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';
@ -132,6 +134,18 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
live,
),
loop: () => train.loop?.status.loop?.id,
// Spoken in the app's language, zones from the person's profile.
planOf: (plan) async {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final profile = await train.profile.read();
return recorderPlanOf(
l10n,
locale,
plan,
maxHeartRate: planMaxHeartRate(profile, clock.now()),
);
},
)..add(const RecordingEventOpened()),
child: child,
);

View file

@ -0,0 +1,120 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import 'package:intl/intl.dart';
/// The age a highest heart rate is guessed for where the profile gives no
/// birth date: 180 bpm by Polar's formula.
const _guessedAge = 40;
/// The highest heart rate a plan's zones are worked out from (D42): the
/// profile's, or Polar's guess at the person's age on [today].
int planMaxHeartRate(ProfileContent? profile, DateTime today) =>
profile?.maxHeartRate ??
defaultMaxHeartRate(profile?.ageOn(today) ?? _guessedAge);
/// [plan] as the recorder runs it (ADR 0030, D42), in the app's language
/// [locale] (a BCP 47 tag): `plan`, its payload, which the recording file
/// keeps; `steps`, the run spelled out, each with its end (`seconds` or
/// `metres`, neither for a lap tap), its target, zones as the heart rates
/// they span for [maxHeartRate], and what is said as it starts; `say`, the
/// words for off target and for the end.
Map<String, Object?> recorderPlanOf(
AppLocalizations l10n,
String locale,
PlanContent plan, {
required int maxHeartRate,
}) => {
'plan': jsonDecode(utf8.decode(Uint8List.sublistView(encodePlan(plan)))),
'offTargetCues': plan.offTargetCues,
'locale': locale,
'steps': [
for (final run in plan.run)
{
...switch (run.step.end) {
EndAfterTime(:final time) => {'seconds': time.inSeconds},
EndAfterDistance(:final metres) => {'metres': metres},
EndWithLap() => const <String, Object?>{},
},
if (run.step.target case final target?)
'target': switch (target.kind) {
TargetKind.heartZone => () {
final zone = heartZone(target.low.round(), maxHeartRate);
return {
'kind': TargetKind.heartRate.name,
'low': zone.low,
'high': zone.high,
};
}(),
_ => {
'kind': target.kind.name,
'low': target.low,
'high': target.high,
},
},
'say': spokenStep(l10n, locale, run),
},
],
'say': {
'harder': l10n.planSayHarder,
'easier': l10n.planSayEasier,
'done': l10n.planSayDone,
},
};
/// What is said as [run] starts: `Work, 2 of 4, 4 minutes, zone 4`.
String spokenStep(AppLocalizations l10n, String locale, RunStep run) => [
stepKindName(l10n, run.step.kind),
if (run.rounds > 1) l10n.planSayRound(run.round, run.rounds),
_spokenLength(l10n, locale, run.step.end),
if (run.step.target case final target?) _spokenTarget(l10n, target),
].join(', ');
String _spokenLength(AppLocalizations l10n, String locale, StepEnd end) =>
switch (end) {
EndAfterTime(:final time) => switch ((
time.inMinutes,
time.inSeconds % 60,
)) {
(0, final seconds) => l10n.planSaySeconds(seconds),
(final minutes, 0) => l10n.planSayMinutes(minutes),
(final minutes, final seconds) =>
'${l10n.planSayMinutes(minutes)} ${l10n.planSaySeconds(seconds)}',
},
EndAfterDistance(:final metres) =>
metres < 1000
? l10n.planSayMetres(metres)
: l10n.planSayKilometres(
NumberFormat.decimalPatternDigits(
locale: locale,
decimalDigits: metres % 1000 == 0 ? 0 : 1,
).format(metres / 1000),
),
EndWithLap() => l10n.planSayUntilLap,
};
String _spokenTarget(AppLocalizations l10n, Target target) {
String whole(double value) => value.round().toString();
final (low, high) = (target.low, target.high);
return switch (target.kind) {
TargetKind.pace => l10n.planSayPace(
minutesSeconds(low),
minutesSeconds(high),
),
TargetKind.speed => l10n.planSaySpeed(whole(low), whole(high)),
TargetKind.heartRate => l10n.planSayHeartRate(whole(low), whole(high)),
TargetKind.heartZone => l10n.planSayZone(low.round()),
TargetKind.split => l10n.planSaySplit(
minutesSeconds(low),
minutesSeconds(high),
),
TargetKind.watts => l10n.planSayWatts(whole(low), whole(high)),
TargetKind.strokeRate => l10n.planSayStrokeRate(whole(low), whole(high)),
};
}

View file

@ -5,6 +5,7 @@ import 'dart:io';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
@ -23,7 +24,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// It remembers the person's heart-rate strap, which a recording uses unless
/// it is switched off for it (D27), and their rower, which a row needs
/// (D28); their Loop, where they have one, gives the heart rate while no
/// strap does (D41).
/// strap does (D41). A recording may run an interval plan (D42), which the
/// recorder runs, its cues given there.
///
/// The recorder decides the pauses (D16); this works out what the screen and
/// the notification show from the same lines the file holds.
@ -46,12 +48,17 @@ class RecordingBloc(
/// The person's Loop as a recording starts; null where they have none.
final String? Function()? loop,
/// A plan as the recorder takes it (`recorderPlanOf`), in the app's
/// language and with the person's heart rates.
final Future<Map<String, Object?>> Function(PlanContent plan)? planOf,
}) extends Bloc<RecordingEvent, RecordingState> {
this : super(const RecordingState()) {
on<RecordingEventOpened>(_onOpened);
on<RecordingEventStartRequested>(_onStart);
on<RecordingEventPauseRequested>((event, emit) => _platform.pause());
on<RecordingEventResumeRequested>((event, emit) => _platform.resume());
on<RecordingEventLapRequested>((event, emit) => _platform.lap());
on<RecordingEventStopRequested>(_onStop);
on<RecordingEventSaveRequested>(_onSave);
on<RecordingEventDiscardRequested>(_onDiscard);
@ -174,6 +181,10 @@ class RecordingBloc(
emit(state.copyWith(bluetoothRefused: true));
return;
}
final plan = switch ((event.plan, planOf)) {
(final plan?, final planOf?) => await planOf(plan),
_ => null,
};
final now = clock.now().toUtc();
final notice = _notice(
liveOf(Recording(activity: activity, startedAt: now), now),
@ -186,6 +197,7 @@ class RecordingBloc(
strap: event.useStrap ? state.strap?.address : null,
rower: rows ? rower?.address : null,
loop: loop?.call(),
plan: plan,
);
_shown = _keyOf(notice);
_follow();
@ -443,11 +455,13 @@ sealed class const RecordingEvent();
final class const RecordingEventOpened() extends RecordingEvent;
/// The person starts recording [activity], pausing by itself where
/// [autoPause] (D16), with their strap unless not [useStrap] (D27).
/// [autoPause] (D16), with their strap unless not [useStrap] (D27), running
/// [plan] where one was chosen (D42).
final class const RecordingEventStartRequested(
final Activity activity, {
final bool autoPause = true,
final bool useStrap = true,
final PlanContent? plan,
}) extends RecordingEvent;
/// The person chose [device] as their [kind] on this device.
@ -466,6 +480,9 @@ final class const RecordingEventResumeRequested() extends RecordingEvent;
final class const RecordingEventStopRequested() extends RecordingEvent;
/// The person tapped Lap: the plan's step ends (D42).
final class const RecordingEventLapRequested() extends RecordingEvent;
/// The recording stopped or cut off is saved as a workout.
final class const RecordingEventSaveRequested() extends RecordingEvent;

View file

@ -37,6 +37,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
String? strap,
String? rower,
String? loop,
Map<String, Object?>? plan,
}) => _channel.invokeMethod<void>('start', {
'path': path,
'activity': activity.name,
@ -45,6 +46,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
'strap': strap,
'rower': rower,
'loop': loop,
'plan': plan == null ? null : jsonEncode(plan),
});
@override
@ -53,6 +55,9 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
@override
Future<void> resume() => _channel.invokeMethod<void>('resume');
@override
Future<void> lap() => _channel.invokeMethod<void>('lap');
@override
Future<void> stop() => _channel.invokeMethod<void>('stop');

View file

@ -1,3 +1,4 @@
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
@ -81,5 +82,64 @@ LiveRecording liveOf(Recording recording, DateTime now) {
split: paused ? null : _splitOf(recording, now),
strokeRate: rowingFresh && !paused ? lastRowing.strokeRate : null,
watts: rowingFresh && !paused ? lastRowing.watts : null,
plan: _planOf(recording, now),
);
}
/// Where [recording]'s plan stands at [now] (D42), from its last step mark:
/// the moving time and the distance since, as the recorder counts them; null
/// without a plan, or before its first step.
LivePlan? _planOf(Recording recording, DateTime now) {
final plan = recording.plan;
final mark = recording.planSteps.lastOrNull;
if (plan == null || mark == null) {
return null;
}
final run = plan.run;
final index = mark.index;
if (index == null || index >= run.length) {
return LivePlan(name: plan.name, index: run.length, count: run.length);
}
final step = run[index];
final before = _until(recording, mark.time);
return LivePlan(
name: plan.name,
step: step,
index: index,
count: run.length,
timeLeft: switch (step.step.end) {
EndAfterTime(:final time) => _atLeastZero(
time - (movingTimeOf(recording, now) - movingTimeOf(before, mark.time)),
),
_ => null,
},
metresLeft: switch (step.step.end) {
EndAfterDistance(:final metres) =>
(metres - ((distanceOf(recording) ?? 0) - (distanceOf(before) ?? 0)))
.clamp(0, metres),
_ => null,
},
next: index + 1 < run.length ? run[index + 1] : null,
);
}
Duration _atLeastZero(Duration time) => time.isNegative ? Duration.zero : time;
/// [recording] as it stood at [time]: its fixes, frames and pauses until
/// then.
Recording _until(Recording recording, DateTime time) => Recording(
activity: recording.activity,
startedAt: recording.startedAt,
fixes: [
for (final fix in recording.fixes)
if (!fix.time.isAfter(time)) fix,
],
pauses: [
for (final mark in recording.pauses)
if (!mark.time.isAfter(time)) mark,
],
rowing: [
for (final sample in recording.rowing)
if (!sample.time.isAfter(time)) sample,
],
);

View file

@ -102,12 +102,19 @@ abstract interface class RecorderPlatform() {
/// The Loop whose heart rate is kept while no strap sends (D41).
String? loop,
/// The interval plan it runs, as the recorder takes it (D42,
/// `recorderPlanOf`).
Map<String, Object?>? plan,
});
/// Pauses, or goes on, by hand.
Future<void> pause();
Future<void> resume();
/// The person tapped Lap: the plan's step ends (D42).
Future<void> lap();
/// Ends the recording: its file is finished and closed when this completes.
Future<void> stop();

View file

@ -1,5 +1,9 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
@ -23,6 +27,8 @@ const recordingFileMagic = 'halkyon-train-recording';
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
/// rower <time> connecting|connected|lost
/// loop <time> connecting|connected|lost
/// plan <time> <the plan's payload, JSON>
/// step <time> <its index in the plan's run>|done
/// end <time>
/// ```
///
@ -58,6 +64,8 @@ Recording? readRecording(String text) {
final loops = <LinkMark>[];
final rowing = <RowingSample>[];
final rowers = <LinkMark>[];
PlanContent? plan;
final planSteps = <StepMark>[];
DateTime? endedAt;
for (final line in lines.skip(1)) {
final fields = line.split(' ');
@ -65,7 +73,22 @@ Recording? readRecording(String text) {
if (time == null) {
continue;
}
if (fields.first == 'plan' && fields.length > 2) {
// Its JSON may hold spaces: all after the time.
plan = decodePlan(
ByteData.sublistView(
utf8.encode(line.substring('plan '.length + fields[1].length + 1)),
),
);
continue;
}
switch (fields) {
case ['step', _, 'done']:
planSteps.add(StepMark(time: time));
case ['step', _, final index]:
if (int.tryParse(index) case final index? when index >= 0) {
planSteps.add(StepMark(time: time, index: index));
}
case ['p', _, final lat, final lon, final accuracy, final altitude]:
final (latE7, lonE7, accuracyCm) = (
int.tryParse(lat),
@ -129,6 +152,8 @@ Recording? readRecording(String text) {
loops: loops,
rowing: rowing,
rowers: rowers,
plan: plan,
planSteps: planSteps,
endedAt: endedAt,
);
}

View file

@ -1,7 +1,40 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Where a recording's plan stands (D42): the step running, which of how
/// many, what is left of it, and the next; no step once the plan is done.
final class const LivePlan({
required final String name,
/// The step running, its index in the plan's run; null once done.
final RunStep? step,
final int index = 0,
required final int count,
/// What is left of a timed step, of a measured one; neither for a step
/// ending with a lap tap.
final Duration? timeLeft,
final int? metresLeft,
/// The step after this one; null for the last.
final RunStep? next,
}) with Equatable {
bool get done => step == null;
@override
List<Object?> get props => [
name,
step,
index,
count,
timeLeft,
metresLeft,
next,
];
}
/// What the recording screen shows of a recording under way (ADR 0030, D21).
final class const LiveRecording({
required final Activity activity,
@ -47,6 +80,9 @@ final class const LiveRecording({
final Duration? split,
final double? strokeRate,
final int? watts,
/// The plan it runs; null without one (D42).
final LivePlan? plan,
}) with Equatable {
@override
List<Object?> get props => [
@ -66,5 +102,6 @@ final class const LiveRecording({
split,
strokeRate,
watts,
plan,
];
}

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