feat(train): interval plans, synced and sealed, with an editor and templates (ADR 0030, D42)

Train's server gains the synced `plan` table, a sealed payload alone;
the migration `plans` only adds it, its space index and the foreign key
to its sync space, and a person's deletion takes it with their space.

The app keeps plans as `plan` rows sealed for their row: a name, what
they are for, whether a step off target is told, and blocks of steps
repeated, each ending after a time, a distance or a lap tap and aiming
at a pace, speed, heart rate or zone, or the rower's split, watts or
stroke rate. Home's Plans tab lists them, a new one starts from nothing
or from a template (4×4 minutes, Tabata, 10×400 m), the editor changes
steps on a sheet, and a deletion asks first and can be undone.

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

View file

@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.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/plan/bloc/plan_editor_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_plan_gateway.dart';
/// A plan being made or changed (ADR 0030, D42).
void main() {
late TrainKeys keys;
late PlanRepository plans;
PlanRepository repository(UnlockedAccountKeys? account) {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => account,
);
return plans = PlanRepository(MemoryPlanGateway(), keys);
}
tearDown(() async {
await plans.dispose();
keys.dispose();
});
test('steps and repeats are added, changed, moved and removed', () {
final cubit =
PlanEditorCubit(
repository(FakeAccountCrypto().keysNumbered(1)),
plan: const PlanContent(name: 'x', blocks: []),
)
..addStep()
..addRepeat()
..setRepeat(1, 6)
..addStep(block: 1)
..setStep(
1,
2,
const PlanStep(kind: StepKind.recovery, end: EndWithLap()),
)
..moveBlock(1, up: true)
..removeStep(0, 0);
final plan = cubit.state.plan;
expect([for (final block in plan.blocks) block.repeat], [6, 1]);
expect(plan.blocks.first.steps.map((s) => s.end), [
const EndAfterTime(Duration(minutes: 1)),
const EndWithLap(),
]);
expect(plan.run, hasLength(13));
cubit
..setRepeat(0, 0)
..removeStep(1, 0);
expect(cubit.state.plan.blocks.single.repeat, 1);
});
test('a plan saves with a name and a step, then edits itself', () async {
final cubit = PlanEditorCubit(
repository(FakeAccountCrypto().keysNumbered(1)),
plan: const PlanContent(name: ' ', blocks: []),
)..addStep();
expect(cubit.state.canSave, isFalse);
expect(await cubit.save(), isFalse);
cubit.rename(' Fartlek ');
expect(await cubit.save(), isTrue);
final id = cubit.state.id!;
cubit.rename('Fartlek, long');
expect(await cubit.save(), isTrue);
final [StoredPlan(id: kept, :content)] = await plans.all();
expect(kept, id);
expect(content?.name, 'Fartlek, long');
});
test('without the keys, nothing is saved', () async {
final cubit = PlanEditorCubit(
repository(null),
plan: const PlanContent(name: 'x', blocks: []),
)..addStep();
expect(await cubit.save(), isFalse);
expect(await plans.all(), isEmpty);
});
}

View file

@ -0,0 +1,126 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_plan_gateway.dart';
/// The person's interval plans, sealed on the way to the instance and opened
/// on the way back (ADR 0030, D7, D42), with libsodium.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late MemoryContentKeyGateway keyRows;
late MemoryPlanGateway rows;
final opened = <TrainKeys>[];
const tabata = PlanContent(
name: 'Tabata',
blocks: [
PlanBlock(
repeat: 8,
steps: [
PlanStep(
kind: StepKind.work,
end: EndAfterTime(Duration(seconds: 20)),
),
PlanStep(
kind: StepKind.recovery,
end: EndAfterTime(Duration(seconds: 10)),
),
],
),
],
);
Future<PlanRepository> device(UnlockedAccountKeys? account) async {
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys);
await keys.refresh();
return PlanRepository(rows, keys);
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
bob = (await crypto.newAccount('bob password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemoryPlanGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('a plan made on one device reads on another of the account, '
'edited and deleted there', () async {
final laptop = await device(alice);
final id = await laptop.save(tabata);
final phone = await device(alice);
expect(await phone.all(), [StoredPlan(id, tabata)]);
const renamed = PlanContent(name: 'Tabata, short', blocks: []);
await phone.update(id, renamed);
expect(await laptop.all(), [StoredPlan(id, renamed)]);
await laptop.delete(id);
expect(await phone.all(), isEmpty);
});
test('nothing the instance gets is readable: no name, no JSON', () async {
await (await device(alice)).save(tabata);
final sealed = rows.rows.values.single;
expect(sealed.keyId, isNotNull);
expect(
utf8.decode(Uint8List.sublistView(sealed.payload), allowMalformed: true),
isNot(contains('Tabata')),
);
});
test("another person's plan, a readable one, or one moved to another row "
'shows locked, never as theirs', () async {
final id = await (await device(alice)).save(tabata);
final sealed = rows.rows[id]!;
rows.rows
..['moved'] = sealed
..['readable'] = (
keyId: null,
nonce: null,
payload: ByteData.sublistView(
Uint8List.fromList(utf8.encode('{"schemaVersion":1}')),
),
);
final others = await device(bob);
expect(
(await others.all()).where((plan) => plan.content != null),
isEmpty,
);
final mine = await device(alice);
expect(
{for (final plan in await mine.all()) plan.id: plan.content},
{id: tabata, 'moved': null, 'readable': null},
);
});
test('a device without the keys reads nothing and saves nothing', () async {
await (await device(alice)).save(tabata);
final stranger = await device(null);
expect((await stranger.all()).single.content, isNull);
await expectLater(stranger.save(tabata), throwsA(isA<KeysMissing>()));
});
}

