Files
GameTime/lib/presentation/workout_execution_screen.dart
Blomios 8ac86c066f fix(exercice): reprise du timer d'étape après restauration de séance (ticket #60)
Relance le timer de l'étape en cours si son état restauré est en
pause, au lieu de rester bloqué en pause après une reprise de séance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:08:30 +02:00

3354 lines
104 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<MediaAsset?> Function(String id)? mediaAssetLoader;
final VideoMediaBuilder? videoMediaBuilder;
final ExerciseStepAudioCuePlayer? stepAudioCuePlayer;
@override
State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState();
}
final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
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 lexercice',
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: 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 lexercice',
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 à laccueil'),
),
],
),
);
}
Future<void> _openExerciseMedia(ExecutionExercise exercise) async {
if (!exercise.hasMedia) {
return;
}
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => _ExerciseMediaSheet(
exercise: exercise,
restRemainingSeconds: _mode == WorkoutExecutionMode.rest
? _remainingRestSeconds
: null,
mediaAssetLoader: _loadMediaAsset,
videoMediaBuilder: widget.videoMediaBuilder,
),
);
}
Future<MediaAsset?> _loadMediaAsset(String id) {
final loader = widget.mediaAssetLoader;
if (loader != null) return loader(id);
final mediaUseCases = widget.mediaUseCases;
if (mediaUseCases == null) return Future<MediaAsset?>.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<void> _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<void> _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<ActiveScoreStopwatchState?> _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<void> _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<void> _resetScoreStopwatch() async {
if (_scoreStopwatchElapsedMs > 0) {
final confirmed = await showDialog<bool>(
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<void> _editScoreStopwatchTime() async {
final initial = Duration(milliseconds: _scoreStopwatchElapsedMs);
final edited = await showDialog<int>(
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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _startCurrentStepTimerIfNeeded() async {
final view = _stepProgress;
final step = view?.currentStep;
if (view == null ||
step == null ||
step.type != ExerciseStepType.time ||
view.state.status == ActiveExerciseStepProgressStatus.runningTimer) {
return;
}
await _startCurrentStepTimer();
}
Future<void> _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<void> _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<void> _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<void> _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<bool?> _confirmStepSkip(
String title,
String content,
String actionLabel,
) {
return showDialog<bool>(
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<void> _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<void> _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<void> _quitAndSave() async {
await widget.activeUseCases.quitAndSave(_session.metadata.id);
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
}
Future<void> _confirmAbandon() async {
final confirmed = await showDialog<bool>(
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<void> _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<void>(
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<void> _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<bool>(
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<void> _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<bool> _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<void> _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<void> _skipRest() async {
_restTicker?.cancel();
final next = _plan.nextPosition(_position);
await _finishRestAndMove(next);
}
Future<void> _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<void> _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<void> _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<void> _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<void> _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<int> 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,
onStartTimer: onStartTimer,
)
else
_RepsStepBody(step: currentStep, onCompleteStep: onCompleteStep),
if (currentStep.hasScore) ...[
const SizedBox(height: 16),
_StepScoreInput(
step: currentStep,
controller: stepScoreController,
elapsedLabel: stepScoreElapsedLabel,
running: stepScoreRunning,
onStart: onStartStepScore,
onStop: onStopStepScore,
onReset: onResetStepScore,
),
],
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton(
onPressed: onSkipStep,
child: const Text('Passer létape'),
),
TextButton(
onPressed: onSkipPassage,
child: const Text('Passer ce passage'),
),
TextButton(
onPressed: onSkipSequence,
child: const Text('Passer la séquence'),
),
],
),
],
if (exercise.repsEnabled) ...[
const SizedBox(height: 16),
Text(
'Passages réalisés : $completedPassages / ${view.expectedPassages}',
),
],
],
),
);
}
}
final class _TimedStepBody extends StatelessWidget {
const _TimedStepBody({
required this.step,
required this.remainingLabel,
required this.running,
required this.onStartTimer,
});
final ExerciseStep step;
final String remainingLabel;
final bool running;
final VoidCallback onStartTimer;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Objectif : ${step.defaultTargetValue} s'),
const SizedBox(height: 8),
Center(
child: Text(
remainingLabel,
style: AppTextStyles.timer(context).copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
if (!running) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onStartTimer,
icon: const Icon(Icons.play_arrow),
label: const Text('Démarrer la séquence'),
),
],
],
);
}
}
final class _RepsStepBody extends StatelessWidget {
const _RepsStepBody({required this.step, required this.onCompleteStep});
final ExerciseStep step;
final VoidCallback onCompleteStep;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
'${step.defaultTargetValue}',
style: AppTextStyles.timer(context).copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onCompleteStep,
icon: const Icon(Icons.check),
label: const Text('Étape suivante'),
),
],
);
}
}
final class _StepScoreInput extends StatelessWidget {
const _StepScoreInput({
required this.step,
required this.controller,
required this.elapsedLabel,
required this.running,
required this.onStart,
required this.onStop,
required this.onReset,
});
final ExerciseStep step;
final TextEditingController controller;
final String elapsedLabel;
final bool running;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onReset;
@override
Widget build(BuildContext context) {
if (step.scoreInputMode == ScoreInputMode.stopwatch) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Chrono score détape',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Center(
child: Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
alignment: WrapAlignment.center,
children: [
FilledButton.icon(
onPressed: running ? onStop : onStart,
icon: Icon(running ? Icons.stop : Icons.play_arrow),
label: Text(running ? 'Arrêter' : 'Démarrer'),
),
OutlinedButton.icon(
onPressed: onReset,
icon: const Icon(Icons.restart_alt),
label: const Text('Réinitialiser'),
),
],
),
],
);
}
final unit = step.scoreUnit?.trim();
return TextField(
controller: controller,
decoration: InputDecoration(
labelText: unit == null || unit.isEmpty
? 'Score de létape'
: 'Score de létape ($unit)',
),
keyboardType: TextInputType.number,
);
}
}
final class _SequenceCompleteSummary extends StatelessWidget {
const _SequenceCompleteSummary({
required this.completedPassages,
required this.expectedPassages,
});
final int completedPassages;
final int expectedPassages;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Séquence terminée',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text('$completedPassages / $expectedPassages passages réalisés'),
],
);
}
}
final class _StepProgressChip extends StatelessWidget {
const _StepProgressChip({required this.index, required this.status});
final int index;
final _StepDisplayStatus status;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context);
final background = switch (status) {
_StepDisplayStatus.current => theme.colorScheme.primary,
_StepDisplayStatus.completed => tokens.success.withAlpha(35),
_StepDisplayStatus.skipped => theme.colorScheme.surface,
_StepDisplayStatus.todo => theme.colorScheme.surface,
};
final border = switch (status) {
_StepDisplayStatus.current => theme.colorScheme.primary,
_StepDisplayStatus.completed => tokens.success,
_StepDisplayStatus.skipped => tokens.mutedText,
_StepDisplayStatus.todo => tokens.border,
};
final foreground = status == _StepDisplayStatus.current
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface;
return Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: border),
),
child: Text(
'${index + 1}',
style: theme.textTheme.labelLarge?.copyWith(color: foreground),
),
);
}
}
enum _StepDisplayStatus { completed, skipped, current, todo }
_StepDisplayStatus _stepDisplayStatus(
ActiveExerciseStepProgressView view,
int stepIndex,
) {
for (final result in view.results) {
if (result.passageIndex == view.state.currentPassageIndex &&
result.stepIndex == stepIndex) {
return result.status == SetResultStatus.completed
? _StepDisplayStatus.completed
: _StepDisplayStatus.skipped;
}
}
if (view.state.status != ActiveExerciseStepProgressStatus.sequenceComplete &&
stepIndex == view.state.currentStepIndex) {
return _StepDisplayStatus.current;
}
if (stepIndex < view.state.currentStepIndex) {
return _StepDisplayStatus.completed;
}
return _StepDisplayStatus.todo;
}
final class _ScoreStopwatchInput extends StatelessWidget {
const _ScoreStopwatchInput({
required this.elapsedLabel,
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.activeRest,
});
final String sessionId;
final WorkoutExecutionPlan plan;
final ExecutionPosition currentPosition;
final List<SetResultPositionState> 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<SetResultPositionState> _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<void> _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<MediaAsset?> 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 lexercice'),
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<String> imageMediaIds;
final PageController pageController;
final int currentIndex;
final ValueChanged<int> onPageChanged;
final Future<MediaAsset?> 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<MediaAsset?> 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<MediaAsset?>(
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<MediaAsset?> Function(String id) mediaAssetLoader;
final VideoMediaBuilder? videoMediaBuilder;
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(16),
child: FutureBuilder<MediaAsset?>(
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<void> _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<void>(
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 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<void> 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, exercise)),
trailing: _PlanStatusBadge(status: planState, onTap: onTap),
onTap: onTap,
),
);
}
Future<void> _handleTap(BuildContext context, _PlanSetStatus status) async {
if (status == _PlanSetStatus.current) {
Navigator.of(context).pop();
return;
}
await showModalBottomSheet<void>(
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<void> 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<void> _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<void> _markSkipped() async {
await _saveSkipped();
}
Future<void> _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<void> _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<bool?> _confirmMarkSkipped() {
return showDialog<bool>(
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 _setResultSummary(
ActiveSetResult result, {
ExecutionExercise? exercise,
}) {
final parts = <String>[];
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<String, dynamic>;
final overrides = snapshot['overrides'] as List<dynamic>? ?? const [];
final programs = (snapshot['programs'] as List<dynamic>? ?? const []).map((
rawProgram,
) {
final program = rawProgram as Map<String, dynamic>;
final programSnapshot =
jsonDecode(program['programSnapshotJson'] as String)
as Map<String, dynamic>;
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<ExecutionProgram> 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<String, dynamic> snapshot,
required List<dynamic> overrides,
}) {
final exercises = (snapshot['exercises'] as List<dynamic>? ?? const []).map(
(raw) {
final exercise = raw as Map<String, dynamic>;
Map<String, dynamic>? override;
for (final rawOverride in overrides) {
final item = rawOverride as Map<String, dynamic>;
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<ExecutionExercise> 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<String, dynamic> exercise,
Map<String, dynamic>? 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<String> imageMediaIds;
final List<ExerciseStep> 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<String> _imageMediaIdsFromSnapshot(Map<String, dynamic> exercise) {
final imageMediaIds =
exercise['imageMediaIdsSnapshot'] ??
exercise['exerciseImageMediaIdsSnapshot'];
if (imageMediaIds is List) {
return imageMediaIds
.whereType<String>()
.where((id) => id.trim().isNotEmpty)
.toList(growable: false);
}
final imageMediaId = exercise['exerciseImageMediaIdSnapshot'] as String?;
return [
if (imageMediaId != null && imageMediaId.trim().isNotEmpty) imageMediaId,
];
}
List<ExerciseStep> _exerciseStepsFromSnapshot(Map<String, dynamic> exercise) {
final rawSteps = exercise['exerciseStepsSnapshot'];
if (rawSteps is! List) return const [];
return rawSteps.map((rawStep) {
final step = rawStep as Map<String, dynamic>;
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);
}