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
4 changed files with 794 additions and 32 deletions

View file

@ -517,6 +517,7 @@ void main() {
LoopStore(File('${folder.path}/loop.json')),
profile,
days: days,
workouts: WorkoutRepository(MemoryWorkoutGateway(), keys),
backgroundLines: File('${folder.path}/loop_days.txt'),
);
await start(
@ -1736,6 +1737,26 @@ void main() {
expect(saved.notes, 'Legs heavy.');
});
testWidgets("a workout's activity is changed on its page, as the Loop's "
'come as Other (D39)', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await startWith(tester);
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Edit'));
await tester.pumpAndSettle();
await tester.tap(find.text('Running').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Walking').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Morning walk'), findsWidgets);
expect((await workouts.all()).single.content?.activity, Activity.walking);
});
testWidgets('deleting asks first, leaves the list at once and can be '
'undone for a moment', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));

View file

@ -0,0 +1,169 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.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/day/model/loop_session.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';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_workout_gateway.dart';
/// The Loop's training sessions as workouts (ADR 0030, D39).
void main() {
late TrainKeys keys;
late WorkoutRepository workouts;
final start = DateTime.utc(2026, 10, 27, 16, 30);
LoopSession session({
String id = '/U/0/20261027/E/173000#0',
int? sport = 16,
DateTime? at,
Duration duration = const Duration(minutes: 45),
}) => LoopSession(
id: id,
date: DateTime.utc(2026, 10, 27),
start: at ?? start,
duration: duration,
sport: sport,
calories: 380,
metres: 6012.4,
interval: const Duration(seconds: 1),
heart: const [null, 120, 135],
);
setUp(() {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
});
tearDown(() async {
await workouts.dispose();
keys.dispose();
});
test('a session line keeps its ID, start, length, sport and heart rate, a '
'sample of 0 as none', () {
final lines = LoopLines()
..add({
'kind': 'session',
'date': '2026-10-27',
'id': '/U/0/20261027/E/173000#0',
'start': '2026-10-27T17:30+01:00',
'seconds': 2700.0,
'sport': 16,
'calories': 380,
'metres': 6012.4,
'interval': 1.0,
'heart': [0, 120, 135],
});
expect(lines.sessions.values.single, session());
expect(lines.isEmpty, isFalse);
expect(lines.dates, isEmpty);
});
test("Polar's running and cycling are Train's; every other sport is "
'Other', () {
expect(activityOfSport(1), Activity.running);
expect(activityOfSport(2), Activity.cycling);
expect(activityOfSport(16), Activity.other);
expect(activityOfSport(null), Activity.other);
});
test(
'a session becomes a workout from the Loop, with its heart rate',
() async {
final taken = await takeLoopSessions(workouts, [session()], taken: {});
final [stored] = await workouts.all();
expect(taken, {'/U/0/20261027/E/173000#0'});
expect(stored.content?.activity, Activity.other);
expect(stored.content?.startedAt, start);
expect(stored.content?.endedAt, start.add(const Duration(minutes: 45)));
expect(stored.content?.distanceMetres, 6012);
expect(stored.content?.origin?.app, loopApp);
final [heart] = await workouts.series(stored.id);
expect(heart.kind, SeriesKind.heartRate);
expect(heart.columns['bpm'], [120, 135]);
},
);
test('a session taken before, or kept as a workout already, is not '
'brought again', () async {
await takeLoopSessions(workouts, [session()], taken: {});
final again = await takeLoopSessions(
workouts,
[
session(),
session(id: '/other#0', at: start.add(const Duration(hours: 3))),
],
taken: {'/other#0'},
);
// Remembered from now on, as already kept.
expect(again, {'/U/0/20261027/E/173000#0'});
expect(await workouts.all(), hasLength(1));
});
test('a session Train recorded too gives the recorded workout its heart '
'rate where it has none, whatever the sports, and is no workout of its '
'own', () async {
final run = WorkoutContent(
activity: Activity.running,
startedAt: start.add(const Duration(minutes: 5)),
endedAt: start.add(const Duration(minutes: 40)),
);
final id = await workouts.save(run, const []);
final taken = await takeLoopSessions(workouts, [session()], taken: {});
expect(taken, hasLength(1));
expect(await workouts.all(), hasLength(1));
expect((await workouts.series(id)).single.kind, SeriesKind.heartRate);
});
test('a recorded workout with a heart rate of its own keeps it', () async {
final run = WorkoutContent(
activity: Activity.running,
startedAt: start,
endedAt: start.add(const Duration(minutes: 45)),
);
final own = chunksOf(
SeriesKind.heartRate,
start: start,
times: const [0],
columns: {
'bpm': [150],
},
);
final id = await workouts.save(run, own);
await takeLoopSessions(workouts, [session()], taken: {});
expect(await workouts.series(id), own);
});
test('a session barely overlapping a recorded workout is a workout of its '
'own', () async {
await workouts.save(
WorkoutContent(
activity: Activity.running,
startedAt: start.add(const Duration(minutes: 40)),
endedAt: start.add(const Duration(minutes: 90)),
),
const [],
);
await takeLoopSessions(workouts, [session()], taken: {});
expect(await workouts.all(), hasLength(2));
});
}

