Stage 4: guided Player for both Modes and the Session summary
- buildSegments lays a Plan out as holds with Transitions (get ready, switch sides, next round, next exercise) or sets with count-up Rests; Supersets alternate members and rest per round unless asked otherwise - SessionRunner drives segments against an injected clock: auto-advance with a chime, pause, skip, back, rep sets confirmed with logged reps and kg, timed sets started on demand, early end - Player screen: countdown ring, accent pulse in the last 3 s, Flip Animation, next-up cards, Rest Screen, mute toggle, screen kept on - Sessions table (schema v2 migration) keeps a Plan snapshot and Set Logs; nothing is saved when no hold or set was done - Summary with time, exercises or sets logged, and every Set Log - Synthesised quiet cue sounds (tools/sounds), status bar per Mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bbdca95541
commit
971ff64117
28 files changed
+3465
-84
No files matched your search
@@ -0,0 +1,158 @@
|
||||
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/player/segments.dart';
|
||||
import 'package:zenxercise/src/player/session_runner.dart';
|
||||
|
||||
Plan _plan(Mode mode, List<PlanItem> items, {int transition = 5}) => Plan(
|
||||
id: 'p',
|
||||
mode: mode,
|
||||
name: 'Plan',
|
||||
items: items,
|
||||
transitionSeconds: transition,
|
||||
createdAt: DateTime(2026),
|
||||
updatedAt: DateTime(2026),
|
||||
);
|
||||
|
||||
final t0 = DateTime(2026, 10, 29, 7);
|
||||
DateTime at(int seconds) => t0.add(Duration(seconds: seconds));
|
||||
|
||||
void main() {
|
||||
group('Stretching runs by itself', () {
|
||||
late SessionRunner runner;
|
||||
var cues = 0;
|
||||
|
||||
setUp(() {
|
||||
cues = 0;
|
||||
runner = SessionRunner(
|
||||
_plan(Mode.stretching, const [StretchingStep(exerciseId: 'A', holdSeconds: 30, bothSides: true, rounds: 1)]),
|
||||
startedAt: t0,
|
||||
onCue: () => cues++,
|
||||
);
|
||||
});
|
||||
|
||||
test('counting down a get-ready, two holds and the switch between them', () {
|
||||
expect(runner.current, isA<TransitionSegment>());
|
||||
expect(runner.remaining(at(2)), const Duration(seconds: 3));
|
||||
|
||||
runner.tick(at(5));
|
||||
expect(runner.current, isA<HoldSegment>().having((h) => h.side, 'side', Side.left));
|
||||
|
||||
runner.tick(at(35));
|
||||
expect(runner.current, isA<TransitionSegment>().having((t) => t.kind, 'kind', TransitionKind.switchSides));
|
||||
|
||||
runner.tick(at(40));
|
||||
runner.tick(at(70));
|
||||
expect(runner.isFinished, isTrue);
|
||||
expect(runner.isComplete, isTrue);
|
||||
expect(cues, 4);
|
||||
});
|
||||
|
||||
test('pausing stops the clock', () {
|
||||
runner.tick(at(5));
|
||||
runner.pause(at(10));
|
||||
runner.tick(at(100));
|
||||
expect(runner.current, isA<HoldSegment>());
|
||||
expect(runner.remaining(at(100)), const Duration(seconds: 25));
|
||||
|
||||
runner.resume(at(100));
|
||||
expect(runner.remaining(at(110)), const Duration(seconds: 15));
|
||||
});
|
||||
|
||||
test('skipping and going back move between holds, passing over Transitions', () {
|
||||
runner.skip(at(1));
|
||||
expect(runner.current, isA<HoldSegment>().having((h) => h.side, 'side', Side.left));
|
||||
runner.skip(at(2));
|
||||
runner.skip(at(3));
|
||||
expect(runner.current, isA<HoldSegment>().having((h) => h.side, 'side', Side.right));
|
||||
|
||||
runner.back(at(4));
|
||||
expect(runner.current, isA<HoldSegment>().having((h) => h.side, 'side', Side.left));
|
||||
expect(runner.remaining(at(4)), const Duration(seconds: 30));
|
||||
});
|
||||
|
||||
test('there is progress worth saving only once a hold has ended', () {
|
||||
expect(runner.hasProgress, isFalse);
|
||||
runner.tick(at(5));
|
||||
// Skipping the left side is not progress; the right side starts after its switch at 15 s.
|
||||
runner.skip(at(10));
|
||||
runner.tick(at(15));
|
||||
expect(runner.hasProgress, isFalse);
|
||||
runner.tick(at(45));
|
||||
expect(runner.hasProgress, isTrue);
|
||||
});
|
||||
|
||||
test('ending early finishes incomplete', () {
|
||||
runner.end(at(12));
|
||||
|
||||
expect(runner.isFinished, isTrue);
|
||||
expect(runner.isComplete, isFalse);
|
||||
expect(runner.result(at(12)).duration, const Duration(seconds: 12));
|
||||
});
|
||||
});
|
||||
|
||||
group('Workout runs at the user\'s pace', () {
|
||||
final plan = _plan(Mode.workout, const [
|
||||
WorkoutStep(exerciseId: 'Squat', sets: 2, target: WorkoutTarget.reps(10), weightKg: 60),
|
||||
WorkoutStep(exerciseId: 'Plank', sets: 1, target: WorkoutTarget.seconds(30)),
|
||||
]);
|
||||
|
||||
test('a rep set waits for "Set done", then the Rest counts up until the next set starts', () {
|
||||
final runner = SessionRunner(plan, startedAt: t0);
|
||||
|
||||
runner.tick(at(600));
|
||||
expect(runner.current, isA<SetSegment>());
|
||||
|
||||
runner.completeSet(reps: 8, weightKg: 62.5, now: at(40));
|
||||
expect(runner.hasProgress, isTrue);
|
||||
expect(runner.current, isA<RestSegment>());
|
||||
expect(runner.elapsed(at(112)), const Duration(seconds: 72));
|
||||
|
||||
runner.startNext(at(120));
|
||||
expect(runner.current, isA<SetSegment>().having((s) => s.set, 'set', 2));
|
||||
});
|
||||
|
||||
test('a timed set starts on demand, counts down and logs itself', () {
|
||||
var cues = 0;
|
||||
final runner = SessionRunner(plan, startedAt: t0, onCue: () => cues++);
|
||||
runner.completeSet(reps: 10, weightKg: 60, now: at(30));
|
||||
runner.startNext(at(90));
|
||||
runner.completeSet(reps: 10, weightKg: 60, now: at(120));
|
||||
runner.startNext(at(180));
|
||||
|
||||
expect(runner.isWaitingToStart, isTrue);
|
||||
runner.tick(at(500));
|
||||
expect(runner.current, isA<SetSegment>());
|
||||
|
||||
runner.startTimer(at(500));
|
||||
runner.tick(at(530));
|
||||
|
||||
expect(runner.isFinished, isTrue);
|
||||
expect(cues, 1);
|
||||
expect(runner.result(at(530)).setLogs.map((l) => (l.exerciseId, l.set, l.reps, l.seconds, l.weightKg)), [
|
||||
('Squat', 1, 10, null, 60.0),
|
||||
('Squat', 2, 10, null, 60.0),
|
||||
('Plank', 1, null, 30, null),
|
||||
]);
|
||||
});
|
||||
|
||||
test('redoing a set after going back replaces its log', () {
|
||||
final runner = SessionRunner(plan, startedAt: t0);
|
||||
runner.completeSet(reps: 6, weightKg: 60, now: at(30));
|
||||
|
||||
runner.back(at(40));
|
||||
runner.completeSet(reps: 10, weightKg: 60, now: at(60));
|
||||
|
||||
expect(runner.result(at(60)).setLogs.single.reps, 10);
|
||||
});
|
||||
|
||||
test('a skipped set is not logged', () {
|
||||
final runner = SessionRunner(plan, startedAt: t0);
|
||||
runner.skip(at(10));
|
||||
runner.skip(at(11));
|
||||
|
||||
expect(runner.current, isA<SetSegment>().having((s) => s.set, 'set', 2));
|
||||
expect(runner.result(at(11)).setLogs, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user