import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'history_screen.dart'; import 'theme.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, 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), 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: 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, ), ); } 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(); _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 _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, ), ), ); } Future _finishSet({required bool skipped}) async { try { 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.manualScoreEnabled ? score : null), 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 = 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: 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.scoreEnabled) ...[ const SizedBox(height: 12), TextField( controller: scoreController, style: AppTextStyles.scoreNumber(context), decoration: InputDecoration( labelText: exercise.scoreUnit == null ? 'Score' : 'Score (${exercise.scoreUnit})', ), keyboardType: TextInputType.number, ), ], ], ); } } final class _WorkoutPlanSheet extends StatefulWidget { const _WorkoutPlanSheet({ required this.sessionId, required this.plan, required this.currentPosition, required this.setStates, required this.activeUseCases, this.activeRest, }); final String sessionId; final WorkoutExecutionPlan plan; final ExecutionPosition currentPosition; final List setStates; final ActiveWorkoutSessionUseCases activeUseCases; 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, 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, this.restRemainingSeconds, }); final ExecutionExercise exercise; 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), ) else if (hasVideo) _VideoMediaPanel(videoMediaId: exercise.videoMediaId!), ], ), ), ); } } final class _ImageMediaGallery extends StatelessWidget { const _ImageMediaGallery({ required this.imageMediaIds, required this.pageController, required this.currentIndex, required this.onPageChanged, }); final List imageMediaIds; final PageController pageController; final int currentIndex; final ValueChanged onPageChanged; @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, ); }, ), ), ], ), ); } } final class _ImageMediaPage extends StatelessWidget { const _ImageMediaPage({required this.mediaAssetId, required this.index}); final String mediaAssetId; final int index; @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: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.image_outlined, size: 64, color: Theme.of(context).colorScheme.primary, ), const SizedBox(height: 16), Text( 'Image ${index + 1}', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), Text(mediaAssetId, style: Theme.of(context).textTheme.bodySmall), ], ), ); } } final class _VideoMediaPanel extends StatelessWidget { const _VideoMediaPanel({required this.videoMediaId}); final String videoMediaId; @override Widget build(BuildContext context) { return CourtBlazerAccentPanel( padding: const EdgeInsets.all(16), child: SizedBox( height: 260, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.play_circle_outline, size: 64, color: Theme.of(context).colorScheme.primary, ), const SizedBox(height: 16), Text('Vidéo', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 4), Text(videoMediaId, style: Theme.of(context).textTheme.bodySmall), ], ), ), ); } } final class _WorkoutPlanSetTile extends StatelessWidget { const _WorkoutPlanSetTile({ required this.position, required this.currentPosition, required this.exercise, required this.onUpdated, required this.activeUseCases, required this.sessionId, this.activeRest, this.state, }); final ExecutionPosition position; final ExecutionPosition currentPosition; final ExecutionExercise exercise; final SetResultPositionState? state; final Future Function() onUpdated; final ActiveWorkoutSessionUseCases activeUseCases; final String sessionId; final ActiveRestState? activeRest; @override Widget build(BuildContext context) { final planState = _planSetState(position, currentPosition, 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 ${position.setIndex + 1}'), subtitle: Text(_planSetSubtitle(planState, state?.result)), 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: sessionId, position: position, exercise: exercise, state: state, status: status, activeUseCases: activeUseCases, activeRest: activeRest, onSaved: onUpdated, ), ); } } 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); } _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.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.scoreEnabled ? double.tryParse(_scoreController.text.trim()) : null, 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) { if (status == _PlanSetStatus.completed && result != null) { return _setResultSummary(result); } if (status == _PlanSetStatus.passed) { return 'Aucun résultat'; } return ''; } String _setResultSummary(ActiveSetResult result) { 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'); } 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 _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.videoMediaId, this.scoreInputMode = ScoreInputMode.manual, this.scoreLabel, 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, imageMediaIds: _imageMediaIdsFromSnapshot(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(), 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 String? videoMediaId; final ScoreInputMode scoreInputMode; final String? scoreLabel; final int? targetTimeSeconds; final int? targetReps; final double? targetScore; final String? scoreUnit; bool get manualScoreEnabled { return scoreEnabled && scoreInputMode == ScoreInputMode.manual; } 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, ]; } 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'; }