From 6c177de6f85e11ded6b70e359f276affc3fcf03a Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 25 Jul 2026 17:53:11 +0200 Subject: [PATCH] feat(watch): route watch commands to existing session use cases (#91-C) --- lib/application/app_bootstrap.dart | 45 +- lib/application/use_cases.dart | 390 ++++++++++ .../watch_companion_command_handler_test.dart | 686 ++++++++++++++++++ 3 files changed, 1104 insertions(+), 17 deletions(-) create mode 100644 test/application/watch_companion_command_handler_test.dart diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 250625d..d9c8096 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -32,6 +32,7 @@ final class AppBootstrap implements AppDependencies { required this.activeWorkoutSessionUseCases, required this.activeExerciseStepUseCases, required this.watchCompanionProjectionUseCases, + required this.watchCompanionCommandHandler, required this.closeWorkoutSessionUseCase, required this.workoutHistoryUseCases, required this.progressionStatsUseCase, @@ -59,6 +60,7 @@ final class AppBootstrap implements AppDependencies { @override final ActiveExerciseStepUseCases activeExerciseStepUseCases; final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; + final WatchCompanionCommandHandler watchCompanionCommandHandler; @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; @override @@ -105,6 +107,25 @@ final class AppBootstrap implements AppDependencies { final ids = LocalIdGenerator(); const clock = SystemClock(); const originDeviceId = 'local-device'; + final activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( + sessionRepository: activeSessionRepository, + templateRepository: templateRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ); + final activeExerciseStepUseCases = ActiveExerciseStepUseCases( + sessionRepository: activeSessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ); + final watchCompanionProjectionUseCases = WatchCompanionProjectionUseCases( + sessionRepository: activeSessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ); await SeedStarterContentUseCase( seedStateRepository: starterSeedRepository, contentRepository: starterSeedRepository, @@ -160,24 +181,14 @@ final class AppBootstrap implements AppDependencies { ids: ids, originDeviceId: originDeviceId, ), - activeWorkoutSessionUseCases: ActiveWorkoutSessionUseCases( + activeWorkoutSessionUseCases: activeWorkoutSessionUseCases, + activeExerciseStepUseCases: activeExerciseStepUseCases, + watchCompanionProjectionUseCases: watchCompanionProjectionUseCases, + watchCompanionCommandHandler: WatchCompanionCommandHandler( sessionRepository: activeSessionRepository, - templateRepository: templateRepository, - clock: clock, - ids: ids, - originDeviceId: originDeviceId, - ), - activeExerciseStepUseCases: ActiveExerciseStepUseCases( - sessionRepository: activeSessionRepository, - clock: clock, - ids: ids, - originDeviceId: originDeviceId, - ), - watchCompanionProjectionUseCases: WatchCompanionProjectionUseCases( - sessionRepository: activeSessionRepository, - clock: clock, - ids: ids, - originDeviceId: originDeviceId, + activeSessionUseCases: activeWorkoutSessionUseCases, + stepUseCases: activeExerciseStepUseCases, + projectionSource: watchCompanionProjectionUseCases, ), closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase( sessionRepository: activeSessionRepository, diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 16298fc..ba3ccb0 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -3134,6 +3134,358 @@ final class WatchCompanionProjectionUseCases implements WatchProjectionSource { } } +final class WatchCompanionCommandHandler implements WatchCommandIngress { + WatchCompanionCommandHandler({ + required ActiveSessionRepository sessionRepository, + required ActiveWorkoutSessionUseCases activeSessionUseCases, + required ActiveExerciseStepUseCases stepUseCases, + required WatchProjectionSource projectionSource, + }) : _sessionRepository = sessionRepository, + _activeSessionUseCases = activeSessionUseCases, + _stepUseCases = stepUseCases, + _projectionSource = projectionSource; + + final ActiveSessionRepository _sessionRepository; + final ActiveWorkoutSessionUseCases _activeSessionUseCases; + final ActiveExerciseStepUseCases _stepUseCases; + final WatchProjectionSource _projectionSource; + final _handledCommands = <_WatchCommandKey, WatchCommandAck>{}; + Future _tail = Future.value(); + + @override + Future dispatch(WatchCommandEnvelope command) { + final run = _tail.then( + (_) => _dispatch(command), + onError: (_) => _dispatch(command), + ); + _tail = run.then((_) {}, onError: (_) {}); + return run; + } + + Future _dispatch(WatchCommandEnvelope command) async { + final key = _WatchCommandKey(command); + final previousAck = _handledCommands[key]; + if (previousAck == WatchCommandAck.accepted || + previousAck == WatchCommandAck.acceptedNoOp) { + return WatchCommandAck.acceptedNoOp; + } + + try { + final projection = await _projectionSource.currentProjection(); + if (projection.phase == WatchSessionPhase.noActiveSession || + projection.deviceSessionId.isEmpty) { + return WatchCommandAck.rejectedNoActiveSession; + } + if (command.sessionId != projection.deviceSessionId) { + return WatchCommandAck.rejectedSessionMismatch; + } + if (command.expectedRevision != projection.revision) { + return WatchCommandAck.rejectedStaleRevision; + } + if (!_isApplicable(command.type, projection)) { + return WatchCommandAck.rejectedNotApplicable; + } + + final session = await _sessionRepository.findOpen(); + if (session == null || + session.status == ActiveWorkoutStatus.completed || + session.status == ActiveWorkoutStatus.abandoned || + session.status == ActiveWorkoutStatus.savedExit) { + return WatchCommandAck.rejectedNoActiveSession; + } + if (session.metadata.id != command.sessionId) { + return WatchCommandAck.rejectedSessionMismatch; + } + + final ack = await _route(command.type, session); + if (ack == WatchCommandAck.accepted || + ack == WatchCommandAck.acceptedNoOp) { + _handledCommands[key] = ack; + } + if (ack == WatchCommandAck.accepted) { + await _emitProjectionAfterCommand(); + } + return ack; + } on DomainException { + return WatchCommandAck.rejectedNotApplicable; + } on StateError { + return WatchCommandAck.rejectedNotApplicable; + } on Exception { + return WatchCommandAck.rejectedPhoneBusy; + } + } + + bool _isApplicable(WatchCommandType type, WatchSessionProjection projection) { + return switch (type) { + WatchCommandType.startCurrentExercise => + projection.primaryAction == WatchPrimaryAction.startCurrentExercise, + WatchCommandType.pauseSession => + projection.primaryAction == WatchPrimaryAction.pauseSession, + WatchCommandType.resumeSession => + projection.primaryAction == WatchPrimaryAction.resumeSession, + WatchCommandType.startPreparedTimedStep => + projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep, + WatchCommandType.skipCurrentStep => projection.secondaryActions.contains( + WatchSecondaryAction.skipCurrentStep, + ), + WatchCommandType.skipCurrentPassage => + projection.secondaryActions.contains( + WatchSecondaryAction.skipCurrentPassage, + ), + WatchCommandType.finishCurrentSet => projection.secondaryActions.contains( + WatchSecondaryAction.finishCurrentSet, + ), + WatchCommandType.skipCurrentSet => projection.secondaryActions.contains( + WatchSecondaryAction.skipCurrentSet, + ), + WatchCommandType.skipCurrentRest => + projection.primaryAction == WatchPrimaryAction.skipCurrentRest || + projection.secondaryActions.contains( + WatchSecondaryAction.skipCurrentRest, + ), + }; + } + + Future _route( + WatchCommandType type, + ActiveWorkoutSession session, + ) { + return switch (type) { + WatchCommandType.startCurrentExercise => _startCurrentExercise(session), + WatchCommandType.pauseSession => _pause(session), + WatchCommandType.resumeSession => _resume(session), + WatchCommandType.startPreparedTimedStep => _startPreparedTimedStep( + session, + ), + WatchCommandType.skipCurrentStep => _skipCurrentStep(session), + WatchCommandType.skipCurrentPassage => _skipCurrentPassage(session), + WatchCommandType.finishCurrentSet => _finishCurrentSet( + session, + skipped: false, + ), + WatchCommandType.skipCurrentSet => _finishCurrentSet( + session, + skipped: true, + ), + WatchCommandType.skipCurrentRest => _skipCurrentRest(session), + }; + } + + Future _startCurrentExercise( + ActiveWorkoutSession session, + ) async { + final result = await _activeSessionUseCases.startCurrentExerciseTimers( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + final changed = + result.setTimer != null || + result.scoreStopwatch != null || + result.stepProgress != null; + return changed ? WatchCommandAck.accepted : WatchCommandAck.acceptedNoOp; + } + + Future _pause(ActiveWorkoutSession session) async { + await _activeSessionUseCases.pause(session.metadata.id); + return WatchCommandAck.accepted; + } + + Future _resume(ActiveWorkoutSession session) async { + await _activeSessionUseCases.resume(session.metadata.id); + return WatchCommandAck.accepted; + } + + Future _startPreparedTimedStep( + ActiveWorkoutSession session, + ) async { + await _stepUseCases.startTimer( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return WatchCommandAck.accepted; + } + + Future _skipCurrentStep(ActiveWorkoutSession session) async { + await _stepUseCases.skipCurrentStep( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return WatchCommandAck.accepted; + } + + Future _skipCurrentPassage( + ActiveWorkoutSession session, + ) async { + await _stepUseCases.skipCurrentPassage( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return WatchCommandAck.accepted; + } + + Future _finishCurrentSet( + ActiveWorkoutSession session, { + required bool skipped, + }) async { + final snapshot = _findExerciseSnapshot( + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + ); + if (snapshot == null) { + return WatchCommandAck.rejectedNotApplicable; + } + final setTimer = skipped + ? await _activeSessionUseCases.skipSetExecutionTimers( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ) + : await _activeSessionUseCases.stopSetExecutionTimers( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + final actualScoreTimeMs = skipped + ? null + : await _scoreStopwatchMsIfNeeded(session, snapshot); + await _activeSessionUseCases.recordCurrentSetResult( + sessionId: session.metadata.id, + programSnapshotId: snapshot.programSnapshotId, + exerciseSnapshotId: snapshot.exerciseSnapshotId, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + actualTimeMs: skipped || !snapshot.timeEnabled + ? null + : setTimer?.accumulatedMs, + actualReps: skipped || !snapshot.repsEnabled ? null : snapshot.targetReps, + actualScoreTimeMs: actualScoreTimeMs, + scoreInputModeSnapshot: snapshot.scoreInputModeSnapshot, + scoreLabelSnapshot: snapshot.scoreLabelSnapshot, + scoreUnitSnapshot: snapshot.scoreUnitSnapshot, + ); + await _advanceAfterSet(session, snapshot); + return WatchCommandAck.accepted; + } + + Future _scoreStopwatchMsIfNeeded( + ActiveWorkoutSession session, + _ResolvedExerciseSnapshot snapshot, + ) async { + if (!snapshot.scoreEnabled || + snapshot.scoreInputModeSnapshot != ScoreInputMode.stopwatch) { + return null; + } + final state = await _sessionRepository.findScoreStopwatchState( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return state?.accumulatedMs; + } + + Future _advanceAfterSet( + ActiveWorkoutSession session, + _ResolvedExerciseSnapshot snapshot, + ) async { + final next = _nextPosition(session.resolvedTemplateSnapshotJson, session); + if (next == null) { + await _activeSessionUseCases.complete(session.metadata.id); + return; + } + if (snapshot.restSeconds > 0) { + await _activeSessionUseCases.startRestAfterSet( + sessionId: session.metadata.id, + afterProgramIndex: session.currentProgramIndex, + afterExerciseIndex: session.currentExerciseIndex, + afterSetIndex: session.currentSetIndex, + plannedRestSeconds: snapshot.restSeconds, + ); + return; + } + await _activeSessionUseCases.updateProgress( + sessionId: session.metadata.id, + programIndex: next.programIndex, + exerciseIndex: next.exerciseIndex, + setIndex: next.setIndex, + ); + } + + Future _skipCurrentRest(ActiveWorkoutSession session) async { + final rest = await _activeSessionUseCases.findActiveRest( + sessionId: session.metadata.id, + ); + if (rest == null) { + return WatchCommandAck.acceptedNoOp; + } + await _activeSessionUseCases.skipRest(restStateId: rest.metadata.id); + final next = _nextPositionAfter( + session.resolvedTemplateSnapshotJson, + programIndex: rest.afterProgramIndex, + exerciseIndex: rest.afterExerciseIndex, + setIndex: rest.afterSetIndex, + ); + if (next == null) { + await _activeSessionUseCases.complete(session.metadata.id); + } else { + await _activeSessionUseCases.updateProgress( + sessionId: session.metadata.id, + programIndex: next.programIndex, + exerciseIndex: next.exerciseIndex, + setIndex: next.setIndex, + ); + } + return WatchCommandAck.accepted; + } + + Future _emitProjectionAfterCommand() async { + try { + await _projectionSource.emitCurrentProjection(); + } on Exception { + // The command has already been applied; a publish failure must not turn + // the watch retry path into a second mutation. + } + } +} + +final class _WatchCommandKey { + _WatchCommandKey(WatchCommandEnvelope command) + : sessionId = command.sessionId, + expectedRevision = command.expectedRevision, + commandId = command.commandId, + type = command.type; + + final String sessionId; + final int expectedRevision; + final String commandId; + final WatchCommandType type; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is _WatchCommandKey && + sessionId == other.sessionId && + expectedRevision == other.expectedRevision && + commandId == other.commandId && + type == other.type; + } + + @override + int get hashCode => Object.hash(sessionId, expectedRevision, commandId, type); +} + final class WatchSessionProjectionProjector { const WatchSessionProjectionProjector({ required this.sessionRepository, @@ -4977,6 +5329,37 @@ _SetPositionSnapshot? _findSetSnapshot({ return null; } +_SetPositionSnapshot? _nextPosition( + String resolvedTemplateSnapshotJson, + ActiveWorkoutSession session, +) { + return _nextPositionAfter( + resolvedTemplateSnapshotJson, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); +} + +_SetPositionSnapshot? _nextPositionAfter( + String resolvedTemplateSnapshotJson, { + required int programIndex, + required int exerciseIndex, + required int setIndex, +}) { + final snapshots = _listSetSnapshots(resolvedTemplateSnapshotJson); + final currentIndex = snapshots.indexWhere( + (snapshot) => + snapshot.programIndex == programIndex && + snapshot.exerciseIndex == exerciseIndex && + snapshot.setIndex == setIndex, + ); + if (currentIndex == -1 || currentIndex + 1 >= snapshots.length) { + return null; + } + return snapshots[currentIndex + 1]; +} + _ResolvedExerciseSnapshot? _findExerciseSnapshot({ required String resolvedTemplateSnapshotJson, required int programIndex, @@ -5031,6 +5414,7 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({ scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, setsCount: exercise['setsCount'] as int? ?? 0, + restSeconds: exercise['restSecondsOverride'] as int? ?? 0, steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), autoStartNextTimedStepEffective: autoStartNextTimedStepEffective, ); @@ -5098,6 +5482,7 @@ List<_SetPositionSnapshot> _listSetSnapshots( scoreInputModeSnapshot: _scoreInputModeFromSnapshot( exercise['scoreInputModeSnapshot'], ), + restSeconds: exercise['restSecondsOverride'] as int? ?? 0, ), ); } @@ -5116,6 +5501,7 @@ final class _SetPositionSnapshot { this.scoreLabelSnapshot, this.scoreUnitSnapshot, required this.scoreInputModeSnapshot, + required this.restSeconds, }); final String programSnapshotId; @@ -5126,6 +5512,7 @@ final class _SetPositionSnapshot { final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final ScoreInputMode scoreInputModeSnapshot; + final int restSeconds; } final class _StepSequenceContext { @@ -5243,6 +5630,7 @@ Map _exerciseSnapshotsById( scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, setsCount: exercise['setsCount'] as int? ?? 0, + restSeconds: exercise['restSecondsOverride'] as int? ?? 0, steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), autoStartNextTimedStepEffective: (exercise['autoStartNextTimedStepOverride'] as bool?) ?? @@ -5272,6 +5660,7 @@ final class _ResolvedExerciseSnapshot { this.scoreLabelSnapshot, this.scoreUnitSnapshot, required this.setsCount, + required this.restSeconds, this.steps = const [], this.autoStartNextTimedStepEffective = true, }); @@ -5292,6 +5681,7 @@ final class _ResolvedExerciseSnapshot { final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final int setsCount; + final int restSeconds; final List steps; final bool autoStartNextTimedStepEffective; } diff --git a/test/application/watch_companion_command_handler_test.dart b/test/application/watch_companion_command_handler_test.dart new file mode 100644 index 0000000..07b99a9 --- /dev/null +++ b/test/application/watch_companion_command_handler_test.dart @@ -0,0 +1,686 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gametime/application/application.dart'; +import 'package:gametime/domain/domain.dart'; +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; + +void main() { + test('routes startCurrentExercise to active execution timers', () async { + final env = _env( + session: _session(timeEnabled: true), + projection: _projection( + primaryAction: WatchPrimaryAction.startCurrentExercise, + ), + ); + + final ack = await env.dispatch(WatchCommandType.startCurrentExercise); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.setTimerStates, hasLength(1)); + expect(env.projections.emitCount, 1); + }); + + test('routes pauseSession and resumeSession to session use cases', () async { + final pauseEnv = _env( + session: _session(timeEnabled: true), + projection: _projection( + phase: WatchSessionPhase.running, + primaryAction: WatchPrimaryAction.pauseSession, + ), + ); + + expect( + await pauseEnv.dispatch(WatchCommandType.pauseSession), + WatchCommandAck.accepted, + ); + expect(pauseEnv.repository.session?.status, ActiveWorkoutStatus.paused); + + final resumeEnv = _env( + session: _session(status: ActiveWorkoutStatus.paused, pausedAt: _now), + projection: _projection( + phase: WatchSessionPhase.paused, + primaryAction: WatchPrimaryAction.resumeSession, + ), + ); + + expect( + await resumeEnv.dispatch(WatchCommandType.resumeSession), + WatchCommandAck.accepted, + ); + expect(resumeEnv.repository.session?.status, ActiveWorkoutStatus.running); + }); + + test('routes startPreparedTimedStep to step timer start', () async { + final session = _session( + steps: [ + _step(), + _step(id: 'step-2', position: 1), + ], + ); + final env = _env( + session: session, + projection: _projection( + phase: WatchSessionPhase.nextTimerReady, + primaryAction: WatchPrimaryAction.startPreparedTimedStep, + ), + ); + env.repository.stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + stepId: 'step-2', + stepIndex: 1, + ); + + final ack = await env.dispatch(WatchCommandType.startPreparedTimedStep); + + expect(ack, WatchCommandAck.accepted); + expect( + env.repository.stepProgressStates['step-state']?.status, + ActiveExerciseStepProgressStatus.runningTimer, + ); + }); + + test('routes skipCurrentStep to step use case', () async { + final session = _session(steps: [_step()]); + final env = _env( + session: session, + projection: _projection( + secondaryActions: [WatchSecondaryAction.skipCurrentStep], + ), + ); + env.repository.stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + ); + + final ack = await env.dispatch(WatchCommandType.skipCurrentStep); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.stepResults, hasLength(1)); + }); + + test('routes skipCurrentPassage to step use case', () async { + final session = _session( + targetReps: 2, + steps: [ + _step(), + _step(id: 'step-2', position: 1), + ], + ); + final env = _env( + session: session, + projection: _projection( + secondaryActions: [WatchSecondaryAction.skipCurrentPassage], + ), + ); + env.repository.stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + ); + + final ack = await env.dispatch(WatchCommandType.skipCurrentPassage); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.stepResults, hasLength(2)); + }); + + test( + 'routes finishCurrentSet and advances to next set without rest', + () async { + final session = _session(timeEnabled: true, setsCount: 2); + final env = _env( + session: session, + projection: _projection( + secondaryActions: [WatchSecondaryAction.finishCurrentSet], + ), + ); + env.repository.setTimerStates['set'] = _setTimer( + sessionId: session.metadata.id, + ); + + final ack = await env.dispatch(WatchCommandType.finishCurrentSet); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.results, hasLength(1)); + expect(env.repository.session?.currentSetIndex, 1); + }, + ); + + test('routes finishCurrentSet and starts rest before next set', () async { + final session = _session(timeEnabled: true, setsCount: 2, restSeconds: 60); + final env = _env( + session: session, + projection: _projection( + secondaryActions: [WatchSecondaryAction.finishCurrentSet], + ), + ); + env.repository.setTimerStates['set'] = _setTimer( + sessionId: session.metadata.id, + ); + + final ack = await env.dispatch(WatchCommandType.finishCurrentSet); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.restStates.values.single.plannedRestSeconds, 60); + expect(env.repository.session?.currentSetIndex, 0); + }); + + test('routes skipCurrentSet and advances once on retry duplicate', () async { + final session = _session(timeEnabled: true, setsCount: 3); + final env = _env( + session: session, + projection: _projection( + secondaryActions: [WatchSecondaryAction.skipCurrentSet], + ), + ); + env.repository.setTimerStates['set'] = _setTimer( + sessionId: session.metadata.id, + ); + final command = _command(WatchCommandType.skipCurrentSet); + + final firstAck = await env.handler.dispatch(command); + final retryAck = await env.handler.dispatch(command); + + expect(firstAck, WatchCommandAck.accepted); + expect(retryAck, WatchCommandAck.acceptedNoOp); + expect(env.repository.results, hasLength(1)); + expect(env.repository.session?.currentSetIndex, 1); + }); + + test('routes skipCurrentRest to rest skip and next position', () async { + final session = _session(setsCount: 2); + final env = _env( + session: session, + projection: _projection( + phase: WatchSessionPhase.restRunning, + primaryAction: WatchPrimaryAction.pauseSession, + secondaryActions: [WatchSecondaryAction.skipCurrentRest], + ), + ); + env.repository.restStates['rest'] = ActiveRestState( + metadata: _metadata('rest'), + activeWorkoutSessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: _now, + ); + + final ack = await env.dispatch(WatchCommandType.skipCurrentRest); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.restStates['rest']?.skippedAt, isNotNull); + expect(env.repository.session?.currentSetIndex, 1); + }); + + test( + 'rejects stale revision, non applicable, missing and mismatch', + () async { + final stale = _env( + session: _session(), + projection: _projection(revision: 2), + ); + expect( + await stale.dispatch(WatchCommandType.startCurrentExercise), + WatchCommandAck.rejectedStaleRevision, + ); + + final nonApplicable = _env( + session: _session(), + projection: _projection(), + ); + expect( + await nonApplicable.dispatch(WatchCommandType.pauseSession), + WatchCommandAck.rejectedNotApplicable, + ); + + final missing = _env( + projection: _projection( + phase: WatchSessionPhase.noActiveSession, + deviceSessionId: '', + primaryAction: WatchPrimaryAction.none, + ), + ); + expect( + await missing.dispatch(WatchCommandType.startCurrentExercise), + WatchCommandAck.rejectedNoActiveSession, + ); + + final mismatch = _env( + session: _session(), + projection: _projection(deviceSessionId: 'other-session'), + ); + expect( + await mismatch.dispatch(WatchCommandType.startCurrentExercise), + WatchCommandAck.rejectedSessionMismatch, + ); + }, + ); +} + +final _now = DateTime.utc(2026, 7, 25, 12); + +_Harness _env({ + ActiveWorkoutSession? session, + required WatchSessionProjection projection, +}) { + final repository = _FakeActiveSessionRepository()..session = session; + final clock = _FakeClock(_now); + final ids = _FakeIds(); + final activeUseCases = ActiveWorkoutSessionUseCases( + sessionRepository: repository, + templateRepository: _FakeWorkoutTemplateRepository(), + clock: clock, + ids: ids, + originDeviceId: 'device-1', + ); + final stepUseCases = ActiveExerciseStepUseCases( + sessionRepository: repository, + clock: clock, + ids: ids, + originDeviceId: 'device-1', + ); + final projections = _FakeProjectionSource(projection); + return _Harness( + repository: repository, + projections: projections, + handler: WatchCompanionCommandHandler( + sessionRepository: repository, + activeSessionUseCases: activeUseCases, + stepUseCases: stepUseCases, + projectionSource: projections, + ), + ); +} + +final class _Harness { + const _Harness({ + required this.repository, + required this.projections, + required this.handler, + }); + + final _FakeActiveSessionRepository repository; + final _FakeProjectionSource projections; + final WatchCompanionCommandHandler handler; + + Future dispatch(WatchCommandType type) { + return handler.dispatch(_command(type)); + } +} + +WatchCommandEnvelope _command( + WatchCommandType type, { + String commandId = 'command-1', +}) { + return WatchCommandEnvelope( + commandId: commandId, + type: type, + sessionId: 'session-1', + expectedRevision: 1, + sentAtEpochMs: _now.millisecondsSinceEpoch, + ); +} + +WatchSessionProjection _projection({ + WatchSessionPhase phase = WatchSessionPhase.ready, + String deviceSessionId = 'session-1', + int revision = 1, + WatchPrimaryAction primaryAction = WatchPrimaryAction.startCurrentExercise, + List secondaryActions = const [ + WatchSecondaryAction.finishCurrentSet, + WatchSecondaryAction.skipCurrentSet, + ], +}) { + return WatchSessionProjection( + deviceSessionId: deviceSessionId, + revision: revision, + projectedAtEpochMs: _now.millisecondsSinceEpoch, + phase: phase, + phoneReachable: true, + seriesIndex: 1, + seriesTotal: 2, + exerciseName: 'Squat', + primaryAction: primaryAction, + secondaryActions: secondaryActions, + ); +} + +ActiveWorkoutSession _session({ + ActiveWorkoutStatus status = ActiveWorkoutStatus.running, + DateTime? pausedAt, + int currentSetIndex = 0, + int setsCount = 2, + bool timeEnabled = false, + int? targetReps = 10, + int restSeconds = 0, + List steps = const [], +}) { + final exerciseSnapshot = { + 'id': 'exercise-snapshot-1', + 'exerciseNameSnapshot': 'Squat', + 'setsCount': setsCount, + 'timeEnabled': timeEnabled, + 'repsEnabled': true, + 'scoreEnabled': false, + 'targetReps': targetReps, + 'scoreInputModeSnapshot': ScoreInputMode.manual.name, + 'restSecondsOverride': restSeconds, + 'exerciseStepsSnapshot': steps + .map((step) => step.toSnapshotJson()) + .toList(), + 'autoStartNextTimedStepSnapshot': false, + }; + return ActiveWorkoutSession( + metadata: _metadata('session-1'), + status: status, + startedAt: _now, + pausedAt: pausedAt, + lastPersistedAt: _now, + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: currentSetIndex, + resolvedTemplateSnapshotJson: jsonEncode({ + 'programs': [ + { + 'id': 'program-snapshot-1', + 'programNameSnapshot': 'Programme', + 'programSnapshotJson': jsonEncode({ + 'exercises': [exerciseSnapshot], + }), + }, + ], + }), + ); +} + +ExerciseStep _step({String id = 'step-1', int position = 0}) { + return ExerciseStep( + id: id, + position: position, + name: 'Step ${position + 1}', + type: ExerciseStepType.time, + defaultTargetValue: 1, + ); +} + +ActiveExerciseStepProgressState _stepState({ + required String sessionId, + String stepId = 'step-1', + int stepIndex = 0, +}) { + return ActiveExerciseStepProgressState( + metadata: _metadata('step-state'), + activeWorkoutSessionId: sessionId, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + currentPassageIndex: 0, + currentStepIndex: stepIndex, + currentStepSnapshotId: stepId, + status: ActiveExerciseStepProgressStatus.stoppedTimer, + accumulatedMs: 0, + lastTransitionAt: _now, + ); +} + +ActiveSetTimerState _setTimer({required String sessionId}) { + return ActiveSetTimerState( + metadata: _metadata('set'), + activeWorkoutSessionId: sessionId, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveSetTimerStatus.running, + startedAt: _now, + accumulatedMs: 0, + ); +} + +EntityMetadata _metadata(String id) { + return EntityMetadata( + id: id, + createdAt: _now, + updatedAt: _now, + originDeviceId: 'device-1', + ); +} + +final class _FakeProjectionSource implements WatchProjectionSource { + _FakeProjectionSource(this.projection); + + WatchSessionProjection projection; + var emitCount = 0; + + @override + Stream get projections => const Stream.empty(); + + @override + Future currentProjection() async => projection; + + @override + Future emitCurrentProjection() async { + emitCount += 1; + projection = WatchSessionProjection( + deviceSessionId: projection.deviceSessionId, + revision: projection.revision + 1, + projectedAtEpochMs: projection.projectedAtEpochMs, + phase: projection.phase, + phoneReachable: projection.phoneReachable, + seriesIndex: projection.seriesIndex, + seriesTotal: projection.seriesTotal, + exerciseName: projection.exerciseName, + primaryAction: projection.primaryAction, + secondaryActions: projection.secondaryActions, + ); + return projection; + } +} + +final class _FakeClock implements Clock { + const _FakeClock(this.value); + + final DateTime value; + + @override + DateTime now() => value; +} + +final class _FakeIds implements IdGenerator { + var next = 0; + + @override + String newId() { + next += 1; + return 'id-$next'; + } +} + +final class _FakeWorkoutTemplateRepository + implements WorkoutTemplateRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceComposition( + WorkoutTemplate template, + DateTime deletedAt, + ) async {} + + @override + Future save(WorkoutTemplate template) async {} + + @override + Future saveOverride(WorkoutTemplateExerciseOverride override) async {} + + @override + Future saveProgram(WorkoutTemplateProgram program) async {} +} + +final class _FakeActiveSessionRepository implements ActiveSessionRepository { + ActiveWorkoutSession? session; + final results = []; + final restStates = {}; + final setTimerStates = {}; + final scoreStopwatchStates = {}; + final stepProgressStates = {}; + final stepResults = []; + + @override + Future deleteScoreStopwatchState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async { + scoreStopwatchStates.clear(); + } + + @override + Future findById(String id) async { + return session?.metadata.id == id ? session : null; + } + + @override + Future findOpen() async => session; + + @override + Future findExerciseStepProgressState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return stepProgressStates.values.where((state) { + return state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex; + }).firstOrNull; + } + + @override + Future findRestStateById(String id) async { + return restStates[id]; + } + + @override + Future findScoreStopwatchState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return scoreStopwatchStates.values.where((state) { + return state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex; + }).firstOrNull; + } + + @override + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return setTimerStates.values.where((state) { + return state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex; + }).firstOrNull; + } + + @override + Future> listExerciseStepProgressStates( + String sessionId, + ) async { + return stepProgressStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listExerciseStepResults( + String sessionId, + ) async { + return stepResults + .where((result) => result.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listRestStates(String sessionId) async { + return restStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listScoreStopwatchStates( + String sessionId, + ) async { + return scoreStopwatchStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listSetResults(String sessionId) async { + return results + .where((result) => result.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listSetTimerStates(String sessionId) async { + return setTimerStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future save(ActiveWorkoutSession session) async { + this.session = session; + } + + @override + Future saveExerciseStepProgressState( + ActiveExerciseStepProgressState state, + ) async { + stepProgressStates[state.metadata.id] = state; + } + + @override + Future saveExerciseStepResult(ActiveExerciseStepResult result) async { + stepResults.add(result); + } + + @override + Future saveRestState(ActiveRestState restState) async { + restStates[restState.metadata.id] = restState; + } + + @override + Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async { + scoreStopwatchStates[state.metadata.id] = state; + } + + @override + Future saveSetResult(ActiveSetResult result) async { + results.add(result); + } + + @override + Future saveSetTimerState(ActiveSetTimerState state) async { + setTimerStates[state.metadata.id] = state; + } +}