diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index 79832fc..152acba 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -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 { 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, ), ), diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 8d59f53..8611751 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -157,6 +157,8 @@ final class _HomeScreenState extends State 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 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 with RouteAware { closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, historyUseCases: widget.bootstrap.workoutHistoryUseCases, workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases, + performanceReferenceUseCase: + widget.bootstrap.exercisePerformanceReferenceUseCase, mediaUseCases: widget.bootstrap.mediaUseCases, ), ), diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index 08b1f6a..13f75e9 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -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 Function(String id)? mediaAssetLoader; final VideoMediaBuilder? videoMediaBuilder; @@ -71,6 +73,7 @@ final class _WorkoutExecutionScreenState extends State { var _lastCountdownBeepSecond = -1; var _advancingStep = false; var _reps = 0; + Future? _performanceReference; final _scoreController = TextEditingController(); var _remainingRestSeconds = 0; @@ -98,6 +101,7 @@ final class _WorkoutExecutionScreenState extends State { ? 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 { 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 { ), ), ), + 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 { _scoreStopwatchLoadGeneration++; _manualScoreTimeMs = null; _stepProgress = null; + _performanceReference = _loadPerformanceReferenceFor( + _plan.exerciseAt(position), + position.setIndex, + ); _resetStepScoreInput(); _refreshScoreStopwatchTicker(); _refreshStepTicker(); @@ -1517,6 +1535,7 @@ final class _WorkoutExecutionScreenState extends State { 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 { if (!exercise.repsEnabled) return 0; return exercise.targetReps ?? 0; } + + Future? _loadPerformanceReference() { + return _loadPerformanceReferenceFor(_exercise, _position.setIndex); + } + + Future? _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 reference; + final ActivePerformanceMeasures activeMeasures; + final String? scoreUnit; + final bool usesStepPassages; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + 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().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 _imageMediaIdsFromSnapshot(Map exercise) { diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index f28cf1d..b1e2453 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -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, ), ),