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

Fusionne feature/exercise-steps-61-plan-history — pub get OK, dart
format appliqué, 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:14 +02:00
7 changed files with 566 additions and 71 deletions

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#60" issueRef: "#60"
version: 1 version: 2
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784481536357 updatedAt: 1784484539686
--- ---

View File

@ -2,15 +2,15 @@
id: "4807a946-834e-44a4-8a39-25ad951e6668" id: "4807a946-834e-44a4-8a39-25ad951e6668"
number: 60 number: 60
title: "[DevFrontend] Exécution de séance avec module séquence et bips" title: "[DevFrontend] Exécution de séance avec module séquence et bips"
status: "open" status: "qa"
priority: "medium" priority: "medium"
sprint: null sprint: null
links: [{"target":"#54","kind":"relatesTo"},{"target":"#58","kind":"dependsOn"},{"target":"#59","kind":"dependsOn"}] links: [{"target":"#54","kind":"relatesTo"},{"target":"#58","kind":"dependsOn"},{"target":"#59","kind":"dependsOn"}]
agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784481536357 createdAt: 1784481536357
updatedAt: 1784481536357 updatedAt: 1784484539686
version: 1 version: 2
--- ---
Implémenter le module `Séquence` sur l'écran d'exécution : affichage passage/étape, étapes temps avec compte à rebours, étapes répétitions avec validation manuelle, boucle multi-passages si reps de série actives, skips distincts étape/passage/série, persistance après pause/kill via use cases backend. Ajouter un port audio testable et un adapter Flutter utilisant des bips courts/longs, sans dépendance des tests widget à un lecteur audio réel. Implémenter le module `Séquence` sur l'écran d'exécution : affichage passage/étape, étapes temps avec compte à rebours, étapes répétitions avec validation manuelle, boucle multi-passages si reps de série actives, skips distincts étape/passage/série, persistance après pause/kill via use cases backend. Ajouter un port audio testable et un adapter Flutter utilisant des bips courts/longs, sans dépendance des tests widget à un lecteur audio réel.

View File

@ -667,13 +667,13 @@
"issueRef": "#60", "issueRef": "#60",
"path": "60", "path": "60",
"title": "[DevFrontend] Exécution de séance avec module séquence et bips", "title": "[DevFrontend] Exécution de séance avec module séquence et bips",
"status": "open", "status": "qa",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d" "9933c93a-b8a1-4164-a3bb-7063fdad747d"
], ],
"updatedAt": 1784481536357 "updatedAt": 1784484539686
}, },
{ {
"issueRef": "#61", "issueRef": "#61",

View File

@ -151,10 +151,21 @@ final class HistoryDetailScreen extends StatelessWidget {
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
for (final set in exercise.sets) for (final set in exercise.sets)
ListTile( Padding(
dense: true, padding: const EdgeInsets.only(bottom: 8),
title: Text('Série ${set.setIndex + 1}'), child: Column(
subtitle: Text(set.summary), crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text('Série ${set.setIndex + 1}'),
subtitle: Text(set.summary),
),
if (set.stepPassages.isNotEmpty)
_HistoryStepResultsSection(passages: set.stepPassages),
],
),
), ),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
@ -246,6 +257,10 @@ final class HistoryDetailData {
? history.results.map(_resultFromDomain).toList() ? history.results.map(_resultFromDomain).toList()
: (snapshot['results'] as List<dynamic>? ?? const []) : (snapshot['results'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>(); .cast<Map<String, dynamic>>();
final rawStepResults = history.stepResults.isNotEmpty
? history.stepResults.map(_stepResultFromDomain).toList()
: (snapshot['stepResults'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
final programs = <HistoryProgramDetail>[]; final programs = <HistoryProgramDetail>[];
final rawPrograms = resolved['programs'] as List<dynamic>? ?? const []; final rawPrograms = resolved['programs'] as List<dynamic>? ?? const [];
for ( for (
@ -270,12 +285,25 @@ final class HistoryDetailData {
return result['programIndex'] == programIndex && return result['programIndex'] == programIndex &&
result['exerciseIndex'] == exerciseIndex; result['exerciseIndex'] == exerciseIndex;
}).toList(); }).toList();
final exerciseStepResults = rawStepResults.where((result) {
return result['programIndex'] == programIndex &&
result['exerciseIndex'] == exerciseIndex;
}).toList();
exercises.add( exercises.add(
HistoryExerciseDetail( HistoryExerciseDetail(
name: rawExercise['exerciseNameSnapshot'] as String, name: rawExercise['exerciseNameSnapshot'] as String,
sets: [ sets: [
for (final result in exerciseResults) for (final result in exerciseResults)
HistorySetDetail.fromSnapshot(rawExercise, result), HistorySetDetail.fromSnapshot(
rawExercise,
result,
exerciseStepResults
.where(
(stepResult) =>
stepResult['setIndex'] == result['setIndex'],
)
.toList(),
),
], ],
), ),
); );
@ -308,11 +336,16 @@ final class HistoryExerciseDetail {
} }
final class HistorySetDetail { final class HistorySetDetail {
const HistorySetDetail({required this.setIndex, required this.summary}); const HistorySetDetail({
required this.setIndex,
required this.summary,
required this.stepPassages,
});
factory HistorySetDetail.fromSnapshot( factory HistorySetDetail.fromSnapshot(
Map<String, dynamic> exercise, Map<String, dynamic> exercise,
Map<String, dynamic> result, Map<String, dynamic> result,
List<Map<String, dynamic>> stepResults,
) { ) {
final parts = <String>[]; final parts = <String>[];
if (exercise['timeEnabled'] == true && result['actualTimeMs'] != null) { if (exercise['timeEnabled'] == true && result['actualTimeMs'] != null) {
@ -345,11 +378,101 @@ final class HistorySetDetail {
return HistorySetDetail( return HistorySetDetail(
setIndex: result['setIndex'] as int, setIndex: result['setIndex'] as int,
summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '), summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '),
stepPassages: _stepPassagesFromResults(stepResults),
); );
} }
final int setIndex; final int setIndex;
final String summary; final String summary;
final List<HistoryStepPassageDetail> stepPassages;
}
final class HistoryStepPassageDetail {
const HistoryStepPassageDetail({required this.index, required this.steps});
final int index;
final List<HistoryStepResultDetail> steps;
}
final class HistoryStepResultDetail {
const HistoryStepResultDetail({
required this.index,
required this.name,
required this.status,
required this.valueLabel,
this.scoreLabel,
});
factory HistoryStepResultDetail.fromResult(Map<String, dynamic> result) {
final status = _setResultStatusFromSnapshot(result['status']);
return HistoryStepResultDetail(
index: result['stepIndex'] as int,
name: result['stepNameSnapshot'] as String,
status: status,
valueLabel: status == SetResultStatus.skipped
? null
: _historyStepValueLabel(result),
scoreLabel: status == SetResultStatus.skipped
? null
: _historyStepScoreLabel(result),
);
}
final int index;
final String name;
final SetResultStatus status;
final String? valueLabel;
final String? scoreLabel;
}
final class _HistoryStepResultsSection extends StatelessWidget {
const _HistoryStepResultsSection({required this.passages});
final List<HistoryStepPassageDetail> passages;
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Étapes réalisées',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
for (final passage in passages) ...[
Text(
'Passage ${passage.index + 1}',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
for (final step in passage.steps)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(_historyStepLine(step))),
if (step.status == SetResultStatus.skipped) ...[
const SizedBox(width: 8),
Chip(
visualDensity: VisualDensity.compact,
label: const Text('Passée'),
side: BorderSide(color: tokens.border),
),
],
],
),
),
const SizedBox(height: 8),
],
],
),
);
}
} }
Map<String, List<WorkoutHistory>> groupHistoriesByPeriod( Map<String, List<WorkoutHistory>> groupHistoriesByPeriod(
@ -395,6 +518,99 @@ Map<String, dynamic> _resultFromDomain(WorkoutHistorySetResult result) {
}; };
} }
Map<String, dynamic> _stepResultFromDomain(WorkoutHistoryStepResult result) {
return {
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'passageIndex': result.passageIndex,
'stepIndex': result.stepIndex,
'stepNameSnapshot': result.stepNameSnapshot,
'stepTypeSnapshot': result.stepTypeSnapshot.name,
'targetValueSnapshot': result.targetValueSnapshot,
'hasScoreSnapshot': result.hasScoreSnapshot,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'status': result.status.name,
};
}
List<HistoryStepPassageDetail> _stepPassagesFromResults(
List<Map<String, dynamic>> results,
) {
if (results.isEmpty) return const [];
final byPassage = <int, List<Map<String, dynamic>>>{};
for (final result in results) {
final passageIndex = result['passageIndex'] as int;
byPassage.putIfAbsent(passageIndex, () => []).add(result);
}
final passages = byPassage.entries.toList()
..sort((left, right) => left.key.compareTo(right.key));
return passages
.map((entry) {
final steps = entry.value
..sort(
(left, right) =>
(left['stepIndex'] as int).compareTo(right['stepIndex'] as int),
);
return HistoryStepPassageDetail(
index: entry.key,
steps: steps.map(HistoryStepResultDetail.fromResult).toList(),
);
})
.toList(growable: false);
}
String _historyStepLine(HistoryStepResultDetail step) {
final parts = <String>['Étape ${step.index + 1}', step.name];
if (step.valueLabel != null) {
parts.add(step.valueLabel!);
}
if (step.scoreLabel != null) {
parts.add(step.scoreLabel!);
}
return parts.join(' · ');
}
String? _historyStepValueLabel(Map<String, dynamic> result) {
final type = result['stepTypeSnapshot'];
if (type == ExerciseStepType.time.name && result['actualTimeMs'] != null) {
return 'Temps ${_formatDurationMs(result['actualTimeMs'] as int)}';
}
if (type == ExerciseStepType.reps.name && result['actualReps'] != null) {
return 'Répétitions ${result['actualReps']}';
}
return null;
}
String? _historyStepScoreLabel(Map<String, dynamic> result) {
if (result['hasScoreSnapshot'] != true) return null;
final mode = _scoreInputModeFromSnapshot(result['scoreInputModeSnapshot']);
if (mode == ScoreInputMode.stopwatch && result['actualScoreTimeMs'] != null) {
return 'Score chrono : ${_formatScoreStopwatch(Duration(milliseconds: result['actualScoreTimeMs'] as int))}';
}
if (mode == ScoreInputMode.manual && result['actualScore'] != null) {
final scoreLabel = result['scoreLabelSnapshot'] as String? ?? 'Score';
final score = _formatScoreValue(result['actualScore'] as num);
final unit = result['scoreUnitSnapshot'] as String?;
final suffix = unit == null || unit.trim().isEmpty ? '' : ' $unit';
return '$scoreLabel : $score$suffix';
}
return null;
}
SetResultStatus _setResultStatusFromSnapshot(Object? value) {
return switch (value) {
'skipped' => SetResultStatus.skipped,
_ => SetResultStatus.completed,
};
}
String _historySummary(WorkoutHistory history) { String _historySummary(WorkoutHistory history) {
final snapshot = final snapshot =
jsonDecode(history.historySnapshotJson) as Map<String, dynamic>; jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;

View File

@ -13,7 +13,8 @@ import 'theme.dart';
enum WorkoutExecutionMode { active, rest, paused, finished } 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 { final class WorkoutExecutionScreen extends StatefulWidget {
const WorkoutExecutionScreen({ const WorkoutExecutionScreen({
@ -244,7 +245,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52), 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) { } on Exception catch (error) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( 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 final previousKey = previous == null
? null ? null
: '${previous.state.currentPassageIndex}:${previous.state.currentStepIndex}'; : '${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) { if (previousKey != null && previousKey != nextKey) {
_resetStepScoreInput(); _resetStepScoreInput();
} }
@ -776,7 +780,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
Future<void> _skipCurrentStep() async { Future<void> _skipCurrentStep() async {
final confirmed = await _confirmStepSkip( final confirmed = await _confirmStepSkip(
'Passer cette étape ?', 'Passer cette étape ?',
_stepProgress?.state.status == ActiveExerciseStepProgressStatus.runningTimer _stepProgress?.state.status ==
ActiveExerciseStepProgressStatus.runningTimer
? 'Le chrono de cette étape sera arrêté.' ? 'Le chrono de cette étape sera arrêté.'
: 'Cette étape sera marquée comme passée.', : 'Cette étape sera marquée comme passée.',
'Passer létape', 'Passer létape',
@ -1017,6 +1022,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
setStates: setStates, setStates: setStates,
activeRest: activeRest, activeRest: activeRest,
activeUseCases: widget.activeUseCases, activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
), ),
), ),
); );
@ -1602,9 +1608,9 @@ final class _TimedStepBody extends StatelessWidget {
Center( Center(
child: Text( child: Text(
remainingLabel, remainingLabel,
style: AppTextStyles.timer(context).copyWith( style: AppTextStyles.timer(
color: Theme.of(context).colorScheme.primary, context,
), ).copyWith(color: Theme.of(context).colorScheme.primary),
), ),
), ),
if (!running) ...[ if (!running) ...[
@ -1632,9 +1638,9 @@ final class _RepsStepBody extends StatelessWidget {
children: [ children: [
Text( Text(
'${step.defaultTargetValue}', '${step.defaultTargetValue}',
style: AppTextStyles.timer(context).copyWith( style: AppTextStyles.timer(
color: Theme.of(context).colorScheme.primary, context,
), ).copyWith(color: Theme.of(context).colorScheme.primary),
), ),
Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge), Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1679,7 +1685,10 @@ final class _StepScoreInput extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Center( Center(
child: Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)), child: Text(
elapsedLabel,
style: AppTextStyles.scoreNumber(context),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
@ -1980,6 +1989,7 @@ final class _WorkoutPlanSheet extends StatefulWidget {
required this.currentPosition, required this.currentPosition,
required this.setStates, required this.setStates,
required this.activeUseCases, required this.activeUseCases,
this.stepUseCases,
this.activeRest, this.activeRest,
}); });
@ -1988,6 +1998,7 @@ final class _WorkoutPlanSheet extends StatefulWidget {
final ExecutionPosition currentPosition; final ExecutionPosition currentPosition;
final List<SetResultPositionState> setStates; final List<SetResultPositionState> setStates;
final ActiveWorkoutSessionUseCases activeUseCases; final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveRestState? activeRest; final ActiveRestState? activeRest;
@override @override
@ -2099,6 +2110,7 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
)], )],
onUpdated: _refreshAfterEdit, onUpdated: _refreshAfterEdit,
activeUseCases: widget.activeUseCases, activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
sessionId: widget.sessionId, sessionId: widget.sessionId,
activeRest: widget.activeRest, activeRest: widget.activeRest,
), ),
@ -2524,7 +2536,7 @@ File _fileFromLocalUri(String localUri) {
return File(localUri); return File(localUri);
} }
final class _WorkoutPlanSetTile extends StatelessWidget { final class _WorkoutPlanSetTile extends StatefulWidget {
const _WorkoutPlanSetTile({ const _WorkoutPlanSetTile({
required this.position, required this.position,
required this.currentPosition, required this.currentPosition,
@ -2532,6 +2544,7 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
required this.onUpdated, required this.onUpdated,
required this.activeUseCases, required this.activeUseCases,
required this.sessionId, required this.sessionId,
this.stepUseCases,
this.activeRest, this.activeRest,
this.state, this.state,
}); });
@ -2542,12 +2555,51 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
final SetResultPositionState? state; final SetResultPositionState? state;
final Future<void> Function() onUpdated; final Future<void> Function() onUpdated;
final ActiveWorkoutSessionUseCases activeUseCases; final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final String sessionId; final String sessionId;
final ActiveRestState? activeRest; 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 @override
Widget build(BuildContext context) { 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 tappable = planState != _PlanSetStatus.todo;
final VoidCallback? onTap = tappable final VoidCallback? onTap = tappable
? () => unawaited(_handleTap(context, planState)) ? () => unawaited(_handleTap(context, planState))
@ -2565,8 +2617,13 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
), ),
child: ListTile( child: ListTile(
enabled: tappable, enabled: tappable,
title: Text('Série ${position.setIndex + 1}'), title: Text('Série ${widget.position.setIndex + 1}'),
subtitle: Text(_planSetSubtitle(planState, state?.result, exercise)), subtitle: _WorkoutPlanSetSubtitle(
status: planState,
result: widget.state?.result,
exercise: widget.exercise,
stepProgress: _stepProgress,
),
trailing: _PlanStatusBadge(status: planState, onTap: onTap), trailing: _PlanStatusBadge(status: planState, onTap: onTap),
onTap: onTap, onTap: onTap,
), ),
@ -2583,19 +2640,59 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
isScrollControlled: true, isScrollControlled: true,
useSafeArea: true, useSafeArea: true,
builder: (context) => _EditSetResultSheet( builder: (context) => _EditSetResultSheet(
sessionId: sessionId, sessionId: widget.sessionId,
position: position, position: widget.position,
exercise: exercise, exercise: widget.exercise,
state: state, state: widget.state,
status: status, status: status,
activeUseCases: activeUseCases, activeUseCases: widget.activeUseCases,
activeRest: activeRest, activeRest: widget.activeRest,
onSaved: onUpdated, 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 { final class _EditSetResultSheet extends StatefulWidget {
const _EditSetResultSheet({ const _EditSetResultSheet({
required this.sessionId, required this.sessionId,
@ -2916,6 +3013,29 @@ String _planSetSubtitle(
return ''; 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( String _setResultSummary(
ActiveSetResult result, { ActiveSetResult result, {
ExecutionExercise? exercise, ExecutionExercise? exercise,
@ -3210,26 +3330,28 @@ List<String> _imageMediaIdsFromSnapshot(Map<String, dynamic> exercise) {
List<ExerciseStep> _exerciseStepsFromSnapshot(Map<String, dynamic> exercise) { List<ExerciseStep> _exerciseStepsFromSnapshot(Map<String, dynamic> exercise) {
final rawSteps = exercise['exerciseStepsSnapshot']; final rawSteps = exercise['exerciseStepsSnapshot'];
if (rawSteps is! List) return const []; if (rawSteps is! List) return const [];
return rawSteps.map((rawStep) { return rawSteps
final step = rawStep as Map<String, dynamic>; .map((rawStep) {
final typeName = step['type'] as String? ?? ExerciseStepType.time.name; final step = rawStep as Map<String, dynamic>;
return ExerciseStep( final typeName = step['type'] as String? ?? ExerciseStepType.time.name;
id: step['id'] as String, return ExerciseStep(
position: step['position'] as int, id: step['id'] as String,
name: step['name'] as String, position: step['position'] as int,
type: ExerciseStepType.values.firstWhere( name: step['name'] as String,
(type) => type.name == typeName, type: ExerciseStepType.values.firstWhere(
orElse: () => ExerciseStepType.time, (type) => type.name == typeName,
), orElse: () => ExerciseStepType.time,
defaultTargetValue: step['defaultTargetValue'] as int, ),
hasScore: step['hasScore'] as bool? ?? false, defaultTargetValue: step['defaultTargetValue'] as int,
scoreInputMode: _scoreInputModeFromSnapshot(step['scoreInputMode']), hasScore: step['hasScore'] as bool? ?? false,
scoreLabel: step['scoreLabel'] as String?, scoreInputMode: _scoreInputModeFromSnapshot(step['scoreInputMode']),
scoreUnit: step['scoreUnit'] as String?, scoreLabel: step['scoreLabel'] as String?,
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(), scoreUnit: step['scoreUnit'] as String?,
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?, defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
); defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
}).toList(growable: false); );
})
.toList(growable: false);
} }
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) { ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {

View File

@ -75,6 +75,33 @@ void main() {
expect(find.textContaining('Score 38.7'), findsNothing); expect(find.textContaining('Score 38.7'), findsNothing);
}); });
testWidgets('le détail affiche les résultats détapes par passage', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(id: 'history-1', withStepResults: true),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(),
closeUseCase: _closeUseCase(),
),
),
);
expect(find.text('Étapes réalisées'), findsOneWidget);
expect(find.text('Passage 1'), findsOneWidget);
expect(
find.text(
'Étape 1 · Dribble gauche · Temps 00:10 · Réussites : 4 paniers',
),
findsOneWidget,
);
expect(find.text('Étape 2 · Dribble droite'), findsOneWidget);
expect(find.text('Passée'), findsOneWidget);
});
testWidgets('la suppression demande une confirmation', (tester) async { testWidgets('la suppression demande une confirmation', (tester) async {
final repository = _FakeWorkoutHistoryRepository(); final repository = _FakeWorkoutHistoryRepository();
@ -114,6 +141,7 @@ WorkoutHistory _history({
required String id, required String id,
DateTime? startedAt, DateTime? startedAt,
bool stopwatchScore = false, bool stopwatchScore = false,
bool withStepResults = false,
}) { }) {
final start = startedAt ?? DateTime.utc(2026, 7, 17, 10); final start = startedAt ?? DateTime.utc(2026, 7, 17, 10);
return WorkoutHistory( return WorkoutHistory(
@ -129,6 +157,7 @@ WorkoutHistory _history({
'sessionId': 'session-1', 'sessionId': 'session-1',
'resolvedTemplateSnapshotJson': _resolvedSnapshot( 'resolvedTemplateSnapshotJson': _resolvedSnapshot(
stopwatchScore: stopwatchScore, stopwatchScore: stopwatchScore,
withSteps: withStepResults,
), ),
'results': [ 'results': [
{ {
@ -150,10 +179,57 @@ WorkoutHistory _history({
}, },
], ],
}), }),
stepResults: withStepResults
? [
WorkoutHistoryStepResult(
metadata: _metadata('step-result-1'),
workoutHistoryId: id,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
passageIndex: 0,
stepIndex: 0,
stepSnapshotId: 'step-1',
stepNameSnapshot: 'Dribble gauche',
stepTypeSnapshot: ExerciseStepType.time,
targetValueSnapshot: 10,
hasScoreSnapshot: true,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Réussites',
scoreUnitSnapshot: 'paniers',
targetScoreSnapshot: 3,
status: SetResultStatus.completed,
actualTimeMs: 10000,
actualScore: 4,
),
WorkoutHistoryStepResult(
metadata: _metadata('step-result-2'),
workoutHistoryId: id,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
passageIndex: 0,
stepIndex: 1,
stepSnapshotId: 'step-2',
stepNameSnapshot: 'Dribble droite',
stepTypeSnapshot: ExerciseStepType.reps,
targetValueSnapshot: 8,
hasScoreSnapshot: false,
status: SetResultStatus.skipped,
),
]
: const [],
); );
} }
String _resolvedSnapshot({bool stopwatchScore = false}) { String _resolvedSnapshot({
bool stopwatchScore = false,
bool withSteps = false,
}) {
return jsonEncode({ return jsonEncode({
'name': 'Séance jambes', 'name': 'Séance jambes',
'programs': [ 'programs': [
@ -179,6 +255,29 @@ String _resolvedSnapshot({bool stopwatchScore = false}) {
'scoreLabelSnapshot': 'Charge', 'scoreLabelSnapshot': 'Charge',
if (!stopwatchScore) 'scoreUnitSnapshot': 'kg', if (!stopwatchScore) 'scoreUnitSnapshot': 'kg',
'restSecondsOverride': 0, 'restSecondsOverride': 0,
if (withSteps)
'exerciseStepsSnapshot': [
{
'id': 'step-1',
'position': 0,
'name': 'Dribble gauche',
'type': ExerciseStepType.time.name,
'defaultTargetValue': 10,
'hasScore': true,
'scoreInputMode': ScoreInputMode.manual.name,
'scoreLabel': 'Réussites',
'scoreUnit': 'paniers',
'defaultTargetScore': 3,
},
{
'id': 'step-2',
'position': 1,
'name': 'Dribble droite',
'type': ExerciseStepType.reps.name,
'defaultTargetValue': 8,
'hasScore': false,
},
],
}, },
], ],
}), }),

View File

@ -837,6 +837,72 @@ void main() {
expect(find.textContaining('Temps réalisé 00:38.7'), findsNothing); expect(find.textContaining('Temps réalisé 00:38.7'), findsNothing);
}); });
testWidgets('le plan affiche le résumé de séquence dune série', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
targetReps: 2,
exerciseSteps: [
_step('step-1', 0, 'Gauche', ExerciseStepType.reps, 4),
_step('step-2', 1, 'Droite', ExerciseStepType.reps, 4),
],
),
);
repository.session = session;
repository.stepResults.add(
ActiveExerciseStepResult(
metadata: _metadata('step-result-1'),
activeWorkoutSessionId: 'session-1',
programSnapshotId: 'template-program-1',
exerciseSnapshotId: 'exercise-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
passageIndex: 0,
stepIndex: 0,
stepSnapshotId: 'step-1',
stepNameSnapshot: 'Gauche',
stepTypeSnapshot: ExerciseStepType.reps,
targetValueSnapshot: 4,
hasScoreSnapshot: false,
status: SetResultStatus.completed,
actualReps: 4,
),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.byTooltip('Voir le plan'));
await tester.pumpAndSettle();
expect(find.text('Séquence : 1/2 étapes · Passage 1/2'), findsOneWidget);
});
testWidgets( testWidgets(
'modifier une série passée la termine sans changer la position courante', 'modifier une série passée la termine sans changer la position courante',
(tester) async { (tester) async {
@ -1011,9 +1077,7 @@ void main() {
currentSetIndex: 0, currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot( resolvedTemplateSnapshotJson: _sessionSnapshot(
targetReps: 1, targetReps: 1,
exerciseSteps: [ exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 8)],
_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 8),
],
), ),
); );
repository.session = session; repository.session = session;
@ -1170,9 +1234,7 @@ void main() {
currentSetIndex: 0, currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot( resolvedTemplateSnapshotJson: _sessionSnapshot(
targetReps: 2, targetReps: 2,
exerciseSteps: [ exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4)],
_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4),
],
), ),
); );
repository.session = session; repository.session = session;
@ -1220,9 +1282,7 @@ void main() {
setsCount: 2, setsCount: 2,
restSeconds: 0, restSeconds: 0,
targetReps: 2, targetReps: 2,
exerciseSteps: [ exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4)],
_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4),
],
), ),
); );
repository.session = session; repository.session = session;
@ -1667,9 +1727,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
} }
@override @override
Future<void> saveExerciseStepResult( Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
ActiveExerciseStepResult result,
) async {
final index = stepResults.indexWhere( final index = stepResults.indexWhere(
(saved) => (saved) =>
saved.activeWorkoutSessionId == result.activeWorkoutSessionId && saved.activeWorkoutSessionId == result.activeWorkoutSessionId &&