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:
parent
fa7680821d
commit
f406ddcc59
1 changed files with 23608 additions and 10 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue