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

@ -951,6 +951,195 @@ void main() {
expect(active?.metadata.id, 'latest-active');
});
test(
'startSetExecution starts set timer score stopwatch and first timed step',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
timeEnabled: true,
repsEnabled: false,
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
exerciseSteps: [_step(type: ExerciseStepType.time)],
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
final started = await useCase.startSetExecution(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(started.setTimer?.status, ActiveSetTimerStatus.running);
expect(
started.scoreStopwatch?.status,
ActiveScoreStopwatchStatus.running,
);
expect(
started.stepProgress?.status,
ActiveExerciseStepProgressStatus.runningTimer,
);
expect(repository.setTimerStates.length, 1);
expect(repository.scoreStopwatchStates.length, 1);
expect(repository.stepProgressStates.length, 1);
},
);
test('startSetExecution is idempotent for already running timers', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
timeEnabled: true,
repsEnabled: false,
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
exerciseSteps: [_step(type: ExerciseStepType.time)],
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
final first = await useCase.startSetExecution(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(seconds: 3));
final second = await useCase.startSetExecution(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(second.setTimer?.metadata.id, first.setTimer?.metadata.id);
expect(second.setTimer?.startedAt, first.setTimer?.startedAt);
expect(repository.setTimerStates.length, 1);
expect(repository.scoreStopwatchStates.length, 1);
expect(repository.stepProgressStates.length, 1);
});
test(
'startSetExecution does not start a next timed step left stopped',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
timeEnabled: true,
repsEnabled: false,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()
..session = session
..stepProgressStates['step-state'] = ActiveExerciseStepProgressState(
metadata: _metadata('step-state'),
activeWorkoutSessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
currentPassageIndex: 0,
currentStepIndex: 1,
currentStepSnapshotId: 'step-2',
status: ActiveExerciseStepProgressStatus.stoppedTimer,
accumulatedMs: 0,
lastTransitionAt: clock.now(),
);
final useCase = _activeUseCase(repository, clock);
final started = await useCase.startSetExecution(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(
started.stepProgress?.status,
ActiveExerciseStepProgressStatus.stoppedTimer,
);
expect(started.stepProgress?.currentStepIndex, 1);
expect(started.stepProgress?.startedAt, isNull);
},
);
test('pause and resume restore running persistent timers', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
timeEnabled: true,
repsEnabled: false,
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
exerciseSteps: [_step(type: ExerciseStepType.time)],
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
await useCase.startSetExecution(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startRestAfterSet(
sessionId: session.metadata.id,
afterProgramIndex: 0,
afterExerciseIndex: 0,
afterSetIndex: 0,
plannedRestSeconds: 60,
);
clock.value = clock.value.add(const Duration(seconds: 5));
await useCase.pause(session.metadata.id);
expect(
repository.setTimerStates.values.single.status,
ActiveSetTimerStatus.paused,
);
expect(repository.setTimerStates.values.single.accumulatedMs, 5000);
expect(
repository.scoreStopwatchStates.values.single.status,
ActiveScoreStopwatchStatus.paused,
);
expect(
repository.stepProgressStates.values.single.status,
ActiveExerciseStepProgressStatus.pausedTimer,
);
final pausedRest = repository.restStates.values.single;
expect(pausedRest.pausedAt, clock.now());
expect(pausedRest.remainingMillisecondsAt(clock.now()), 55000);
clock.value = clock.value.add(const Duration(minutes: 2));
await useCase.resume(session.metadata.id);
expect(
repository.setTimerStates.values.single.status,
ActiveSetTimerStatus.running,
);
expect(
repository.scoreStopwatchStates.values.single.status,
ActiveScoreStopwatchStatus.running,
);
expect(
repository.stepProgressStates.values.single.status,
ActiveExerciseStepProgressStatus.runningTimer,
);
final resumedRest = repository.restStates.values.single;
expect(resumedRest.pausedAt, isNull);
expect(resumedRest.accumulatedPausedMs, 120000);
expect(resumedRest.remainingMillisecondsAt(clock.now()), 55000);
});
test('upsertSetResultAtPosition does not move session cursor', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
@ -2830,6 +3019,7 @@ 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>[];
@ -2864,6 +3054,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return setTimerStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null,
)
.firstOrNull;
}
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
@ -2910,6 +3119,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.metadata.deletedAt == null,
)
.toList();
}
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
@ -2938,6 +3158,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
restStates[restState.metadata.id] = restState;
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates[state.metadata.id] = state;
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
scoreStopwatchStates[state.metadata.id] = state;
@ -3003,6 +3228,12 @@ ActiveWorkoutSession _sessionWithSnapshot({
required int currentSetIndex,
int setsCount = 2,
int targetReps = 10,
bool timeEnabled = false,
bool repsEnabled = true,
bool scoreEnabled = false,
int? targetTimeSeconds,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
int? targetScoreTimeMs,
List<ExerciseStep> exerciseSteps = const [],
bool? autoStartNextTimedStepSnapshot = true,
bool? autoStartNextTimedStepOverride,
@ -3012,10 +3243,13 @@ ActiveWorkoutSession _sessionWithSnapshot({
'id': 'exercise-snapshot-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': setsCount,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': targetReps,
'timeEnabled': timeEnabled,
'repsEnabled': repsEnabled,
'scoreEnabled': scoreEnabled,
'targetTimeSeconds': targetTimeSeconds,
'targetReps': repsEnabled ? targetReps : null,
'targetScoreTimeMs': targetScoreTimeMs,
'scoreInputModeSnapshot': scoreInputMode.name,
'exerciseStepsSnapshot': exerciseSteps
.map((step) => step.toSnapshotJson())
.toList(),