merge(main): affichage du chrono de score dans le plan et l'historique (ticket #28)

Fusionne feature/#28-affichage-chrono-plan-historique — analyze
propre, 77/77 tests verts, build APK debug validé. Dernier ticket du
backlog, clôture aussi le ticket parent #18 (score chronométré).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 14:40:49 +02:00
9 changed files with 218 additions and 29 deletions

View File

@ -1,6 +1,6 @@
---
issueRef: "#18"
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1784324398610
version: 3
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784378420355
---

View File

@ -2,15 +2,15 @@
id: "bf1aef7d-5f3b-4354-aba9-851df92be313"
number: 18
title: "Ajouter un type de score spécial temps"
status: "open"
status: "closed"
priority: "medium"
sprint: null
links: []
agentRefs: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784324324152
updatedAt: 1784324398610
version: 2
updatedAt: 1784378420355
version: 3
---
J'aimerais ajouter un type de score qui serait un temps avec un chrono intégré. Le but serait que l'utilisateur au début de sa série puisse lancer le chrono et l'arrêter à la fin. Ca lui permettrait ainsi d'avoir comme score le temps qu'il a mit à faire sa série sans avoir à lancer un autre chrono

View File

@ -1,6 +1,6 @@
---
issueRef: "#28"
version: 2
version: 4
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784328260662
updatedAt: 1784378419582
---

View File

