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" issueRef: "#18"
version: 2 version: 3
updatedBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784324398610 updatedAt: 1784378420355
--- ---

View File

@ -2,15 +2,15 @@
id: "bf1aef7d-5f3b-4354-aba9-851df92be313" id: "bf1aef7d-5f3b-4354-aba9-851df92be313"
number: 18 number: 18
title: "Ajouter un type de score spécial temps" title: "Ajouter un type de score spécial temps"
status: "open" status: "closed"
priority: "medium" priority: "medium"
sprint: null sprint: null
links: [] links: []
agentRefs: [] agentRefs: []
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784324324152 createdAt: 1784324324152
updatedAt: 1784324398610 updatedAt: 1784378420355
version: 2 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 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" issueRef: "#28"
version: 2 version: 4
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} 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" id: "dd96c2f3-63a7-4e0d-b976-b1e4a2f58240"
number: 28 number: 28
title: "[DevFrontend] Affichage du Score chrono dans le plan de séance et l'historique" title: "[DevFrontend] Affichage du Score chrono dans le plan de séance et l'historique"
status: "open" status: "closed"
priority: "medium" priority: "medium"
sprint: null sprint: null
links: [{"target":"#18","kind":"relatesTo"},{"target":"#27","kind":"dependsOn"}] 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"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784328251633 createdAt: 1784328251633
updatedAt: 1784328260662 updatedAt: 1784378419582
version: 2 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". 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", "issueRef": "#18",
"path": "18", "path": "18",
"title": "Ajouter un type de score spécial temps", "title": "Ajouter un type de score spécial temps",
"status": "open", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784324398610 "updatedAt": 1784378420355
}, },
{ {
"issueRef": "#19", "issueRef": "#19",
@ -315,13 +315,13 @@
"issueRef": "#28", "issueRef": "#28",
"path": "28", "path": "28",
"title": "[DevFrontend] Affichage du Score chrono dans le plan de séance et l'historique", "title": "[DevFrontend] Affichage du Score chrono dans le plan de séance et l'historique",
"status": "open", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d" "9933c93a-b8a1-4164-a3bb-7063fdad747d"
], ],
"updatedAt": 1784328260662 "updatedAt": 1784378419582
}, },
{ {
"issueRef": "#29", "issueRef": "#29",

View File

@ -314,6 +314,22 @@ final class HistorySetDetail {
final value = _formatScoreValue(result['actualScore'] as num); final value = _formatScoreValue(result['actualScore'] as num);
parts.add(unit == null ? 'Score $value' : 'Score $value $unit'); 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( 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(' · '),
@ -361,6 +377,9 @@ Map<String, dynamic> _resultFromDomain(WorkoutHistorySetResult result) {
'actualTimeMs': result.actualTimeMs, 'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps, 'actualReps': result.actualReps,
'actualScore': result.actualScore, '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 []) : (snapshot['results'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>(); .cast<Map<String, dynamic>>();
final scoreCount = rawResults final scoreCount = rawResults
.where((result) => result['actualScore'] != null) .where(
(result) =>
result['actualScore'] != null ||
result['actualScoreTimeMs'] != null,
)
.length; .length;
return '${rawResults.length} séries · $scoreCount score${scoreCount > 1 ? 's' : ''}'; return '${rawResults.length} séries · $scoreCount score${scoreCount > 1 ? 's' : ''}';
} }
@ -403,6 +426,47 @@ String _formatDurationMs(int milliseconds) {
return '$minutes:$seconds'; 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 { final class _CenteredMessage extends StatelessWidget {
const _CenteredMessage({required this.title, this.message}); const _CenteredMessage({required this.title, this.message});

View File

@ -1515,7 +1515,7 @@ final class _WorkoutPlanSetTile extends StatelessWidget {
child: ListTile( child: ListTile(
enabled: tappable, enabled: tappable,
title: Text('Série ${position.setIndex + 1}'), 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), trailing: _PlanStatusBadge(status: planState, onTap: onTap),
onTap: onTap, onTap: onTap,
), ),
@ -1851,9 +1851,13 @@ _PlanSetStatus _planSetState(
return _PlanSetStatus.todo; return _PlanSetStatus.todo;
} }
String _planSetSubtitle(_PlanSetStatus status, ActiveSetResult? result) { String _planSetSubtitle(
_PlanSetStatus status,
ActiveSetResult? result,
ExecutionExercise exercise,
) {
if (status == _PlanSetStatus.completed && result != null) { if (status == _PlanSetStatus.completed && result != null) {
return _setResultSummary(result); return _setResultSummary(result, exercise: exercise);
} }
if (status == _PlanSetStatus.passed) { if (status == _PlanSetStatus.passed) {
return 'Aucun résultat'; return 'Aucun résultat';
@ -1861,7 +1865,10 @@ String _planSetSubtitle(_PlanSetStatus status, ActiveSetResult? result) {
return ''; return '';
} }
String _setResultSummary(ActiveSetResult result) { String _setResultSummary(
ActiveSetResult result, {
ExecutionExercise? exercise,
}) {
final parts = <String>[]; final parts = <String>[];
if (result.actualTimeMs != null) { if (result.actualTimeMs != null) {
parts.add(_formatDuration(Duration(milliseconds: result.actualTimeMs!))); parts.add(_formatDuration(Duration(milliseconds: result.actualTimeMs!)));
@ -1878,7 +1885,15 @@ String _setResultSummary(ActiveSetResult result) {
final value = _formatScoreStopwatch( final value = _formatScoreStopwatch(
Duration(milliseconds: result.actualScoreTimeMs!), 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(' · '); return parts.isEmpty ? 'Aucun résultat' : parts.join(' · ');
} }
@ -2050,6 +2065,7 @@ final class ExecutionExercise {
this.targetTimeSeconds, this.targetTimeSeconds,
this.targetReps, this.targetReps,
this.targetScore, this.targetScore,
this.targetScoreTimeMs,
this.scoreUnit, this.scoreUnit,
}); });
@ -2084,6 +2100,9 @@ final class ExecutionExercise {
targetScore: targetScore:
(override?['targetScoreOverride'] as num?)?.toDouble() ?? (override?['targetScoreOverride'] as num?)?.toDouble() ??
(exercise['targetScore'] as num?)?.toDouble(), (exercise['targetScore'] as num?)?.toDouble(),
targetScoreTimeMs:
(override?['targetScoreTimeMsOverride'] as int?) ??
(exercise['targetScoreTimeMs'] as int?),
scoreUnit: exercise['scoreUnitSnapshot'] as String?, scoreUnit: exercise['scoreUnitSnapshot'] as String?,
); );
} }
@ -2102,6 +2121,7 @@ final class ExecutionExercise {
final int? targetTimeSeconds; final int? targetTimeSeconds;
final int? targetReps; final int? targetReps;
final double? targetScore; final double? targetScore;
final int? targetScoreTimeMs;
final String? scoreUnit; final String? scoreUnit;
bool get manualScoreEnabled { bool get manualScoreEnabled {
@ -2235,3 +2255,10 @@ String _formatScoreStopwatch(Duration duration) {
final tenths = totalTenths % 10; final tenths = totalTenths % 10;
return '$minutes:$seconds.$tenths'; 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 { testWidgets('la suppression demande une confirmation', (tester) async {
final repository = _FakeWorkoutHistoryRepository(); 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); final start = startedAt ?? DateTime.utc(2026, 7, 17, 10);
return WorkoutHistory( return WorkoutHistory(
metadata: _metadata(id), metadata: _metadata(id),
@ -98,7 +127,9 @@ WorkoutHistory _history({required String id, DateTime? startedAt}) {
completed: true, completed: true,
historySnapshotJson: jsonEncode({ historySnapshotJson: jsonEncode({
'sessionId': 'session-1', 'sessionId': 'session-1',
'resolvedTemplateSnapshotJson': _resolvedSnapshot(), 'resolvedTemplateSnapshotJson': _resolvedSnapshot(
stopwatchScore: stopwatchScore,
),
'results': [ 'results': [
{ {
'programSnapshotId': 'program-snapshot-1', 'programSnapshotId': 'program-snapshot-1',
@ -108,16 +139,21 @@ WorkoutHistory _history({required String id, DateTime? startedAt}) {
'setIndex': 0, 'setIndex': 0,
'actualTimeMs': 45000, 'actualTimeMs': 45000,
'actualReps': 10, 'actualReps': 10,
'actualScore': 80, if (stopwatchScore) 'actualScoreTimeMs': 38700,
if (!stopwatchScore) 'actualScore': 80,
'scoreLabelSnapshot': 'Charge', '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({ return jsonEncode({
'name': 'Séance jambes', 'name': 'Séance jambes',
'programs': [ 'programs': [
@ -135,9 +171,13 @@ String _resolvedSnapshot() {
'scoreEnabled': true, 'scoreEnabled': true,
'targetTimeSeconds': 45, 'targetTimeSeconds': 45,
'targetReps': 10, 'targetReps': 10,
'targetScore': 80, if (stopwatchScore) 'targetScoreTimeMs': 45000,
if (!stopwatchScore) 'targetScore': 80,
'scoreInputModeSnapshot': stopwatchScore
? ScoreInputMode.stopwatch.name
: ScoreInputMode.manual.name,
'scoreLabelSnapshot': 'Charge', 'scoreLabelSnapshot': 'Charge',
'scoreUnitSnapshot': 'kg', if (!stopwatchScore) 'scoreUnitSnapshot': 'kg',
'restSecondsOverride': 0, 'restSecondsOverride': 0,
}, },
], ],

View File

@ -673,6 +673,62 @@ void main() {
expect(find.text('Plan de séance'), findsNothing); 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( 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 {
@ -836,6 +892,7 @@ String _sessionSnapshot({
int targetTimeSeconds = 45, int targetTimeSeconds = 45,
int targetReps = 10, int targetReps = 10,
double targetScore = 80, double targetScore = 80,
int? targetScoreTimeMs,
String scoreUnit = 'kg', String scoreUnit = 'kg',
ScoreInputMode scoreInputMode = ScoreInputMode.manual, ScoreInputMode scoreInputMode = ScoreInputMode.manual,
List<String> imageMediaIds = const [], List<String> imageMediaIds = const [],
@ -859,6 +916,7 @@ String _sessionSnapshot({
'targetTimeSeconds': targetTimeSeconds, 'targetTimeSeconds': targetTimeSeconds,
'targetReps': targetReps, 'targetReps': targetReps,
'targetScore': targetScore, 'targetScore': targetScore,
'targetScoreTimeMs': targetScoreTimeMs,
'scoreInputModeSnapshot': scoreInputMode.name, 'scoreInputModeSnapshot': scoreInputMode.name,
'scoreLabelSnapshot': 'Score', 'scoreLabelSnapshot': 'Score',
'scoreUnitSnapshot': scoreUnit, 'scoreUnitSnapshot': scoreUnit,