From cdcb0beead683a0e3a7ab25e7ec511e01c4f892c Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 14:34:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(ex=C3=A9cution):=20chrono=20du=20score=20c?= =?UTF-8?q?hronom=C3=A9tr=C3=A9=20pendant=20la=20s=C3=A9ance=20(ticket=20#?= =?UTF-8?q?27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute le chrono du score chronométré (fondation domain/config des tickets #25/#26) sur workout_execution_screen.dart, avec les use cases associés. flutter analyze propre, 75/75 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 --- lib/application/use_cases.dart | 18 + .../workout_execution_screen.dart | 431 +++++++++++++++++- .../workout_execution_screen_test.dart | 164 ++++++- 3 files changed, 609 insertions(+), 4 deletions(-) diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index ece6697..da54905 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -1188,6 +1188,24 @@ final class ActiveWorkoutSessionUseCases { ); } + Future findScoreStopwatch({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) { + return sessionRepository.findScoreStopwatchState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + } + + int scoreStopwatchElapsedMilliseconds(ActiveScoreStopwatchState state) { + return state.elapsedMillisecondsAt(clock.now()); + } + Future> listSetResults(String sessionId) async { final session = await _requiredSession(sessionId); final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson); diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index 5366a10..1ff0766 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -38,6 +38,8 @@ final class _WorkoutExecutionScreenState extends State { Timer? _restTicker; WorkoutHistory? _completedHistory; String? _activeRestStateId; + ActiveScoreStopwatchState? _scoreStopwatch; + int? _manualScoreTimeMs; late DateTime _seriesStartedAt; var _reps = 0; final _scoreController = TextEditingController(); @@ -63,6 +65,7 @@ final class _WorkoutExecutionScreenState extends State { _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); + unawaited(_loadScoreStopwatch()); unawaited(_restoreActiveRest()); } @@ -151,6 +154,20 @@ final class _WorkoutExecutionScreenState extends State { scoreController: _scoreController, onRepsChanged: (value) => setState(() => _reps = value), ), + if (_exercise.stopwatchScoreEnabled) ...[ + const SizedBox(height: 12), + _ScoreStopwatchInput( + elapsedLabel: _formatScoreStopwatch( + Duration(milliseconds: _scoreStopwatchElapsedMs), + ), + status: _scoreStopwatch?.status, + onStart: _startScoreStopwatch, + onStop: _stopScoreStopwatch, + onResume: _resumeScoreStopwatch, + onReset: _resetScoreStopwatch, + onEdit: _editScoreStopwatchTime, + ), + ], const SizedBox(height: 24), FilledButton( onPressed: () => _finishSet(skipped: false), @@ -326,6 +343,120 @@ final class _WorkoutExecutionScreenState extends State { ); } + int get _scoreStopwatchElapsedMs { + final manual = _manualScoreTimeMs; + if (manual != null) { + return manual; + } + final state = _scoreStopwatch; + if (state == null) { + return 0; + } + return widget.activeUseCases.scoreStopwatchElapsedMilliseconds(state); + } + + Future _loadScoreStopwatch() async { + if (!_exercise.stopwatchScoreEnabled) { + _scoreStopwatch = null; + _manualScoreTimeMs = null; + return; + } + final state = await widget.activeUseCases.findScoreStopwatch( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return; + setState(() => _scoreStopwatch = state); + } + + Future _startScoreStopwatch() async { + final state = await widget.activeUseCases.startScoreStopwatch( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return; + setState(() { + _scoreStopwatch = state; + _manualScoreTimeMs = null; + }); + } + + Future _stopScoreStopwatch() async { + final state = _scoreStopwatch; + if (state == null) return null; + final stopped = await widget.activeUseCases.stopScoreStopwatch( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return stopped; + setState(() => _scoreStopwatch = stopped); + return stopped; + } + + Future _resumeScoreStopwatch() async { + final state = await widget.activeUseCases.resumeScoreStopwatch( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return; + setState(() { + _scoreStopwatch = state; + _manualScoreTimeMs = null; + }); + } + + Future _resetScoreStopwatch() async { + if (_scoreStopwatchElapsedMs > 0) { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Réinitialiser le chrono ?'), + content: const Text('Le temps mesuré sera supprimé.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Réinitialiser'), + ), + ], + ), + ); + if (confirmed != true) return; + } + await widget.activeUseCases.resetScoreStopwatch( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return; + setState(() { + _scoreStopwatch = null; + _manualScoreTimeMs = null; + }); + } + + Future _editScoreStopwatchTime() async { + final initial = Duration(milliseconds: _scoreStopwatchElapsedMs); + final edited = await showDialog( + context: context, + builder: (context) => _ScoreStopwatchEditDialog(initial: initial), + ); + if (edited == null || !mounted) return; + setState(() => _manualScoreTimeMs = edited); + } + bool get _canPauseFromNavigation { return _mode == WorkoutExecutionMode.active || _mode == WorkoutExecutionMode.rest; @@ -338,6 +469,9 @@ final class _WorkoutExecutionScreenState extends State { Future _pause() async { _restTicker?.cancel(); + if (_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) { + await _stopScoreStopwatch(); + } _session = await widget.activeUseCases.pause(_session.metadata.id); if (!mounted) return; setState(() => _mode = WorkoutExecutionMode.paused); @@ -348,6 +482,8 @@ final class _WorkoutExecutionScreenState extends State { if (!mounted) return; final restoredRest = await _restoreActiveRest(); if (!mounted || restoredRest) return; + await _loadScoreStopwatch(); + if (!mounted) return; setState(() => _mode = WorkoutExecutionMode.active); } @@ -408,8 +544,88 @@ final class _WorkoutExecutionScreenState extends State { } Future _finishSet({required bool skipped}) async { + if (skipped && + _scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Passer cette série ?'), + content: const Text('Le chrono en cours sera ignoré.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Continuer la série'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Passer'), + ), + ], + ), + ); + if (confirmed != true) return; + await widget.activeUseCases.resetScoreStopwatch( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return; + setState(() { + _scoreStopwatch = null; + _manualScoreTimeMs = null; + }); + } + if (!skipped && + _exercise.stopwatchScoreEnabled && + _scoreStopwatch == null && + _manualScoreTimeMs == null) { + final action = await showDialog<_MissingStopwatchAction>( + context: context, + builder: (context) => AlertDialog( + title: const Text('Aucun temps chronométré'), + content: const Text("Tu n'as pas démarré le chrono score."), + actions: [ + TextButton( + onPressed: () => + Navigator.of(context).pop(_MissingStopwatchAction.start), + child: const Text('Démarrer le chrono'), + ), + FilledButton( + onPressed: () => Navigator.of( + context, + ).pop(_MissingStopwatchAction.finishWithout), + child: const Text('Terminer sans chrono'), + ), + ], + ), + ); + if (action == _MissingStopwatchAction.start) { + await _startScoreStopwatch(); + return; + } + if (action != _MissingStopwatchAction.finishWithout) { + return; + } + } + await _recordAndAdvance(skipped: skipped); + } + + Future _recordAndAdvance({required bool skipped}) async { try { final score = double.tryParse(_scoreController.text.trim()); + var actualScoreTimeMs = skipped ? null : _manualScoreTimeMs; + if (!skipped && + _exercise.stopwatchScoreEnabled && + actualScoreTimeMs == null) { + final state = _scoreStopwatch; + if (state?.status == ActiveScoreStopwatchStatus.running) { + final stopped = await _stopScoreStopwatch(); + actualScoreTimeMs = stopped?.accumulatedMs; + } else { + actualScoreTimeMs = state?.accumulatedMs; + } + } final actualTimeMs = DateTime.now() .toUtc() .difference(_seriesStartedAt) @@ -425,6 +641,7 @@ final class _WorkoutExecutionScreenState extends State { actualScore: skipped ? null : (_exercise.manualScoreEnabled ? score : null), + actualScoreTimeMs: actualScoreTimeMs, scoreInputModeSnapshot: _exercise.scoreInputMode, scoreLabelSnapshot: _exercise.scoreLabel, scoreUnitSnapshot: _exercise.scoreUnit, @@ -548,6 +765,8 @@ final class _WorkoutExecutionScreenState extends State { _scoreController.clear(); _reps = 0; _activeRestStateId = null; + _scoreStopwatch = null; + _manualScoreTimeMs = null; _seriesStartedAt = DateTime.now().toUtc(); _session = await widget.activeUseCases.updateProgress( sessionId: _session.metadata.id, @@ -556,6 +775,8 @@ final class _WorkoutExecutionScreenState extends State { setIndex: position.setIndex, ); if (!mounted) return; + await _loadScoreStopwatch(); + if (!mounted) return; setState(() => _mode = WorkoutExecutionMode.active); } @@ -676,7 +897,7 @@ final class SetMeasureInput extends StatelessWidget { ], ), ], - if (exercise.scoreEnabled) ...[ + if (exercise.manualScoreEnabled) ...[ const SizedBox(height: 12), TextField( controller: scoreController, @@ -694,6 +915,176 @@ final class SetMeasureInput extends StatelessWidget { } } +enum _MissingStopwatchAction { start, finishWithout } + +final class _ScoreStopwatchInput extends StatelessWidget { + const _ScoreStopwatchInput({ + required this.elapsedLabel, + required this.status, + required this.onStart, + required this.onStop, + required this.onResume, + required this.onReset, + required this.onEdit, + }); + + final String elapsedLabel; + final ActiveScoreStopwatchStatus? status; + final VoidCallback onStart; + final VoidCallback onStop; + final VoidCallback onResume; + final VoidCallback onReset; + final VoidCallback onEdit; + + @override + Widget build(BuildContext context) { + final running = status == ActiveScoreStopwatchStatus.running; + final stopped = status == ActiveScoreStopwatchStatus.stopped; + return CourtBlazerAccentPanel( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Chrono score', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + Center( + child: Text( + elapsedLabel, + style: AppTextStyles.scoreNumber(context), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + if (status == null) + FilledButton.icon( + onPressed: onStart, + icon: const Icon(Icons.play_arrow), + label: const Text('Démarrer'), + ) + else if (running) + FilledButton.icon( + onPressed: onStop, + icon: const Icon(Icons.stop), + label: const Text('Arrêter'), + ) + else ...[ + FilledButton.icon( + onPressed: onResume, + icon: const Icon(Icons.play_arrow), + label: const Text('Reprendre'), + ), + OutlinedButton.icon( + onPressed: onReset, + icon: const Icon(Icons.restart_alt), + label: const Text('Réinitialiser'), + ), + ], + if (stopped) + TextButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit), + label: const Text('Modifier le temps'), + ), + ], + ), + ], + ), + ); + } +} + +final class _ScoreStopwatchEditDialog extends StatefulWidget { + const _ScoreStopwatchEditDialog({required this.initial}); + + final Duration initial; + + @override + State<_ScoreStopwatchEditDialog> createState() => + _ScoreStopwatchEditDialogState(); +} + +final class _ScoreStopwatchEditDialogState + extends State<_ScoreStopwatchEditDialog> { + late final TextEditingController _minutesController; + late final TextEditingController _secondsController; + late final TextEditingController _tenthsController; + + @override + void initState() { + super.initState(); + final totalTenths = (widget.initial.inMilliseconds / 100).round(); + final minutes = totalTenths ~/ 600; + final seconds = (totalTenths ~/ 10) % 60; + final tenths = totalTenths % 10; + _minutesController = TextEditingController(text: '$minutes'); + _secondsController = TextEditingController(text: '$seconds'); + _tenthsController = TextEditingController(text: '$tenths'); + } + + @override + void dispose() { + _minutesController.dispose(); + _secondsController.dispose(); + _tenthsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Modifier le temps'), + content: Row( + children: [ + Expanded( + child: TextField( + controller: _minutesController, + decoration: const InputDecoration(labelText: 'Min'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _secondsController, + decoration: const InputDecoration(labelText: 'S'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _tenthsController, + decoration: const InputDecoration(labelText: 'Dixièmes'), + keyboardType: TextInputType.number, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () { + final minutes = int.tryParse(_minutesController.text.trim()) ?? 0; + final seconds = int.tryParse(_secondsController.text.trim()) ?? 0; + final tenths = int.tryParse(_tenthsController.text.trim()) ?? 0; + final milliseconds = + (minutes * 60 * 1000) + (seconds * 1000) + (tenths * 100); + Navigator.of(context).pop(milliseconds); + }, + child: const Text('Enregistrer'), + ), + ], + ); + } +} + final class _WorkoutPlanSheet extends StatefulWidget { const _WorkoutPlanSheet({ required this.sessionId, @@ -1202,6 +1593,10 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> { if (actualScore != null) { _scoreController.text = _formatScore(actualScore); } + final actualScoreTimeMs = result?.actualScoreTimeMs; + if (actualScoreTimeMs != null) { + _scoreController.text = (actualScoreTimeMs / 1000).toStringAsFixed(1); + } _remainingRestSeconds = _remainingSecondsForRest(widget.activeRest); if (widget.activeRest != null && _remainingRestSeconds > 0) { _timer = Timer.periodic(const Duration(seconds: 1), (_) { @@ -1287,7 +1682,9 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> { TextField( controller: _scoreController, decoration: InputDecoration( - labelText: widget.exercise.scoreUnit == null + labelText: widget.exercise.stopwatchScoreEnabled + ? 'Temps réalisé (chrono score, s)' + : widget.exercise.scoreUnit == null ? 'Score' : 'Score (${widget.exercise.scoreUnit})', ), @@ -1339,9 +1736,14 @@ final class _EditSetResultSheetState extends State<_EditSetResultSheet> { actualReps: widget.exercise.repsEnabled ? int.tryParse(_repsController.text.trim()) : null, - actualScore: widget.exercise.scoreEnabled + actualScore: widget.exercise.manualScoreEnabled ? double.tryParse(_scoreController.text.trim()) : null, + actualScoreTimeMs: widget.exercise.stopwatchScoreEnabled + ? _decimalSecondsToMilliseconds(_scoreController.text) + : null, + scoreInputModeSnapshot: widget.exercise.scoreInputMode, + scoreLabelSnapshot: widget.exercise.scoreLabel, scoreUnitSnapshot: widget.exercise.scoreUnit, ); await _finishSave(); @@ -1472,6 +1874,12 @@ String _setResultSummary(ActiveSetResult result) { final unit = result.scoreUnitSnapshot?.trim(); parts.add(unit == null || unit.isEmpty ? 'Score $value' : '$value $unit'); } + if (result.actualScoreTimeMs != null) { + final value = _formatScoreStopwatch( + Duration(milliseconds: result.actualScoreTimeMs!), + ); + parts.add('Temps réalisé $value'); + } return parts.isEmpty ? 'Aucun résultat' : parts.join(' · '); } @@ -1486,6 +1894,11 @@ int? _secondsToMilliseconds(String rawValue) { return seconds == null ? null : seconds * 1000; } +int? _decimalSecondsToMilliseconds(String rawValue) { + final seconds = double.tryParse(rawValue.trim()); + return seconds == null ? null : (seconds * 1000).round(); +} + int _remainingSecondsForRest(ActiveRestState? rest) { if (rest == null) return 0; final elapsedSeconds = DateTime.now() @@ -1695,6 +2108,10 @@ final class ExecutionExercise { return scoreEnabled && scoreInputMode == ScoreInputMode.manual; } + bool get stopwatchScoreEnabled { + return scoreEnabled && scoreInputMode == ScoreInputMode.stopwatch; + } + bool get hasMedia { return imageMediaIds.isNotEmpty || videoMediaId != null; } @@ -1810,3 +2227,11 @@ String _formatDuration(Duration duration) { } return '$minutes:$seconds'; } + +String _formatScoreStopwatch(Duration duration) { + final totalTenths = (duration.inMilliseconds / 100).floor(); + final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0'); + final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0'); + final tenths = totalTenths % 10; + return '$minutes:$seconds.$tenths'; +} diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 0f2ef33..f024945 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -241,6 +241,160 @@ void main() { expect(find.text('Image 2'), findsOneWidget); }); + testWidgets('le chrono score démarre et s’arrête avec la bonne durée', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 1400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + 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: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + scoreInputMode: ScoreInputMode.stopwatch, + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + + expect(find.text('Chrono score'), findsOneWidget); + expect(find.text('00:00.0'), findsOneWidget); + + await tester.tap(find.text('Démarrer')); + await tester.pump(); + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('00:01.2'), findsOneWidget); + + await tester.tap(find.text('Arrêter')); + await tester.pump(); + + expect(find.text('00:01.2'), findsOneWidget); + expect( + repository.scoreStopwatchStates.single.status, + ActiveScoreStopwatchStatus.stopped, + ); + }); + + testWidgets('terminer sans chrono démarré affiche une confirmation', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 1400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + 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: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + scoreInputMode: ScoreInputMode.stopwatch, + ), + ); + 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.text('Terminer la série')); + await tester.pumpAndSettle(); + + expect(find.text('Aucun temps chronométré'), findsOneWidget); + expect(find.text("Tu n'as pas démarré le chrono score."), findsOneWidget); + expect(find.text('Démarrer le chrono'), findsOneWidget); + expect(find.text('Terminer sans chrono'), findsOneWidget); + expect(repository.results, isEmpty); + }); + + testWidgets('terminer avec chrono en cours l’arrête et enregistre', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 1400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + 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: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + setsCount: 2, + scoreInputMode: ScoreInputMode.stopwatch, + ), + ); + 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.text('Démarrer')); + await tester.pump(); + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2, 500); + await tester.pump(const Duration(seconds: 1)); + await tester.tap(find.text('Terminer la série')); + await tester.pumpAndSettle(); + + expect(repository.results.single.actualScoreTimeMs, 2500); + expect( + repository.scoreStopwatchStates.single.status, + ActiveScoreStopwatchStatus.stopped, + ); + expect(repository.session?.currentSetIndex, 1); + }); + testWidgets('un repos actif est repris avec le temps restant recalculé', ( tester, ) async { @@ -850,7 +1004,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { required int exerciseIndex, required int setIndex, required DateTime deletedAt, - }) async {} + }) async { + scoreStopwatchStates.removeWhere( + (state) => + state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex, + ); + } @override Future saveSetResult(ActiveSetResult result) async {