feat(exécution): clarifie Durée et Temps réalisé, renomme Durée par défaut (s)

Sépare la notion de Durée (paramètre configuré) de Temps réalisé (résultat
mesuré au chronomètre), renomme le champ « Temps par défaut (s) » en
« Durée par défaut (s) » dans la bibliothèque d'exercices, et limite
l'exécution à un seul chrono visible à la fois. Le mode Score chrono est
conservé tel quel comme mode du score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 09:37:44 +02:00
parent 6d86700348
commit e3c7ca0dab
11 changed files with 316 additions and 56 deletions

View File

@ -19,6 +19,95 @@ void main() {
);
});
test('Exercise rejects manual score defaults on stopwatch score mode', () {
expect(
() => Exercise(
metadata: _metadata('exercise-1'),
name: 'Sprint',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetScore: 10,
),
throwsA(isA<DomainException>()),
);
expect(
() => Exercise(
metadata: _metadata('exercise-2'),
name: 'Sprint',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetScore: 10,
defaultTargetScoreTimeMs: 12000,
),
throwsA(isA<DomainException>()),
);
});
test('Program exercise allows reps or time with stopwatch score', () {
final repsAndScore = ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
position: 0,
exerciseNameSnapshot: 'Sprint reps',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: true,
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: true,
targetReps: 10,
targetScoreTimeMs: 12000,
);
final timeAndScore = ProgramExercise(
metadata: _metadata('program-exercise-2'),
programId: 'program-1',
position: 1,
exerciseNameSnapshot: 'Sprint time',
availableTimeSnapshot: true,
availableRepsSnapshot: false,
availableScoreSnapshot: true,
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
setsCount: 3,
timeEnabled: true,
repsEnabled: false,
scoreEnabled: true,
targetTimeSeconds: 30,
targetScoreTimeMs: 12000,
);
expect(repsAndScore.scoreInputModeSnapshot, ScoreInputMode.stopwatch);
expect(timeAndScore.scoreInputModeSnapshot, ScoreInputMode.stopwatch);
});
test('Program exercise rejects manual score target on stopwatch score', () {
expect(
() => ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
position: 0,
exerciseNameSnapshot: 'Sprint score',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: true,
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: true,
targetReps: 10,
targetScore: 10,
targetScoreTimeMs: 12000,
),
throwsA(isA<DomainException>()),
);
});
test(
'Exercise use case rejects active measures without default targets',
() async {

View File

@ -40,23 +40,61 @@ void main() {
expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise);
});
test('projects running with duration timer before stopwatch score', () 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.setTimer,
]);
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
expect(
projection.secondaryActions,
contains(WatchSecondaryAction.finishCurrentSet),
);
});
test(
'projects running with dominant step timer and secondary timers',
'projects duration and stopwatch score with only duration timer visible',
() 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)),
@ -70,21 +108,8 @@ void main() {
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),
);
expect(projection.dominantTimer?.kind, WatchTimerKind.setTimer);
expect(projection.secondaryTimers, isEmpty);
},
);

View File

