From 1a315d586f94dd4b3da50a67ec997ac9e3466e4d Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 14:40:37 +0200 Subject: [PATCH 1/2] feat(ui): affichage du chrono de score dans le plan et l'historique (ticket #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Affiche le chrono du score chronométré sur workout_execution_screen.dart (plan de séance) et history_screen.dart (historique). flutter 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 --- lib/presentation/history_screen.dart | 66 ++++++++++++++++++- .../workout_execution_screen.dart | 37 +++++++++-- test/presentation/history_screen_test.dart | 54 +++++++++++++-- .../workout_execution_screen_test.dart | 58 ++++++++++++++++ 4 files changed, 202 insertions(+), 13 deletions(-) diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index f5942c0..5d53fca 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -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 _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? ?? const []) .cast>(); 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 exercise, + Map result, +) { + return result['targetScoreTimeMsSnapshot'] as int? ?? + (exercise['targetScoreTimeMs'] as int?); +} + +ScoreInputMode _historyScoreInputMode( + Map exercise, + Map 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}); diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index 1ff0766..4548e6b 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -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 = []; 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); +} diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart index 3e6fe73..1b81011 100644 --- a/test/presentation/history_screen_test.dart +++ b/test/presentation/history_screen_test.dart @@ -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, }, ], diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index f024945..3d91586 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -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 imageMediaIds = const [], @@ -859,6 +916,7 @@ String _sessionSnapshot({ 'targetTimeSeconds': targetTimeSeconds, 'targetReps': targetReps, 'targetScore': targetScore, + 'targetScoreTimeMs': targetScoreTimeMs, 'scoreInputModeSnapshot': scoreInputMode.name, 'scoreLabelSnapshot': 'Score', 'scoreUnitSnapshot': scoreUnit, From fef5c78b0dd678eadf083486942cdedf11a4fdfd Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 14:40:43 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20met=20=C3=A0=20jour=20les=20?= =?UTF-8?q?tickets=20#18/#28=20=E2=80=94=20score=20chronom=C3=A9tr=C3=A9?= =?UTF-8?q?=20clos,=20backlog=20termin=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/18/carnet.md | 6 +++--- .ideai/tickets/18/issue.md | 8 ++++---- .ideai/tickets/28/carnet.md | 4 ++-- .ideai/tickets/28/issue.md | 6 +++--- .ideai/tickets/index.json | 8 ++++---- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.ideai/tickets/18/carnet.md b/.ideai/tickets/18/carnet.md index 0169327..9244321 100644 --- a/.ideai/tickets/18/carnet.md +++ b/.ideai/tickets/18/carnet.md @@ -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 --- diff --git a/.ideai/tickets/18/issue.md b/.ideai/tickets/18/issue.md index baac10c..ebe9363 100644 --- a/.ideai/tickets/18/issue.md +++ b/.ideai/tickets/18/issue.md @@ -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 \ No newline at end of file diff --git a/.ideai/tickets/28/carnet.md b/.ideai/tickets/28/carnet.md index 09dc37c..50b7e61 100644 --- a/.ideai/tickets/28/carnet.md +++ b/.ideai/tickets/28/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#28" -version: 2 +version: 4 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784328260662 +updatedAt: 1784378419582 --- diff --git a/.ideai/tickets/28/issue.md b/.ideai/tickets/28/issue.md index 98140b4..247b233 100644 --- a/.ideai/tickets/28/issue.md +++ b/.ideai/tickets/28/issue.md @@ -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". \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 266cd42..b436093 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -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",