From 97ad7b79ccac20e9fc95fe8dbb9b46218b43b942 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 30 Jul 2026 09:43:48 +0200 Subject: [PATCH] fix(execution): corrige la reprise de seance affichant a tort Seance Terminee (#192) Co-Authored-By: Claude Opus 4.8 --- .../workout_execution_screen.dart | 94 ++++++++++++++----- .../workout_execution_screen_test.dart | 57 +++++++++++ 2 files changed, 129 insertions(+), 22 deletions(-) diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index ea33cf3..94bf1d2 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -12,7 +12,7 @@ import 'exercise_step_audio.dart'; import 'history_screen.dart'; import 'theme.dart'; -enum WorkoutExecutionMode { active, rest, paused, finished } +enum WorkoutExecutionMode { active, rest, paused, finished, resumingSavedExit } typedef VideoMediaBuilder = Widget Function(BuildContext context, MediaAsset asset); @@ -117,9 +117,7 @@ final class _WorkoutExecutionScreenState extends State { (widget.stepUseCases == null ? const NoOpExerciseStepAudioCuePlayer() : AudioplayersExerciseStepAudioCuePlayer()); - _mode = _session.status == ActiveWorkoutStatus.running - ? WorkoutExecutionMode.active - : WorkoutExecutionMode.paused; + _mode = _modeForSessionStatus(_session.status); _reps = _initialRepsFor(_exercise); _performanceReference = _loadPerformanceReference(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { @@ -136,11 +134,16 @@ final class _WorkoutExecutionScreenState extends State { } setState(() => _sensorState = state); }); - unawaited(_loadSetTimer()); - unawaited(_loadScoreStopwatch()); - unawaited(_syncManualScoreInput(force: true)); - unawaited(_loadStepProgress()); - unawaited(_restoreActiveRest()); + final resumingSavedExit = _session.status == ActiveWorkoutStatus.savedExit; + if (resumingSavedExit) { + unawaited(_resumeSavedExitSession()); + } else { + unawaited(_loadSetTimer()); + unawaited(_loadScoreStopwatch()); + unawaited(_syncManualScoreInput(force: true)); + unawaited(_loadStepProgress()); + unawaited(_restoreActiveRest()); + } } @override @@ -225,7 +228,8 @@ final class _WorkoutExecutionScreenState extends State { onPressed: _openWorkoutPlan, icon: const Icon(Icons.list_alt), ), - TextButton(onPressed: _pause, child: const Text('Pause')), + if (_canPauseFromNavigation) + TextButton(onPressed: _pause, child: const Text('Pause')), ], ), bottomNavigationBar: _mode == WorkoutExecutionMode.active @@ -243,6 +247,7 @@ final class _WorkoutExecutionScreenState extends State { WorkoutExecutionMode.rest => _buildRest(), WorkoutExecutionMode.paused => _buildPaused(), WorkoutExecutionMode.finished => _buildFinished(), + WorkoutExecutionMode.resumingSavedExit => _buildResumingSavedExit(), }, ), ); @@ -510,6 +515,22 @@ final class _WorkoutExecutionScreenState extends State { ); } + Widget _buildResumingSavedExit() { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Reprise de la séance...'), + ], + ), + ), + ); + } + Widget _buildFinished() { final elapsed = Duration( milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(_session), @@ -1221,7 +1242,9 @@ final class _WorkoutExecutionScreenState extends State { } Future _syncExternalSessionChanges() async { - if (_externalSyncInFlight || !mounted) { + if (_externalSyncInFlight || + !mounted || + _mode == WorkoutExecutionMode.resumingSavedExit) { return; } _externalSyncInFlight = true; @@ -1250,16 +1273,7 @@ final class _WorkoutExecutionScreenState extends State { return; } setState(() { - _mode = switch (session.status) { - ActiveWorkoutStatus.running => - _activeRestStateId == null - ? WorkoutExecutionMode.active - : WorkoutExecutionMode.rest, - ActiveWorkoutStatus.paused => WorkoutExecutionMode.paused, - ActiveWorkoutStatus.completed || - ActiveWorkoutStatus.abandoned || - ActiveWorkoutStatus.savedExit => WorkoutExecutionMode.finished, - }; + _mode = _modeForSessionStatus(session.status); }); _refreshScoreStopwatchTicker(); _refreshStepTicker(); @@ -1400,6 +1414,19 @@ final class _WorkoutExecutionScreenState extends State { _mode == WorkoutExecutionMode.rest; } + WorkoutExecutionMode _modeForSessionStatus(ActiveWorkoutStatus status) { + return switch (status) { + ActiveWorkoutStatus.running => + _activeRestStateId == null + ? WorkoutExecutionMode.active + : WorkoutExecutionMode.rest, + ActiveWorkoutStatus.paused => WorkoutExecutionMode.paused, + ActiveWorkoutStatus.savedExit => WorkoutExecutionMode.resumingSavedExit, + ActiveWorkoutStatus.completed || + ActiveWorkoutStatus.abandoned => WorkoutExecutionMode.finished, + }; + } + void _handleSystemBack(bool didPop, Object? result) { if (didPop || !_canPauseFromNavigation) return; unawaited(_pause()); @@ -1443,6 +1470,13 @@ final class _WorkoutExecutionScreenState extends State { _refreshStepTicker(); } + Future _resumeSavedExitSession() async { + if (!mounted || _mode != WorkoutExecutionMode.resumingSavedExit) { + return; + } + await _resume(); + } + Future _quitAndSave() async { await widget.activeUseCases.quitAndSave(_session.metadata.id); if (!mounted) return; @@ -2777,6 +2811,8 @@ final class _CurrentStepPane extends StatelessWidget { step.type == ExerciseStepType.time && step.hasScore && step.scoreInputMode == ScoreInputMode.manual; + final useCompactRepsActions = + step.type == ExerciseStepType.reps && constraints.maxHeight < 180; if (hasRepsWithStopwatchScore || hasTimedWithManualScore) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2900,7 +2936,8 @@ final class _CurrentStepPane extends StatelessWidget { ), ], const SizedBox(height: 4), - if (step.type == ExerciseStepType.reps) ...[ + if (step.type == ExerciseStepType.reps && + !useCompactRepsActions) ...[ FilledButton.icon( onPressed: onCompleteStep, style: FilledButton.styleFrom( @@ -2922,6 +2959,19 @@ final class _CurrentStepPane extends StatelessWidget { child: const Text('Passer l’étape'), ), ), + if (useCompactRepsActions) ...[ + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + onPressed: onCompleteStep, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + icon: const Icon(Icons.check), + label: const Text('Étape suivante'), + ), + ), + ], const SizedBox(width: 8), PopupMenuButton<_StepSkipAction>( tooltip: 'Plus d’actions', diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 2dc3706..1c4a041 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -88,6 +89,60 @@ void main() { expect(repository.session?.status, ActiveWorkoutStatus.abandoned); }); + testWidgets( + 'une séance sauvegardée est reprise sans afficher un faux état terminé', + (tester) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15)); + final resumeGate = Completer(); + final repository = _FakeActiveSessionRepository() + ..findByIdBlocker = resumeGate.future; + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + sourceWorkoutTemplateId: 'template-1', + status: ActiveWorkoutStatus.savedExit, + startedAt: DateTime.utc(2026, 7, 17, 11, 59), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 30000, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot(), + ); + 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.pump(); + + expect(find.text('Reprise de la séance...'), findsOneWidget); + expect(find.text('Pause'), findsNothing); + expect(find.text('Séance terminée'), findsNothing); + + await tester.pump(const Duration(seconds: 2)); + + expect(find.text('Reprise de la séance...'), findsOneWidget); + expect(find.text('Séance terminée'), findsNothing); + expect(repository.session?.status, ActiveWorkoutStatus.savedExit); + + resumeGate.complete(); + await tester.pump(); + await tester.pump(); + + expect(repository.session?.status, ActiveWorkoutStatus.running); + expect(find.text('Squat'), findsOneWidget); + expect(find.text('Séance terminée'), findsNothing); + }, + ); + testWidgets('les répétitions sont initialisées avec la cible', ( tester, ) async { @@ -2678,6 +2733,7 @@ final class _FakeExercisePerformanceReferenceRepository final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; + Future? findByIdBlocker; final results = []; final restStates = []; final scoreStopwatchStates = []; @@ -2688,6 +2744,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future findById(String id) async { + await findByIdBlocker; return session?.metadata.id == id ? session : null; }