merge(develop): clarification chronos - Durée vs Temps réalisé

Intègre le lot de clarification des chronos (Durée / Temps réalisé,
renommage Durée par défaut (s), chrono unique visible à l'exécution).
QA verte sur les 6 fichiers de test couvrant use_cases, watch companion
projection, drift repositories, bibliothèque d'exercices, programme et
exécution de séance.

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

View File

@ -3559,13 +3559,22 @@ final class WatchSessionProjectionProjector {
final expectedPassages = _expectedPassages(snapshot);
final projectedAtEpochMs = _epochMs(now);
final scoreStopwatchTimer = scoreStopwatch == null
? null
: _scoreStopwatchTimerProjection(scoreStopwatch, now);
final setTimerProjection = setTimer == null
? null
: _setTimerProjection(setTimer, now);
final hideScoreStopwatchTimer = _shouldHideScoreStopwatchForSetTimer(
snapshot: snapshot,
setTimerProjection: setTimerProjection,
);
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),
?setTimerProjection,
if (!hideScoreStopwatchTimer) ?scoreStopwatchTimer,
];
final dominantTimer = timers.isEmpty ? null : timers.first;
final secondaryTimers = dominantTimer == null
@ -3846,6 +3855,16 @@ WatchTimerProjection? _setTimerProjection(
);
}
bool _shouldHideScoreStopwatchForSetTimer({
required _ResolvedExerciseSnapshot snapshot,
required WatchTimerProjection? setTimerProjection,
}) {
return snapshot.timeEnabled &&
snapshot.scoreEnabled &&
snapshot.scoreInputModeSnapshot == ScoreInputMode.stopwatch &&
setTimerProjection != null;
}
ExerciseStep? _initialStep(_ResolvedExerciseSnapshot snapshot) {
return snapshot.steps.isEmpty ? null : snapshot.steps.first;
}

View File

@ -375,6 +375,12 @@ final class Exercise {
defaultTargetScoreTimeMs,
'Default target score time ms',
);
_requireScoreTargetShape(
scoreEnabled: hasScoreMeasure,
scoreInputMode: scoreInputMode,
targetScore: defaultTargetScore,
targetScoreTimeMs: defaultTargetScoreTimeMs,
);
}
final EntityMetadata metadata;

View File

@ -628,7 +628,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
TextFormField(
controller: _defaultTimeController,
decoration: const InputDecoration(
labelText: 'Temps par défaut (s)',
labelText: 'Durée par défaut (s)',
),
keyboardType: TextInputType.number,
validator: (value) => _hasTime
@ -758,7 +758,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
TextFormField(
controller: _defaultScoreTimeController,
decoration: const InputDecoration(
labelText: 'Objectif de chrono par défaut (optionnel)',
labelText: 'Objectif chrono (optionnel)',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
@ -1107,7 +1107,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Objectif de chrono par défaut (optionnel)',
labelText: 'Objectif chrono (optionnel)',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,

View File

@ -967,7 +967,7 @@ final class _ProgramExerciseCustomizationScreenState
if (_draft.enabledMeasures.contains(WorkoutMeasure.score))
if (hasStopwatchScore)
_NumberField(
label: 'Objectif de chrono',
label: 'Objectif chrono (optionnel)',
helperText: 'Le résultat réel sera mesuré pendant la série.',
initialValue: _millisecondsToSeconds(
_draft.targetScoreTimeMs,

View File

@ -242,7 +242,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
),
),
],
if (_exercise.stopwatchScoreEnabled) ...[
if (_shouldShowScoreStopwatchInput) ...[
const SizedBox(height: 8),
SizedBox(
height: 96,
@ -265,7 +265,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_exercise.stopwatchScoreEnabled) ...[
if (_shouldShowScoreStopwatchInput) ...[
_ScoreStopwatchInput(
elapsedLabel: _formatScoreStopwatch(
Duration(milliseconds: _scoreStopwatchElapsedMs),
@ -501,6 +501,14 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
return hasGlobalTimer && !_hasSetExecutionStarted;
}
bool get _usesSetTimerForStopwatchScore {
return _exercise.timeEnabled && _exercise.stopwatchScoreEnabled;
}
bool get _shouldShowScoreStopwatchInput {
return _exercise.stopwatchScoreEnabled && !_usesSetTimerForStopwatchScore;
}
bool get _hasSetExecutionStarted {
if (_setTimer != null) {
return true;
@ -1248,18 +1256,19 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
if (!skipped &&
!finishWithoutChrono &&
_exercise.stopwatchScoreEnabled &&
!_usesSetTimerForStopwatchScore &&
_scoreStopwatch == null &&
_manualScoreTimeMs == null) {
final action = await showDialog<_MissingStopwatchAction>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Chrono non lancé'),
content: const Text("Tu nas pas démarré le chrono de cette série."),
title: const Text('Aucun temps chronométré'),
content: const Text('Tu nas pas démarré le chrono score.'),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(_MissingStopwatchAction.start),
child: const Text('Démarrer'),
child: const Text('Démarrer le chrono'),
),
FilledButton(
onPressed: () => Navigator.of(
@ -1311,8 +1320,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
if (!skipped &&
_exercise.stopwatchScoreEnabled &&
actualScoreTimeMs == null) {
final state = _scoreStopwatch;
actualScoreTimeMs = state?.accumulatedMs;
actualScoreTimeMs = _usesSetTimerForStopwatchScore
? setTimer?.accumulatedMs
: _scoreStopwatch?.accumulatedMs;
}
await widget.activeUseCases.recordCurrentSetResult(
sessionId: _session.metadata.id,
@ -1747,7 +1757,7 @@ final class SetMeasureInput extends StatelessWidget {
padding: const EdgeInsets.all(24),
child: Column(
children: [
const Text('Temps'),
const Text('Durée'),
const SizedBox(height: 8),
Text(
exercise.targetTimeSeconds == null
@ -1902,8 +1912,8 @@ final class _ExecutionContextHeader extends StatelessWidget {
const SizedBox(height: 4),
Text(
setTimerComplete
? 'Temps de série terminé : $setTimerLabel'
: 'Temps de série : $setTimerLabel',
? 'Durée de série terminée : $setTimerLabel'
: 'Durée de série : $setTimerLabel',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
),
@ -3568,7 +3578,9 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> {
if (widget.exercise.timeEnabled) ...[
TextField(
controller: _timeController,
decoration: const InputDecoration(labelText: 'Temps réalisé (s)'),
decoration: const InputDecoration(
labelText: 'Durée réalisée (s)',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
@ -3586,7 +3598,7 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> {
controller: _scoreController,
decoration: InputDecoration(
labelText: widget.exercise.stopwatchScoreEnabled
? 'Temps réalisé (chrono score, s)'
? 'Temps réalisé (s)'
: widget.exercise.scoreUnit == null
? 'Score'
: 'Score (${widget.exercise.scoreUnit})',

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();