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:
@ -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 l’exercice'),
|
||||
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 l’exercice'),
|
||||
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 l’exercice'),
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user