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

@ -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<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 {
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<String, _ResolvedExerciseSnapshot> _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<ExerciseStep> steps;
final bool autoStartNextTimedStepEffective;
}