fix(exercice): score par défaut à 0 et affichage des répétitions en séance (tickets #44, #45)

Autorise un score par défaut à 0 (#44) et corrige l'affichage des
répétitions à 0 pendant l'exécution au lieu de la vraie cible (#45),
sur exercise_library_screen.dart et workout_execution_screen.dart,
avec les ajustements domain/use cases associés. flutter analyze
propre (mêmes 8 infos préexistantes), 79/79 tests verts, build APK
debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 22:35:52 +02:00
parent d3b93cb131
commit 1aa2304328
6 changed files with 85 additions and 16 deletions

View File

@ -1646,8 +1646,8 @@ void _validateExerciseDefaultTargets({
} }
if (hasScoreMeasure && if (hasScoreMeasure &&
scoreInputMode == ScoreInputMode.manual && scoreInputMode == ScoreInputMode.manual &&
(defaultTargetScore == null || defaultTargetScore <= 0)) { (defaultTargetScore == null || defaultTargetScore < 0)) {
throw const DomainException('Default target score must be positive.'); throw const DomainException('Default target score must not be negative.');
} }
if (defaultTargetScore != null && if (defaultTargetScore != null &&
(!hasScoreMeasure || scoreInputMode != ScoreInputMode.manual)) { (!hasScoreMeasure || scoreInputMode != ScoreInputMode.manual)) {

View File

@ -167,7 +167,10 @@ final class Exercise {
'Default target time seconds', 'Default target time seconds',
); );
_requireNullablePositive(defaultTargetReps, 'Default target reps'); _requireNullablePositive(defaultTargetReps, 'Default target reps');
_requireNullablePositiveDouble(defaultTargetScore, 'Default target score'); _requireNullableNonNegativeDouble(
defaultTargetScore,
'Default target score',
);
_requireNullablePositive( _requireNullablePositive(
defaultTargetScoreTimeMs, defaultTargetScoreTimeMs,
'Default target score time ms', 'Default target score time ms',
@ -1035,12 +1038,6 @@ void _requireNullableNonNegativeDouble(double? value, String label) {
} }
} }
void _requireNullablePositiveDouble(double? value, String label) {
if (value != null && value <= 0) {
throw DomainException('$label must be positive.');
}
}
void _requireScoreTargetShape({ void _requireScoreTargetShape({
required bool scoreEnabled, required bool scoreEnabled,
required ScoreInputMode scoreInputMode, required ScoreInputMode scoreInputMode,

View File

@ -635,9 +635,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_scoreInputMode != ScoreInputMode.manual) { _scoreInputMode != ScoreInputMode.manual) {
return null; return null;
} }
return _positiveDoubleValidator( return _nonNegativeDoubleValidator(
value, value,
'Saisis un score supérieur à 0.', 'Saisis un score positif ou nul.',
); );
}, },
), ),
@ -914,6 +914,14 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
return null; return null;
} }
String? _nonNegativeDoubleValidator(String? value, String message) {
final number = double.tryParse(value?.trim() ?? '');
if (number == null || number < 0) {
return message;
}
return null;
}
String _optionalIntText(int? value) { String _optionalIntText(int? value) {
return value == null ? '' : '$value'; return value == null ? '' : '$value';
} }

View File

@ -72,6 +72,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_mode = _session.status == ActiveWorkoutStatus.running _mode = _session.status == ActiveWorkoutStatus.running
? WorkoutExecutionMode.active ? WorkoutExecutionMode.active
: WorkoutExecutionMode.paused; : WorkoutExecutionMode.paused;
_reps = _initialRepsFor(_exercise);
_seriesStartedAt = DateTime.now().toUtc(); _seriesStartedAt = DateTime.now().toUtc();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) { _ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {}); if (mounted) setState(() {});
@ -808,7 +809,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
Future<void> _moveTo(ExecutionPosition position) async { Future<void> _moveTo(ExecutionPosition position) async {
_scoreController.clear(); _scoreController.clear();
_reps = 0; _reps = _initialRepsFor(_plan.exerciseAt(position));
_activeRestStateId = null; _activeRestStateId = null;
_scoreStopwatch = null; _scoreStopwatch = null;
_manualScoreTimeMs = null; _manualScoreTimeMs = null;
@ -889,6 +890,11 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
), ),
); );
} }
int _initialRepsFor(ExecutionExercise exercise) {
if (!exercise.repsEnabled) return 0;
return exercise.targetReps ?? 0;
}
} }
final class SetMeasureInput extends StatelessWidget { final class SetMeasureInput extends StatelessWidget {

View File

@ -55,7 +55,7 @@ void main() {
}, },
); );
test('Exercise use case rejects non-positive default targets', () async { test('Exercise use case rejects invalid default targets', () async {
final useCase = _exerciseUseCase(_FakeExerciseRepository()); final useCase = _exerciseUseCase(_FakeExerciseRepository());
await expectLater( await expectLater(
@ -86,12 +86,33 @@ void main() {
hasScoreMeasure: true, hasScoreMeasure: true,
scoreLabel: 'Charge', scoreLabel: 'Charge',
scoreUnit: 'kg', scoreUnit: 'kg',
defaultTargetScore: 0, defaultTargetScore: -1,
), ),
throwsA(isA<DomainException>()), throwsA(isA<DomainException>()),
); );
}); });
test(
'Exercise use case accepts a zero manual score default target',
() async {
final repository = _FakeExerciseRepository();
final useCase = _exerciseUseCase(repository);
final exercise = await useCase.create(
name: 'Charge',
hasTimeMeasure: false,
hasRepsMeasure: false,
hasScoreMeasure: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
defaultTargetScore: 0,
);
expect(exercise.defaultTargetScore, 0);
expect(repository.saved.single.defaultTargetScore, 0);
},
);
test( test(
'Exercise use case accepts stopwatch score without default target', 'Exercise use case accepts stopwatch score without default target',
() async { () async {

View File

@ -43,6 +43,42 @@ void main() {
expect(find.text('00:45'), findsOneWidget); expect(find.text('00:45'), findsOneWidget);
}); });
testWidgets('les répétitions sont initialisées avec la cible', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
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(targetReps: 8),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(
_FakeActiveSessionRepository(),
clock,
),
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
expect(find.text('Répétitions'), findsOneWidget);
expect(find.text('8'), findsOneWidget);
});
testWidgets('les mesures cumulées respectent la hiérarchie visuelle', ( testWidgets('les mesures cumulées respectent la hiérarchie visuelle', (
tester, tester,
) async { ) async {
@ -618,8 +654,8 @@ void main() {
expect(find.text('2 / 3'), findsOneWidget); expect(find.text('2 / 3'), findsOneWidget);
expect(find.text('30 s'), findsOneWidget); expect(find.text('30 s'), findsOneWidget);
for (var i = 0; i < 3; i++) { for (var i = 0; i < 2; i++) {
await tester.tap(find.byIcon(Icons.add)); await tester.tap(find.byIcon(Icons.remove));
await tester.pump(); await tester.pump();
} }
await tester.enterText( await tester.enterText(
@ -637,6 +673,7 @@ void main() {
expect(repository.session?.currentSetIndex, 2); expect(repository.session?.currentSetIndex, 2);
expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget);
expect(find.text('3 / 3'), findsOneWidget); expect(find.text('3 / 3'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
}, },
); );