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 [];