@ -2,7 +2,7 @@
id: "dd96c2f3-63a7-4e0d-b976-b1e4a2f58240"
number: 28
title: "[DevFrontend] Affichage du Score chrono dans le plan de séance et l'historique"
status: "open"
status: "closed"
priority: "medium"
sprint: null
links: [{"target":"#18","kind":"relatesTo"},{"target":"#27","kind":"dependsOn"}]
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784328251633
updatedAt: 1784328260662
version: 2
updatedAt: 1784378419582
version: 4
---
Dans le plan de séance (bottom sheet de workout_execution_screen.dart) et l'écran d'historique (history_screen.dart) : afficher "Score chrono" avec la durée formatée (ex "00:38.7") au lieu d'un score numérique brut, avec l'objectif si défini ("00:38.7 / objectif 00:45"). La bottom sheet "Modifier la série" (édition ponctuelle, ticket #23) doit permettre de saisir/corriger manuellement une durée (min/s/dixièmes) pour une série en Score chrono. Cf. mémoire "gametime-ux-score-chrono".

View File

@ -197,11 +197,11 @@
"issueRef": "#18",
"path": "18",
"title": "Ajouter un type de score spécial temps",
"status": "open",
"status": "closed",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784324398610
"updatedAt": 1784378420355
},
{
"issueRef": "#19",
@ -315,13 +315,13 @@
"issueRef": "#28",
"path": "28",
"title": "[DevFrontend] Affichage du Score chrono dans le plan de séance et l'historique",
"status": "open",
"status": "closed",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
],
"updatedAt": 1784328260662
"updatedAt": 1784378419582
},
{
"issueRef": "#29",

View File

@ -314,6 +314,22 @@ final class HistorySetDetail {
final value = _formatScoreValue(result['actualScore'] as num);
parts.add(unit == null ? 'Score $value' : 'Score $value $unit');
}
if (exercise['scoreEnabled'] == true &&
_historyScoreInputMode(exercise, result) == ScoreInputMode.stopwatch &&
result['actualScoreTimeMs'] != null) {
final value = _formatScoreStopwatch(
Duration(milliseconds: result['actualScoreTimeMs'] as int),
);
final target = _targetScoreTimeMsFromSnapshot(exercise, result);
if (target == null) {
parts.add('Temps réalisé : $value');
} else {
parts.add(
'Temps réalisé : $value / objectif '
'${_formatScoreStopwatchTarget(Duration(milliseconds: target))}',
);
}
}
return HistorySetDetail(
setIndex: result['setIndex'] as int,
summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '),
@ -361,6 +377,9 @@ Map<String, dynamic> _resultFromDomain(WorkoutHistorySetResult result) {
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
};
}
@ -372,7 +391,11 @@ String _historySummary(WorkoutHistory history) {
: (snapshot['results'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
final scoreCount = rawResults
.where((result) => result['actualScore'] != null)
.where(
(result) =>
result['actualScore'] != null ||
result['actualScoreTimeMs'] != null,
)
.length;
return '${rawResults.length} séries · $scoreCount score${scoreCount > 1 ? 's' : ''}';
}
@ -403,6 +426,47 @@ String _formatDurationMs(int milliseconds) {
return '$minutes:$seconds';
}
String _formatScoreStopwatch(Duration duration) {
final totalTenths = (duration.inMilliseconds / 100).floor();
final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0');
final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0');
final tenths = totalTenths % 10;
return '$minutes:$seconds.$tenths';
}
String _formatScoreStopwatchTarget(Duration duration) {
if (duration.inMilliseconds % 1000 == 0) {
return _formatDurationMs(duration.inMilliseconds);
}
return _formatScoreStopwatch(duration);
}
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
return switch (value) {
'stopwatch' => ScoreInputMode.stopwatch,
_ => ScoreInputMode.manual,
};
}
int? _targetScoreTimeMsFromSnapshot(
Map<String, dynamic> exercise,
Map<String, dynamic> result,
) {
return result['targetScoreTimeMsSnapshot'] as int? ??
(exercise['targetScoreTimeMs'] as int?);
}
ScoreInputMode _historyScoreInputMode(
Map<String, dynamic> exercise,
Map<String, dynamic> result,
) {
final resultMode = result['scoreInputModeSnapshot'];
if (resultMode != null) {
return _scoreInputModeFromSnapshot(resultMode);
}
return _scoreInputModeFromSnapshot(exercise['scoreInputModeSnapshot']);
}
final class _CenteredMessage extends StatelessWidget {
const _CenteredMessage({required this.title, this.message});

View File

@ -1515,7 +1515,7 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
child: ListTile(
enabled: tappable,
title: Text('Série ${position.setIndex + 1}'),
subtitle: Text(_planSetSubtitle(planState, state?.result)),
subtitle: Text(_planSetSubtitle(planState, state?.result, exercise)),
trailing: _PlanStatusBadge(status: planState, onTap: onTap),
onTap: onTap,
),
@ -1851,9 +1851,13 @@ _PlanSetStatus _planSetState(
return _PlanSetStatus.todo;
}
String _planSetSubtitle(_PlanSetStatus status, ActiveSetResult? result) {
String _planSetSubtitle(
_PlanSetStatus status,
ActiveSetResult? result,
ExecutionExercise exercise,
) {
if (status == _PlanSetStatus.completed && result != null) {
return _setResultSummary(result);
return _setResultSummary(result, exercise: exercise);
}
if (status == _PlanSetStatus.passed) {
return 'Aucun résultat';
@ -1861,7 +1865,10 @@ String _planSetSubtitle(_PlanSetStatus status, ActiveSetResult? result) {
return '';
}
String _setResultSummary(ActiveSetResult result) {
String _setResultSummary(
ActiveSetResult result, {
ExecutionExercise? exercise,
}) {
final parts = <String>[];
if (result.actualTimeMs != null) {
parts.add(_formatDuration(Duration(milliseconds: result.actualTimeMs!)));
@ -1878,7 +1885,15 @@ String _setResultSummary(ActiveSetResult result) {
final value = _formatScoreStopwatch(
Duration(milliseconds: result.actualScoreTimeMs!),
);
parts.add('Temps réalisé $value');
final targetScoreTimeMs = exercise?.targetScoreTimeMs;
if (targetScoreTimeMs == null) {
parts.add('Score chrono $value');
} else {
final targetValue = _formatScoreStopwatchTarget(
Duration(milliseconds: targetScoreTimeMs),
);
parts.add('Score chrono $value / objectif $targetValue');
}
}
return parts.isEmpty ? 'Aucun résultat' : parts.join(' · ');
}
@ -2050,6 +2065,7 @@ final class ExecutionExercise {
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.targetScoreTimeMs,
this.scoreUnit,
});
@ -2084,6 +2100,9 @@ final class ExecutionExercise {
targetScore:
(override?['targetScoreOverride'] as num?)?.toDouble() ??
(exercise['targetScore'] as num?)?.toDouble(),
targetScoreTimeMs:
(override?['targetScoreTimeMsOverride'] as int?) ??
(exercise['targetScoreTimeMs'] as int?),
scoreUnit: exercise['scoreUnitSnapshot'] as String?,
);
}
@ -2102,6 +2121,7 @@ final class ExecutionExercise {
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final int? targetScoreTimeMs;
final String? scoreUnit;
bool get manualScoreEnabled {
@ -2235,3 +2255,10 @@ String _formatScoreStopwatch(Duration duration) {
final tenths = totalTenths % 10;
return '$minutes:$seconds.$tenths';
}
String _formatScoreStopwatchTarget(Duration duration) {
if (duration.inMilliseconds % 1000 == 0) {
return _formatDuration(duration);
}
return _formatScoreStopwatch(duration);
}

View File

@ -50,6 +50,31 @@ void main() {
);
});
testWidgets('le détail affiche le score chrono comme temps réalisé', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(id: 'history-1', stopwatchScore: true),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(),
closeUseCase: _closeUseCase(),
),
),
);
expect(
find.text(
'Temps 00:45 · 10 répétitions · '
'Temps réalisé : 00:38.7 / objectif 00:45',
),
findsOneWidget,
);
expect(find.textContaining('Score 38.7'), findsNothing);
});
testWidgets('la suppression demande une confirmation', (tester) async {
final repository = _FakeWorkoutHistoryRepository();
@ -85,7 +110,11 @@ void main() {
});
}
WorkoutHistory _history({required String id, DateTime? startedAt}) {
WorkoutHistory _history({
required String id,
DateTime? startedAt,
bool stopwatchScore = false,
}) {
final start = startedAt ?? DateTime.utc(2026, 7, 17, 10);
return WorkoutHistory(
metadata: _metadata(id),
@ -98,7 +127,9 @@ WorkoutHistory _history({required String id, DateTime? startedAt}) {
completed: true,
historySnapshotJson: jsonEncode({
'sessionId': 'session-1',
'resolvedTemplateSnapshotJson': _resolvedSnapshot(),
'resolvedTemplateSnapshotJson': _resolvedSnapshot(
stopwatchScore: stopwatchScore,
),
'results': [
{
'programSnapshotId': 'program-snapshot-1',
@ -108,16 +139,21 @@ WorkoutHistory _history({required String id, DateTime? startedAt}) {
'setIndex': 0,
'actualTimeMs': 45000,
'actualReps': 10,
'actualScore': 80,
if (stopwatchScore) 'actualScoreTimeMs': 38700,
if (!stopwatchScore) 'actualScore': 80,
'scoreLabelSnapshot': 'Charge',
'scoreUnitSnapshot': 'kg',
'scoreInputModeSnapshot': stopwatchScore
? ScoreInputMode.stopwatch.name
: ScoreInputMode.manual.name,
if (!stopwatchScore) 'scoreUnitSnapshot': 'kg',
if (stopwatchScore) 'targetScoreTimeMsSnapshot': 45000,
},
],
}),
);
}
String _resolvedSnapshot() {
String _resolvedSnapshot({bool stopwatchScore = false}) {
return jsonEncode({
'name': 'Séance jambes',
'programs': [
@ -135,9 +171,13 @@ String _resolvedSnapshot() {
'scoreEnabled': true,
'targetTimeSeconds': 45,
'targetReps': 10,
'targetScore': 80,
if (stopwatchScore) 'targetScoreTimeMs': 45000,
if (!stopwatchScore) 'targetScore': 80,
'scoreInputModeSnapshot': stopwatchScore
? ScoreInputMode.stopwatch.name
: ScoreInputMode.manual.name,
'scoreLabelSnapshot': 'Charge',
'scoreUnitSnapshot': 'kg',
if (!stopwatchScore) 'scoreUnitSnapshot': 'kg',
'restSecondsOverride': 0,
},
],

View File

@ -673,6 +673,62 @@ void main() {
expect(find.text('Plan de séance'), findsNothing);
});
testWidgets('le plan affiche le score chrono formaté avec objectif', (
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: 1,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
restSeconds: 0,
scoreInputMode: ScoreInputMode.stopwatch,
targetScoreTimeMs: 45000,
),
);
repository.session = session;
repository.results.add(
ActiveSetResult(
metadata: _metadata('result-1'),
activeWorkoutSessionId: 'session-1',
programSnapshotId: 'template-program-1',
exerciseSnapshotId: 'exercise-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScoreTimeMs: 38700,
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.tap(find.byTooltip('Voir le plan'));
await tester.pumpAndSettle();
expect(find.text('Score chrono 00:38.7 / objectif 00:45'), findsOneWidget);
expect(find.textContaining('Temps réalisé 00:38.7'), findsNothing);
});
testWidgets(
'modifier une série passée la termine sans changer la position courante',
(tester) async {
@ -836,6 +892,7 @@ String _sessionSnapshot({
int targetTimeSeconds = 45,
int targetReps = 10,
double targetScore = 80,
int? targetScoreTimeMs,
String scoreUnit = 'kg',
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
List<String> imageMediaIds = const [],
@ -859,6 +916,7 @@ String _sessionSnapshot({
'targetTimeSeconds': targetTimeSeconds,
'targetReps': targetReps,
'targetScore': targetScore,
'targetScoreTimeMs': targetScoreTimeMs,
'scoreInputModeSnapshot': scoreInputMode.name,
'scoreLabelSnapshot': 'Score',
'scoreUnitSnapshot': scoreUnit,