From 9f9d0dbe3bb21383b387267c6106e19f2966af14 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 17 Jul 2026 22:14:07 +0200 Subject: [PATCH 1/2] fix(persistence): correctifs post-QA sur les repositories (ticket #13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige les ports, use cases, entités et l'implémentation Drift des repositories suite aux retours de QA. flutter analyze propre, 24/24 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 --- lib/application/ports.dart | 1 + lib/application/use_cases.dart | 44 ++++++ lib/domain/entities.dart | 22 +++ .../local/drift_repositories.dart | 8 + test/application/use_cases_test.dart | 146 ++++++++++++++++++ test/presentation/history_screen_test.dart | 3 + .../workout_execution_screen_test.dart | 3 + 7 files changed, 227 insertions(+) diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 691a615..83f1981 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -80,6 +80,7 @@ abstract interface class ActiveSessionRepository { Future save(ActiveWorkoutSession session); Future saveSetResult(ActiveSetResult result); Future saveRestState(ActiveRestState restState); + Future findRestStateById(String id); Future> listSetResults(String sessionId); Future> listRestStates(String sessionId); } diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index cd7cdb1..9ff71b2 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -803,6 +803,50 @@ final class ActiveWorkoutSessionUseCases { return rest; } + Future adjustRestSeconds({ + required String restStateId, + required int deltaSeconds, + }) async { + final rest = await sessionRepository.findRestStateById(restStateId); + if (rest == null) { + throw const DomainException('Active rest state not found.'); + } + final now = clock.now(); + final adjusted = rest.copyWith( + metadata: rest.metadata.touch(now), + adjustedRestSeconds: (rest.adjustedRestSeconds + deltaSeconds).clamp( + 0, + 1 << 31, + ), + ); + await sessionRepository.saveRestState(adjusted); + return adjusted; + } + + Future skipRest({required String restStateId}) async { + final rest = await sessionRepository.findRestStateById(restStateId); + if (rest == null) { + throw const DomainException('Active rest state not found.'); + } + final now = clock.now(); + final skipped = rest.copyWith( + metadata: rest.metadata.touch(now), + skippedAt: now, + ); + await sessionRepository.saveRestState(skipped); + return skipped; + } + + Future findActiveRest({required String sessionId}) async { + final restStates = await sessionRepository.listRestStates(sessionId); + final active = + restStates + .where((rest) => rest.endedAt == null && rest.skippedAt == null) + .toList() + ..sort((left, right) => right.startedAt.compareTo(left.startedAt)); + return active.isEmpty ? null : active.first; + } + Future _requiredSession(String sessionId) async { final session = await sessionRepository.findById(sessionId); if (session == null) { diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 85e8d10..4b23e76 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -631,6 +631,28 @@ final class ActiveRestState { final DateTime startedAt; final DateTime? endedAt; final DateTime? skippedAt; + + ActiveRestState copyWith({ + EntityMetadata? metadata, + int? adjustedRestSeconds, + Object? endedAt = _unchanged, + Object? skippedAt = _unchanged, + }) { + return ActiveRestState( + metadata: metadata ?? this.metadata, + activeWorkoutSessionId: activeWorkoutSessionId, + afterProgramIndex: afterProgramIndex, + afterExerciseIndex: afterExerciseIndex, + afterSetIndex: afterSetIndex, + plannedRestSeconds: plannedRestSeconds, + adjustedRestSeconds: adjustedRestSeconds ?? this.adjustedRestSeconds, + startedAt: startedAt, + endedAt: endedAt == _unchanged ? this.endedAt : endedAt as DateTime?, + skippedAt: skippedAt == _unchanged + ? this.skippedAt + : skippedAt as DateTime?, + ); + } } final class WorkoutHistory { diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 627dd6b..9978bda 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -398,6 +398,14 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { .insertOnConflictUpdate(_activeRestStateCompanion(restState)); } + @override + Future findRestStateById(String id) async { + final row = await (database.select( + database.activeRestStates, + )..where((table) => table.id.equals(id))).getSingleOrNull(); + return row == null ? null : _activeRestStateFromRow(row); + } + @override Future> listSetResults(String sessionId) async { final rows = diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index cc923f3..a808b73 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -111,6 +111,104 @@ void main() { clock.value = clock.value.add(const Duration(seconds: 15)); expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000); }); + + test('adjustRestSeconds persists adjusted rest duration', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository() + ..restStates['rest-1'] = ActiveRestState( + metadata: _metadata('rest-1'), + activeWorkoutSessionId: 'session-1', + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: clock.now(), + ); + final useCase = _activeUseCase(repository, clock); + + final adjusted = await useCase.adjustRestSeconds( + restStateId: 'rest-1', + deltaSeconds: -75, + ); + + expect(adjusted.adjustedRestSeconds, 0); + expect(repository.restStates['rest-1']!.adjustedRestSeconds, 0); + }); + + test('skipRest persists skippedAt timestamp', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository() + ..restStates['rest-1'] = ActiveRestState( + metadata: _metadata('rest-1'), + activeWorkoutSessionId: 'session-1', + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: clock.now(), + ); + clock.value = clock.value.add(const Duration(seconds: 10)); + final useCase = _activeUseCase(repository, clock); + + final skipped = await useCase.skipRest(restStateId: 'rest-1'); + + expect(skipped.skippedAt, clock.now()); + expect(repository.restStates['rest-1']!.skippedAt, clock.now()); + }); + + test('findActiveRest returns latest non-ended non-skipped rest', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository() + ..restStates['ended'] = ActiveRestState( + metadata: _metadata('ended'), + activeWorkoutSessionId: 'session-1', + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: clock.now(), + endedAt: clock.now().add(const Duration(seconds: 60)), + ) + ..restStates['older-active'] = ActiveRestState( + metadata: _metadata('older-active'), + activeWorkoutSessionId: 'session-1', + afterProgramIndex: 0, + afterExerciseIndex: 1, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: clock.now().add(const Duration(seconds: 30)), + ) + ..restStates['latest-active'] = ActiveRestState( + metadata: _metadata('latest-active'), + activeWorkoutSessionId: 'session-1', + afterProgramIndex: 0, + afterExerciseIndex: 2, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: clock.now().add(const Duration(seconds: 90)), + ) + ..restStates['skipped'] = ActiveRestState( + metadata: _metadata('skipped'), + activeWorkoutSessionId: 'session-1', + afterProgramIndex: 0, + afterExerciseIndex: 3, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: clock.now().add(const Duration(seconds: 120)), + skippedAt: clock.now().add(const Duration(seconds: 125)), + ); + final useCase = _activeUseCase(repository, clock); + + final active = await useCase.findActiveRest(sessionId: 'session-1'); + + expect(active?.metadata.id, 'latest-active'); + }); } EntityMetadata _metadata(String id) { @@ -181,3 +279,51 @@ final class _FakeProgramRepository implements ProgramRepository { @override Future saveExercise(ProgramExercise exercise) async {} } + +ActiveWorkoutSessionUseCases _activeUseCase( + _FakeActiveSessionRepository repository, + _FakeClock clock, +) { + return ActiveWorkoutSessionUseCases( + sessionRepository: repository, + templateRepository: _FakeWorkoutTemplateRepository(), + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + +final class _FakeActiveSessionRepository implements ActiveSessionRepository { + final restStates = {}; + + @override + Future findById(String id) async => null; + + @override + Future findOpen() async => null; + + @override + Future findRestStateById(String id) async => restStates[id]; + + @override + Future> listRestStates(String sessionId) async { + return restStates.values + .where((rest) => rest.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listSetResults(String sessionId) async => + const []; + + @override + Future save(ActiveWorkoutSession session) async {} + + @override + Future saveRestState(ActiveRestState restState) async { + restStates[restState.metadata.id] = restState; + } + + @override + Future saveSetResult(ActiveSetResult result) async {} +} diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart index 483b552..394b599 100644 --- a/test/presentation/history_screen_test.dart +++ b/test/presentation/history_screen_test.dart @@ -284,6 +284,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future findOpen() async => null; + @override + Future findRestStateById(String id) async => null; + @override Future> listRestStates(String sessionId) async { return const []; diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 4166c8f..7d247a2 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -230,6 +230,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future findOpen() async => session; + @override + Future findRestStateById(String id) async => null; + @override Future> listRestStates(String sessionId) async { return const []; From 66c8491e794ce73c4262bd217987a55ce7413037 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 17 Jul 2026 22:14:10 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20met=20=C3=A0=20jour=20les=20?= =?UTF-8?q?tickets=20#13/#14/#15=20apr=C3=A8s=20le=20correctif=20repositor?= =?UTF-8?q?ies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/13/carnet.md | 6 ++++++ .ideai/tickets/13/issue.md | 16 ++++++++++++++++ .ideai/tickets/14/carnet.md | 6 ++++++ .ideai/tickets/14/issue.md | 18 ++++++++++++++++++ .ideai/tickets/15/carnet.md | 6 ++++++ .ideai/tickets/15/issue.md | 16 ++++++++++++++++ .ideai/tickets/counter.json | 2 +- .ideai/tickets/index.json | 36 ++++++++++++++++++++++++++++++++++++ 8 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 .ideai/tickets/13/carnet.md create mode 100644 .ideai/tickets/13/issue.md create mode 100644 .ideai/tickets/14/carnet.md create mode 100644 .ideai/tickets/14/issue.md create mode 100644 .ideai/tickets/15/carnet.md create mode 100644 .ideai/tickets/15/issue.md diff --git a/.ideai/tickets/13/carnet.md b/.ideai/tickets/13/carnet.md new file mode 100644 index 0000000..a8bdd4b --- /dev/null +++ b/.ideai/tickets/13/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#13" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784319234004 +--- diff --git a/.ideai/tickets/13/issue.md b/.ideai/tickets/13/issue.md new file mode 100644 index 0000000..208a5f6 --- /dev/null +++ b/.ideai/tickets/13/issue.md @@ -0,0 +1,16 @@ +--- +id: "da27e09c-79d2-43fa-90e5-3e8ef32e789f" +number: 13 +title: "[DevBackend] Use cases de persistance et reprise du minuteur de repos" +status: "closed" +priority: "high" +sprint: null +links: [{"target":"#9","kind":"relatesTo"}] +agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784318990378 +updatedAt: 1784319234004 +version: 3 +--- +Ajouter les use cases manquants pour que l'ajustement du minuteur de repos (±15s) soit persisté et que l'écran d'exécution puisse retrouver l'état de repos en cours après une fermeture d'app. lib/application/use_cases.dart expose déjà startRestAfterSet, mais aucun moyen de mettre à jour adjustedRestSeconds ou de marquer skippedAt après création. Le repository (lib/application/ports.dart / lib/infrastructure/local/drift_repositories.dart) expose déjà saveRestState et listRestStates — à réutiliser. \ No newline at end of file diff --git a/.ideai/tickets/14/carnet.md b/.ideai/tickets/14/carnet.md new file mode 100644 index 0000000..26c1fb5 --- /dev/null +++ b/.ideai/tickets/14/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#14" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784319234693 +--- diff --git a/.ideai/tickets/14/issue.md b/.ideai/tickets/14/issue.md new file mode 100644 index 0000000..bd256b5 --- /dev/null +++ b/.ideai/tickets/14/issue.md @@ -0,0 +1,18 @@ +--- +id: "bf12e886-4849-4095-afc9-aa37c61fa257" +number: 14 +title: "[DevFrontend] Repos persistant/reprenable + temps réel par série" +status: "inProgress" +priority: "high" +sprint: null +links: [{"target":"#9","kind":"relatesTo"}] +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: 1784318996196 +updatedAt: 1784319234693 +version: 2 +--- +Deux corrections sur lib/presentation/workout_execution_screen.dart, trouvées par QA (ticket #11) : +1. Les ajustements ±15s du minuteur de repos ne sont modifiés qu'en mémoire (_remainingRestSeconds) et jamais re-persistés — après un kill d'app pendant un repos, l'écran redémarre sur l'exercice actif au lieu de reprendre le décompte de repos exact. À l'ouverture de l'écran, il faut relire l'état de repos actif éventuel (listRestStates / le nouveau use case du ticket dédié) et calculer le temps restant depuis les horodatages persistés, pas repartir de zéro. +2. `actualTimeMs` est toujours enregistré à 0 (ligne ~315) même quand la mesure Temps est active pour la série — il faut chronométrer réellement le temps passé sur la série (depuis son démarrage jusqu'à "Terminer la série") et transmettre cette valeur réelle à recordCurrentSetResult. \ No newline at end of file diff --git a/.ideai/tickets/15/carnet.md b/.ideai/tickets/15/carnet.md new file mode 100644 index 0000000..995c5b1 --- /dev/null +++ b/.ideai/tickets/15/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#15" +version: 1 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784318999121 +--- diff --git a/.ideai/tickets/15/issue.md b/.ideai/tickets/15/issue.md new file mode 100644 index 0000000..64f8019 --- /dev/null +++ b/.ideai/tickets/15/issue.md @@ -0,0 +1,16 @@ +--- +id: "3463569f-e837-4af9-8156-4968e178d4e1" +number: 15 +title: "[DevFrontend] Sélecteur de fichier pour l'import média (remplace la saisie manuelle de chemin)" +status: "open" +priority: "medium" +sprint: null +links: [{"target":"#6","kind":"relatesTo"}] +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: 1784318999121 +updatedAt: 1784318999121 +version: 1 +--- +L'écran de création/édition d'exercice (lib/presentation/exercise_library_screen.dart) fait actuellement saisir manuellement un chemin de fichier pour importer une image/vidéo. Remplacer par un vrai sélecteur de fichiers via le package `image_picker` (à ajouter en dépendance dans pubspec.yaml — nouvelle dépendance, signalée et acceptée par l'utilisateur). Garder MediaUseCases.importMedia tel quel côté logique métier, seul le déclenchement du chemin source change (sélection graphique au lieu de TextField). \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 26192b3..87512f6 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 13 + "nextNumber": 16 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 4395dba..3aec601 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -144,6 +144,42 @@ "10ee045b-1c41-479e-ba03-dceed9edd495" ], "updatedAt": 1784301400489 + }, + { + "issueRef": "#13", + "path": "13", + "title": "[DevBackend] Use cases de persistance et reprise du minuteur de repos", + "status": "closed", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "10ee045b-1c41-479e-ba03-dceed9edd495" + ], + "updatedAt": 1784319234004 + }, + { + "issueRef": "#14", + "path": "14", + "title": "[DevFrontend] Repos persistant/reprenable + temps réel par série", + "status": "inProgress", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784319234693 + }, + { + "issueRef": "#15", + "path": "15", + "title": "[DevFrontend] Sélecteur de fichier pour l'import média (remplace la saisie manuelle de chemin)", + "status": "open", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784318999121 } ] } \ No newline at end of file