feat(train): the Loop's training sessions as workouts (ADR 0030, D39)
Each read of the band lists the sessions it holds, reads those from the read's first day on (`LoopDays.kt`, one line per exercise: its start with the band's offset, length, Polar's sport ID, figures and heart rate at its interval), and deletes those up to the day kept, as the days. The app keeps each as a workout of origin Polar Loop with its heart rate (`takeLoopSessions`), Polar's running and cycling as Train's and every other sport as the new `Activity.other`, which a workout's edit sheet now changes; a session overlapping a workout Train recorded gives it its heart rate where it has none instead. The sessions taken are remembered in `loop.json`, so one deleted in Train isn't brought back. Health Connect writes the Loop's workouts as Train's own. The SDK gives sessions as its protobuf messages: protobuf-javalite 3.20.0, the SDK's own, is named for compiling only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
06cc39025b
commit
2c5467a282
29 changed files with 794 additions and 32 deletions
|
|
@ -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; home's *Days* tab and a day's page (`DaysCubit`, `DayCubit`); `loopFiguresOf` fills Polar's defaults for the band alone (`loop_figures.dart`), the setup sheet asks for what the profile lacks with `ProfileForm(only: …)`. The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (`halkyon_train_flutter/LICENSE-polar-exception.md`), its notices are in the licence list (`registerPolarLicense`, on Android), and an F-Droid listing of Train declares `NonFreeDep`. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
|
||||
- **The Polar Loop** (ADR 0030, D32 to D36) is Android's (`TrainPerson.loop` null elsewhere): `LoopBridge.kt` on Polar's BLE SDK 8.3.0 (from JitPack, a repository asked for Polar's group alone, its files pinned by SHA-256 in `android/gradle/verification-metadata.xml`; a new version needs its checksums there) over `app.halkyon.train/loop`, `…/loop/state` and `…/loop/search`, the first-time use's figures mapped and checked in `LoopSetup.kt` (tested on the JVM); `LoopPlatform` in Dart (`lib/src/feature/loop/`), `PolarLoop` per person: the band remembered in the person's `loop.json` (`LoopStore`, never synced) with what it was set up with and what it told, connected while the app is open, its clock set as each connection is ready, a changed profile offered on the band's screen and sent only when the person says (D35), *Let the band go* a factory reset after asking; its days and nights (D37, D38, `lib/src/feature/day/`) read as each connection is ready (every half hour at most, or when the person pulls *Days*) and by a WorkManager job every four hours while the app is closed (`LoopWorker.kt`, into the person's `loop_days.txt`, which the app takes in as it opens, `LoopLines`), `LoopDays.kt` turning the SDK's answers into lines of JSON (tested on the JVM), `DayRepository` sealing them as `day`, `night` and `day_series` rows (payloads in `day_payload.dart`), and what is sealed, up to yesterday, deleted on the band by the next read (`KnownLoop.keptUntil`), never today; its training sessions (D39) read with the days and kept as workouts of origin *Polar Loop* (`loop_workouts.dart`: `takeLoopSessions`, Polar's sport 1 and 2 as running and cycling, the rest as `Activity.other`, which the edit sheet changes; a double of a recorded workout gives it its heart rate), remembered in `loop.json` (`takenSessions`), written to Health Connect as Train's own; home's *Days* tab and a day's page (`DaysCubit`, `DayCubit`); `loopFiguresOf` fills Polar's defaults for the band alone (`loop_figures.dart`), the setup sheet asks for what the profile lacks with `ProfileForm(only: …)`. The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (`halkyon_train_flutter/LICENSE-polar-exception.md`), its notices are in the licence list (`registerPolarLicense`, on Android), and an F-Droid listing of Train declares `NonFreeDep`. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
|
||||
- **The 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.
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ dependencies {
|
|||
implementation("androidx.health.connect:connect-client:1.1.0")
|
||||
// The Polar Loop (ADR 0030, D32, D34): Polar's BLE SDK, under Polar's licence.
|
||||
implementation("com.github.polarofficial:polar-ble-sdk:8.3.0")
|
||||
// The protobuf runtime the SDK brings at run time, at its version, to read
|
||||
// the training sessions it gives as Polar's messages (ADR 0030, D39).
|
||||
compileOnly("com.google.protobuf:protobuf-javalite:3.20.0")
|
||||
testImplementation("com.google.protobuf:protobuf-javalite:3.20.0")
|
||||
// The Loop read every few hours while Train is closed (ADR 0030, D37).
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.1")
|
||||
// The recorder's pure Kotlin parts (auto-pause) tested on the JVM.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
|||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TRAINING_DATA,
|
||||
),
|
||||
).apply {
|
||||
holding = true
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import com.polar.sdk.api.model.activity.PolarActiveTime
|
|||
import com.polar.sdk.api.model.activity.PolarDailySummaryData
|
||||
import com.polar.sdk.api.model.sleep.PolarNightlyRechargeData
|
||||
import com.polar.sdk.api.model.sleep.PolarSleepData
|
||||
import com.polar.sdk.api.model.trainingsession.PolarExercise
|
||||
import com.polar.sdk.api.model.trainingsession.PolarTrainingSession
|
||||
import com.polar.sdk.api.model.trainingsession.PolarTrainingSessionReference
|
||||
import fi.polar.remote.representation.protobuf.Types
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
|
|
@ -28,6 +34,13 @@ interface LoopSource {
|
|||
suspend fun skinTemperatures(from: LocalDate, to: LocalDate): List<PolarSkinTemperatureData>
|
||||
|
||||
suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate)
|
||||
|
||||
/** Every training session the band holds (D39): few, as kept ones go. */
|
||||
suspend fun sessions(): List<PolarTrainingSessionReference>
|
||||
|
||||
suspend fun session(reference: PolarTrainingSessionReference): PolarTrainingSession
|
||||
|
||||
suspend fun deleteSession(reference: PolarTrainingSessionReference)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,8 +87,19 @@ object LoopDays {
|
|||
source.skinTemperatures(from, today).mapNotNullTo(lines, ::skinOf)
|
||||
}
|
||||
val until = keptUntil?.let { minOf(it, today.minusDays(1)) }
|
||||
// Every session the band holds is listed, those from [from] on read,
|
||||
// those up to [keptUntil] deleted below, as the days are (D39).
|
||||
val sessions = source.sessions()
|
||||
for (reference in sessions) {
|
||||
if (!reference.date.isBefore(from) && !reference.date.isAfter(today)) {
|
||||
lines += sessionsOf(source.session(reference))
|
||||
}
|
||||
}
|
||||
if (until != null) {
|
||||
for (type in deletedTypes) source.delete(type, until)
|
||||
for (reference in sessions) {
|
||||
if (!reference.date.isAfter(until)) source.deleteSession(reference)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
source.stopSync()
|
||||
|
|
@ -174,6 +198,57 @@ object LoopDays {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A training session, one line per exercise (a multisport session has
|
||||
* several): its band reference and index as its ID, its start with the
|
||||
* band's offset, its length, Polar's sport ID, its figures and its samples
|
||||
* at their interval (heart rate in bpm, 0 where none was read; speed in
|
||||
* km/h; cadence).
|
||||
*/
|
||||
fun sessionsOf(session: PolarTrainingSession): List<Map<String, Any?>> =
|
||||
session.exercises.mapNotNull { exercise -> exerciseOf(session, exercise) }
|
||||
|
||||
private fun exerciseOf(session: PolarTrainingSession, exercise: PolarExercise): Map<String, Any?>? {
|
||||
val base = exercise.exerciseSummary
|
||||
val summary = session.sessionSummary
|
||||
val start = (base?.takeIf { it.hasStart() }?.start ?: summary?.takeIf { it.hasStart() }?.start) ?: return null
|
||||
val samples = exercise.samples
|
||||
return mapOf(
|
||||
"kind" to "session",
|
||||
"date" to dateOf(session.reference.date),
|
||||
"id" to "${session.reference.path}#${exercise.index}",
|
||||
"start" to startOf(start),
|
||||
"seconds" to (base?.takeIf { it.hasDuration() }?.duration ?: summary?.duration)?.let(::secondsOf),
|
||||
"sport" to (base?.takeIf { it.hasSport() }?.sport ?: summary?.takeIf { it.hasSport() }?.sport)?.value,
|
||||
"calories" to base?.takeIf { it.hasCalories() }?.calories,
|
||||
"metres" to base?.takeIf { it.hasDistance() }?.distance,
|
||||
"ascent" to base?.takeIf { it.hasAscent() }?.ascent,
|
||||
"descent" to base?.takeIf { it.hasDescent() }?.descent,
|
||||
"interval" to samples?.takeIf { it.hasRecordingInterval() }?.recordingInterval?.let(::secondsOf),
|
||||
"heart" to samples?.heartRateSamplesList,
|
||||
"speed" to samples?.speedSamplesList,
|
||||
"cadence" to samples?.cadenceSamplesList,
|
||||
)
|
||||
}
|
||||
|
||||
/** The band's local time with its offset, as ISO 8601. */
|
||||
fun startOf(time: Types.PbLocalDateTime): String {
|
||||
val local =
|
||||
LocalDateTime.of(
|
||||
time.date.year,
|
||||
time.date.month,
|
||||
time.date.day,
|
||||
time.time.hour,
|
||||
time.time.minute,
|
||||
time.time.seconds,
|
||||
time.time.millis * 1_000_000,
|
||||
)
|
||||
return local.atOffset(ZoneOffset.ofTotalSeconds(time.timeZoneOffset * 60)).toString()
|
||||
}
|
||||
|
||||
private fun secondsOf(duration: Types.PbDuration): Double =
|
||||
duration.hours * 3600.0 + duration.minutes * 60 + duration.seconds + duration.millis / 1000.0
|
||||
|
||||
private fun secondsOf(time: PolarActiveTime?): Int? =
|
||||
time?.let { it.hours * 3600 + it.minutes * 60 + it.seconds }
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class LoopWorker(context: Context, parameters: WorkerParameters) : CoroutineWork
|
|||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TRAINING_DATA,
|
||||
),
|
||||
)
|
||||
api.setApiCallback(ReadyCallback(config.id, ready))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import com.polar.sdk.api.PolarBleApi
|
||||
import com.polar.sdk.api.model.trainingsession.PolarTrainingSessionReference
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.flow.toList
|
||||
|
||||
/** [LoopSource] on Polar's SDK, for the band [id] (ADR 0030, D37). */
|
||||
class PolarLoopSource(private val api: PolarBleApi, private val id: String) : LoopSource {
|
||||
|
|
@ -26,4 +28,12 @@ class PolarLoopSource(private val api: PolarBleApi, private val id: String) : Lo
|
|||
override suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate) {
|
||||
api.deleteStoredDeviceData(id, type, until)
|
||||
}
|
||||
|
||||
override suspend fun sessions() = api.getTrainingSessionReferences(id).toList()
|
||||
|
||||
override suspend fun session(reference: PolarTrainingSessionReference) = api.getTrainingSession(id, reference)
|
||||
|
||||
override suspend fun deleteSession(reference: PolarTrainingSessionReference) {
|
||||
api.deleteTrainingSession(id, reference)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ import com.polar.sdk.api.model.sleep.SleepRating
|
|||
import com.polar.sdk.api.model.sleep.SleepSkinTemperatureResult
|
||||
import com.polar.sdk.api.model.sleep.SleepWakePhase
|
||||
import com.polar.sdk.api.model.sleep.SleepWakeState
|
||||
import com.polar.sdk.api.model.trainingsession.PolarExercise
|
||||
import com.polar.sdk.api.model.trainingsession.PolarTrainingSession
|
||||
import com.polar.sdk.api.model.trainingsession.PolarTrainingSessionReference
|
||||
import fi.polar.remote.representation.protobuf.ExerciseSamples
|
||||
import fi.polar.remote.representation.protobuf.Structures
|
||||
import fi.polar.remote.representation.protobuf.Training
|
||||
import fi.polar.remote.representation.protobuf.TrainingSession
|
||||
import fi.polar.remote.representation.protobuf.Types
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
|
|
@ -132,7 +140,35 @@ class LoopDaysTest {
|
|||
),
|
||||
)
|
||||
|
||||
private inner class FakeBand : LoopSource {
|
||||
private fun duration(seconds: Int) = Types.PbDuration.newBuilder().setHours(seconds / 3600).setMinutes(seconds / 60 % 60).setSeconds(seconds % 60).buildPartial()
|
||||
|
||||
private fun reference(date: LocalDate, path: String) = PolarTrainingSessionReference(date, path, emptyList(), emptyList(), 0)
|
||||
|
||||
/** A session the band detected: 45 minutes from 17:30 at +01:00, heart rate every second. */
|
||||
private fun session(reference: PolarTrainingSessionReference): PolarTrainingSession {
|
||||
val start =
|
||||
Types.PbLocalDateTime.newBuilder()
|
||||
.setDate(Types.PbDate.newBuilder().setYear(reference.date.year).setMonth(reference.date.monthValue).setDay(reference.date.dayOfMonth))
|
||||
.setTime(Types.PbTime.newBuilder().setHour(17).setMinute(30).setSeconds(0).setMillis(0))
|
||||
.setTimeZoneOffset(60)
|
||||
.buildPartial()
|
||||
val base =
|
||||
Training.PbExerciseBase.newBuilder()
|
||||
.setStart(start)
|
||||
.setDuration(duration(45 * 60))
|
||||
.setSport(Structures.PbSportIdentifier.newBuilder().setValue(16))
|
||||
.setCalories(380)
|
||||
.buildPartial()
|
||||
val samples =
|
||||
ExerciseSamples.PbExerciseSamples.newBuilder()
|
||||
.setRecordingInterval(duration(1))
|
||||
.addAllHeartRateSamples(listOf(0, 120, 135))
|
||||
.buildPartial()
|
||||
val exercise = PolarExercise(0, "${reference.path}/00", emptyList(), base, null, null, samples, null, emptyMap())
|
||||
return PolarTrainingSession(reference, TrainingSession.PbTrainingSession.newBuilder().buildPartial(), listOf(exercise))
|
||||
}
|
||||
|
||||
private inner class FakeBand(val held: List<PolarTrainingSessionReference> = emptyList()) : LoopSource {
|
||||
val calls = mutableListOf<String>()
|
||||
|
||||
override suspend fun startSync() {
|
||||
|
|
@ -156,6 +192,15 @@ class LoopDaysTest {
|
|||
override suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate) {
|
||||
calls += "delete $type $until"
|
||||
}
|
||||
|
||||
override suspend fun sessions() = held
|
||||
|
||||
override suspend fun session(reference: PolarTrainingSessionReference): PolarTrainingSession =
|
||||
this@LoopDaysTest.session(reference).also { calls += "session ${reference.path}" }
|
||||
|
||||
override suspend fun deleteSession(reference: PolarTrainingSessionReference) {
|
||||
calls += "deleteSession ${reference.path}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -261,4 +306,32 @@ class LoopDaysTest {
|
|||
JsonText.of(mapOf("kind" to "read", "n" to listOf(1, 2.5, null, true), "s" to "a\"b")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session the band recorded is a line of its own, its start with the band's offset`() {
|
||||
val line = LoopDays.sessionsOf(session(reference(yesterday, "/U/0/20261027/E/173000"))).single()
|
||||
|
||||
assertEquals("session", line["kind"])
|
||||
assertEquals("2026-10-27", line["date"])
|
||||
assertEquals("/U/0/20261027/E/173000#0", line["id"])
|
||||
assertEquals("2026-10-27T17:30+01:00", line["start"])
|
||||
assertEquals(2700.0, line["seconds"])
|
||||
assertEquals(16L, line["sport"])
|
||||
assertEquals(380, line["calories"])
|
||||
assertEquals(1.0, line["interval"])
|
||||
assertEquals(listOf(0, 120, 135), line["heart"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sessions from the read's first day are read, those kept deleted, never today's`() {
|
||||
val kept = reference(yesterday.minusDays(1), "/kept")
|
||||
val new = reference(yesterday, "/new")
|
||||
val now = reference(today, "/today")
|
||||
val band = FakeBand(listOf(kept, new, now))
|
||||
|
||||
val lines = runBlocking { LoopDays.read(band, yesterday, today, keptUntil = yesterday) }
|
||||
|
||||
assertEquals(listOf("/new#0", "/today#0"), lines.filter { it["kind"] == "session" }.map { it["id"] })
|
||||
assertEquals(listOf("deleteSession /kept", "deleteSession /new"), band.calls.filter { it.startsWith("deleteSession") })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"activityHiking": "Wandern",
|
||||
"activityCycling": "Radfahren",
|
||||
"activityIndoorRowing": "Rudern drinnen",
|
||||
"activityOther": "Anderes Training",
|
||||
"recordAutoPause": "Von selbst pausieren",
|
||||
"recordAutoPauseHint": "Wenn du stehst, und weiter, sobald du dich wieder bewegst",
|
||||
"recordStart": "Start",
|
||||
|
|
@ -60,6 +61,7 @@
|
|||
"workoutEdit": "Bearbeiten",
|
||||
"workoutEditTitle": "Training bearbeiten",
|
||||
"workoutNameLabel": "Name",
|
||||
"workoutActivityLabel": "Aktivität",
|
||||
"workoutNotesLabel": "Notizen",
|
||||
"workoutSave": "Speichern",
|
||||
"workoutDelete": "Löschen",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"activityHiking": "Hiking",
|
||||
"activityCycling": "Cycling",
|
||||
"activityIndoorRowing": "Rowing indoors",
|
||||
"activityOther": "Other workout",
|
||||
"recordAutoPause": "Pause by itself",
|
||||
"@recordAutoPause": {"description": "The switch for auto-pause, as the recording starts."},
|
||||
"recordAutoPauseHint": "When you stand still, and going on as you move again",
|
||||
|
|
@ -75,6 +76,7 @@
|
|||
"workoutEdit": "Edit",
|
||||
"workoutEditTitle": "Edit workout",
|
||||
"workoutNameLabel": "Name",
|
||||
"workoutActivityLabel": "Activity",
|
||||
"workoutNotesLabel": "Notes",
|
||||
"workoutSave": "Save",
|
||||
"workoutDelete": "Delete",
|
||||
|
|
|
|||
|
|
@ -202,6 +202,12 @@ abstract class AppLocalizations {
|
|||
/// **'Rowing indoors'**
|
||||
String get activityIndoorRowing;
|
||||
|
||||
/// No description provided for @activityOther.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Other workout'**
|
||||
String get activityOther;
|
||||
|
||||
/// The switch for auto-pause, as the recording starts.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -418,6 +424,12 @@ abstract class AppLocalizations {
|
|||
/// **'Name'**
|
||||
String get workoutNameLabel;
|
||||
|
||||
/// No description provided for @workoutActivityLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Activity'**
|
||||
String get workoutActivityLabel;
|
||||
|
||||
/// No description provided for @workoutNotesLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get activityIndoorRowing => 'Rudern drinnen';
|
||||
|
||||
@override
|
||||
String get activityOther => 'Anderes Training';
|
||||
|
||||
@override
|
||||
String get recordAutoPause => 'Von selbst pausieren';
|
||||
|
||||
|
|
@ -219,6 +222,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get workoutNameLabel => 'Name';
|
||||
|
||||
@override
|
||||
String get workoutActivityLabel => 'Aktivität';
|
||||
|
||||
@override
|
||||
String get workoutNotesLabel => 'Notizen';
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get activityIndoorRowing => 'Rowing indoors';
|
||||
|
||||
@override
|
||||
String get activityOther => 'Other workout';
|
||||
|
||||
@override
|
||||
String get recordAutoPause => 'Pause by itself';
|
||||
|
||||
|
|
@ -217,6 +220,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get workoutNameLabel => 'Name';
|
||||
|
||||
@override
|
||||
String get workoutActivityLabel => 'Activity';
|
||||
|
||||
@override
|
||||
String get workoutNotesLabel => 'Notes';
|
||||
|
||||
|
|
|
|||
|
|
@ -346,6 +346,7 @@ Future<TrainPerson> _openPersonData(
|
|||
LoopStore(File(p.join(folder.path, 'loop.json'))),
|
||||
profile,
|
||||
days: days,
|
||||
workouts: workouts,
|
||||
backgroundLines: File(p.join(folder.path, 'loop_days.txt')),
|
||||
)..start());
|
||||
return TrainPerson(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/model/loop_session.dart';
|
||||
|
||||
/// What reads of the Loop brought (ADR 0030, D37): the lines `LoopDays.kt`
|
||||
/// makes, from the method channel or the background job's file, sorted by
|
||||
|
|
@ -17,15 +18,20 @@ final class LoopLines() {
|
|||
final recharges = <DateTime, Recharge>{};
|
||||
final skins = <DateTime, List<(Duration, double)>>{};
|
||||
|
||||
/// The training sessions the band recorded (D39), by their ID.
|
||||
final sessions = <String, LoopSession>{};
|
||||
|
||||
/// Whether nothing but reads came.
|
||||
bool get isEmpty =>
|
||||
summaries.isEmpty &&
|
||||
hearts.isEmpty &&
|
||||
nights.isEmpty &&
|
||||
recharges.isEmpty &&
|
||||
skins.isEmpty;
|
||||
skins.isEmpty &&
|
||||
sessions.isEmpty;
|
||||
|
||||
/// Every date something came for.
|
||||
/// Every date a day's row is written for: a night and a session have rows
|
||||
/// of their own.
|
||||
Set<DateTime> get dates => {
|
||||
...summaries.keys,
|
||||
...hearts.keys,
|
||||
|
|
@ -91,6 +97,10 @@ final class LoopLines() {
|
|||
baselineBreathInterval: _int(json['baselineBreathInterval']),
|
||||
baselineBreathIntervalSd: _int(json['baselineBreathIntervalSd']),
|
||||
);
|
||||
case 'session':
|
||||
if (_sessionOf(date, json) case final session?) {
|
||||
sessions[session.id] = session;
|
||||
}
|
||||
case 'skin':
|
||||
skins[date] = [
|
||||
for (final sample in _list(json['samples']))
|
||||
|
|
@ -187,6 +197,33 @@ NightContent _nightOf(DateTime date, Map<String, Object?> json) => NightContent(
|
|||
skinDeviation: _double(json['skinDeviation']),
|
||||
);
|
||||
|
||||
LoopSession? _sessionOf(DateTime date, Map<String, Object?> json) {
|
||||
final id = json['id'];
|
||||
final start = _time(json['start']);
|
||||
final seconds = json['seconds'];
|
||||
if (id is! String || start == null || seconds is! num || seconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
final interval = json['interval'];
|
||||
return LoopSession(
|
||||
id: id,
|
||||
date: date,
|
||||
start: start,
|
||||
duration: Duration(milliseconds: (seconds * 1000).round()),
|
||||
sport: _int(json['sport']),
|
||||
calories: _int(json['calories']),
|
||||
metres: _double(json['metres']),
|
||||
ascent: _double(json['ascent']),
|
||||
interval: interval is num && interval > 0
|
||||
? Duration(milliseconds: (interval * 1000).round())
|
||||
: null,
|
||||
heart: [
|
||||
for (final bpm in _list(json['heart']))
|
||||
if (bpm is num && bpm > 0) bpm.round() else null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
DateTime? _time(Object? value) =>
|
||||
value is String ? DateTime.tryParse(value)?.toUtc() : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import 'package:halkyon_train_flutter/src/feature/day/model/loop_session.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_import.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// Train's activity for Polar's sport [id]: only those the SDK itself names
|
||||
/// (running 1, cycling 2); every other, its default *Other outdoor* (16)
|
||||
/// among them, is [Activity.other] until a band shows its IDs.
|
||||
Activity activityOfSport(int? id) => switch (id) {
|
||||
1 => Activity.running,
|
||||
2 => Activity.cycling,
|
||||
_ => Activity.other,
|
||||
};
|
||||
|
||||
/// [session] as a workout of Train's (ADR 0030, D39): its sport, its time,
|
||||
/// its distance as the band summed it, and its heart rate as its series.
|
||||
(WorkoutContent, List<SeriesChunk>) loopWorkout(LoopSession session) {
|
||||
final content = WorkoutContent(
|
||||
activity: activityOfSport(session.sport),
|
||||
startedAt: session.start,
|
||||
endedAt: session.end,
|
||||
distanceMetres: switch (session.metres) {
|
||||
final metres? when metres > 0 => metres.round(),
|
||||
_ => null,
|
||||
},
|
||||
movingTime: session.duration,
|
||||
origin: WorkoutOrigin(
|
||||
app: loopApp,
|
||||
appName: 'Polar Loop',
|
||||
sessionId: session.id,
|
||||
),
|
||||
);
|
||||
return (content, heartRateSeries(content, loopHeartRates(session)));
|
||||
}
|
||||
|
||||
/// [session]'s heart rate, each sample at its time.
|
||||
List<HeartRate> loopHeartRates(LoopSession session) {
|
||||
final interval = session.interval;
|
||||
if (interval == null) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final (i, bpm) in session.heart.indexed)
|
||||
if (bpm != null)
|
||||
HeartRate(time: session.start.add(interval * i), bpm: bpm),
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether [session] is a double of [workout], whatever the sports (D39):
|
||||
/// overlapping for at least half the shorter of the two.
|
||||
bool isLoopDouble(LoopSession session, WorkoutContent workout) {
|
||||
final from = session.start.isAfter(workout.startedAt)
|
||||
? session.start
|
||||
: workout.startedAt;
|
||||
final to = session.end.isBefore(workout.endedAt)
|
||||
? session.end
|
||||
: workout.endedAt;
|
||||
final overlap = to.difference(from);
|
||||
final shorter = [
|
||||
session.duration,
|
||||
workout.endedAt.difference(workout.startedAt),
|
||||
].reduce((a, b) => a < b ? a : b);
|
||||
return overlap > Duration.zero && overlap * 2 >= shorter;
|
||||
}
|
||||
|
||||
/// Keeps [sessions] as workouts (D39), but those [taken] before or kept as
|
||||
/// a workout already: a double of a workout Train recorded gives it its
|
||||
/// heart rate where it has none, and is no workout of its own. Returns the
|
||||
/// IDs to remember as taken from now on, those found kept already among
|
||||
/// them; throws `KeysMissing` where this device can't seal.
|
||||
Future<Set<String>> takeLoopSessions(
|
||||
WorkoutRepository workouts,
|
||||
Iterable<LoopSession> sessions, {
|
||||
required Set<String> taken,
|
||||
}) async {
|
||||
final now = <String>{};
|
||||
final fresh = [
|
||||
for (final session in sessions)
|
||||
if (!taken.contains(session.id)) session,
|
||||
];
|
||||
if (fresh.isEmpty) {
|
||||
return now;
|
||||
}
|
||||
final all = await workouts.all();
|
||||
final kept = {
|
||||
for (final workout in all)
|
||||
if (workout.content?.origin case WorkoutOrigin(
|
||||
app: loopApp,
|
||||
:final sessionId,
|
||||
))
|
||||
sessionId,
|
||||
};
|
||||
for (final session in fresh) {
|
||||
now.add(session.id);
|
||||
if (kept.contains(session.id)) {
|
||||
continue;
|
||||
}
|
||||
final double = all
|
||||
.where(
|
||||
(workout) => switch (workout.content) {
|
||||
final content? =>
|
||||
content.origin == null && isLoopDouble(session, content),
|
||||
null => false,
|
||||
},
|
||||
)
|
||||
.firstOrNull;
|
||||
if (double != null) {
|
||||
final series = await workouts.series(double.id);
|
||||
if (!series.any((chunk) => chunk.kind == SeriesKind.heartRate)) {
|
||||
await workouts.addSeries(
|
||||
double.id,
|
||||
heartRateSeries(double.content!, loopHeartRates(session)),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final (content, series) = loopWorkout(session);
|
||||
await workouts.save(content, series);
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A training session the Loop recorded by itself (ADR 0030, D39), one of
|
||||
/// its exercises, as `LoopDays.kt` read it; what the band gave nothing for
|
||||
/// is null.
|
||||
final class const LoopSession({
|
||||
/// The band's reference of the session and the exercise's index.
|
||||
required final String id,
|
||||
|
||||
/// The band's date it started on, as UTC midnight.
|
||||
required final DateTime date,
|
||||
required final DateTime start,
|
||||
required final Duration duration,
|
||||
|
||||
/// Polar's sport ID.
|
||||
final int? sport,
|
||||
final int? calories,
|
||||
final double? metres,
|
||||
final double? ascent,
|
||||
|
||||
/// How far apart its samples are; null where it has none.
|
||||
final Duration? interval,
|
||||
|
||||
/// Its heart rate at [interval], null where the band read none.
|
||||
final List<int?> heart = const [],
|
||||
}) with Equatable {
|
||||
DateTime get end => start.add(duration);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
date,
|
||||
start,
|
||||
duration,
|
||||
sport,
|
||||
calories,
|
||||
metres,
|
||||
ascent,
|
||||
interval,
|
||||
heart,
|
||||
];
|
||||
}
|
||||
|
|
@ -191,13 +191,16 @@ class HealthConnect(
|
|||
}
|
||||
}
|
||||
|
||||
/// Writes each workout of Train's not written as it is now, and deletes
|
||||
/// there those deleted here; one that doesn't open on this device is left
|
||||
/// as it is, one brought in from there is never written back.
|
||||
/// Writes each workout of Train's not written as it is now, the Loop's
|
||||
/// among them (D39), and deletes there those deleted here; one that
|
||||
/// doesn't open on this device is left as it is, one brought in from there
|
||||
/// is never written back.
|
||||
Future<void> _write(List<StoredWorkout> workouts) async {
|
||||
for (final workout in workouts) {
|
||||
final content = workout.content;
|
||||
if (content == null || content.origin != null || !_settings.writes) {
|
||||
if (content == null ||
|
||||
(content.origin?.fromHealthConnect ?? false) ||
|
||||
!_settings.writes) {
|
||||
continue;
|
||||
}
|
||||
final fingerprint = _fingerprintOf(encodeWorkout(content));
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ final class const KnownLoop({
|
|||
|
||||
/// When its days were last read with the app open.
|
||||
final DateTime? lastRead,
|
||||
|
||||
/// The band's training sessions taken in (D39), by ID, each with its
|
||||
/// date, so that one isn't brought again, deleted in Train or not.
|
||||
final Map<String, DateTime> takenSessions = const {},
|
||||
}) with Equatable {
|
||||
KnownLoop copyWith({
|
||||
String? name,
|
||||
|
|
@ -39,6 +43,7 @@ final class const KnownLoop({
|
|||
String? firmware,
|
||||
DateTime? keptUntil,
|
||||
DateTime? lastRead,
|
||||
Map<String, DateTime>? takenSessions,
|
||||
}) => KnownLoop(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
|
|
@ -49,6 +54,7 @@ final class const KnownLoop({
|
|||
firmware: firmware ?? this.firmware,
|
||||
keptUntil: keptUntil ?? this.keptUntil,
|
||||
lastRead: lastRead ?? this.lastRead,
|
||||
takenSessions: takenSessions ?? this.takenSessions,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -62,6 +68,7 @@ final class const KnownLoop({
|
|||
firmware,
|
||||
keptUntil,
|
||||
lastRead,
|
||||
takenSessions,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +100,11 @@ class LoopStore(final File _file) {
|
|||
final String read => DateTime.tryParse(read),
|
||||
_ => null,
|
||||
},
|
||||
takenSessions: {
|
||||
if (json['takenSessions'] case final Map<String, Object?> taken)
|
||||
for (final MapEntry(:key, :value) in taken.entries)
|
||||
key: ?dateOfKey(value),
|
||||
},
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
|
@ -116,6 +128,11 @@ class LoopStore(final File _file) {
|
|||
null => null,
|
||||
},
|
||||
'lastRead': ?loop.lastRead?.toUtc().toIso8601String(),
|
||||
if (loop.takenSessions.isNotEmpty)
|
||||
'takenSessions': {
|
||||
for (final MapEntry(:key, :value) in loop.takenSessions.entries)
|
||||
key: dayKey(value),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import 'package:equatable/equatable.dart';
|
|||
import 'package:halkyon_core/halkyon_core.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/loop_workouts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// How long a band chosen has to connect and be ready for its setup.
|
||||
|
|
@ -23,6 +25,10 @@ const _readEvery = Duration(minutes: 30);
|
|||
/// How far back the first read of a band goes.
|
||||
const _firstReadDays = 28;
|
||||
|
||||
/// How long a session taken in is remembered after its day was kept, when
|
||||
/// the band has deleted it long since (D39).
|
||||
const _takenKept = Duration(days: 7);
|
||||
|
||||
/// Where the person's Loop stands.
|
||||
final class const LoopStatus({
|
||||
/// The band remembered; null without one.
|
||||
|
|
@ -53,6 +59,9 @@ class PolarLoop(
|
|||
final ProfileRepository _profile, {
|
||||
required final DayRepository days,
|
||||
|
||||
/// Where the band's training sessions go (D39).
|
||||
required final WorkoutRepository workouts,
|
||||
|
||||
/// `loop_days.txt` in the person's folder, where the background job
|
||||
/// writes what it reads.
|
||||
required final File backgroundLines,
|
||||
|
|
@ -152,6 +161,7 @@ class PolarLoop(
|
|||
firmware: same ? before?.firmware : null,
|
||||
keptUntil: same ? before?.keptUntil : null,
|
||||
lastRead: same ? before?.lastRead : null,
|
||||
takenSessions: same ? before!.takenSessions : const {},
|
||||
),
|
||||
connected: true,
|
||||
),
|
||||
|
|
@ -312,7 +322,8 @@ class PolarLoop(
|
|||
keptUntil: known.keptUntil,
|
||||
)).forEach(lines.add);
|
||||
await days.take(lines);
|
||||
await _kept(lines, readAt: now().toUtc());
|
||||
final taken = await _takeSessions(lines);
|
||||
await _kept(lines, taken: taken, readAt: now().toUtc());
|
||||
}
|
||||
|
||||
/// Keeps what the background job wrote: the file moved aside first, so
|
||||
|
|
@ -340,29 +351,52 @@ class PolarLoop(
|
|||
lines.addText(file.readAsStringSync());
|
||||
}
|
||||
await days.take(lines);
|
||||
final taken = await _takeSessions(lines);
|
||||
for (final file in taking) {
|
||||
file.deleteSync();
|
||||
}
|
||||
await _kept(lines);
|
||||
await _kept(lines, taken: taken);
|
||||
}
|
||||
|
||||
/// Keeps the band's sessions [lines] brought as workouts (D39); the IDs
|
||||
/// to remember as taken.
|
||||
Future<Set<String>> _takeSessions(LoopLines lines) => takeLoopSessions(
|
||||
workouts,
|
||||
lines.sessions.values,
|
||||
taken: {...?_status.loop?.takenSessions.keys},
|
||||
);
|
||||
|
||||
/// Remembers that the days [lines] saw whole are kept, so that the band
|
||||
/// may delete them (D38), and tells the background job.
|
||||
Future<void> _kept(LoopLines lines, {DateTime? readAt}) async {
|
||||
/// may delete them (D38), and the sessions [taken] (D39); tells the
|
||||
/// background job.
|
||||
Future<void> _kept(
|
||||
LoopLines lines, {
|
||||
Set<String> taken = const {},
|
||||
DateTime? readAt,
|
||||
}) async {
|
||||
final known = _status.loop;
|
||||
if (known == null) {
|
||||
return;
|
||||
}
|
||||
final before = known.keptUntil;
|
||||
final complete = lines.completeUntil;
|
||||
final keptUntil =
|
||||
before != null && complete != null && before.isAfter(complete)
|
||||
? before
|
||||
: complete ?? before;
|
||||
final forgetBefore = keptUntil?.subtract(_takenKept);
|
||||
final takenSessions = {
|
||||
for (final MapEntry(:key, :value) in known.takenSessions.entries)
|
||||
if (forgetBefore == null || !value.isBefore(forgetBefore)) key: value,
|
||||
for (final id in taken)
|
||||
if (lines.sessions[id] case final session?) id: session.date,
|
||||
};
|
||||
_update(
|
||||
_with(
|
||||
known.copyWith(
|
||||
keptUntil:
|
||||
before != null && complete != null && before.isAfter(complete)
|
||||
? before
|
||||
: complete,
|
||||
keptUntil: keptUntil,
|
||||
lastRead: readAt,
|
||||
takenSessions: takenSessions,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ class RecordingBloc(
|
|||
Activity.walking ||
|
||||
Activity.hiking => AutoPauseBy.steps,
|
||||
Activity.indoorRowing => AutoPauseBy.strokes,
|
||||
// Not recorded on the phone (D39): only the Loop brings it.
|
||||
Activity.other => null,
|
||||
};
|
||||
final granted = await _platform.ask({
|
||||
// The rower's connection, which also gives Android its service's type.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue