feat(exécution): refonte des chronos de séance et carte exercice actif (ticket #90)

Réorganise l'écran d'exécution autour d'une carte "Exercice actif" sous
le compteur SÉRIE X/Y (nom, médias, temps de série, action "Démarrer
l'exercice"). Le timer de série devient un état persistant dédié (plus
un DateTime UI volatile), pause-aware, et "Démarrer l'exercice" lance
en une action tous les chronos qui doivent démarrer en début de série
(série, première étape temps, score chrono si stopwatch).

Implémentation restée non commitée depuis sa réalisation ; QA a validé
les vérifications statiques exécutables en sandbox (dart analyze,
git diff --check). flutter analyze / flutter test restent à relancer
dans un environnement avec cache Flutter SDK accessible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 22:26:28 +02:00
parent 73cfa644c2
commit 75f52200a2
16 changed files with 4615 additions and 364 deletions

View File

@ -388,6 +388,7 @@ abstract interface class ActiveSessionRepository {
Future<ActiveWorkoutSession?> findOpen();
Future<void> save(ActiveWorkoutSession session);
Future<void> saveSetResult(ActiveSetResult result);
Future<void> saveSetTimerState(ActiveSetTimerState state);
Future<void> saveRestState(ActiveRestState restState);
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
Future<void> saveExerciseStepProgressState(
@ -408,6 +409,12 @@ abstract interface class ActiveSessionRepository {
required int exerciseIndex,
required int setIndex,
});
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
});
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
@ -415,6 +422,7 @@ abstract interface class ActiveSessionRepository {
required int setIndex,
});
Future<List<ActiveSetResult>> listSetResults(String sessionId);
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId);
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
);

View File

@ -1601,6 +1601,18 @@ final class WorkoutTemplateExerciseOverrideConfig {
final bool? autoStartNextTimedStepOverride;
}
final class SetExecutionTimerStartResult {
const SetExecutionTimerStartResult({
this.setTimer,
this.scoreStopwatch,
this.stepProgress,
});
final ActiveSetTimerState? setTimer;
final ActiveScoreStopwatchState? scoreStopwatch;
final ActiveExerciseStepProgressState? stepProgress;
}
final class ActiveWorkoutSessionUseCases {
const ActiveWorkoutSessionUseCases({
required this.sessionRepository,
@ -1697,13 +1709,25 @@ final class ActiveWorkoutSessionUseCases {
sessionId,
)).where((state) => state.status == ActiveScoreStopwatchStatus.running);
for (final state in runningStopwatches) {
final stopped = state.copyWith(
final pausedStopwatch = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveScoreStopwatchStatus.stopped,
status: ActiveScoreStopwatchStatus.paused,
accumulatedMs: state.elapsedMillisecondsAt(now),
stoppedAt: now,
);
await sessionRepository.saveScoreStopwatchState(stopped);
await sessionRepository.saveScoreStopwatchState(pausedStopwatch);
}
final runningSetTimers = (await sessionRepository.listSetTimerStates(
sessionId,
)).where((state) => state.status == ActiveSetTimerStatus.running);
for (final state in runningSetTimers) {
final pausedSetTimer = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveSetTimerStatus.paused,
startedAt: null,
accumulatedMs: state.elapsedMillisecondsAt(now),
);
await sessionRepository.saveSetTimerState(pausedSetTimer);
}
final runningStepTimers =
(await sessionRepository.listExerciseStepProgressStates(
@ -1722,6 +1746,18 @@ final class ActiveWorkoutSessionUseCases {
);
await sessionRepository.saveExerciseStepProgressState(pausedStep);
}
final activeRests = (await sessionRepository.listRestStates(sessionId))
.where(
(rest) =>
rest.endedAt == null &&
rest.skippedAt == null &&
rest.pausedAt == null,
);
for (final rest in activeRests) {
await sessionRepository.saveRestState(
rest.copyWith(metadata: rest.metadata.touch(now), pausedAt: now),
);
}
final paused = session.pause(now);
await sessionRepository.save(paused);
return paused;
@ -1729,7 +1765,68 @@ final class ActiveWorkoutSessionUseCases {
Future<ActiveWorkoutSession> resume(String sessionId) async {
final session = await _requiredSession(sessionId);
final resumed = session.resume(clock.now());
final now = clock.now();
final pausedSetTimers = (await sessionRepository.listSetTimerStates(
sessionId,
)).where((state) => state.status == ActiveSetTimerStatus.paused);
for (final state in pausedSetTimers) {
await sessionRepository.saveSetTimerState(
state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveSetTimerStatus.running,
startedAt: now,
),
);
}
final pausedStopwatches = (await sessionRepository.listScoreStopwatchStates(
sessionId,
)).where((state) => state.status == ActiveScoreStopwatchStatus.paused);
for (final state in pausedStopwatches) {
await sessionRepository.saveScoreStopwatchState(
state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveScoreStopwatchStatus.running,
startedAt: now,
stoppedAt: null,
),
);
}
final sessionPausedAt = session.pausedAt;
final pausedStepTimers =
(await sessionRepository.listExerciseStepProgressStates(
sessionId,
)).where(
(state) =>
state.status == ActiveExerciseStepProgressStatus.pausedTimer &&
sessionPausedAt != null &&
state.lastTransitionAt.isAtSameMomentAs(sessionPausedAt),
);
for (final state in pausedStepTimers) {
await sessionRepository.saveExerciseStepProgressState(
state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now,
lastTransitionAt: now,
),
);
}
final pausedRests = (await sessionRepository.listRestStates(
sessionId,
)).where((rest) => rest.pausedAt != null);
for (final rest in pausedRests) {
final pausedAt = rest.pausedAt!;
await sessionRepository.saveRestState(
rest.copyWith(
metadata: rest.metadata.touch(now),
pausedAt: null,
accumulatedPausedMs:
rest.accumulatedPausedMs +
now.difference(pausedAt).inMilliseconds,
),
);
}
final resumed = session.resume(now);
await sessionRepository.save(resumed);
return resumed;
}
@ -1738,6 +1835,182 @@ final class ActiveWorkoutSessionUseCases {
return session.elapsedActiveMillisecondsAt(clock.now());
}
Future<SetExecutionTimerStartResult> startSetExecution({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final session = await _requiredSession(sessionId);
final snapshot = _findExerciseSnapshot(
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
);
if (snapshot == null) {
throw const DomainException('Exercise position not found in session.');
}
final now = clock.now();
final setTimer = snapshot.timeEnabled
? await _startOrResumeSetTimer(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
now: now,
)
: null;
final scoreStopwatch =
snapshot.scoreEnabled &&
snapshot.scoreInputModeSnapshot == ScoreInputMode.stopwatch
? await _startScoreStopwatchForSetExecution(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
now: now,
)
: null;
final stepProgress = await _startFirstStepTimerIfNeeded(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
snapshot: snapshot,
);
return SetExecutionTimerStartResult(
setTimer: setTimer,
scoreStopwatch: scoreStopwatch,
stepProgress: stepProgress,
);
}
Future<SetExecutionTimerStartResult> startCurrentExerciseTimers({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
return startSetExecution(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
}
Future<ActiveSetTimerState?> stopSetExecutionTimers({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final now = clock.now();
final setTimer = await _finishSetTimer(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
status: ActiveSetTimerStatus.stopped,
now: now,
);
final scoreStopwatch = await sessionRepository.findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (scoreStopwatch != null &&
scoreStopwatch.status != ActiveScoreStopwatchStatus.stopped) {
await sessionRepository.saveScoreStopwatchState(
scoreStopwatch.copyWith(
metadata: scoreStopwatch.metadata.touch(now),
status: ActiveScoreStopwatchStatus.stopped,
accumulatedMs: scoreStopwatch.elapsedMillisecondsAt(now),
stoppedAt: now,
),
);
}
final stepProgress = await sessionRepository.findExerciseStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (stepProgress?.status == ActiveExerciseStepProgressStatus.runningTimer) {
await sessionRepository.saveExerciseStepProgressState(
stepProgress!.copyWith(
metadata: stepProgress.metadata.touch(now),
status: ActiveExerciseStepProgressStatus.pausedTimer,
startedAt: null,
accumulatedMs: stepProgress.elapsedMillisecondsAt(now),
lastTransitionAt: now,
),
);
}
return setTimer;
}
Future<ActiveSetTimerState?> skipSetExecutionTimers({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final now = clock.now();
final setTimer = await _finishSetTimer(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
status: ActiveSetTimerStatus.skipped,
now: now,
);
final scoreStopwatch = await sessionRepository.findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (scoreStopwatch != null &&
scoreStopwatch.status != ActiveScoreStopwatchStatus.stopped) {
await sessionRepository.saveScoreStopwatchState(
scoreStopwatch.copyWith(
metadata: scoreStopwatch.metadata.touch(now),
status: ActiveScoreStopwatchStatus.stopped,
accumulatedMs: scoreStopwatch.elapsedMillisecondsAt(now),
stoppedAt: now,
),
);
}
final stepProgress = await sessionRepository.findExerciseStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (stepProgress != null &&
stepProgress.status !=
ActiveExerciseStepProgressStatus.sequenceComplete) {
try {
await ActiveExerciseStepUseCases(
sessionRepository: sessionRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
).skipSequence(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
} on DomainException {
// A set without configured steps has no step sequence to skip.
}
}
return setTimer;
}
Future<ActiveSetResult> recordSetResult(ActiveSetResult result) async {
await sessionRepository.saveSetResult(result);
return result;
@ -1981,6 +2254,24 @@ final class ActiveWorkoutSessionUseCases {
return state.elapsedMillisecondsAt(clock.now());
}
Future<ActiveSetTimerState?> findSetTimer({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
return sessionRepository.findSetTimerState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
}
int setTimerElapsedMilliseconds(ActiveSetTimerState state) {
return state.elapsedMillisecondsAt(clock.now());
}
Future<List<SetResultPositionState>> listSetResults(String sessionId) async {
final session = await _requiredSession(sessionId);
final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson);
@ -2140,6 +2431,158 @@ final class ActiveWorkoutSessionUseCases {
return active.isEmpty ? null : active.first;
}
Future<ActiveSetTimerState> _startOrResumeSetTimer({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime now,
}) async {
final existing = await sessionRepository.findSetTimerState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (existing != null) {
if (existing.status == ActiveSetTimerStatus.running ||
existing.status == ActiveSetTimerStatus.stopped ||
existing.status == ActiveSetTimerStatus.skipped) {
return existing;
}
final resumed = existing.copyWith(
metadata: existing.metadata.touch(now),
status: ActiveSetTimerStatus.running,
startedAt: now,
stoppedAt: null,
skippedAt: null,
);
await sessionRepository.saveSetTimerState(resumed);
return resumed;
}
final state = ActiveSetTimerState(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
status: ActiveSetTimerStatus.running,
startedAt: now,
accumulatedMs: 0,
);
await sessionRepository.saveSetTimerState(state);
return state;
}
Future<ActiveSetTimerState?> _finishSetTimer({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required ActiveSetTimerStatus status,
required DateTime now,
}) async {
final existing = await sessionRepository.findSetTimerState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (existing == null) {
return null;
}
if (existing.status == status) {
return existing;
}
final finished = existing.copyWith(
metadata: existing.metadata.touch(now),
status: status,
startedAt: null,
accumulatedMs: status == ActiveSetTimerStatus.skipped
? existing.accumulatedMs
: existing.elapsedMillisecondsAt(now),
stoppedAt: status == ActiveSetTimerStatus.stopped ? now : null,
skippedAt: status == ActiveSetTimerStatus.skipped ? now : null,
);
await sessionRepository.saveSetTimerState(finished);
return finished;
}
Future<ActiveScoreStopwatchState> _startScoreStopwatchForSetExecution({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime now,
}) async {
final existing = await sessionRepository.findScoreStopwatchState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (existing != null) {
if (existing.status == ActiveScoreStopwatchStatus.running ||
existing.status == ActiveScoreStopwatchStatus.stopped) {
return existing;
}
final resumed = existing.copyWith(
metadata: existing.metadata.touch(now),
status: ActiveScoreStopwatchStatus.running,
startedAt: now,
stoppedAt: null,
);
await sessionRepository.saveScoreStopwatchState(resumed);
return resumed;
}
final state = ActiveScoreStopwatchState(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
status: ActiveScoreStopwatchStatus.running,
startedAt: now,
accumulatedMs: 0,
);
await sessionRepository.saveScoreStopwatchState(state);
return state;
}
Future<ActiveExerciseStepProgressState?> _startFirstStepTimerIfNeeded({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required _ResolvedExerciseSnapshot snapshot,
}) async {
if (snapshot.steps.isEmpty ||
snapshot.steps.first.type != ExerciseStepType.time) {
return null;
}
final existing = await sessionRepository.findExerciseStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (existing != null &&
(existing.currentPassageIndex != 0 || existing.currentStepIndex != 0)) {
return existing;
}
return ActiveExerciseStepUseCases(
sessionRepository: sessionRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
).startTimer(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
}
Future<ActiveWorkoutSession> _requiredSession(String sessionId) async {
final session = await sessionRepository.findById(sessionId);
if (session == null) {