import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; 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'; enum WorkoutExecutionMode { active, rest, paused, finished } typedef VideoMediaBuilder = Widget Function(BuildContext context, MediaAsset asset); final class WorkoutExecutionScreen extends StatefulWidget { const WorkoutExecutionScreen({ required this.initialSession, required this.activeUseCases, required this.closeUseCase, required this.historyUseCases, required this.workoutTemplateUseCases, 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(); } final class _WorkoutExecutionScreenState extends State { late ActiveWorkoutSession _session; late final WorkoutExecutionPlan _plan; late WorkoutExecutionMode _mode; 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(); var _remainingRestSeconds = 0; ExecutionPosition get _position => ExecutionPosition( programIndex: _session.currentProgramIndex, exerciseIndex: _session.currentExerciseIndex, setIndex: _session.currentSetIndex, ); ExecutionExercise get _exercise => _plan.exerciseAt(_position); @override void initState() { 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; _reps = _initialRepsFor(_exercise); _seriesStartedAt = DateTime.now().toUtc(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); unawaited(_loadScoreStopwatch()); unawaited(_loadStepProgress()); unawaited(_restoreActiveRest()); } @override void dispose() { _ticker?.cancel(); _restTicker?.cancel(); _scoreStopwatchTicker?.cancel(); _stepTicker?.cancel(); _stepScoreTicker?.cancel(); if (_ownsStepAudioCuePlayer) { unawaited(_stepAudioCuePlayer.dispose()); } _scoreController.dispose(); _stepScoreController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvoked: _handleSystemBack, child: Scaffold( appBar: AppBar( automaticallyImplyLeading: false, leading: _canPauseFromNavigation ? IconButton( tooltip: 'Mettre en pause', onPressed: () => unawaited(_pause()), icon: const Icon(Icons.arrow_back), ) : null, title: Text(_plan.name), actions: [ IconButton( tooltip: 'Voir le plan', onPressed: _openWorkoutPlan, icon: const Icon(Icons.list_alt), ), TextButton(onPressed: _pause, child: const Text('Pause')), ], ), body: switch (_mode) { WorkoutExecutionMode.active => _buildActive(), WorkoutExecutionMode.rest => _buildRest(), WorkoutExecutionMode.paused => _buildPaused(), WorkoutExecutionMode.finished => _buildFinished(), }, ), ); } Widget _buildActive() { return ListView( padding: const EdgeInsets.all(16), children: [ _Header( elapsedLabel: _formatDuration( Duration( milliseconds: widget.activeUseCases.elapsedActiveMilliseconds( _session, ), ), ), progressLabel: _plan.progressLabel(_position), ), const SizedBox(height: 24), _SeriesCounterCard( currentSet: _position.setIndex + 1, totalSets: _exercise.setsCount, ), const SizedBox(height: 16), Text(_exercise.name, style: Theme.of(context).textTheme.headlineMedium), if (_exercise.hasMedia) ...[ const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, child: Tooltip( message: 'Voir les médias de l’exercice', child: OutlinedButton.icon( onPressed: () => _openExerciseMedia(_exercise), icon: const Icon(Icons.perm_media_outlined), label: const Text('Voir médias'), ), ), ), ], 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, 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), style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(56)), child: const Text('Terminer la série'), ), const SizedBox(height: 12), OutlinedButton( onPressed: () => _finishSet(skipped: true), style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(52), ), child: Text(_hasActiveStepSequence ? 'Passer la série' : 'Passer'), ), ], ); } Widget _buildRest() { final next = _plan.nextPosition(_position); final nextExercise = next == null ? null : _plan.exerciseAt(next); return Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text('Repos avant la prochaine série'), const SizedBox(height: 24), Center( child: Text( _formatDuration(Duration(seconds: _remainingRestSeconds)), style: AppTextStyles.timer(context), ), ), if (next != null && nextExercise != null) ...[ const SizedBox(height: 24), Text('Ensuite : ${nextExercise.name}', textAlign: TextAlign.center), const SizedBox(height: 8), Text( 'Série ${next.setIndex + 1} / ${nextExercise.setsCount}', textAlign: TextAlign.center, style: Theme.of(context).textTheme.displaySmall?.copyWith( color: Theme.of(context).colorScheme.primary, fontSize: 30, ), ), if (nextExercise.hasMedia) ...[ const SizedBox(height: 12), Center( child: Tooltip( message: 'Voir les médias de l’exercice', child: OutlinedButton.icon( onPressed: () => _openExerciseMedia(nextExercise), icon: const Icon(Icons.perm_media_outlined), label: const Text('Voir médias'), ), ), ), ], ], const Spacer(), Row( children: [ Expanded( child: OutlinedButton( onPressed: () => _adjustRest(-15), child: const Text('-15 s'), ), ), const SizedBox(width: 12), Expanded( child: OutlinedButton( onPressed: () => _adjustRest(15), child: const Text('+15 s'), ), ), ], ), const SizedBox(height: 12), FilledButton( onPressed: _skipRest, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), ), child: const Text('Ignorer le repos'), ), const SizedBox(height: 12), TextButton(onPressed: _pause, child: const Text('Pause')), ], ), ); } Widget _buildPaused() { return Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Pause', style: Theme.of(context).textTheme.headlineMedium), const SizedBox(height: 24), FilledButton( onPressed: _resume, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(56), ), child: const Text('Reprendre'), ), const SizedBox(height: 12), OutlinedButton( onPressed: _quitAndSave, child: const Text('Quitter et sauvegarder'), ), const Spacer(), TextButton( onPressed: _confirmAbandon, child: const Text('Abandonner la séance'), ), ], ), ); } Widget _buildFinished() { final elapsed = Duration( milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(_session), ); return Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Séance terminée', style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 12), Text('Temps total : ${_formatDuration(elapsed)}'), const SizedBox(height: 24), OutlinedButton( onPressed: _completedHistory == null ? null : _openHistoryDetail, child: const Text('Voir le détail'), ), OutlinedButton( onPressed: _completedHistory == null ? null : _restartCompleted, child: const Text('Relancer cette séance'), ), FilledButton( onPressed: () { Navigator.of(context).popUntil((route) => route.isFirst); }, child: const Text('Retour à l’accueil'), ), ], ), ); } Future _openExerciseMedia(ExecutionExercise exercise) async { if (!exercise.hasMedia) { return; } await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (context) => _ExerciseMediaSheet( exercise: exercise, restRemainingSeconds: _mode == WorkoutExecutionMode.rest ? _remainingRestSeconds : null, mediaAssetLoader: _loadMediaAsset, videoMediaBuilder: widget.videoMediaBuilder, ), ); } Future _loadMediaAsset(String id) { final loader = widget.mediaAssetLoader; if (loader != null) return loader(id); final mediaUseCases = widget.mediaUseCases; if (mediaUseCases == null) return Future.value(); return mediaUseCases.findById(id); } 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; _refreshScoreStopwatchTicker(); 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); _refreshScoreStopwatchTicker(); } 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; }); _refreshScoreStopwatchTicker(); } 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); _refreshScoreStopwatchTicker(); 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; }); _refreshScoreStopwatchTicker(); } 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; }); _refreshScoreStopwatchTicker(); } void _refreshScoreStopwatchTicker() { final shouldTick = _scoreStopwatch?.status == ActiveScoreStopwatchStatus.running; if (!shouldTick) { _scoreStopwatchTicker?.cancel(); _scoreStopwatchTicker = null; return; } if (_scoreStopwatchTicker != null) return; _scoreStopwatchTicker = Timer.periodic(const Duration(milliseconds: 100), ( _, ) { if (mounted) setState(() {}); }); } 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); } 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() { _refreshStepTicker(); return Future.value(); } 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; } void _handleSystemBack(bool didPop) { if (didPop || !_canPauseFromNavigation) return; unawaited(_pause()); } Future _pause() async { _restTicker?.cancel(); _stepTicker?.cancel(); _stepTicker = null; if (_stepScoreRunning) { _stopStepScore(); } if (_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) { await _stopScoreStopwatch(); } _session = await widget.activeUseCases.pause(_session.metadata.id); if (!mounted) return; setState(() => _mode = WorkoutExecutionMode.paused); } Future _resume() async { _session = await widget.activeUseCases.resume(_session.metadata.id); if (!mounted) return; final restoredRest = await _restoreActiveRest(); if (!mounted || restoredRest) return; await _loadScoreStopwatch(); await _loadStepProgress(); if (_stepProgress?.state.status == ActiveExerciseStepProgressStatus.pausedTimer) { await _startCurrentStepTimer(); } if (!mounted) return; setState(() => _mode = WorkoutExecutionMode.active); _refreshStepTicker(); } Future _quitAndSave() async { await widget.activeUseCases.quitAndSave(_session.metadata.id); if (!mounted) return; Navigator.of(context).popUntil((route) => route.isFirst); } Future _confirmAbandon() async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Abandonner la séance ?'), content: const Text('Cette action est définitive.'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Abandonner'), ), ], ), ); if (confirmed != true) return; await widget.activeUseCases.abandon(_session.metadata.id); if (!mounted) return; Navigator.of(context).popUntil((route) => route.isFirst); } Future _openWorkoutPlan() async { final setStates = await widget.activeUseCases.listSetResults( _session.metadata.id, ); final activeRest = await widget.activeUseCases.findActiveRest( sessionId: _session.metadata.id, ); if (!mounted) return; await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (context) => FractionallySizedBox( heightFactor: 1, child: _WorkoutPlanSheet( sessionId: _session.metadata.id, plan: _plan, currentPosition: _position, setStates: setStates, activeRest: activeRest, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, ), ), ); } 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( 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; }); _refreshScoreStopwatchTicker(); } 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) .inMilliseconds; await widget.activeUseCases.recordCurrentSetResult( sessionId: _session.metadata.id, programSnapshotId: _plan.programAt(_position).id, exerciseSnapshotId: _exercise.id, programIndex: _position.programIndex, exerciseIndex: _position.exerciseIndex, setIndex: _position.setIndex, actualReps: skipped ? null : (_exercise.repsEnabled ? _reps : null), actualScore: skipped ? null : (_exercise.manualScoreEnabled ? score : null), actualScoreTimeMs: actualScoreTimeMs, scoreInputModeSnapshot: _exercise.scoreInputMode, scoreLabelSnapshot: _exercise.scoreLabel, scoreUnitSnapshot: _exercise.scoreUnit, actualTimeMs: skipped ? null : (_exercise.timeEnabled ? actualTimeMs : null), ); final next = _plan.nextPosition(_position); if (next == null) { await _complete(); return; } final shouldRest = _plan.shouldShowRestAfter(_position); if (shouldRest) { final rest = await widget.activeUseCases.startRestAfterSet( sessionId: _session.metadata.id, afterProgramIndex: _position.programIndex, afterExerciseIndex: _position.exerciseIndex, afterSetIndex: _position.setIndex, plannedRestSeconds: _exercise.restSeconds, ); if (!mounted) return; _activeRestStateId = rest.metadata.id; _remainingRestSeconds = _exercise.restSeconds; _startRestTicker(next); setState(() => _mode = WorkoutExecutionMode.rest); return; } await _moveTo(next); } on Exception catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Impossible de terminer la série : $error')), ); return; } } void _startRestTicker(ExecutionPosition next) { _restTicker?.cancel(); _restTicker = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) return; if (_remainingRestSeconds <= 1) { timer.cancel(); unawaited(_finishRestAndMove(next)); } else { setState(() => _remainingRestSeconds -= 1); } }); } Future _restoreActiveRest() async { final rest = await widget.activeUseCases.findActiveRest( sessionId: _session.metadata.id, ); if (!mounted || rest == null) { return false; } final after = ExecutionPosition( programIndex: rest.afterProgramIndex, exerciseIndex: rest.afterExerciseIndex, setIndex: rest.afterSetIndex, ); final next = _plan.nextPosition(after); final remaining = _remainingSecondsFor(rest); _activeRestStateId = rest.metadata.id; if (next == null || remaining <= 0) { await _finishRestAndMove(next); return true; } _remainingRestSeconds = remaining; _startRestTicker(next); if (!mounted) return true; setState(() => _mode = WorkoutExecutionMode.rest); return true; } int _remainingSecondsFor(ActiveRestState rest) { final elapsedSeconds = DateTime.now() .toUtc() .difference(rest.startedAt.toUtc()) .inSeconds; return (rest.adjustedRestSeconds - elapsedSeconds).clamp(0, 9999).toInt(); } Future _adjustRest(int deltaSeconds) async { final restStateId = _activeRestStateId; setState(() { _remainingRestSeconds = (_remainingRestSeconds + deltaSeconds) .clamp(0, 9999) .toInt(); }); if (restStateId == null) return; await widget.activeUseCases.adjustRestSeconds( restStateId: restStateId, deltaSeconds: deltaSeconds, ); } Future _skipRest() async { _restTicker?.cancel(); final next = _plan.nextPosition(_position); await _finishRestAndMove(next); } Future _finishRestAndMove(ExecutionPosition? next) async { final restStateId = _activeRestStateId; _activeRestStateId = null; if (restStateId != null) { await widget.activeUseCases.skipRest(restStateId: restStateId); } if (next == null) { await _complete(); return; } await _moveTo(next); } Future _moveTo(ExecutionPosition position) async { _scoreController.clear(); _reps = _initialRepsFor(_plan.exerciseAt(position)); _activeRestStateId = null; _scoreStopwatch = null; _manualScoreTimeMs = null; _stepProgress = null; _resetStepScoreInput(); _refreshScoreStopwatchTicker(); _refreshStepTicker(); _seriesStartedAt = DateTime.now().toUtc(); _session = await widget.activeUseCases.updateProgress( sessionId: _session.metadata.id, programIndex: position.programIndex, exerciseIndex: position.exerciseIndex, setIndex: position.setIndex, ); if (!mounted) return; await _loadScoreStopwatch(); await _loadStepProgress(); if (!mounted) return; setState(() => _mode = WorkoutExecutionMode.active); } Future _complete() async { _session = await widget.activeUseCases.complete(_session.metadata.id); _completedHistory = await widget.closeUseCase.close( sessionId: _session.metadata.id, nameSnapshot: _plan.name, completed: true, ); if (!mounted) return; setState(() => _mode = WorkoutExecutionMode.finished); } Future _openHistoryDetail() async { final history = _completedHistory; if (history == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (context) => HistoryDetailScreen( history: history, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, closeUseCase: widget.closeUseCase, ), ), ); } Future _restartCompleted() async { final history = _completedHistory; if (history == null) return; ActiveWorkoutSession session; final sourceId = history.sourceWorkoutTemplateId; if (sourceId != null && await widget.workoutTemplateUseCases.findById(sourceId) != null) { session = await widget.activeUseCases.startFromTemplate(sourceId); } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( "La séance originale n'existe plus. Une copie va être utilisée.", ), ), ); } session = await widget.activeUseCases.startFromHistory(history); } if (!mounted) return; await Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => WorkoutExecutionScreen( initialSession: session, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, closeUseCase: widget.closeUseCase, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, mediaUseCases: widget.mediaUseCases, mediaAssetLoader: widget.mediaAssetLoader, videoMediaBuilder: widget.videoMediaBuilder, ), ), ); } int _initialRepsFor(ExecutionExercise exercise) { if (!exercise.repsEnabled) return 0; return exercise.targetReps ?? 0; } } final class SetMeasureInput extends StatelessWidget { const SetMeasureInput({ required this.exercise, required this.reps, required this.scoreController, required this.onRepsChanged, super.key, }); final ExecutionExercise exercise; final int reps; final TextEditingController scoreController; final ValueChanged onRepsChanged; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (exercise.timeEnabled) Card( child: Padding( padding: const EdgeInsets.all(24), child: Column( children: [ const Text('Temps'), const SizedBox(height: 8), Text( exercise.targetTimeSeconds == null ? 'Chronométrer' : '${exercise.targetTimeSeconds} s', style: AppTextStyles.scoreNumber(context), ), ], ), ), ), if (exercise.repsEnabled) ...[ const SizedBox(height: 12), Row( children: [ const Expanded(child: Text('Répétitions')), IconButton( onPressed: () => onRepsChanged((reps - 1).clamp(0, 999).toInt()), icon: const Icon(Icons.remove), ), Text('$reps', style: AppTextStyles.scoreNumber(context)), IconButton( onPressed: () => onRepsChanged(reps + 1), icon: const Icon(Icons.add), ), ], ), ], if (exercise.manualScoreEnabled) ...[ const SizedBox(height: 12), TextField( controller: scoreController, style: AppTextStyles.scoreNumber(context), decoration: InputDecoration( labelText: exercise.scoreUnit == null ? 'Score' : 'Score (${exercise.scoreUnit})', ), keyboardType: TextInputType.number, ), ], ], ); } } 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, readyToStart: _isNextTimedStepReady(view), 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.readyToStart, required this.onStartTimer, }); final ExerciseStep step; final String remainingLabel; final bool running; final bool readyToStart; 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), if (readyToStart) ...[ Text( 'Chrono suivant prêt', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Theme.of(context).colorScheme.primary, ), ), const SizedBox(height: 8), ], FilledButton.icon( onPressed: onStartTimer, icon: const Icon(Icons.play_arrow), label: Text( readyToStart ? 'Démarrer le chrono' : 'Démarrer la séquence', ), ), ], ], ); } } bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) { final step = view.currentStep; if (step == null || step.type != ExerciseStepType.time || view.state.status != ActiveExerciseStepProgressStatus.stoppedTimer) { return false; } return view.results.any((result) { if (result.passageIndex < view.state.currentPassageIndex) { return true; } return result.passageIndex == view.state.currentPassageIndex && result.stepIndex < view.state.currentStepIndex; }); } 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, 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, required this.plan, required this.currentPosition, required this.setStates, required this.activeUseCases, this.stepUseCases, this.activeRest, }); final String sessionId; final WorkoutExecutionPlan plan; final ExecutionPosition currentPosition; final List setStates; final ActiveWorkoutSessionUseCases activeUseCases; final ActiveExerciseStepUseCases? stepUseCases; final ActiveRestState? activeRest; @override State<_WorkoutPlanSheet> createState() => _WorkoutPlanSheetState(); } final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> { Timer? _timer; late int _remainingRestSeconds; late List _setStates; @override void initState() { super.initState(); _setStates = widget.setStates; _remainingRestSeconds = _remainingSecondsForRest(widget.activeRest); if (widget.activeRest != null && _remainingRestSeconds > 0) { _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) return; setState(() { _remainingRestSeconds = (_remainingRestSeconds - 1) .clamp(0, 9999) .toInt(); }); }); } } @override void dispose() { _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final statesByPosition = { for (final state in _setStates) _stateKey(state): state, }; return Scaffold( appBar: AppBar( title: const Text('Plan de séance'), leading: IconButton( tooltip: 'Fermer', onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ), ), body: ListView( padding: const EdgeInsets.all(16), children: [ const Text('Touchez une série pour voir ses valeurs.'), if (widget.activeRest != null) ...[ const SizedBox(height: 12), _RestPlanBanner(remainingSeconds: _remainingRestSeconds), ], const SizedBox(height: 24), for ( var programIndex = 0; programIndex < widget.plan.programs.length; programIndex++ ) ...[ Text( widget.plan.programs[programIndex].name, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), for ( var exerciseIndex = 0; exerciseIndex < widget.plan.programs[programIndex].exercises.length; exerciseIndex++ ) ...[ Text( widget .plan .programs[programIndex] .exercises[exerciseIndex] .name, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 4), for ( var setIndex = 0; setIndex < widget .plan .programs[programIndex] .exercises[exerciseIndex] .setsCount; setIndex++ ) _WorkoutPlanSetTile( position: ExecutionPosition( programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, ), currentPosition: widget.currentPosition, exercise: widget .plan .programs[programIndex] .exercises[exerciseIndex], state: statesByPosition[_positionKey( programIndex, exerciseIndex, setIndex, )], onUpdated: _refreshAfterEdit, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, sessionId: widget.sessionId, activeRest: widget.activeRest, ), const SizedBox(height: 16), ], ], ], ), ); } Future _refreshAfterEdit() async { final updated = await widget.activeUseCases.listSetResults( widget.sessionId, ); if (!mounted) return; setState(() => _setStates = updated); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Série mise à jour'))); } } final class _RestPlanBanner extends StatelessWidget { const _RestPlanBanner({required this.remainingSeconds}); final int remainingSeconds; @override Widget build(BuildContext context) { final label = remainingSeconds <= 0 ? 'Repos terminé · Reprendre' : 'Repos en cours · ${_formatDuration(Duration(seconds: remainingSeconds))}'; return CourtBlazerAccentPanel( padding: const EdgeInsets.all(12), child: Text(label), ); } } enum _MediaSheetSection { images, video } final class _ExerciseMediaSheet extends StatefulWidget { const _ExerciseMediaSheet({ required this.exercise, required this.mediaAssetLoader, this.videoMediaBuilder, this.restRemainingSeconds, }); final ExecutionExercise exercise; final Future Function(String id) mediaAssetLoader; final VideoMediaBuilder? videoMediaBuilder; final int? restRemainingSeconds; @override State<_ExerciseMediaSheet> createState() => _ExerciseMediaSheetState(); } final class _ExerciseMediaSheetState extends State<_ExerciseMediaSheet> { late final PageController _pageController; late _MediaSheetSection _section; var _imageIndex = 0; @override void initState() { super.initState(); _pageController = PageController(); _section = widget.exercise.imageMediaIds.isNotEmpty ? _MediaSheetSection.images : _MediaSheetSection.video; } @override void dispose() { _pageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final exercise = widget.exercise; final hasImages = exercise.imageMediaIds.isNotEmpty; final hasVideo = exercise.videoMediaId != null; return SizedBox( height: MediaQuery.of(context).size.height * 0.95, child: Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Médias de l’exercice'), Text(exercise.name, style: Theme.of(context).textTheme.bodySmall), ], ), automaticallyImplyLeading: false, actions: [ IconButton( tooltip: 'Fermer', onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ), ], ), body: ListView( padding: const EdgeInsets.all(16), children: [ if (widget.restRemainingSeconds != null) ...[ _RestPlanBanner(remainingSeconds: widget.restRemainingSeconds!), const SizedBox(height: 16), ], if (hasImages && hasVideo) ...[ Wrap( spacing: 8, children: [ ChoiceChip( label: const Text('Images'), selected: _section == _MediaSheetSection.images, onSelected: (_) { setState(() => _section = _MediaSheetSection.images); }, ), ChoiceChip( label: const Text('Vidéo'), selected: _section == _MediaSheetSection.video, onSelected: (_) { setState(() => _section = _MediaSheetSection.video); }, ), ], ), const SizedBox(height: 16), ], if (_section == _MediaSheetSection.images && hasImages) _ImageMediaGallery( imageMediaIds: exercise.imageMediaIds, pageController: _pageController, currentIndex: _imageIndex, onPageChanged: (index) => setState(() => _imageIndex = index), mediaAssetLoader: widget.mediaAssetLoader, ) else if (hasVideo) _VideoMediaPanel( videoMediaId: exercise.videoMediaId!, mediaAssetLoader: widget.mediaAssetLoader, videoMediaBuilder: widget.videoMediaBuilder, ), ], ), ), ); } } final class _ImageMediaGallery extends StatelessWidget { const _ImageMediaGallery({ required this.imageMediaIds, required this.pageController, required this.currentIndex, required this.onPageChanged, required this.mediaAssetLoader, }); final List imageMediaIds; final PageController pageController; final int currentIndex; final ValueChanged onPageChanged; final Future Function(String id) mediaAssetLoader; @override Widget build(BuildContext context) { return CourtBlazerAccentPanel( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Align( alignment: Alignment.centerRight, child: Text( '${currentIndex + 1}/${imageMediaIds.length}', style: Theme.of(context).textTheme.labelLarge?.copyWith( color: Theme.of(context).colorScheme.primary, ), ), ), const SizedBox(height: 12), SizedBox( height: 320, child: PageView.builder( controller: pageController, itemCount: imageMediaIds.length, onPageChanged: onPageChanged, itemBuilder: (context, index) { return _ImageMediaPage( mediaAssetId: imageMediaIds[index], index: index, mediaAssetLoader: mediaAssetLoader, ); }, ), ), ], ), ); } } final class _ImageMediaPage extends StatelessWidget { const _ImageMediaPage({ required this.mediaAssetId, required this.index, required this.mediaAssetLoader, }); final String mediaAssetId; final int index; final Future Function(String id) mediaAssetLoader; @override Widget build(BuildContext context) { final tokens = courtBlazerTokensOf(context); return Container( margin: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), border: Border.all(color: tokens.border), color: Theme.of(context).colorScheme.surface, ), child: FutureBuilder( future: mediaAssetLoader(mediaAssetId), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } final asset = snapshot.data; if (asset == null || asset.kind != MediaKind.image) { return _MissingMediaPlaceholder( icon: Icons.broken_image_outlined, title: 'Image indisponible', subtitle: mediaAssetId, ); } return ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.file( _fileFromLocalUri(asset.localUri), key: ValueKey('exercise-image-${asset.metadata.id}'), fit: BoxFit.contain, errorBuilder: (context, error, stackTrace) { return _MissingMediaPlaceholder( icon: Icons.broken_image_outlined, title: 'Image introuvable', subtitle: asset.metadata.id, ); }, ), ); }, ), ); } } final class _VideoMediaPanel extends StatelessWidget { const _VideoMediaPanel({ required this.videoMediaId, required this.mediaAssetLoader, this.videoMediaBuilder, }); final String videoMediaId; final Future Function(String id) mediaAssetLoader; final VideoMediaBuilder? videoMediaBuilder; @override Widget build(BuildContext context) { return CourtBlazerAccentPanel( padding: const EdgeInsets.all(16), child: FutureBuilder( future: mediaAssetLoader(videoMediaId), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const SizedBox( height: 260, child: Center(child: CircularProgressIndicator()), ); } final asset = snapshot.data; if (asset == null || asset.kind != MediaKind.video) { return SizedBox( height: 260, child: _MissingMediaPlaceholder( icon: Icons.videocam_off_outlined, title: 'Vidéo indisponible', subtitle: videoMediaId, ), ); } final builder = videoMediaBuilder; if (builder != null) { return builder(context, asset); } return _VideoPlayerSurface(asset: asset); }, ), ); } } final class _VideoPlayerSurface extends StatefulWidget { const _VideoPlayerSurface({required this.asset}); final MediaAsset asset; @override State<_VideoPlayerSurface> createState() => _VideoPlayerSurfaceState(); } final class _VideoPlayerSurfaceState extends State<_VideoPlayerSurface> { late final VideoPlayerController _controller; late final Future _initialize; @override void initState() { super.initState(); _controller = VideoPlayerController.file( _fileFromLocalUri(widget.asset.localUri), ); _initialize = _controller.initialize().then((_) { if (mounted) setState(() {}); }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return FutureBuilder( future: _initialize, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const SizedBox( height: 260, child: Center(child: CircularProgressIndicator()), ); } if (snapshot.hasError || !_controller.value.isInitialized) { return SizedBox( height: 260, child: _MissingMediaPlaceholder( icon: Icons.videocam_off_outlined, title: 'Vidéo introuvable', subtitle: widget.asset.metadata.id, ), ); } return Column( mainAxisSize: MainAxisSize.min, children: [ AspectRatio( aspectRatio: _controller.value.aspectRatio, child: VideoPlayer(_controller), ), const SizedBox(height: 12), IconButton.filled( tooltip: _controller.value.isPlaying ? 'Pause' : 'Lecture', onPressed: () async { if (_controller.value.isPlaying) { await _controller.pause(); } else { await _controller.play(); } if (mounted) setState(() {}); }, icon: Icon( _controller.value.isPlaying ? Icons.pause : Icons.play_arrow, ), ), ], ); }, ); } } final class _MissingMediaPlaceholder extends StatelessWidget { const _MissingMediaPlaceholder({ required this.icon, required this.title, required this.subtitle, }); final IconData icon; final String title; final String subtitle; @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, size: 64, color: Theme.of(context).colorScheme.primary), const SizedBox(height: 16), Text(title, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 4), Text(subtitle, style: Theme.of(context).textTheme.bodySmall), ], ), ); } } File _fileFromLocalUri(String localUri) { final uri = Uri.tryParse(localUri); if (uri != null && uri.hasScheme) { return File.fromUri(uri); } return File(localUri); } final class _WorkoutPlanSetTile extends StatefulWidget { const _WorkoutPlanSetTile({ required this.position, required this.currentPosition, required this.exercise, required this.onUpdated, required this.activeUseCases, required this.sessionId, this.stepUseCases, this.activeRest, this.state, }); final ExecutionPosition position; final ExecutionPosition currentPosition; final ExecutionExercise exercise; final SetResultPositionState? state; final Future Function() onUpdated; final ActiveWorkoutSessionUseCases activeUseCases; final ActiveExerciseStepUseCases? stepUseCases; final String sessionId; final ActiveRestState? activeRest; @override State<_WorkoutPlanSetTile> createState() => _WorkoutPlanSetTileState(); } final class _WorkoutPlanSetTileState extends State<_WorkoutPlanSetTile> { late final Future _stepProgress; @override void initState() { super.initState(); _stepProgress = _loadStepProgress(); } Future _loadStepProgress() async { final stepUseCases = widget.stepUseCases; if (stepUseCases == null || widget.exercise.steps.isEmpty) { return null; } final planState = _planSetState( widget.position, widget.currentPosition, widget.state, ); if (planState == _PlanSetStatus.todo) { return null; } return stepUseCases.readProgress( sessionId: widget.sessionId, programIndex: widget.position.programIndex, exerciseIndex: widget.position.exerciseIndex, setIndex: widget.position.setIndex, ); } @override Widget build(BuildContext context) { final planState = _planSetState( widget.position, widget.currentPosition, widget.state, ); final tappable = planState != _PlanSetStatus.todo; final VoidCallback? onTap = tappable ? () => unawaited(_handleTap(context, planState)) : null; final tokens = courtBlazerTokensOf(context); return Card( margin: const EdgeInsets.only(bottom: 8), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), side: BorderSide( color: planState == _PlanSetStatus.current ? Theme.of(context).colorScheme.primary : tokens.border, ), ), child: ListTile( enabled: tappable, title: Text('Série ${widget.position.setIndex + 1}'), subtitle: _WorkoutPlanSetSubtitle( status: planState, result: widget.state?.result, exercise: widget.exercise, stepProgress: _stepProgress, ), trailing: _PlanStatusBadge(status: planState, onTap: onTap), onTap: onTap, ), ); } Future _handleTap(BuildContext context, _PlanSetStatus status) async { if (status == _PlanSetStatus.current) { Navigator.of(context).pop(); return; } await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (context) => _EditSetResultSheet( sessionId: widget.sessionId, position: widget.position, exercise: widget.exercise, state: widget.state, status: status, activeUseCases: widget.activeUseCases, activeRest: widget.activeRest, onSaved: widget.onUpdated, ), ); } } final class _WorkoutPlanSetSubtitle extends StatelessWidget { const _WorkoutPlanSetSubtitle({ required this.status, required this.result, required this.exercise, required this.stepProgress, }); final _PlanSetStatus status; final ActiveSetResult? result; final ExecutionExercise exercise; final Future stepProgress; @override Widget build(BuildContext context) { final resultLabel = _planSetSubtitle(status, result, exercise); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (resultLabel.isNotEmpty) Text(resultLabel), if (exercise.steps.isNotEmpty) FutureBuilder( future: stepProgress, builder: (context, snapshot) { final progress = snapshot.data; final label = progress == null ? 'Séquence : 0/${exercise.steps.length} étapes' : _planStepSequenceSummary(progress); return Padding( padding: EdgeInsets.only(top: resultLabel.isEmpty ? 0 : 4), child: Text(label), ); }, ), ], ); } } final class _EditSetResultSheet extends StatefulWidget { const _EditSetResultSheet({ required this.sessionId, required this.position, required this.exercise, required this.state, required this.status, required this.activeUseCases, required this.onSaved, this.activeRest, }); final String sessionId; final ExecutionPosition position; final ExecutionExercise exercise; final SetResultPositionState? state; final _PlanSetStatus status; final ActiveWorkoutSessionUseCases activeUseCases; final Future Function() onSaved; final ActiveRestState? activeRest; @override State<_EditSetResultSheet> createState() => _EditSetResultSheetState(); } final class _EditSetResultSheetState extends State<_EditSetResultSheet> { final _timeController = TextEditingController(); final _repsController = TextEditingController(); final _scoreController = TextEditingController(); Timer? _timer; late int _remainingRestSeconds; @override void initState() { super.initState(); final result = widget.state?.result; final actualTimeMs = result?.actualTimeMs; if (actualTimeMs != null) { _timeController.text = (actualTimeMs / 1000).round().toString(); } final actualReps = result?.actualReps; if (actualReps != null) { _repsController.text = actualReps.toString(); } final actualScore = result?.actualScore; if (actualScore != null) { _scoreController.text = _formatScore(actualScore); } 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), (_) { if (!mounted) return; setState(() { _remainingRestSeconds = (_remainingRestSeconds - 1) .clamp(0, 9999) .toInt(); }); }); } } @override void dispose() { _timer?.cancel(); _timeController.dispose(); _repsController.dispose(); _scoreController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final isCompleted = widget.status == _PlanSetStatus.completed; return Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: MediaQuery.of(context).viewInsets.bottom + 16, ), child: ListView( shrinkWrap: true, children: [ Row( children: [ Expanded( child: Text( 'Modifier la série', style: Theme.of(context).textTheme.titleLarge, ), ), IconButton( tooltip: 'Annuler', onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ), ], ), const SizedBox(height: 8), Text( '${widget.exercise.name} · ' 'Série ${widget.position.setIndex + 1}/${widget.exercise.setsCount}', ), const SizedBox(height: 8), Text( 'État actuel : ${isCompleted ? 'Terminée' : 'Passée'}', style: Theme.of(context).textTheme.bodySmall, ), if (widget.activeRest != null) ...[ const SizedBox(height: 12), _RestPlanBanner(remainingSeconds: _remainingRestSeconds), ], const SizedBox(height: 24), if (widget.exercise.timeEnabled) ...[ TextField( controller: _timeController, decoration: const InputDecoration(labelText: 'Temps réalisé (s)'), keyboardType: TextInputType.number, ), const SizedBox(height: 12), ], if (widget.exercise.repsEnabled) ...[ TextField( controller: _repsController, decoration: const InputDecoration(labelText: 'Répétitions'), keyboardType: TextInputType.number, ), const SizedBox(height: 12), ], if (widget.exercise.scoreEnabled) ...[ TextField( controller: _scoreController, decoration: InputDecoration( labelText: widget.exercise.stopwatchScoreEnabled ? 'Temps réalisé (chrono score, s)' : widget.exercise.scoreUnit == null ? 'Score' : 'Score (${widget.exercise.scoreUnit})', ), keyboardType: TextInputType.number, ), const SizedBox(height: 12), ], const SizedBox(height: 12), FilledButton( onPressed: _saveCompleted, child: Text( isCompleted ? 'Enregistrer les corrections' : 'Enregistrer le résultat', ), ), const SizedBox(height: 8), OutlinedButton( onPressed: _markSkipped, child: const Text('Marquer comme passée'), ), TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Annuler'), ), ], ), ); } Future _saveCompleted() async { if (!_hasAnyValue()) { if (widget.status == _PlanSetStatus.completed) { final confirmed = await _confirmMarkSkipped(); if (confirmed != true) return; } await _saveSkipped(); return; } await widget.activeUseCases.upsertSetResultAtPosition( sessionId: widget.sessionId, programIndex: widget.position.programIndex, exerciseIndex: widget.position.exerciseIndex, setIndex: widget.position.setIndex, status: SetResultStatus.completed, actualTimeMs: widget.exercise.timeEnabled ? _secondsToMilliseconds(_timeController.text) : null, actualReps: widget.exercise.repsEnabled ? int.tryParse(_repsController.text.trim()) : null, actualScore: widget.exercise.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(); } Future _markSkipped() async { await _saveSkipped(); } Future _saveSkipped() async { await widget.activeUseCases.upsertSetResultAtPosition( sessionId: widget.sessionId, programIndex: widget.position.programIndex, exerciseIndex: widget.position.exerciseIndex, setIndex: widget.position.setIndex, status: SetResultStatus.skipped, ); await _finishSave(); } Future _finishSave() async { await widget.onSaved(); if (!mounted) return; Navigator.of(context).pop(); } bool _hasAnyValue() { return _timeController.text.trim().isNotEmpty || _repsController.text.trim().isNotEmpty || _scoreController.text.trim().isNotEmpty; } Future _confirmMarkSkipped() { return showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Supprimer le résultat de cette série ?'), content: const Text( 'La série restera dans le plan comme passée, sans résultat saisi.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Marquer comme passée'), ), ], ), ); } } final class _PlanStatusBadge extends StatelessWidget { const _PlanStatusBadge({required this.status, this.onTap}); final _PlanSetStatus status; final VoidCallback? onTap; @override Widget build(BuildContext context) { final tokens = courtBlazerTokensOf(context); final label = switch (status) { _PlanSetStatus.todo => 'À faire', _PlanSetStatus.current => 'En cours', _PlanSetStatus.completed => 'Terminée', _PlanSetStatus.passed => 'Passée', }; final color = switch (status) { _PlanSetStatus.completed => tokens.success, _PlanSetStatus.current => Theme.of(context).colorScheme.primary, _ => tokens.border, }; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), child: Chip( label: Text(label), backgroundColor: color.withAlpha(35), side: BorderSide(color: color), ), ); } } enum _PlanSetStatus { todo, current, completed, passed } _PlanSetStatus _planSetState( ExecutionPosition position, ExecutionPosition currentPosition, SetResultPositionState? state, ) { if (_comparePosition(position, currentPosition) == 0) { return _PlanSetStatus.current; } if (state?.status == SetPositionStatus.completed) { return _PlanSetStatus.completed; } if (state?.status == SetPositionStatus.skipped || _comparePosition(position, currentPosition) < 0) { return _PlanSetStatus.passed; } return _PlanSetStatus.todo; } String _planSetSubtitle( _PlanSetStatus status, ActiveSetResult? result, ExecutionExercise exercise, ) { if (status == _PlanSetStatus.completed && result != null) { return _setResultSummary(result, exercise: exercise); } if (status == _PlanSetStatus.passed) { return 'Aucun résultat'; } return ''; } String _planStepSequenceSummary(ActiveExerciseStepProgressView view) { final state = view.state; final stepsCount = view.steps.length; final passageIndex = state.status == ActiveExerciseStepProgressStatus.sequenceComplete ? (view.expectedPassages - 1).clamp(0, 9999).toInt() : state.currentPassageIndex; final completedInPassage = state.status == ActiveExerciseStepProgressStatus.sequenceComplete ? stepsCount : view.results .where((result) => result.passageIndex == passageIndex) .map((result) => result.stepIndex) .toSet() .length .clamp(0, stepsCount) .toInt(); final passageLabel = view.expectedPassages > 1 ? ' · Passage ${passageIndex + 1}/${view.expectedPassages}' : ''; return 'Séquence : $completedInPassage/$stepsCount étapes$passageLabel'; } String _setResultSummary( ActiveSetResult result, { ExecutionExercise? exercise, }) { final parts = []; if (result.actualTimeMs != null) { parts.add(_formatDuration(Duration(milliseconds: result.actualTimeMs!))); } if (result.actualReps != null) { parts.add('${result.actualReps} reps'); } if (result.actualScore != null) { final value = _formatScore(result.actualScore!); 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!), ); final targetScoreTimeMs = exercise?.targetScoreTimeMs; if (targetScoreTimeMs == null) { parts.add('Score chrono $value'); } else { final targetValue = _formatScoreStopwatchTarget( Duration(milliseconds: targetScoreTimeMs), ); parts.add('Score chrono $value / objectif $targetValue'); } } return parts.isEmpty ? 'Aucun résultat' : parts.join(' · '); } String _formatScore(double value) { return value == value.roundToDouble() ? value.round().toString() : value.toString(); } int? _secondsToMilliseconds(String rawValue) { final seconds = int.tryParse(rawValue.trim()); 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() .toUtc() .difference(rest.startedAt.toUtc()) .inSeconds; return (rest.adjustedRestSeconds - elapsedSeconds).clamp(0, 9999).toInt(); } String _stateKey(SetResultPositionState state) { return _positionKey(state.programIndex, state.exerciseIndex, state.setIndex); } String _positionKey(int programIndex, int exerciseIndex, int setIndex) { return '$programIndex:$exerciseIndex:$setIndex'; } int _comparePosition(ExecutionPosition left, ExecutionPosition right) { final program = left.programIndex.compareTo(right.programIndex); if (program != 0) return program; final exercise = left.exerciseIndex.compareTo(right.exerciseIndex); if (exercise != 0) return exercise; return left.setIndex.compareTo(right.setIndex); } final class WorkoutExecutionPlan { const WorkoutExecutionPlan({required this.name, required this.programs}); factory WorkoutExecutionPlan.fromSession(ActiveWorkoutSession session) { final snapshot = jsonDecode(session.resolvedTemplateSnapshotJson) as Map; final overrides = snapshot['overrides'] as List? ?? const []; final programs = (snapshot['programs'] as List? ?? const []).map(( rawProgram, ) { final program = rawProgram as Map; final programSnapshot = jsonDecode(program['programSnapshotJson'] as String) as Map; return ExecutionProgram.fromSnapshot( id: program['id'] as String, name: program['programNameSnapshot'] as String, snapshot: programSnapshot, overrides: overrides, ); }).toList(); return WorkoutExecutionPlan( name: snapshot['name'] as String? ?? 'Séance', programs: programs, ); } final String name; final List programs; ExecutionProgram programAt(ExecutionPosition position) { return programs[position.programIndex]; } ExecutionExercise exerciseAt(ExecutionPosition position) { return programAt(position).exercises[position.exerciseIndex]; } ExecutionPosition? nextPosition(ExecutionPosition position) { final exercise = exerciseAt(position); if (position.setIndex + 1 < exercise.setsCount) { return position.copyWith(setIndex: position.setIndex + 1); } final program = programAt(position); if (position.exerciseIndex + 1 < program.exercises.length) { return ExecutionPosition( programIndex: position.programIndex, exerciseIndex: position.exerciseIndex + 1, setIndex: 0, ); } if (position.programIndex + 1 < programs.length) { return ExecutionPosition( programIndex: position.programIndex + 1, exerciseIndex: 0, setIndex: 0, ); } return null; } bool shouldShowRestAfter(ExecutionPosition position) { return nextPosition(position) != null && exerciseAt(position).restSeconds > 0; } String progressLabel(ExecutionPosition position) { final program = programAt(position); return 'Programme ${position.programIndex + 1}/${programs.length} · ' 'Exercice ${position.exerciseIndex + 1}/${program.exercises.length}'; } } final class ExecutionProgram { const ExecutionProgram({ required this.id, required this.name, required this.exercises, }); factory ExecutionProgram.fromSnapshot({ required String id, required String name, required Map snapshot, required List overrides, }) { final exercises = (snapshot['exercises'] as List? ?? const []).map( (raw) { final exercise = raw as Map; Map? override; for (final rawOverride in overrides) { final item = rawOverride as Map; if (item['workoutTemplateProgramId'] == id && item['snapshotProgramExerciseId'] == exercise['id']) { override = item; break; } } return ExecutionExercise.fromSnapshot(exercise, override); }, ).toList(); return ExecutionProgram(id: id, name: name, exercises: exercises); } final String id; final String name; final List exercises; } final class ExecutionExercise { const ExecutionExercise({ required this.id, required this.name, required this.setsCount, required this.timeEnabled, required this.repsEnabled, required this.scoreEnabled, required this.restSeconds, this.imageMediaIds = const [], this.steps = const [], this.videoMediaId, this.scoreInputMode = ScoreInputMode.manual, this.scoreLabel, this.targetTimeSeconds, this.targetReps, this.targetScore, this.targetScoreTimeMs, this.scoreUnit, }); factory ExecutionExercise.fromSnapshot( Map exercise, Map? override, ) { return ExecutionExercise( id: exercise['id'] as String, name: exercise['exerciseNameSnapshot'] as String, setsCount: (override?['setsCountOverride'] as int?) ?? (exercise['setsCount'] as int), timeEnabled: exercise['timeEnabled'] as bool, repsEnabled: exercise['repsEnabled'] as bool, 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?, scoreInputMode: _scoreInputModeFromSnapshot( exercise['scoreInputModeSnapshot'], ), scoreLabel: exercise['scoreLabelSnapshot'] as String?, targetTimeSeconds: (override?['targetTimeSecondsOverride'] as int?) ?? (exercise['targetTimeSeconds'] as int?), targetReps: (override?['targetRepsOverride'] as int?) ?? (exercise['targetReps'] as int?), targetScore: (override?['targetScoreOverride'] as num?)?.toDouble() ?? (exercise['targetScore'] as num?)?.toDouble(), targetScoreTimeMs: (override?['targetScoreTimeMsOverride'] as int?) ?? (exercise['targetScoreTimeMs'] as int?), scoreUnit: exercise['scoreUnitSnapshot'] as String?, ); } final String id; final String name; final int setsCount; final bool timeEnabled; final bool repsEnabled; final bool scoreEnabled; final int restSeconds; final List imageMediaIds; final List steps; final String? videoMediaId; final ScoreInputMode scoreInputMode; final String? scoreLabel; final int? targetTimeSeconds; final int? targetReps; final double? targetScore; final int? targetScoreTimeMs; final String? scoreUnit; bool get manualScoreEnabled { return scoreEnabled && scoreInputMode == ScoreInputMode.manual; } bool get stopwatchScoreEnabled { return scoreEnabled && scoreInputMode == ScoreInputMode.stopwatch; } bool get hasMedia { return imageMediaIds.isNotEmpty || videoMediaId != null; } } List _imageMediaIdsFromSnapshot(Map exercise) { final imageMediaIds = exercise['imageMediaIdsSnapshot'] ?? exercise['exerciseImageMediaIdsSnapshot']; if (imageMediaIds is List) { return imageMediaIds .whereType() .where((id) => id.trim().isNotEmpty) .toList(growable: false); } final imageMediaId = exercise['exerciseImageMediaIdSnapshot'] as String?; return [ if (imageMediaId != null && imageMediaId.trim().isNotEmpty) imageMediaId, ]; } 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, _ => ScoreInputMode.manual, }; } final class ExecutionPosition { const ExecutionPosition({ required this.programIndex, required this.exerciseIndex, required this.setIndex, }); final int programIndex; final int exerciseIndex; final int setIndex; ExecutionPosition copyWith({int? setIndex}) { return ExecutionPosition( programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex ?? this.setIndex, ); } } final class _Header extends StatelessWidget { const _Header({required this.elapsedLabel, required this.progressLabel}); final String elapsedLabel; final String progressLabel; @override Widget build(BuildContext context) { return CourtBlazerAccentPanel( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)), const SizedBox(height: 4), Text(progressLabel), ], ), ); } } final class _SeriesCounterCard extends StatelessWidget { const _SeriesCounterCard({required this.currentSet, required this.totalSets}); final int currentSet; final int totalSets; @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = courtBlazerTokensOf(context); return CourtBlazerAccentPanel( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'SÉRIE', style: theme.textTheme.labelSmall?.copyWith( color: tokens.mutedText, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 6), Text( '$currentSet / $totalSets', style: theme.textTheme.displayLarge?.copyWith( color: theme.colorScheme.primary, fontSize: 48, ), ), ], ), ); } } String _formatDuration(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); final hours = duration.inHours; if (hours > 0) { return '$hours:$minutes:$seconds'; } 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'); final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0'); final tenths = totalTenths % 10; return '$minutes:$seconds.$tenths'; } String _formatScoreStopwatchTarget(Duration duration) { if (duration.inMilliseconds % 1000 == 0) { return _formatDuration(duration); } return _formatScoreStopwatch(duration); }