feat(execution): refonte de l'UI de l'écran de séance (ticket #93)

Refonte de workout_execution_screen.dart cadrée UX+Architect ; résout au passage la dette de tests du ticket #90 (3 échecs préexistants désormais verts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:32:52 +02:00
parent 9b7ec74831
commit 5c826ce294
2 changed files with 345 additions and 187 deletions

View File

@ -141,7 +141,10 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
icon: const Icon(Icons.arrow_back),
)
: null,
title: Text(_plan.name),
title: _ExecutionAppBarTitle(
workoutName: _plan.name,
programName: _plan.programAt(_position).name,
),
actions: [
IconButton(
tooltip: 'Voir le plan',
@ -183,7 +186,6 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
exercise: _exercise,
currentSet: _position.setIndex + 1,
totalSets: _exercise.setsCount,
progressLabel: _plan.progressLabel(_position),
setTimerLabel: _setTimerLabel,
setTimerComplete: _setTimer?.status == ActiveSetTimerStatus.stopped,
showStartButton: _shouldShowStartExerciseButton,
@ -191,13 +193,6 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
onOpenMedia: _exercise.hasMedia
? () => _openExerciseMedia(_exercise)
: null,
elapsedLabel: _formatDuration(
Duration(
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(
_session,
),
),
),
),
),
if (_performanceReference != null) ...[
@ -237,16 +232,16 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
onResetStepScore: _resetStepScore,
),
),
const SizedBox(height: 8),
SizedBox(
height: _exercise.manualScoreEnabled ? 66 : 48,
child: _StepSetResultSummary(
view: _stepProgress,
exercise: _exercise,
reps: _reps,
scoreController: _scoreController,
if (_exercise.manualScoreEnabled) ...[
const SizedBox(height: 8),
SizedBox(
height: 66,
child: _StepSetResultSummary(
exercise: _exercise,
scoreController: _scoreController,
),
),
),
],
if (_exercise.stopwatchScoreEnabled) ...[
const SizedBox(height: 8),
SizedBox(
@ -1230,7 +1225,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
TextButton(
onPressed: () =>
Navigator.of(context).pop(_MissingSetTimerAction.start),
child: const Text('Démarrer lexercice'),
child: const Text('Démarrer'),
),
FilledButton(
onPressed: () => Navigator.of(
@ -1264,7 +1259,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
TextButton(
onPressed: () =>
Navigator.of(context).pop(_MissingStopwatchAction.start),
child: const Text('Démarrer lexercice'),
child: const Text('Démarrer'),
),
FilledButton(
onPressed: () => Navigator.of(
@ -1757,7 +1752,9 @@ final class SetMeasureInput extends StatelessWidget {
Text(
exercise.targetTimeSeconds == null
? 'Chronométrer'
: '${exercise.targetTimeSeconds} s',
: _formatDuration(
Duration(seconds: exercise.targetTimeSeconds!),
),
style: AppTextStyles.scoreNumber(context),
),
],
@ -1800,13 +1797,46 @@ final class SetMeasureInput extends StatelessWidget {
}
}
final class _ExecutionAppBarTitle extends StatelessWidget {
const _ExecutionAppBarTitle({
required this.workoutName,
required this.programName,
});
final String workoutName;
final String programName;
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
workoutName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).appBarTheme.titleTextStyle,
),
Text(
programName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: tokens.mutedText),
),
],
);
}
}
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,
@ -1817,8 +1847,6 @@ final class _ExecutionContextHeader extends StatelessWidget {
final ExecutionExercise exercise;
final int currentSet;
final int totalSets;
final String progressLabel;
final String elapsedLabel;
final String? setTimerLabel;
final bool setTimerComplete;
final bool showStartButton;
@ -1834,40 +1862,39 @@ final class _ExecutionContextHeader extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 76,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'SÉRIE',
style: theme.textTheme.labelMedium?.copyWith(
color: tokens.mutedText,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'$currentSet / $totalSets',
maxLines: 1,
style: AppTextStyles.scoreNumber(
context,
).copyWith(color: theme.colorScheme.primary),
),
],
),
),
const SizedBox(width: 12),
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,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge,
),
@ -1896,7 +1923,7 @@ final class _ExecutionContextHeader extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12),
),
icon: const Icon(Icons.play_arrow),
label: const Text('Démarrer lexercice'),
label: const Text('Démarrer'),
),
),
if (onOpenMedia != null) ...[
@ -1951,71 +1978,38 @@ final class _SetActionBar extends StatelessWidget {
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),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
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,
),
),
],
],
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 }
@ -2221,29 +2215,27 @@ final class _CurrentStepPane extends StatelessWidget {
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,
child: step.type == ExerciseStepType.time
? _TimedStepBody(
step: step,
remainingLabel: remainingLabel,
running:
view.state.status ==
ActiveExerciseStepProgressStatus.runningTimer,
readyToStart: _isNextTimedStepReady(view),
onStartTimer: onStartTimer,
)
: Align(
alignment: Alignment.topCenter,
child: SizedBox(width: constraints.maxWidth, child: body),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topCenter,
child: _RepsStepBody(
step: step,
onCompleteStep: onCompleteStep,
),
),
),
);
},
),
),
if (step.hasScore) ...[
const SizedBox(height: 8),
@ -2321,34 +2313,37 @@ final class _TimedStepBody extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Objectif : ${step.defaultTargetValue} s'),
const SizedBox(height: 8),
Text('Objectif : ${_formatShortSeconds(step.defaultTargetValue)}'),
const SizedBox(height: 4),
Center(
child: Text(
remainingLabel,
style: AppTextStyles.timer(
context,
).copyWith(color: Theme.of(context).colorScheme.primary),
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontFamily: 'Anton',
fontFeatures: AppTextStyles.timer(context).fontFeatures,
color: Theme.of(context).colorScheme.primary,
),
),
),
if (!running) ...[
const SizedBox(height: 12),
const SizedBox(height: 8),
if (readyToStart) ...[
Text(
'Chrono suivant prêt',
'Chrono prêt',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 8),
const SizedBox(height: 4),
],
FilledButton.icon(
onPressed: onStartTimer,
icon: const Icon(Icons.play_arrow),
label: Text(
readyToStart ? 'Démarrer le chrono' : 'Démarrer la séquence',
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.play_arrow),
label: const Text('Lancer'),
),
],
],
@ -2444,7 +2439,7 @@ final class _StepScoreInput extends StatelessWidget {
FilledButton.icon(
onPressed: running ? onStop : onStart,
icon: Icon(running ? Icons.stop : Icons.play_arrow),
label: Text(running ? 'Arrêter' : 'Démarrer'),
label: Text(running ? 'Arrêter' : 'Lancer'),
),
OutlinedButton.icon(
onPressed: onReset,
@ -2620,7 +2615,7 @@ final class _ScoreStopwatchInput extends StatelessWidget {
FilledButton.icon(
onPressed: onStart,
icon: const Icon(Icons.play_arrow),
label: const Text('Démarrer'),
label: const Text('Lancer'),
)
else if (running)
FilledButton.icon(
@ -4089,7 +4084,7 @@ String? _formatSetPerformance(
if (performance == null) return null;
final values = [
if (measures.timeEnabled && performance.actualTimeMs != null)
_formatReferenceTime(performance.actualTimeMs!, stopwatch: false),
_formatReferenceMeasureTime(performance.actualTimeMs!),
if (measures.repsEnabled && performance.actualReps != null)
_formatReferenceReps(
performance.actualReps!,
@ -4117,7 +4112,7 @@ String? _formatMetricPerformance(
PerformanceMetric.time =>
performance.actualTimeMs == null
? null
: _formatReferenceTime(performance.actualTimeMs!, stopwatch: false),
: _formatReferenceMeasureTime(performance.actualTimeMs!),
PerformanceMetric.reps =>
performance.actualReps == null
? null
@ -4129,10 +4124,7 @@ String? _formatMetricPerformance(
performance.scoreInputModeSnapshot == ScoreInputMode.stopwatch
? performance.actualScoreTimeMs == null
? null
: _formatReferenceTime(
performance.actualScoreTimeMs!,
stopwatch: true,
)
: _formatReferenceStopwatchScore(performance.actualScoreTimeMs!)
: performance.actualScore == null
? null
: _formatReferenceManualScore(performance.actualScore!, scoreUnit),
@ -4162,7 +4154,7 @@ String? _formatReferenceScore({
if (scoreInputMode == ScoreInputMode.stopwatch) {
return actualScoreTimeMs == null
? null
: _formatReferenceTime(actualScoreTimeMs, stopwatch: true);
: _formatReferenceStopwatchScore(actualScoreTimeMs);
}
return actualScore == null
? null
@ -4180,22 +4172,27 @@ String _formatReferenceManualScore(double score, String? scoreUnit) {
return '$value $unit';
}
String _formatReferenceTime(int milliseconds, {required bool stopwatch}) {
final totalTenths = (milliseconds / 100).round();
final minutes = totalTenths ~/ 600;
final seconds = (totalTenths ~/ 10) % 60;
final tenths = totalTenths % 10;
if (minutes == 0) {
if (!stopwatch && tenths == 0) {
return '$seconds s';
}
return '${(seconds + tenths / 10).toStringAsFixed(1)} s';
String _formatReferenceMeasureTime(int milliseconds) {
final totalSeconds = (milliseconds / 1000).round().clamp(0, 999999).toInt();
if (totalSeconds < 60) {
return '$totalSeconds s';
}
final paddedSeconds = seconds.toString().padLeft(2, '0');
if (stopwatch) {
return '$minutes:$paddedSeconds.$tenths';
final minutes = totalSeconds ~/ 60;
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
String _formatReferenceStopwatchScore(int milliseconds) {
return _formatScoreStopwatch(Duration(milliseconds: milliseconds));
}
String _formatShortSeconds(int seconds) {
if (seconds < 60) {
return '$seconds s';
}
return '$minutes:$paddedSeconds';
final minutes = seconds ~/ 60;
final remainingSeconds = (seconds % 60).toString().padLeft(2, '0');
return '$minutes:$remainingSeconds';
}
List<String> _imageMediaIdsFromSnapshot(Map<String, dynamic> exercise) {