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:
@ -151,10 +151,21 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
for (final set in exercise.sets)
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text('Série ${set.setIndex + 1}'),
|
||||
subtitle: Text(set.summary),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
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),
|
||||
@ -246,6 +257,10 @@ final class HistoryDetailData {
|
||||
? history.results.map(_resultFromDomain).toList()
|
||||
: (snapshot['results'] as List<dynamic>? ?? const [])
|
||||
.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 rawPrograms = resolved['programs'] as List<dynamic>? ?? const [];
|
||||
for (
|
||||
@ -270,12 +285,25 @@ final class HistoryDetailData {
|
||||
return result['programIndex'] == programIndex &&
|
||||
result['exerciseIndex'] == exerciseIndex;
|
||||
}).toList();
|
||||
final exerciseStepResults = rawStepResults.where((result) {
|
||||
return result['programIndex'] == programIndex &&
|
||||
result['exerciseIndex'] == exerciseIndex;
|
||||
}).toList();
|
||||
exercises.add(
|
||||
HistoryExerciseDetail(
|
||||
name: rawExercise['exerciseNameSnapshot'] as String,
|
||||
sets: [
|
||||
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 {
|
||||
const HistorySetDetail({required this.setIndex, required this.summary});
|
||||
const HistorySetDetail({
|
||||
required this.setIndex,
|
||||
required this.summary,
|
||||
required this.stepPassages,
|
||||
});
|
||||
|
||||
factory HistorySetDetail.fromSnapshot(
|
||||
Map<String, dynamic> exercise,
|
||||
Map<String, dynamic> result,
|
||||
List<Map<String, dynamic>> stepResults,
|
||||
) {
|
||||
final parts = <String>[];
|
||||
if (exercise['timeEnabled'] == true && result['actualTimeMs'] != null) {
|
||||
@ -345,11 +378,101 @@ final class HistorySetDetail {
|
||||
return HistorySetDetail(
|
||||
setIndex: result['setIndex'] as int,
|
||||
summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '),
|
||||
stepPassages: _stepPassagesFromResults(stepResults),
|
||||
);
|
||||
}
|
||||
|
||||
final int setIndex;
|
||||
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(
|
||||
@ -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) {
|
||||
final snapshot =
|
||||
jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
|
||||
|
||||
Reference in New Issue
Block a user