From c2f807e58858cbf213bfd724066d45b03c7bcdcf Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 00:37:57 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20=C3=A9dition=20ponctuelle=20des?= =?UTF-8?q?=20s=C3=A9ries=20en=20s=C3=A9ance=20(ticket=20#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose l'édition ponctuelle des séries (backend du ticket #21) sur workout_execution_screen.dart, sans impacter le modèle de séance. flutter analyze propre, 33/33 tests verts, build APK debug validé. Clôture le lot navigation libre / programme simplifié (tickets #20 à #23). Co-Authored-By: Claude Opus 4.8 --- .../workout_execution_screen.dart | 317 +++++++++++++++++- .../workout_execution_screen_test.dart | 130 ++++++- 2 files changed, 441 insertions(+), 6 deletions(-) diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index 702b46d..78d8f37 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -323,10 +323,12 @@ final class _WorkoutExecutionScreenState extends State { builder: (context) => FractionallySizedBox( heightFactor: 1, child: _WorkoutPlanSheet( + sessionId: _session.metadata.id, plan: _plan, currentPosition: _position, setStates: setStates, activeRest: activeRest, + activeUseCases: widget.activeUseCases, ), ), ); @@ -609,15 +611,19 @@ final class SetMeasureInput extends StatelessWidget { final class _WorkoutPlanSheet extends StatefulWidget { const _WorkoutPlanSheet({ + required this.sessionId, required this.plan, required this.currentPosition, required this.setStates, + required this.activeUseCases, this.activeRest, }); + final String sessionId; final WorkoutExecutionPlan plan; final ExecutionPosition currentPosition; final List setStates; + final ActiveWorkoutSessionUseCases activeUseCases; final ActiveRestState? activeRest; @override @@ -627,10 +633,12 @@ final class _WorkoutPlanSheet extends StatefulWidget { final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> { Timer? _timer; late int _remainingRestSeconds; + late List _setStates; @override void initState() { super.initState(); + _setStates = widget.setStates; _remainingRestSeconds = _remainingSecondsForRest(widget.activeRest); if (widget.activeRest != null && _remainingRestSeconds > 0) { _timer = Timer.periodic(const Duration(seconds: 1), (_) { @@ -653,7 +661,7 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> { @override Widget build(BuildContext context) { final statesByPosition = { - for (final state in widget.setStates) _stateKey(state): state, + for (final state in _setStates) _stateKey(state): state, }; return Scaffold( appBar: AppBar( @@ -715,12 +723,20 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> { setIndex: setIndex, ), currentPosition: widget.currentPosition, + exercise: widget + .plan + .programs[programIndex] + .exercises[exerciseIndex], state: statesByPosition[_positionKey( programIndex, exerciseIndex, setIndex, )], + onUpdated: _refreshAfterEdit, + activeUseCases: widget.activeUseCases, + sessionId: widget.sessionId, + activeRest: widget.activeRest, ), const SizedBox(height: 16), ], @@ -729,6 +745,17 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> { ), ); } + + Future _refreshAfterEdit() async { + final updated = await widget.activeUseCases.listSetResults( + widget.sessionId, + ); + if (!mounted) return; + setState(() => _setStates = updated); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Série mise à jour'))); + } } final class _RestPlanBanner extends StatelessWidget { @@ -752,17 +779,30 @@ final class _WorkoutPlanSetTile extends StatelessWidget { const _WorkoutPlanSetTile({ required this.position, required this.currentPosition, + required this.exercise, + required this.onUpdated, + required this.activeUseCases, + required this.sessionId, + this.activeRest, this.state, }); final ExecutionPosition position; final ExecutionPosition currentPosition; + final ExecutionExercise exercise; final SetResultPositionState? state; + final Future Function() onUpdated; + final ActiveWorkoutSessionUseCases activeUseCases; + final String sessionId; + final ActiveRestState? activeRest; @override Widget build(BuildContext context) { final planState = _planSetState(position, currentPosition, state); final tappable = planState != _PlanSetStatus.todo; + final VoidCallback? onTap = tappable + ? () => unawaited(_handleTap(context, planState)) + : null; final tokens = courtBlazerTokensOf(context); return Card( margin: const EdgeInsets.only(bottom: 8), @@ -778,11 +818,273 @@ final class _WorkoutPlanSetTile extends StatelessWidget { enabled: tappable, title: Text('Série ${position.setIndex + 1}'), subtitle: Text(_planSetSubtitle(planState, state?.result)), - trailing: _PlanStatusBadge( - status: planState, - onTap: tappable ? () => Navigator.of(context).pop() : null, + trailing: _PlanStatusBadge(status: planState, onTap: onTap), + onTap: onTap, + ), + ); + } + + Future _handleTap(BuildContext context, _PlanSetStatus status) async { + if (status == _PlanSetStatus.current) { + Navigator.of(context).pop(); + return; + } + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => _EditSetResultSheet( + sessionId: sessionId, + position: position, + exercise: exercise, + state: state, + status: status, + activeUseCases: activeUseCases, + activeRest: activeRest, + onSaved: onUpdated, + ), + ); + } +} + +final class _EditSetResultSheet extends StatefulWidget { + const _EditSetResultSheet({ + required this.sessionId, + required this.position, + required this.exercise, + required this.state, + required this.status, + required this.activeUseCases, + required this.onSaved, + this.activeRest, + }); + + final String sessionId; + final ExecutionPosition position; + final ExecutionExercise exercise; + final SetResultPositionState? state; + final _PlanSetStatus status; + final ActiveWorkoutSessionUseCases activeUseCases; + final Future Function() onSaved; + final ActiveRestState? activeRest; + + @override + State<_EditSetResultSheet> createState() => _EditSetResultSheetState(); +} + +final class _EditSetResultSheetState extends State<_EditSetResultSheet> { + final _timeController = TextEditingController(); + final _repsController = TextEditingController(); + final _scoreController = TextEditingController(); + Timer? _timer; + late int _remainingRestSeconds; + + @override + void initState() { + super.initState(); + final result = widget.state?.result; + final actualTimeMs = result?.actualTimeMs; + if (actualTimeMs != null) { + _timeController.text = (actualTimeMs / 1000).round().toString(); + } + final actualReps = result?.actualReps; + if (actualReps != null) { + _repsController.text = actualReps.toString(); + } + final actualScore = result?.actualScore; + if (actualScore != null) { + _scoreController.text = _formatScore(actualScore); + } + _remainingRestSeconds = _remainingSecondsForRest(widget.activeRest); + if (widget.activeRest != null && _remainingRestSeconds > 0) { + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() { + _remainingRestSeconds = (_remainingRestSeconds - 1) + .clamp(0, 9999) + .toInt(); + }); + }); + } + } + + @override + void dispose() { + _timer?.cancel(); + _timeController.dispose(); + _repsController.dispose(); + _scoreController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isCompleted = widget.status == _PlanSetStatus.completed; + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: ListView( + shrinkWrap: true, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Modifier la série', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + IconButton( + tooltip: 'Annuler', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + Text( + '${widget.exercise.name} · ' + 'Série ${widget.position.setIndex + 1}/${widget.exercise.setsCount}', + ), + const SizedBox(height: 8), + Text( + 'État actuel : ${isCompleted ? 'Terminée' : 'Passée'}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (widget.activeRest != null) ...[ + const SizedBox(height: 12), + _RestPlanBanner(remainingSeconds: _remainingRestSeconds), + ], + const SizedBox(height: 24), + if (widget.exercise.timeEnabled) ...[ + TextField( + controller: _timeController, + decoration: const InputDecoration(labelText: 'Temps réalisé (s)'), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + ], + if (widget.exercise.repsEnabled) ...[ + TextField( + controller: _repsController, + decoration: const InputDecoration(labelText: 'Répétitions'), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + ], + if (widget.exercise.scoreEnabled) ...[ + TextField( + controller: _scoreController, + decoration: InputDecoration( + labelText: widget.exercise.scoreUnit == null + ? 'Score' + : 'Score (${widget.exercise.scoreUnit})', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + ], + const SizedBox(height: 12), + FilledButton( + onPressed: _saveCompleted, + child: Text( + isCompleted + ? 'Enregistrer les corrections' + : 'Enregistrer le résultat', + ), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: _markSkipped, + child: const Text('Marquer comme passée'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ], + ), + ); + } + + Future _saveCompleted() async { + if (!_hasAnyValue()) { + if (widget.status == _PlanSetStatus.completed) { + final confirmed = await _confirmMarkSkipped(); + if (confirmed != true) return; + } + await _saveSkipped(); + return; + } + await widget.activeUseCases.upsertSetResultAtPosition( + sessionId: widget.sessionId, + programIndex: widget.position.programIndex, + exerciseIndex: widget.position.exerciseIndex, + setIndex: widget.position.setIndex, + status: SetResultStatus.completed, + actualTimeMs: widget.exercise.timeEnabled + ? _secondsToMilliseconds(_timeController.text) + : null, + actualReps: widget.exercise.repsEnabled + ? int.tryParse(_repsController.text.trim()) + : null, + actualScore: widget.exercise.scoreEnabled + ? double.tryParse(_scoreController.text.trim()) + : null, + scoreUnitSnapshot: widget.exercise.scoreUnit, + ); + await _finishSave(); + } + + Future _markSkipped() async { + await _saveSkipped(); + } + + Future _saveSkipped() async { + await widget.activeUseCases.upsertSetResultAtPosition( + sessionId: widget.sessionId, + programIndex: widget.position.programIndex, + exerciseIndex: widget.position.exerciseIndex, + setIndex: widget.position.setIndex, + status: SetResultStatus.skipped, + ); + await _finishSave(); + } + + Future _finishSave() async { + await widget.onSaved(); + if (!mounted) return; + Navigator.of(context).pop(); + } + + bool _hasAnyValue() { + return _timeController.text.trim().isNotEmpty || + _repsController.text.trim().isNotEmpty || + _scoreController.text.trim().isNotEmpty; + } + + Future _confirmMarkSkipped() { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer le résultat de cette série ?'), + content: const Text( + 'La série restera dans le plan comme passée, sans résultat saisi.', ), - onTap: tappable ? () => Navigator.of(context).pop() : null, + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Marquer comme passée'), + ), + ], ), ); } @@ -872,6 +1174,11 @@ String _formatScore(double value) { : value.toString(); } +int? _secondsToMilliseconds(String rawValue) { + final seconds = int.tryParse(rawValue.trim()); + return seconds == null ? null : seconds * 1000; +} + int _remainingSecondsForRest(ActiveRestState? rest) { if (rest == null) return 0; final elapsedSeconds = DateTime.now() diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 3f46219..45b4f31 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -280,6 +280,123 @@ void main() { expect(find.text('Plan de séance'), findsNothing); }); + + testWidgets( + 'modifier une série passée la termine sans changer la position courante', + (tester) async { + 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: 2, + resolvedTemplateSnapshotJson: _sessionSnapshot( + setsCount: 4, + restSeconds: 0, + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + + await tester.tap(find.byTooltip('Voir le plan')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ListTile, 'Série 2')); + await tester.pumpAndSettle(); + + expect(find.text('Modifier la série'), findsOneWidget); + + await tester.enterText(_textFieldWithLabel('Répétitions'), '12'); + await tester.ensureVisible(find.text('Enregistrer le résultat')); + await tester.tap(find.text('Enregistrer le résultat')); + await tester.pumpAndSettle(); + + expect(repository.session?.currentSetIndex, 2); + expect(repository.results.single.status, SetResultStatus.completed); + expect(repository.results.single.actualReps, 12); + expect(find.text('Série mise à jour'), findsWidgets); + expect(find.text('12 reps'), findsOneWidget); + + await tester.tap(find.byTooltip('Fermer')); + await tester.pumpAndSettle(); + + expect( + find.text('Programme 1/1 · Exercice 1/1 · Série 3/4'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'toucher la série courante dans le plan revient à l’écran principal', + (tester) async { + 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: 1, + resolvedTemplateSnapshotJson: _sessionSnapshot( + setsCount: 3, + restSeconds: 0, + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + + await tester.tap(find.byTooltip('Voir le plan')); + await tester.pumpAndSettle(); + await tester.tap(find.text('En cours')); + await tester.pumpAndSettle(); + + expect(find.text('Plan de séance'), findsNothing); + expect(find.text('Modifier la série'), findsNothing); + expect( + find.text('Programme 1/1 · Exercice 1/1 · Série 2/3'), + findsOneWidget, + ); + }, + ); +} + +Finder _textFieldWithLabel(String label) { + return find.byWidgetPredicate( + (widget) => widget is TextField && widget.decoration?.labelText == label, + description: 'TextField with label "$label"', + ); } ActiveWorkoutSessionUseCases _activeUseCases( @@ -437,7 +554,18 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future saveSetResult(ActiveSetResult result) async { - results.add(result); + final index = results.indexWhere( + (saved) => + saved.activeWorkoutSessionId == result.activeWorkoutSessionId && + saved.programIndex == result.programIndex && + saved.exerciseIndex == result.exerciseIndex && + saved.setIndex == result.setIndex, + ); + if (index == -1) { + results.add(result); + } else { + results[index] = result; + } } } From defcfbc24252c00229fee5f2ec35a3ba33cef670 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 00:37:59 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20met=20=C3=A0=20jour=20le=20t?= =?UTF-8?q?icket=20#23=20=E2=80=94=20lot=20#20-#23=20clos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/23/carnet.md | 4 ++-- .ideai/tickets/23/issue.md | 6 +++--- .ideai/tickets/index.json | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.ideai/tickets/23/carnet.md b/.ideai/tickets/23/carnet.md index 5b8623a..2933fbb 100644 --- a/.ideai/tickets/23/carnet.md +++ b/.ideai/tickets/23/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#23" -version: 3 +version: 5 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784325855260 +updatedAt: 1784327862613 --- diff --git a/.ideai/tickets/23/issue.md b/.ideai/tickets/23/issue.md index 87b74ab..ce9a661 100644 --- a/.ideai/tickets/23/issue.md +++ b/.ideai/tickets/23/issue.md @@ -2,7 +2,7 @@ id: "5b61b1a0-6bed-4d51-912f-cedf27f2b1a2" number: 23 title: "[DevFrontend] Exécution : édition ponctuelle d'une série passée ou terminée" -status: "open" +status: "closed" priority: "high" sprint: null links: [{"target":"#9","kind":"relatesTo"},{"target":"#21","kind":"dependsOn"},{"target":"#22","kind":"dependsOn"}] @@ -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: 1784325846325 -updatedAt: 1784325855260 -version: 3 +updatedAt: 1784327862613 +version: 5 --- Depuis le plan de séance, permettre de taper une série Terminée ou Passée pour ouvrir une bottom sheet "Modifier la série" (mêmes champs que l'exécution active) et sauvegarder via upsertSetResultAtPosition, sans déplacer le curseur de progression ni affecter les repos/temps total. Gestion du repos actif pendant l'édition (bandeau compact persistant). Cf. mémoire "gametime-ux-execution-nav-and-program-simplification" point 1 pour le détail complet des cas limites. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 1aa948c..037bffc 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -255,13 +255,13 @@ "issueRef": "#23", "path": "23", "title": "[DevFrontend] Exécution : édition ponctuelle d'une série passée ou terminée", - "status": "open", + "status": "closed", "priority": "high", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784325855260 + "updatedAt": 1784327862613 } ] } \ No newline at end of file