View file

@ -110,6 +110,25 @@ void main() {
expect(platform.written, contains('$first/heartRate/0'));
});
test("a workout the Loop recorded is Train's own and written; one "
'brought in from Health Connect is not written back (D39)', () async {
WorkoutContent from(String app) => WorkoutContent(
activity: Activity.other,
startedAt: start,
endedAt: start.add(const Duration(minutes: 45)),
origin: WorkoutOrigin(app: app, sessionId: 'session'),
);
final band = await workouts.save(from(loopApp), heartRate);
final other = await workouts.save(from('org.example.app'), const []);
final health = open();
expect(await health.setWriting(on: true), isTrue);
await settled(health);
expect(sessionOf(band), isNotNull);
expect(sessionOf(other), isNull);
});
test('refused, it stays off and writes nothing', () async {
platform = FakeHealthPlatform(grants: {});
await workouts.save(run(0), const []);

View file

@ -11,11 +11,14 @@ 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/profile/model/profile.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:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import '../../support/fake_loop_platform.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_day_gateway.dart';
import '../../support/memory_profile_gateway.dart';
import '../../support/memory_workout_gateway.dart';
/// The person's Loop on this phone (ADR 0030, D34, D35): set up with their
/// profile, remembered, connected while the app is open, its clock set at
@ -38,6 +41,7 @@ void main() {
late TrainKeys keys;
late ProfileRepository profile;
late DayRepository days;
late WorkoutRepository workouts;
late FakeLoopPlatform platform;
var now = DateTime.utc(2026, 10, 28, 9);
final opened = <PolarLoop>[];
@ -62,6 +66,7 @@ void main() {
store(),
profile,
days: days,
workouts: workouts,
backgroundLines: File('${folder.path}/loop_days.txt'),
today: () => today,
now: () => now,
@ -90,6 +95,7 @@ void main() {
);
await profile.save(body);
days = DayRepository(MemoryDayGateway(), keys, person: 'person');
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
now = DateTime.utc(2026, 10, 28, 9);
platform = FakeLoopPlatform()..inReach.add('A1');
});
@ -101,6 +107,7 @@ void main() {
opened.clear();
await profile.dispose();
await days.dispose();
await workouts.dispose();
keys.dispose();
folder.deleteSync(recursive: true);
});
@ -378,6 +385,39 @@ void main() {
expect(loop.status.loop?.keptUntil, before?.keptUntil);
});
test('a session the band recorded becomes a workout once, and is '
'remembered, so that a read again brings it no more (D39)', () async {
final line = {
'kind': 'session',
'date': '2026-10-27',
'id': '/U/0/20261027/E/173000#0',
'start': '2026-10-27T17:30+01:00',
'seconds': 2700,
'sport': 1,
'interval': 1,
'heart': [120, 125],
};
platform.days.addAll([
[line],
[line],
]);
final loop = await setUpBand();
await settled(loop);
final [workout] = await workouts.all();
expect(workout.content?.activity, Activity.running);
expect(loop.status.loop?.takenSessions.keys, [
'/U/0/20261027/E/173000#0',
]);
await workouts.delete(workout.id);
await loop.readDays();
expect(await workouts.all(), isEmpty);
expect(store().read()?.takenSessions, {
'/U/0/20261027/E/173000#0': DateTime.utc(2026, 10, 27),
});
});
test('letting the band go stops the background job', () async {
final loop = await setUpBand();
await settled(loop);