feat(watch): phone session projection to watch (#91-B)
This commit is contained in:
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
20
lib/application/watch_companion_use_cases.dart
Normal file
20
lib/application/watch_companion_use_cases.dart
Normal 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 {}
|
||||||
673
test/application/watch_companion_projection_test.dart
Normal file
673
test/application/watch_companion_projection_test.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user