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:
@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user