diff --git a/.ideai/tickets/20/carnet.md b/.ideai/tickets/20/carnet.md index 2ca3b28..fe89260 100644 --- a/.ideai/tickets/20/carnet.md +++ b/.ideai/tickets/20/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#20" -version: 2 +version: 3 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784325859788 +updatedAt: 1784327055822 --- diff --git a/.ideai/tickets/20/issue.md b/.ideai/tickets/20/issue.md index 7adee1a..f685114 100644 --- a/.ideai/tickets/20/issue.md +++ b/.ideai/tickets/20/issue.md @@ -2,7 +2,7 @@ id: "8355edb7-bdbd-4f93-820c-d7de0affebef" number: 20 title: "[DevFrontend] Programme : cartes exercice compactes + écran de personnalisation séparé" -status: "inProgress" +status: "closed" priority: "high" sprint: null links: [{"target":"#7","kind":"relatesTo"}] @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784325837553 -updatedAt: 1784325859788 -version: 2 +updatedAt: 1784327055822 +version: 3 --- Simplifier la liste d'exercices d'un programme (lib/presentation/program_screen.dart) : chaque ligne n'affiche par défaut que la poignée de déplacement, le nom de l'exercice, le repos affiché, et une icône "personnaliser" (tooltip "Personnaliser l'exercice") ouvrant un écran séparé "Personnaliser l'exercice" avec la configuration complète (séries, mesures, cibles, repos). Ajout d'un exercice = valeurs par défaut immédiates (3 séries, toutes les mesures disponibles actives, repos = défaut du programme) + snackbar "Exercice ajouté" avec action rapide "Personnaliser". Cf. mémoire "gametime-ux-execution-nav-and-program-simplification" point 2 pour le détail complet. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 241ae32..4ab4a6a 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -219,13 +219,13 @@ "issueRef": "#20", "path": "20", "title": "[DevFrontend] Programme : cartes exercice compactes + écran de personnalisation séparé", - "status": "inProgress", + "status": "closed", "priority": "high", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784325859788 + "updatedAt": 1784327055822 }, { "issueRef": "#21", 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); }, ); }