From c2f807e58858cbf213bfd724066d45b03c7bcdcf Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 00:37:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=C3=A9dition=20ponctuelle=20des=20s?= =?UTF-8?q?=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; + } } }