diff --git a/.ideai/tickets/14/carnet.md b/.ideai/tickets/14/carnet.md index 26c1fb5..7fba00c 100644 --- a/.ideai/tickets/14/carnet.md +++ b/.ideai/tickets/14/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#14" -version: 2 +version: 3 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784319234693 +updatedAt: 1784319492849 --- diff --git a/.ideai/tickets/14/issue.md b/.ideai/tickets/14/issue.md index bd256b5..b1d4dce 100644 --- a/.ideai/tickets/14/issue.md +++ b/.ideai/tickets/14/issue.md @@ -2,7 +2,7 @@ id: "bf12e886-4849-4095-afc9-aa37c61fa257" number: 14 title: "[DevFrontend] Repos persistant/reprenable + temps réel par série" -status: "inProgress" +status: "closed" priority: "high" sprint: null links: [{"target":"#9","kind":"relatesTo"}] @@ -10,8 +10,8 @@ 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 +updatedAt: 1784319492849 +version: 3 --- 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. diff --git a/.ideai/tickets/15/carnet.md b/.ideai/tickets/15/carnet.md index 995c5b1..7aec3df 100644 --- a/.ideai/tickets/15/carnet.md +++ b/.ideai/tickets/15/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#15" -version: 1 +version: 2 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784318999121 +updatedAt: 1784319493575 --- diff --git a/.ideai/tickets/15/issue.md b/.ideai/tickets/15/issue.md index 64f8019..19d1cd0 100644 --- a/.ideai/tickets/15/issue.md +++ b/.ideai/tickets/15/issue.md @@ -2,7 +2,7 @@ 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" +status: "inProgress" priority: "medium" sprint: null links: [{"target":"#6","kind":"relatesTo"}] @@ -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: 1784318999121 -updatedAt: 1784318999121 -version: 1 +updatedAt: 1784319493575 +version: 2 --- 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/index.json b/.ideai/tickets/index.json index 3aec601..39da797 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -161,25 +161,25 @@ "issueRef": "#14", "path": "14", "title": "[DevFrontend] Repos persistant/reprenable + temps réel par série", - "status": "inProgress", + "status": "closed", "priority": "high", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784319234693 + "updatedAt": 1784319492849 }, { "issueRef": "#15", "path": "15", "title": "[DevFrontend] Sélecteur de fichier pour l'import média (remplace la saisie manuelle de chemin)", - "status": "open", + "status": "inProgress", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784318999121 + "updatedAt": 1784319493575 } ] } \ No newline at end of file diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index ef73d91..c907794 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -36,6 +36,8 @@ final class _WorkoutExecutionScreenState extends State { Timer? _ticker; Timer? _restTicker; WorkoutHistory? _completedHistory; + String? _activeRestStateId; + late DateTime _seriesStartedAt; var _reps = 0; final _scoreController = TextEditingController(); var _remainingRestSeconds = 0; @@ -56,9 +58,11 @@ final class _WorkoutExecutionScreenState extends State { _mode = _session.status == ActiveWorkoutStatus.running ? WorkoutExecutionMode.active : WorkoutExecutionMode.paused; + _seriesStartedAt = DateTime.now().toUtc(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); + unawaited(_restoreActiveRest()); } @override @@ -158,20 +162,14 @@ final class _WorkoutExecutionScreenState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: () => setState(() { - _remainingRestSeconds = (_remainingRestSeconds - 15) - .clamp(0, 9999) - .toInt(); - }), + onPressed: () => _adjustRest(-15), child: const Text('-15 s'), ), ), const SizedBox(width: 12), Expanded( child: OutlinedButton( - onPressed: () => setState(() { - _remainingRestSeconds += 15; - }), + onPressed: () => _adjustRest(15), child: const Text('+15 s'), ), ), @@ -267,6 +265,8 @@ final class _WorkoutExecutionScreenState extends State { Future _resume() async { _session = await widget.activeUseCases.resume(_session.metadata.id); if (!mounted) return; + final restoredRest = await _restoreActiveRest(); + if (!mounted || restoredRest) return; setState(() => _mode = WorkoutExecutionMode.active); } @@ -302,6 +302,10 @@ final class _WorkoutExecutionScreenState extends State { Future _finishSet({required bool skipped}) async { final score = double.tryParse(_scoreController.text.trim()); + final actualTimeMs = DateTime.now() + .toUtc() + .difference(_seriesStartedAt) + .inMilliseconds; await widget.activeUseCases.recordCurrentSetResult( sessionId: _session.metadata.id, programSnapshotId: _plan.programAt(_position).id, @@ -312,7 +316,9 @@ final class _WorkoutExecutionScreenState extends State { actualReps: skipped ? null : (_exercise.repsEnabled ? _reps : null), actualScore: skipped ? null : (_exercise.scoreEnabled ? score : null), scoreUnitSnapshot: _exercise.scoreUnit, - actualTimeMs: skipped ? null : (_exercise.timeEnabled ? 0 : null), + actualTimeMs: skipped + ? null + : (_exercise.timeEnabled ? actualTimeMs : null), ); final next = _plan.nextPosition(_position); @@ -322,7 +328,7 @@ final class _WorkoutExecutionScreenState extends State { } final shouldRest = _plan.shouldShowRestAfter(_position); if (shouldRest) { - await widget.activeUseCases.startRestAfterSet( + final rest = await widget.activeUseCases.startRestAfterSet( sessionId: _session.metadata.id, afterProgramIndex: _position.programIndex, afterExerciseIndex: _position.exerciseIndex, @@ -330,6 +336,7 @@ final class _WorkoutExecutionScreenState extends State { plannedRestSeconds: _exercise.restSeconds, ); if (!mounted) return; + _activeRestStateId = rest.metadata.id; _remainingRestSeconds = _exercise.restSeconds; _startRestTicker(next); setState(() => _mode = WorkoutExecutionMode.rest); @@ -344,16 +351,73 @@ final class _WorkoutExecutionScreenState extends State { if (!mounted) return; if (_remainingRestSeconds <= 1) { timer.cancel(); - unawaited(_moveTo(next)); + unawaited(_finishRestAndMove(next)); } else { setState(() => _remainingRestSeconds -= 1); } }); } + Future _restoreActiveRest() async { + final rest = await widget.activeUseCases.findActiveRest( + sessionId: _session.metadata.id, + ); + if (!mounted || rest == null) { + return false; + } + final after = ExecutionPosition( + programIndex: rest.afterProgramIndex, + exerciseIndex: rest.afterExerciseIndex, + setIndex: rest.afterSetIndex, + ); + final next = _plan.nextPosition(after); + final remaining = _remainingSecondsFor(rest); + _activeRestStateId = rest.metadata.id; + if (next == null || remaining <= 0) { + await _finishRestAndMove(next); + return true; + } + _remainingRestSeconds = remaining; + _startRestTicker(next); + if (!mounted) return true; + setState(() => _mode = WorkoutExecutionMode.rest); + return true; + } + + int _remainingSecondsFor(ActiveRestState rest) { + final elapsedSeconds = DateTime.now() + .toUtc() + .difference(rest.startedAt.toUtc()) + .inSeconds; + return (rest.adjustedRestSeconds - elapsedSeconds).clamp(0, 9999).toInt(); + } + + Future _adjustRest(int deltaSeconds) async { + final restStateId = _activeRestStateId; + setState(() { + _remainingRestSeconds = (_remainingRestSeconds + deltaSeconds) + .clamp(0, 9999) + .toInt(); + }); + if (restStateId == null) return; + await widget.activeUseCases.adjustRestSeconds( + restStateId: restStateId, + deltaSeconds: deltaSeconds, + ); + } + Future _skipRest() async { _restTicker?.cancel(); final next = _plan.nextPosition(_position); + await _finishRestAndMove(next); + } + + Future _finishRestAndMove(ExecutionPosition? next) async { + final restStateId = _activeRestStateId; + _activeRestStateId = null; + if (restStateId != null) { + await widget.activeUseCases.skipRest(restStateId: restStateId); + } if (next == null) { await _complete(); return; @@ -364,6 +428,8 @@ final class _WorkoutExecutionScreenState extends State { Future _moveTo(ExecutionPosition position) async { _scoreController.clear(); _reps = 0; + _activeRestStateId = null; + _seriesStartedAt = DateTime.now().toUtc(); _session = await widget.activeUseCases.updateProgress( sessionId: _session.metadata.id, programIndex: position.programIndex, diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 7d247a2..413211d 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -115,6 +115,108 @@ void main() { expect(find.text('Pas de repos'), findsOneWidget); }); + + testWidgets('un repos actif est repris avec le temps restant recalculé', ( + 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: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + setsCount: 2, + restSeconds: 60, + ), + ); + repository.session = session; + repository.restStates.add( + ActiveRestState( + metadata: _metadata('rest-1'), + activeWorkoutSessionId: 'session-1', + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: DateTime.now().toUtc().subtract(const Duration(seconds: 15)), + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + expect(find.text('Repos avant la prochaine série'), findsOneWidget); + expect(find.text('00:45'), findsOneWidget); + + await tester.tap(find.text('+15 s')); + await tester.pump(); + + expect(find.text('01:00'), findsOneWidget); + expect(repository.restStates.single.adjustedRestSeconds, 75); + + await tester.tap(find.text('Ignorer le repos')); + await tester.pump(); + + expect(repository.restStates.single.skippedAt, isNotNull); + }); + + testWidgets('la mesure temps enregistre une durée réelle de série', ( + 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: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + setsCount: 2, + restSeconds: 0, + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(const Duration(seconds: 2)); + await tester.tap(find.text('Terminer la série')); + await tester.pump(); + + expect(repository.results.single.actualTimeMs, greaterThan(0)); + }); } ActiveWorkoutSessionUseCases _activeUseCases( @@ -221,6 +323,7 @@ final class _FakeIds implements IdGenerator { final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final results = []; + final restStates = []; @override Future findById(String id) async { @@ -231,11 +334,20 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { Future findOpen() async => session; @override - Future findRestStateById(String id) async => null; + Future findRestStateById(String id) async { + for (final restState in restStates) { + if (restState.metadata.id == id) { + return restState; + } + } + return null; + } @override Future> listRestStates(String sessionId) async { - return const []; + return restStates + .where((restState) => restState.activeWorkoutSessionId == sessionId) + .toList(); } @override @@ -249,7 +361,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { } @override - Future saveRestState(ActiveRestState restState) async {} + Future saveRestState(ActiveRestState restState) async { + final index = restStates.indexWhere( + (saved) => saved.metadata.id == restState.metadata.id, + ); + if (index == -1) { + restStates.add(restState); + } else { + restStates[index] = restState; + } + } @override Future saveSetResult(ActiveSetResult result) async {