feat(watch): route watch commands to existing session use cases (#91-C)
This commit is contained in:
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
686
test/application/watch_companion_command_handler_test.dart
Normal file
686
test/application/watch_companion_command_handler_test.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user