Stage 3: Plan editor with Steps, Supersets and reordering

- Plan model with immutable edits: add with Mode defaults, move, remove,
  Superset with next, take a member out, ungroup; Stretching length
  estimate from holds, sides, rounds and Transitions
- drift database (plans table, items as JSON, ADR 0005) and
  PlanRepository; Plans list per Mode with rename/duplicate/delete
- Editor: drag handles, step editor sheet (sets, reps or time, optional
  kg; hold, both sides, rounds; Superset sets and rest between),
  pause between holds for Stretching Plans
- Exercise picker reusing the Library browser; Add to plan from the
  Exercise detail page
- Theme: Mode-styled snackbar, FAB, menus, segmented buttons, switches

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 23:48:07 +01:00
1 parent 786dd52e30
commit bbdca95541
28 files changed
+3654 -11

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/database/database.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/plans/models/plan.dart';
import 'package:zenxercise/src/plans/plan_repository.dart';
import '../support/test_database.dart';
void main() {
late AppDatabase db;
late PlanRepository plans;
var now = DateTime(2026, 10, 1, 8);
setUp(() {
db = testDatabase();
now = DateTime(2026, 10, 1, 8);
plans = PlanRepository(db, clock: () => now);
});
tearDown(() => db.close());
test('a created Plan is empty, belongs to its Mode and is listed newest first', () async {
final morning = await plans.create(Mode.stretching, 'Morning');
now = now.add(const Duration(hours: 1));
final evening = await plans.create(Mode.stretching, 'Evening');
await plans.create(Mode.workout, 'Legs');
final listed = await plans.watchPlans(Mode.stretching).first;
expect(listed.map((p) => p.name), ['Evening', 'Morning']);
expect(morning.items, isEmpty);
expect(evening.transitionSeconds, Plan.defaultTransitionSeconds);
});
test('saving keeps the items and stamps the change time', () async {
final plan = await plans.create(Mode.workout, 'Push');
now = DateTime(2026, 10, 2);
await plans.save(plan.withExerciseAdded('Bench').withExerciseAdded('Dips').withSupersetWithNext(0));
final stored = (await plans.get(plan.id))!;
expect(stored.items.single, isA<Superset>());
expect(stored.updatedAt, DateTime(2026, 10, 2));
expect(stored.createdAt, plan.createdAt);
});
test('a duplicate copies the items under a new id and name', () async {
final plan = await plans.create(Mode.workout, 'Push');
await plans.save(plan.withExerciseAdded('Bench'));
final copy = await plans.duplicate(plan.id, 'Push (copy)');
expect(copy.id, isNot(plan.id));
expect(copy.items, (await plans.get(plan.id))!.items);
expect((await plans.watchPlans(Mode.workout).first), hasLength(2));
});
test('a deleted Plan is gone', () async {
final plan = await plans.create(Mode.workout, 'Push');
await plans.delete(plan.id);
expect(await plans.get(plan.id), isNull);
});
}
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/plans/models/plan.dart';
Plan _plan(Mode mode, List<PlanItem> items) => Plan(
id: 'p1',
mode: mode,
name: 'Push day',
items: items,
createdAt: DateTime(2026, 10, 1),
updatedAt: DateTime(2026, 10, 1),
);
WorkoutStep _w(String id, {int sets = 3, WorkoutTarget target = const WorkoutTarget.reps(10), double? kg}) =>
WorkoutStep(exerciseId: id, sets: sets, target: target, weightKg: kg);
void main() {
group('adding Exercises', () {
test('a Workout Plan adds a Workout Step of 3 × 10 reps without weight', () {
final plan = _plan(Mode.workout, const []).withExerciseAdded('Bench_Press');
expect(plan.items.single, _w('Bench_Press'));
});
test('a Stretching Plan adds a single 30 s hold on one side', () {
final plan = _plan(Mode.stretching, const []).withExerciseAdded('Calf_Stretch');
expect(
plan.items.single,
const StretchingStep(exerciseId: 'Calf_Stretch', holdSeconds: 30, bothSides: false, rounds: 1),
);
});
});
test('moving follows the list convention of an index past the removed item', () {
final plan = _plan(Mode.workout, [_w('A'), _w('B'), _w('C')]);
expect(plan.withItemMoved(0, 3).items, [_w('B'), _w('C'), _w('A')]);
expect(plan.withItemMoved(2, 0).items, [_w('C'), _w('A'), _w('B')]);
expect(plan.withItemRemoved(1).items, [_w('A'), _w('C')]);
});
group('Supersets', () {
test('a Step grouped with the next keeps each target and takes the first set count', () {
final plan = _plan(Mode.workout, [
_w('A', sets: 4, kg: 20),
_w('B', sets: 2, target: const WorkoutTarget.seconds(45)),
_w('C'),
]).withSupersetWithNext(0);
expect(plan.items, [
const Superset(
sets: 4,
restBetween: false,
members: [
SupersetMember(exerciseId: 'A', target: WorkoutTarget.reps(10), weightKg: 20),
SupersetMember(exerciseId: 'B', target: WorkoutTarget.seconds(45)),
],
),
_w('C'),
]);
expect(plan.exerciseCount, 3);
});
test('grouping again with the next item grows the Superset, also when the next is a Superset', () {
final grown = _plan(Mode.workout, [_w('A'), _w('B'), _w('C')]).withSupersetWithNext(0).withSupersetWithNext(0);
expect((grown.items.single as Superset).members.map((m) => m.exerciseId), ['A', 'B', 'C']);
final merged = _plan(Mode.workout, [_w('A'), _w('B'), _w('C')]).withSupersetWithNext(1).withSupersetWithNext(0);
expect((merged.items.single as Superset).members.map((m) => m.exerciseId), ['A', 'B', 'C']);
});
test('only an item with a next item can be grouped', () {
final plan = _plan(Mode.workout, [_w('A'), _w('B')]);
expect(plan.canSupersetWithNext(0), isTrue);
expect(plan.canSupersetWithNext(1), isFalse);
expect(_plan(Mode.stretching, const []).withExerciseAdded('X').canSupersetWithNext(0), isFalse);
});
test('a member taken out lands right after its Superset with the Superset set count', () {
final plan = _plan(Mode.workout, [
_w('A'),
_w('B'),
_w('C'),
_w('D'),
]).withSupersetWithNext(0).withSupersetWithNext(0).withMemberRemovedFromSuperset(0, 0);
expect((plan.items[0] as Superset).members.map((m) => m.exerciseId), ['B', 'C']);
expect(plan.items[1], _w('A'));
expect(plan.items[2], _w('D'));
});
test('a Superset left with one member turns back into a Step', () {
final plan = _plan(Mode.workout, [
_w('A', sets: 5),
_w('B'),
]).withSupersetWithNext(0).withMemberRemovedFromSuperset(0, 1);
expect(plan.items, [_w('A', sets: 5), _w('B', sets: 5)]);
});
test('ungrouping gives every member its own Step with the Superset set count', () {
final plan = _plan(Mode.workout, [_w('A', sets: 4), _w('B')]).withSupersetWithNext(0).withUngrouped(0);
expect(plan.items, [_w('A', sets: 4), _w('B', sets: 4)]);
});
});
test('a Stretching Plan estimates its length from holds, sides, rounds and Transitions', () {
final plan = _plan(Mode.stretching, const [
StretchingStep(exerciseId: 'A', holdSeconds: 30, bothSides: true, rounds: 2),
StretchingStep(exerciseId: 'B', holdSeconds: 60, bothSides: false, rounds: 1),
]);
// Holds: 4 × 30 s + 60 s = 180 s; 5 holds have 4 Transitions of 5 s between them.
expect(plan.estimatedDuration, const Duration(seconds: 200));
expect(_plan(Mode.workout, [_w('A')]).estimatedDuration, isNull);
});
test('items survive a JSON round trip', () {
final items = [
_w('A', kg: 12.5),
const StretchingStep(exerciseId: 'B', holdSeconds: 45, bothSides: true, rounds: 3),
const Superset(
sets: 3,
restBetween: true,
members: [
SupersetMember(exerciseId: 'C', target: WorkoutTarget.seconds(30)),
SupersetMember(exerciseId: 'D', target: WorkoutTarget.reps(8), weightKg: 40),
],
),
];
expect(PlanItem.listFromJson(PlanItem.listToJson(items)), items);
});
}
+7
View File
@@ -6,8 +6,11 @@ import 'package:zenxercise/src/app.dart';
import 'package:zenxercise/src/exercises/exercise.dart';
import 'package:zenxercise/src/exercises/exercise_library.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/plans/plan_repository.dart';
import 'package:zenxercise/src/settings/settings_controller.dart';
import 'test_database.dart';
Exercise exercise(
String id,
Mode mode, {
@@ -58,15 +61,19 @@ Future<SettingsController> pumpApp(
WidgetTester tester, {
ExerciseLibrary? library,
Map<String, Object> prefs = const {},
PlanRepository? plans,
}) async {
SharedPreferences.setMockInitialValues(prefs);
final settings = SettingsController(SettingsService());
await settings.loadSettings();
final db = testDatabase();
addTearDown(db.close);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: settings),
Provider.value(value: library ?? sampleLibrary),
Provider.value(value: plans ?? PlanRepository(db)),
],
child: const ZenXerciseApp(),
),
+21
View File
@@ -0,0 +1,21 @@
import 'dart:ffi';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:sqlite3/open.dart';
import 'package:zenxercise/src/database/database.dart';
/// An in-memory database for tests. On Windows hosts the sqlite3 library comes
/// from ZENXERCISE_SQLITE_DLL if set, otherwise from the system's winsqlite3.dll.
AppDatabase testDatabase() {
if (Platform.isWindows) {
open.overrideFor(OperatingSystem.windows, () {
final custom = Platform.environment['ZENXERCISE_SQLITE_DLL'];
return DynamicLibrary.open(custom ?? 'winsqlite3.dll');
});
}
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
// Synchronous stream teardown keeps widget tests from hanging on pending timers.
return AppDatabase(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/plans/models/plan.dart';
import 'package:zenxercise/src/plans/plan_repository.dart';
import '../support/test_app.dart';
import '../support/test_database.dart';
void main() {
late PlanRepository plans;
setUp(() {
final db = testDatabase();
addTearDown(db.close);
plans = PlanRepository(db);
});
/// A Workout Plan with two Squat Steps, open in the editor.
Future<Plan> openWorkoutPlan(WidgetTester tester) async {
final plan = await plans.create(Mode.workout, 'Legs');
await plans.save(plan.withExerciseAdded('Barbell_Squat').withExerciseAdded('Barbell_Squat'));
usePhoneScreen(tester);
await pumpApp(tester, plans: plans, prefs: {'mode': 'workout'});
await tester.tap(find.text('Legs'));
await tester.pumpAndSettle();
return plan;
}
testWidgets('a new Plan is named, filled from the picker and listed with its count', (tester) async {
usePhoneScreen(tester);
await pumpApp(tester, plans: plans);
await tester.tap(find.text('New plan'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Morning');
await tester.pump();
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(find.text('Morning'), findsOneWidget);
expect(find.text('No exercises yet'), findsWidgets);
await tester.tap(find.text('Add exercises'));
await tester.pumpAndSettle();
await tester.tap(find.text('90/90 Hamstring'));
await tester.pumpAndSettle();
await tester.tap(find.text('Calf Stretch'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('90/90 Hamstring'), findsOneWidget);
expect(find.text('Calf Stretch'), findsOneWidget);
// Two 30 s holds and one 5 s Transition.
expect(find.text('2 exercises · about 1 min'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Morning'), findsOneWidget);
expect(find.text('2 exercises · about 1 min'), findsOneWidget);
});
testWidgets('editing a Step saves at once and shows in its summary', (tester) async {
final plan = await openWorkoutPlan(tester);
await tester.tap(find.text('Barbell Squat').first);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Sets +'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add a target weight'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('4 × 10 reps · 20 kg'), findsOneWidget);
expect((await plans.get(plan.id))!.items.first, isA<WorkoutStep>().having((s) => s.sets, 'sets', 4));
});
testWidgets('two Steps become a Superset and split again', (tester) async {
await openWorkoutPlan(tester);
// The first Step's menu; the Plan's own menu in the app bar comes last.
await tester.tap(find.byTooltip('More actions').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Superset with next'));
await tester.pumpAndSettle();
expect(find.text('Superset · 3 sets'), findsOneWidget);
expect(find.text('10 reps'), findsNWidgets(2));
await tester.tap(find.byTooltip('More actions').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Ungroup'));
await tester.pumpAndSettle();
expect(find.text('Superset · 3 sets'), findsNothing);
expect(find.text('3 × 10 reps'), findsNWidgets(2));
});
testWidgets('deleting the Plan from its editor returns to the list', (tester) async {
await openWorkoutPlan(tester);
await tester.tap(find.byTooltip('More actions').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('Legs'), findsNothing);
expect(find.text('No plans yet'), findsOneWidget);
});
testWidgets('an Exercise is added to a Plan from its detail page', (tester) async {
final plan = await plans.create(Mode.stretching, 'Evening');
usePhoneScreen(tester);
await pumpApp(tester, plans: plans);
await tester.tap(find.byTooltip('Library'));
await tester.pumpAndSettle();
await tester.tap(find.text('Calf Stretch'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add to plan'));
await tester.pumpAndSettle();
await tester.tap(find.text('Evening'));
await tester.pumpAndSettle();
expect(find.text('Added to Evening'), findsOneWidget);
expect((await plans.get(plan.id))!.items.single, isA<StretchingStep>());
});
}