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

View file

@ -19,6 +19,9 @@ import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.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 '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/profile/widget/profile_screen.dart';
@ -37,6 +40,7 @@ import '../support/fake_loop_platform.dart';
import '../support/fake_recorder_platform.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_day_gateway.dart';
import '../support/memory_plan_gateway.dart';
import '../support/memory_profile_gateway.dart';
import '../support/memory_workout_gateway.dart';
@ -70,6 +74,7 @@ void main() {
ProfileRepository? profile,
DayRepository? days,
PolarLoop? loop,
PlanRepository? plans,
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
@ -160,6 +165,16 @@ void main() {
),
person: person.authUserId,
),
plans:
plans ??
PlanRepository(
MemoryPlanGateway(),
TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
),
),
recording: recording,
health: health,
loop: loop,
@ -1575,6 +1590,121 @@ void main() {
});
});
group('plans (ADR 0030, D42)', () {
late TrainKeys keys;
late PlanRepository plans;
setUp(() {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
plans = PlanRepository(MemoryPlanGateway(), keys);
});
tearDown(() async {
await plans.dispose();
keys.dispose();
});
Future<void> openPlans(WidgetTester tester) async {
await start(tester, usedBefore: true, plans: plans);
await tester.tap(find.text('Plans'));
await tester.pumpAndSettle();
}
testWidgets('a plan made from a template is saved and listed with its '
'steps and time', (tester) async {
await openPlans(tester);
expect(find.text('No plans yet'), findsOneWidget);
await tester.tap(find.text('New plan'));
await tester.pumpAndSettle();
await tester.tap(find.text('4×4 minutes'));
await tester.pumpAndSettle();
expect(find.text('4×'), findsOneWidget);
expect(find.text('4 min · Zone 4'), findsNWidgets(1));
await tester.tap(find.text('Save'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(find.text('4×4 minutes'), findsOneWidget);
expect(find.text('10 steps · 43 min'), findsOneWidget);
final [plan] = await tester.runAsync(plans.all) ?? const <StoredPlan>[];
expect(plan.content?.blocks, hasLength(3));
});
testWidgets('a plan from nothing saves only with a name and a step; a '
'step is changed on its sheet', (tester) async {
await openPlans(tester);
await tester.tap(find.text('New plan'));
await tester.pumpAndSettle();
await tester.tap(find.text('From nothing'));
await tester.pumpAndSettle();
final save = find.widgetWithText(TextButton, 'Save');
expect(tester.widget<TextButton>(save).onPressed, isNull);
await tester.enterText(find.byType(TextFormField), 'Hills');
await tester.tap(find.text('Add a step'));
await tester.pumpAndSettle();
expect(tester.widget<TextButton>(save).onPressed, isNotNull);
await tester.tap(find.text('1 min'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, '1:00'), '0:45');
await tester.tap(find.text('None'));
await tester.pumpAndSettle();
await tester.tap(find.text('Heart-rate zone').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('45 s · Zone 3'), findsOneWidget);
await tester.tap(save);
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(find.text('Hills'), findsOneWidget);
expect(find.text('1 step · 45 s'), findsOneWidget);
});
testWidgets('deleting a plan asks first, and can be undone', (
tester,
) async {
await tester.runAsync(
() => plans.save(
const PlanContent(
name: 'Strides',
blocks: [
PlanBlock(
repeat: 6,
steps: [
PlanStep(kind: StepKind.work, end: EndAfterDistance(100)),
],
),
],
),
),
);
await openPlans(tester);
expect(find.text('6 steps · 600 m'), findsOneWidget);
await tester.tap(find.text('Strides'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('Strides'), findsNothing);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.text('Strides'), findsOneWidget);
});
});
group('workouts (ADR 0030, D24)', () {
late MemoryWorkoutGateway rows;
late TrainKeys keys;