diff --git a/assets/audio/step_completion_long.wav b/assets/audio/step_completion_long.wav new file mode 100644 index 0000000..129362b Binary files /dev/null and b/assets/audio/step_completion_long.wav differ diff --git a/assets/audio/step_countdown_short.wav b/assets/audio/step_countdown_short.wav new file mode 100644 index 0000000..a48e89a Binary files /dev/null and b/assets/audio/step_countdown_short.wav differ diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 436ef6f..ca9ed39 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -7,6 +7,7 @@ abstract interface class AppDependencies { ProgramUseCases get programUseCases; WorkoutTemplateUseCases get workoutTemplateUseCases; ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases; + ActiveExerciseStepUseCases get activeExerciseStepUseCases; CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase; WorkoutHistoryUseCases get workoutHistoryUseCases; } @@ -19,6 +20,7 @@ final class AppBootstrap implements AppDependencies { required this.programUseCases, required this.workoutTemplateUseCases, required this.activeWorkoutSessionUseCases, + required this.activeExerciseStepUseCases, required this.closeWorkoutSessionUseCase, required this.workoutHistoryUseCases, required this.syncGateway, @@ -36,6 +38,8 @@ final class AppBootstrap implements AppDependencies { @override final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; @override + final ActiveExerciseStepUseCases activeExerciseStepUseCases; + @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; @override final WorkoutHistoryUseCases workoutHistoryUseCases; @@ -93,6 +97,12 @@ final class AppBootstrap implements AppDependencies { ids: ids, originDeviceId: originDeviceId, ), + activeExerciseStepUseCases: ActiveExerciseStepUseCases( + sessionRepository: activeSessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ), closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase( sessionRepository: activeSessionRepository, historyRepository: historyRepository, diff --git a/lib/presentation/exercise_step_audio.dart b/lib/presentation/exercise_step_audio.dart new file mode 100644 index 0000000..ad07739 --- /dev/null +++ b/lib/presentation/exercise_step_audio.dart @@ -0,0 +1,49 @@ +import 'package:audioplayers/audioplayers.dart'; + +abstract interface class ExerciseStepAudioCuePlayer { + Future playShortCountdownBeep(); + + Future playLongCompletionBeep(); + + Future dispose(); +} + +final class AudioplayersExerciseStepAudioCuePlayer + implements ExerciseStepAudioCuePlayer { + AudioplayersExerciseStepAudioCuePlayer(); + + final AudioPlayer _player = AudioPlayer(); + + @override + Future playShortCountdownBeep() { + return _play('audio/step_countdown_short.wav'); + } + + @override + Future playLongCompletionBeep() { + return _play('audio/step_completion_long.wav'); + } + + Future _play(String assetPath) async { + await _player.stop(); + await _player.play(AssetSource(assetPath)); + } + + @override + Future dispose() { + return _player.dispose(); + } +} + +final class NoOpExerciseStepAudioCuePlayer implements ExerciseStepAudioCuePlayer { + const NoOpExerciseStepAudioCuePlayer(); + + @override + Future playShortCountdownBeep() async {} + + @override + Future playLongCompletionBeep() async {} + + @override + Future dispose() async {} +} diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index eb37453..e55fda1 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -14,12 +14,14 @@ final class HistoryListScreen extends StatefulWidget { required this.activeUseCases, required this.closeUseCase, this.mediaUseCases, + this.stepUseCases, super.key, }); final WorkoutHistoryUseCases historyUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases; final ActiveWorkoutSessionUseCases activeUseCases; + final ActiveExerciseStepUseCases? stepUseCases; final CloseWorkoutSessionUseCase closeUseCase; final MediaUseCases? mediaUseCases; @@ -94,6 +96,7 @@ final class _HistoryListScreenState extends State { historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, activeUseCases: widget.activeUseCases, + stepUseCases: widget.stepUseCases, closeUseCase: widget.closeUseCase, mediaUseCases: widget.mediaUseCases, ), @@ -115,6 +118,7 @@ final class HistoryDetailScreen extends StatelessWidget { required this.activeUseCases, required this.closeUseCase, this.mediaUseCases, + this.stepUseCases, super.key, }); @@ -122,6 +126,7 @@ final class HistoryDetailScreen extends StatelessWidget { final WorkoutHistoryUseCases historyUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases; final ActiveWorkoutSessionUseCases activeUseCases; + final ActiveExerciseStepUseCases? stepUseCases; final CloseWorkoutSessionUseCase closeUseCase; final MediaUseCases? mediaUseCases; @@ -193,6 +198,7 @@ final class HistoryDetailScreen extends StatelessWidget { builder: (context) => WorkoutExecutionScreen( initialSession: session, activeUseCases: activeUseCases, + stepUseCases: stepUseCases, closeUseCase: closeUseCase, historyUseCases: historyUseCases, workoutTemplateUseCases: workoutTemplateUseCases, diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 96f1167..112ad93 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -152,6 +152,7 @@ final class _HomeScreenState extends State with RouteAware { widget.bootstrap.workoutTemplateUseCases, programUseCases: widget.bootstrap.programUseCases, activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, + stepUseCases: widget.bootstrap.activeExerciseStepUseCases, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, historyUseCases: widget.bootstrap.workoutHistoryUseCases, mediaUseCases: widget.bootstrap.mediaUseCases, @@ -170,6 +171,7 @@ final class _HomeScreenState extends State with RouteAware { workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases, activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, + stepUseCases: widget.bootstrap.activeExerciseStepUseCases, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, mediaUseCases: widget.bootstrap.mediaUseCases, ), @@ -187,6 +189,7 @@ final class _HomeScreenState extends State with RouteAware { builder: (context) => WorkoutExecutionScreen( initialSession: session, activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, + stepUseCases: widget.bootstrap.activeExerciseStepUseCases, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, historyUseCases: widget.bootstrap.workoutHistoryUseCases, workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases, diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index af12a86..77ab5d1 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -1,4 +1,5 @@ export 'game_time_app.dart'; +export 'exercise_step_audio.dart'; export 'exercise_library_screen.dart'; export 'history_screen.dart'; export 'home_screen.dart'; diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index 7a164df..f3bc70d 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -7,6 +7,7 @@ import 'package:video_player/video_player.dart'; import '../application/application.dart'; import '../domain/domain.dart'; +import 'exercise_step_audio.dart'; import 'history_screen.dart'; import 'theme.dart'; @@ -24,17 +25,21 @@ final class WorkoutExecutionScreen extends StatefulWidget { this.mediaUseCases, this.mediaAssetLoader, this.videoMediaBuilder, + this.stepUseCases, + this.stepAudioCuePlayer, super.key, }); final ActiveWorkoutSession initialSession; final ActiveWorkoutSessionUseCases activeUseCases; + final ActiveExerciseStepUseCases? stepUseCases; final CloseWorkoutSessionUseCase closeUseCase; final WorkoutHistoryUseCases historyUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases; final MediaUseCases? mediaUseCases; final Future Function(String id)? mediaAssetLoader; final VideoMediaBuilder? videoMediaBuilder; + final ExerciseStepAudioCuePlayer? stepAudioCuePlayer; @override State createState() => _WorkoutExecutionScreenState(); @@ -47,10 +52,21 @@ final class _WorkoutExecutionScreenState extends State { Timer? _ticker; Timer? _restTicker; Timer? _scoreStopwatchTicker; + Timer? _stepTicker; + Timer? _stepScoreTicker; WorkoutHistory? _completedHistory; String? _activeRestStateId; ActiveScoreStopwatchState? _scoreStopwatch; + ActiveExerciseStepProgressView? _stepProgress; + late final ExerciseStepAudioCuePlayer _stepAudioCuePlayer; + late final bool _ownsStepAudioCuePlayer; + final _stepScoreController = TextEditingController(); int? _manualScoreTimeMs; + DateTime? _stepScoreStartedAt; + var _stepScoreAccumulatedMs = 0; + var _stepScoreRunning = false; + var _lastCountdownBeepSecond = -1; + var _advancingStep = false; late DateTime _seriesStartedAt; var _reps = 0; final _scoreController = TextEditingController(); @@ -69,6 +85,13 @@ final class _WorkoutExecutionScreenState extends State { super.initState(); _session = widget.initialSession; _plan = WorkoutExecutionPlan.fromSession(_session); + _ownsStepAudioCuePlayer = + widget.stepAudioCuePlayer == null && widget.stepUseCases != null; + _stepAudioCuePlayer = + widget.stepAudioCuePlayer ?? + (widget.stepUseCases == null + ? const NoOpExerciseStepAudioCuePlayer() + : AudioplayersExerciseStepAudioCuePlayer()); _mode = _session.status == ActiveWorkoutStatus.running ? WorkoutExecutionMode.active : WorkoutExecutionMode.paused; @@ -78,6 +101,7 @@ final class _WorkoutExecutionScreenState extends State { if (mounted) setState(() {}); }); unawaited(_loadScoreStopwatch()); + unawaited(_loadStepProgress()); unawaited(_restoreActiveRest()); } @@ -86,7 +110,13 @@ final class _WorkoutExecutionScreenState extends State { _ticker?.cancel(); _restTicker?.cancel(); _scoreStopwatchTicker?.cancel(); + _stepTicker?.cancel(); + _stepScoreTicker?.cancel(); + if (_ownsStepAudioCuePlayer) { + unawaited(_stepAudioCuePlayer.dispose()); + } _scoreController.dispose(); + _stepScoreController.dispose(); super.dispose(); } @@ -161,6 +191,27 @@ final class _WorkoutExecutionScreenState extends State { ), ], const SizedBox(height: 16), + if (_exercise.steps.isNotEmpty && widget.stepUseCases != null) ...[ + _StepSequencePanel( + view: _stepProgress, + exercise: _exercise, + remainingLabel: _stepRemainingLabel, + stepScoreController: _stepScoreController, + stepScoreElapsedLabel: _formatScoreStopwatch( + Duration(milliseconds: _stepScoreElapsedMs), + ), + stepScoreRunning: _stepScoreRunning, + onStartTimer: _startCurrentStepTimer, + onCompleteStep: _completeCurrentStep, + onSkipStep: _skipCurrentStep, + onSkipPassage: _skipCurrentPassage, + onSkipSequence: _skipSequence, + onStartStepScore: _startStepScore, + onStopStepScore: _stopStepScore, + onResetStepScore: _resetStepScore, + ), + const SizedBox(height: 16), + ], SetMeasureInput( exercise: _exercise, reps: _reps, @@ -502,6 +553,378 @@ final class _WorkoutExecutionScreenState extends State { setState(() => _manualScoreTimeMs = edited); } + String get _stepRemainingLabel { + final view = _stepProgress; + final step = view?.currentStep; + if (view == null || step == null || step.type != ExerciseStepType.time) { + return ''; + } + final stepUseCases = widget.stepUseCases; + if (stepUseCases == null) return ''; + final remainingMs = stepUseCases.remainingMilliseconds( + state: view.state, + step: step, + ); + return _formatStepCountdown(Duration(milliseconds: remainingMs)); + } + + int get _stepScoreElapsedMs { + if (!_stepScoreRunning || _stepScoreStartedAt == null) { + return _stepScoreAccumulatedMs; + } + return _stepScoreAccumulatedMs + + DateTime.now().toUtc().difference(_stepScoreStartedAt!).inMilliseconds; + } + + bool get _hasActiveStepSequence { + return _exercise.steps.isNotEmpty && + widget.stepUseCases != null && + _stepProgress?.state.status != + ActiveExerciseStepProgressStatus.sequenceComplete; + } + + Future _loadStepProgress({bool startNextTimed = false}) async { + final stepUseCases = widget.stepUseCases; + if (_exercise.steps.isEmpty || stepUseCases == null) { + _stepTicker?.cancel(); + _stepTicker = null; + if (!mounted) return; + setState(() => _stepProgress = null); + return; + } + try { + final view = await stepUseCases.startOrResumeProgress( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return; + _applyStepProgress(view); + if (startNextTimed) { + await _startCurrentStepTimerIfNeeded(); + } + _refreshStepTicker(); + } on Exception catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Impossible de charger la séquence : $error')), + ); + } + } + + Future _readStepProgress({bool startNextTimed = false}) async { + final stepUseCases = widget.stepUseCases; + if (_exercise.steps.isEmpty || stepUseCases == null) return; + try { + final view = await stepUseCases.readProgress( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return; + _applyStepProgress(view); + if (startNextTimed) { + await _startCurrentStepTimerIfNeeded(); + } + _refreshStepTicker(); + } on Exception catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Impossible de rafraîchir la séquence : $error')), + ); + } + } + + void _applyStepProgress(ActiveExerciseStepProgressView view) { + final previous = _stepProgress; + final previousKey = previous == null + ? null + : '${previous.state.currentPassageIndex}:${previous.state.currentStepIndex}'; + final nextKey = '${view.state.currentPassageIndex}:${view.state.currentStepIndex}'; + if (previousKey != null && previousKey != nextKey) { + _resetStepScoreInput(); + } + setState(() => _stepProgress = view); + } + + void _refreshStepTicker() { + final view = _stepProgress; + final running = + _mode == WorkoutExecutionMode.active && + view?.state.status == ActiveExerciseStepProgressStatus.runningTimer; + if (!running) { + _stepTicker?.cancel(); + _stepTicker = null; + _lastCountdownBeepSecond = -1; + return; + } + if (_stepTicker != null) return; + _stepTicker = Timer.periodic(const Duration(milliseconds: 100), (_) { + unawaited(_tickStepTimer()); + }); + } + + Future _tickStepTimer() async { + if (!mounted || _advancingStep) return; + final view = _stepProgress; + final step = view?.currentStep; + if (view == null || step == null || step.type != ExerciseStepType.time) { + return; + } + final stepUseCases = widget.stepUseCases; + if (stepUseCases == null) return; + final remainingMs = stepUseCases.remainingMilliseconds( + state: view.state, + step: step, + ); + final remainingSecond = (remainingMs / 1000).ceil(); + if (remainingSecond >= 1 && + remainingSecond <= 3 && + remainingSecond != _lastCountdownBeepSecond) { + _lastCountdownBeepSecond = remainingSecond; + await _playStepAudioCue(short: true); + } + if (remainingMs <= 0) { + _advancingStep = true; + _stepTicker?.cancel(); + _stepTicker = null; + await _playStepAudioCue(short: false); + await _readStepProgress(startNextTimed: true); + _advancingStep = false; + return; + } + setState(() {}); + } + + Future _playStepAudioCue({required bool short}) async { + try { + if (short) { + await _stepAudioCuePlayer.playShortCountdownBeep(); + } else { + await _stepAudioCuePlayer.playLongCompletionBeep(); + } + } on Object catch (error) { + debugPrint('Audio cue ignored: $error'); + } + } + + Future _startCurrentStepTimer() async { + if (_stepProgress?.currentStep?.type != ExerciseStepType.time) return; + try { + final stepUseCases = widget.stepUseCases; + if (stepUseCases == null) return; + final state = await stepUseCases.startTimer( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted || _stepProgress == null) return; + setState(() { + _stepProgress = ActiveExerciseStepProgressView( + state: state, + steps: _stepProgress!.steps, + expectedPassages: _stepProgress!.expectedPassages, + results: _stepProgress!.results, + ); + }); + _refreshStepTicker(); + } on Exception catch (error) { + _showStepError('Impossible de démarrer la séquence : $error'); + } + } + + Future _startCurrentStepTimerIfNeeded() async { + final view = _stepProgress; + final step = view?.currentStep; + if (view == null || + step == null || + step.type != ExerciseStepType.time || + view.state.status == ActiveExerciseStepProgressStatus.runningTimer) { + return; + } + await _startCurrentStepTimer(); + } + + Future _completeCurrentStep() async { + final view = _stepProgress; + final step = view?.currentStep; + if (view == null || step == null) return; + try { + final stepUseCases = widget.stepUseCases; + if (stepUseCases == null) return; + await stepUseCases.completeCurrentStep( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + actualReps: step.type == ExerciseStepType.reps + ? step.defaultTargetValue + : null, + actualScore: _actualStepScore(step), + actualScoreTimeMs: _actualStepScoreTimeMs(step), + ); + _resetStepScoreInput(); + await _readStepProgress(startNextTimed: true); + } on Exception catch (error) { + _showStepError('Impossible de valider l’étape : $error'); + } + } + + Future _skipCurrentStep() async { + final confirmed = await _confirmStepSkip( + 'Passer cette étape ?', + _stepProgress?.state.status == ActiveExerciseStepProgressStatus.runningTimer + ? 'Le chrono de cette étape sera arrêté.' + : 'Cette étape sera marquée comme passée.', + 'Passer l’étape', + ); + if (confirmed != true) return; + try { + final stepUseCases = widget.stepUseCases; + if (stepUseCases == null) return; + await stepUseCases.skipCurrentStep( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + _resetStepScoreInput(); + await _readStepProgress(startNextTimed: true); + } on Exception catch (error) { + _showStepError('Impossible de passer l’étape : $error'); + } + } + + Future _skipCurrentPassage() async { + final confirmed = await _confirmStepSkip( + 'Passer ce passage ?', + 'Les étapes restantes de ce passage seront marquées comme passées.', + 'Passer ce passage', + ); + if (confirmed != true) return; + try { + final stepUseCases = widget.stepUseCases; + if (stepUseCases == null) return; + await stepUseCases.skipCurrentPassage( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + _resetStepScoreInput(); + await _readStepProgress(startNextTimed: true); + } on Exception catch (error) { + _showStepError('Impossible de passer ce passage : $error'); + } + } + + Future _skipSequence() async { + final confirmed = await _confirmStepSkip( + 'Passer la séquence ?', + 'Toutes les étapes restantes seront marquées comme passées.', + 'Passer la séquence', + ); + if (confirmed != true) return; + try { + final stepUseCases = widget.stepUseCases; + if (stepUseCases == null) return; + await stepUseCases.skipSequence( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + _resetStepScoreInput(); + await _readStepProgress(); + } on Exception catch (error) { + _showStepError('Impossible de passer la séquence : $error'); + } + } + + Future _confirmStepSkip( + String title, + String content, + String actionLabel, + ) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(actionLabel), + ), + ], + ), + ); + } + + double? _actualStepScore(ExerciseStep step) { + if (!step.hasScore || step.scoreInputMode != ScoreInputMode.manual) { + return null; + } + return double.tryParse(_stepScoreController.text.trim()); + } + + int? _actualStepScoreTimeMs(ExerciseStep step) { + if (!step.hasScore || step.scoreInputMode != ScoreInputMode.stopwatch) { + return null; + } + final elapsed = _stepScoreElapsedMs; + return elapsed > 0 ? elapsed : null; + } + + void _startStepScore() { + if (_stepScoreRunning) return; + setState(() { + _stepScoreRunning = true; + _stepScoreStartedAt = DateTime.now().toUtc(); + }); + _stepScoreTicker ??= Timer.periodic(const Duration(milliseconds: 100), (_) { + if (mounted) setState(() {}); + }); + } + + void _stopStepScore() { + if (!_stepScoreRunning || _stepScoreStartedAt == null) return; + setState(() { + _stepScoreAccumulatedMs = _stepScoreElapsedMs; + _stepScoreStartedAt = null; + _stepScoreRunning = false; + }); + _stepScoreTicker?.cancel(); + _stepScoreTicker = null; + } + + void _resetStepScore() { + setState(_resetStepScoreInput); + } + + void _resetStepScoreInput() { + _stepScoreController.clear(); + _stepScoreStartedAt = null; + _stepScoreAccumulatedMs = 0; + _stepScoreRunning = false; + _stepScoreTicker?.cancel(); + _stepScoreTicker = null; + } + + void _showStepError(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + bool get _canPauseFromNavigation { return _mode == WorkoutExecutionMode.active || _mode == WorkoutExecutionMode.rest; @@ -514,6 +937,11 @@ final class _WorkoutExecutionScreenState extends State { Future _pause() async { _restTicker?.cancel(); + _stepTicker?.cancel(); + _stepTicker = null; + if (_stepScoreRunning) { + _stopStepScore(); + } if (_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) { await _stopScoreStopwatch(); } @@ -528,8 +956,10 @@ final class _WorkoutExecutionScreenState extends State { final restoredRest = await _restoreActiveRest(); if (!mounted || restoredRest) return; await _loadScoreStopwatch(); + await _loadStepProgress(); if (!mounted) return; setState(() => _mode = WorkoutExecutionMode.active); + _refreshStepTicker(); } Future _quitAndSave() async { @@ -589,6 +1019,24 @@ final class _WorkoutExecutionScreenState extends State { } Future _finishSet({required bool skipped}) async { + if (skipped && _hasActiveStepSequence) { + final confirmed = await _confirmStepSkip( + 'Passer la série ?', + 'La séquence en cours sera arrêtée.', + 'Passer la série', + ); + if (confirmed != true) return; + final stepUseCases = widget.stepUseCases; + if (stepUseCases == null) return; + await stepUseCases.skipSequence( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + _resetStepScoreInput(); + await _readStepProgress(); + } if (skipped && _scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) { final confirmed = await showDialog( @@ -813,7 +1261,10 @@ final class _WorkoutExecutionScreenState extends State { _activeRestStateId = null; _scoreStopwatch = null; _manualScoreTimeMs = null; + _stepProgress = null; + _resetStepScoreInput(); _refreshScoreStopwatchTicker(); + _refreshStepTicker(); _seriesStartedAt = DateTime.now().toUtc(); _session = await widget.activeUseCases.updateProgress( sessionId: _session.metadata.id, @@ -823,6 +1274,7 @@ final class _WorkoutExecutionScreenState extends State { ); if (!mounted) return; await _loadScoreStopwatch(); + await _loadStepProgress(); if (!mounted) return; setState(() => _mode = WorkoutExecutionMode.active); } @@ -848,6 +1300,7 @@ final class _WorkoutExecutionScreenState extends State { historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, activeUseCases: widget.activeUseCases, + stepUseCases: widget.stepUseCases, closeUseCase: widget.closeUseCase, ), ), @@ -880,6 +1333,7 @@ final class _WorkoutExecutionScreenState extends State { builder: (context) => WorkoutExecutionScreen( initialSession: session, activeUseCases: widget.activeUseCases, + stepUseCases: widget.stepUseCases, closeUseCase: widget.closeUseCase, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, @@ -972,6 +1426,381 @@ final class SetMeasureInput extends StatelessWidget { enum _MissingStopwatchAction { start, finishWithout } +final class _StepSequencePanel extends StatelessWidget { + const _StepSequencePanel({ + required this.view, + required this.exercise, + required this.remainingLabel, + required this.stepScoreController, + required this.stepScoreElapsedLabel, + required this.stepScoreRunning, + required this.onStartTimer, + required this.onCompleteStep, + required this.onSkipStep, + required this.onSkipPassage, + required this.onSkipSequence, + required this.onStartStepScore, + required this.onStopStepScore, + required this.onResetStepScore, + }); + + final ActiveExerciseStepProgressView? view; + final ExecutionExercise exercise; + final String remainingLabel; + final TextEditingController stepScoreController; + final String stepScoreElapsedLabel; + final bool stepScoreRunning; + final VoidCallback onStartTimer; + final VoidCallback onCompleteStep; + final VoidCallback onSkipStep; + final VoidCallback onSkipPassage; + final VoidCallback onSkipSequence; + final VoidCallback onStartStepScore; + final VoidCallback onStopStepScore; + final VoidCallback onResetStepScore; + + @override + Widget build(BuildContext context) { + final view = this.view; + if (view == null) { + return const CourtBlazerAccentPanel( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + final currentStep = view.currentStep; + final sequenceComplete = + view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete; + final completedPassages = sequenceComplete + ? view.expectedPassages + : view.state.currentPassageIndex; + return CourtBlazerAccentPanel( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('SÉQUENCE', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + view.expectedPassages > 1 && !sequenceComplete + ? 'Passage ${view.state.currentPassageIndex + 1} / ${view.expectedPassages}' + : view.expectedPassages > 1 + ? 'Séquence terminée' + : 'Passage en cours', + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var index = 0; index < view.steps.length; index++) ...[ + _StepProgressChip( + index: index, + status: _stepDisplayStatus(view, index), + ), + if (index < view.steps.length - 1) const SizedBox(width: 8), + ], + ], + ), + ), + const SizedBox(height: 16), + if (sequenceComplete || currentStep == null) + _SequenceCompleteSummary( + completedPassages: completedPassages, + expectedPassages: view.expectedPassages, + ) + else ...[ + Text( + 'ÉTAPE ${view.state.currentStepIndex + 1} / ${view.steps.length}', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + Text( + currentStep.name, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 12), + if (currentStep.type == ExerciseStepType.time) + _TimedStepBody( + step: currentStep, + remainingLabel: remainingLabel, + running: + view.state.status == + ActiveExerciseStepProgressStatus.runningTimer, + onStartTimer: onStartTimer, + ) + else + _RepsStepBody(step: currentStep, onCompleteStep: onCompleteStep), + if (currentStep.hasScore) ...[ + const SizedBox(height: 16), + _StepScoreInput( + step: currentStep, + controller: stepScoreController, + elapsedLabel: stepScoreElapsedLabel, + running: stepScoreRunning, + onStart: onStartStepScore, + onStop: onStopStepScore, + onReset: onResetStepScore, + ), + ], + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton( + onPressed: onSkipStep, + child: const Text('Passer l’étape'), + ), + TextButton( + onPressed: onSkipPassage, + child: const Text('Passer ce passage'), + ), + TextButton( + onPressed: onSkipSequence, + child: const Text('Passer la séquence'), + ), + ], + ), + ], + if (exercise.repsEnabled) ...[ + const SizedBox(height: 16), + Text( + 'Passages réalisés : $completedPassages / ${view.expectedPassages}', + ), + ], + ], + ), + ); + } +} + +final class _TimedStepBody extends StatelessWidget { + const _TimedStepBody({ + required this.step, + required this.remainingLabel, + required this.running, + required this.onStartTimer, + }); + + final ExerciseStep step; + final String remainingLabel; + final bool running; + final VoidCallback onStartTimer; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Objectif : ${step.defaultTargetValue} s'), + const SizedBox(height: 8), + Center( + child: Text( + remainingLabel, + style: AppTextStyles.timer(context).copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + if (!running) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onStartTimer, + icon: const Icon(Icons.play_arrow), + label: const Text('Démarrer la séquence'), + ), + ], + ], + ); + } +} + +final class _RepsStepBody extends StatelessWidget { + const _RepsStepBody({required this.step, required this.onCompleteStep}); + + final ExerciseStep step; + final VoidCallback onCompleteStep; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + '${step.defaultTargetValue}', + style: AppTextStyles.timer(context).copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onCompleteStep, + icon: const Icon(Icons.check), + label: const Text('Étape suivante'), + ), + ], + ); + } +} + +final class _StepScoreInput extends StatelessWidget { + const _StepScoreInput({ + required this.step, + required this.controller, + required this.elapsedLabel, + required this.running, + required this.onStart, + required this.onStop, + required this.onReset, + }); + + final ExerciseStep step; + final TextEditingController controller; + final String elapsedLabel; + final bool running; + final VoidCallback onStart; + final VoidCallback onStop; + final VoidCallback onReset; + + @override + Widget build(BuildContext context) { + if (step.scoreInputMode == ScoreInputMode.stopwatch) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Chrono score d’étape', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Center( + child: Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + alignment: WrapAlignment.center, + children: [ + FilledButton.icon( + onPressed: running ? onStop : onStart, + icon: Icon(running ? Icons.stop : Icons.play_arrow), + label: Text(running ? 'Arrêter' : 'Démarrer'), + ), + OutlinedButton.icon( + onPressed: onReset, + icon: const Icon(Icons.restart_alt), + label: const Text('Réinitialiser'), + ), + ], + ), + ], + ); + } + final unit = step.scoreUnit?.trim(); + return TextField( + controller: controller, + decoration: InputDecoration( + labelText: unit == null || unit.isEmpty + ? 'Score de l’étape' + : 'Score de l’étape ($unit)', + ), + keyboardType: TextInputType.number, + ); + } +} + +final class _SequenceCompleteSummary extends StatelessWidget { + const _SequenceCompleteSummary({ + required this.completedPassages, + required this.expectedPassages, + }); + + final int completedPassages; + final int expectedPassages; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Séquence terminée', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text('$completedPassages / $expectedPassages passages réalisés'), + ], + ); + } +} + +final class _StepProgressChip extends StatelessWidget { + const _StepProgressChip({required this.index, required this.status}); + + final int index; + final _StepDisplayStatus status; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = courtBlazerTokensOf(context); + final background = switch (status) { + _StepDisplayStatus.current => theme.colorScheme.primary, + _StepDisplayStatus.completed => tokens.success.withAlpha(35), + _StepDisplayStatus.skipped => theme.colorScheme.surface, + _StepDisplayStatus.todo => theme.colorScheme.surface, + }; + final border = switch (status) { + _StepDisplayStatus.current => theme.colorScheme.primary, + _StepDisplayStatus.completed => tokens.success, + _StepDisplayStatus.skipped => tokens.mutedText, + _StepDisplayStatus.todo => tokens.border, + }; + final foreground = status == _StepDisplayStatus.current + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface; + return Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: border), + ), + child: Text( + '${index + 1}', + style: theme.textTheme.labelLarge?.copyWith(color: foreground), + ), + ); + } +} + +enum _StepDisplayStatus { completed, skipped, current, todo } + +_StepDisplayStatus _stepDisplayStatus( + ActiveExerciseStepProgressView view, + int stepIndex, +) { + for (final result in view.results) { + if (result.passageIndex == view.state.currentPassageIndex && + result.stepIndex == stepIndex) { + return result.status == SetResultStatus.completed + ? _StepDisplayStatus.completed + : _StepDisplayStatus.skipped; + } + } + if (view.state.status != ActiveExerciseStepProgressStatus.sequenceComplete && + stepIndex == view.state.currentStepIndex) { + return _StepDisplayStatus.current; + } + if (stepIndex < view.state.currentStepIndex) { + return _StepDisplayStatus.completed; + } + return _StepDisplayStatus.todo; +} + final class _ScoreStopwatchInput extends StatelessWidget { const _ScoreStopwatchInput({ required this.elapsedLabel, @@ -2277,6 +3106,7 @@ final class ExecutionExercise { required this.scoreEnabled, required this.restSeconds, this.imageMediaIds = const [], + this.steps = const [], this.videoMediaId, this.scoreInputMode = ScoreInputMode.manual, this.scoreLabel, @@ -2302,6 +3132,7 @@ final class ExecutionExercise { scoreEnabled: exercise['scoreEnabled'] as bool, restSeconds: exercise['restSecondsOverride'] as int? ?? 0, imageMediaIds: _imageMediaIdsFromSnapshot(exercise), + steps: _exerciseStepsFromSnapshot(exercise), videoMediaId: exercise['videoMediaIdSnapshot'] as String? ?? exercise['exerciseVideoMediaIdSnapshot'] as String?, @@ -2333,6 +3164,7 @@ final class ExecutionExercise { final bool scoreEnabled; final int restSeconds; final List imageMediaIds; + final List steps; final String? videoMediaId; final ScoreInputMode scoreInputMode; final String? scoreLabel; @@ -2371,6 +3203,31 @@ List _imageMediaIdsFromSnapshot(Map exercise) { ]; } +List _exerciseStepsFromSnapshot(Map exercise) { + final rawSteps = exercise['exerciseStepsSnapshot']; + if (rawSteps is! List) return const []; + return rawSteps.map((rawStep) { + final step = rawStep as Map; + final typeName = step['type'] as String? ?? ExerciseStepType.time.name; + return ExerciseStep( + id: step['id'] as String, + position: step['position'] as int, + name: step['name'] as String, + type: ExerciseStepType.values.firstWhere( + (type) => type.name == typeName, + orElse: () => ExerciseStepType.time, + ), + defaultTargetValue: step['defaultTargetValue'] as int, + hasScore: step['hasScore'] as bool? ?? false, + scoreInputMode: _scoreInputModeFromSnapshot(step['scoreInputMode']), + scoreLabel: step['scoreLabel'] as String?, + scoreUnit: step['scoreUnit'] as String?, + defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(), + defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?, + ); + }).toList(growable: false); +} + ScoreInputMode _scoreInputModeFromSnapshot(Object? value) { return switch (value) { 'stopwatch' => ScoreInputMode.stopwatch, @@ -2466,6 +3323,16 @@ String _formatDuration(Duration duration) { return '$minutes:$seconds'; } +String _formatStepCountdown(Duration duration) { + final totalSeconds = (duration.inMilliseconds / 1000) + .ceil() + .clamp(0, 9999) + .toInt(); + final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0'); + final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; +} + String _formatScoreStopwatch(Duration duration) { final totalTenths = (duration.inMilliseconds / 100).floor(); final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0'); diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index d1bb840..05f480f 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -14,12 +14,14 @@ final class WorkoutTemplateListScreen extends StatefulWidget { required this.closeUseCase, required this.historyUseCases, this.mediaUseCases, + this.stepUseCases, super.key, }); final WorkoutTemplateUseCases workoutTemplateUseCases; final ProgramUseCases programUseCases; final ActiveWorkoutSessionUseCases activeUseCases; + final ActiveExerciseStepUseCases? stepUseCases; final CloseWorkoutSessionUseCase closeUseCase; final WorkoutHistoryUseCases historyUseCases; final MediaUseCases? mediaUseCases; @@ -140,6 +142,7 @@ final class _WorkoutTemplateListScreenState builder: (context) => WorkoutExecutionScreen( initialSession: session, activeUseCases: widget.activeUseCases, + stepUseCases: widget.stepUseCases, closeUseCase: widget.closeUseCase, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, diff --git a/pubspec.lock b/pubspec.lock index 6684c65..589efe6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.dev" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.dev" + source: hosted + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -813,6 +869,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.dev" + source: hosted + version: "3.4.1" term_glyph: dependency: transitive description: @@ -837,6 +901,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 39ce96c..a307db7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: drift: ^2.34.2 drift_flutter: ^0.3.1 image_picker: ^1.2.3 + audioplayers: ^6.8.1 path: ^1.9.1 path_provider: ^2.1.5 sqlite3_flutter_libs: ^0.6.0+eol @@ -29,6 +30,8 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/audio/ fonts: - family: Anton fonts: diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index ad6a979..43ff150 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -122,6 +122,12 @@ final class _FakeBootstrap implements AppDependencies { ids: _FakeIds(), originDeviceId: 'device-1', ), + activeExerciseStepUseCases = ActiveExerciseStepUseCases( + sessionRepository: activeRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ), closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase( sessionRepository: activeRepository, historyRepository: _FakeWorkoutHistoryRepository(), @@ -179,6 +185,9 @@ final class _FakeBootstrap implements AppDependencies { @override final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; + @override + final ActiveExerciseStepUseCases activeExerciseStepUseCases; + @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 34ff377..2de0dfd 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -942,6 +942,379 @@ void main() { expect(find.text('2 / 3'), findsOneWidget); }, ); + + testWidgets('le module séquence s’affiche pour un exercice avec étapes', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 1600)); + 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( + targetReps: 10, + exerciseSteps: [ + _step('step-1', 0, 'Dribble main droite', ExerciseStepType.time, 10), + _step('step-2', 1, 'Pompes', ExerciseStepType.reps, 8), + ], + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: _FakeStepAudioCuePlayer(), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + expect(find.text('SÉQUENCE'), findsOneWidget); + expect(find.text('Passage 1 / 10'), findsOneWidget); + expect(find.text('ÉTAPE 1 / 2'), findsOneWidget); + expect(find.text('Dribble main droite'), findsOneWidget); + expect(find.text('00:10'), findsOneWidget); + }); + + testWidgets('une étape répétitions peut être validée', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 1600)); + 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( + targetReps: 1, + exerciseSteps: [ + _step('step-1', 0, 'Pompes', ExerciseStepType.reps, 8), + ], + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: _FakeStepAudioCuePlayer(), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + expect(find.text('8'), findsWidgets); + await tester.tap(find.text('Étape suivante')); + await tester.pump(); + + expect(repository.stepResults.single.actualReps, 8); + expect(find.text('Séquence terminée'), findsWidgets); + }); + + testWidgets('une étape temps affiche un décompte recalculé', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 1600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository(); + final audio = _FakeStepAudioCuePlayer(); + 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( + targetReps: 1, + exerciseSteps: [ + _step('step-1', 0, 'Gainage', ExerciseStepType.time, 5), + ], + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: audio, + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + expect(find.text('00:05'), findsOneWidget); + await tester.tap(find.text('Démarrer la séquence')); + await tester.pump(); + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('00:03'), findsOneWidget); + expect(audio.shortBeeps, 1); + }); + + testWidgets('deux étapes temps consécutives s’enchaînent automatiquement', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 1600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository(); + final audio = _FakeStepAudioCuePlayer(); + 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( + targetReps: 1, + exerciseSteps: [ + _step('step-1', 0, 'Gauche', ExerciseStepType.time, 1), + _step('step-2', 1, 'Droite', ExerciseStepType.time, 2), + ], + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: audio, + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Démarrer la séquence')); + await tester.pump(); + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(); + + expect(repository.stepResults.single.stepIndex, 0); + expect(repository.stepResults.single.status, SetResultStatus.completed); + expect(find.text('ÉTAPE 2 / 2'), findsOneWidget); + expect(find.text('Droite'), findsOneWidget); + expect(find.text('00:02'), findsOneWidget); + expect(audio.longBeeps, 1); + }); + + testWidgets('la séquence passe au deuxième passage si répétitions > 1', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 1600)); + 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( + targetReps: 2, + exerciseSteps: [ + _step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4), + ], + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: _FakeStepAudioCuePlayer(), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Étape suivante')); + await tester.pump(); + + expect(find.text('Passage 2 / 2'), findsOneWidget); + expect(find.text('Passages réalisés : 1 / 2'), findsOneWidget); + }); + + testWidgets('terminer la série reste utilisable avant la fin de séquence', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 1800)); + 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, + restSeconds: 0, + targetReps: 2, + exerciseSteps: [ + _step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4), + ], + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: _FakeStepAudioCuePlayer(), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + await tester.ensureVisible(find.text('Terminer la série')); + await tester.tap(find.text('Terminer la série')); + await tester.pumpAndSettle(); + + expect(repository.results.single.setIndex, 0); + expect(repository.session?.currentSetIndex, 1); + }); + + testWidgets('les trois niveaux de skip de séquence fonctionnent', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 1800)); + 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( + targetReps: 2, + exerciseSteps: [ + _step('step-1', 0, 'Gauche', ExerciseStepType.reps, 3), + _step('step-2', 1, 'Droite', ExerciseStepType.reps, 3), + ], + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: _FakeStepAudioCuePlayer(), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Passer l’étape')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Passer l’étape')); + await tester.pumpAndSettle(); + + expect(repository.stepResults.single.stepIndex, 0); + expect(repository.stepResults.single.status, SetResultStatus.skipped); + + await tester.tap(find.text('Passer ce passage')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Passer ce passage')); + await tester.pumpAndSettle(); + + expect(find.text('Passage 2 / 2'), findsOneWidget); + + await tester.tap(find.text('Passer la séquence')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Passer la séquence')); + await tester.pumpAndSettle(); + + expect(find.text('Séquence terminée'), findsWidgets); + }); } Finder _textFieldWithLabel(String label) { @@ -987,6 +1360,18 @@ CloseWorkoutSessionUseCase _closeUseCase( ); } +ActiveExerciseStepUseCases _stepUseCases( + _FakeActiveSessionRepository repository, + _FakeClock clock, +) { + return ActiveExerciseStepUseCases( + sessionRepository: repository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + WorkoutHistoryUseCases _historyUseCases(_FakeClock clock) { return WorkoutHistoryUseCases( repository: _FakeWorkoutHistoryRepository(), @@ -1014,6 +1399,7 @@ String _sessionSnapshot({ String scoreUnit = 'kg', ScoreInputMode scoreInputMode = ScoreInputMode.manual, List imageMediaIds = const [], + List> exerciseSteps = const [], String? videoMediaId, }) { return jsonEncode({ @@ -1043,6 +1429,7 @@ String _sessionSnapshot({ 'imageMediaIdsSnapshot': imageMediaIds, 'exerciseVideoMediaIdSnapshot': videoMediaId, 'videoMediaIdSnapshot': videoMediaId, + 'exerciseStepsSnapshot': exerciseSteps, }, ], }), @@ -1052,6 +1439,34 @@ String _sessionSnapshot({ }); } +Map _step( + String id, + int position, + String name, + ExerciseStepType type, + int defaultTargetValue, { + bool hasScore = false, + ScoreInputMode scoreInputMode = ScoreInputMode.manual, + String? scoreLabel, + String? scoreUnit, + double? defaultTargetScore, + int? defaultTargetScoreTimeMs, +}) { + return { + 'id': id, + 'position': position, + 'name': name, + 'type': type.name, + 'defaultTargetValue': defaultTargetValue, + 'hasScore': hasScore, + 'scoreInputMode': scoreInputMode.name, + 'scoreLabel': scoreLabel, + 'scoreUnit': scoreUnit, + 'defaultTargetScore': defaultTargetScore, + 'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs, + }; +} + EntityMetadata _metadata(String id) { return EntityMetadata( id: id, @@ -1080,6 +1495,27 @@ final class _FakeIds implements IdGenerator { } } +final class _FakeStepAudioCuePlayer implements ExerciseStepAudioCuePlayer { + var shortBeeps = 0; + var longBeeps = 0; + var disposed = false; + + @override + Future playShortCountdownBeep() async { + shortBeeps += 1; + } + + @override + Future playLongCompletionBeep() async { + longBeeps += 1; + } + + @override + Future dispose() async { + disposed = true; + } +} + final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final results = [];