@ -1532,6 +1532,53 @@ void main() {
},
);
test(
'active set result persists duration and stopwatch score separately',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
await activeRepository.save(
ActiveWorkoutSession(
metadata: _metadata('session-chrono', now),
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
),
);
await activeRepository.saveSetResult(
ActiveSetResult(
metadata: _metadata('active-result-chrono', now),
activeWorkoutSessionId: 'session-chrono',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualTimeMs: 30000,
actualScoreTimeMs: 12000,
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
completedAt: now,
),
);
final afterAppKillRepository = local.DriftActiveSessionRepository(
database,
);
final restored = await afterAppKillRepository.listSetResults(
'session-chrono',
);
expect(restored, hasLength(1));
expect(restored.single.actualTimeMs, 30000);
expect(restored.single.actualScoreTimeMs, 12000);
expect(restored.single.scoreInputModeSnapshot, ScoreInputMode.stopwatch);
},
);
test(
'closing a session stores autonomous history rows with set snapshots',
() async {

View File

@ -107,13 +107,13 @@ void main() {
expect(find.widgetWithText(Chip, 'match'), findsOneWidget);
await tester.dragUntilVisible(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
find.byType(ListView),
const Offset(0, -200),
);
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.dragUntilVisible(
@ -195,7 +195,7 @@ void main() {
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Run');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score'));
@ -510,7 +510,7 @@ void main() {
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.tap(
@ -557,7 +557,7 @@ void main() {
'Combo',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.tap(
@ -621,7 +621,7 @@ void main() {
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Squat');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'45',
);
await tester.tap(find.text('Ajouter une image'));

View File

@ -707,12 +707,12 @@ void main() {
findsOneWidget,
);
await tester.dragUntilVisible(
find.text('Objectif de chrono'),
find.text('Objectif chrono (optionnel)'),
find.byType(ListView),
const Offset(0, -200),
);
expect(
find.widgetWithText(TextFormField, 'Objectif de chrono'),
find.widgetWithText(TextFormField, 'Objectif chrono (optionnel)'),
findsOneWidget,
);
expect(find.widgetWithText(TextFormField, 'Cible score'), findsNothing);

View File

@ -41,7 +41,7 @@ void main() {
);
expect(find.text('Squat'), findsOneWidget);
expect(find.text('Temps de série : 00:45'), findsOneWidget);
expect(find.text('Durée de série : 00:45'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget);
});
@ -141,7 +141,7 @@ void main() {
await tester.pump();
await tester.pump();
expect(find.text('Temps de série : 05:00'), findsOneWidget);
expect(find.text('Durée de série : 05:00'), findsOneWidget);
expect(find.text('1:12 · 00:45.1'), findsOneWidget);
expect(find.text('00:42.8'), findsOneWidget);
expect(find.textContaining('72000'), findsNothing);
@ -176,7 +176,7 @@ void main() {
),
);
final timeY = tester.getTopLeft(find.text('Temps')).dy;
final timeY = tester.getTopLeft(find.text('Durée')).dy;
final repsY = tester.getTopLeft(find.text('Répétitions')).dy;
final scoreY = tester.getTopLeft(find.text('Score (kg)')).dy;
@ -426,6 +426,7 @@ void main() {
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
timeEnabled: false,
scoreInputMode: ScoreInputMode.stopwatch,
),
);
@ -492,6 +493,7 @@ void main() {
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
timeEnabled: false,
scoreInputMode: ScoreInputMode.stopwatch,
),
);
@ -512,12 +514,12 @@ void main() {
await tester.tap(find.text('Terminer la série'));
await tester.pumpAndSettle();
expect(find.text('Chrono non lancé'), findsOneWidget);
expect(find.text('Aucun temps chronométré'), findsOneWidget);
expect(find.text('Tu nas pas démarré le chrono score.'), findsOneWidget);
expect(
find.text('Tu nas pas démarré le chrono de cette série.'),
find.widgetWithText(TextButton, 'Démarrer le chrono'),
findsOneWidget,
);
expect(find.widgetWithText(TextButton, 'Démarrer'), findsOneWidget);
expect(find.text('Terminer sans chrono'), findsOneWidget);
expect(repository.results, isEmpty);
});
@ -542,6 +544,7 @@ void main() {
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
timeEnabled: false,
scoreInputMode: ScoreInputMode.stopwatch,
),
);
@ -736,6 +739,65 @@ void main() {
expect(repository.results.single.actualTimeMs, 2000);
});
testWidgets(
'durée et score chrono utilisent un seul chrono visible en exécution',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
timeEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
targetTimeSeconds: 30,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
expect(find.text('Chrono score'), findsNothing);
expect(find.text('Durée de série : 00:30'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2);
await tester.pump(const Duration(seconds: 2));
expect(find.text('Chrono score'), findsNothing);
expect(find.text('Durée de série : 00:02'), findsOneWidget);
await tester.tap(find.text('Terminer la série'));
await tester.pump();
final result = repository.results.single;
expect(result.actualTimeMs, 2000);
expect(result.actualScoreTimeMs, 2000);
},
);
testWidgets(
'terminer la série 2 avec temps répétitions et score manuel avance',
(tester) async {
@ -780,7 +842,7 @@ void main() {
expect(find.text('Programme jambes'), findsOneWidget);
expect(find.text('2 / 3'), findsOneWidget);
expect(find.text('Squat'), findsOneWidget);
expect(find.text('Temps de série : 00:30'), findsOneWidget);
expect(find.text('Durée de série : 00:30'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump();