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
28 changed files with 794 additions and 32 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ String activityName(AppLocalizations l10n, Activity activity) =>
|
|||
Activity.hiking => l10n.activityHiking,
|
||||
Activity.cycling => l10n.activityCycling,
|
||||
Activity.indoorRowing => l10n.activityIndoorRowing,
|
||||
Activity.other => l10n.activityOther,
|
||||
};
|
||||
|
||||
/// [time] as a clock shows it: `32:14`, `1:02:03` from an hour on.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue