feat(exécution): affichage dernière performance et meilleur score (ticket #81)

Affiche la dernière performance et le meilleur score par exercice
pendant l'exécution de séance (écran d'exécution), ainsi que les
points d'accès associés dans l'accueil, la séance-modèle et
l'historique, en s'appuyant sur ExercisePerformanceReferenceUseCase.

Validé GO par Main : dart analyze propre, 178 tests (4 échecs
préexistants sans rapport, identiques à ceux déjà documentés sur #80).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 08:03:00 +02:00
parent ac51c16b51
commit 0813327bef
4 changed files with 346 additions and 11 deletions

View File

@ -15,6 +15,7 @@ final class HistoryListScreen extends StatefulWidget {
required this.closeUseCase,
this.mediaUseCases,
this.stepUseCases,
this.performanceReferenceUseCase,
super.key,
});
@ -22,6 +23,7 @@ final class HistoryListScreen extends StatefulWidget {
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@ -97,6 +99,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
workoutTemplateUseCases: widget.workoutTemplateUseCases,
activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases,
),
@ -119,6 +122,7 @@ final class HistoryDetailScreen extends StatelessWidget {
required this.closeUseCase,
this.mediaUseCases,
this.stepUseCases,
this.performanceReferenceUseCase,
super.key,
});
@ -127,6 +131,7 @@ final class HistoryDetailScreen extends StatelessWidget {
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@ -213,6 +218,7 @@ final class HistoryDetailScreen extends StatelessWidget {
closeUseCase: closeUseCase,
historyUseCases: historyUseCases,
workoutTemplateUseCases: workoutTemplateUseCases,
performanceReferenceUseCase: performanceReferenceUseCase,
mediaUseCases: mediaUseCases,
),
),

View File

@ -157,6 +157,8 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
programUseCases: widget.bootstrap.programUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
mediaUseCases: widget.bootstrap.mediaUseCases,
@ -179,6 +181,8 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
widget.bootstrap.workoutTemplateUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases,
),
@ -231,6 +235,8 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases,
),
),

View File

@ -23,6 +23,7 @@ final class WorkoutExecutionScreen extends StatefulWidget {
required this.closeUseCase,
required this.historyUseCases,
required this.workoutTemplateUseCases,
this.performanceReferenceUseCase,
this.mediaUseCases,
this.mediaAssetLoader,
this.videoMediaBuilder,
@ -37,6 +38,7 @@ final class WorkoutExecutionScreen extends StatefulWidget {
final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final MediaUseCases? mediaUseCases;
final Future<MediaAsset?> Function(String id)? mediaAssetLoader;
final VideoMediaBuilder? videoMediaBuilder;
@ -71,6 +73,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
var _lastCountdownBeepSecond = -1;
var _advancingStep = false;
var _reps = 0;
Future<ExercisePerformanceReference>? _performanceReference;
final _scoreController = TextEditingController();
var _remainingRestSeconds = 0;
@ -98,6 +101,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
? WorkoutExecutionMode.active
: WorkoutExecutionMode.paused;
_reps = _initialRepsFor(_exercise);
_performanceReference = _loadPerformanceReference();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
@ -181,8 +185,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
totalSets: _exercise.setsCount,
progressLabel: _plan.progressLabel(_position),
setTimerLabel: _setTimerLabel,
setTimerComplete:
_setTimer?.status == ActiveSetTimerStatus.stopped,
setTimerComplete: _setTimer?.status == ActiveSetTimerStatus.stopped,
showStartButton: _shouldShowStartExerciseButton,
onStartExercise: _startExercise,
onOpenMedia: _exercise.hasMedia
@ -197,6 +200,17 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
),
),
),
if (_performanceReference != null) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: PerformanceReferenceCard(
reference: _performanceReference!,
activeMeasures: _exercise.activePerformanceMeasures,
scoreUnit: _exercise.scoreUnit,
usesStepPassages: _exercise.steps.isNotEmpty,
),
),
],
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
@ -1442,6 +1456,10 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_scoreStopwatchLoadGeneration++;
_manualScoreTimeMs = null;
_stepProgress = null;
_performanceReference = _loadPerformanceReferenceFor(
_plan.exerciseAt(position),
position.setIndex,
);
_resetStepScoreInput();
_refreshScoreStopwatchTicker();
_refreshStepTicker();
@ -1517,6 +1535,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
closeUseCase: widget.closeUseCase,
historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
mediaUseCases: widget.mediaUseCases,
mediaAssetLoader: widget.mediaAssetLoader,
videoMediaBuilder: widget.videoMediaBuilder,
@ -1529,6 +1548,181 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
if (!exercise.repsEnabled) return 0;
return exercise.targetReps ?? 0;
}
Future<ExercisePerformanceReference>? _loadPerformanceReference() {
return _loadPerformanceReferenceFor(_exercise, _position.setIndex);
}
Future<ExercisePerformanceReference>? _loadPerformanceReferenceFor(
ExecutionExercise exercise,
int setIndex,
) {
final useCase = widget.performanceReferenceUseCase;
final sourceExerciseId = exercise.sourceExerciseId;
if (useCase == null || sourceExerciseId == null) {
return null;
}
return useCase.getExercisePerformanceReference(
exerciseId: sourceExerciseId,
activeMeasures: exercise.activePerformanceMeasures,
currentSetIndex: setIndex,
);
}
}
final class PerformanceReferenceCard extends StatelessWidget {
const PerformanceReferenceCard({
required this.reference,
required this.activeMeasures,
this.scoreUnit,
this.usesStepPassages = false,
super.key,
});
final Future<ExercisePerformanceReference> reference;
final ActivePerformanceMeasures activeMeasures;
final String? scoreUnit;
final bool usesStepPassages;
@override
Widget build(BuildContext context) {
return FutureBuilder<ExercisePerformanceReference>(
future: reference,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const _PerformanceReferenceShell(
child: Center(child: Text('Chargement…')),
);
}
if (snapshot.hasError) {
return const _PerformanceReferenceShell(
child: Center(child: Text('Références indisponibles')),
);
}
final reference = snapshot.data;
if (reference == null || !activeMeasures.hasAny) {
return const _PerformanceReferenceShell(
child: Center(child: Text('Aucune référence pour ces mesures')),
);
}
if (!reference.hasAnyHistoryForExercise) {
return const _PerformanceReferenceShell(
child: Center(child: Text('Première fois sur cet exercice')),
);
}
final lastLabel = _formatSetPerformance(
reference.last,
activeMeasures,
scoreUnit: scoreUnit,
usesStepPassages: usesStepPassages,
);
final recordLabel = _formatMetricPerformance(
reference.record,
scoreUnit: scoreUnit,
usesStepPassages: usesStepPassages,
);
if (lastLabel == null && recordLabel == null) {
return const _PerformanceReferenceShell(
child: Center(child: Text('Aucune référence pour ces mesures')),
);
}
return _PerformanceReferenceShell(
child: Row(
children: [
Expanded(
child: _PerformanceReferenceColumn(
title: 'Dernière',
value: lastLabel ?? '',
),
),
const SizedBox(width: 12),
Expanded(
child: _PerformanceReferenceColumn(
title: _recordTitle(reference.record),
value: recordLabel ?? '',
alignEnd: true,
),
),
],
),
);
},
);
}
}
final class _PerformanceReferenceShell extends StatelessWidget {
const _PerformanceReferenceShell({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context);
return Container(
constraints: const BoxConstraints(minHeight: 64),
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: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Références',
style: theme.textTheme.labelSmall?.copyWith(
color: tokens.mutedText,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
child,
],
),
);
}
}
final class _PerformanceReferenceColumn extends StatelessWidget {
const _PerformanceReferenceColumn({
required this.title,
required this.value,
this.alignEnd = false,
});
final String title;
final String value;
final bool alignEnd;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: alignEnd
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.labelSmall?.copyWith(color: tokens.mutedText),
),
const SizedBox(height: 2),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: alignEnd ? TextAlign.end : TextAlign.start,
style: theme.textTheme.titleSmall,
),
],
);
}
}
final class SetMeasureInput extends StatelessWidget {
@ -1726,10 +1920,7 @@ final class _ExecutionContextHeader extends StatelessWidget {
}
final class _SetActionBar extends StatelessWidget {
const _SetActionBar({
required this.onFinishSet,
required this.onSkipSet,
});
const _SetActionBar({required this.onFinishSet, required this.onSkipSet});
final VoidCallback onFinishSet;
final VoidCallback onSkipSet;
@ -1892,10 +2083,7 @@ final class _StepSequencePanel extends StatelessWidget {
style: Theme.of(context).textTheme.titleMedium,
),
),
Text(
'SÉQUENCE',
style: Theme.of(context).textTheme.labelMedium,
),
Text('SÉQUENCE', style: Theme.of(context).textTheme.labelMedium),
],
),
const SizedBox(height: 6),
@ -1972,7 +2160,9 @@ final class _BoundedAccentPanel extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(height: 2, color: tokens.accent),
Expanded(child: Padding(padding: padding, child: child)),
Expanded(
child: Padding(padding: padding, child: child),
),
],
),
);
@ -3790,6 +3980,7 @@ final class ExecutionProgram {
final class ExecutionExercise {
const ExecutionExercise({
required this.id,
this.sourceExerciseId,
required this.name,
required this.setsCount,
required this.timeEnabled,
@ -3814,6 +4005,7 @@ final class ExecutionExercise {
) {
return ExecutionExercise(
id: exercise['id'] as String,
sourceExerciseId: exercise['sourceExerciseId'] as String?,
name: exercise['exerciseNameSnapshot'] as String,
setsCount:
(override?['setsCountOverride'] as int?) ??
@ -3848,6 +4040,7 @@ final class ExecutionExercise {
}
final String id;
final String? sourceExerciseId;
final String name;
final int setsCount;
final bool timeEnabled;
@ -3876,6 +4069,133 @@ final class ExecutionExercise {
bool get hasMedia {
return imageMediaIds.isNotEmpty || videoMediaId != null;
}
ActivePerformanceMeasures get activePerformanceMeasures {
return ActivePerformanceMeasures(
timeEnabled: timeEnabled,
repsEnabled: repsEnabled,
scoreEnabled: scoreEnabled,
scoreInputMode: scoreInputMode,
);
}
}
String? _formatSetPerformance(
WorkoutHistorySetPerformance? performance,
ActivePerformanceMeasures measures, {
required String? scoreUnit,
required bool usesStepPassages,
}) {
if (performance == null) return null;
final values = [
if (measures.timeEnabled && performance.actualTimeMs != null)
_formatReferenceTime(performance.actualTimeMs!, stopwatch: false),
if (measures.repsEnabled && performance.actualReps != null)
_formatReferenceReps(
performance.actualReps!,
usesStepPassages: usesStepPassages,
),
if (measures.scoreEnabled)
_formatReferenceScore(
actualScore: performance.actualScore,
actualScoreTimeMs: performance.actualScoreTimeMs,
scoreInputMode: measures.scoreInputMode,
scoreUnit: scoreUnit,
),
].whereType<String>().toList();
if (values.isEmpty) return null;
return values.join(' · ');
}
String? _formatMetricPerformance(
WorkoutHistoryMetricPerformance? performance, {
required String? scoreUnit,
required bool usesStepPassages,
}) {
if (performance == null) return null;
return switch (performance.metric) {
PerformanceMetric.time =>
performance.actualTimeMs == null
? null
: _formatReferenceTime(performance.actualTimeMs!, stopwatch: false),
PerformanceMetric.reps =>
performance.actualReps == null
? null
: _formatReferenceReps(
performance.actualReps!,
usesStepPassages: usesStepPassages,
),
PerformanceMetric.score =>
performance.scoreInputModeSnapshot == ScoreInputMode.stopwatch
? performance.actualScoreTimeMs == null
? null
: _formatReferenceTime(
performance.actualScoreTimeMs!,
stopwatch: true,
)
: performance.actualScore == null
? null
: _formatReferenceManualScore(performance.actualScore!, scoreUnit),
};
}
String _recordTitle(WorkoutHistoryMetricPerformance? performance) {
return switch (performance?.metric) {
PerformanceMetric.time => 'Max',
PerformanceMetric.score
when performance?.scoreInputModeSnapshot == ScoreInputMode.stopwatch =>
'Meilleur temps',
_ => 'Record',
};
}
String _formatReferenceReps(int reps, {required bool usesStepPassages}) {
return '$reps ${usesStepPassages ? 'passages' : 'reps'}';
}
String? _formatReferenceScore({
required double? actualScore,
required int? actualScoreTimeMs,
required ScoreInputMode scoreInputMode,
required String? scoreUnit,
}) {
if (scoreInputMode == ScoreInputMode.stopwatch) {
return actualScoreTimeMs == null
? null
: _formatReferenceTime(actualScoreTimeMs, stopwatch: true);
}
return actualScore == null
? null
: _formatReferenceManualScore(actualScore, scoreUnit);
}
String _formatReferenceManualScore(double score, String? scoreUnit) {
final value = score == score.roundToDouble()
? score.toInt().toString()
: score.toStringAsFixed(1);
final unit = scoreUnit?.trim();
if (unit == null || unit.isEmpty) {
return value;
}
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';
}
final paddedSeconds = seconds.toString().padLeft(2, '0');
if (stopwatch) {
return '$minutes:$paddedSeconds.$tenths';
}
return '$minutes:$paddedSeconds';
}
List<String> _imageMediaIdsFromSnapshot(Map<String, dynamic> exercise) {

View File

@ -17,6 +17,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
required this.historyUseCases,
this.mediaUseCases,
this.stepUseCases,
this.performanceReferenceUseCase,
this.shareUseCases,
this.authUseCases,
this.syncUseCases,
@ -27,6 +28,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
final ProgramUseCases programUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
final MediaUseCases? mediaUseCases;
@ -166,6 +168,7 @@ final class _WorkoutTemplateListScreenState
closeUseCase: widget.closeUseCase,
historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
mediaUseCases: widget.mediaUseCases,
),
),