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:
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user