Files
GameTime/lib/presentation/workout_execution_screen.dart
Blomios 75f52200a2 feat(exécution): refonte des chronos de séance et carte exercice actif (ticket #90)
Réorganise l'écran d'exécution autour d'une carte "Exercice actif" sous
le compteur SÉRIE X/Y (nom, médias, temps de série, action "Démarrer
l'exercice"). Le timer de série devient un état persistant dédié (plus
un DateTime UI volatile), pause-aware, et "Démarrer l'exercice" lance
en une action tous les chronos qui doivent démarrer en début de série
(série, première étape temps, score chrono si stopwatch).

Implémentation restée non commitée depuis sa réalisation ; QA a validé
les vérifications statiques exécutables en sandbox (dart analyze,
git diff --check). flutter analyze / flutter test restent à relancer
dans un environnement avec cache Flutter SDK accessible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:26:28 +02:00

3985 lines
125 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;
ActiveSetTimerState? _setTimer;
ActiveScoreStopwatchState? _scoreStopwatch;
var _scoreStopwatchLoadGeneration = 0;
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;
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);
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
unawaited(_loadSetTimer());
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')),
],
),
bottomNavigationBar: _mode == WorkoutExecutionMode.active
? SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: _SetActionBar(
onFinishSet: () => _finishSet(skipped: false),
onSkipSet: () => _finishSet(skipped: true),
),
)
: null,
body: switch (_mode) {
WorkoutExecutionMode.active => _buildActive(),
WorkoutExecutionMode.rest => _buildRest(),
WorkoutExecutionMode.paused => _buildPaused(),
WorkoutExecutionMode.finished => _buildFinished(),
},
),
);
}
Widget _buildActive() {
final hasStepSequence =
_exercise.steps.isNotEmpty && widget.stepUseCases != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: _ExecutionContextHeader(
exercise: _exercise,
currentSet: _position.setIndex + 1,
totalSets: _exercise.setsCount,
progressLabel: _plan.progressLabel(_position),
setTimerLabel: _setTimerLabel,
setTimerComplete:
_setTimer?.status == ActiveSetTimerStatus.stopped,
showStartButton: _shouldShowStartExerciseButton,
onStartExercise: _startExercise,
onOpenMedia: _exercise.hasMedia
? () => _openExerciseMedia(_exercise)
: null,
elapsedLabel: _formatDuration(
Duration(
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(
_session,
),
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: hasStepSequence
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _StepSequencePanel(
view: _stepProgress,
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: 8),
SizedBox(
height: _exercise.manualScoreEnabled ? 66 : 48,
child: _StepSetResultSummary(
view: _stepProgress,
exercise: _exercise,
reps: _reps,
scoreController: _scoreController,
),
),
if (_exercise.stopwatchScoreEnabled) ...[
const SizedBox(height: 8),
SizedBox(
height: 96,
child: _ScoreStopwatchInput(
elapsedLabel: _formatScoreStopwatch(
Duration(milliseconds: _scoreStopwatchElapsedMs),
),
status: _scoreStopwatch?.status,
onStart: _startScoreStopwatch,
onStop: _stopScoreStopwatch,
onResume: _resumeScoreStopwatch,
onReset: _resetScoreStopwatch,
onEdit: _editScoreStopwatchTime,
compact: true,
),
),
],
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_exercise.stopwatchScoreEnabled) ...[
_ScoreStopwatchInput(
elapsedLabel: _formatScoreStopwatch(
Duration(milliseconds: _scoreStopwatchElapsedMs),
),
status: _scoreStopwatch?.status,
onStart: _startScoreStopwatch,
onStop: _stopScoreStopwatch,
onResume: _resumeScoreStopwatch,
onReset: _resetScoreStopwatch,
onEdit: _editScoreStopwatchTime,
),
const SizedBox(height: 12),
],
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
SetMeasureInput(
exercise: _exercise,
showTime: false,
reps: _reps,
scoreController: _scoreController,
onRepsChanged: (value) =>
setState(() => _reps = value),
),
],
),
),
],
),
),
),
],
);
}
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 _setTimerElapsedMs {
final state = _setTimer;
if (state == null) {
return 0;
}
return widget.activeUseCases.setTimerElapsedMilliseconds(state);
}
String? get _setTimerLabel {
if (!_exercise.timeEnabled) {
return null;
}
final state = _setTimer;
if (state == null) {
final targetSeconds = _exercise.targetTimeSeconds;
if (targetSeconds == null) {
return 'Chronométrer';
}
return _formatDuration(Duration(seconds: targetSeconds));
}
return _formatDuration(Duration(milliseconds: _setTimerElapsedMs));
}
bool get _shouldShowStartExerciseButton {
final hasGlobalTimer =
_exercise.timeEnabled ||
_exercise.stopwatchScoreEnabled ||
(_exercise.steps.isNotEmpty &&
_exercise.steps.first.type == ExerciseStepType.time);
return hasGlobalTimer && !_hasSetExecutionStarted;
}
bool get _hasSetExecutionStarted {
if (_setTimer != null) {
return true;
}
if (_scoreStopwatch != null) {
return true;
}
final view = _stepProgress;
if (view == null) {
return false;
}
return view.results.isNotEmpty ||
view.state.currentPassageIndex > 0 ||
view.state.currentStepIndex > 0 ||
view.state.status == ActiveExerciseStepProgressStatus.runningTimer ||
view.state.status == ActiveExerciseStepProgressStatus.pausedTimer ||
view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete;
}
bool get _shouldConfirmSkipSet {
return _setTimer?.status == ActiveSetTimerStatus.running ||
_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running ||
_hasStartedStepSequence;
}
bool get _shouldPromptStartExerciseChrono {
if (_exercise.steps.isNotEmpty && widget.stepUseCases != null) {
return _isFirstTimedStepNotStarted;
}
return (_exercise.timeEnabled && _setTimer == null) ||
_isFirstTimedStepNotStarted;
}
bool get _isFirstTimedStepNotStarted {
if (_exercise.steps.isEmpty ||
_exercise.steps.first.type != ExerciseStepType.time) {
return false;
}
final view = _stepProgress;
if (view == null) {
return true;
}
return view.state.currentPassageIndex == 0 &&
view.state.currentStepIndex == 0 &&
view.state.status == ActiveExerciseStepProgressStatus.stoppedTimer &&
view.results.isEmpty;
}
bool get _hasStartedStepSequence {
final view = _stepProgress;
if (view == null) {
return false;
}
return view.results.isNotEmpty ||
view.state.currentPassageIndex > 0 ||
view.state.currentStepIndex > 0 ||
view.state.status == ActiveExerciseStepProgressStatus.runningTimer ||
view.state.status == ActiveExerciseStepProgressStatus.pausedTimer;
}
Future<void> _loadSetTimer() async {
if (!_exercise.timeEnabled) {
if (!mounted) return;
setState(() => _setTimer = null);
return;
}
final state = await widget.activeUseCases.findSetTimer(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
);
if (!mounted) return;
setState(() => _setTimer = state);
}
Future<void> _startExercise() async {
try {
final result = await widget.activeUseCases.startSetExecution(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
);
if (!mounted) return;
setState(() {
_setTimer = result.setTimer;
_scoreStopwatch = result.scoreStopwatch ?? _scoreStopwatch;
_manualScoreTimeMs = null;
});
await _loadScoreStopwatch();
await _readStepProgress();
_refreshScoreStopwatchTicker();
_refreshStepTicker();
} on Exception catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Impossible de démarrer lexercice : $error")),
);
}
}
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 generation = _scoreStopwatchLoadGeneration;
final state = await widget.activeUseCases.findScoreStopwatch(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
);
if (!mounted) return;
if (generation != _scoreStopwatchLoadGeneration) return;
if (state == null && _scoreStopwatch != null) return;
setState(() => _scoreStopwatch = state);
_refreshScoreStopwatchTicker();
}
Future<void> _startScoreStopwatch() async {
_scoreStopwatchLoadGeneration++;
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;
_scoreStopwatchLoadGeneration++;
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;
}
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() {
_refreshStepTicker();
return Future<void>.value();
}
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();
}
_session = await widget.activeUseCases.pause(_session.metadata.id);
if (!mounted) return;
await _loadSetTimer();
await _loadScoreStopwatch();
await _readStepProgress();
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 _loadSetTimer();
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,
stepUseCases: widget.stepUseCases,
),
),
);
}
Future<void> _finishSet({required bool skipped}) async {
var finishWithoutChrono = false;
if (skipped && _shouldConfirmSkipSet) {
final confirmed = await _confirmStepSkip(
'Passer la série ?',
'Les chronos ou la séquence en cours seront ignorés.',
'Passer la série',
);
if (confirmed != true) return;
}
if (!skipped && _shouldPromptStartExerciseChrono) {
final action = await showDialog<_MissingSetTimerAction>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Chrono non lancé'),
content: const Text("Tu nas pas démarré le chrono de cette série."),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(_MissingSetTimerAction.start),
child: const Text('Démarrer lexercice'),
),
FilledButton(
onPressed: () => Navigator.of(
context,
).pop(_MissingSetTimerAction.finishWithout),
child: const Text('Terminer sans chrono'),
),
],
),
);
if (action == _MissingSetTimerAction.start) {
await _startExercise();
return;
}
if (action != _MissingSetTimerAction.finishWithout) {
return;
}
finishWithoutChrono = true;
}
if (!skipped &&
!finishWithoutChrono &&
_exercise.stopwatchScoreEnabled &&
_scoreStopwatch == null &&
_manualScoreTimeMs == null) {
final action = await showDialog<_MissingStopwatchAction>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Chrono non lancé'),
content: const Text("Tu nas pas démarré le chrono de cette série."),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(_MissingStopwatchAction.start),
child: const Text('Démarrer lexercice'),
),
FilledButton(
onPressed: () => Navigator.of(
context,
).pop(_MissingStopwatchAction.finishWithout),
child: const Text('Terminer sans chrono'),
),
],
),
);
if (action == _MissingStopwatchAction.start) {
await _startExercise();
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());
final setTimer = skipped
? await widget.activeUseCases.skipSetExecutionTimers(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
)
: await widget.activeUseCases.stopSetExecutionTimers(
sessionId: _session.metadata.id,
programIndex: _position.programIndex,
exerciseIndex: _position.exerciseIndex,
setIndex: _position.setIndex,
);
if (!mounted) return;
setState(() {
_setTimer = setTimer;
if (skipped) {
_scoreStopwatch = null;
_manualScoreTimeMs = null;
}
});
await _loadScoreStopwatch();
await _readStepProgress();
var actualScoreTimeMs = skipped ? null : _manualScoreTimeMs;
if (!skipped &&
_exercise.stopwatchScoreEnabled &&
actualScoreTimeMs == null) {
final state = _scoreStopwatch;
actualScoreTimeMs = state?.accumulatedMs;
}
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 ? setTimer?.accumulatedMs : 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 remainingMs = rest.remainingMillisecondsAt(DateTime.now().toUtc());
return (remainingMs / 1000).ceil().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;
_setTimer = null;
_scoreStopwatch = null;
_scoreStopwatchLoadGeneration++;
_manualScoreTimeMs = null;
_stepProgress = null;
_resetStepScoreInput();
_refreshScoreStopwatchTicker();
_refreshStepTicker();
_session = await widget.activeUseCases.updateProgress(
sessionId: _session.metadata.id,
programIndex: position.programIndex,
exerciseIndex: position.exerciseIndex,
setIndex: position.setIndex,
);
if (!mounted) return;
await _loadSetTimer();
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,
this.showTime = true,
required this.reps,
required this.scoreController,
required this.onRepsChanged,
super.key,
});
final ExecutionExercise exercise;
final bool showTime;
final int reps;
final TextEditingController scoreController;
final ValueChanged<int> onRepsChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (exercise.timeEnabled && showTime)
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,
),
],
],
);
}
}
final class _ExecutionContextHeader extends StatelessWidget {
const _ExecutionContextHeader({
required this.exercise,
required this.currentSet,
required this.totalSets,
required this.progressLabel,
required this.elapsedLabel,
required this.setTimerLabel,
required this.setTimerComplete,
required this.showStartButton,
required this.onStartExercise,
required this.onOpenMedia,
});
final ExecutionExercise exercise;
final int currentSet;
final int totalSets;
final String progressLabel;
final String elapsedLabel;
final String? setTimerLabel;
final bool setTimerComplete;
final bool showStartButton;
final VoidCallback onStartExercise;
final VoidCallback? onOpenMedia;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context);
return CourtBlazerAccentPanel(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
spacing: 8,
runSpacing: 4,
children: [
Text(
'Série $currentSet/$totalSets',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
Text(
progressLabel,
style: theme.textTheme.labelMedium?.copyWith(
color: tokens.mutedText,
),
),
Text(
elapsedLabel,
style: theme.textTheme.labelMedium?.copyWith(
color: tokens.mutedText,
),
),
],
),
const SizedBox(height: 4),
Text(
exercise.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge,
),
if (setTimerLabel != null) ...[
const SizedBox(height: 4),
Text(
setTimerComplete
? 'Temps de série terminé : $setTimerLabel'
: 'Temps de série : $setTimerLabel',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
),
),
],
],
),
),
const SizedBox(width: 8),
if (showStartButton)
Tooltip(
message: 'Démarrer lexercice',
child: FilledButton.icon(
onPressed: onStartExercise,
style: FilledButton.styleFrom(
minimumSize: const Size(52, 52),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
icon: const Icon(Icons.play_arrow),
label: const Text('Démarrer lexercice'),
),
),
if (onOpenMedia != null) ...[
const SizedBox(width: 8),
Tooltip(
message: 'Voir les médias de lexercice',
child: OutlinedButton(
onPressed: onOpenMedia,
style: OutlinedButton.styleFrom(
minimumSize: const Size(52, 52),
padding: EdgeInsets.zero,
),
child: const Icon(Icons.perm_media_outlined),
),
),
],
],
),
);
}
}
final class _SetActionBar extends StatelessWidget {
const _SetActionBar({
required this.onFinishSet,
required this.onSkipSet,
});
final VoidCallback onFinishSet;
final VoidCallback onSkipSet;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton(
onPressed: onFinishSet,
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(56)),
child: const Text('Terminer la série'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: onSkipSet,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
child: const Text('Passer la série'),
),
],
);
}
}
final class _StepSetResultSummary extends StatelessWidget {
const _StepSetResultSummary({
required this.view,
required this.exercise,
required this.reps,
required this.scoreController,
});
final ActiveExerciseStepProgressView? view;
final ExecutionExercise exercise;
final int reps;
final TextEditingController scoreController;
@override
Widget build(BuildContext context) {
final completedPassages = _completedPassagesFor(view);
final expectedPassages = view?.expectedPassages ?? reps;
final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: tokens.border),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
expectedPassages > 0
? 'Passages réalisés : $completedPassages / $expectedPassages'
: 'Passages réalisés : $completedPassages',
style: theme.textTheme.titleSmall,
),
),
if (exercise.manualScoreEnabled) ...[
const SizedBox(width: 12),
SizedBox(
width: 128,
child: TextField(
controller: scoreController,
decoration: InputDecoration(
isDense: true,
labelText: exercise.scoreUnit == null
? 'Score'
: 'Score (${exercise.scoreUnit})',
),
keyboardType: TextInputType.number,
),
),
],
],
),
);
}
}
int _completedPassagesFor(ActiveExerciseStepProgressView? view) {
if (view == null) return 0;
if (view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
return view.expectedPassages;
}
return view.state.currentPassageIndex;
}
enum _MissingSetTimerAction { start, finishWithout }
enum _MissingStopwatchAction { start, finishWithout }
final class _StepSequencePanel extends StatelessWidget {
const _StepSequencePanel({
required this.view,
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 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 _BoundedAccentPanel(
padding: EdgeInsets.all(12),
child: Center(child: CircularProgressIndicator()),
);
}
final currentStep = view.currentStep;
final sequenceComplete =
view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete;
final completedPassages = sequenceComplete
? view.expectedPassages
: view.state.currentPassageIndex;
return _BoundedAccentPanel(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
view.expectedPassages > 1 && !sequenceComplete
? 'Passage ${view.state.currentPassageIndex + 1} / ${view.expectedPassages}'
: view.expectedPassages > 1
? 'Séquence terminée'
: 'Passage en cours',
style: Theme.of(context).textTheme.titleMedium,
),
),
Text(
'SÉQUENCE',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const SizedBox(height: 6),
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: 6),
],
],
),
),
const SizedBox(height: 8),
if (sequenceComplete || currentStep == null)
Expanded(
child: Align(
alignment: Alignment.topLeft,
child: _SequenceCompleteSummary(
completedPassages: completedPassages,
expectedPassages: view.expectedPassages,
),
),
)
else
Expanded(
child: _CurrentStepPane(
view: view,
step: currentStep,
remainingLabel: remainingLabel,
stepScoreController: stepScoreController,
stepScoreElapsedLabel: stepScoreElapsedLabel,
stepScoreRunning: stepScoreRunning,
onStartTimer: onStartTimer,
onCompleteStep: onCompleteStep,
onSkipStep: onSkipStep,
onSkipPassage: onSkipPassage,
onSkipSequence: onSkipSequence,
onStartStepScore: onStartStepScore,
onStopStepScore: onStopStepScore,
onResetStepScore: onResetStepScore,
),
),
],
),
);
}
}
enum _StepSkipAction { passage, sequence }
final class _BoundedAccentPanel extends StatelessWidget {
const _BoundedAccentPanel({required this.child, required this.padding});
final Widget child;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: tokens.border),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(height: 2, color: tokens.accent),
Expanded(child: Padding(padding: padding, child: child)),
],
),
);
}
}
final class _CurrentStepPane extends StatelessWidget {
const _CurrentStepPane({
required this.view,
required this.step,
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 ExerciseStep step;
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) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'ÉTAPE ${view.state.currentStepIndex + 1} / ${view.steps.length}',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 2),
Text(
step.name,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Flexible(
fit: FlexFit.tight,
child: LayoutBuilder(
builder: (context, constraints) {
final body = step.type == ExerciseStepType.time
? _TimedStepBody(
step: step,
remainingLabel: remainingLabel,
running:
view.state.status ==
ActiveExerciseStepProgressStatus.runningTimer,
readyToStart: _isNextTimedStepReady(view),
onStartTimer: onStartTimer,
)
: _RepsStepBody(step: step, onCompleteStep: onCompleteStep);
return Align(
alignment: Alignment.topCenter,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topCenter,
child: SizedBox(width: constraints.maxWidth, child: body),
),
);
},
),
),
if (step.hasScore) ...[
const SizedBox(height: 8),
Flexible(
fit: FlexFit.loose,
child: _StepScoreInput(
step: step,
controller: stepScoreController,
elapsedLabel: stepScoreElapsedLabel,
running: stepScoreRunning,
onStart: onStartStepScore,
onStop: onStopStepScore,
onReset: onResetStepScore,
),
),
],
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onSkipStep,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
child: const Text('Passer létape'),
),
),
const SizedBox(width: 8),
PopupMenuButton<_StepSkipAction>(
tooltip: 'Plus dactions',
icon: const Icon(Icons.more_horiz),
onSelected: (action) {
if (action == _StepSkipAction.passage) {
onSkipPassage();
} else {
onSkipSequence();
}
},
itemBuilder: (context) => const [
PopupMenuItem(
value: _StepSkipAction.passage,
child: Text('Passer ce passage'),
),
PopupMenuItem(
value: _StepSkipAction.sequence,
child: Text('Passer la séquence'),
),
],
),
],
),
],
);
}
}
final class _TimedStepBody extends StatelessWidget {
const _TimedStepBody({
required this.step,
required this.remainingLabel,
required this.running,
required this.readyToStart,
required this.onStartTimer,
});
final ExerciseStep step;
final String remainingLabel;
final bool running;
final bool readyToStart;
final VoidCallback onStartTimer;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Objectif : ${step.defaultTargetValue} s'),
const SizedBox(height: 8),
Center(
child: Text(
remainingLabel,
style: AppTextStyles.timer(
context,
).copyWith(color: Theme.of(context).colorScheme.primary),
),
),
if (!running) ...[
const SizedBox(height: 12),
if (readyToStart) ...[
Text(
'Chrono suivant prêt',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 8),
],
FilledButton.icon(
onPressed: onStartTimer,
icon: const Icon(Icons.play_arrow),
label: Text(
readyToStart ? 'Démarrer le chrono' : 'Démarrer la séquence',
),
),
],
],
);
}
}
bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) {
final step = view.currentStep;
if (step == null ||
step.type != ExerciseStepType.time ||
view.state.status != ActiveExerciseStepProgressStatus.stoppedTimer) {
return false;
}
return view.results.any((result) {
if (result.passageIndex < view.state.currentPassageIndex) {
return true;
}
return result.passageIndex == view.state.currentPassageIndex &&
result.stepIndex < view.state.currentStepIndex;
});
}
final class _RepsStepBody extends StatelessWidget {
const _RepsStepBody({required this.step, required this.onCompleteStep});
final ExerciseStep step;
final VoidCallback onCompleteStep;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
'${step.defaultTargetValue}',
style: AppTextStyles.timer(
context,
).copyWith(color: Theme.of(context).colorScheme.primary),
),
Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onCompleteStep,
icon: const Icon(Icons.check),
label: const Text('Étape suivante'),
),
],
);
}
}
final class _StepScoreInput extends StatelessWidget {
const _StepScoreInput({
required this.step,
required this.controller,
required this.elapsedLabel,
required this.running,
required this.onStart,
required this.onStop,
required this.onReset,
});
final ExerciseStep step;
final TextEditingController controller;
final String elapsedLabel;
final bool running;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onReset;
@override
Widget build(BuildContext context) {
if (step.scoreInputMode == ScoreInputMode.stopwatch) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Chrono score détape',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Center(
child: Text(
elapsedLabel,
style: AppTextStyles.scoreNumber(context),
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
alignment: WrapAlignment.center,
children: [
FilledButton.icon(
onPressed: running ? onStop : onStart,
icon: Icon(running ? Icons.stop : Icons.play_arrow),
label: Text(running ? 'Arrêter' : 'Démarrer'),
),
OutlinedButton.icon(
onPressed: onReset,
icon: const Icon(Icons.restart_alt),
label: const Text('Réinitialiser'),
),
],
),
],
);
}
final unit = step.scoreUnit?.trim();
return TextField(
controller: controller,
decoration: InputDecoration(
labelText: unit == null || unit.isEmpty
? 'Score de létape'
: 'Score de létape ($unit)',
),
keyboardType: TextInputType.number,
);
}
}
final class _SequenceCompleteSummary extends StatelessWidget {
const _SequenceCompleteSummary({
required this.completedPassages,
required this.expectedPassages,
});
final int completedPassages;
final int expectedPassages;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Séquence terminée',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text('$completedPassages / $expectedPassages passages réalisés'),
],
);
}
}
final class _StepProgressChip extends StatelessWidget {
const _StepProgressChip({required this.index, required this.status});
final int index;
final _StepDisplayStatus status;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context);
final background = switch (status) {
_StepDisplayStatus.current => theme.colorScheme.primary,
_StepDisplayStatus.completed => tokens.success.withAlpha(35),
_StepDisplayStatus.skipped => theme.colorScheme.surface,
_StepDisplayStatus.todo => theme.colorScheme.surface,
};
final border = switch (status) {
_StepDisplayStatus.current => theme.colorScheme.primary,
_StepDisplayStatus.completed => tokens.success,
_StepDisplayStatus.skipped => tokens.mutedText,
_StepDisplayStatus.todo => tokens.border,
};
final foreground = status == _StepDisplayStatus.current
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface;
return Container(
width: 30,
height: 30,
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,
this.compact = false,
});
final String elapsedLabel;
final ActiveScoreStopwatchStatus? status;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onResume;
final VoidCallback onReset;
final VoidCallback onEdit;
final bool compact;
@override
Widget build(BuildContext context) {
final running = status == ActiveScoreStopwatchStatus.running;
final stopped = status == ActiveScoreStopwatchStatus.stopped;
return CourtBlazerAccentPanel(
padding: compact
? const EdgeInsets.symmetric(horizontal: 14, vertical: 10)
: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
'Chrono score',
style: compact
? Theme.of(context).textTheme.titleSmall
: Theme.of(context).textTheme.titleMedium,
),
),
Text(
elapsedLabel,
style: compact
? Theme.of(context).textTheme.titleLarge
: AppTextStyles.scoreNumber(context),
),
],
),
SizedBox(height: compact ? 8 : 12),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
if (status == null)
FilledButton.icon(
onPressed: onStart,
icon: const Icon(Icons.play_arrow),
label: const Text('Démarrer'),
)
else if (running)
FilledButton.icon(
onPressed: onStop,
icon: const Icon(Icons.stop),
label: const Text('Arrêter'),
)
else ...[
FilledButton.icon(
onPressed: onResume,
icon: const Icon(Icons.play_arrow),
label: const Text('Reprendre'),
),
OutlinedButton.icon(
onPressed: onReset,
icon: const Icon(Icons.restart_alt),
label: const Text('Réinitialiser'),
),
],
if (stopped)
TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit),
label: const Text('Modifier le temps'),
),
],
),
],
),
);
}
}
final class _ScoreStopwatchEditDialog extends StatefulWidget {
const _ScoreStopwatchEditDialog({required this.initial});
final Duration initial;
@override
State<_ScoreStopwatchEditDialog> createState() =>
_ScoreStopwatchEditDialogState();
}
final class _ScoreStopwatchEditDialogState
extends State<_ScoreStopwatchEditDialog> {
late final TextEditingController _minutesController;
late final TextEditingController _secondsController;
late final TextEditingController _tenthsController;
@override
void initState() {
super.initState();
final totalTenths = (widget.initial.inMilliseconds / 100).round();
final minutes = totalTenths ~/ 600;
final seconds = (totalTenths ~/ 10) % 60;
final tenths = totalTenths % 10;
_minutesController = TextEditingController(text: '$minutes');
_secondsController = TextEditingController(text: '$seconds');
_tenthsController = TextEditingController(text: '$tenths');
}
@override
void dispose() {
_minutesController.dispose();
_secondsController.dispose();
_tenthsController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Modifier le temps'),
content: Row(
children: [
Expanded(
child: TextField(
controller: _minutesController,
decoration: const InputDecoration(labelText: 'Min'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _secondsController,
decoration: const InputDecoration(labelText: 'S'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _tenthsController,
decoration: const InputDecoration(labelText: 'Dixièmes'),
keyboardType: TextInputType.number,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
final minutes = int.tryParse(_minutesController.text.trim()) ?? 0;
final seconds = int.tryParse(_secondsController.text.trim()) ?? 0;
final tenths = int.tryParse(_tenthsController.text.trim()) ?? 0;
final milliseconds =
(minutes * 60 * 1000) + (seconds * 1000) + (tenths * 100);
Navigator.of(context).pop(milliseconds);
},
child: const Text('Enregistrer'),
),
],
);
}
}
final class _WorkoutPlanSheet extends StatefulWidget {
const _WorkoutPlanSheet({
required this.sessionId,
required this.plan,
required this.currentPosition,
required this.setStates,
required this.activeUseCases,
this.stepUseCases,
this.activeRest,
});
final String sessionId;
final WorkoutExecutionPlan plan;
final ExecutionPosition currentPosition;
final List<SetResultPositionState> setStates;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveRestState? activeRest;
@override
State<_WorkoutPlanSheet> createState() => _WorkoutPlanSheetState();
}
final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
Timer? _timer;
late int _remainingRestSeconds;
late List<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,
stepUseCases: widget.stepUseCases,
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 StatefulWidget {
const _WorkoutPlanSetTile({
required this.position,
required this.currentPosition,
required this.exercise,
required this.onUpdated,
required this.activeUseCases,
required this.sessionId,
this.stepUseCases,
this.activeRest,
this.state,
});
final ExecutionPosition position;
final ExecutionPosition currentPosition;
final ExecutionExercise exercise;
final SetResultPositionState? state;
final Future<void> Function() onUpdated;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final String sessionId;
final ActiveRestState? activeRest;
@override
State<_WorkoutPlanSetTile> createState() => _WorkoutPlanSetTileState();
}
final class _WorkoutPlanSetTileState extends State<_WorkoutPlanSetTile> {
late final Future<ActiveExerciseStepProgressView?> _stepProgress;
@override
void initState() {
super.initState();
_stepProgress = _loadStepProgress();
}
Future<ActiveExerciseStepProgressView?> _loadStepProgress() async {
final stepUseCases = widget.stepUseCases;
if (stepUseCases == null || widget.exercise.steps.isEmpty) {
return null;
}
final planState = _planSetState(
widget.position,
widget.currentPosition,
widget.state,
);
if (planState == _PlanSetStatus.todo) {
return null;
}
return stepUseCases.readProgress(
sessionId: widget.sessionId,
programIndex: widget.position.programIndex,
exerciseIndex: widget.position.exerciseIndex,
setIndex: widget.position.setIndex,
);
}
@override
Widget build(BuildContext context) {
final planState = _planSetState(
widget.position,
widget.currentPosition,
widget.state,
);
final tappable = planState != _PlanSetStatus.todo;
final VoidCallback? onTap = tappable
? () => unawaited(_handleTap(context, planState))
: null;
final tokens = courtBlazerTokensOf(context);
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: BorderSide(
color: planState == _PlanSetStatus.current
? Theme.of(context).colorScheme.primary
: tokens.border,
),
),
child: ListTile(
enabled: tappable,
title: Text('Série ${widget.position.setIndex + 1}'),
subtitle: _WorkoutPlanSetSubtitle(
status: planState,
result: widget.state?.result,
exercise: widget.exercise,
stepProgress: _stepProgress,
),
trailing: _PlanStatusBadge(status: planState, onTap: onTap),
onTap: onTap,
),
);
}
Future<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: widget.sessionId,
position: widget.position,
exercise: widget.exercise,
state: widget.state,
status: status,
activeUseCases: widget.activeUseCases,
activeRest: widget.activeRest,
onSaved: widget.onUpdated,
),
);
}
}
final class _WorkoutPlanSetSubtitle extends StatelessWidget {
const _WorkoutPlanSetSubtitle({
required this.status,
required this.result,
required this.exercise,
required this.stepProgress,
});
final _PlanSetStatus status;
final ActiveSetResult? result;
final ExecutionExercise exercise;
final Future<ActiveExerciseStepProgressView?> stepProgress;
@override
Widget build(BuildContext context) {
final resultLabel = _planSetSubtitle(status, result, exercise);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (resultLabel.isNotEmpty) Text(resultLabel),
if (exercise.steps.isNotEmpty)
FutureBuilder<ActiveExerciseStepProgressView?>(
future: stepProgress,
builder: (context, snapshot) {
final progress = snapshot.data;
final label = progress == null
? 'Séquence : 0/${exercise.steps.length} étapes'
: _planStepSequenceSummary(progress);
return Padding(
padding: EdgeInsets.only(top: resultLabel.isEmpty ? 0 : 4),
child: Text(label),
);
},
),
],
);
}
}
final class _EditSetResultSheet extends StatefulWidget {
const _EditSetResultSheet({
required this.sessionId,
required this.position,
required this.exercise,
required this.state,
required this.status,
required this.activeUseCases,
required this.onSaved,
this.activeRest,
});
final String sessionId;
final ExecutionPosition position;
final ExecutionExercise exercise;
final SetResultPositionState? state;
final _PlanSetStatus status;
final ActiveWorkoutSessionUseCases activeUseCases;
final Future<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 _planStepSequenceSummary(ActiveExerciseStepProgressView view) {
final state = view.state;
final stepsCount = view.steps.length;
final passageIndex =
state.status == ActiveExerciseStepProgressStatus.sequenceComplete
? (view.expectedPassages - 1).clamp(0, 9999).toInt()
: state.currentPassageIndex;
final completedInPassage =
state.status == ActiveExerciseStepProgressStatus.sequenceComplete
? stepsCount
: view.results
.where((result) => result.passageIndex == passageIndex)
.map((result) => result.stepIndex)
.toSet()
.length
.clamp(0, stepsCount)
.toInt();
final passageLabel = view.expectedPassages > 1
? ' · Passage ${passageIndex + 1}/${view.expectedPassages}'
: '';
return 'Séquence : $completedInPassage/$stepsCount étapes$passageLabel';
}
String _setResultSummary(
ActiveSetResult result, {
ExecutionExercise? exercise,
}) {
final parts = <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 remainingMs = rest.remainingMillisecondsAt(DateTime.now().toUtc());
return (remainingMs / 1000).ceil().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,
);
}
}
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);
}