feat(watch): route watch commands to existing session use cases (#91-C)

This commit is contained in:
2026-07-25 17:53:11 +02:00
parent d1c6076899
commit 6c177de6f8
3 changed files with 1104 additions and 17 deletions

View File

@ -32,6 +32,7 @@ final class AppBootstrap implements AppDependencies {
required this.activeWorkoutSessionUseCases, required this.activeWorkoutSessionUseCases,
required this.activeExerciseStepUseCases, required this.activeExerciseStepUseCases,
required this.watchCompanionProjectionUseCases, required this.watchCompanionProjectionUseCases,
required this.watchCompanionCommandHandler,
required this.closeWorkoutSessionUseCase, required this.closeWorkoutSessionUseCase,
required this.workoutHistoryUseCases, required this.workoutHistoryUseCases,
required this.progressionStatsUseCase, required this.progressionStatsUseCase,
@ -59,6 +60,7 @@ final class AppBootstrap implements AppDependencies {
@override @override
final ActiveExerciseStepUseCases activeExerciseStepUseCases; final ActiveExerciseStepUseCases activeExerciseStepUseCases;
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
final WatchCompanionCommandHandler watchCompanionCommandHandler;
@override @override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@override @override
@ -105,6 +107,25 @@ final class AppBootstrap implements AppDependencies {
final ids = LocalIdGenerator(); final ids = LocalIdGenerator();
const clock = SystemClock(); const clock = SystemClock();
const originDeviceId = 'local-device'; 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( await SeedStarterContentUseCase(
seedStateRepository: starterSeedRepository, seedStateRepository: starterSeedRepository,
contentRepository: starterSeedRepository, contentRepository: starterSeedRepository,
@ -160,24 +181,14 @@ final class AppBootstrap implements AppDependencies {
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
), ),
activeWorkoutSessionUseCases: ActiveWorkoutSessionUseCases( activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
activeExerciseStepUseCases: activeExerciseStepUseCases,
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
watchCompanionCommandHandler: WatchCompanionCommandHandler(
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,
templateRepository: templateRepository, activeSessionUseCases: activeWorkoutSessionUseCases,
clock: clock, stepUseCases: activeExerciseStepUseCases,
ids: ids, projectionSource: watchCompanionProjectionUseCases,
originDeviceId: originDeviceId,
),
activeExerciseStepUseCases: ActiveExerciseStepUseCases(
sessionRepository: activeSessionRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
),
watchCompanionProjectionUseCases: WatchCompanionProjectionUseCases(
sessionRepository: activeSessionRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
), ),
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase( closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,

View File

@ -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<void> _tail = Future<void>.value();
@override
Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) {
final run = _tail.then(
(_) => _dispatch(command),
onError: (_) => _dispatch(command),
);
_tail = run.then((_) {}, onError: (_) {});
return run;
}
Future<WatchCommandAck> _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<WatchCommandAck> _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<WatchCommandAck> _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<WatchCommandAck> _pause(ActiveWorkoutSession session) async {
await _activeSessionUseCases.pause(session.metadata.id);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _resume(ActiveWorkoutSession session) async {
await _activeSessionUseCases.resume(session.metadata.id);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _startPreparedTimedStep(
ActiveWorkoutSession session,
) async {
await _stepUseCases.startTimer(
sessionId: session.metadata.id,
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
setIndex: session.currentSetIndex,
);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _skipCurrentStep(ActiveWorkoutSession session) async {
await _stepUseCases.skipCurrentStep(
sessionId: session.metadata.id,
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
setIndex: session.currentSetIndex,
);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _skipCurrentPassage(
ActiveWorkoutSession session,
) async {
await _stepUseCases.skipCurrentPassage(
sessionId: session.metadata.id,
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
setIndex: session.currentSetIndex,
);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _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<int?> _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<void> _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<WatchCommandAck> _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<void> _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 { final class WatchSessionProjectionProjector {
const WatchSessionProjectionProjector({ const WatchSessionProjectionProjector({
required this.sessionRepository, required this.sessionRepository,
@ -4977,6 +5329,37 @@ _SetPositionSnapshot? _findSetSnapshot({
return null; 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({ _ResolvedExerciseSnapshot? _findExerciseSnapshot({
required String resolvedTemplateSnapshotJson, required String resolvedTemplateSnapshotJson,
required int programIndex, required int programIndex,
@ -5031,6 +5414,7 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
setsCount: exercise['setsCount'] as int? ?? 0, setsCount: exercise['setsCount'] as int? ?? 0,
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
autoStartNextTimedStepEffective: autoStartNextTimedStepEffective, autoStartNextTimedStepEffective: autoStartNextTimedStepEffective,
); );
@ -5098,6 +5482,7 @@ List<_SetPositionSnapshot> _listSetSnapshots(
scoreInputModeSnapshot: _scoreInputModeFromSnapshot( scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
exercise['scoreInputModeSnapshot'], exercise['scoreInputModeSnapshot'],
), ),
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
), ),
); );
} }
@ -5116,6 +5501,7 @@ final class _SetPositionSnapshot {
this.scoreLabelSnapshot, this.scoreLabelSnapshot,
this.scoreUnitSnapshot, this.scoreUnitSnapshot,
required this.scoreInputModeSnapshot, required this.scoreInputModeSnapshot,
required this.restSeconds,
}); });
final String programSnapshotId; final String programSnapshotId;
@ -5126,6 +5512,7 @@ final class _SetPositionSnapshot {
final String? scoreLabelSnapshot; final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot; final String? scoreUnitSnapshot;
final ScoreInputMode scoreInputModeSnapshot; final ScoreInputMode scoreInputModeSnapshot;
final int restSeconds;
} }
final class _StepSequenceContext { final class _StepSequenceContext {
@ -5243,6 +5630,7 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
setsCount: exercise['setsCount'] as int? ?? 0, setsCount: exercise['setsCount'] as int? ?? 0,
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
autoStartNextTimedStepEffective: autoStartNextTimedStepEffective:
(exercise['autoStartNextTimedStepOverride'] as bool?) ?? (exercise['autoStartNextTimedStepOverride'] as bool?) ??
@ -5272,6 +5660,7 @@ final class _ResolvedExerciseSnapshot {
this.scoreLabelSnapshot, this.scoreLabelSnapshot,
this.scoreUnitSnapshot, this.scoreUnitSnapshot,
required this.setsCount, required this.setsCount,
required this.restSeconds,
this.steps = const [], this.steps = const [],
this.autoStartNextTimedStepEffective = true, this.autoStartNextTimedStepEffective = true,
}); });
@ -5292,6 +5681,7 @@ final class _ResolvedExerciseSnapshot {
final String? scoreLabelSnapshot; final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot; final String? scoreUnitSnapshot;
final int setsCount; final int setsCount;
final int restSeconds;
final List<ExerciseStep> steps; final List<ExerciseStep> steps;
final bool autoStartNextTimedStepEffective; final bool autoStartNextTimedStepEffective;
} }

View File

@ -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<WatchCommandAck> 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<WatchSecondaryAction> 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<ExerciseStep> 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<WatchSessionProjection> get projections => const Stream.empty();
@override
Future<WatchSessionProjection> currentProjection() async => projection;
@override
Future<WatchSessionProjection> 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<WorkoutTemplate?> findById(String id) async => null;
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
final results = <ActiveSetResult>[];
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
scoreStopwatchStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveExerciseStepProgressState?> 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<ActiveRestState?> findRestStateById(String id) async {
return restStates[id];
}
@override
Future<ActiveScoreStopwatchState?> 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<ActiveSetTimerState?> 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<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return stepProgressStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return stepResults
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return scoreStopwatchStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {
stepProgressStates[state.metadata.id] = state;
}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
stepResults.add(result);
}
@override
Future<void> saveRestState(ActiveRestState restState) async {
restStates[restState.metadata.id] = restState;
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates[state.metadata.id] = state;
}
}