feat(exercice): plan de séance et historique avec résultats d'étapes (ticket #61)

Affiche le plan de séance et l'historique avec les résultats par
étape sur workout_execution_screen.dart et history_screen.dart.
flutter pub get OK, dart format appliqué (3 fichiers reformatés),
analyze propre (mêmes infos préexistantes), 102/102 tests verts,
build APK debug validé. Dernier ticket UI du chantier "Exercice à
plusieurs étapes" (#54) — il ne reste que la QA finale (#62).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 20:19:01 +02:00
parent cdaf45646c
commit 8bf4f89276
4 changed files with 557 additions and 62 deletions

View File

@ -13,7 +13,8 @@ import 'theme.dart';
enum WorkoutExecutionMode { active, rest, paused, finished }
typedef VideoMediaBuilder = Widget Function(BuildContext context, MediaAsset asset);
typedef VideoMediaBuilder =
Widget Function(BuildContext context, MediaAsset asset);
final class WorkoutExecutionScreen extends StatefulWidget {
const WorkoutExecutionScreen({
@ -244,7 +245,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
child: const Text('Passer'),
child: Text(_hasActiveStepSequence ? 'Passer la série' : 'Passer'),
),
],
);
@ -632,7 +633,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
} on Exception catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossible de rafraîchir la séquence : $error')),
SnackBar(
content: Text('Impossible de rafraîchir la séquence : $error'),
),
);
}
}
@ -642,7 +645,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
final previousKey = previous == null
? null
: '${previous.state.currentPassageIndex}:${previous.state.currentStepIndex}';
final nextKey = '${view.state.currentPassageIndex}:${view.state.currentStepIndex}';
final nextKey =
'${view.state.currentPassageIndex}:${view.state.currentStepIndex}';
if (previousKey != null && previousKey != nextKey) {
_resetStepScoreInput();
}
@ -776,7 +780,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
Future<void> _skipCurrentStep() async {
final confirmed = await _confirmStepSkip(
'Passer cette étape ?',
_stepProgress?.state.status == ActiveExerciseStepProgressStatus.runningTimer
_stepProgress?.state.status ==
ActiveExerciseStepProgressStatus.runningTimer
? 'Le chrono de cette étape sera arrêté.'
: 'Cette étape sera marquée comme passée.',
'Passer létape',
@ -1017,6 +1022,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
setStates: setStates,
activeRest: activeRest,
activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
),
),
);
@ -1602,9 +1608,9 @@ final class _TimedStepBody extends StatelessWidget {
Center(
child: Text(
remainingLabel,
style: AppTextStyles.timer(context).copyWith(
color: Theme.of(context).colorScheme.primary,
),
style: AppTextStyles.timer(
context,
).copyWith(color: Theme.of(context).colorScheme.primary),
),
),
if (!running) ...[
@ -1632,9 +1638,9 @@ final class _RepsStepBody extends StatelessWidget {
children: [
Text(
'${step.defaultTargetValue}',
style: AppTextStyles.timer(context).copyWith(
color: Theme.of(context).colorScheme.primary,
),
style: AppTextStyles.timer(
context,
).copyWith(color: Theme.of(context).colorScheme.primary),
),
Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 12),
@ -1679,7 +1685,10 @@ final class _StepScoreInput extends StatelessWidget {
),
const SizedBox(height: 8),
Center(
child: Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)),
child: Text(
elapsedLabel,
style: AppTextStyles.scoreNumber(context),
),
),
const SizedBox(height: 8),
Wrap(
@ -1980,6 +1989,7 @@ final class _WorkoutPlanSheet extends StatefulWidget {
required this.currentPosition,
required this.setStates,
required this.activeUseCases,
this.stepUseCases,
this.activeRest,
});
@ -1988,6 +1998,7 @@ final class _WorkoutPlanSheet extends StatefulWidget {
final ExecutionPosition currentPosition;
final List<SetResultPositionState> setStates;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveRestState? activeRest;
@override
@ -2099,6 +2110,7 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
)],
onUpdated: _refreshAfterEdit,
activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
sessionId: widget.sessionId,
activeRest: widget.activeRest,
),
@ -2524,7 +2536,7 @@ File _fileFromLocalUri(String localUri) {
return File(localUri);
}
final class _WorkoutPlanSetTile extends StatelessWidget {
final class _WorkoutPlanSetTile extends StatefulWidget {
const _WorkoutPlanSetTile({
required this.position,
required this.currentPosition,
@ -2532,6 +2544,7 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
required this.onUpdated,
required this.activeUseCases,
required this.sessionId,
this.stepUseCases,
this.activeRest,
this.state,
});
@ -2542,12 +2555,51 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
final SetResultPositionState? state;
final Future<void> Function() onUpdated;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final String sessionId;
final ActiveRestState? activeRest;
@override
State<_WorkoutPlanSetTile> createState() => _WorkoutPlanSetTileState();
}
final class _WorkoutPlanSetTileState extends State<_WorkoutPlanSetTile> {
late final Future<ActiveExerciseStepProgressView?> _stepProgress;
@override
void initState() {
super.initState();
_stepProgress = _loadStepProgress();
}
Future<ActiveExerciseStepProgressView?> _loadStepProgress() async {
final stepUseCases = widget.stepUseCases;
if (stepUseCases == null || widget.exercise.steps.isEmpty) {
return null;
}
final planState = _planSetState(
widget.position,
widget.currentPosition,
widget.state,
);
if (planState == _PlanSetStatus.todo) {
return null;
}
return stepUseCases.readProgress(
sessionId: widget.sessionId,
programIndex: widget.position.programIndex,
exerciseIndex: widget.position.exerciseIndex,
setIndex: widget.position.setIndex,
);
}
@override
Widget build(BuildContext context) {
final planState = _planSetState(position, currentPosition, state);
final planState = _planSetState(
widget.position,
widget.currentPosition,
widget.state,
);
final tappable = planState != _PlanSetStatus.todo;
final VoidCallback? onTap = tappable
? () => unawaited(_handleTap(context, planState))
@ -2565,8 +2617,13 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
),
child: ListTile(
enabled: tappable,
title: Text('Série ${position.setIndex + 1}'),
subtitle: Text(_planSetSubtitle(planState, state?.result, exercise)),
title: Text('Série ${widget.position.setIndex + 1}'),
subtitle: _WorkoutPlanSetSubtitle(
status: planState,
result: widget.state?.result,
exercise: widget.exercise,
stepProgress: _stepProgress,
),
trailing: _PlanStatusBadge(status: planState, onTap: onTap),
onTap: onTap,
),
@ -2583,19 +2640,59 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
isScrollControlled: true,
useSafeArea: true,
builder: (context) => _EditSetResultSheet(
sessionId: sessionId,
position: position,
exercise: exercise,
state: state,
sessionId: widget.sessionId,
position: widget.position,
exercise: widget.exercise,
state: widget.state,
status: status,
activeUseCases: activeUseCases,
activeRest: activeRest,
onSaved: onUpdated,
activeUseCases: widget.activeUseCases,
activeRest: widget.activeRest,
onSaved: widget.onUpdated,
),
);
}
}
final class _WorkoutPlanSetSubtitle extends StatelessWidget {
const _WorkoutPlanSetSubtitle({
required this.status,
required this.result,
required this.exercise,
required this.stepProgress,
});
final _PlanSetStatus status;
final ActiveSetResult? result;
final ExecutionExercise exercise;
final Future<ActiveExerciseStepProgressView?> stepProgress;
@override
Widget build(BuildContext context) {
final resultLabel = _planSetSubtitle(status, result, exercise);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (resultLabel.isNotEmpty) Text(resultLabel),
if (exercise.steps.isNotEmpty)
FutureBuilder<ActiveExerciseStepProgressView?>(
future: stepProgress,
builder: (context, snapshot) {
final progress = snapshot.data;
final label = progress == null
? 'Séquence : 0/${exercise.steps.length} étapes'
: _planStepSequenceSummary(progress);
return Padding(
padding: EdgeInsets.only(top: resultLabel.isEmpty ? 0 : 4),
child: Text(label),
);
},
),
],
);
}
}
final class _EditSetResultSheet extends StatefulWidget {
const _EditSetResultSheet({
required this.sessionId,
@ -2916,6 +3013,29 @@ String _planSetSubtitle(
return '';
}
String _planStepSequenceSummary(ActiveExerciseStepProgressView view) {
final state = view.state;
final stepsCount = view.steps.length;
final passageIndex =
state.status == ActiveExerciseStepProgressStatus.sequenceComplete
? (view.expectedPassages - 1).clamp(0, 9999).toInt()
: state.currentPassageIndex;
final completedInPassage =
state.status == ActiveExerciseStepProgressStatus.sequenceComplete
? stepsCount
: view.results
.where((result) => result.passageIndex == passageIndex)
.map((result) => result.stepIndex)
.toSet()
.length
.clamp(0, stepsCount)
.toInt();
final passageLabel = view.expectedPassages > 1
? ' · Passage ${passageIndex + 1}/${view.expectedPassages}'
: '';
return 'Séquence : $completedInPassage/$stepsCount étapes$passageLabel';
}
String _setResultSummary(
ActiveSetResult result, {
ExecutionExercise? exercise,
@ -3210,26 +3330,28 @@ List<String> _imageMediaIdsFromSnapshot(Map<String, dynamic> exercise) {
List<ExerciseStep> _exerciseStepsFromSnapshot(Map<String, dynamic> exercise) {
final rawSteps = exercise['exerciseStepsSnapshot'];
if (rawSteps is! List) return const [];
return rawSteps.map((rawStep) {
final step = rawStep as Map<String, dynamic>;
final typeName = step['type'] as String? ?? ExerciseStepType.time.name;
return ExerciseStep(
id: step['id'] as String,
position: step['position'] as int,
name: step['name'] as String,
type: ExerciseStepType.values.firstWhere(
(type) => type.name == typeName,
orElse: () => ExerciseStepType.time,
),
defaultTargetValue: step['defaultTargetValue'] as int,
hasScore: step['hasScore'] as bool? ?? false,
scoreInputMode: _scoreInputModeFromSnapshot(step['scoreInputMode']),
scoreLabel: step['scoreLabel'] as String?,
scoreUnit: step['scoreUnit'] as String?,
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
);
}).toList(growable: false);
return rawSteps
.map((rawStep) {
final step = rawStep as Map<String, dynamic>;
final typeName = step['type'] as String? ?? ExerciseStepType.time.name;
return ExerciseStep(
id: step['id'] as String,
position: step['position'] as int,
name: step['name'] as String,
type: ExerciseStepType.values.firstWhere(
(type) => type.name == typeName,
orElse: () => ExerciseStepType.time,
),
defaultTargetValue: step['defaultTargetValue'] as int,
hasScore: step['hasScore'] as bool? ?? false,
scoreInputMode: _scoreInputModeFromSnapshot(step['scoreInputMode']),
scoreLabel: step['scoreLabel'] as String?,
scoreUnit: step['scoreUnit'] as String?,
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
);
})
.toList(growable: false);
}
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {