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

@ -0,0 +1,35 @@
import 'dart:async';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_gateway.dart';
/// [PlanGateway] in memory, whose rows a test changes as an instance could.
class MemoryPlanGateway() implements PlanGateway {
final rows = <String, Envelope>{};
final _remote = StreamController<void>.broadcast();
/// As a sync bringing others' changes.
void bringRemoteChanges() => _remote.add(null);
@override
Future<List<PlanRow>> plans() async => [
for (final MapEntry(:key, :value) in rows.entries)
(id: key, envelope: value),
];
@override
Future<void> insert(PlanRow plan) async => rows[plan.id] = plan.envelope;
@override
Future<void> update(String id, Envelope envelope) async {
if (rows.containsKey(id)) {
rows[id] = envelope;
}
}
@override
Future<void> delete(String id) async => rows.remove(id);
@override
Stream<void> get remoteChanges => _remote.stream;
}