import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'history_screen.dart'; enum WorkoutExecutionMode { active, rest, paused, finished } final class WorkoutExecutionScreen extends StatefulWidget { const WorkoutExecutionScreen({ required this.initialSession, required this.activeUseCases, required this.closeUseCase, required this.historyUseCases, required this.workoutTemplateUseCases, super.key, }); final ActiveWorkoutSession initialSession; final ActiveWorkoutSessionUseCases activeUseCases; final CloseWorkoutSessionUseCase closeUseCase; final WorkoutHistoryUseCases historyUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases; @override State createState() => _WorkoutExecutionScreenState(); } final class _WorkoutExecutionScreenState extends State { late ActiveWorkoutSession _session; late final WorkoutExecutionPlan _plan; late WorkoutExecutionMode _mode; Timer? _ticker; Timer? _restTicker; WorkoutHistory? _completedHistory; String? _activeRestStateId; 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); _mode = _session.status == ActiveWorkoutStatus.running ? WorkoutExecutionMode.active : WorkoutExecutionMode.paused; _seriesStartedAt = DateTime.now().toUtc(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); unawaited(_restoreActiveRest()); } @override void dispose() { _ticker?.cancel(); _restTicker?.cancel(); _scoreController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return PopScope( canPop: false, child: Scaffold( appBar: AppBar( title: Text(_plan.name), actions: [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), Text(_exercise.name, style: Theme.of(context).textTheme.headlineMedium), const SizedBox(height: 16), SetMeasureInput( exercise: _exercise, reps: _reps, scoreController: _scoreController, onRepsChanged: (value) => setState(() => _reps = value), ), 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: const Text('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: Theme.of(context).textTheme.displayLarge, ), ), if (next != null && nextExercise != null) ...[ const SizedBox(height: 24), Text( 'Ensuite : ${nextExercise.name} · ' 'Série ${next.setIndex + 1}/${nextExercise.setsCount}', textAlign: TextAlign.center, ), ], 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 _pause() async { _restTicker?.cancel(); _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; setState(() => _mode = WorkoutExecutionMode.active); } 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 _finishSet({required bool skipped}) async { final score = double.tryParse(_scoreController.text.trim()); 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.scoreEnabled ? score : null), 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); } 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 = 0; _activeRestStateId = null; _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; 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, 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, closeUseCase: widget.closeUseCase, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, ), ), ); } } 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: Theme.of(context).textTheme.displaySmall, ), ], ), ), ), 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'), IconButton( onPressed: () => onRepsChanged(reps + 1), icon: const Icon(Icons.add), ), ], ), ], if (exercise.scoreEnabled) ...[ const SizedBox(height: 12), TextField( controller: scoreController, decoration: InputDecoration( labelText: exercise.scoreUnit == null ? 'Score' : 'Score (${exercise.scoreUnit})', border: const OutlineInputBorder(), ), keyboardType: TextInputType.number, ), ], ], ); } } 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); final exercise = exerciseAt(position); return 'Programme ${position.programIndex + 1}/${programs.length} · ' 'Exercice ${position.exerciseIndex + 1}/${program.exercises.length} · ' 'Série ${position.setIndex + 1}/${exercise.setsCount}'; } } 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.targetTimeSeconds, this.targetReps, this.targetScore, 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, 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(), 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 int? targetTimeSeconds; final int? targetReps; final double? targetScore; final String? scoreUnit; } 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 Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(elapsedLabel, style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 4), Text(progressLabel), ], ); } } 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'; }