fix(execution): corrige la reprise de seance affichant a tort Seance Terminee (#192)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 09:43:48 +02:00
parent 0584ee337c
commit 97ad7b79cc
2 changed files with 129 additions and 22 deletions

View File

@ -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<WorkoutExecutionScreen> {
(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,12 +134,17 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
}
setState(() => _sensorState = state);
});
final resumingSavedExit = _session.status == ActiveWorkoutStatus.savedExit;
if (resumingSavedExit) {
unawaited(_resumeSavedExitSession());
} else {
unawaited(_loadSetTimer());
unawaited(_loadScoreStopwatch());
unawaited(_syncManualScoreInput(force: true));
unawaited(_loadStepProgress());
unawaited(_restoreActiveRest());
}
}
@override
void dispose() {
@ -225,6 +228,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
onPressed: _openWorkoutPlan,
icon: const Icon(Icons.list_alt),
),
if (_canPauseFromNavigation)
TextButton(onPressed: _pause, child: const Text('Pause')),
],
),
@ -243,6 +247,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
WorkoutExecutionMode.rest => _buildRest(),
WorkoutExecutionMode.paused => _buildPaused(),
WorkoutExecutionMode.finished => _buildFinished(),
WorkoutExecutionMode.resumingSavedExit => _buildResumingSavedExit(),
},
),
);
@ -510,6 +515,22 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
);
}
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<WorkoutExecutionScreen> {
}
Future<void> _syncExternalSessionChanges() async {
if (_externalSyncInFlight || !mounted) {
if (_externalSyncInFlight ||
!mounted ||
_mode == WorkoutExecutionMode.resumingSavedExit) {
return;
}
_externalSyncInFlight = true;
@ -1250,16 +1273,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
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<WorkoutExecutionScreen> {
_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<WorkoutExecutionScreen> {
_refreshStepTicker();
}
Future<void> _resumeSavedExitSession() async {
if (!mounted || _mode != WorkoutExecutionMode.resumingSavedExit) {
return;
}
await _resume();
}
Future<void> _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 dactions',

View File

@ -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<void>();
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<void>? findByIdBlocker;
final results = <ActiveSetResult>[];
final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
@ -2688,6 +2744,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<ActiveWorkoutSession?> findById(String id) async {
await findByIdBlocker;
return session?.metadata.id == id ? session : null;
}