From 2f732aa1b149b2169519d720307243e77ab42b6b Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 09:08:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20configuration=20du=20score=20chrono?= =?UTF-8?q?m=C3=A9tr=C3=A9=20(ticket=20#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la configuration du score chronométré sur exercise_library_screen.dart et program_screen.dart, s'appuyant sur les fondations domain du ticket #25. flutter analyze propre, 41/41 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 --- lib/presentation/exercise_library_screen.dart | 123 ++++++++++++++---- lib/presentation/program_screen.dart | 83 ++++++++++-- .../exercise_library_screen_test.dart | 64 +++++++++ test/presentation/program_screen_test.dart | 67 ++++++++++ 4 files changed, 299 insertions(+), 38 deletions(-) diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index 07c9945..1085b84 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -246,6 +246,7 @@ final class ExerciseListTile extends StatelessWidget { (measure) => MeasureBadge( measure: measure, scoreUnit: exercise.scoreUnit, + scoreInputMode: exercise.scoreInputMode, ), ), if (hasMedia) @@ -293,6 +294,7 @@ final class _ExerciseFormScreenState extends State { var _hasTime = true; var _hasReps = false; var _hasScore = false; + var _scoreInputMode = ScoreInputMode.manual; var _saving = false; var _importingImage = false; var _importingVideo = false; @@ -314,6 +316,7 @@ final class _ExerciseFormScreenState extends State { _hasTime = exercise?.hasTimeMeasure ?? true; _hasReps = exercise?.hasRepsMeasure ?? false; _hasScore = exercise?.hasScoreMeasure ?? false; + _scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual; } @override @@ -408,27 +411,63 @@ final class _ExerciseFormScreenState extends State { ), if (_hasScore) ...[ const SizedBox(height: 12), - TextFormField( - controller: _scoreLabelController, - decoration: const InputDecoration(labelText: 'Score à saisir'), - validator: (value) { - if (!_hasScore) return null; - return value == null || value.trim().isEmpty - ? 'Le libellé du score est obligatoire.' - : null; + Text( + 'Mode de saisie', + style: Theme.of(context).textTheme.titleSmall, + ), + RadioListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Saisie libre'), + value: ScoreInputMode.manual, + groupValue: _scoreInputMode, + onChanged: (value) { + if (value == null) return; + setState(() => _scoreInputMode = value); }, ), - const SizedBox(height: 12), - TextFormField( - controller: _scoreUnitController, - decoration: const InputDecoration(labelText: 'Unité'), - validator: (value) { - if (!_hasScore) return null; - return value == null || value.trim().isEmpty - ? 'L’unité du score est obligatoire.' - : null; + RadioListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Chrono intégré'), + subtitle: const Text('Temps réalisé'), + value: ScoreInputMode.stopwatch, + groupValue: _scoreInputMode, + onChanged: (value) { + if (value == null) return; + setState(() => _scoreInputMode = value); }, ), + if (_scoreInputMode == ScoreInputMode.manual) ...[ + const SizedBox(height: 12), + TextFormField( + controller: _scoreLabelController, + decoration: const InputDecoration( + labelText: 'Score à saisir', + ), + validator: (value) { + if (!_hasScore || + _scoreInputMode != ScoreInputMode.manual) { + return null; + } + return value == null || value.trim().isEmpty + ? 'Le libellé du score est obligatoire.' + : null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _scoreUnitController, + decoration: const InputDecoration(labelText: 'Unité'), + validator: (value) { + if (!_hasScore || + _scoreInputMode != ScoreInputMode.manual) { + return null; + } + return value == null || value.trim().isEmpty + ? 'L’unité du score est obligatoire.' + : null; + }, + ), + ], ], if (_warning != null) ...[ const SizedBox(height: 16), @@ -537,6 +576,16 @@ final class _ExerciseFormScreenState extends State { } setState(() => _saving = true); + final scoreInputMode = _hasScore ? _scoreInputMode : ScoreInputMode.manual; + final scoreLabel = _hasScore + ? switch (scoreInputMode) { + ScoreInputMode.manual => _scoreLabelController.text.trim(), + ScoreInputMode.stopwatch => 'Temps réalisé', + } + : null; + final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual + ? _scoreUnitController.text.trim() + : null; try { if (exercise == null) { await widget.exerciseUseCases.create( @@ -547,8 +596,9 @@ final class _ExerciseFormScreenState extends State { hasTimeMeasure: _hasTime, hasRepsMeasure: _hasReps, hasScoreMeasure: _hasScore, - scoreLabel: _hasScore ? _scoreLabelController.text.trim() : null, - scoreUnit: _hasScore ? _scoreUnitController.text.trim() : null, + scoreInputMode: scoreInputMode, + scoreLabel: scoreLabel, + scoreUnit: scoreUnit, ); } else { await widget.exerciseUseCases.update( @@ -560,12 +610,16 @@ final class _ExerciseFormScreenState extends State { hasTimeMeasure: _hasTime, hasRepsMeasure: _hasReps, hasScoreMeasure: _hasScore, - scoreLabel: _hasScore ? _scoreLabelController.text.trim() : null, - scoreUnit: _hasScore ? _scoreUnitController.text.trim() : null, + scoreInputMode: scoreInputMode, + scoreLabel: scoreLabel, + scoreUnit: scoreUnit, ); } if (mounted) { - Navigator.of(context).pop(true); + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(true); + } } } on Exception catch (error) { _showSnackBar(error.toString()); @@ -579,7 +633,8 @@ final class _ExerciseFormScreenState extends State { bool _measuresChanged(Exercise exercise) { return exercise.hasTimeMeasure != _hasTime || exercise.hasRepsMeasure != _hasReps || - exercise.hasScoreMeasure != _hasScore; + exercise.hasScoreMeasure != _hasScore || + exercise.scoreInputMode != _scoreInputMode; } String? _optionalText(TextEditingController controller) { @@ -601,20 +656,32 @@ final class _ExerciseFormScreenState extends State { } final class MeasureBadge extends StatelessWidget { - const MeasureBadge({required this.measure, this.scoreUnit, super.key}); + const MeasureBadge({ + required this.measure, + this.scoreInputMode = ScoreInputMode.manual, + this.scoreUnit, + super.key, + }); final WorkoutMeasure measure; + final ScoreInputMode scoreInputMode; final String? scoreUnit; @override Widget build(BuildContext context) { final unit = scoreUnit?.trim(); - final label = - measure == WorkoutMeasure.score && unit != null && unit.isNotEmpty - ? '${measure.label} ($unit)' - : measure.label; + var label = measure.label; + if (measure == WorkoutMeasure.score && + scoreInputMode == ScoreInputMode.stopwatch) { + label = 'Score chrono'; + } else if (measure == WorkoutMeasure.score && + unit != null && + unit.isNotEmpty) { + label = '${measure.label} ($unit)'; + } return Semantics( label: label, + excludeSemantics: true, child: Chip(visualDensity: VisualDensity.compact, label: Text(label)), ); } diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index 49bd6f3..700e657 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -539,6 +539,13 @@ final class _ProgramExerciseCustomizationScreenState @override Widget build(BuildContext context) { final scoreUnit = _draft.scoreUnitSnapshot; + final hasStopwatchScore = + _draft.scoreInputModeSnapshot == ScoreInputMode.stopwatch; + final scoreTitle = hasStopwatchScore + ? 'Score chrono' + : scoreUnit == null + ? 'Score' + : 'Score ($scoreUnit)'; return Scaffold( appBar: AppBar(title: const Text("Personnaliser l'exercice")), bottomNavigationBar: SafeArea( @@ -603,10 +610,19 @@ final class _ProgramExerciseCustomizationScreenState if (_draft.availableScoreSnapshot) SwitchListTile( contentPadding: EdgeInsets.zero, - title: Text(scoreUnit == null ? 'Score' : 'Score ($scoreUnit)'), + title: Text(scoreTitle), value: _draft.enabledMeasures.contains(WorkoutMeasure.score), onChanged: (value) => _toggle(WorkoutMeasure.score, value), ), + if (_draft.enabledMeasures.contains(WorkoutMeasure.time) && + _draft.enabledMeasures.contains(WorkoutMeasure.score) && + hasStopwatchScore) ...[ + const SizedBox(height: 8), + const Text( + "Temps sert d'objectif de durée ; Score chrono enregistre " + 'le temps réalisé.', + ), + ], if (_measureError != null) Padding( padding: const EdgeInsets.only(top: 8), @@ -630,13 +646,31 @@ final class _ProgramExerciseCustomizationScreenState onChanged: (value) => _draft.targetReps = value?.round(), ), if (_draft.enabledMeasures.contains(WorkoutMeasure.score)) - _NumberField( - label: scoreUnit == null - ? 'Cible score' - : 'Cible score ($scoreUnit)', - initialValue: _draft.targetScore, - onChanged: (value) => _draft.targetScore = value, - ), + if (hasStopwatchScore) + _NumberField( + label: 'Objectif de chrono', + helperText: 'Le résultat réel sera mesuré pendant la série.', + initialValue: _millisecondsToSeconds( + _draft.targetScoreTimeMs, + ), + onChanged: (value) { + _draft.targetScore = null; + _draft.targetScoreTimeMs = value == null + ? null + : (value * 1000).round(); + }, + ) + else + _NumberField( + label: scoreUnit == null + ? 'Cible score' + : 'Cible score ($scoreUnit)', + initialValue: _draft.targetScore, + onChanged: (value) { + _draft.targetScore = value; + _draft.targetScoreTimeMs = null; + }, + ), const SizedBox(height: 24), Text('Repos', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), @@ -662,6 +696,10 @@ final class _ProgramExerciseCustomizationScreenState _draft.enabledMeasures = {..._draft.enabledMeasures, measure}; } else { _draft.enabledMeasures = {..._draft.enabledMeasures}..remove(measure); + if (measure == WorkoutMeasure.score) { + _draft.targetScore = null; + _draft.targetScoreTimeMs = null; + } } }); } @@ -731,11 +769,13 @@ final class _NumberField extends StatelessWidget { required this.label, required this.initialValue, required this.onChanged, + this.helperText, }); final String label; final num? initialValue; final ValueChanged onChanged; + final String? helperText; @override Widget build(BuildContext context) { @@ -743,7 +783,7 @@ final class _NumberField extends StatelessWidget { padding: const EdgeInsets.only(top: 8), child: TextFormField( initialValue: initialValue?.toString(), - decoration: InputDecoration(labelText: label), + decoration: InputDecoration(labelText: label, helperText: helperText), keyboardType: TextInputType.number, onChanged: (value) => onChanged(double.tryParse(value)), ), @@ -764,6 +804,7 @@ final class _ProgramExerciseDraft { required this.availableTimeSnapshot, required this.availableRepsSnapshot, required this.availableScoreSnapshot, + this.scoreInputModeSnapshot = ScoreInputMode.manual, this.scoreLabelSnapshot, this.scoreUnitSnapshot, required this.setsCount, @@ -771,6 +812,7 @@ final class _ProgramExerciseDraft { this.targetTimeSeconds, this.targetReps, this.targetScore, + this.targetScoreTimeMs, this.restSecondsOverride, }); @@ -789,6 +831,7 @@ final class _ProgramExerciseDraft { availableTimeSnapshot: exercise.hasTimeMeasure, availableRepsSnapshot: exercise.hasRepsMeasure, availableScoreSnapshot: exercise.hasScoreMeasure, + scoreInputModeSnapshot: exercise.scoreInputMode, scoreLabelSnapshot: exercise.scoreLabel, scoreUnitSnapshot: exercise.scoreUnit, setsCount: 3, @@ -810,6 +853,7 @@ final class _ProgramExerciseDraft { availableTimeSnapshot: exercise.availableTimeSnapshot, availableRepsSnapshot: exercise.availableRepsSnapshot, availableScoreSnapshot: exercise.availableScoreSnapshot, + scoreInputModeSnapshot: exercise.scoreInputModeSnapshot, scoreLabelSnapshot: exercise.scoreLabelSnapshot, scoreUnitSnapshot: exercise.scoreUnitSnapshot, setsCount: exercise.setsCount, @@ -821,6 +865,7 @@ final class _ProgramExerciseDraft { targetTimeSeconds: exercise.targetTimeSeconds, targetReps: exercise.targetReps, targetScore: exercise.targetScore, + targetScoreTimeMs: exercise.targetScoreTimeMs, restSecondsOverride: exercise.restSecondsOverride, ); } @@ -836,6 +881,7 @@ final class _ProgramExerciseDraft { final bool availableTimeSnapshot; final bool availableRepsSnapshot; final bool availableScoreSnapshot; + final ScoreInputMode scoreInputModeSnapshot; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; int setsCount; @@ -843,6 +889,7 @@ final class _ProgramExerciseDraft { int? targetTimeSeconds; int? targetReps; double? targetScore; + int? targetScoreTimeMs; int? restSecondsOverride; _ProgramExerciseDraft copy() { @@ -858,6 +905,7 @@ final class _ProgramExerciseDraft { availableTimeSnapshot: availableTimeSnapshot, availableRepsSnapshot: availableRepsSnapshot, availableScoreSnapshot: availableScoreSnapshot, + scoreInputModeSnapshot: scoreInputModeSnapshot, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, setsCount: setsCount, @@ -865,11 +913,14 @@ final class _ProgramExerciseDraft { targetTimeSeconds: targetTimeSeconds, targetReps: targetReps, targetScore: targetScore, + targetScoreTimeMs: targetScoreTimeMs, restSecondsOverride: restSecondsOverride, ); } ProgramExerciseConfig toConfig() { + final scoreEnabled = enabledMeasures.contains(WorkoutMeasure.score); + final stopwatchScore = scoreInputModeSnapshot == ScoreInputMode.stopwatch; return ProgramExerciseConfig( existingMetadata: existingMetadata, sourceExerciseId: sourceExerciseId, @@ -881,18 +932,26 @@ final class _ProgramExerciseDraft { availableTimeSnapshot: availableTimeSnapshot, availableRepsSnapshot: availableRepsSnapshot, availableScoreSnapshot: availableScoreSnapshot, + scoreInputModeSnapshot: scoreInputModeSnapshot, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, setsCount: setsCount, enabledMeasures: enabledMeasures, targetTimeSeconds: targetTimeSeconds, targetReps: targetReps, - targetScore: targetScore, + targetScore: scoreEnabled && !stopwatchScore ? targetScore : null, + targetScoreTimeMs: scoreEnabled && stopwatchScore + ? targetScoreTimeMs + : null, restSecondsOverride: restSecondsOverride, ); } } +double? _millisecondsToSeconds(int? milliseconds) { + return milliseconds == null ? null : milliseconds / 1000; +} + String _programSummary(Program program) { final exerciseCount = program.exercises.length; final setsCount = program.exercises.fold( @@ -907,6 +966,10 @@ String _exerciseSummary(_ProgramExerciseDraft draft, int defaultRestSeconds) { final measures = draft.enabledMeasures .map((measure) { final unit = draft.scoreUnitSnapshot?.trim(); + if (measure == WorkoutMeasure.score && + draft.scoreInputModeSnapshot == ScoreInputMode.stopwatch) { + return 'Score chrono'; + } if (measure == WorkoutMeasure.score && unit != null && unit.isNotEmpty) { diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index e7d5769..e12a6ea 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -66,6 +66,70 @@ void main() { expect(find.text('Score (kg)'), findsOneWidget); }); + testWidgets('basculer en chrono intégré masque l’unité et affiche le badge', ( + tester, + ) async { + final exerciseRepository = _FakeExerciseRepository(); + + await tester.pumpWidget( + MaterialApp( + home: ExerciseFormScreen( + exerciseUseCases: _exerciseUseCases(exerciseRepository), + mediaUseCases: _mediaUseCases(exerciseRepository), + ), + ), + ); + + await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Run'); + await tester.dragUntilVisible( + find.text('Score'), + find.byType(ListView), + const Offset(0, -200), + ); + await tester.tap(find.widgetWithText(SwitchListTile, 'Score')); + await tester.pump(); + + await tester.dragUntilVisible( + find.text('Unité'), + find.byType(ListView), + const Offset(0, -200), + ); + expect(find.widgetWithText(TextFormField, 'Unité'), findsOneWidget); + + await tester.dragUntilVisible( + find.text('Chrono intégré'), + find.byType(ListView), + const Offset(0, 200), + ); + await tester.tap(find.text('Chrono intégré')); + await tester.pump(); + + await tester.dragUntilVisible( + find.text('Temps réalisé'), + find.byType(ListView), + const Offset(0, -200), + ); + expect(find.text('Temps réalisé'), findsOneWidget); + expect(find.widgetWithText(TextFormField, 'Unité'), findsNothing); + + await tester.ensureVisible(find.text('Enregistrer')); + await tester.tap(find.text('Enregistrer')); + await tester.pumpAndSettle(); + + final saved = exerciseRepository.saved.single; + expect(saved.scoreInputMode, ScoreInputMode.stopwatch); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: ExerciseListTile(exercise: saved)), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Score chrono'), findsOneWidget); + expect(find.text('Score'), findsNothing); + }); + testWidgets( 'sélectionner une image déclenche l’import et confirme le fichier', (tester) async { diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index 461dd28..fe47706 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -102,6 +102,73 @@ void main() { expect(find.widgetWithText(SwitchListTile, 'Score'), findsNothing); }, ); + + testWidgets('affiche l’objectif de chrono pour un score en mode chrono', ( + tester, + ) async { + final exerciseRepository = _FakeExerciseRepository() + ..exercises.add( + Exercise( + metadata: _metadata('exercise-1'), + name: 'Suicides', + hasTimeMeasure: true, + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + scoreLabel: 'Temps réalisé', + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: ProgramFormScreen( + programUseCases: _programUseCases( + _FakeProgramRepository(), + exerciseRepository, + ), + exerciseUseCases: _exerciseUseCases(exerciseRepository), + ), + ), + ); + + await tester.tap(find.text('Ajouter un exercice')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Suicides')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip("Personnaliser l'exercice")); + await tester.pumpAndSettle(); + + expect(find.text('Score chrono'), findsOneWidget); + await tester.dragUntilVisible( + find.text( + "Temps sert d'objectif de durée ; Score chrono enregistre " + 'le temps réalisé.', + ), + find.byType(ListView), + const Offset(0, -200), + ); + expect( + find.text( + "Temps sert d'objectif de durée ; Score chrono enregistre " + 'le temps réalisé.', + ), + findsOneWidget, + ); + await tester.dragUntilVisible( + find.text('Objectif de chrono'), + find.byType(ListView), + const Offset(0, -200), + ); + expect( + find.widgetWithText(TextFormField, 'Objectif de chrono'), + findsOneWidget, + ); + expect(find.widgetWithText(TextFormField, 'Cible score'), findsNothing); + expect( + find.text('Le résultat réel sera mesuré pendant la série.'), + findsOneWidget, + ); + }); } ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {