feat(watch): phone session projection to watch (#91-B)

This commit is contained in:
2026-07-25 17:43:09 +02:00
parent cf68a72403
commit d1c6076899
5 changed files with 1217 additions and 0 deletions

View File

@ -31,6 +31,7 @@ final class AppBootstrap implements AppDependencies {
required this.workoutTemplateUseCases, required this.workoutTemplateUseCases,
required this.activeWorkoutSessionUseCases, required this.activeWorkoutSessionUseCases,
required this.activeExerciseStepUseCases, required this.activeExerciseStepUseCases,
required this.watchCompanionProjectionUseCases,
required this.closeWorkoutSessionUseCase, required this.closeWorkoutSessionUseCase,
required this.workoutHistoryUseCases, required this.workoutHistoryUseCases,
required this.progressionStatsUseCase, required this.progressionStatsUseCase,
@ -57,6 +58,7 @@ final class AppBootstrap implements AppDependencies {
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
@override @override
final ActiveExerciseStepUseCases activeExerciseStepUseCases; final ActiveExerciseStepUseCases activeExerciseStepUseCases;
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
@override @override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@override @override
@ -171,6 +173,12 @@ final class AppBootstrap implements AppDependencies {
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
), ),
watchCompanionProjectionUseCases: WatchCompanionProjectionUseCases(
sessionRepository: activeSessionRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
),
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase( closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,
historyRepository: historyRepository, historyRepository: historyRepository,

View File

@ -8,3 +8,4 @@ export 'ports.dart';
export 'starter_content/basket_starter_seed_v1.dart'; export 'starter_content/basket_starter_seed_v1.dart';
export 'starter_content/starter_content.dart'; export 'starter_content/starter_content.dart';
export 'use_cases.dart'; export 'use_cases.dart';
export 'watch_companion_use_cases.dart';

View File

@ -1,10 +1,14 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
import 'ports.dart'; import 'ports.dart';
import 'starter_content/basket_starter_seed_v1.dart'; import 'starter_content/basket_starter_seed_v1.dart';
import 'starter_content/starter_content.dart'; import 'starter_content/starter_content.dart';
import 'watch_companion_use_cases.dart';
const Object _useCaseUnchanged = Object(); const Object _useCaseUnchanged = Object();
@ -3084,6 +3088,513 @@ final class ActiveExerciseStepProgressView {
: steps[state.currentStepIndex]; : 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<WatchSessionProjection>.broadcast();
WatchSessionProjection? _latestProjection;
int _revision = 0;
@override
Stream<WatchSessionProjection> get projections => _controller.stream;
@override
Future<WatchSessionProjection> currentProjection() async {
return _latestProjection ?? _projectWithCurrentRevision();
}
@override
Future<WatchSessionProjection> emitCurrentProjection() async {
_revision += 1;
final projection = await _projector.project(revision: _revision);
_latestProjection = projection;
_controller.add(projection);
await _publisher?.publish(projection);
return projection;
}
Future<void> dispose() => _controller.close();
Future<WatchSessionProjection> _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<WatchSessionProjection> 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 = <WatchTimerProjection>[
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 <WatchTimerProjection>[]
: 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<ActiveExerciseStepProgressView?> _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<ActiveRestState?> _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<WatchTimerProjection> 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<WatchSecondaryAction> _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 { final class ActiveExerciseStepUseCases {
const ActiveExerciseStepUseCases({ const ActiveExerciseStepUseCases({
required this.sessionRepository, required this.sessionRepository,
@ -4519,6 +5030,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,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
autoStartNextTimedStepEffective: autoStartNextTimedStepEffective, autoStartNextTimedStepEffective: autoStartNextTimedStepEffective,
); );
@ -4730,6 +5242,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,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
autoStartNextTimedStepEffective: autoStartNextTimedStepEffective:
(exercise['autoStartNextTimedStepOverride'] as bool?) ?? (exercise['autoStartNextTimedStepOverride'] as bool?) ??
@ -4758,6 +5271,7 @@ final class _ResolvedExerciseSnapshot {
required this.scoreInputModeSnapshot, required this.scoreInputModeSnapshot,
this.scoreLabelSnapshot, this.scoreLabelSnapshot,
this.scoreUnitSnapshot, this.scoreUnitSnapshot,
required this.setsCount,
this.steps = const [], this.steps = const [],
this.autoStartNextTimedStepEffective = true, this.autoStartNextTimedStepEffective = true,
}); });
@ -4777,6 +5291,7 @@ final class _ResolvedExerciseSnapshot {
final ScoreInputMode scoreInputModeSnapshot; final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot; final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot; final String? scoreUnitSnapshot;
final int setsCount;
final List<ExerciseStep> steps; final List<ExerciseStep> steps;
final bool autoStartNextTimedStepEffective; final bool autoStartNextTimedStepEffective;
} }

View File

@ -0,0 +1,20 @@
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
abstract interface class WatchCommandIngress {
Future<WatchCommandAck> dispatch(WatchCommandEnvelope command);
}
abstract interface class WatchProjectionPublisher {
Future<void> publish(WatchSessionProjection projection);
}
abstract interface class WatchProjectionSource {
Stream<WatchSessionProjection> get projections;
Future<WatchSessionProjection> currentProjection();
Future<WatchSessionProjection> emitCurrentProjection();
}
abstract interface class WatchCompanionUseCases
implements WatchCommandIngress, WatchProjectionSource {}

View File

@ -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 = <WatchSessionProjection>[];
final subscription = useCases.projections.listen(emitted.add);
final first = await useCases.emitCurrentProjection();
final second = await useCases.emitCurrentProjection();
await Future<void>.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<ExerciseStep> 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 = <WatchSessionProjection>[];
@override
Future<void> 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 = <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;
}
}