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:
Tizian 「ティツィアーン」 2026-10-28 20:22:23 +01:00
commit 2c5467a282
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
28 changed files with 794 additions and 32 deletions

View file

@ -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.

View file

@ -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

View file

@ -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 }

View file

@ -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))

View file

@ -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)
}
}

View file

@ -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") })
}
}

View file

@ -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",

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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(

View file

@ -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;

View file

@ -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;
}

View file

@ -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,
];
}

View file

@ -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));

View file

@ -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),
},
}),
);

View file

@ -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,
),
),
);

View file

@ -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.

View file

@ -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