View file

@ -0,0 +1,128 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Interval plans (ADR 0030, D42): steps in repeat blocks, each ending after
/// a time, a distance or a lap tap, and aiming at a target.
void main() {
final fourByFour = PlanContent(
name: '4×4',
activity: Activity.running,
blocks: [
const PlanBlock(
steps: [
PlanStep(
kind: StepKind.warmUp,
end: EndAfterTime(Duration(minutes: 10)),
),
],
),
PlanBlock(
repeat: 4,
steps: [
PlanStep(
kind: StepKind.work,
end: const EndAfterTime(Duration(minutes: 4)),
target: Target.zone(4),
),
const PlanStep(
kind: StepKind.recovery,
end: EndAfterDistance(400),
target: Target(TargetKind.pace, 330, 390),
),
],
),
const PlanBlock(
steps: [PlanStep(kind: StepKind.coolDown, end: EndWithLap())],
),
],
);
ByteData bytes(Object json) =>
ByteData.sublistView(Uint8List.fromList(utf8.encode(jsonEncode(json))));
test("a plan's rounds are spelled out in the order they run", () {
final run = fourByFour.run;
expect(run, hasLength(10));
expect(run.first.step.kind, StepKind.warmUp);
expect(
[for (final s in run.skip(1).take(8)) s.round],
[
1, 1, 2, 2, 3, 3, 4, 4, //
],
);
expect(run.last.step.end, const EndWithLap());
expect(fourByFour.timed, const Duration(minutes: 26));
expect(fourByFour.metres, 1600);
});
test('the zones are 50–60 % up to 90–100 % of the highest rate', () {
expect(heartZone(1, 190), (low: 95, high: 114));
expect(heartZone(4, 190), (low: 152, high: 171));
expect(heartZone(5, 190), (low: 171, high: 190));
});
test('a plan sealed reads back as it was', () {
expect(decodePlan(encodePlan(fourByFour)), fourByFour);
});
test('a plan of a newer schema, or none, is not read', () {
expect(
decodePlan(
bytes({'schemaVersion': 2, 'name': 'x', 'blocks': <Object>[]}),
),
isNull,
);
expect(decodePlan(bytes({'name': 'x'})), isNull);
});
test('a step it cannot read is left out, a target it does not know is '
'none', () {
final plan = decodePlan(
bytes({
'schemaVersion': 1,
'name': 'odd',
'blocks': [
{
'repeat': 2,
'steps': [
{
'kind': 'work',
'end': {'by': 'time', 'seconds': 30},
'target': {'kind': 'lactate', 'low': 1, 'high': 2},
},
{
'kind': 'sprint',
'end': {'by': 'lap'},
},
{
'kind': 'recovery',
'end': {'by': 'time', 'seconds': 0},
},
],
},
{'repeat': 0, 'steps': <Object>[]},
],
}),
)!;
expect(plan.blocks, [
const PlanBlock(
repeat: 2,
steps: [
PlanStep(
kind: StepKind.work,
end: EndAfterTime(Duration(seconds: 30)),
),
],
),
]);
expect(plan.offTargetCues, isTrue);
expect(plan.activity, isNull);
});
}

View file

@ -0,0 +1,104 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
// The plans on the device's own database, as the sync engine keeps them,
// with libsodium as its build hook compiles it.
final _person = protocol.UuidValue.withValidation(
'01a16000-0000-7000-8000-000000000001',
);
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late Directory folder;
late OfflineSyncDatabaseSession db;
late SyncService sync;
late TrainKeys keys;
late PlanRepository plans;
const tenBy400 = PlanContent(
name: '10×400 m',
blocks: [
PlanBlock(
repeat: 10,
steps: [
PlanStep(kind: StepKind.work, end: EndAfterDistance(400)),
PlanStep(
kind: StepKind.recovery,
end: EndAfterTime(Duration(seconds: 90)),
),
],
),
],
);
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('plans');
db = await protocol.Client(
'http://127.0.0.1:9/',
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
sync = SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
);
keys = TrainKeys(
SyncContentKeyGateway(db, sync),
SodiumContentCipher(),
() async => alice,
);
await keys.refresh();
plans = PlanRepository(SyncPlanGateway(db, sync), keys);
});
tearDown(() async {
await plans.dispose();
keys.dispose();
await sync.dispose();
await db.close();
await folder.delete(recursive: true);
});
test('a plan is kept, edited and read back opened', () async {
final id = await plans.save(tenBy400);
const renamed = PlanContent(name: '5×400 m', blocks: []);
await plans.update(id, renamed);
expect(await plans.all(), [StoredPlan(id, renamed)]);
});
test('deleting a plan blanks its payload first', () async {
final id = await plans.save(tenBy400);
final kept = await plans.save(tenBy400);
await plans.delete(id);
expect(await plans.all(), [StoredPlan(kept, tenBy400)]);
// The sync engine keeps the deleted row as its tombstone: blank.
final rows = await db.db.unsafeQuery(
'SELECT length("payload"), "keyId", "nonce" FROM "plan"',
);
final blank = [
for (final row in rows)
if (row[0] == 0) row,
];
expect(rows.length - blank.length, 1);
expect(blank, hasLength(1));
expect(blank.single.skip(1), [null, null]);
});
}