diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 2678ffe..250625d 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -31,6 +31,7 @@ final class AppBootstrap implements AppDependencies { required this.workoutTemplateUseCases, required this.activeWorkoutSessionUseCases, required this.activeExerciseStepUseCases, + required this.watchCompanionProjectionUseCases, required this.closeWorkoutSessionUseCase, required this.workoutHistoryUseCases, required this.progressionStatsUseCase, @@ -57,6 +58,7 @@ final class AppBootstrap implements AppDependencies { final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; @override final ActiveExerciseStepUseCases activeExerciseStepUseCases; + final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; @override @@ -171,6 +173,12 @@ final class AppBootstrap implements AppDependencies { ids: ids, originDeviceId: originDeviceId, ), + watchCompanionProjectionUseCases: WatchCompanionProjectionUseCases( + sessionRepository: activeSessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ), closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase( sessionRepository: activeSessionRepository, historyRepository: historyRepository, diff --git a/lib/application/application.dart b/lib/application/application.dart index c115371..f07094e 100644 --- a/lib/application/application.dart +++ b/lib/application/application.dart @@ -8,3 +8,4 @@ export 'ports.dart'; export 'starter_content/basket_starter_seed_v1.dart'; export 'starter_content/starter_content.dart'; export 'use_cases.dart'; +export 'watch_companion_use_cases.dart'; diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 17992da..16298fc 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -1,10 +1,14 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; + import '../domain/domain.dart'; import 'ports.dart'; import 'starter_content/basket_starter_seed_v1.dart'; import 'starter_content/starter_content.dart'; +import 'watch_companion_use_cases.dart'; const Object _useCaseUnchanged = Object(); @@ -3084,6 +3088,513 @@ final class ActiveExerciseStepProgressView { : steps[state.currentStepIndex]; } +final class WatchCompanionProjectionUseCases implements WatchProjectionSource { + WatchCompanionProjectionUseCases({ + required ActiveSessionRepository sessionRepository, + required Clock clock, + required IdGenerator ids, + required String originDeviceId, + WatchProjectionPublisher? publisher, + }) : _projector = WatchSessionProjectionProjector( + sessionRepository: sessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ), + _publisher = publisher; + + final WatchSessionProjectionProjector _projector; + final WatchProjectionPublisher? _publisher; + final _controller = StreamController.broadcast(); + WatchSessionProjection? _latestProjection; + int _revision = 0; + + @override + Stream get projections => _controller.stream; + + @override + Future currentProjection() async { + return _latestProjection ?? _projectWithCurrentRevision(); + } + + @override + Future emitCurrentProjection() async { + _revision += 1; + final projection = await _projector.project(revision: _revision); + _latestProjection = projection; + _controller.add(projection); + await _publisher?.publish(projection); + return projection; + } + + Future dispose() => _controller.close(); + + Future _projectWithCurrentRevision() { + return _projector.project(revision: _revision); + } +} + +final class WatchSessionProjectionProjector { + const WatchSessionProjectionProjector({ + required this.sessionRepository, + required this.clock, + required this.ids, + required this.originDeviceId, + }); + + final ActiveSessionRepository sessionRepository; + final Clock clock; + final IdGenerator ids; + final String originDeviceId; + + Future project({required int revision}) async { + final now = clock.now(); + final session = await sessionRepository.findOpen(); + if (session == null || + session.status == ActiveWorkoutStatus.completed || + session.status == ActiveWorkoutStatus.abandoned || + session.status == ActiveWorkoutStatus.savedExit) { + return WatchSessionProjection( + deviceSessionId: '', + revision: revision, + projectedAtEpochMs: _epochMs(now), + phase: WatchSessionPhase.noActiveSession, + phoneReachable: true, + seriesIndex: 0, + seriesTotal: 0, + exerciseName: '', + primaryAction: WatchPrimaryAction.none, + statusLabel: 'Aucune séance en cours', + ); + } + + final snapshot = _findExerciseSnapshot( + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + ); + if (snapshot == null) { + return WatchSessionProjection( + deviceSessionId: session.metadata.id, + revision: revision, + projectedAtEpochMs: _epochMs(now), + phase: WatchSessionPhase.noActiveSession, + phoneReachable: true, + seriesIndex: session.currentSetIndex + 1, + seriesTotal: 0, + exerciseName: '', + primaryAction: WatchPrimaryAction.none, + statusLabel: 'Séance indisponible', + ); + } + + final activeRest = await _findActiveRest(session.metadata.id); + final setTimer = await sessionRepository.findSetTimerState( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + final scoreStopwatch = await sessionRepository.findScoreStopwatchState( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + final stepView = await _readStepViewIfStarted(session, snapshot); + final stepState = stepView?.state; + final currentStep = stepView?.currentStep ?? _initialStep(snapshot); + final expectedPassages = _expectedPassages(snapshot); + final projectedAtEpochMs = _epochMs(now); + + final timers = [ + if (activeRest != null) _restTimerProjection(activeRest, now), + if (currentStep != null && currentStep.type == ExerciseStepType.time) + _stepTimerProjection(stepState, currentStep, now), + if (scoreStopwatch != null) + ?_scoreStopwatchTimerProjection(scoreStopwatch, now), + if (setTimer != null) ?_setTimerProjection(setTimer, now), + ]; + final dominantTimer = timers.isEmpty ? null : timers.first; + final secondaryTimers = dominantTimer == null + ? const [] + : timers.skip(1).toList(growable: false); + final phase = _phase( + session: session, + snapshot: snapshot, + activeRest: activeRest, + stepState: stepState, + currentStep: currentStep, + timers: timers, + ); + + return WatchSessionProjection( + deviceSessionId: session.metadata.id, + revision: revision, + projectedAtEpochMs: projectedAtEpochMs, + phase: phase, + phoneReachable: true, + seriesIndex: session.currentSetIndex + 1, + seriesTotal: snapshot.setsCount, + exerciseName: snapshot.exerciseNameSnapshot, + passageIndex: expectedPassages > 1 && stepState != null + ? stepState.currentPassageIndex + 1 + : null, + passageTotal: expectedPassages > 1 ? expectedPassages : null, + stepIndex: currentStep == null + ? null + : (stepState?.currentStepIndex ?? 0) + 1, + stepTotal: snapshot.steps.isEmpty ? null : snapshot.steps.length, + stepName: currentStep?.name, + dominantTimer: dominantTimer, + secondaryTimers: secondaryTimers, + primaryAction: _primaryAction(phase), + secondaryActions: _secondaryActions( + phase: phase, + snapshot: snapshot, + stepState: stepState, + expectedPassages: expectedPassages, + ), + nextExerciseName: activeRest != null + ? _restNextExerciseName( + session.resolvedTemplateSnapshotJson, + session, + activeRest, + snapshot, + ) + : phase == WatchSessionPhase.betweenSetsReady + ? _betweenSetsNextExerciseName( + session.resolvedTemplateSnapshotJson, + session, + snapshot, + ) + : null, + statusLabel: _statusLabel(phase, dominantTimer), + ); + } + + Future _readStepViewIfStarted( + ActiveWorkoutSession session, + _ResolvedExerciseSnapshot snapshot, + ) async { + if (snapshot.steps.isEmpty) { + return null; + } + final state = await sessionRepository.findExerciseStepProgressState( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + if (state == null) { + return null; + } + if (state.status == ActiveExerciseStepProgressStatus.runningTimer) { + return ActiveExerciseStepUseCases( + sessionRepository: sessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ).readProgress( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + } + return ActiveExerciseStepProgressView( + state: state, + steps: snapshot.steps, + expectedPassages: _expectedPassages(snapshot), + results: const [], + ); + } + + Future _findActiveRest(String sessionId) async { + final active = + (await sessionRepository.listRestStates(sessionId)) + .where((rest) => rest.endedAt == null && rest.skippedAt == null) + .toList() + ..sort((left, right) => right.startedAt.compareTo(left.startedAt)); + return active.isEmpty ? null : active.first; + } +} + +WatchSessionPhase _phase({ + required ActiveWorkoutSession session, + required _ResolvedExerciseSnapshot snapshot, + required ActiveRestState? activeRest, + required ActiveExerciseStepProgressState? stepState, + required ExerciseStep? currentStep, + required List timers, +}) { + if (activeRest != null) { + return activeRest.pausedAt == null + ? WatchSessionPhase.restRunning + : WatchSessionPhase.restPaused; + } + if (session.status == ActiveWorkoutStatus.paused) { + return WatchSessionPhase.paused; + } + if (_isNextTimerReady(snapshot, stepState, currentStep)) { + return WatchSessionPhase.nextTimerReady; + } + if (timers.any((timer) => timer.runState == WatchTimerRunState.running)) { + return WatchSessionPhase.running; + } + return session.currentProgramIndex == 0 && + session.currentExerciseIndex == 0 && + session.currentSetIndex == 0 + ? WatchSessionPhase.ready + : WatchSessionPhase.betweenSetsReady; +} + +WatchPrimaryAction _primaryAction(WatchSessionPhase phase) { + return switch (phase) { + WatchSessionPhase.noActiveSession => WatchPrimaryAction.none, + WatchSessionPhase.ready => WatchPrimaryAction.startCurrentExercise, + WatchSessionPhase.running => WatchPrimaryAction.pauseSession, + WatchSessionPhase.paused => WatchPrimaryAction.resumeSession, + WatchSessionPhase.nextTimerReady => + WatchPrimaryAction.startPreparedTimedStep, + WatchSessionPhase.restRunning => WatchPrimaryAction.pauseSession, + WatchSessionPhase.restPaused => WatchPrimaryAction.resumeSession, + WatchSessionPhase.betweenSetsReady => + WatchPrimaryAction.startCurrentExercise, + }; +} + +List _secondaryActions({ + required WatchSessionPhase phase, + required _ResolvedExerciseSnapshot snapshot, + required ActiveExerciseStepProgressState? stepState, + required int expectedPassages, +}) { + if (phase == WatchSessionPhase.noActiveSession) { + return const []; + } + if (phase == WatchSessionPhase.restRunning || + phase == WatchSessionPhase.restPaused) { + return const [WatchSecondaryAction.skipCurrentRest]; + } + final hasCurrentStep = + snapshot.steps.isNotEmpty && + stepState?.status != ActiveExerciseStepProgressStatus.sequenceComplete; + final hasPassageToSkip = + hasCurrentStep && + expectedPassages > 1 && + stepState != null && + stepState.currentPassageIndex < expectedPassages - 1; + return [ + if (hasCurrentStep) WatchSecondaryAction.skipCurrentStep, + if (hasPassageToSkip) WatchSecondaryAction.skipCurrentPassage, + WatchSecondaryAction.finishCurrentSet, + WatchSecondaryAction.skipCurrentSet, + ]; +} + +String _statusLabel( + WatchSessionPhase phase, + WatchTimerProjection? dominantTimer, +) { + return switch (phase) { + WatchSessionPhase.noActiveSession => 'Aucune séance en cours', + WatchSessionPhase.ready => 'Prêt à démarrer', + WatchSessionPhase.running => dominantTimer?.label ?? 'En cours', + WatchSessionPhase.paused => 'Séance en pause', + WatchSessionPhase.nextTimerReady => 'Chrono suivant prêt', + WatchSessionPhase.restRunning => 'Repos en cours', + WatchSessionPhase.restPaused => 'Repos en pause', + WatchSessionPhase.betweenSetsReady => 'Prêt pour la série suivante', + }; +} + +WatchTimerProjection _restTimerProjection(ActiveRestState rest, DateTime now) { + final paused = rest.pausedAt != null; + return WatchTimerProjection( + kind: WatchTimerKind.rest, + label: 'Repos', + displayMode: WatchTimerDisplayMode.countdown, + runState: paused ? WatchTimerRunState.paused : WatchTimerRunState.running, + referenceEpochMs: _epochMs(now), + accumulatedMs: rest.elapsedMillisecondsAt(now), + startedAtEpochMs: paused ? null : _epochMs(now), + targetMs: rest.adjustedRestSeconds * 1000, + ); +} + +WatchTimerProjection _stepTimerProjection( + ActiveExerciseStepProgressState? state, + ExerciseStep step, + DateTime now, +) { + return WatchTimerProjection( + kind: WatchTimerKind.step, + label: 'Chrono étape', + displayMode: WatchTimerDisplayMode.countdown, + runState: switch (state?.status) { + ActiveExerciseStepProgressStatus.runningTimer => + WatchTimerRunState.running, + ActiveExerciseStepProgressStatus.pausedTimer => WatchTimerRunState.paused, + _ => WatchTimerRunState.stopped, + }, + referenceEpochMs: _epochMs(now), + accumulatedMs: state?.accumulatedMs ?? 0, + startedAtEpochMs: + state?.status == ActiveExerciseStepProgressStatus.runningTimer + ? _epochMs(state!.startedAt!) + : null, + targetMs: step.defaultTargetValue * 1000, + ); +} + +WatchTimerProjection? _scoreStopwatchTimerProjection( + ActiveScoreStopwatchState state, + DateTime now, +) { + if (state.status == ActiveScoreStopwatchStatus.stopped) { + return null; + } + return WatchTimerProjection( + kind: WatchTimerKind.scoreStopwatch, + label: 'Score chrono', + displayMode: WatchTimerDisplayMode.elapsed, + runState: state.status == ActiveScoreStopwatchStatus.running + ? WatchTimerRunState.running + : WatchTimerRunState.paused, + referenceEpochMs: _epochMs(now), + accumulatedMs: state.accumulatedMs, + startedAtEpochMs: state.status == ActiveScoreStopwatchStatus.running + ? _epochMs(state.startedAt) + : null, + ); +} + +WatchTimerProjection? _setTimerProjection( + ActiveSetTimerState state, + DateTime now, +) { + if (state.status == ActiveSetTimerStatus.stopped || + state.status == ActiveSetTimerStatus.skipped) { + return null; + } + return WatchTimerProjection( + kind: WatchTimerKind.setTimer, + label: 'Temps de série', + displayMode: WatchTimerDisplayMode.elapsed, + runState: state.status == ActiveSetTimerStatus.running + ? WatchTimerRunState.running + : WatchTimerRunState.paused, + referenceEpochMs: _epochMs(now), + accumulatedMs: state.accumulatedMs, + startedAtEpochMs: + state.status == ActiveSetTimerStatus.running && state.startedAt != null + ? _epochMs(state.startedAt!) + : null, + ); +} + +ExerciseStep? _initialStep(_ResolvedExerciseSnapshot snapshot) { + return snapshot.steps.isEmpty ? null : snapshot.steps.first; +} + +bool _isNextTimerReady( + _ResolvedExerciseSnapshot snapshot, + ActiveExerciseStepProgressState? state, + ExerciseStep? currentStep, +) { + if (state == null || + currentStep == null || + state.status != ActiveExerciseStepProgressStatus.stoppedTimer || + currentStep.type != ExerciseStepType.time || + snapshot.autoStartNextTimedStepEffective) { + return false; + } + final previous = _previousStep(snapshot, state); + return previous?.type == ExerciseStepType.time; +} + +ExerciseStep? _previousStep( + _ResolvedExerciseSnapshot snapshot, + ActiveExerciseStepProgressState state, +) { + if (snapshot.steps.isEmpty) { + return null; + } + if (state.currentStepIndex > 0) { + return snapshot.steps[state.currentStepIndex - 1]; + } + if (state.currentPassageIndex > 0) { + return snapshot.steps.last; + } + return null; +} + +int _expectedPassages(_ResolvedExerciseSnapshot snapshot) { + return snapshot.repsEnabled + ? (snapshot.targetReps ?? 1).clamp(1, 1 << 31) + : 1; +} + +String? _restNextExerciseName( + String resolvedTemplateSnapshotJson, + ActiveWorkoutSession session, + ActiveRestState rest, + _ResolvedExerciseSnapshot currentSnapshot, +) { + final sessionIsAfterRestSource = + _comparePositions( + session.currentProgramIndex, + session.currentExerciseIndex, + session.currentSetIndex, + rest.afterProgramIndex, + rest.afterExerciseIndex, + rest.afterSetIndex, + ) > + 0; + if (sessionIsAfterRestSource) { + return currentSnapshot.exerciseNameSnapshot; + } + final snapshots = _exerciseSnapshotsById(resolvedTemplateSnapshotJson); + final setSnapshots = _listSetSnapshots(resolvedTemplateSnapshotJson); + final currentIndex = setSnapshots.indexWhere( + (snapshot) => + snapshot.programIndex == rest.afterProgramIndex && + snapshot.exerciseIndex == rest.afterExerciseIndex && + snapshot.setIndex == rest.afterSetIndex, + ); + if (currentIndex == -1 || currentIndex + 1 >= setSnapshots.length) { + return null; + } + final next = setSnapshots[currentIndex + 1]; + return snapshots[next.exerciseSnapshotId]?.exerciseNameSnapshot; +} + +String? _betweenSetsNextExerciseName( + String resolvedTemplateSnapshotJson, + ActiveWorkoutSession session, + _ResolvedExerciseSnapshot currentSnapshot, +) { + final setSnapshots = _listSetSnapshots(resolvedTemplateSnapshotJson); + final currentIndex = setSnapshots.indexWhere( + (snapshot) => + snapshot.programIndex == session.currentProgramIndex && + snapshot.exerciseIndex == session.currentExerciseIndex && + snapshot.setIndex == session.currentSetIndex, + ); + if (currentIndex <= 0) { + return null; + } + final previous = setSnapshots[currentIndex - 1]; + final current = setSnapshots[currentIndex]; + if (previous.exerciseSnapshotId == current.exerciseSnapshotId) { + return null; + } + return currentSnapshot.exerciseNameSnapshot; +} + +int _epochMs(DateTime value) => value.toUtc().millisecondsSinceEpoch; + final class ActiveExerciseStepUseCases { const ActiveExerciseStepUseCases({ required this.sessionRepository, @@ -4519,6 +5030,7 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({ ), scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, + setsCount: exercise['setsCount'] as int? ?? 0, steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), autoStartNextTimedStepEffective: autoStartNextTimedStepEffective, ); @@ -4730,6 +5242,7 @@ Map _exerciseSnapshotsById( ), scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, + setsCount: exercise['setsCount'] as int? ?? 0, steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), autoStartNextTimedStepEffective: (exercise['autoStartNextTimedStepOverride'] as bool?) ?? @@ -4758,6 +5271,7 @@ final class _ResolvedExerciseSnapshot { required this.scoreInputModeSnapshot, this.scoreLabelSnapshot, this.scoreUnitSnapshot, + required this.setsCount, this.steps = const [], this.autoStartNextTimedStepEffective = true, }); @@ -4777,6 +5291,7 @@ final class _ResolvedExerciseSnapshot { final ScoreInputMode scoreInputModeSnapshot; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; + final int setsCount; final List steps; final bool autoStartNextTimedStepEffective; } diff --git a/lib/application/watch_companion_use_cases.dart b/lib/application/watch_companion_use_cases.dart new file mode 100644 index 0000000..dd8803b --- /dev/null +++ b/lib/application/watch_companion_use_cases.dart @@ -0,0 +1,20 @@ +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; + +abstract interface class WatchCommandIngress { + Future dispatch(WatchCommandEnvelope command); +} + +abstract interface class WatchProjectionPublisher { + Future publish(WatchSessionProjection projection); +} + +abstract interface class WatchProjectionSource { + Stream get projections; + + Future currentProjection(); + + Future emitCurrentProjection(); +} + +abstract interface class WatchCompanionUseCases + implements WatchCommandIngress, WatchProjectionSource {} diff --git a/test/application/watch_companion_projection_test.dart b/test/application/watch_companion_projection_test.dart new file mode 100644 index 0000000..629fc6a --- /dev/null +++ b/test/application/watch_companion_projection_test.dart @@ -0,0 +1,673 @@ +import 'dart:convert'; + +import 'package:gametime/application/application.dart'; +import 'package:gametime/domain/domain.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; + +void main() { + test('projects noActiveSession without an open session', () async { + final projector = _projector(_FakeActiveSessionRepository(), _clock()); + + final projection = await projector.project(revision: 1); + + expect(projection.phase, WatchSessionPhase.noActiveSession); + expect(projection.primaryAction, WatchPrimaryAction.none); + expect(projection.phoneReachable, isTrue); + }); + + test('projects ready at the first set before timers start', () async { + final repository = _FakeActiveSessionRepository() + ..session = _session( + currentSetIndex: 0, + timeEnabled: true, + targetTimeSeconds: 20, + steps: [_step(defaultTargetValue: 20)], + ); + final projector = _projector(repository, _clock()); + + final projection = await projector.project(revision: 1); + + expect(projection.phase, WatchSessionPhase.ready); + expect(projection.seriesIndex, 1); + expect(projection.seriesTotal, 2); + expect(projection.exerciseName, 'Squat'); + expect(projection.stepIndex, 1); + expect(projection.stepTotal, 1); + expect(projection.dominantTimer?.kind, WatchTimerKind.step); + expect(projection.dominantTimer?.runState, WatchTimerRunState.stopped); + expect(projection.dominantTimer?.targetMs, 20000); + expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise); + }); + + test( + 'projects running with dominant step timer and secondary timers', + () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session( + timeEnabled: true, + scoreEnabled: true, + scoreInputMode: ScoreInputMode.stopwatch, + steps: [_step(defaultTargetValue: 30)], + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + status: ActiveExerciseStepProgressStatus.runningTimer, + startedAt: now.subtract(const Duration(seconds: 5)), + ) + ..scoreStopwatchStates['score'] = _scoreStopwatch( + sessionId: session.metadata.id, + startedAt: now.subtract(const Duration(seconds: 4)), + ) + ..setTimerStates['set'] = _setTimer( + sessionId: session.metadata.id, + startedAt: now.subtract(const Duration(seconds: 6)), + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 1); + + expect(projection.phase, WatchSessionPhase.running); + expect(projection.dominantTimer?.kind, WatchTimerKind.step); + expect(projection.dominantTimer?.accumulatedMs, 0); + expect( + projection.dominantTimer?.startedAtEpochMs, + now.subtract(const Duration(seconds: 5)).millisecondsSinceEpoch, + ); + expect(projection.secondaryTimers.map((timer) => timer.kind), [ + WatchTimerKind.scoreStopwatch, + WatchTimerKind.setTimer, + ]); + expect(projection.primaryAction, WatchPrimaryAction.pauseSession); + expect( + projection.secondaryActions, + contains(WatchSecondaryAction.finishCurrentSet), + ); + }, + ); + + test('projects paused after a running session is paused', () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session( + status: ActiveWorkoutStatus.paused, + pausedAt: now, + timeEnabled: true, + steps: [_step()], + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + status: ActiveExerciseStepProgressStatus.pausedTimer, + accumulatedMs: 5000, + lastTransitionAt: now, + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 2); + + expect(projection.phase, WatchSessionPhase.paused); + expect(projection.primaryAction, WatchPrimaryAction.resumeSession); + expect(projection.statusLabel, 'Séance en pause'); + expect(projection.dominantTimer?.runState, WatchTimerRunState.paused); + }); + + test( + 'projects nextTimerReady after an elapsed timer with chaining disabled', + () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session( + autoStartNextTimedStepSnapshot: false, + steps: [ + _step(id: 'step-1'), + _step(id: 'step-2', position: 1), + ], + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + stepId: 'step-1', + status: ActiveExerciseStepProgressStatus.runningTimer, + startedAt: now.subtract(const Duration(milliseconds: 1500)), + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 3); + + expect(projection.phase, WatchSessionPhase.nextTimerReady); + expect(projection.stepIndex, 2); + expect(projection.stepName, 'Step 2'); + expect(projection.statusLabel, 'Chrono suivant prêt'); + expect( + projection.primaryAction, + WatchPrimaryAction.startPreparedTimedStep, + ); + expect(projection.dominantTimer?.runState, WatchTimerRunState.stopped); + expect(repository.stepResults, hasLength(1)); + }, + ); + + test('projects restRunning after finishing a set with rest', () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session(currentSetIndex: 1); + final repository = _FakeActiveSessionRepository() + ..session = session + ..restStates['rest'] = ActiveRestState( + metadata: _metadata('rest'), + activeWorkoutSessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: now.subtract(const Duration(seconds: 10)), + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 4); + + expect(projection.phase, WatchSessionPhase.restRunning); + expect(projection.primaryAction, WatchPrimaryAction.pauseSession); + expect(projection.secondaryActions, [WatchSecondaryAction.skipCurrentRest]); + expect(projection.dominantTimer?.kind, WatchTimerKind.rest); + expect(projection.dominantTimer?.targetMs, 60000); + expect(projection.dominantTimer?.accumulatedMs, 10000); + expect(projection.nextExerciseName, 'Squat'); + }); + + test( + 'projects restRunning next exercise when rest precedes another exercise', + () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session( + currentExerciseIndex: 1, + currentSetIndex: 0, + secondExerciseName: 'Fentes', + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..restStates['rest'] = ActiveRestState( + metadata: _metadata('rest'), + activeWorkoutSessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 1, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: now.subtract(const Duration(seconds: 10)), + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 4); + + expect(projection.phase, WatchSessionPhase.restRunning); + expect(projection.exerciseName, 'Fentes'); + expect(projection.nextExerciseName, 'Fentes'); + }, + ); + + test('projects restPaused', () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session(status: ActiveWorkoutStatus.paused, pausedAt: now); + final repository = _FakeActiveSessionRepository() + ..session = session + ..restStates['rest'] = ActiveRestState( + metadata: _metadata('rest'), + activeWorkoutSessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: now.subtract(const Duration(seconds: 15)), + pausedAt: now, + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 5); + + expect(projection.phase, WatchSessionPhase.restPaused); + expect(projection.primaryAction, WatchPrimaryAction.resumeSession); + expect(projection.dominantTimer?.runState, WatchTimerRunState.paused); + }); + + test( + 'projects betweenSetsReady after rest ends before the next set', + () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session(currentSetIndex: 1); + final repository = _FakeActiveSessionRepository() + ..session = session + ..restStates['rest'] = ActiveRestState( + metadata: _metadata('rest'), + activeWorkoutSessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: now.subtract(const Duration(seconds: 60)), + endedAt: now, + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 6); + + expect(projection.phase, WatchSessionPhase.betweenSetsReady); + expect(projection.statusLabel, 'Prêt pour la série suivante'); + expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise); + }, + ); + + test( + 'projects nextExerciseName between sets when exercise changes', + () async { + final session = _session( + currentExerciseIndex: 1, + currentSetIndex: 0, + secondExerciseName: 'Fentes', + ); + final repository = _FakeActiveSessionRepository()..session = session; + final projector = _projector(repository, _clock()); + + final projection = await projector.project(revision: 7); + + expect(projection.phase, WatchSessionPhase.betweenSetsReady); + expect(projection.exerciseName, 'Fentes'); + expect(projection.nextExerciseName, 'Fentes'); + }, + ); + + test( + 'emits projections through stream and publisher with incremented revision', + () async { + final repository = _FakeActiveSessionRepository() + ..session = _session(steps: [_step()]); + final publisher = _FakeWatchProjectionPublisher(); + final useCases = WatchCompanionProjectionUseCases( + sessionRepository: repository, + clock: _clock(), + ids: _FakeIds(), + originDeviceId: 'device-1', + publisher: publisher, + ); + final emitted = []; + final subscription = useCases.projections.listen(emitted.add); + + final first = await useCases.emitCurrentProjection(); + final second = await useCases.emitCurrentProjection(); + await Future.delayed(Duration.zero); + + expect(first.revision, 1); + expect(second.revision, 2); + expect(emitted.map((projection) => projection.revision), [1, 2]); + expect(publisher.published.map((projection) => projection.revision), [ + 1, + 2, + ]); + + await subscription.cancel(); + await useCases.dispose(); + }, + ); +} + +WatchSessionProjectionProjector _projector( + _FakeActiveSessionRepository repository, + _FakeClock clock, +) { + return WatchSessionProjectionProjector( + sessionRepository: repository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + +_FakeClock _clock([DateTime? now]) { + return _FakeClock(now ?? DateTime.utc(2026, 7, 25, 12)); +} + +ActiveWorkoutSession _session({ + ActiveWorkoutStatus status = ActiveWorkoutStatus.running, + DateTime? pausedAt, + int currentExerciseIndex = 0, + int currentSetIndex = 0, + int setsCount = 2, + bool timeEnabled = false, + bool repsEnabled = true, + bool scoreEnabled = false, + int? targetTimeSeconds, + ScoreInputMode scoreInputMode = ScoreInputMode.manual, + bool? autoStartNextTimedStepSnapshot = true, + List steps = const [], + String? secondExerciseName, +}) { + final exerciseSnapshot = { + 'id': 'exercise-snapshot-1', + 'exerciseNameSnapshot': 'Squat', + 'setsCount': setsCount, + 'timeEnabled': timeEnabled, + 'repsEnabled': repsEnabled, + 'scoreEnabled': scoreEnabled, + 'targetTimeSeconds': targetTimeSeconds, + 'targetReps': repsEnabled ? setsCount : null, + 'scoreInputModeSnapshot': scoreInputMode.name, + 'exerciseStepsSnapshot': steps + .map((step) => step.toSnapshotJson()) + .toList(), + 'autoStartNextTimedStepSnapshot': ?autoStartNextTimedStepSnapshot, + }; + final secondExerciseSnapshot = secondExerciseName == null + ? null + : { + 'id': 'exercise-snapshot-2', + 'exerciseNameSnapshot': secondExerciseName, + 'setsCount': 1, + 'timeEnabled': false, + 'repsEnabled': true, + 'scoreEnabled': false, + 'targetReps': 1, + 'scoreInputModeSnapshot': ScoreInputMode.manual.name, + 'exerciseStepsSnapshot': const [], + 'autoStartNextTimedStepSnapshot': true, + }; + return ActiveWorkoutSession( + metadata: _metadata('session-1'), + status: status, + startedAt: DateTime.utc(2026, 7, 25, 12), + pausedAt: pausedAt, + lastPersistedAt: DateTime.utc(2026, 7, 25, 12), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: currentExerciseIndex, + currentSetIndex: currentSetIndex, + resolvedTemplateSnapshotJson: jsonEncode({ + 'programs': [ + { + 'id': 'program-snapshot-1', + 'programNameSnapshot': 'Programme', + 'programSnapshotJson': jsonEncode({ + 'exercises': [exerciseSnapshot, ?secondExerciseSnapshot], + }), + }, + ], + }), + ); +} + +ExerciseStep _step({ + String id = 'step-1', + int position = 0, + int defaultTargetValue = 1, +}) { + return ExerciseStep( + id: id, + position: position, + name: 'Step ${position + 1}', + type: ExerciseStepType.time, + defaultTargetValue: defaultTargetValue, + ); +} + +ActiveExerciseStepProgressState _stepState({ + required String sessionId, + String stepId = 'step-1', + int stepIndex = 0, + ActiveExerciseStepProgressStatus status = + ActiveExerciseStepProgressStatus.stoppedTimer, + DateTime? startedAt, + int accumulatedMs = 0, + DateTime? lastTransitionAt, +}) { + return ActiveExerciseStepProgressState( + metadata: _metadata('step-state'), + activeWorkoutSessionId: sessionId, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + currentPassageIndex: 0, + currentStepIndex: stepIndex, + currentStepSnapshotId: stepId, + status: status, + startedAt: startedAt, + accumulatedMs: accumulatedMs, + lastTransitionAt: lastTransitionAt ?? DateTime.utc(2026, 7, 25, 12), + ); +} + +ActiveScoreStopwatchState _scoreStopwatch({ + required String sessionId, + required DateTime startedAt, +}) { + return ActiveScoreStopwatchState( + metadata: _metadata('score'), + activeWorkoutSessionId: sessionId, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveScoreStopwatchStatus.running, + startedAt: startedAt, + accumulatedMs: 0, + ); +} + +ActiveSetTimerState _setTimer({ + required String sessionId, + required DateTime startedAt, +}) { + return ActiveSetTimerState( + metadata: _metadata('set'), + activeWorkoutSessionId: sessionId, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveSetTimerStatus.running, + startedAt: startedAt, + accumulatedMs: 0, + ); +} + +EntityMetadata _metadata(String id) { + return EntityMetadata( + id: id, + createdAt: DateTime.utc(2026, 7, 25, 12), + updatedAt: DateTime.utc(2026, 7, 25, 12), + originDeviceId: 'device-1', + ); +} + +final class _FakeWatchProjectionPublisher implements WatchProjectionPublisher { + final published = []; + + @override + Future publish(WatchSessionProjection projection) async { + published.add(projection); + } +} + +final class _FakeClock implements Clock { + _FakeClock(this.value); + + DateTime value; + + @override + DateTime now() => value; +} + +final class _FakeIds implements IdGenerator { + var _next = 0; + + @override + String newId() { + _next += 1; + return 'id-$_next'; + } +} + +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; + } +}