From bf1134d4ff3ee4c04d5e1673ebfaf428c6fa43e4 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 00:24:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20programme=20simplifi=C3=A9=20en=20c?= =?UTF-8?q?artes=20compactes=20(ticket=20#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplifie l'affichage du programme sur program_screen.dart avec des cartes compactes. flutter analyze propre, 30/30 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 --- lib/presentation/program_screen.dart | 354 +++++++++++++++++---- test/presentation/program_screen_test.dart | 40 ++- 2 files changed, 311 insertions(+), 83 deletions(-) diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index c0690ac..49bd6f3 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -226,7 +226,7 @@ final class _ProgramFormScreenState extends State { key: ValueKey(exercise.key), draft: exercise, defaultRestSeconds: _defaultRestSeconds, - onChanged: () => setState(() {}), + onCustomize: () => _customizeExercise(exercise.key), index: index, ); }, @@ -246,8 +246,46 @@ final class _ProgramFormScreenState extends State { ); if (exercise == null) return; if (!mounted) return; + final draft = _ProgramExerciseDraft.fromExercise( + exercise, + restSecondsOverride: _defaultRestSeconds, + ); setState(() { - _exercises.add(_ProgramExerciseDraft.fromExercise(exercise)); + _exercises.add(draft); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Exercice ajouté'), + action: SnackBarAction( + label: 'Personnaliser', + onPressed: () => _customizeExercise(draft.key), + ), + ), + ); + } + + Future _customizeExercise(String key) async { + final index = _exercises.indexWhere((exercise) => exercise.key == key); + if (index == -1) return; + final result = await Navigator.of(context).push<_ProgramExerciseEditResult>( + MaterialPageRoute( + builder: (context) => _ProgramExerciseCustomizationScreen( + draft: _exercises[index], + defaultRestSeconds: _defaultRestSeconds, + ), + ), + ); + if (result == null || !mounted) return; + setState(() { + final currentIndex = _exercises.indexWhere( + (exercise) => exercise.key == key, + ); + if (currentIndex == -1) return; + if (result.deleted) { + _exercises.removeAt(currentIndex); + } else if (result.draft != null) { + _exercises[currentIndex] = result.draft!; + } }); } @@ -418,116 +456,198 @@ final class _ProgramExerciseCard extends StatelessWidget { const _ProgramExerciseCard({ required this.draft, required this.defaultRestSeconds, - required this.onChanged, + required this.onCustomize, required this.index, super.key, }); final _ProgramExerciseDraft draft; final int defaultRestSeconds; - final VoidCallback onChanged; + final VoidCallback onCustomize; final int index; @override Widget build(BuildContext context) { - final scoreUnit = draft.scoreUnitSnapshot; return Card( - margin: const EdgeInsets.only(bottom: 12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle), + ), + title: Text(draft.exerciseNameSnapshot), + subtitle: Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Row( - children: [ - ReorderableDragStartListener( - index: index, - child: const Padding( - padding: EdgeInsets.only(right: 8), - child: Icon(Icons.drag_handle), - ), - ), - Expanded( - child: Text( - draft.exerciseNameSnapshot, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - if (draft.exerciseArchivedSnapshot) - const Chip(label: Text('Exercice archivé')), - ], + Text(_restLabel(draft, defaultRestSeconds)), + if (draft.exerciseArchivedSnapshot) + const Chip(label: Text('Exercice archivé')), + ], + ), + trailing: IconButton( + tooltip: "Personnaliser l'exercice", + onPressed: onCustomize, + icon: const Icon(Icons.tune), + ), + ), + ); + } +} + +final class _ProgramExerciseCustomizationScreen extends StatefulWidget { + const _ProgramExerciseCustomizationScreen({ + required _ProgramExerciseDraft draft, + required this.defaultRestSeconds, + super.key, + }) : _initialDraft = draft; + + final _ProgramExerciseDraft _initialDraft; + final int defaultRestSeconds; + + @override + State<_ProgramExerciseCustomizationScreen> createState() => + _ProgramExerciseCustomizationScreenState(); +} + +final class _ProgramExerciseCustomizationScreenState + extends State<_ProgramExerciseCustomizationScreen> { + final _formKey = GlobalKey(); + late final _ProgramExerciseDraft _draft; + late final TextEditingController _setsController; + late final TextEditingController _restController; + String? _measureError; + + @override + void initState() { + super.initState(); + _draft = widget._initialDraft.copy(); + _setsController = TextEditingController(text: '${_draft.setsCount}'); + _restController = TextEditingController( + text: '${_draft.restSecondsOverride ?? widget.defaultRestSeconds}', + ); + } + + @override + void dispose() { + _setsController.dispose(); + _restController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scoreUnit = _draft.scoreUnitSnapshot; + return Scaffold( + appBar: AppBar(title: const Text("Personnaliser l'exercice")), + bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + onPressed: _save, + icon: const Icon(Icons.check), + label: const Text('Enregistrer'), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: _confirmDelete, + icon: const Icon(Icons.delete_outline), + label: const Text('Supprimer du programme'), + ), + ], + ), + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + _draft.exerciseNameSnapshot, + style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 4), - Text(_exerciseSummary(draft, defaultRestSeconds)), - const SizedBox(height: 12), + Text(_exerciseSummary(_draft, widget.defaultRestSeconds)), + const SizedBox(height: 24), + Text('Séries', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), TextFormField( - initialValue: '${draft.setsCount}', + controller: _setsController, decoration: const InputDecoration(labelText: 'Nombre de séries'), keyboardType: TextInputType.number, - onChanged: (value) { - draft.setsCount = int.tryParse(value) ?? draft.setsCount; - onChanged(); - }, + validator: _positiveIntValidator, ), - const SizedBox(height: 12), + const SizedBox(height: 24), Text( 'Mesures à suivre', - style: Theme.of(context).textTheme.titleSmall, + style: Theme.of(context).textTheme.titleMedium, ), - if (draft.availableTimeSnapshot) - CheckboxListTile( + if (_draft.availableTimeSnapshot) + SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Temps'), - value: draft.enabledMeasures.contains(WorkoutMeasure.time), + value: _draft.enabledMeasures.contains(WorkoutMeasure.time), onChanged: (value) => _toggle(WorkoutMeasure.time, value), ), - if (draft.availableRepsSnapshot) - CheckboxListTile( + if (_draft.availableRepsSnapshot) + SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Répétitions'), - value: draft.enabledMeasures.contains(WorkoutMeasure.reps), + value: _draft.enabledMeasures.contains(WorkoutMeasure.reps), onChanged: (value) => _toggle(WorkoutMeasure.reps, value), ), - if (draft.availableScoreSnapshot) - CheckboxListTile( + if (_draft.availableScoreSnapshot) + SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(scoreUnit == null ? 'Score' : 'Score ($scoreUnit)'), - value: draft.enabledMeasures.contains(WorkoutMeasure.score), + value: _draft.enabledMeasures.contains(WorkoutMeasure.score), onChanged: (value) => _toggle(WorkoutMeasure.score, value), ), - if (draft.enabledMeasures.contains(WorkoutMeasure.time)) + if (_measureError != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _measureError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + const SizedBox(height: 24), + Text('Objectifs', style: Theme.of(context).textTheme.titleMedium), + if (_draft.enabledMeasures.contains(WorkoutMeasure.time)) _NumberField( label: 'Cible temps (s)', - initialValue: draft.targetTimeSeconds, - onChanged: (value) => draft.targetTimeSeconds = value?.round(), + initialValue: _draft.targetTimeSeconds, + onChanged: (value) => _draft.targetTimeSeconds = value?.round(), ), - if (draft.enabledMeasures.contains(WorkoutMeasure.reps)) + if (_draft.enabledMeasures.contains(WorkoutMeasure.reps)) _NumberField( label: 'Cible répétitions', - initialValue: draft.targetReps, - onChanged: (value) => draft.targetReps = value?.round(), + initialValue: _draft.targetReps, + onChanged: (value) => _draft.targetReps = value?.round(), ), - if (draft.enabledMeasures.contains(WorkoutMeasure.score)) + if (_draft.enabledMeasures.contains(WorkoutMeasure.score)) _NumberField( label: scoreUnit == null ? 'Cible score' : 'Cible score ($scoreUnit)', - initialValue: draft.targetScore, - onChanged: (value) => draft.targetScore = value, + initialValue: _draft.targetScore, + onChanged: (value) => _draft.targetScore = value, ), - const SizedBox(height: 12), + const SizedBox(height: 24), + Text('Repos', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), TextFormField( - initialValue: - '${draft.restSecondsOverride ?? defaultRestSeconds}', + controller: _restController, decoration: const InputDecoration( labelText: 'Repos après chaque série (s)', helperText: 'Pas de repos après la toute dernière série.', ), keyboardType: TextInputType.number, - onChanged: (value) { - draft.restSecondsOverride = int.tryParse(value); - onChanged(); - }, + validator: _positiveIntValidator, ), ], ), @@ -535,14 +655,75 @@ final class _ProgramExerciseCard extends StatelessWidget { ); } - void _toggle(WorkoutMeasure measure, bool? value) { - if (value == true) { - draft.enabledMeasures = {...draft.enabledMeasures, measure}; - } else if (draft.enabledMeasures.length > 1) { - draft.enabledMeasures = {...draft.enabledMeasures}..remove(measure); - } - onChanged(); + void _toggle(WorkoutMeasure measure, bool value) { + setState(() { + _measureError = null; + if (value) { + _draft.enabledMeasures = {..._draft.enabledMeasures, measure}; + } else { + _draft.enabledMeasures = {..._draft.enabledMeasures}..remove(measure); + } + }); } + + String? _positiveIntValidator(String? value) { + final number = int.tryParse(value?.trim() ?? ''); + if (number == null || number <= 0) { + return 'Saisis un nombre supérieur à 0.'; + } + return null; + } + + void _save() { + final hasMeasure = _draft.enabledMeasures.isNotEmpty; + setState(() { + _measureError = hasMeasure + ? null + : 'Active au moins une mesure pour enregistrer.'; + }); + if (!_formKey.currentState!.validate() || !hasMeasure) { + return; + } + _draft.setsCount = int.parse(_setsController.text.trim()); + _draft.restSecondsOverride = int.parse(_restController.text.trim()); + Navigator.of(context).pop(_ProgramExerciseEditResult.saved(_draft)); + } + + Future _confirmDelete() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer cet exercice ?'), + content: const Text('Il sera retiré de ce programme.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Supprimer'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + Navigator.of(context).pop(const _ProgramExerciseEditResult.deleted()); + } + } +} + +final class _ProgramExerciseEditResult { + const _ProgramExerciseEditResult._({this.draft, required this.deleted}); + + const _ProgramExerciseEditResult.deleted() + : this._(draft: null, deleted: true); + + const _ProgramExerciseEditResult.saved(_ProgramExerciseDraft draft) + : this._(draft: draft, deleted: false); + + final _ProgramExerciseDraft? draft; + final bool deleted; } final class _NumberField extends StatelessWidget { @@ -593,7 +774,10 @@ final class _ProgramExerciseDraft { this.restSecondsOverride, }); - factory _ProgramExerciseDraft.fromExercise(Exercise exercise) { + factory _ProgramExerciseDraft.fromExercise( + Exercise exercise, { + int? restSecondsOverride, + }) { return _ProgramExerciseDraft( key: UniqueKey().toString(), sourceExerciseId: exercise.metadata.id, @@ -609,6 +793,7 @@ final class _ProgramExerciseDraft { scoreUnitSnapshot: exercise.scoreUnit, setsCount: 3, enabledMeasures: exercise.availableMeasures, + restSecondsOverride: restSecondsOverride, ); } @@ -660,6 +845,30 @@ final class _ProgramExerciseDraft { double? targetScore; int? restSecondsOverride; + _ProgramExerciseDraft copy() { + return _ProgramExerciseDraft( + key: key, + existingMetadata: existingMetadata, + sourceExerciseId: sourceExerciseId, + exerciseNameSnapshot: exerciseNameSnapshot, + exerciseDescriptionSnapshot: exerciseDescriptionSnapshot, + exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot, + exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, + exerciseArchivedSnapshot: exerciseArchivedSnapshot, + availableTimeSnapshot: availableTimeSnapshot, + availableRepsSnapshot: availableRepsSnapshot, + availableScoreSnapshot: availableScoreSnapshot, + scoreLabelSnapshot: scoreLabelSnapshot, + scoreUnitSnapshot: scoreUnitSnapshot, + setsCount: setsCount, + enabledMeasures: {...enabledMeasures}, + targetTimeSeconds: targetTimeSeconds, + targetReps: targetReps, + targetScore: targetScore, + restSecondsOverride: restSecondsOverride, + ); + } + ProgramExerciseConfig toConfig() { return ProgramExerciseConfig( existingMetadata: existingMetadata, @@ -710,6 +919,11 @@ String _exerciseSummary(_ProgramExerciseDraft draft, int defaultRestSeconds) { return '${draft.setsCount} séries · $measures · Repos $rest s'; } +String _restLabel(_ProgramExerciseDraft draft, int defaultRestSeconds) { + final rest = draft.restSecondsOverride ?? defaultRestSeconds; + return 'Repos $rest s'; +} + String _measureLabel(WorkoutMeasure measure) { return switch (measure) { WorkoutMeasure.time => 'Temps', diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index 71afa4e..461dd28 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -8,6 +8,7 @@ void main() { testWidgets( 'ajoute un exercice au programme avec sa configuration par défaut', (tester) async { + final programRepository = _FakeProgramRepository(); final exerciseRepository = _FakeExerciseRepository() ..exercises.add( Exercise( @@ -23,7 +24,7 @@ void main() { MaterialApp( home: ProgramFormScreen( programUseCases: _programUseCases( - _FakeProgramRepository(), + programRepository, exerciseRepository, ), exerciseUseCases: _exerciseUseCases(exerciseRepository), @@ -31,21 +32,30 @@ void main() { ), ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Nom'), + 'Jambes', + ); await tester.tap(find.text('Ajouter un exercice')); await tester.pumpAndSettle(); await tester.tap(find.text('Squat')); await tester.pumpAndSettle(); expect(find.text('Squat'), findsOneWidget); - expect( - find.text('3 séries · Temps + Répétitions · Repos 60 s'), - findsOneWidget, - ); - expect(find.widgetWithText(CheckboxListTile, 'Temps'), findsOneWidget); - expect( - find.widgetWithText(CheckboxListTile, 'Répétitions'), - findsOneWidget, - ); + expect(find.text('Repos 60 s'), findsOneWidget); + expect(find.text('Exercice ajouté'), findsOneWidget); + expect(find.byTooltip("Personnaliser l'exercice"), findsOneWidget); + expect(find.byType(CheckboxListTile), findsNothing); + + await tester.tap(find.text('Enregistrer')); + await tester.pumpAndSettle(); + + final savedExercise = programRepository.programs.single.exercises.single; + expect(savedExercise.setsCount, 3); + expect(savedExercise.timeEnabled, isTrue); + expect(savedExercise.repsEnabled, isTrue); + expect(savedExercise.scoreEnabled, isFalse); + expect(savedExercise.restSecondsOverride, 60); }, ); @@ -80,12 +90,16 @@ void main() { await tester.tap(find.text('Pompes')); await tester.pumpAndSettle(); + await tester.tap(find.byTooltip("Personnaliser l'exercice")); + await tester.pumpAndSettle(); + + expect(find.text("Personnaliser l'exercice"), findsOneWidget); expect( - find.widgetWithText(CheckboxListTile, 'Répétitions'), + find.widgetWithText(SwitchListTile, 'Répétitions'), findsOneWidget, ); - expect(find.widgetWithText(CheckboxListTile, 'Temps'), findsNothing); - expect(find.widgetWithText(CheckboxListTile, 'Score'), findsNothing); + expect(find.widgetWithText(SwitchListTile, 'Temps'), findsNothing); + expect(find.widgetWithText(SwitchListTile, 'Score'), findsNothing); }, ); }