From 75f52200a211ae0ef2de396a159b390b7a132727 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 21 Jul 2026 22:26:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(ex=C3=A9cution):=20refonte=20des=20chronos?= =?UTF-8?q?=20de=20s=C3=A9ance=20et=20carte=20exercice=20actif=20(ticket?= =?UTF-8?q?=20#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Réorganise l'écran d'exécution autour d'une carte "Exercice actif" sous le compteur SÉRIE X/Y (nom, médias, temps de série, action "Démarrer l'exercice"). Le timer de série devient un état persistant dédié (plus un DateTime UI volatile), pause-aware, et "Démarrer l'exercice" lance en une action tous les chronos qui doivent démarrer en début de série (série, première étape temps, score chrono si stopwatch). Implémentation restée non commitée depuis sa réalisation ; QA a validé les vérifications statiques exécutables en sandbox (dart analyze, git diff --check). flutter analyze / flutter test restent à relancer dans un environnement avec cache Flutter SDK accessible. Co-Authored-By: Claude Opus 4.8 --- lib/application/ports.dart | 8 + lib/application/use_cases.dart | 451 +++- lib/domain/entities.dart | 100 +- lib/infrastructure/local/app_database.dart | 149 +- lib/infrastructure/local/app_database.g.dart | 2079 ++++++++++++++++- .../local/drift_repositories.dart | 267 ++- lib/infrastructure/local/tables.dart | 35 +- lib/presentation/exercise_library_screen.dart | 5 +- .../workout_execution_screen.dart | 1081 ++++++--- test/application/use_cases_test.dart | 242 +- .../drift_repositories_test.dart | 278 +++ .../exercise_library_screen_test.dart | 1 + test/presentation/history_screen_test.dart | 16 + test/presentation/home_screen_test.dart | 21 + .../workout_execution_screen_test.dart | 228 +- .../workout_template_screen_test.dart | 18 + 16 files changed, 4615 insertions(+), 364 deletions(-) diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 36666d7..db61a3c 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -388,6 +388,7 @@ abstract interface class ActiveSessionRepository { Future findOpen(); Future save(ActiveWorkoutSession session); Future saveSetResult(ActiveSetResult result); + Future saveSetTimerState(ActiveSetTimerState state); Future saveRestState(ActiveRestState restState); Future saveScoreStopwatchState(ActiveScoreStopwatchState state); Future saveExerciseStepProgressState( @@ -408,6 +409,12 @@ abstract interface class ActiveSessionRepository { required int exerciseIndex, required int setIndex, }); + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }); Future findExerciseStepProgressState({ required String sessionId, required int programIndex, @@ -415,6 +422,7 @@ abstract interface class ActiveSessionRepository { required int setIndex, }); Future> listSetResults(String sessionId); + Future> listSetTimerStates(String sessionId); Future> listExerciseStepProgressStates( String sessionId, ); diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 8852541..42a829b 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -1601,6 +1601,18 @@ final class WorkoutTemplateExerciseOverrideConfig { final bool? autoStartNextTimedStepOverride; } +final class SetExecutionTimerStartResult { + const SetExecutionTimerStartResult({ + this.setTimer, + this.scoreStopwatch, + this.stepProgress, + }); + + final ActiveSetTimerState? setTimer; + final ActiveScoreStopwatchState? scoreStopwatch; + final ActiveExerciseStepProgressState? stepProgress; +} + final class ActiveWorkoutSessionUseCases { const ActiveWorkoutSessionUseCases({ required this.sessionRepository, @@ -1697,13 +1709,25 @@ final class ActiveWorkoutSessionUseCases { sessionId, )).where((state) => state.status == ActiveScoreStopwatchStatus.running); for (final state in runningStopwatches) { - final stopped = state.copyWith( + final pausedStopwatch = state.copyWith( metadata: state.metadata.touch(now), - status: ActiveScoreStopwatchStatus.stopped, + status: ActiveScoreStopwatchStatus.paused, accumulatedMs: state.elapsedMillisecondsAt(now), stoppedAt: now, ); - await sessionRepository.saveScoreStopwatchState(stopped); + await sessionRepository.saveScoreStopwatchState(pausedStopwatch); + } + final runningSetTimers = (await sessionRepository.listSetTimerStates( + sessionId, + )).where((state) => state.status == ActiveSetTimerStatus.running); + for (final state in runningSetTimers) { + final pausedSetTimer = state.copyWith( + metadata: state.metadata.touch(now), + status: ActiveSetTimerStatus.paused, + startedAt: null, + accumulatedMs: state.elapsedMillisecondsAt(now), + ); + await sessionRepository.saveSetTimerState(pausedSetTimer); } final runningStepTimers = (await sessionRepository.listExerciseStepProgressStates( @@ -1722,6 +1746,18 @@ final class ActiveWorkoutSessionUseCases { ); await sessionRepository.saveExerciseStepProgressState(pausedStep); } + final activeRests = (await sessionRepository.listRestStates(sessionId)) + .where( + (rest) => + rest.endedAt == null && + rest.skippedAt == null && + rest.pausedAt == null, + ); + for (final rest in activeRests) { + await sessionRepository.saveRestState( + rest.copyWith(metadata: rest.metadata.touch(now), pausedAt: now), + ); + } final paused = session.pause(now); await sessionRepository.save(paused); return paused; @@ -1729,7 +1765,68 @@ final class ActiveWorkoutSessionUseCases { Future resume(String sessionId) async { final session = await _requiredSession(sessionId); - final resumed = session.resume(clock.now()); + final now = clock.now(); + final pausedSetTimers = (await sessionRepository.listSetTimerStates( + sessionId, + )).where((state) => state.status == ActiveSetTimerStatus.paused); + for (final state in pausedSetTimers) { + await sessionRepository.saveSetTimerState( + state.copyWith( + metadata: state.metadata.touch(now), + status: ActiveSetTimerStatus.running, + startedAt: now, + ), + ); + } + final pausedStopwatches = (await sessionRepository.listScoreStopwatchStates( + sessionId, + )).where((state) => state.status == ActiveScoreStopwatchStatus.paused); + for (final state in pausedStopwatches) { + await sessionRepository.saveScoreStopwatchState( + state.copyWith( + metadata: state.metadata.touch(now), + status: ActiveScoreStopwatchStatus.running, + startedAt: now, + stoppedAt: null, + ), + ); + } + final sessionPausedAt = session.pausedAt; + final pausedStepTimers = + (await sessionRepository.listExerciseStepProgressStates( + sessionId, + )).where( + (state) => + state.status == ActiveExerciseStepProgressStatus.pausedTimer && + sessionPausedAt != null && + state.lastTransitionAt.isAtSameMomentAs(sessionPausedAt), + ); + for (final state in pausedStepTimers) { + await sessionRepository.saveExerciseStepProgressState( + state.copyWith( + metadata: state.metadata.touch(now), + status: ActiveExerciseStepProgressStatus.runningTimer, + startedAt: now, + lastTransitionAt: now, + ), + ); + } + final pausedRests = (await sessionRepository.listRestStates( + sessionId, + )).where((rest) => rest.pausedAt != null); + for (final rest in pausedRests) { + final pausedAt = rest.pausedAt!; + await sessionRepository.saveRestState( + rest.copyWith( + metadata: rest.metadata.touch(now), + pausedAt: null, + accumulatedPausedMs: + rest.accumulatedPausedMs + + now.difference(pausedAt).inMilliseconds, + ), + ); + } + final resumed = session.resume(now); await sessionRepository.save(resumed); return resumed; } @@ -1738,6 +1835,182 @@ final class ActiveWorkoutSessionUseCases { return session.elapsedActiveMillisecondsAt(clock.now()); } + Future startSetExecution({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + final session = await _requiredSession(sessionId); + final snapshot = _findExerciseSnapshot( + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + ); + if (snapshot == null) { + throw const DomainException('Exercise position not found in session.'); + } + final now = clock.now(); + final setTimer = snapshot.timeEnabled + ? await _startOrResumeSetTimer( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + now: now, + ) + : null; + final scoreStopwatch = + snapshot.scoreEnabled && + snapshot.scoreInputModeSnapshot == ScoreInputMode.stopwatch + ? await _startScoreStopwatchForSetExecution( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + now: now, + ) + : null; + final stepProgress = await _startFirstStepTimerIfNeeded( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + snapshot: snapshot, + ); + return SetExecutionTimerStartResult( + setTimer: setTimer, + scoreStopwatch: scoreStopwatch, + stepProgress: stepProgress, + ); + } + + Future startCurrentExerciseTimers({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) { + return startSetExecution( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + } + + Future stopSetExecutionTimers({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + final now = clock.now(); + final setTimer = await _finishSetTimer( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + status: ActiveSetTimerStatus.stopped, + now: now, + ); + final scoreStopwatch = await sessionRepository.findScoreStopwatchState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (scoreStopwatch != null && + scoreStopwatch.status != ActiveScoreStopwatchStatus.stopped) { + await sessionRepository.saveScoreStopwatchState( + scoreStopwatch.copyWith( + metadata: scoreStopwatch.metadata.touch(now), + status: ActiveScoreStopwatchStatus.stopped, + accumulatedMs: scoreStopwatch.elapsedMillisecondsAt(now), + stoppedAt: now, + ), + ); + } + final stepProgress = await sessionRepository.findExerciseStepProgressState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (stepProgress?.status == ActiveExerciseStepProgressStatus.runningTimer) { + await sessionRepository.saveExerciseStepProgressState( + stepProgress!.copyWith( + metadata: stepProgress.metadata.touch(now), + status: ActiveExerciseStepProgressStatus.pausedTimer, + startedAt: null, + accumulatedMs: stepProgress.elapsedMillisecondsAt(now), + lastTransitionAt: now, + ), + ); + } + return setTimer; + } + + Future skipSetExecutionTimers({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + final now = clock.now(); + final setTimer = await _finishSetTimer( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + status: ActiveSetTimerStatus.skipped, + now: now, + ); + final scoreStopwatch = await sessionRepository.findScoreStopwatchState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (scoreStopwatch != null && + scoreStopwatch.status != ActiveScoreStopwatchStatus.stopped) { + await sessionRepository.saveScoreStopwatchState( + scoreStopwatch.copyWith( + metadata: scoreStopwatch.metadata.touch(now), + status: ActiveScoreStopwatchStatus.stopped, + accumulatedMs: scoreStopwatch.elapsedMillisecondsAt(now), + stoppedAt: now, + ), + ); + } + final stepProgress = await sessionRepository.findExerciseStepProgressState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (stepProgress != null && + stepProgress.status != + ActiveExerciseStepProgressStatus.sequenceComplete) { + try { + await ActiveExerciseStepUseCases( + sessionRepository: sessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ).skipSequence( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + } on DomainException { + // A set without configured steps has no step sequence to skip. + } + } + return setTimer; + } + Future recordSetResult(ActiveSetResult result) async { await sessionRepository.saveSetResult(result); return result; @@ -1981,6 +2254,24 @@ final class ActiveWorkoutSessionUseCases { return state.elapsedMillisecondsAt(clock.now()); } + Future findSetTimer({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) { + return sessionRepository.findSetTimerState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + } + + int setTimerElapsedMilliseconds(ActiveSetTimerState state) { + return state.elapsedMillisecondsAt(clock.now()); + } + Future> listSetResults(String sessionId) async { final session = await _requiredSession(sessionId); final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson); @@ -2140,6 +2431,158 @@ final class ActiveWorkoutSessionUseCases { return active.isEmpty ? null : active.first; } + Future _startOrResumeSetTimer({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime now, + }) async { + final existing = await sessionRepository.findSetTimerState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (existing != null) { + if (existing.status == ActiveSetTimerStatus.running || + existing.status == ActiveSetTimerStatus.stopped || + existing.status == ActiveSetTimerStatus.skipped) { + return existing; + } + final resumed = existing.copyWith( + metadata: existing.metadata.touch(now), + status: ActiveSetTimerStatus.running, + startedAt: now, + stoppedAt: null, + skippedAt: null, + ); + await sessionRepository.saveSetTimerState(resumed); + return resumed; + } + final state = ActiveSetTimerState( + metadata: _newMetadata(ids, originDeviceId, now), + activeWorkoutSessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + status: ActiveSetTimerStatus.running, + startedAt: now, + accumulatedMs: 0, + ); + await sessionRepository.saveSetTimerState(state); + return state; + } + + Future _finishSetTimer({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required ActiveSetTimerStatus status, + required DateTime now, + }) async { + final existing = await sessionRepository.findSetTimerState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (existing == null) { + return null; + } + if (existing.status == status) { + return existing; + } + final finished = existing.copyWith( + metadata: existing.metadata.touch(now), + status: status, + startedAt: null, + accumulatedMs: status == ActiveSetTimerStatus.skipped + ? existing.accumulatedMs + : existing.elapsedMillisecondsAt(now), + stoppedAt: status == ActiveSetTimerStatus.stopped ? now : null, + skippedAt: status == ActiveSetTimerStatus.skipped ? now : null, + ); + await sessionRepository.saveSetTimerState(finished); + return finished; + } + + Future _startScoreStopwatchForSetExecution({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime now, + }) async { + final existing = await sessionRepository.findScoreStopwatchState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (existing != null) { + if (existing.status == ActiveScoreStopwatchStatus.running || + existing.status == ActiveScoreStopwatchStatus.stopped) { + return existing; + } + final resumed = existing.copyWith( + metadata: existing.metadata.touch(now), + status: ActiveScoreStopwatchStatus.running, + startedAt: now, + stoppedAt: null, + ); + await sessionRepository.saveScoreStopwatchState(resumed); + return resumed; + } + final state = ActiveScoreStopwatchState( + metadata: _newMetadata(ids, originDeviceId, now), + activeWorkoutSessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + status: ActiveScoreStopwatchStatus.running, + startedAt: now, + accumulatedMs: 0, + ); + await sessionRepository.saveScoreStopwatchState(state); + return state; + } + + Future _startFirstStepTimerIfNeeded({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required _ResolvedExerciseSnapshot snapshot, + }) async { + if (snapshot.steps.isEmpty || + snapshot.steps.first.type != ExerciseStepType.time) { + return null; + } + final existing = await sessionRepository.findExerciseStepProgressState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (existing != null && + (existing.currentPassageIndex != 0 || existing.currentStepIndex != 0)) { + return existing; + } + return ActiveExerciseStepUseCases( + sessionRepository: sessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ).startTimer( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + } + 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 088b9ef..787ed48 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -14,7 +14,9 @@ enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned } enum SetResultStatus { completed, skipped } -enum ActiveScoreStopwatchStatus { running, stopped } +enum ActiveSetTimerStatus { running, paused, stopped, skipped } + +enum ActiveScoreStopwatchStatus { running, paused, stopped } enum ShareResourceType { program, workoutTemplate } @@ -1031,7 +1033,7 @@ final class ActiveScoreStopwatchState { final DateTime? stoppedAt; int elapsedMillisecondsAt(DateTime now) { - if (status == ActiveScoreStopwatchStatus.stopped) { + if (status != ActiveScoreStopwatchStatus.running) { return accumulatedMs; } return accumulatedMs + now.difference(startedAt).inMilliseconds; @@ -1060,6 +1062,80 @@ final class ActiveScoreStopwatchState { } } +final class ActiveSetTimerState { + ActiveSetTimerState({ + required this.metadata, + required this.activeWorkoutSessionId, + required this.programIndex, + required this.exerciseIndex, + required this.setIndex, + required this.status, + this.startedAt, + required this.accumulatedMs, + this.stoppedAt, + this.skippedAt, + }) { + _requireNonNegative(programIndex, 'Program index'); + _requireNonNegative(exerciseIndex, 'Exercise index'); + _requireNonNegative(setIndex, 'Set index'); + _requireNonNegative(accumulatedMs, 'Accumulated milliseconds'); + if (status == ActiveSetTimerStatus.running && startedAt == null) { + throw const DomainException( + 'Running set timer requires a start timestamp.', + ); + } + if (stoppedAt != null && skippedAt != null) { + throw const DomainException('Set timer cannot be stopped and skipped.'); + } + } + + final EntityMetadata metadata; + final String activeWorkoutSessionId; + final int programIndex; + final int exerciseIndex; + final int setIndex; + final ActiveSetTimerStatus status; + final DateTime? startedAt; + final int accumulatedMs; + final DateTime? stoppedAt; + final DateTime? skippedAt; + + int elapsedMillisecondsAt(DateTime now) { + if (status != ActiveSetTimerStatus.running || startedAt == null) { + return accumulatedMs; + } + return accumulatedMs + now.difference(startedAt!).inMilliseconds; + } + + ActiveSetTimerState copyWith({ + EntityMetadata? metadata, + ActiveSetTimerStatus? status, + Object? startedAt = _unchanged, + int? accumulatedMs, + Object? stoppedAt = _unchanged, + Object? skippedAt = _unchanged, + }) { + return ActiveSetTimerState( + metadata: metadata ?? this.metadata, + activeWorkoutSessionId: activeWorkoutSessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + status: status ?? this.status, + startedAt: startedAt == _unchanged + ? this.startedAt + : startedAt as DateTime?, + accumulatedMs: accumulatedMs ?? this.accumulatedMs, + stoppedAt: stoppedAt == _unchanged + ? this.stoppedAt + : stoppedAt as DateTime?, + skippedAt: skippedAt == _unchanged + ? this.skippedAt + : skippedAt as DateTime?, + ); + } +} + final class ActiveRestState { const ActiveRestState({ required this.metadata, @@ -1072,6 +1148,8 @@ final class ActiveRestState { required this.startedAt, this.endedAt, this.skippedAt, + this.pausedAt, + this.accumulatedPausedMs = 0, }); final EntityMetadata metadata; @@ -1084,12 +1162,28 @@ final class ActiveRestState { final DateTime startedAt; final DateTime? endedAt; final DateTime? skippedAt; + final DateTime? pausedAt; + final int accumulatedPausedMs; + + int elapsedMillisecondsAt(DateTime now) { + final effectiveNow = pausedAt ?? now; + final elapsed = + effectiveNow.difference(startedAt).inMilliseconds - accumulatedPausedMs; + return elapsed < 0 ? 0 : elapsed; + } + + int remainingMillisecondsAt(DateTime now) { + final remaining = adjustedRestSeconds * 1000 - elapsedMillisecondsAt(now); + return remaining < 0 ? 0 : remaining; + } ActiveRestState copyWith({ EntityMetadata? metadata, int? adjustedRestSeconds, Object? endedAt = _unchanged, Object? skippedAt = _unchanged, + Object? pausedAt = _unchanged, + int? accumulatedPausedMs, }) { return ActiveRestState( metadata: metadata ?? this.metadata, @@ -1104,6 +1198,8 @@ final class ActiveRestState { skippedAt: skippedAt == _unchanged ? this.skippedAt : skippedAt as DateTime?, + pausedAt: pausedAt == _unchanged ? this.pausedAt : pausedAt as DateTime?, + accumulatedPausedMs: accumulatedPausedMs ?? this.accumulatedPausedMs, ); } } diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 9608606..1003880 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -11,6 +11,7 @@ part 'app_database.g.dart'; ActiveExerciseStepResults, ActiveRestStates, ActiveScoreStopwatchStates, + ActiveSetTimerStates, ActiveSetResults, ActiveWorkoutSessions, ChangeLogEntries, @@ -46,13 +47,14 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 14; + int get schemaVersion => 15; @override MigrationStrategy get migration { return MigrationStrategy( onCreate: (migrator) async { await migrator.createAll(); + await _migrateToSchema15(); await _createIndexes(); }, onUpgrade: (migrator, from, to) async { @@ -103,6 +105,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 14) { await _migrateToSchema14(); } + if (from < 15) { + await _migrateToSchema15(); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -171,6 +176,10 @@ final class AppDatabase extends _$AppDatabase { 'CREATE INDEX IF NOT EXISTS idx_active_score_stopwatch_states_session_id ' 'ON active_score_stopwatch_states (active_workout_session_id)', ); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_active_set_timer_states_session_id ' + 'ON active_set_timer_states (active_workout_session_id)', + ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_active_rest_states_session_id ' 'ON active_rest_states (active_workout_session_id)', @@ -223,6 +232,7 @@ const _syncableTableNames = [ 'active_exercise_step_results', 'active_rest_states', 'active_score_stopwatch_states', + 'active_set_timer_states', 'active_set_results', 'active_workout_sessions', 'exercises', @@ -494,4 +504,141 @@ FROM exercises 'CHECK (auto_start_next_timed_step_override IN (0, 1))', ); } + + Future _migrateToSchema15() async { + await customStatement('PRAGMA foreign_keys = OFF'); + await customStatement(''' +CREATE TABLE active_score_stopwatch_states_new ( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + schema_version INTEGER NOT NULL DEFAULT 1, + sync_state TEXT NOT NULL CHECK (sync_state IN ('localOnly', 'dirty', 'synced', 'deleted')), + local_revision INTEGER NOT NULL CHECK (local_revision >= 0), + origin_device_id TEXT NOT NULL, + future_owner_profile_id TEXT, + last_synced_at INTEGER, + remote_revision TEXT, + active_workout_session_id TEXT NOT NULL REFERENCES active_workout_sessions(id), + program_index INTEGER NOT NULL, + exercise_index INTEGER NOT NULL, + set_index INTEGER NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + accumulated_ms INTEGER NOT NULL, + stopped_at INTEGER, + UNIQUE (active_workout_session_id, program_index, exercise_index, set_index), + CHECK (length(trim(origin_device_id)) > 0), + CHECK (future_owner_profile_id IS NULL OR length(trim(future_owner_profile_id)) > 0), + CHECK (remote_revision IS NULL OR length(trim(remote_revision)) > 0), + CHECK (program_index >= 0), + CHECK (exercise_index >= 0), + CHECK (set_index >= 0), + CHECK (status IN ('running', 'paused', 'stopped')), + CHECK (accumulated_ms >= 0) +) +'''); + await customStatement(''' +INSERT INTO active_score_stopwatch_states_new ( + id, + created_at, + updated_at, + deleted_at, + schema_version, + sync_state, + local_revision, + origin_device_id, + future_owner_profile_id, + last_synced_at, + remote_revision, + active_workout_session_id, + program_index, + exercise_index, + set_index, + status, + started_at, + accumulated_ms, + stopped_at +) +SELECT + id, + created_at, + updated_at, + deleted_at, + schema_version, + sync_state, + local_revision, + origin_device_id, + future_owner_profile_id, + last_synced_at, + remote_revision, + active_workout_session_id, + program_index, + exercise_index, + set_index, + status, + started_at, + accumulated_ms, + stopped_at +FROM active_score_stopwatch_states +'''); + await customStatement('DROP TABLE active_score_stopwatch_states'); + await customStatement( + 'ALTER TABLE active_score_stopwatch_states_new ' + 'RENAME TO active_score_stopwatch_states', + ); + await customStatement('PRAGMA foreign_keys = ON'); + await customStatement(''' +CREATE TABLE IF NOT EXISTS active_set_timer_states ( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + schema_version INTEGER NOT NULL DEFAULT 1, + sync_state TEXT NOT NULL CHECK (sync_state IN ('localOnly', 'dirty', 'synced', 'deleted')), + local_revision INTEGER NOT NULL CHECK (local_revision >= 0), + origin_device_id TEXT NOT NULL, + future_owner_profile_id TEXT, + last_synced_at INTEGER, + remote_revision TEXT, + active_workout_session_id TEXT NOT NULL REFERENCES active_workout_sessions(id), + program_index INTEGER NOT NULL, + exercise_index INTEGER NOT NULL, + set_index INTEGER NOT NULL, + status TEXT NOT NULL, + started_at INTEGER, + accumulated_ms INTEGER NOT NULL, + stopped_at INTEGER, + skipped_at INTEGER, + UNIQUE (active_workout_session_id, program_index, exercise_index, set_index), + CHECK (length(trim(origin_device_id)) > 0), + CHECK (future_owner_profile_id IS NULL OR length(trim(future_owner_profile_id)) > 0), + CHECK (remote_revision IS NULL OR length(trim(remote_revision)) > 0), + CHECK (program_index >= 0), + CHECK (exercise_index >= 0), + CHECK (set_index >= 0), + CHECK (status IN ('running', 'paused', 'stopped', 'skipped')), + CHECK (status != 'running' OR started_at IS NOT NULL), + CHECK (accumulated_ms >= 0), + CHECK (stopped_at IS NULL OR skipped_at IS NULL) +) +'''); + if (!await _hasColumn('active_rest_states', 'paused_at')) { + await customStatement( + 'ALTER TABLE active_rest_states ADD COLUMN paused_at INTEGER', + ); + } + if (!await _hasColumn('active_rest_states', 'accumulated_paused_ms')) { + await customStatement( + 'ALTER TABLE active_rest_states ADD COLUMN accumulated_paused_ms ' + 'INTEGER NOT NULL DEFAULT 0 CHECK (accumulated_paused_ms >= 0)', + ); + } + } + + Future _hasColumn(String tableName, String columnName) async { + final rows = await customSelect('PRAGMA table_info($tableName)').get(); + return rows.any((row) => row.data['name'] == columnName); + } } diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index e621f7a..8268540 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -5774,6 +5774,28 @@ class $ActiveRestStatesTable extends ActiveRestStates type: DriftSqlType.dateTime, requiredDuringInsert: false, ); + static const VerificationMeta _pausedAtMeta = const VerificationMeta( + 'pausedAt', + ); + @override + late final GeneratedColumn pausedAt = GeneratedColumn( + 'paused_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _accumulatedPausedMsMeta = + const VerificationMeta('accumulatedPausedMs'); + @override + late final GeneratedColumn accumulatedPausedMs = GeneratedColumn( + 'accumulated_paused_ms', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); @override List get $columns => [ id, @@ -5796,6 +5818,8 @@ class $ActiveRestStatesTable extends ActiveRestStates startedAt, endedAt, skippedAt, + pausedAt, + accumulatedPausedMs, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -5988,6 +6012,21 @@ class $ActiveRestStatesTable extends ActiveRestStates skippedAt.isAcceptableOrUnknown(data['skipped_at']!, _skippedAtMeta), ); } + if (data.containsKey('paused_at')) { + context.handle( + _pausedAtMeta, + pausedAt.isAcceptableOrUnknown(data['paused_at']!, _pausedAtMeta), + ); + } + if (data.containsKey('accumulated_paused_ms')) { + context.handle( + _accumulatedPausedMsMeta, + accumulatedPausedMs.isAcceptableOrUnknown( + data['accumulated_paused_ms']!, + _accumulatedPausedMsMeta, + ), + ); + } return context; } @@ -6077,6 +6116,14 @@ class $ActiveRestStatesTable extends ActiveRestStates DriftSqlType.dateTime, data['${effectivePrefix}skipped_at'], ), + pausedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}paused_at'], + ), + accumulatedPausedMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}accumulated_paused_ms'], + )!, ); } @@ -6107,6 +6154,8 @@ class ActiveRestState extends DataClass implements Insertable { final DateTime startedAt; final DateTime? endedAt; final DateTime? skippedAt; + final DateTime? pausedAt; + final int accumulatedPausedMs; const ActiveRestState({ required this.id, required this.createdAt, @@ -6128,6 +6177,8 @@ class ActiveRestState extends DataClass implements Insertable { required this.startedAt, this.endedAt, this.skippedAt, + this.pausedAt, + required this.accumulatedPausedMs, }); @override Map toColumns(bool nullToAbsent) { @@ -6164,6 +6215,10 @@ class ActiveRestState extends DataClass implements Insertable { if (!nullToAbsent || skippedAt != null) { map['skipped_at'] = Variable(skippedAt); } + if (!nullToAbsent || pausedAt != null) { + map['paused_at'] = Variable(pausedAt); + } + map['accumulated_paused_ms'] = Variable(accumulatedPausedMs); return map; } @@ -6201,6 +6256,10 @@ class ActiveRestState extends DataClass implements Insertable { skippedAt: skippedAt == null && nullToAbsent ? const Value.absent() : Value(skippedAt), + pausedAt: pausedAt == null && nullToAbsent + ? const Value.absent() + : Value(pausedAt), + accumulatedPausedMs: Value(accumulatedPausedMs), ); } @@ -6236,6 +6295,10 @@ class ActiveRestState extends DataClass implements Insertable { startedAt: serializer.fromJson(json['startedAt']), endedAt: serializer.fromJson(json['endedAt']), skippedAt: serializer.fromJson(json['skippedAt']), + pausedAt: serializer.fromJson(json['pausedAt']), + accumulatedPausedMs: serializer.fromJson( + json['accumulatedPausedMs'], + ), ); } @override @@ -6264,6 +6327,8 @@ class ActiveRestState extends DataClass implements Insertable { 'startedAt': serializer.toJson(startedAt), 'endedAt': serializer.toJson(endedAt), 'skippedAt': serializer.toJson(skippedAt), + 'pausedAt': serializer.toJson(pausedAt), + 'accumulatedPausedMs': serializer.toJson(accumulatedPausedMs), }; } @@ -6288,6 +6353,8 @@ class ActiveRestState extends DataClass implements Insertable { DateTime? startedAt, Value endedAt = const Value.absent(), Value skippedAt = const Value.absent(), + Value pausedAt = const Value.absent(), + int? accumulatedPausedMs, }) => ActiveRestState( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -6314,6 +6381,8 @@ class ActiveRestState extends DataClass implements Insertable { startedAt: startedAt ?? this.startedAt, endedAt: endedAt.present ? endedAt.value : this.endedAt, skippedAt: skippedAt.present ? skippedAt.value : this.skippedAt, + pausedAt: pausedAt.present ? pausedAt.value : this.pausedAt, + accumulatedPausedMs: accumulatedPausedMs ?? this.accumulatedPausedMs, ); ActiveRestState copyWithCompanion(ActiveRestStatesCompanion data) { return ActiveRestState( @@ -6361,6 +6430,10 @@ class ActiveRestState extends DataClass implements Insertable { startedAt: data.startedAt.present ? data.startedAt.value : this.startedAt, endedAt: data.endedAt.present ? data.endedAt.value : this.endedAt, skippedAt: data.skippedAt.present ? data.skippedAt.value : this.skippedAt, + pausedAt: data.pausedAt.present ? data.pausedAt.value : this.pausedAt, + accumulatedPausedMs: data.accumulatedPausedMs.present + ? data.accumulatedPausedMs.value + : this.accumulatedPausedMs, ); } @@ -6386,13 +6459,15 @@ class ActiveRestState extends DataClass implements Insertable { ..write('adjustedRestSeconds: $adjustedRestSeconds, ') ..write('startedAt: $startedAt, ') ..write('endedAt: $endedAt, ') - ..write('skippedAt: $skippedAt') + ..write('skippedAt: $skippedAt, ') + ..write('pausedAt: $pausedAt, ') + ..write('accumulatedPausedMs: $accumulatedPausedMs') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ id, createdAt, updatedAt, @@ -6413,7 +6488,9 @@ class ActiveRestState extends DataClass implements Insertable { startedAt, endedAt, skippedAt, - ); + pausedAt, + accumulatedPausedMs, + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -6437,7 +6514,9 @@ class ActiveRestState extends DataClass implements Insertable { other.adjustedRestSeconds == this.adjustedRestSeconds && other.startedAt == this.startedAt && other.endedAt == this.endedAt && - other.skippedAt == this.skippedAt); + other.skippedAt == this.skippedAt && + other.pausedAt == this.pausedAt && + other.accumulatedPausedMs == this.accumulatedPausedMs); } class ActiveRestStatesCompanion extends UpdateCompanion { @@ -6461,6 +6540,8 @@ class ActiveRestStatesCompanion extends UpdateCompanion { final Value startedAt; final Value endedAt; final Value skippedAt; + final Value pausedAt; + final Value accumulatedPausedMs; final Value rowid; const ActiveRestStatesCompanion({ this.id = const Value.absent(), @@ -6483,6 +6564,8 @@ class ActiveRestStatesCompanion extends UpdateCompanion { this.startedAt = const Value.absent(), this.endedAt = const Value.absent(), this.skippedAt = const Value.absent(), + this.pausedAt = const Value.absent(), + this.accumulatedPausedMs = const Value.absent(), this.rowid = const Value.absent(), }); ActiveRestStatesCompanion.insert({ @@ -6506,6 +6589,8 @@ class ActiveRestStatesCompanion extends UpdateCompanion { required DateTime startedAt, this.endedAt = const Value.absent(), this.skippedAt = const Value.absent(), + this.pausedAt = const Value.absent(), + this.accumulatedPausedMs = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -6541,6 +6626,8 @@ class ActiveRestStatesCompanion extends UpdateCompanion { Expression? startedAt, Expression? endedAt, Expression? skippedAt, + Expression? pausedAt, + Expression? accumulatedPausedMs, Expression? rowid, }) { return RawValuesInsertable({ @@ -6569,6 +6656,9 @@ class ActiveRestStatesCompanion extends UpdateCompanion { if (startedAt != null) 'started_at': startedAt, if (endedAt != null) 'ended_at': endedAt, if (skippedAt != null) 'skipped_at': skippedAt, + if (pausedAt != null) 'paused_at': pausedAt, + if (accumulatedPausedMs != null) + 'accumulated_paused_ms': accumulatedPausedMs, if (rowid != null) 'rowid': rowid, }); } @@ -6594,6 +6684,8 @@ class ActiveRestStatesCompanion extends UpdateCompanion { Value? startedAt, Value? endedAt, Value? skippedAt, + Value? pausedAt, + Value? accumulatedPausedMs, Value? rowid, }) { return ActiveRestStatesCompanion( @@ -6618,6 +6710,8 @@ class ActiveRestStatesCompanion extends UpdateCompanion { startedAt: startedAt ?? this.startedAt, endedAt: endedAt ?? this.endedAt, skippedAt: skippedAt ?? this.skippedAt, + pausedAt: pausedAt ?? this.pausedAt, + accumulatedPausedMs: accumulatedPausedMs ?? this.accumulatedPausedMs, rowid: rowid ?? this.rowid, ); } @@ -6689,6 +6783,12 @@ class ActiveRestStatesCompanion extends UpdateCompanion { if (skippedAt.present) { map['skipped_at'] = Variable(skippedAt.value); } + if (pausedAt.present) { + map['paused_at'] = Variable(pausedAt.value); + } + if (accumulatedPausedMs.present) { + map['accumulated_paused_ms'] = Variable(accumulatedPausedMs.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -6718,6 +6818,8 @@ class ActiveRestStatesCompanion extends UpdateCompanion { ..write('startedAt: $startedAt, ') ..write('endedAt: $endedAt, ') ..write('skippedAt: $skippedAt, ') + ..write('pausedAt: $pausedAt, ') + ..write('accumulatedPausedMs: $accumulatedPausedMs, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -7848,6 +7950,1174 @@ class ActiveScoreStopwatchStatesCompanion } } +class $ActiveSetTimerStatesTable extends ActiveSetTimerStates + with TableInfo<$ActiveSetTimerStatesTable, ActiveSetTimerState> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ActiveSetTimerStatesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _deletedAtMeta = const VerificationMeta( + 'deletedAt', + ); + @override + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _schemaVersionMeta = const VerificationMeta( + 'schemaVersion', + ); + @override + late final GeneratedColumn schemaVersion = GeneratedColumn( + 'schema_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _syncStateMeta = const VerificationMeta( + 'syncState', + ); + @override + late final GeneratedColumn syncState = GeneratedColumn( + 'sync_state', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL CHECK (sync_state IN (\'localOnly\', \'dirty\', \'synced\', \'deleted\'))', + ); + static const VerificationMeta _localRevisionMeta = const VerificationMeta( + 'localRevision', + ); + @override + late final GeneratedColumn localRevision = GeneratedColumn( + 'local_revision', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL CHECK (local_revision >= 0)', + ); + static const VerificationMeta _originDeviceIdMeta = const VerificationMeta( + 'originDeviceId', + ); + @override + late final GeneratedColumn originDeviceId = GeneratedColumn( + 'origin_device_id', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _futureOwnerProfileIdMeta = + const VerificationMeta('futureOwnerProfileId'); + @override + late final GeneratedColumn futureOwnerProfileId = + GeneratedColumn( + 'future_owner_profile_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastSyncedAtMeta = const VerificationMeta( + 'lastSyncedAt', + ); + @override + late final GeneratedColumn lastSyncedAt = GeneratedColumn( + 'last_synced_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _remoteRevisionMeta = const VerificationMeta( + 'remoteRevision', + ); + @override + late final GeneratedColumn remoteRevision = GeneratedColumn( + 'remote_revision', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _activeWorkoutSessionIdMeta = + const VerificationMeta('activeWorkoutSessionId'); + @override + late final GeneratedColumn activeWorkoutSessionId = + GeneratedColumn( + 'active_workout_session_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES active_workout_sessions (id)', + ), + ); + static const VerificationMeta _programIndexMeta = const VerificationMeta( + 'programIndex', + ); + @override + late final GeneratedColumn programIndex = GeneratedColumn( + 'program_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _exerciseIndexMeta = const VerificationMeta( + 'exerciseIndex', + ); + @override + late final GeneratedColumn exerciseIndex = GeneratedColumn( + 'exercise_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _setIndexMeta = const VerificationMeta( + 'setIndex', + ); + @override + late final GeneratedColumn setIndex = GeneratedColumn( + 'set_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _startedAtMeta = const VerificationMeta( + 'startedAt', + ); + @override + late final GeneratedColumn startedAt = GeneratedColumn( + 'started_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _accumulatedMsMeta = const VerificationMeta( + 'accumulatedMs', + ); + @override + late final GeneratedColumn accumulatedMs = GeneratedColumn( + 'accumulated_ms', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _stoppedAtMeta = const VerificationMeta( + 'stoppedAt', + ); + @override + late final GeneratedColumn stoppedAt = GeneratedColumn( + 'stopped_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _skippedAtMeta = const VerificationMeta( + 'skippedAt', + ); + @override + late final GeneratedColumn skippedAt = GeneratedColumn( + 'skipped_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + schemaVersion, + syncState, + localRevision, + originDeviceId, + futureOwnerProfileId, + lastSyncedAt, + remoteRevision, + activeWorkoutSessionId, + programIndex, + exerciseIndex, + setIndex, + status, + startedAt, + accumulatedMs, + stoppedAt, + skippedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'active_set_timer_states'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('deleted_at')) { + context.handle( + _deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta), + ); + } + if (data.containsKey('schema_version')) { + context.handle( + _schemaVersionMeta, + schemaVersion.isAcceptableOrUnknown( + data['schema_version']!, + _schemaVersionMeta, + ), + ); + } + if (data.containsKey('sync_state')) { + context.handle( + _syncStateMeta, + syncState.isAcceptableOrUnknown(data['sync_state']!, _syncStateMeta), + ); + } else if (isInserting) { + context.missing(_syncStateMeta); + } + if (data.containsKey('local_revision')) { + context.handle( + _localRevisionMeta, + localRevision.isAcceptableOrUnknown( + data['local_revision']!, + _localRevisionMeta, + ), + ); + } else if (isInserting) { + context.missing(_localRevisionMeta); + } + if (data.containsKey('origin_device_id')) { + context.handle( + _originDeviceIdMeta, + originDeviceId.isAcceptableOrUnknown( + data['origin_device_id']!, + _originDeviceIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_originDeviceIdMeta); + } + if (data.containsKey('future_owner_profile_id')) { + context.handle( + _futureOwnerProfileIdMeta, + futureOwnerProfileId.isAcceptableOrUnknown( + data['future_owner_profile_id']!, + _futureOwnerProfileIdMeta, + ), + ); + } + if (data.containsKey('last_synced_at')) { + context.handle( + _lastSyncedAtMeta, + lastSyncedAt.isAcceptableOrUnknown( + data['last_synced_at']!, + _lastSyncedAtMeta, + ), + ); + } + if (data.containsKey('remote_revision')) { + context.handle( + _remoteRevisionMeta, + remoteRevision.isAcceptableOrUnknown( + data['remote_revision']!, + _remoteRevisionMeta, + ), + ); + } + if (data.containsKey('active_workout_session_id')) { + context.handle( + _activeWorkoutSessionIdMeta, + activeWorkoutSessionId.isAcceptableOrUnknown( + data['active_workout_session_id']!, + _activeWorkoutSessionIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_activeWorkoutSessionIdMeta); + } + if (data.containsKey('program_index')) { + context.handle( + _programIndexMeta, + programIndex.isAcceptableOrUnknown( + data['program_index']!, + _programIndexMeta, + ), + ); + } else if (isInserting) { + context.missing(_programIndexMeta); + } + if (data.containsKey('exercise_index')) { + context.handle( + _exerciseIndexMeta, + exerciseIndex.isAcceptableOrUnknown( + data['exercise_index']!, + _exerciseIndexMeta, + ), + ); + } else if (isInserting) { + context.missing(_exerciseIndexMeta); + } + if (data.containsKey('set_index')) { + context.handle( + _setIndexMeta, + setIndex.isAcceptableOrUnknown(data['set_index']!, _setIndexMeta), + ); + } else if (isInserting) { + context.missing(_setIndexMeta); + } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } else if (isInserting) { + context.missing(_statusMeta); + } + if (data.containsKey('started_at')) { + context.handle( + _startedAtMeta, + startedAt.isAcceptableOrUnknown(data['started_at']!, _startedAtMeta), + ); + } + if (data.containsKey('accumulated_ms')) { + context.handle( + _accumulatedMsMeta, + accumulatedMs.isAcceptableOrUnknown( + data['accumulated_ms']!, + _accumulatedMsMeta, + ), + ); + } else if (isInserting) { + context.missing(_accumulatedMsMeta); + } + if (data.containsKey('stopped_at')) { + context.handle( + _stoppedAtMeta, + stoppedAt.isAcceptableOrUnknown(data['stopped_at']!, _stoppedAtMeta), + ); + } + if (data.containsKey('skipped_at')) { + context.handle( + _skippedAtMeta, + skippedAt.isAcceptableOrUnknown(data['skipped_at']!, _skippedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ActiveSetTimerState map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ActiveSetTimerState( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + schemaVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}schema_version'], + )!, + syncState: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}sync_state'], + )!, + localRevision: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}local_revision'], + )!, + originDeviceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}origin_device_id'], + )!, + futureOwnerProfileId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}future_owner_profile_id'], + ), + lastSyncedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_synced_at'], + ), + remoteRevision: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}remote_revision'], + ), + activeWorkoutSessionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}active_workout_session_id'], + )!, + programIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}program_index'], + )!, + exerciseIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}exercise_index'], + )!, + setIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}set_index'], + )!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + startedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}started_at'], + ), + accumulatedMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}accumulated_ms'], + )!, + stoppedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}stopped_at'], + ), + skippedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}skipped_at'], + ), + ); + } + + @override + $ActiveSetTimerStatesTable createAlias(String alias) { + return $ActiveSetTimerStatesTable(attachedDatabase, alias); + } +} + +class ActiveSetTimerState extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final int schemaVersion; + final String syncState; + final int localRevision; + final String originDeviceId; + final String? futureOwnerProfileId; + final DateTime? lastSyncedAt; + final String? remoteRevision; + final String activeWorkoutSessionId; + final int programIndex; + final int exerciseIndex; + final int setIndex; + final String status; + final DateTime? startedAt; + final int accumulatedMs; + final DateTime? stoppedAt; + final DateTime? skippedAt; + const ActiveSetTimerState({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.schemaVersion, + required this.syncState, + required this.localRevision, + required this.originDeviceId, + this.futureOwnerProfileId, + this.lastSyncedAt, + this.remoteRevision, + required this.activeWorkoutSessionId, + required this.programIndex, + required this.exerciseIndex, + required this.setIndex, + required this.status, + this.startedAt, + required this.accumulatedMs, + this.stoppedAt, + this.skippedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['schema_version'] = Variable(schemaVersion); + map['sync_state'] = Variable(syncState); + map['local_revision'] = Variable(localRevision); + map['origin_device_id'] = Variable(originDeviceId); + if (!nullToAbsent || futureOwnerProfileId != null) { + map['future_owner_profile_id'] = Variable(futureOwnerProfileId); + } + if (!nullToAbsent || lastSyncedAt != null) { + map['last_synced_at'] = Variable(lastSyncedAt); + } + if (!nullToAbsent || remoteRevision != null) { + map['remote_revision'] = Variable(remoteRevision); + } + map['active_workout_session_id'] = Variable(activeWorkoutSessionId); + map['program_index'] = Variable(programIndex); + map['exercise_index'] = Variable(exerciseIndex); + map['set_index'] = Variable(setIndex); + map['status'] = Variable(status); + if (!nullToAbsent || startedAt != null) { + map['started_at'] = Variable(startedAt); + } + map['accumulated_ms'] = Variable(accumulatedMs); + if (!nullToAbsent || stoppedAt != null) { + map['stopped_at'] = Variable(stoppedAt); + } + if (!nullToAbsent || skippedAt != null) { + map['skipped_at'] = Variable(skippedAt); + } + return map; + } + + ActiveSetTimerStatesCompanion toCompanion(bool nullToAbsent) { + return ActiveSetTimerStatesCompanion( + id: Value(id), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + deletedAt: deletedAt == null && nullToAbsent + ? const Value.absent() + : Value(deletedAt), + schemaVersion: Value(schemaVersion), + syncState: Value(syncState), + localRevision: Value(localRevision), + originDeviceId: Value(originDeviceId), + futureOwnerProfileId: futureOwnerProfileId == null && nullToAbsent + ? const Value.absent() + : Value(futureOwnerProfileId), + lastSyncedAt: lastSyncedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastSyncedAt), + remoteRevision: remoteRevision == null && nullToAbsent + ? const Value.absent() + : Value(remoteRevision), + activeWorkoutSessionId: Value(activeWorkoutSessionId), + programIndex: Value(programIndex), + exerciseIndex: Value(exerciseIndex), + setIndex: Value(setIndex), + status: Value(status), + startedAt: startedAt == null && nullToAbsent + ? const Value.absent() + : Value(startedAt), + accumulatedMs: Value(accumulatedMs), + stoppedAt: stoppedAt == null && nullToAbsent + ? const Value.absent() + : Value(stoppedAt), + skippedAt: skippedAt == null && nullToAbsent + ? const Value.absent() + : Value(skippedAt), + ); + } + + factory ActiveSetTimerState.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ActiveSetTimerState( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + schemaVersion: serializer.fromJson(json['schemaVersion']), + syncState: serializer.fromJson(json['syncState']), + localRevision: serializer.fromJson(json['localRevision']), + originDeviceId: serializer.fromJson(json['originDeviceId']), + futureOwnerProfileId: serializer.fromJson( + json['futureOwnerProfileId'], + ), + lastSyncedAt: serializer.fromJson(json['lastSyncedAt']), + remoteRevision: serializer.fromJson(json['remoteRevision']), + activeWorkoutSessionId: serializer.fromJson( + json['activeWorkoutSessionId'], + ), + programIndex: serializer.fromJson(json['programIndex']), + exerciseIndex: serializer.fromJson(json['exerciseIndex']), + setIndex: serializer.fromJson(json['setIndex']), + status: serializer.fromJson(json['status']), + startedAt: serializer.fromJson(json['startedAt']), + accumulatedMs: serializer.fromJson(json['accumulatedMs']), + stoppedAt: serializer.fromJson(json['stoppedAt']), + skippedAt: serializer.fromJson(json['skippedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'schemaVersion': serializer.toJson(schemaVersion), + 'syncState': serializer.toJson(syncState), + 'localRevision': serializer.toJson(localRevision), + 'originDeviceId': serializer.toJson(originDeviceId), + 'futureOwnerProfileId': serializer.toJson(futureOwnerProfileId), + 'lastSyncedAt': serializer.toJson(lastSyncedAt), + 'remoteRevision': serializer.toJson(remoteRevision), + 'activeWorkoutSessionId': serializer.toJson( + activeWorkoutSessionId, + ), + 'programIndex': serializer.toJson(programIndex), + 'exerciseIndex': serializer.toJson(exerciseIndex), + 'setIndex': serializer.toJson(setIndex), + 'status': serializer.toJson(status), + 'startedAt': serializer.toJson(startedAt), + 'accumulatedMs': serializer.toJson(accumulatedMs), + 'stoppedAt': serializer.toJson(stoppedAt), + 'skippedAt': serializer.toJson(skippedAt), + }; + } + + ActiveSetTimerState copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + int? schemaVersion, + String? syncState, + int? localRevision, + String? originDeviceId, + Value futureOwnerProfileId = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value remoteRevision = const Value.absent(), + String? activeWorkoutSessionId, + int? programIndex, + int? exerciseIndex, + int? setIndex, + String? status, + Value startedAt = const Value.absent(), + int? accumulatedMs, + Value stoppedAt = const Value.absent(), + Value skippedAt = const Value.absent(), + }) => ActiveSetTimerState( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + schemaVersion: schemaVersion ?? this.schemaVersion, + syncState: syncState ?? this.syncState, + localRevision: localRevision ?? this.localRevision, + originDeviceId: originDeviceId ?? this.originDeviceId, + futureOwnerProfileId: futureOwnerProfileId.present + ? futureOwnerProfileId.value + : this.futureOwnerProfileId, + lastSyncedAt: lastSyncedAt.present ? lastSyncedAt.value : this.lastSyncedAt, + remoteRevision: remoteRevision.present + ? remoteRevision.value + : this.remoteRevision, + activeWorkoutSessionId: + activeWorkoutSessionId ?? this.activeWorkoutSessionId, + programIndex: programIndex ?? this.programIndex, + exerciseIndex: exerciseIndex ?? this.exerciseIndex, + setIndex: setIndex ?? this.setIndex, + status: status ?? this.status, + startedAt: startedAt.present ? startedAt.value : this.startedAt, + accumulatedMs: accumulatedMs ?? this.accumulatedMs, + stoppedAt: stoppedAt.present ? stoppedAt.value : this.stoppedAt, + skippedAt: skippedAt.present ? skippedAt.value : this.skippedAt, + ); + ActiveSetTimerState copyWithCompanion(ActiveSetTimerStatesCompanion data) { + return ActiveSetTimerState( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + schemaVersion: data.schemaVersion.present + ? data.schemaVersion.value + : this.schemaVersion, + syncState: data.syncState.present ? data.syncState.value : this.syncState, + localRevision: data.localRevision.present + ? data.localRevision.value + : this.localRevision, + originDeviceId: data.originDeviceId.present + ? data.originDeviceId.value + : this.originDeviceId, + futureOwnerProfileId: data.futureOwnerProfileId.present + ? data.futureOwnerProfileId.value + : this.futureOwnerProfileId, + lastSyncedAt: data.lastSyncedAt.present + ? data.lastSyncedAt.value + : this.lastSyncedAt, + remoteRevision: data.remoteRevision.present + ? data.remoteRevision.value + : this.remoteRevision, + activeWorkoutSessionId: data.activeWorkoutSessionId.present + ? data.activeWorkoutSessionId.value + : this.activeWorkoutSessionId, + programIndex: data.programIndex.present + ? data.programIndex.value + : this.programIndex, + exerciseIndex: data.exerciseIndex.present + ? data.exerciseIndex.value + : this.exerciseIndex, + setIndex: data.setIndex.present ? data.setIndex.value : this.setIndex, + status: data.status.present ? data.status.value : this.status, + startedAt: data.startedAt.present ? data.startedAt.value : this.startedAt, + accumulatedMs: data.accumulatedMs.present + ? data.accumulatedMs.value + : this.accumulatedMs, + stoppedAt: data.stoppedAt.present ? data.stoppedAt.value : this.stoppedAt, + skippedAt: data.skippedAt.present ? data.skippedAt.value : this.skippedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ActiveSetTimerState(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('schemaVersion: $schemaVersion, ') + ..write('syncState: $syncState, ') + ..write('localRevision: $localRevision, ') + ..write('originDeviceId: $originDeviceId, ') + ..write('futureOwnerProfileId: $futureOwnerProfileId, ') + ..write('lastSyncedAt: $lastSyncedAt, ') + ..write('remoteRevision: $remoteRevision, ') + ..write('activeWorkoutSessionId: $activeWorkoutSessionId, ') + ..write('programIndex: $programIndex, ') + ..write('exerciseIndex: $exerciseIndex, ') + ..write('setIndex: $setIndex, ') + ..write('status: $status, ') + ..write('startedAt: $startedAt, ') + ..write('accumulatedMs: $accumulatedMs, ') + ..write('stoppedAt: $stoppedAt, ') + ..write('skippedAt: $skippedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + deletedAt, + schemaVersion, + syncState, + localRevision, + originDeviceId, + futureOwnerProfileId, + lastSyncedAt, + remoteRevision, + activeWorkoutSessionId, + programIndex, + exerciseIndex, + setIndex, + status, + startedAt, + accumulatedMs, + stoppedAt, + skippedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ActiveSetTimerState && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.schemaVersion == this.schemaVersion && + other.syncState == this.syncState && + other.localRevision == this.localRevision && + other.originDeviceId == this.originDeviceId && + other.futureOwnerProfileId == this.futureOwnerProfileId && + other.lastSyncedAt == this.lastSyncedAt && + other.remoteRevision == this.remoteRevision && + other.activeWorkoutSessionId == this.activeWorkoutSessionId && + other.programIndex == this.programIndex && + other.exerciseIndex == this.exerciseIndex && + other.setIndex == this.setIndex && + other.status == this.status && + other.startedAt == this.startedAt && + other.accumulatedMs == this.accumulatedMs && + other.stoppedAt == this.stoppedAt && + other.skippedAt == this.skippedAt); +} + +class ActiveSetTimerStatesCompanion + extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value schemaVersion; + final Value syncState; + final Value localRevision; + final Value originDeviceId; + final Value futureOwnerProfileId; + final Value lastSyncedAt; + final Value remoteRevision; + final Value activeWorkoutSessionId; + final Value programIndex; + final Value exerciseIndex; + final Value setIndex; + final Value status; + final Value startedAt; + final Value accumulatedMs; + final Value stoppedAt; + final Value skippedAt; + final Value rowid; + const ActiveSetTimerStatesCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.schemaVersion = const Value.absent(), + this.syncState = const Value.absent(), + this.localRevision = const Value.absent(), + this.originDeviceId = const Value.absent(), + this.futureOwnerProfileId = const Value.absent(), + this.lastSyncedAt = const Value.absent(), + this.remoteRevision = const Value.absent(), + this.activeWorkoutSessionId = const Value.absent(), + this.programIndex = const Value.absent(), + this.exerciseIndex = const Value.absent(), + this.setIndex = const Value.absent(), + this.status = const Value.absent(), + this.startedAt = const Value.absent(), + this.accumulatedMs = const Value.absent(), + this.stoppedAt = const Value.absent(), + this.skippedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ActiveSetTimerStatesCompanion.insert({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + this.deletedAt = const Value.absent(), + this.schemaVersion = const Value.absent(), + required String syncState, + required int localRevision, + required String originDeviceId, + this.futureOwnerProfileId = const Value.absent(), + this.lastSyncedAt = const Value.absent(), + this.remoteRevision = const Value.absent(), + required String activeWorkoutSessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required String status, + this.startedAt = const Value.absent(), + required int accumulatedMs, + this.stoppedAt = const Value.absent(), + this.skippedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt), + syncState = Value(syncState), + localRevision = Value(localRevision), + originDeviceId = Value(originDeviceId), + activeWorkoutSessionId = Value(activeWorkoutSessionId), + programIndex = Value(programIndex), + exerciseIndex = Value(exerciseIndex), + setIndex = Value(setIndex), + status = Value(status), + accumulatedMs = Value(accumulatedMs); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? schemaVersion, + Expression? syncState, + Expression? localRevision, + Expression? originDeviceId, + Expression? futureOwnerProfileId, + Expression? lastSyncedAt, + Expression? remoteRevision, + Expression? activeWorkoutSessionId, + Expression? programIndex, + Expression? exerciseIndex, + Expression? setIndex, + Expression? status, + Expression? startedAt, + Expression? accumulatedMs, + Expression? stoppedAt, + Expression? skippedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (schemaVersion != null) 'schema_version': schemaVersion, + if (syncState != null) 'sync_state': syncState, + if (localRevision != null) 'local_revision': localRevision, + if (originDeviceId != null) 'origin_device_id': originDeviceId, + if (futureOwnerProfileId != null) + 'future_owner_profile_id': futureOwnerProfileId, + if (lastSyncedAt != null) 'last_synced_at': lastSyncedAt, + if (remoteRevision != null) 'remote_revision': remoteRevision, + if (activeWorkoutSessionId != null) + 'active_workout_session_id': activeWorkoutSessionId, + if (programIndex != null) 'program_index': programIndex, + if (exerciseIndex != null) 'exercise_index': exerciseIndex, + if (setIndex != null) 'set_index': setIndex, + if (status != null) 'status': status, + if (startedAt != null) 'started_at': startedAt, + if (accumulatedMs != null) 'accumulated_ms': accumulatedMs, + if (stoppedAt != null) 'stopped_at': stoppedAt, + if (skippedAt != null) 'skipped_at': skippedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ActiveSetTimerStatesCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? schemaVersion, + Value? syncState, + Value? localRevision, + Value? originDeviceId, + Value? futureOwnerProfileId, + Value? lastSyncedAt, + Value? remoteRevision, + Value? activeWorkoutSessionId, + Value? programIndex, + Value? exerciseIndex, + Value? setIndex, + Value? status, + Value? startedAt, + Value? accumulatedMs, + Value? stoppedAt, + Value? skippedAt, + Value? rowid, + }) { + return ActiveSetTimerStatesCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + schemaVersion: schemaVersion ?? this.schemaVersion, + syncState: syncState ?? this.syncState, + localRevision: localRevision ?? this.localRevision, + originDeviceId: originDeviceId ?? this.originDeviceId, + futureOwnerProfileId: futureOwnerProfileId ?? this.futureOwnerProfileId, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + remoteRevision: remoteRevision ?? this.remoteRevision, + activeWorkoutSessionId: + activeWorkoutSessionId ?? this.activeWorkoutSessionId, + programIndex: programIndex ?? this.programIndex, + exerciseIndex: exerciseIndex ?? this.exerciseIndex, + setIndex: setIndex ?? this.setIndex, + status: status ?? this.status, + startedAt: startedAt ?? this.startedAt, + accumulatedMs: accumulatedMs ?? this.accumulatedMs, + stoppedAt: stoppedAt ?? this.stoppedAt, + skippedAt: skippedAt ?? this.skippedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (schemaVersion.present) { + map['schema_version'] = Variable(schemaVersion.value); + } + if (syncState.present) { + map['sync_state'] = Variable(syncState.value); + } + if (localRevision.present) { + map['local_revision'] = Variable(localRevision.value); + } + if (originDeviceId.present) { + map['origin_device_id'] = Variable(originDeviceId.value); + } + if (futureOwnerProfileId.present) { + map['future_owner_profile_id'] = Variable( + futureOwnerProfileId.value, + ); + } + if (lastSyncedAt.present) { + map['last_synced_at'] = Variable(lastSyncedAt.value); + } + if (remoteRevision.present) { + map['remote_revision'] = Variable(remoteRevision.value); + } + if (activeWorkoutSessionId.present) { + map['active_workout_session_id'] = Variable( + activeWorkoutSessionId.value, + ); + } + if (programIndex.present) { + map['program_index'] = Variable(programIndex.value); + } + if (exerciseIndex.present) { + map['exercise_index'] = Variable(exerciseIndex.value); + } + if (setIndex.present) { + map['set_index'] = Variable(setIndex.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (startedAt.present) { + map['started_at'] = Variable(startedAt.value); + } + if (accumulatedMs.present) { + map['accumulated_ms'] = Variable(accumulatedMs.value); + } + if (stoppedAt.present) { + map['stopped_at'] = Variable(stoppedAt.value); + } + if (skippedAt.present) { + map['skipped_at'] = Variable(skippedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ActiveSetTimerStatesCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('schemaVersion: $schemaVersion, ') + ..write('syncState: $syncState, ') + ..write('localRevision: $localRevision, ') + ..write('originDeviceId: $originDeviceId, ') + ..write('futureOwnerProfileId: $futureOwnerProfileId, ') + ..write('lastSyncedAt: $lastSyncedAt, ') + ..write('remoteRevision: $remoteRevision, ') + ..write('activeWorkoutSessionId: $activeWorkoutSessionId, ') + ..write('programIndex: $programIndex, ') + ..write('exerciseIndex: $exerciseIndex, ') + ..write('setIndex: $setIndex, ') + ..write('status: $status, ') + ..write('startedAt: $startedAt, ') + ..write('accumulatedMs: $accumulatedMs, ') + ..write('stoppedAt: $stoppedAt, ') + ..write('skippedAt: $skippedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $ActiveSetResultsTable extends ActiveSetResults with TableInfo<$ActiveSetResultsTable, ActiveSetResult> { @override @@ -28437,6 +29707,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { ); late final $ActiveScoreStopwatchStatesTable activeScoreStopwatchStates = $ActiveScoreStopwatchStatesTable(this); + late final $ActiveSetTimerStatesTable activeSetTimerStates = + $ActiveSetTimerStatesTable(this); late final $ActiveSetResultsTable activeSetResults = $ActiveSetResultsTable( this, ); @@ -28486,6 +29758,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { activeExerciseStepResults, activeRestStates, activeScoreStopwatchStates, + activeSetTimerStates, activeSetResults, changeLogEntries, mediaAssets, @@ -29421,6 +30694,38 @@ final class $$ActiveWorkoutSessionsTableReferences ); } + static MultiTypedResultKey< + $ActiveSetTimerStatesTable, + List + > + _activeSetTimerStatesRefsTable( + _$AppDatabase db, + ) => MultiTypedResultKey.fromTable( + db.activeSetTimerStates, + aliasName: + 'active_workout_sessions__id__active_set_timer_states__active_workout_session_id', + ); + + $$ActiveSetTimerStatesTableProcessedTableManager + get activeSetTimerStatesRefs { + final manager = + $$ActiveSetTimerStatesTableTableManager( + $_db, + $_db.activeSetTimerStates, + ).filter( + (f) => f.activeWorkoutSessionId.id.sqlEquals( + $_itemColumn('id')!, + ), + ); + + final cache = $_typedResult.readTableOrNull( + _activeSetTimerStatesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + static MultiTypedResultKey<$ActiveSetResultsTable, List> _activeSetResultsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.activeSetResults, @@ -29713,6 +31018,31 @@ class $$ActiveWorkoutSessionsTableFilterComposer return f(composer); } + Expression activeSetTimerStatesRefs( + Expression Function($$ActiveSetTimerStatesTableFilterComposer f) f, + ) { + final $$ActiveSetTimerStatesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.activeSetTimerStates, + getReferencedColumn: (t) => t.activeWorkoutSessionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveSetTimerStatesTableFilterComposer( + $db: $db, + $table: $db.activeSetTimerStates, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression activeSetResultsRefs( Expression Function($$ActiveSetResultsTableFilterComposer f) f, ) { @@ -30133,6 +31463,32 @@ class $$ActiveWorkoutSessionsTableAnnotationComposer return f(composer); } + Expression activeSetTimerStatesRefs( + Expression Function($$ActiveSetTimerStatesTableAnnotationComposer a) f, + ) { + final $$ActiveSetTimerStatesTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.activeSetTimerStates, + getReferencedColumn: (t) => t.activeWorkoutSessionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveSetTimerStatesTableAnnotationComposer( + $db: $db, + $table: $db.activeSetTimerStates, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression activeSetResultsRefs( Expression Function($$ActiveSetResultsTableAnnotationComposer a) f, ) { @@ -30203,6 +31559,7 @@ class $$ActiveWorkoutSessionsTableTableManager bool activeExerciseStepResultsRefs, bool activeRestStatesRefs, bool activeScoreStopwatchStatesRefs, + bool activeSetTimerStatesRefs, bool activeSetResultsRefs, bool workoutHistoriesRefs, }) @@ -30345,6 +31702,7 @@ class $$ActiveWorkoutSessionsTableTableManager activeExerciseStepResultsRefs = false, activeRestStatesRefs = false, activeScoreStopwatchStatesRefs = false, + activeSetTimerStatesRefs = false, activeSetResultsRefs = false, workoutHistoriesRefs = false, }) { @@ -30358,6 +31716,7 @@ class $$ActiveWorkoutSessionsTableTableManager if (activeRestStatesRefs) db.activeRestStates, if (activeScoreStopwatchStatesRefs) db.activeScoreStopwatchStates, + if (activeSetTimerStatesRefs) db.activeSetTimerStates, if (activeSetResultsRefs) db.activeSetResults, if (workoutHistoriesRefs) db.workoutHistories, ], @@ -30488,6 +31847,28 @@ class $$ActiveWorkoutSessionsTableTableManager ), typedResults: items, ), + if (activeSetTimerStatesRefs) + await $_getPrefetchedData< + ActiveWorkoutSession, + $ActiveWorkoutSessionsTable, + ActiveSetTimerState + >( + currentTable: table, + referencedTable: + $$ActiveWorkoutSessionsTableReferences + ._activeSetTimerStatesRefsTable(db), + managerFromTypedResult: (p0) => + $$ActiveWorkoutSessionsTableReferences( + db, + table, + p0, + ).activeSetTimerStatesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.activeWorkoutSessionId == item.id, + ), + typedResults: items, + ), if (activeSetResultsRefs) await $_getPrefetchedData< ActiveWorkoutSession, @@ -30559,6 +31940,7 @@ typedef $$ActiveWorkoutSessionsTableProcessedTableManager = bool activeExerciseStepResultsRefs, bool activeRestStatesRefs, bool activeScoreStopwatchStatesRefs, + bool activeSetTimerStatesRefs, bool activeSetResultsRefs, bool workoutHistoriesRefs, }) @@ -32301,6 +33683,8 @@ typedef $$ActiveRestStatesTableCreateCompanionBuilder = required DateTime startedAt, Value endedAt, Value skippedAt, + Value pausedAt, + Value accumulatedPausedMs, Value rowid, }); typedef $$ActiveRestStatesTableUpdateCompanionBuilder = @@ -32325,6 +33709,8 @@ typedef $$ActiveRestStatesTableUpdateCompanionBuilder = Value startedAt, Value endedAt, Value skippedAt, + Value pausedAt, + Value accumulatedPausedMs, Value rowid, }); @@ -32464,6 +33850,16 @@ class $$ActiveRestStatesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get pausedAt => $composableBuilder( + column: $table.pausedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get accumulatedPausedMs => $composableBuilder( + column: $table.accumulatedPausedMs, + builder: (column) => ColumnFilters(column), + ); + $$ActiveWorkoutSessionsTableFilterComposer get activeWorkoutSessionId { final $$ActiveWorkoutSessionsTableFilterComposer composer = $composerBuilder( @@ -32593,6 +33989,16 @@ class $$ActiveRestStatesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get pausedAt => $composableBuilder( + column: $table.pausedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get accumulatedPausedMs => $composableBuilder( + column: $table.accumulatedPausedMs, + builder: (column) => ColumnOrderings(column), + ); + $$ActiveWorkoutSessionsTableOrderingComposer get activeWorkoutSessionId { final $$ActiveWorkoutSessionsTableOrderingComposer composer = $composerBuilder( @@ -32706,6 +34112,14 @@ class $$ActiveRestStatesTableAnnotationComposer GeneratedColumn get skippedAt => $composableBuilder(column: $table.skippedAt, builder: (column) => column); + GeneratedColumn get pausedAt => + $composableBuilder(column: $table.pausedAt, builder: (column) => column); + + GeneratedColumn get accumulatedPausedMs => $composableBuilder( + column: $table.accumulatedPausedMs, + builder: (column) => column, + ); + $$ActiveWorkoutSessionsTableAnnotationComposer get activeWorkoutSessionId { final $$ActiveWorkoutSessionsTableAnnotationComposer composer = $composerBuilder( @@ -32781,6 +34195,8 @@ class $$ActiveRestStatesTableTableManager Value startedAt = const Value.absent(), Value endedAt = const Value.absent(), Value skippedAt = const Value.absent(), + Value pausedAt = const Value.absent(), + Value accumulatedPausedMs = const Value.absent(), Value rowid = const Value.absent(), }) => ActiveRestStatesCompanion( id: id, @@ -32803,6 +34219,8 @@ class $$ActiveRestStatesTableTableManager startedAt: startedAt, endedAt: endedAt, skippedAt: skippedAt, + pausedAt: pausedAt, + accumulatedPausedMs: accumulatedPausedMs, rowid: rowid, ), createCompanionCallback: @@ -32827,6 +34245,8 @@ class $$ActiveRestStatesTableTableManager required DateTime startedAt, Value endedAt = const Value.absent(), Value skippedAt = const Value.absent(), + Value pausedAt = const Value.absent(), + Value accumulatedPausedMs = const Value.absent(), Value rowid = const Value.absent(), }) => ActiveRestStatesCompanion.insert( id: id, @@ -32849,6 +34269,8 @@ class $$ActiveRestStatesTableTableManager startedAt: startedAt, endedAt: endedAt, skippedAt: skippedAt, + pausedAt: pausedAt, + accumulatedPausedMs: accumulatedPausedMs, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -33554,6 +34976,653 @@ typedef $$ActiveScoreStopwatchStatesTableProcessedTableManager = ActiveScoreStopwatchState, PrefetchHooks Function({bool activeWorkoutSessionId}) >; +typedef $$ActiveSetTimerStatesTableCreateCompanionBuilder = + ActiveSetTimerStatesCompanion Function({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + Value deletedAt, + Value schemaVersion, + required String syncState, + required int localRevision, + required String originDeviceId, + Value futureOwnerProfileId, + Value lastSyncedAt, + Value remoteRevision, + required String activeWorkoutSessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required String status, + Value startedAt, + required int accumulatedMs, + Value stoppedAt, + Value skippedAt, + Value rowid, + }); +typedef $$ActiveSetTimerStatesTableUpdateCompanionBuilder = + ActiveSetTimerStatesCompanion Function({ + Value id, + Value createdAt, + Value updatedAt, + Value deletedAt, + Value schemaVersion, + Value syncState, + Value localRevision, + Value originDeviceId, + Value futureOwnerProfileId, + Value lastSyncedAt, + Value remoteRevision, + Value activeWorkoutSessionId, + Value programIndex, + Value exerciseIndex, + Value setIndex, + Value status, + Value startedAt, + Value accumulatedMs, + Value stoppedAt, + Value skippedAt, + Value rowid, + }); + +final class $$ActiveSetTimerStatesTableReferences + extends + BaseReferences< + _$AppDatabase, + $ActiveSetTimerStatesTable, + ActiveSetTimerState + > { + $$ActiveSetTimerStatesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $ActiveWorkoutSessionsTable _activeWorkoutSessionIdTable( + _$AppDatabase db, + ) => db.activeWorkoutSessions.createAlias( + 'active_set_timer_states__active_workout_session_id__active_workout_sessions__id', + ); + + $$ActiveWorkoutSessionsTableProcessedTableManager get activeWorkoutSessionId { + final $_column = $_itemColumn('active_workout_session_id')!; + + final manager = $$ActiveWorkoutSessionsTableTableManager( + $_db, + $_db.activeWorkoutSessions, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull( + _activeWorkoutSessionIdTable($_db), + ); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$ActiveSetTimerStatesTableFilterComposer + extends Composer<_$AppDatabase, $ActiveSetTimerStatesTable> { + $$ActiveSetTimerStatesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get schemaVersion => $composableBuilder( + column: $table.schemaVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get syncState => $composableBuilder( + column: $table.syncState, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get localRevision => $composableBuilder( + column: $table.localRevision, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get originDeviceId => $composableBuilder( + column: $table.originDeviceId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get futureOwnerProfileId => $composableBuilder( + column: $table.futureOwnerProfileId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get remoteRevision => $composableBuilder( + column: $table.remoteRevision, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get setIndex => $composableBuilder( + column: $table.setIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get startedAt => $composableBuilder( + column: $table.startedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get accumulatedMs => $composableBuilder( + column: $table.accumulatedMs, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get stoppedAt => $composableBuilder( + column: $table.stoppedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get skippedAt => $composableBuilder( + column: $table.skippedAt, + builder: (column) => ColumnFilters(column), + ); + + $$ActiveWorkoutSessionsTableFilterComposer get activeWorkoutSessionId { + final $$ActiveWorkoutSessionsTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.activeWorkoutSessionId, + referencedTable: $db.activeWorkoutSessions, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutSessionsTableFilterComposer( + $db: $db, + $table: $db.activeWorkoutSessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ActiveSetTimerStatesTableOrderingComposer + extends Composer<_$AppDatabase, $ActiveSetTimerStatesTable> { + $$ActiveSetTimerStatesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get schemaVersion => $composableBuilder( + column: $table.schemaVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get syncState => $composableBuilder( + column: $table.syncState, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get localRevision => $composableBuilder( + column: $table.localRevision, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get originDeviceId => $composableBuilder( + column: $table.originDeviceId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get futureOwnerProfileId => $composableBuilder( + column: $table.futureOwnerProfileId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get remoteRevision => $composableBuilder( + column: $table.remoteRevision, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get setIndex => $composableBuilder( + column: $table.setIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get startedAt => $composableBuilder( + column: $table.startedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get accumulatedMs => $composableBuilder( + column: $table.accumulatedMs, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get stoppedAt => $composableBuilder( + column: $table.stoppedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get skippedAt => $composableBuilder( + column: $table.skippedAt, + builder: (column) => ColumnOrderings(column), + ); + + $$ActiveWorkoutSessionsTableOrderingComposer get activeWorkoutSessionId { + final $$ActiveWorkoutSessionsTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.activeWorkoutSessionId, + referencedTable: $db.activeWorkoutSessions, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutSessionsTableOrderingComposer( + $db: $db, + $table: $db.activeWorkoutSessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ActiveSetTimerStatesTableAnnotationComposer + extends Composer<_$AppDatabase, $ActiveSetTimerStatesTable> { + $$ActiveSetTimerStatesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get deletedAt => + $composableBuilder(column: $table.deletedAt, builder: (column) => column); + + GeneratedColumn get schemaVersion => $composableBuilder( + column: $table.schemaVersion, + builder: (column) => column, + ); + + GeneratedColumn get syncState => + $composableBuilder(column: $table.syncState, builder: (column) => column); + + GeneratedColumn get localRevision => $composableBuilder( + column: $table.localRevision, + builder: (column) => column, + ); + + GeneratedColumn get originDeviceId => $composableBuilder( + column: $table.originDeviceId, + builder: (column) => column, + ); + + GeneratedColumn get futureOwnerProfileId => $composableBuilder( + column: $table.futureOwnerProfileId, + builder: (column) => column, + ); + + GeneratedColumn get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => column, + ); + + GeneratedColumn get remoteRevision => $composableBuilder( + column: $table.remoteRevision, + builder: (column) => column, + ); + + GeneratedColumn get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => column, + ); + + GeneratedColumn get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => column, + ); + + GeneratedColumn get setIndex => + $composableBuilder(column: $table.setIndex, builder: (column) => column); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get startedAt => + $composableBuilder(column: $table.startedAt, builder: (column) => column); + + GeneratedColumn get accumulatedMs => $composableBuilder( + column: $table.accumulatedMs, + builder: (column) => column, + ); + + GeneratedColumn get stoppedAt => + $composableBuilder(column: $table.stoppedAt, builder: (column) => column); + + GeneratedColumn get skippedAt => + $composableBuilder(column: $table.skippedAt, builder: (column) => column); + + $$ActiveWorkoutSessionsTableAnnotationComposer get activeWorkoutSessionId { + final $$ActiveWorkoutSessionsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.activeWorkoutSessionId, + referencedTable: $db.activeWorkoutSessions, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutSessionsTableAnnotationComposer( + $db: $db, + $table: $db.activeWorkoutSessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ActiveSetTimerStatesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ActiveSetTimerStatesTable, + ActiveSetTimerState, + $$ActiveSetTimerStatesTableFilterComposer, + $$ActiveSetTimerStatesTableOrderingComposer, + $$ActiveSetTimerStatesTableAnnotationComposer, + $$ActiveSetTimerStatesTableCreateCompanionBuilder, + $$ActiveSetTimerStatesTableUpdateCompanionBuilder, + (ActiveSetTimerState, $$ActiveSetTimerStatesTableReferences), + ActiveSetTimerState, + PrefetchHooks Function({bool activeWorkoutSessionId}) + > { + $$ActiveSetTimerStatesTableTableManager( + _$AppDatabase db, + $ActiveSetTimerStatesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ActiveSetTimerStatesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ActiveSetTimerStatesTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$ActiveSetTimerStatesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value deletedAt = const Value.absent(), + Value schemaVersion = const Value.absent(), + Value syncState = const Value.absent(), + Value localRevision = const Value.absent(), + Value originDeviceId = const Value.absent(), + Value futureOwnerProfileId = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value remoteRevision = const Value.absent(), + Value activeWorkoutSessionId = const Value.absent(), + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value status = const Value.absent(), + Value startedAt = const Value.absent(), + Value accumulatedMs = const Value.absent(), + Value stoppedAt = const Value.absent(), + Value skippedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ActiveSetTimerStatesCompanion( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + schemaVersion: schemaVersion, + syncState: syncState, + localRevision: localRevision, + originDeviceId: originDeviceId, + futureOwnerProfileId: futureOwnerProfileId, + lastSyncedAt: lastSyncedAt, + remoteRevision: remoteRevision, + activeWorkoutSessionId: activeWorkoutSessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + status: status, + startedAt: startedAt, + accumulatedMs: accumulatedMs, + stoppedAt: stoppedAt, + skippedAt: skippedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + Value deletedAt = const Value.absent(), + Value schemaVersion = const Value.absent(), + required String syncState, + required int localRevision, + required String originDeviceId, + Value futureOwnerProfileId = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value remoteRevision = const Value.absent(), + required String activeWorkoutSessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required String status, + Value startedAt = const Value.absent(), + required int accumulatedMs, + Value stoppedAt = const Value.absent(), + Value skippedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ActiveSetTimerStatesCompanion.insert( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + schemaVersion: schemaVersion, + syncState: syncState, + localRevision: localRevision, + originDeviceId: originDeviceId, + futureOwnerProfileId: futureOwnerProfileId, + lastSyncedAt: lastSyncedAt, + remoteRevision: remoteRevision, + activeWorkoutSessionId: activeWorkoutSessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + status: status, + startedAt: startedAt, + accumulatedMs: accumulatedMs, + stoppedAt: stoppedAt, + skippedAt: skippedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$ActiveSetTimerStatesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({activeWorkoutSessionId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (activeWorkoutSessionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.activeWorkoutSessionId, + referencedTable: + $$ActiveSetTimerStatesTableReferences + ._activeWorkoutSessionIdTable(db), + referencedColumn: + $$ActiveSetTimerStatesTableReferences + ._activeWorkoutSessionIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$ActiveSetTimerStatesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ActiveSetTimerStatesTable, + ActiveSetTimerState, + $$ActiveSetTimerStatesTableFilterComposer, + $$ActiveSetTimerStatesTableOrderingComposer, + $$ActiveSetTimerStatesTableAnnotationComposer, + $$ActiveSetTimerStatesTableCreateCompanionBuilder, + $$ActiveSetTimerStatesTableUpdateCompanionBuilder, + (ActiveSetTimerState, $$ActiveSetTimerStatesTableReferences), + ActiveSetTimerState, + PrefetchHooks Function({bool activeWorkoutSessionId}) + >; typedef $$ActiveSetResultsTableCreateCompanionBuilder = ActiveSetResultsCompanion Function({ required String id, @@ -45804,6 +47873,8 @@ class $AppDatabaseManager { _db, _db.activeScoreStopwatchStates, ); + $$ActiveSetTimerStatesTableTableManager get activeSetTimerStates => + $$ActiveSetTimerStatesTableTableManager(_db, _db.activeSetTimerStates); $$ActiveSetResultsTableTableManager get activeSetResults => $$ActiveSetResultsTableTableManager(_db, _db.activeSetResults); $$ChangeLogEntriesTableTableManager get changeLogEntries => diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index cd27ddf..bfe76a3 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -998,6 +998,17 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { ); } + @override + Future saveSetTimerState(domain.ActiveSetTimerState state) async { + await _upsertWithChangeLog( + database: database, + tableName: 'active_set_timer_states', + entityType: 'ActiveSetTimerState', + metadata: state.metadata, + write: () => _upsertActiveSetTimerState(database, state), + ); + } + @override Future saveRestState(domain.ActiveRestState restState) async { await _upsertWithChangeLog( @@ -1005,18 +1016,24 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { tableName: 'active_rest_states', entityType: 'ActiveRestState', metadata: restState.metadata, - write: () => database - .into(database.activeRestStates) - .insertOnConflictUpdate(_activeRestStateCompanion(restState)), + write: () async { + await database + .into(database.activeRestStates) + .insertOnConflictUpdate(_activeRestStateCompanion(restState)); + await _updateActiveRestPauseFields(database, 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); + final row = await database + .customSelect( + 'SELECT * FROM active_rest_states WHERE id = ? LIMIT 1', + variables: [Variable(id)], + ) + .getSingleOrNull(); + return row == null ? null : _activeRestStateFromCustomRow(row); } @override @@ -1122,6 +1139,33 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { return row == null ? null : _activeScoreStopwatchStateFromRow(row); } + @override + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + final row = await database + .customSelect( + 'SELECT * FROM active_set_timer_states ' + 'WHERE active_workout_session_id = ? ' + 'AND program_index = ? ' + 'AND exercise_index = ? ' + 'AND set_index = ? ' + 'AND deleted_at IS NULL ' + 'LIMIT 1', + variables: [ + Variable(sessionId), + Variable(programIndex), + Variable(exerciseIndex), + Variable(setIndex), + ], + ) + .getSingleOrNull(); + return row == null ? null : _activeSetTimerStateFromCustomRow(row); + } + @override Future findExerciseStepProgressState({ @@ -1158,6 +1202,22 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { return rows.map(_activeSetResultFromRow).toList(); } + @override + Future> listSetTimerStates( + String sessionId, + ) async { + final rows = await database + .customSelect( + 'SELECT * FROM active_set_timer_states ' + 'WHERE active_workout_session_id = ? ' + 'AND deleted_at IS NULL ' + 'ORDER BY program_index, exercise_index, set_index', + variables: [Variable(sessionId)], + ) + .get(); + return rows.map(_activeSetTimerStateFromCustomRow).toList(); + } + @override Future> listExerciseStepProgressStates(String sessionId) async { @@ -1201,12 +1261,15 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { @override Future> listRestStates(String sessionId) async { - final rows = - await (database.select(database.activeRestStates) - ..where((table) => table.activeWorkoutSessionId.equals(sessionId)) - ..orderBy([(table) => OrderingTerm.asc(table.startedAt)])) - .get(); - return rows.map(_activeRestStateFromRow).toList(); + final rows = await database + .customSelect( + 'SELECT * FROM active_rest_states ' + 'WHERE active_workout_session_id = ? ' + 'ORDER BY started_at', + variables: [Variable(sessionId)], + ) + .get(); + return rows.map(_activeRestStateFromCustomRow).toList(); } @override @@ -1839,6 +1902,47 @@ domain.EntityMetadata _metadataFromRow(dynamic row) { ); } +domain.EntityMetadata _metadataFromData(Map data) { + return domain.EntityMetadata( + id: data['id'] as String, + createdAt: _dateTimeFromData(data, 'created_at'), + updatedAt: _dateTimeFromData(data, 'updated_at'), + deletedAt: _dateTimeOrNullFromData(data, 'deleted_at'), + schemaVersion: data['schema_version'] as int, + syncState: _syncStateFromDb(data['sync_state'] as String), + localRevision: data['local_revision'] as int, + originDeviceId: data['origin_device_id'] as String, + futureOwnerProfileId: data['future_owner_profile_id'] as String?, + lastSyncedAt: _dateTimeOrNullFromData(data, 'last_synced_at'), + remoteRevision: data['remote_revision'] as String?, + ); +} + +DateTime _dateTimeFromData(Map data, String key) { + final value = data[key]; + if (value is DateTime) { + return value.toUtc(); + } + if (value is int) { + return DateTime.fromMillisecondsSinceEpoch(value, isUtc: true); + } + throw domain.DomainException('Invalid timestamp column: $key'); +} + +DateTime? _dateTimeOrNullFromData(Map data, String key) { + final value = data[key]; + if (value == null) { + return null; + } + if (value is DateTime) { + return value.toUtc(); + } + if (value is int) { + return DateTime.fromMillisecondsSinceEpoch(value, isUtc: true); + } + throw domain.DomainException('Invalid timestamp column: $key'); +} + List _metadataValues(domain.EntityMetadata metadata) => [ Value(metadata.id), Value(metadata.createdAt.toUtc()), @@ -2458,18 +2562,112 @@ db.ActiveRestStatesCompanion _activeRestStateCompanion( ); } -domain.ActiveRestState _activeRestStateFromRow(db.ActiveRestState row) { +Future _updateActiveRestPauseFields( + db.AppDatabase database, + domain.ActiveRestState restState, +) async { + await database.customUpdate( + 'UPDATE active_rest_states SET paused_at = ?, accumulated_paused_ms = ? ' + 'WHERE id = ?', + variables: [ + Variable(_utcOrNull(restState.pausedAt)), + Variable(restState.accumulatedPausedMs), + Variable(restState.metadata.id), + ], + ); +} + +Future _upsertActiveSetTimerState( + db.AppDatabase database, + domain.ActiveSetTimerState state, +) async { + final metadata = state.metadata; + await database.customInsert( + 'INSERT INTO active_set_timer_states (' + 'id, created_at, updated_at, deleted_at, schema_version, sync_state, ' + 'local_revision, origin_device_id, future_owner_profile_id, ' + 'last_synced_at, remote_revision, active_workout_session_id, ' + 'program_index, exercise_index, set_index, status, started_at, ' + 'accumulated_ms, stopped_at, skipped_at' + ') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(active_workout_session_id, program_index, exercise_index, ' + 'set_index) DO UPDATE SET ' + 'id = excluded.id, ' + 'created_at = excluded.created_at, ' + 'updated_at = excluded.updated_at, ' + 'deleted_at = excluded.deleted_at, ' + 'schema_version = excluded.schema_version, ' + 'sync_state = excluded.sync_state, ' + 'local_revision = excluded.local_revision, ' + 'origin_device_id = excluded.origin_device_id, ' + 'future_owner_profile_id = excluded.future_owner_profile_id, ' + 'last_synced_at = excluded.last_synced_at, ' + 'remote_revision = excluded.remote_revision, ' + 'active_workout_session_id = excluded.active_workout_session_id, ' + 'program_index = excluded.program_index, ' + 'exercise_index = excluded.exercise_index, ' + 'set_index = excluded.set_index, ' + 'status = excluded.status, ' + 'started_at = excluded.started_at, ' + 'accumulated_ms = excluded.accumulated_ms, ' + 'stopped_at = excluded.stopped_at, ' + 'skipped_at = excluded.skipped_at', + variables: [ + Variable(metadata.id), + Variable(metadata.createdAt.toUtc()), + Variable(metadata.updatedAt.toUtc()), + Variable(_utcOrNull(metadata.deletedAt)), + Variable(metadata.schemaVersion), + Variable(_syncStateToDb(metadata.syncState)), + Variable(metadata.localRevision), + Variable(metadata.originDeviceId), + Variable(metadata.futureOwnerProfileId), + Variable(_utcOrNull(metadata.lastSyncedAt)), + Variable(metadata.remoteRevision), + Variable(state.activeWorkoutSessionId), + Variable(state.programIndex), + Variable(state.exerciseIndex), + Variable(state.setIndex), + Variable(_setTimerStatusToDb(state.status)), + Variable(_utcOrNull(state.startedAt)), + Variable(state.accumulatedMs), + Variable(_utcOrNull(state.stoppedAt)), + Variable(_utcOrNull(state.skippedAt)), + ], + ); +} + +domain.ActiveSetTimerState _activeSetTimerStateFromCustomRow(QueryRow row) { + final data = row.data; + return domain.ActiveSetTimerState( + metadata: _metadataFromData(data), + activeWorkoutSessionId: data['active_workout_session_id'] as String, + programIndex: data['program_index'] as int, + exerciseIndex: data['exercise_index'] as int, + setIndex: data['set_index'] as int, + status: _setTimerStatusFromDb(data['status'] as String), + startedAt: _dateTimeOrNullFromData(data, 'started_at'), + accumulatedMs: data['accumulated_ms'] as int, + stoppedAt: _dateTimeOrNullFromData(data, 'stopped_at'), + skippedAt: _dateTimeOrNullFromData(data, 'skipped_at'), + ); +} + +domain.ActiveRestState _activeRestStateFromCustomRow(QueryRow row) { + final data = row.data; return domain.ActiveRestState( - metadata: _metadataFromRow(row), - activeWorkoutSessionId: row.activeWorkoutSessionId, - afterProgramIndex: row.afterProgramIndex, - afterExerciseIndex: row.afterExerciseIndex, - afterSetIndex: row.afterSetIndex, - plannedRestSeconds: row.plannedRestSeconds, - adjustedRestSeconds: row.adjustedRestSeconds, - startedAt: _utc(row.startedAt), - endedAt: _utcOrNull(row.endedAt), - skippedAt: _utcOrNull(row.skippedAt), + metadata: _metadataFromData(data), + activeWorkoutSessionId: data['active_workout_session_id'] as String, + afterProgramIndex: data['after_program_index'] as int, + afterExerciseIndex: data['after_exercise_index'] as int, + afterSetIndex: data['after_set_index'] as int, + plannedRestSeconds: data['planned_rest_seconds'] as int, + adjustedRestSeconds: data['adjusted_rest_seconds'] as int, + startedAt: _dateTimeFromData(data, 'started_at'), + endedAt: _dateTimeOrNullFromData(data, 'ended_at'), + skippedAt: _dateTimeOrNullFromData(data, 'skipped_at'), + pausedAt: _dateTimeOrNullFromData(data, 'paused_at'), + accumulatedPausedMs: data['accumulated_paused_ms'] as int? ?? 0, ); } @@ -3509,18 +3707,39 @@ domain.SetResultStatus _setResultStatusFromDb(String value) => switch (value) { String _scoreStopwatchStatusToDb(domain.ActiveScoreStopwatchStatus status) => switch (status) { domain.ActiveScoreStopwatchStatus.running => 'running', + domain.ActiveScoreStopwatchStatus.paused => 'paused', domain.ActiveScoreStopwatchStatus.stopped => 'stopped', }; domain.ActiveScoreStopwatchStatus _scoreStopwatchStatusFromDb(String value) => switch (value) { 'running' => domain.ActiveScoreStopwatchStatus.running, + 'paused' => domain.ActiveScoreStopwatchStatus.paused, 'stopped' => domain.ActiveScoreStopwatchStatus.stopped, _ => throw domain.DomainException( 'Unknown active score stopwatch status: $value', ), }; +String _setTimerStatusToDb(domain.ActiveSetTimerStatus status) => + switch (status) { + domain.ActiveSetTimerStatus.running => 'running', + domain.ActiveSetTimerStatus.paused => 'paused', + domain.ActiveSetTimerStatus.stopped => 'stopped', + domain.ActiveSetTimerStatus.skipped => 'skipped', + }; + +domain.ActiveSetTimerStatus _setTimerStatusFromDb(String value) => + switch (value) { + 'running' => domain.ActiveSetTimerStatus.running, + 'paused' => domain.ActiveSetTimerStatus.paused, + 'stopped' => domain.ActiveSetTimerStatus.stopped, + 'skipped' => domain.ActiveSetTimerStatus.skipped, + _ => throw domain.DomainException( + 'Unknown active set timer status: $value', + ), + }; + String _exerciseStepProgressStatusToDb( domain.ActiveExerciseStepProgressStatus status, ) => switch (status) { diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 170a117..47ca44e 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -513,11 +513,40 @@ class ActiveScoreStopwatchStates extends SyncableTable { 'CHECK (program_index >= 0)', 'CHECK (exercise_index >= 0)', 'CHECK (set_index >= 0)', - "CHECK (status IN ('running', 'stopped'))", + "CHECK (status IN ('running', 'paused', 'stopped'))", 'CHECK (accumulated_ms >= 0)', ]; } +class ActiveSetTimerStates extends SyncableTable { + @override + String get tableName => 'active_set_timer_states'; + + TextColumn get activeWorkoutSessionId => + text().references(ActiveWorkoutSessions, #id)(); + IntColumn get programIndex => integer()(); + IntColumn get exerciseIndex => integer()(); + IntColumn get setIndex => integer()(); + TextColumn get status => text()(); + DateTimeColumn get startedAt => dateTime().nullable()(); + IntColumn get accumulatedMs => integer()(); + DateTimeColumn get stoppedAt => dateTime().nullable()(); + DateTimeColumn get skippedAt => dateTime().nullable()(); + + @override + List get customConstraints => [ + 'UNIQUE (active_workout_session_id, program_index, exercise_index, ' + 'set_index)', + 'CHECK (program_index >= 0)', + 'CHECK (exercise_index >= 0)', + 'CHECK (set_index >= 0)', + "CHECK (status IN ('running', 'paused', 'stopped', 'skipped'))", + 'CHECK (status != \'running\' OR started_at IS NOT NULL)', + 'CHECK (accumulated_ms >= 0)', + 'CHECK (stopped_at IS NULL OR skipped_at IS NULL)', + ]; +} + class ActiveRestStates extends SyncableTable { @override String get tableName => 'active_rest_states'; @@ -532,6 +561,9 @@ class ActiveRestStates extends SyncableTable { DateTimeColumn get startedAt => dateTime()(); DateTimeColumn get endedAt => dateTime().nullable()(); DateTimeColumn get skippedAt => dateTime().nullable()(); + DateTimeColumn get pausedAt => dateTime().nullable()(); + IntColumn get accumulatedPausedMs => + integer().withDefault(const Constant(0))(); @override List get customConstraints => [ @@ -540,6 +572,7 @@ class ActiveRestStates extends SyncableTable { 'CHECK (after_set_index >= 0)', 'CHECK (planned_rest_seconds >= 0)', 'CHECK (adjusted_rest_seconds >= 0)', + 'CHECK (accumulated_paused_ms >= 0)', 'CHECK (ended_at IS NULL OR skipped_at IS NULL)', ]; } diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index e1e06ac..70fddc9 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -405,7 +405,6 @@ final class _ExerciseFormScreenState extends State { var _importingVideo = false; var _stepsEnabled = false; var _autoStartNextTimedStep = true; - var _nextStepDraftId = 0; final _stepDrafts = <_ExerciseStepDraft>[]; String? _measureError; String? _stepError; @@ -1057,7 +1056,7 @@ final class _ExerciseFormScreenState extends State { _stepError = null; _stepDrafts.add( _ExerciseStepDraft( - id: 'step-draft-${_nextStepDraftId++}', + id: widget.exerciseUseCases.ids.newId(), name: 'Nouvelle étape', defaultTargetValue: '10', ), @@ -1111,7 +1110,7 @@ final class _ExerciseFormScreenState extends State { _stepError = null; _stepDrafts.insert( index + 1, - _stepDrafts[index].duplicate('step-draft-${_nextStepDraftId++}'), + _stepDrafts[index].duplicate(widget.exerciseUseCases.ids.newId()), ); }); } diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index a7fc51f..08b1f6a 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -57,7 +57,9 @@ final class _WorkoutExecutionScreenState extends State { Timer? _stepScoreTicker; WorkoutHistory? _completedHistory; String? _activeRestStateId; + ActiveSetTimerState? _setTimer; ActiveScoreStopwatchState? _scoreStopwatch; + var _scoreStopwatchLoadGeneration = 0; ActiveExerciseStepProgressView? _stepProgress; late final ExerciseStepAudioCuePlayer _stepAudioCuePlayer; late final bool _ownsStepAudioCuePlayer; @@ -68,7 +70,6 @@ final class _WorkoutExecutionScreenState extends State { var _stepScoreRunning = false; var _lastCountdownBeepSecond = -1; var _advancingStep = false; - late DateTime _seriesStartedAt; var _reps = 0; final _scoreController = TextEditingController(); var _remainingRestSeconds = 0; @@ -97,10 +98,10 @@ final class _WorkoutExecutionScreenState extends State { ? WorkoutExecutionMode.active : WorkoutExecutionMode.paused; _reps = _initialRepsFor(_exercise); - _seriesStartedAt = DateTime.now().toUtc(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); + unawaited(_loadSetTimer()); unawaited(_loadScoreStopwatch()); unawaited(_loadStepProgress()); unawaited(_restoreActiveRest()); @@ -146,6 +147,16 @@ final class _WorkoutExecutionScreenState extends State { TextButton(onPressed: _pause, child: const Text('Pause')), ], ), + bottomNavigationBar: _mode == WorkoutExecutionMode.active + ? SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: _SetActionBar( + onFinishSet: () => _finishSet(skipped: false), + onSkipSet: () => _finishSet(skipped: true), + ), + ) + : null, body: switch (_mode) { WorkoutExecutionMode.active => _buildActive(), WorkoutExecutionMode.rest => _buildRest(), @@ -157,95 +168,126 @@ final class _WorkoutExecutionScreenState extends State { } Widget _buildActive() { - return ListView( - padding: const EdgeInsets.all(16), + final hasStepSequence = + _exercise.steps.isNotEmpty && widget.stepUseCases != null; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _Header( - elapsedLabel: _formatDuration( - Duration( - milliseconds: widget.activeUseCases.elapsedActiveMilliseconds( - _session, - ), - ), - ), - progressLabel: _plan.progressLabel(_position), - ), - const SizedBox(height: 24), - _SeriesCounterCard( - currentSet: _position.setIndex + 1, - totalSets: _exercise.setsCount, - ), - const SizedBox(height: 16), - Text(_exercise.name, style: Theme.of(context).textTheme.headlineMedium), - if (_exercise.hasMedia) ...[ - const SizedBox(height: 12), - Align( - alignment: Alignment.centerLeft, - child: Tooltip( - message: 'Voir les médias de l’exercice', - child: OutlinedButton.icon( - onPressed: () => _openExerciseMedia(_exercise), - icon: const Icon(Icons.perm_media_outlined), - label: const Text('Voir médias'), - ), - ), - ), - ], - const SizedBox(height: 16), - if (_exercise.steps.isNotEmpty && widget.stepUseCases != null) ...[ - _StepSequencePanel( - view: _stepProgress, + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: _ExecutionContextHeader( exercise: _exercise, - remainingLabel: _stepRemainingLabel, - stepScoreController: _stepScoreController, - stepScoreElapsedLabel: _formatScoreStopwatch( - Duration(milliseconds: _stepScoreElapsedMs), + currentSet: _position.setIndex + 1, + totalSets: _exercise.setsCount, + progressLabel: _plan.progressLabel(_position), + setTimerLabel: _setTimerLabel, + setTimerComplete: + _setTimer?.status == ActiveSetTimerStatus.stopped, + showStartButton: _shouldShowStartExerciseButton, + onStartExercise: _startExercise, + onOpenMedia: _exercise.hasMedia + ? () => _openExerciseMedia(_exercise) + : null, + elapsedLabel: _formatDuration( + Duration( + milliseconds: widget.activeUseCases.elapsedActiveMilliseconds( + _session, + ), + ), ), - stepScoreRunning: _stepScoreRunning, - onStartTimer: _startCurrentStepTimer, - onCompleteStep: _completeCurrentStep, - onSkipStep: _skipCurrentStep, - onSkipPassage: _skipCurrentPassage, - onSkipSequence: _skipSequence, - onStartStepScore: _startStepScore, - onStopStepScore: _stopStepScore, - onResetStepScore: _resetStepScore, ), - const SizedBox(height: 16), - ], - SetMeasureInput( - exercise: _exercise, - reps: _reps, - scoreController: _scoreController, - onRepsChanged: (value) => setState(() => _reps = value), ), - if (_exercise.stopwatchScoreEnabled) ...[ - const SizedBox(height: 12), - _ScoreStopwatchInput( - elapsedLabel: _formatScoreStopwatch( - Duration(milliseconds: _scoreStopwatchElapsedMs), - ), - status: _scoreStopwatch?.status, - onStart: _startScoreStopwatch, - onStop: _stopScoreStopwatch, - onResume: _resumeScoreStopwatch, - onReset: _resetScoreStopwatch, - onEdit: _editScoreStopwatchTime, + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: hasStepSequence + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: _StepSequencePanel( + view: _stepProgress, + remainingLabel: _stepRemainingLabel, + stepScoreController: _stepScoreController, + stepScoreElapsedLabel: _formatScoreStopwatch( + Duration(milliseconds: _stepScoreElapsedMs), + ), + stepScoreRunning: _stepScoreRunning, + onStartTimer: _startCurrentStepTimer, + onCompleteStep: _completeCurrentStep, + onSkipStep: _skipCurrentStep, + onSkipPassage: _skipCurrentPassage, + onSkipSequence: _skipSequence, + onStartStepScore: _startStepScore, + onStopStepScore: _stopStepScore, + onResetStepScore: _resetStepScore, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: _exercise.manualScoreEnabled ? 66 : 48, + child: _StepSetResultSummary( + view: _stepProgress, + exercise: _exercise, + reps: _reps, + scoreController: _scoreController, + ), + ), + if (_exercise.stopwatchScoreEnabled) ...[ + const SizedBox(height: 8), + SizedBox( + height: 96, + child: _ScoreStopwatchInput( + elapsedLabel: _formatScoreStopwatch( + Duration(milliseconds: _scoreStopwatchElapsedMs), + ), + status: _scoreStopwatch?.status, + onStart: _startScoreStopwatch, + onStop: _stopScoreStopwatch, + onResume: _resumeScoreStopwatch, + onReset: _resetScoreStopwatch, + onEdit: _editScoreStopwatchTime, + compact: true, + ), + ), + ], + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_exercise.stopwatchScoreEnabled) ...[ + _ScoreStopwatchInput( + elapsedLabel: _formatScoreStopwatch( + Duration(milliseconds: _scoreStopwatchElapsedMs), + ), + status: _scoreStopwatch?.status, + onStart: _startScoreStopwatch, + onStop: _stopScoreStopwatch, + onResume: _resumeScoreStopwatch, + onReset: _resetScoreStopwatch, + onEdit: _editScoreStopwatchTime, + ), + const SizedBox(height: 12), + ], + Expanded( + child: ListView( + padding: const EdgeInsets.only(bottom: 16), + children: [ + SetMeasureInput( + exercise: _exercise, + showTime: false, + reps: _reps, + scoreController: _scoreController, + onRepsChanged: (value) => + setState(() => _reps = value), + ), + ], + ), + ), + ], + ), ), - ], - const SizedBox(height: 24), - FilledButton( - onPressed: () => _finishSet(skipped: false), - style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(56)), - child: const Text('Terminer la série'), - ), - const SizedBox(height: 12), - OutlinedButton( - onPressed: () => _finishSet(skipped: true), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(52), - ), - child: Text(_hasActiveStepSequence ? 'Passer la série' : 'Passer'), ), ], ); @@ -418,6 +460,140 @@ final class _WorkoutExecutionScreenState extends State { return mediaUseCases.findById(id); } + int get _setTimerElapsedMs { + final state = _setTimer; + if (state == null) { + return 0; + } + return widget.activeUseCases.setTimerElapsedMilliseconds(state); + } + + String? get _setTimerLabel { + if (!_exercise.timeEnabled) { + return null; + } + final state = _setTimer; + if (state == null) { + final targetSeconds = _exercise.targetTimeSeconds; + if (targetSeconds == null) { + return 'Chronométrer'; + } + return _formatDuration(Duration(seconds: targetSeconds)); + } + return _formatDuration(Duration(milliseconds: _setTimerElapsedMs)); + } + + bool get _shouldShowStartExerciseButton { + final hasGlobalTimer = + _exercise.timeEnabled || + _exercise.stopwatchScoreEnabled || + (_exercise.steps.isNotEmpty && + _exercise.steps.first.type == ExerciseStepType.time); + return hasGlobalTimer && !_hasSetExecutionStarted; + } + + bool get _hasSetExecutionStarted { + if (_setTimer != null) { + return true; + } + if (_scoreStopwatch != null) { + return true; + } + final view = _stepProgress; + if (view == null) { + return false; + } + return view.results.isNotEmpty || + view.state.currentPassageIndex > 0 || + view.state.currentStepIndex > 0 || + view.state.status == ActiveExerciseStepProgressStatus.runningTimer || + view.state.status == ActiveExerciseStepProgressStatus.pausedTimer || + view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete; + } + + bool get _shouldConfirmSkipSet { + return _setTimer?.status == ActiveSetTimerStatus.running || + _scoreStopwatch?.status == ActiveScoreStopwatchStatus.running || + _hasStartedStepSequence; + } + + bool get _shouldPromptStartExerciseChrono { + if (_exercise.steps.isNotEmpty && widget.stepUseCases != null) { + return _isFirstTimedStepNotStarted; + } + return (_exercise.timeEnabled && _setTimer == null) || + _isFirstTimedStepNotStarted; + } + + bool get _isFirstTimedStepNotStarted { + if (_exercise.steps.isEmpty || + _exercise.steps.first.type != ExerciseStepType.time) { + return false; + } + final view = _stepProgress; + if (view == null) { + return true; + } + return view.state.currentPassageIndex == 0 && + view.state.currentStepIndex == 0 && + view.state.status == ActiveExerciseStepProgressStatus.stoppedTimer && + view.results.isEmpty; + } + + bool get _hasStartedStepSequence { + final view = _stepProgress; + if (view == null) { + return false; + } + return view.results.isNotEmpty || + view.state.currentPassageIndex > 0 || + view.state.currentStepIndex > 0 || + view.state.status == ActiveExerciseStepProgressStatus.runningTimer || + view.state.status == ActiveExerciseStepProgressStatus.pausedTimer; + } + + Future _loadSetTimer() async { + if (!_exercise.timeEnabled) { + if (!mounted) return; + setState(() => _setTimer = null); + return; + } + final state = await widget.activeUseCases.findSetTimer( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return; + setState(() => _setTimer = state); + } + + Future _startExercise() async { + try { + final result = await widget.activeUseCases.startSetExecution( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return; + setState(() { + _setTimer = result.setTimer; + _scoreStopwatch = result.scoreStopwatch ?? _scoreStopwatch; + _manualScoreTimeMs = null; + }); + await _loadScoreStopwatch(); + await _readStepProgress(); + _refreshScoreStopwatchTicker(); + _refreshStepTicker(); + } on Exception catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Impossible de démarrer l’exercice : $error")), + ); + } + } + int get _scoreStopwatchElapsedMs { final manual = _manualScoreTimeMs; if (manual != null) { @@ -437,6 +613,7 @@ final class _WorkoutExecutionScreenState extends State { _refreshScoreStopwatchTicker(); return; } + final generation = _scoreStopwatchLoadGeneration; final state = await widget.activeUseCases.findScoreStopwatch( sessionId: _session.metadata.id, programIndex: _position.programIndex, @@ -444,11 +621,14 @@ final class _WorkoutExecutionScreenState extends State { setIndex: _position.setIndex, ); if (!mounted) return; + if (generation != _scoreStopwatchLoadGeneration) return; + if (state == null && _scoreStopwatch != null) return; setState(() => _scoreStopwatch = state); _refreshScoreStopwatchTicker(); } Future _startScoreStopwatch() async { + _scoreStopwatchLoadGeneration++; final state = await widget.activeUseCases.startScoreStopwatch( sessionId: _session.metadata.id, programIndex: _position.programIndex, @@ -521,6 +701,7 @@ final class _WorkoutExecutionScreenState extends State { setIndex: _position.setIndex, ); if (!mounted) return; + _scoreStopwatchLoadGeneration++; setState(() { _scoreStopwatch = null; _manualScoreTimeMs = null; @@ -577,13 +758,6 @@ final class _WorkoutExecutionScreenState extends State { DateTime.now().toUtc().difference(_stepScoreStartedAt!).inMilliseconds; } - bool get _hasActiveStepSequence { - return _exercise.steps.isNotEmpty && - widget.stepUseCases != null && - _stepProgress?.state.status != - ActiveExerciseStepProgressStatus.sequenceComplete; - } - Future _loadStepProgress({bool startNextTimed = false}) async { final stepUseCases = widget.stepUseCases; if (_exercise.steps.isEmpty || stepUseCases == null) { @@ -940,11 +1114,11 @@ final class _WorkoutExecutionScreenState extends State { if (_stepScoreRunning) { _stopStepScore(); } - if (_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) { - await _stopScoreStopwatch(); - } _session = await widget.activeUseCases.pause(_session.metadata.id); if (!mounted) return; + await _loadSetTimer(); + await _loadScoreStopwatch(); + await _readStepProgress(); setState(() => _mode = WorkoutExecutionMode.paused); } @@ -953,6 +1127,7 @@ final class _WorkoutExecutionScreenState extends State { if (!mounted) return; final restoredRest = await _restoreActiveRest(); if (!mounted || restoredRest) return; + await _loadSetTimer(); await _loadScoreStopwatch(); await _loadStepProgress(); if (_stepProgress?.state.status == @@ -1022,71 +1197,60 @@ final class _WorkoutExecutionScreenState extends State { } Future _finishSet({required bool skipped}) async { - if (skipped && _hasActiveStepSequence) { + var finishWithoutChrono = false; + if (skipped && _shouldConfirmSkipSet) { final confirmed = await _confirmStepSkip( 'Passer la série ?', - 'La séquence en cours sera arrêtée.', + 'Les chronos ou la séquence en cours seront ignorés.', 'Passer la série', ); if (confirmed != true) return; - final stepUseCases = widget.stepUseCases; - if (stepUseCases == null) return; - await stepUseCases.skipSequence( - sessionId: _session.metadata.id, - programIndex: _position.programIndex, - exerciseIndex: _position.exerciseIndex, - setIndex: _position.setIndex, - ); - _resetStepScoreInput(); - await _readStepProgress(); } - if (skipped && - _scoreStopwatch?.status == ActiveScoreStopwatchStatus.running) { - final confirmed = await showDialog( + if (!skipped && _shouldPromptStartExerciseChrono) { + final action = await showDialog<_MissingSetTimerAction>( context: context, builder: (context) => AlertDialog( - title: const Text('Passer cette série ?'), - content: const Text('Le chrono en cours sera ignoré.'), + title: const Text('Chrono non lancé'), + content: const Text("Tu n’as pas démarré le chrono de cette série."), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Continuer la série'), + onPressed: () => + Navigator.of(context).pop(_MissingSetTimerAction.start), + child: const Text('Démarrer l’exercice'), ), FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Passer'), + onPressed: () => Navigator.of( + context, + ).pop(_MissingSetTimerAction.finishWithout), + child: const Text('Terminer sans chrono'), ), ], ), ); - if (confirmed != true) return; - await widget.activeUseCases.resetScoreStopwatch( - sessionId: _session.metadata.id, - programIndex: _position.programIndex, - exerciseIndex: _position.exerciseIndex, - setIndex: _position.setIndex, - ); - if (!mounted) return; - setState(() { - _scoreStopwatch = null; - _manualScoreTimeMs = null; - }); - _refreshScoreStopwatchTicker(); + if (action == _MissingSetTimerAction.start) { + await _startExercise(); + return; + } + if (action != _MissingSetTimerAction.finishWithout) { + return; + } + finishWithoutChrono = true; } if (!skipped && + !finishWithoutChrono && _exercise.stopwatchScoreEnabled && _scoreStopwatch == null && _manualScoreTimeMs == null) { final action = await showDialog<_MissingStopwatchAction>( context: context, builder: (context) => AlertDialog( - title: const Text('Aucun temps chronométré'), - content: const Text("Tu n'as pas démarré le chrono score."), + title: const Text('Chrono non lancé'), + content: const Text("Tu n’as pas démarré le chrono de cette série."), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(_MissingStopwatchAction.start), - child: const Text('Démarrer le chrono'), + child: const Text('Démarrer l’exercice'), ), FilledButton( onPressed: () => Navigator.of( @@ -1098,7 +1262,7 @@ final class _WorkoutExecutionScreenState extends State { ), ); if (action == _MissingStopwatchAction.start) { - await _startScoreStopwatch(); + await _startExercise(); return; } if (action != _MissingStopwatchAction.finishWithout) { @@ -1111,22 +1275,36 @@ final class _WorkoutExecutionScreenState extends State { Future _recordAndAdvance({required bool skipped}) async { try { final score = double.tryParse(_scoreController.text.trim()); + final setTimer = skipped + ? await widget.activeUseCases.skipSetExecutionTimers( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ) + : await widget.activeUseCases.stopSetExecutionTimers( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) return; + setState(() { + _setTimer = setTimer; + if (skipped) { + _scoreStopwatch = null; + _manualScoreTimeMs = null; + } + }); + await _loadScoreStopwatch(); + await _readStepProgress(); var actualScoreTimeMs = skipped ? null : _manualScoreTimeMs; if (!skipped && _exercise.stopwatchScoreEnabled && actualScoreTimeMs == null) { final state = _scoreStopwatch; - if (state?.status == ActiveScoreStopwatchStatus.running) { - final stopped = await _stopScoreStopwatch(); - actualScoreTimeMs = stopped?.accumulatedMs; - } else { - actualScoreTimeMs = state?.accumulatedMs; - } + actualScoreTimeMs = state?.accumulatedMs; } - final actualTimeMs = DateTime.now() - .toUtc() - .difference(_seriesStartedAt) - .inMilliseconds; await widget.activeUseCases.recordCurrentSetResult( sessionId: _session.metadata.id, programSnapshotId: _plan.programAt(_position).id, @@ -1144,7 +1322,7 @@ final class _WorkoutExecutionScreenState extends State { scoreUnitSnapshot: _exercise.scoreUnit, actualTimeMs: skipped ? null - : (_exercise.timeEnabled ? actualTimeMs : null), + : (_exercise.timeEnabled ? setTimer?.accumulatedMs : null), ); final next = _plan.nextPosition(_position); @@ -1218,11 +1396,8 @@ final class _WorkoutExecutionScreenState extends State { } int _remainingSecondsFor(ActiveRestState rest) { - final elapsedSeconds = DateTime.now() - .toUtc() - .difference(rest.startedAt.toUtc()) - .inSeconds; - return (rest.adjustedRestSeconds - elapsedSeconds).clamp(0, 9999).toInt(); + final remainingMs = rest.remainingMillisecondsAt(DateTime.now().toUtc()); + return (remainingMs / 1000).ceil().clamp(0, 9999).toInt(); } Future _adjustRest(int deltaSeconds) async { @@ -1262,13 +1437,14 @@ final class _WorkoutExecutionScreenState extends State { _scoreController.clear(); _reps = _initialRepsFor(_plan.exerciseAt(position)); _activeRestStateId = null; + _setTimer = null; _scoreStopwatch = null; + _scoreStopwatchLoadGeneration++; _manualScoreTimeMs = null; _stepProgress = null; _resetStepScoreInput(); _refreshScoreStopwatchTicker(); _refreshStepTicker(); - _seriesStartedAt = DateTime.now().toUtc(); _session = await widget.activeUseCases.updateProgress( sessionId: _session.metadata.id, programIndex: position.programIndex, @@ -1276,6 +1452,7 @@ final class _WorkoutExecutionScreenState extends State { setIndex: position.setIndex, ); if (!mounted) return; + await _loadSetTimer(); await _loadScoreStopwatch(); await _loadStepProgress(); if (!mounted) return; @@ -1357,6 +1534,7 @@ final class _WorkoutExecutionScreenState extends State { final class SetMeasureInput extends StatelessWidget { const SetMeasureInput({ required this.exercise, + this.showTime = true, required this.reps, required this.scoreController, required this.onRepsChanged, @@ -1364,6 +1542,7 @@ final class SetMeasureInput extends StatelessWidget { }); final ExecutionExercise exercise; + final bool showTime; final int reps; final TextEditingController scoreController; final ValueChanged onRepsChanged; @@ -1373,7 +1552,7 @@ final class SetMeasureInput extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (exercise.timeEnabled) + if (exercise.timeEnabled && showTime) Card( child: Padding( padding: const EdgeInsets.all(24), @@ -1427,12 +1606,232 @@ final class SetMeasureInput extends StatelessWidget { } } +final class _ExecutionContextHeader extends StatelessWidget { + const _ExecutionContextHeader({ + required this.exercise, + required this.currentSet, + required this.totalSets, + required this.progressLabel, + required this.elapsedLabel, + required this.setTimerLabel, + required this.setTimerComplete, + required this.showStartButton, + required this.onStartExercise, + required this.onOpenMedia, + }); + + final ExecutionExercise exercise; + final int currentSet; + final int totalSets; + final String progressLabel; + final String elapsedLabel; + final String? setTimerLabel; + final bool setTimerComplete; + final bool showStartButton; + final VoidCallback onStartExercise; + final VoidCallback? onOpenMedia; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = courtBlazerTokensOf(context); + return CourtBlazerAccentPanel( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + Text( + 'Série $currentSet/$totalSets', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + Text( + progressLabel, + style: theme.textTheme.labelMedium?.copyWith( + color: tokens.mutedText, + ), + ), + Text( + elapsedLabel, + style: theme.textTheme.labelMedium?.copyWith( + color: tokens.mutedText, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + exercise.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleLarge, + ), + if (setTimerLabel != null) ...[ + const SizedBox(height: 4), + Text( + setTimerComplete + ? 'Temps de série terminé : $setTimerLabel' + : 'Temps de série : $setTimerLabel', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + if (showStartButton) + Tooltip( + message: 'Démarrer l’exercice', + child: FilledButton.icon( + onPressed: onStartExercise, + style: FilledButton.styleFrom( + minimumSize: const Size(52, 52), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + icon: const Icon(Icons.play_arrow), + label: const Text('Démarrer l’exercice'), + ), + ), + if (onOpenMedia != null) ...[ + const SizedBox(width: 8), + Tooltip( + message: 'Voir les médias de l’exercice', + child: OutlinedButton( + onPressed: onOpenMedia, + style: OutlinedButton.styleFrom( + minimumSize: const Size(52, 52), + padding: EdgeInsets.zero, + ), + child: const Icon(Icons.perm_media_outlined), + ), + ), + ], + ], + ), + ); + } +} + +final class _SetActionBar extends StatelessWidget { + const _SetActionBar({ + required this.onFinishSet, + required this.onSkipSet, + }); + + final VoidCallback onFinishSet; + final VoidCallback onSkipSet; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton( + onPressed: onFinishSet, + style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(56)), + child: const Text('Terminer la série'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: onSkipSet, + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(52), + ), + child: const Text('Passer la série'), + ), + ], + ); + } +} + +final class _StepSetResultSummary extends StatelessWidget { + const _StepSetResultSummary({ + required this.view, + required this.exercise, + required this.reps, + required this.scoreController, + }); + + final ActiveExerciseStepProgressView? view; + final ExecutionExercise exercise; + final int reps; + final TextEditingController scoreController; + + @override + Widget build(BuildContext context) { + final completedPassages = _completedPassagesFor(view); + final expectedPassages = view?.expectedPassages ?? reps; + final theme = Theme.of(context); + final tokens = courtBlazerTokensOf(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: tokens.border), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + expectedPassages > 0 + ? 'Passages réalisés : $completedPassages / $expectedPassages' + : 'Passages réalisés : $completedPassages', + style: theme.textTheme.titleSmall, + ), + ), + if (exercise.manualScoreEnabled) ...[ + const SizedBox(width: 12), + SizedBox( + width: 128, + child: TextField( + controller: scoreController, + decoration: InputDecoration( + isDense: true, + labelText: exercise.scoreUnit == null + ? 'Score' + : 'Score (${exercise.scoreUnit})', + ), + keyboardType: TextInputType.number, + ), + ), + ], + ], + ), + ); + } +} + +int _completedPassagesFor(ActiveExerciseStepProgressView? view) { + if (view == null) return 0; + if (view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete) { + return view.expectedPassages; + } + return view.state.currentPassageIndex; +} + +enum _MissingSetTimerAction { start, finishWithout } + enum _MissingStopwatchAction { start, finishWithout } final class _StepSequencePanel extends StatelessWidget { const _StepSequencePanel({ required this.view, - required this.exercise, required this.remainingLabel, required this.stepScoreController, required this.stepScoreElapsedLabel, @@ -1448,7 +1847,6 @@ final class _StepSequencePanel extends StatelessWidget { }); final ActiveExerciseStepProgressView? view; - final ExecutionExercise exercise; final String remainingLabel; final TextEditingController stepScoreController; final String stepScoreElapsedLabel; @@ -1466,8 +1864,8 @@ final class _StepSequencePanel extends StatelessWidget { Widget build(BuildContext context) { final view = this.view; if (view == null) { - return const CourtBlazerAccentPanel( - padding: EdgeInsets.all(16), + return const _BoundedAccentPanel( + padding: EdgeInsets.all(12), child: Center(child: CircularProgressIndicator()), ); } @@ -1477,21 +1875,30 @@ final class _StepSequencePanel extends StatelessWidget { final completedPassages = sequenceComplete ? view.expectedPassages : view.state.currentPassageIndex; - return CourtBlazerAccentPanel( - padding: const EdgeInsets.all(16), + return _BoundedAccentPanel( + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('SÉQUENCE', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text( - view.expectedPassages > 1 && !sequenceComplete - ? 'Passage ${view.state.currentPassageIndex + 1} / ${view.expectedPassages}' - : view.expectedPassages > 1 - ? 'Séquence terminée' - : 'Passage en cours', + Row( + children: [ + Expanded( + child: Text( + view.expectedPassages > 1 && !sequenceComplete + ? 'Passage ${view.state.currentPassageIndex + 1} / ${view.expectedPassages}' + : view.expectedPassages > 1 + ? 'Séquence terminée' + : 'Passage en cours', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Text( + 'SÉQUENCE', + style: Theme.of(context).textTheme.labelMedium, + ), + ], ), - const SizedBox(height: 12), + const SizedBox(height: 6), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( @@ -1501,80 +1908,205 @@ final class _StepSequencePanel extends StatelessWidget { index: index, status: _stepDisplayStatus(view, index), ), - if (index < view.steps.length - 1) const SizedBox(width: 8), + if (index < view.steps.length - 1) const SizedBox(width: 6), ], ], ), ), - const SizedBox(height: 16), + const SizedBox(height: 8), if (sequenceComplete || currentStep == null) - _SequenceCompleteSummary( - completedPassages: completedPassages, - expectedPassages: view.expectedPassages, + Expanded( + child: Align( + alignment: Alignment.topLeft, + child: _SequenceCompleteSummary( + completedPassages: completedPassages, + expectedPassages: view.expectedPassages, + ), + ), ) - else ...[ - Text( - 'ÉTAPE ${view.state.currentStepIndex + 1} / ${view.steps.length}', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 4), - Text( - currentStep.name, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 12), - if (currentStep.type == ExerciseStepType.time) - _TimedStepBody( + else + Expanded( + child: _CurrentStepPane( + view: view, step: currentStep, remainingLabel: remainingLabel, - running: - view.state.status == - ActiveExerciseStepProgressStatus.runningTimer, - readyToStart: _isNextTimedStepReady(view), + stepScoreController: stepScoreController, + stepScoreElapsedLabel: stepScoreElapsedLabel, + stepScoreRunning: stepScoreRunning, onStartTimer: onStartTimer, - ) - else - _RepsStepBody(step: currentStep, onCompleteStep: onCompleteStep), - if (currentStep.hasScore) ...[ - const SizedBox(height: 16), - _StepScoreInput( - step: currentStep, - controller: stepScoreController, - elapsedLabel: stepScoreElapsedLabel, - running: stepScoreRunning, - onStart: onStartStepScore, - onStop: onStopStepScore, - onReset: onResetStepScore, + onCompleteStep: onCompleteStep, + onSkipStep: onSkipStep, + onSkipPassage: onSkipPassage, + onSkipSequence: onSkipSequence, + onStartStepScore: onStartStepScore, + onStopStepScore: onStopStepScore, + onResetStepScore: onResetStepScore, ), - ], - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton( - onPressed: onSkipStep, - child: const Text('Passer l’étape'), + ), + ], + ), + ); + } +} + +enum _StepSkipAction { passage, sequence } + +final class _BoundedAccentPanel extends StatelessWidget { + const _BoundedAccentPanel({required this.child, required this.padding}); + + final Widget child; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = courtBlazerTokensOf(context); + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: tokens.border), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(height: 2, color: tokens.accent), + Expanded(child: Padding(padding: padding, child: child)), + ], + ), + ); + } +} + +final class _CurrentStepPane extends StatelessWidget { + const _CurrentStepPane({ + required this.view, + required this.step, + required this.remainingLabel, + required this.stepScoreController, + required this.stepScoreElapsedLabel, + required this.stepScoreRunning, + required this.onStartTimer, + required this.onCompleteStep, + required this.onSkipStep, + required this.onSkipPassage, + required this.onSkipSequence, + required this.onStartStepScore, + required this.onStopStepScore, + required this.onResetStepScore, + }); + + final ActiveExerciseStepProgressView view; + final ExerciseStep step; + final String remainingLabel; + final TextEditingController stepScoreController; + final String stepScoreElapsedLabel; + final bool stepScoreRunning; + final VoidCallback onStartTimer; + final VoidCallback onCompleteStep; + final VoidCallback onSkipStep; + final VoidCallback onSkipPassage; + final VoidCallback onSkipSequence; + final VoidCallback onStartStepScore; + final VoidCallback onStopStepScore; + final VoidCallback onResetStepScore; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'ÉTAPE ${view.state.currentStepIndex + 1} / ${view.steps.length}', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 2), + Text( + step.name, + style: Theme.of(context).textTheme.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Flexible( + fit: FlexFit.tight, + child: LayoutBuilder( + builder: (context, constraints) { + final body = step.type == ExerciseStepType.time + ? _TimedStepBody( + step: step, + remainingLabel: remainingLabel, + running: + view.state.status == + ActiveExerciseStepProgressStatus.runningTimer, + readyToStart: _isNextTimedStepReady(view), + onStartTimer: onStartTimer, + ) + : _RepsStepBody(step: step, onCompleteStep: onCompleteStep); + return Align( + alignment: Alignment.topCenter, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topCenter, + child: SizedBox(width: constraints.maxWidth, child: body), ), - TextButton( - onPressed: onSkipPassage, - child: const Text('Passer ce passage'), + ); + }, + ), + ), + if (step.hasScore) ...[ + const SizedBox(height: 8), + Flexible( + fit: FlexFit.loose, + child: _StepScoreInput( + step: step, + controller: stepScoreController, + elapsedLabel: stepScoreElapsedLabel, + running: stepScoreRunning, + onStart: onStartStepScore, + onStop: onStopStepScore, + onReset: onResetStepScore, + ), + ), + ], + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onSkipStep, + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(44), ), - TextButton( - onPressed: onSkipSequence, - child: const Text('Passer la séquence'), + child: const Text('Passer l’étape'), + ), + ), + const SizedBox(width: 8), + PopupMenuButton<_StepSkipAction>( + tooltip: 'Plus d’actions', + icon: const Icon(Icons.more_horiz), + onSelected: (action) { + if (action == _StepSkipAction.passage) { + onSkipPassage(); + } else { + onSkipSequence(); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: _StepSkipAction.passage, + child: Text('Passer ce passage'), + ), + PopupMenuItem( + value: _StepSkipAction.sequence, + child: Text('Passer la séquence'), ), ], ), ], - if (exercise.repsEnabled) ...[ - const SizedBox(height: 16), - Text( - 'Passages réalisés : $completedPassages / ${view.expectedPassages}', - ), - ], - ], - ), + ), + ], ); } } @@ -1798,8 +2330,8 @@ final class _StepProgressChip extends StatelessWidget { ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface; return Container( - width: 36, - height: 36, + width: 30, + height: 30, alignment: Alignment.center, decoration: BoxDecoration( color: background, @@ -1847,6 +2379,7 @@ final class _ScoreStopwatchInput extends StatelessWidget { required this.onResume, required this.onReset, required this.onEdit, + this.compact = false, }); final String elapsedLabel; @@ -1856,25 +2389,38 @@ final class _ScoreStopwatchInput extends StatelessWidget { final VoidCallback onResume; final VoidCallback onReset; final VoidCallback onEdit; + final bool compact; @override Widget build(BuildContext context) { final running = status == ActiveScoreStopwatchStatus.running; final stopped = status == ActiveScoreStopwatchStatus.stopped; return CourtBlazerAccentPanel( - padding: const EdgeInsets.all(16), + padding: compact + ? const EdgeInsets.symmetric(horizontal: 14, vertical: 10) + : const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Chrono score', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), - Center( - child: Text( - elapsedLabel, - style: AppTextStyles.scoreNumber(context), - ), + Row( + children: [ + Expanded( + child: Text( + 'Chrono score', + style: compact + ? Theme.of(context).textTheme.titleSmall + : Theme.of(context).textTheme.titleMedium, + ), + ), + Text( + elapsedLabel, + style: compact + ? Theme.of(context).textTheme.titleLarge + : AppTextStyles.scoreNumber(context), + ), + ], ), - const SizedBox(height: 12), + SizedBox(height: compact ? 8 : 12), Wrap( spacing: 8, runSpacing: 8, @@ -3111,11 +3657,8 @@ int? _decimalSecondsToMilliseconds(String rawValue) { int _remainingSecondsForRest(ActiveRestState? rest) { if (rest == null) return 0; - final elapsedSeconds = DateTime.now() - .toUtc() - .difference(rest.startedAt.toUtc()) - .inSeconds; - return (rest.adjustedRestSeconds - elapsedSeconds).clamp(0, 9999).toInt(); + final remainingMs = rest.remainingMillisecondsAt(DateTime.now().toUtc()); + return (remainingMs / 1000).ceil().clamp(0, 9999).toInt(); } String _stateKey(SetResultPositionState state) { @@ -3405,64 +3948,6 @@ final class ExecutionPosition { } } -final class _Header extends StatelessWidget { - const _Header({required this.elapsedLabel, required this.progressLabel}); - - final String elapsedLabel; - final String progressLabel; - - @override - Widget build(BuildContext context) { - return CourtBlazerAccentPanel( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)), - const SizedBox(height: 4), - Text(progressLabel), - ], - ), - ); - } -} - -final class _SeriesCounterCard extends StatelessWidget { - const _SeriesCounterCard({required this.currentSet, required this.totalSets}); - - final int currentSet; - final int totalSets; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final tokens = courtBlazerTokensOf(context); - return CourtBlazerAccentPanel( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'SÉRIE', - style: theme.textTheme.labelSmall?.copyWith( - color: tokens.mutedText, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - '$currentSet / $totalSets', - style: theme.textTheme.displayLarge?.copyWith( - color: theme.colorScheme.primary, - fontSize: 48, - ), - ), - ], - ), - ); - } -} - String _formatDuration(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index c195c99..fde3e06 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -951,6 +951,195 @@ void main() { expect(active?.metadata.id, 'latest-active'); }); + test( + 'startSetExecution starts set timer score stopwatch and first timed step', + () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + timeEnabled: true, + repsEnabled: false, + scoreEnabled: true, + scoreInputMode: ScoreInputMode.stopwatch, + exerciseSteps: [_step(type: ExerciseStepType.time)], + ); + final repository = _FakeActiveSessionRepository()..session = session; + final useCase = _activeUseCase(repository, clock); + + final started = await useCase.startSetExecution( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + + expect(started.setTimer?.status, ActiveSetTimerStatus.running); + expect( + started.scoreStopwatch?.status, + ActiveScoreStopwatchStatus.running, + ); + expect( + started.stepProgress?.status, + ActiveExerciseStepProgressStatus.runningTimer, + ); + expect(repository.setTimerStates.length, 1); + expect(repository.scoreStopwatchStates.length, 1); + expect(repository.stepProgressStates.length, 1); + }, + ); + + test('startSetExecution is idempotent for already running timers', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + timeEnabled: true, + repsEnabled: false, + scoreEnabled: true, + scoreInputMode: ScoreInputMode.stopwatch, + exerciseSteps: [_step(type: ExerciseStepType.time)], + ); + final repository = _FakeActiveSessionRepository()..session = session; + final useCase = _activeUseCase(repository, clock); + + final first = await useCase.startSetExecution( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + clock.value = clock.value.add(const Duration(seconds: 3)); + final second = await useCase.startSetExecution( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + + expect(second.setTimer?.metadata.id, first.setTimer?.metadata.id); + expect(second.setTimer?.startedAt, first.setTimer?.startedAt); + expect(repository.setTimerStates.length, 1); + expect(repository.scoreStopwatchStates.length, 1); + expect(repository.stepProgressStates.length, 1); + }); + + test( + 'startSetExecution does not start a next timed step left stopped', + () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + timeEnabled: true, + repsEnabled: false, + exerciseSteps: _twoTimedSteps(), + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..stepProgressStates['step-state'] = ActiveExerciseStepProgressState( + metadata: _metadata('step-state'), + activeWorkoutSessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + currentPassageIndex: 0, + currentStepIndex: 1, + currentStepSnapshotId: 'step-2', + status: ActiveExerciseStepProgressStatus.stoppedTimer, + accumulatedMs: 0, + lastTransitionAt: clock.now(), + ); + final useCase = _activeUseCase(repository, clock); + + final started = await useCase.startSetExecution( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + + expect( + started.stepProgress?.status, + ActiveExerciseStepProgressStatus.stoppedTimer, + ); + expect(started.stepProgress?.currentStepIndex, 1); + expect(started.stepProgress?.startedAt, isNull); + }, + ); + + test('pause and resume restore running persistent timers', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + timeEnabled: true, + repsEnabled: false, + scoreEnabled: true, + scoreInputMode: ScoreInputMode.stopwatch, + exerciseSteps: [_step(type: ExerciseStepType.time)], + ); + final repository = _FakeActiveSessionRepository()..session = session; + final useCase = _activeUseCase(repository, clock); + + await useCase.startSetExecution( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + await useCase.startRestAfterSet( + sessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + ); + clock.value = clock.value.add(const Duration(seconds: 5)); + await useCase.pause(session.metadata.id); + + expect( + repository.setTimerStates.values.single.status, + ActiveSetTimerStatus.paused, + ); + expect(repository.setTimerStates.values.single.accumulatedMs, 5000); + expect( + repository.scoreStopwatchStates.values.single.status, + ActiveScoreStopwatchStatus.paused, + ); + expect( + repository.stepProgressStates.values.single.status, + ActiveExerciseStepProgressStatus.pausedTimer, + ); + final pausedRest = repository.restStates.values.single; + expect(pausedRest.pausedAt, clock.now()); + expect(pausedRest.remainingMillisecondsAt(clock.now()), 55000); + + clock.value = clock.value.add(const Duration(minutes: 2)); + await useCase.resume(session.metadata.id); + + expect( + repository.setTimerStates.values.single.status, + ActiveSetTimerStatus.running, + ); + expect( + repository.scoreStopwatchStates.values.single.status, + ActiveScoreStopwatchStatus.running, + ); + expect( + repository.stepProgressStates.values.single.status, + ActiveExerciseStepProgressStatus.runningTimer, + ); + final resumedRest = repository.restStates.values.single; + expect(resumedRest.pausedAt, isNull); + expect(resumedRest.accumulatedPausedMs, 120000); + expect(resumedRest.remainingMillisecondsAt(clock.now()), 55000); + }); + test('upsertSetResultAtPosition does not move session cursor', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = _sessionWithSnapshot( @@ -2830,6 +3019,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final results = []; final restStates = {}; + final setTimerStates = {}; final scoreStopwatchStates = {}; final stepProgressStates = {}; final stepResults = []; @@ -2864,6 +3054,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { .firstOrNull; } + @override + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return setTimerStates.values + .where( + (state) => + state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex && + state.metadata.deletedAt == null, + ) + .firstOrNull; + } + @override Future findExerciseStepProgressState({ required String sessionId, @@ -2910,6 +3119,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { .toList(); } + @override + Future> listSetTimerStates(String sessionId) async { + return setTimerStates.values + .where( + (state) => + state.activeWorkoutSessionId == sessionId && + state.metadata.deletedAt == null, + ) + .toList(); + } + @override Future> listExerciseStepProgressStates( String sessionId, @@ -2938,6 +3158,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { restStates[restState.metadata.id] = restState; } + @override + Future saveSetTimerState(ActiveSetTimerState state) async { + setTimerStates[state.metadata.id] = state; + } + @override Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async { scoreStopwatchStates[state.metadata.id] = state; @@ -3003,6 +3228,12 @@ ActiveWorkoutSession _sessionWithSnapshot({ required int currentSetIndex, int setsCount = 2, int targetReps = 10, + bool timeEnabled = false, + bool repsEnabled = true, + bool scoreEnabled = false, + int? targetTimeSeconds, + ScoreInputMode scoreInputMode = ScoreInputMode.manual, + int? targetScoreTimeMs, List exerciseSteps = const [], bool? autoStartNextTimedStepSnapshot = true, bool? autoStartNextTimedStepOverride, @@ -3012,10 +3243,13 @@ ActiveWorkoutSession _sessionWithSnapshot({ 'id': 'exercise-snapshot-1', 'exerciseNameSnapshot': 'Squat', 'setsCount': setsCount, - 'timeEnabled': false, - 'repsEnabled': true, - 'scoreEnabled': false, - 'targetReps': targetReps, + 'timeEnabled': timeEnabled, + 'repsEnabled': repsEnabled, + 'scoreEnabled': scoreEnabled, + 'targetTimeSeconds': targetTimeSeconds, + 'targetReps': repsEnabled ? targetReps : null, + 'targetScoreTimeMs': targetScoreTimeMs, + 'scoreInputModeSnapshot': scoreInputMode.name, 'exerciseStepsSnapshot': exerciseSteps .map((step) => step.toSnapshotJson()) .toList(), diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index d2c0094..5b8c3b0 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -258,6 +258,196 @@ void main() { expect(restored.steps[2].defaultTargetScoreTimeMs, 12000); }); + group('exercise repository round-trips exercise option combinations', () { + final now = DateTime.utc(2026, 7, 17, 12); + final cases = <_ExerciseRoundTripCase>[ + _ExerciseRoundTripCase( + label: 'reps without score or steps', + exercise: Exercise( + metadata: _metadata('exercise-combo-reps', now), + name: 'Pompes', + description: 'Simple reps target', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 12, + ), + ), + _ExerciseRoundTripCase( + label: 'reps without score and with steps', + exercise: Exercise( + metadata: _metadata('exercise-combo-reps-steps', now), + name: 'Complexe poids du corps', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 1, + steps: [ + _exerciseStep( + id: 'combo-reps-step-1', + position: 0, + name: 'Pompes', + type: ExerciseStepType.reps, + defaultTargetValue: 10, + ), + _exerciseStep( + id: 'combo-reps-step-2', + position: 1, + name: 'Gainage', + type: ExerciseStepType.time, + defaultTargetValue: 30, + ), + ], + ), + ), + _ExerciseRoundTripCase( + label: 'manual score without steps', + exercise: Exercise( + metadata: _metadata('exercise-combo-manual-score', now), + name: 'Charge max', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreLabel: 'Charge', + scoreUnit: 'kg', + defaultTargetScore: 0, + ), + ), + _ExerciseRoundTripCase( + label: 'manual score with unscored steps', + exercise: Exercise( + metadata: _metadata('exercise-combo-manual-score-steps', now), + name: 'Technique haltères', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Charge', + scoreUnit: 'kg', + defaultTargetReps: 8, + defaultTargetScore: 12.5, + steps: [ + _exerciseStep( + id: 'combo-manual-score-step-1', + position: 0, + name: 'Installation', + type: ExerciseStepType.time, + defaultTargetValue: 15, + ), + _exerciseStep( + id: 'combo-manual-score-step-2', + position: 1, + name: 'Serie', + type: ExerciseStepType.reps, + defaultTargetValue: 8, + ), + ], + ), + ), + _ExerciseRoundTripCase( + label: 'manual exercise score with manual scored steps', + exercise: Exercise( + metadata: _metadata('exercise-combo-manual-step-score', now), + name: 'Circuit precision', + hasTimeMeasure: true, + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Qualite', + scoreUnit: 'pts', + defaultTargetTimeSeconds: 45, + defaultTargetReps: 10, + defaultTargetScore: 80, + autoStartNextTimedStep: false, + steps: [ + _exerciseStep( + id: 'combo-manual-step-score-1', + position: 0, + name: 'Bloc reps', + type: ExerciseStepType.reps, + defaultTargetValue: 10, + hasScore: true, + scoreLabel: 'Amplitude', + scoreUnit: 'pts', + defaultTargetScore: 0, + ), + _exerciseStep( + id: 'combo-manual-step-score-2', + position: 1, + name: 'Bloc temps', + type: ExerciseStepType.time, + defaultTargetValue: 35, + hasScore: true, + scoreLabel: 'Tenue', + scoreUnit: 's', + defaultTargetScore: 35, + ), + ], + ), + ), + _ExerciseRoundTripCase( + label: 'stopwatch score without steps', + exercise: Exercise( + metadata: _metadata('exercise-combo-stopwatch-score', now), + name: 'Sprint chrono', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + defaultTargetTimeSeconds: 20, + defaultTargetScoreTimeMs: 11500, + ), + ), + _ExerciseRoundTripCase( + label: 'stopwatch score with stopwatch scored steps', + exercise: Exercise( + metadata: _metadata('exercise-combo-stopwatch-score-steps', now), + name: 'Sprint fractionne', + imageMediaIds: const ['media-start', 'media-finish'], + iconMediaId: 'media-start', + videoMediaId: 'video-demo', + hasTimeMeasure: true, + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + defaultTargetTimeSeconds: 60, + defaultTargetReps: 4, + defaultTargetScoreTimeMs: 42000, + steps: [ + _exerciseStep( + id: 'combo-stopwatch-step-1', + position: 0, + name: 'Acceleration', + type: ExerciseStepType.time, + defaultTargetValue: 10, + hasScore: true, + scoreInputMode: ScoreInputMode.stopwatch, + defaultTargetScoreTimeMs: 9500, + ), + _exerciseStep( + id: 'combo-stopwatch-step-2', + position: 1, + name: 'Recuperation active', + type: ExerciseStepType.reps, + defaultTargetValue: 6, + ), + ], + ), + ), + ]; + + for (final roundTripCase in cases) { + test(roundTripCase.label, () async { + await exerciseRepository.save(roundTripCase.exercise); + + final restored = await exerciseRepository.findById( + roundTripCase.exercise.metadata.id, + ); + + expect(restored, isNotNull); + _expectExerciseEquals(restored!, roundTripCase.exercise); + }); + } + }); + test( 'program exercise step snapshot is preserved when source exercise changes', () async { @@ -578,6 +768,94 @@ ProgramExercise _programExercise( ); } +ExerciseStep _exerciseStep({ + required String id, + required int position, + required String name, + required ExerciseStepType type, + required int defaultTargetValue, + bool hasScore = false, + ScoreInputMode scoreInputMode = ScoreInputMode.manual, + String? scoreLabel, + String? scoreUnit, + double? defaultTargetScore, + int? defaultTargetScoreTimeMs, +}) { + return ExerciseStep( + id: id, + position: position, + name: name, + type: type, + defaultTargetValue: defaultTargetValue, + hasScore: hasScore, + scoreInputMode: scoreInputMode, + scoreLabel: scoreLabel, + scoreUnit: scoreUnit, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + ); +} + +void _expectExerciseEquals(Exercise actual, Exercise expected) { + _expectMetadataEquals(actual.metadata, expected.metadata); + expect(actual.name, expected.name); + expect(actual.description, expected.description); + expect(actual.imageMediaIds, expected.imageMediaIds); + expect(actual.iconMediaId, expected.iconMediaId); + expect(actual.videoMediaId, expected.videoMediaId); + expect(actual.hasTimeMeasure, expected.hasTimeMeasure); + expect(actual.hasRepsMeasure, expected.hasRepsMeasure); + expect(actual.hasScoreMeasure, expected.hasScoreMeasure); + expect(actual.scoreInputMode, expected.scoreInputMode); + expect(actual.scoreLabel, expected.scoreLabel); + expect(actual.scoreUnit, expected.scoreUnit); + expect(actual.defaultTargetTimeSeconds, expected.defaultTargetTimeSeconds); + expect(actual.defaultTargetReps, expected.defaultTargetReps); + expect(actual.defaultTargetScore, expected.defaultTargetScore); + expect(actual.defaultTargetScoreTimeMs, expected.defaultTargetScoreTimeMs); + expect(actual.autoStartNextTimedStep, expected.autoStartNextTimedStep); + expect(actual.archivedAt?.toUtc(), expected.archivedAt?.toUtc()); + expect(actual.steps, hasLength(expected.steps.length)); + for (var index = 0; index < expected.steps.length; index++) { + _expectExerciseStepEquals(actual.steps[index], expected.steps[index]); + } +} + +void _expectExerciseStepEquals(ExerciseStep actual, ExerciseStep expected) { + expect(actual.id, expected.id); + expect(actual.position, expected.position); + expect(actual.name, expected.name); + expect(actual.type, expected.type); + expect(actual.defaultTargetValue, expected.defaultTargetValue); + expect(actual.hasScore, expected.hasScore); + expect(actual.scoreInputMode, expected.scoreInputMode); + expect(actual.scoreLabel, expected.scoreLabel); + expect(actual.scoreUnit, expected.scoreUnit); + expect(actual.defaultTargetScore, expected.defaultTargetScore); + expect(actual.defaultTargetScoreTimeMs, expected.defaultTargetScoreTimeMs); +} + +void _expectMetadataEquals(EntityMetadata actual, EntityMetadata expected) { + expect(actual.id, expected.id); + expect(actual.createdAt.toUtc(), expected.createdAt.toUtc()); + expect(actual.updatedAt.toUtc(), expected.updatedAt.toUtc()); + expect(actual.deletedAt?.toUtc(), expected.deletedAt?.toUtc()); + expect(actual.schemaVersion, expected.schemaVersion); + expect(actual.syncState, expected.syncState); + expect(actual.localRevision, expected.localRevision); + expect(actual.originDeviceId, expected.originDeviceId); + expect(actual.futureOwnerProfileId, expected.futureOwnerProfileId); + expect(actual.lastSyncedAt?.toUtc(), expected.lastSyncedAt?.toUtc()); + expect(actual.remoteRevision, expected.remoteRevision); +} + +final class _ExerciseRoundTripCase { + const _ExerciseRoundTripCase({required this.label, required this.exercise}); + + final String label; + final Exercise exercise; +} + final class _FakeClock implements Clock { const _FakeClock(this.value); diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index 7e56be5..0613548 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -430,6 +430,7 @@ void main() { final saved = exerciseRepository.saved.single; expect(saved.steps, hasLength(1)); + expect(saved.steps.single.id, isNot(startsWith('step-draft-'))); expect(saved.steps.single.name, 'Dribble main droite'); expect(saved.steps.single.type, ExerciseStepType.time); expect(saved.steps.single.defaultTargetValue, 12); diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart index 5169de6..20e4e5b 100644 --- a/test/presentation/history_screen_test.dart +++ b/test/presentation/history_screen_test.dart @@ -437,6 +437,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { required int setIndex, }) async => null; + @override + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async => null; + @override Future findExerciseStepProgressState({ required String sessionId, @@ -457,6 +465,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return const []; } + @override + Future> listSetTimerStates(String sessionId) async { + return const []; + } + @override Future> listSetResults(String sessionId) async { return const []; @@ -485,6 +498,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async {} + @override + Future saveSetTimerState(ActiveSetTimerState state) async {} + @override Future saveExerciseStepProgressState( ActiveExerciseStepProgressState state, diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 24b9ec0..4a3e500 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -474,6 +474,7 @@ final class _FakePendingShareActionRepository final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final restStates = []; + final setTimerStates = []; final scoreStopwatchStates = []; final results = []; @@ -498,6 +499,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return null; } + @override + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return null; + } + @override Future findExerciseStepProgressState({ required String sessionId, @@ -520,6 +531,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return scoreStopwatchStates; } + @override + Future> listSetTimerStates(String sessionId) async { + return setTimerStates; + } + @override Future> listSetResults(String sessionId) async { return results; @@ -554,6 +570,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { scoreStopwatchStates.add(state); } + @override + Future saveSetTimerState(ActiveSetTimerState state) async { + setTimerStates.add(state); + } + @override Future saveExerciseStepProgressState( ActiveExerciseStepProgressState state, diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index fe2667c..13f5af7 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -40,7 +40,9 @@ void main() { ), ); - expect(find.text('00:45'), findsOneWidget); + expect(find.text('Squat'), findsOneWidget); + expect(find.text('Temps de série : 00:45'), findsOneWidget); + expect(find.text('Démarrer l’exercice'), findsOneWidget); }); testWidgets('les répétitions sont initialisées avec la cible', ( @@ -253,7 +255,7 @@ void main() { expect(find.byTooltip('Voir les médias de l’exercice'), findsOneWidget); - await tester.tap(find.text('Voir médias')); + await tester.tap(find.byTooltip('Voir les médias de l’exercice')); await tester.runAsync(() async { await tester.pumpAndSettle(); }); @@ -319,7 +321,7 @@ void main() { ); }); - await tester.tap(find.text('Voir médias')); + await tester.tap(find.byTooltip('Voir les médias de l’exercice')); await tester.runAsync(() async { await tester.pumpAndSettle(); }); @@ -377,7 +379,7 @@ void main() { expect(find.text('Chrono score'), findsOneWidget); expect(find.text('00:00.0'), findsOneWidget); - await tester.tap(find.text('Démarrer')); + await tester.tap(find.widgetWithText(FilledButton, 'Démarrer')); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 100); await tester.pump(const Duration(milliseconds: 100)); @@ -394,7 +396,7 @@ void main() { expect(find.text('00:01.2'), findsOneWidget); - await tester.tap(find.text('Arrêter')); + await tester.tap(find.widgetWithText(FilledButton, 'Arrêter')); await tester.pump(); expect(find.text('00:01.2'), findsOneWidget); @@ -443,9 +445,15 @@ void main() { await tester.tap(find.text('Terminer la série')); await tester.pumpAndSettle(); - expect(find.text('Aucun temps chronométré'), findsOneWidget); - expect(find.text("Tu n'as pas démarré le chrono score."), findsOneWidget); - expect(find.text('Démarrer le chrono'), findsOneWidget); + expect(find.text('Chrono non lancé'), findsOneWidget); + expect( + find.text("Tu n’as pas démarré le chrono de cette série."), + findsOneWidget, + ); + expect( + find.widgetWithText(TextButton, 'Démarrer l’exercice'), + findsOneWidget, + ); expect(find.text('Terminer sans chrono'), findsOneWidget); expect(repository.results, isEmpty); }); @@ -487,7 +495,7 @@ void main() { ), ); - await tester.tap(find.text('Démarrer')); + await tester.tap(find.text('Démarrer l’exercice')); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2, 500); await tester.pump(const Duration(seconds: 1)); @@ -565,6 +573,59 @@ void main() { expect(repository.restStates.single.skippedAt, isNotNull); }); + testWidgets('un repos en pause est repris sans compter la durée de pause', ( + 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; + final now = DateTime.now().toUtc(); + repository.restStates.add( + ActiveRestState( + metadata: _metadata('rest-1'), + activeWorkoutSessionId: 'session-1', + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: now.subtract(const Duration(minutes: 10, seconds: 15)), + pausedAt: now.subtract(const Duration(minutes: 10)), + ), + ); + + 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); + }); + testWidgets('la mesure temps enregistre une durée réelle de série', ( tester, ) async { @@ -601,11 +662,14 @@ void main() { ), ), ); + await tester.tap(find.text('Démarrer l’exercice')); + await tester.pump(); + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2); 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)); + expect(repository.results.single.actualTimeMs, 2000); }); testWidgets( @@ -650,9 +714,12 @@ void main() { ); expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); - expect(find.text('SÉRIE'), findsOneWidget); - expect(find.text('2 / 3'), findsOneWidget); - expect(find.text('30 s'), findsOneWidget); + expect(find.text('Série 2/3'), findsOneWidget); + expect(find.text('Squat'), findsOneWidget); + expect(find.text('Temps de série : 00:30'), findsOneWidget); + + await tester.tap(find.text('Démarrer l’exercice')); + await tester.pump(); for (var i = 0; i < 2; i++) { await tester.tap(find.byIcon(Icons.remove)); @@ -672,7 +739,7 @@ void main() { expect(repository.results.single.actualScore, 5); expect(repository.session?.currentSetIndex, 2); expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); - expect(find.text('3 / 3'), findsOneWidget); + expect(find.text('Série 3/3'), findsOneWidget); expect(find.text('5'), findsOneWidget); }, ); @@ -959,7 +1026,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); - expect(find.text('3 / 4'), findsOneWidget); + expect(find.text('Série 3/4'), findsOneWidget); }, ); @@ -1005,7 +1072,7 @@ void main() { expect(find.text('Plan de séance'), findsNothing); expect(find.text('Modifier la série'), findsNothing); expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); - expect(find.text('2 / 3'), findsOneWidget); + expect(find.text('Série 2/3'), findsOneWidget); }, ); @@ -1052,6 +1119,7 @@ void main() { ); await tester.pump(); + expect(find.byType(ListView), findsNothing); expect(find.text('SÉQUENCE'), findsOneWidget); expect(find.text('Passage 1 / 10'), findsOneWidget); expect(find.text('ÉTAPE 1 / 2'), findsOneWidget); @@ -1147,7 +1215,7 @@ void main() { await tester.pump(); expect(find.text('00:05'), findsOneWidget); - await tester.tap(find.text('Démarrer la séquence')); + await tester.tap(find.widgetWithText(FilledButton, 'Démarrer la séquence')); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2); await tester.pump(const Duration(milliseconds: 100)); @@ -1156,6 +1224,68 @@ void main() { expect(audio.shortBeeps, 1); }); + testWidgets( + 'terminer sans démarrer la première étape temps affiche une confirmation', + (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 1600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository(); + final audio = _FakeStepAudioCuePlayer(); + 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( + timeEnabled: false, + scoreEnabled: false, + targetReps: 1, + exerciseSteps: [ + _step('step-1', 0, 'Gainage', ExerciseStepType.time, 5), + ], + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: audio, + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Terminer la série')); + await tester.pumpAndSettle(); + + expect(find.text('Chrono non lancé'), findsOneWidget); + expect( + find.text("Tu n’as pas démarré le chrono de cette série."), + findsOneWidget, + ); + expect( + find.widgetWithText(TextButton, 'Démarrer l’exercice'), + findsOneWidget, + ); + expect(find.text('Terminer sans chrono'), findsOneWidget); + expect(repository.results, isEmpty); + }, + ); + testWidgets('deux étapes temps consécutives s’enchaînent automatiquement', ( tester, ) async { @@ -1200,7 +1330,7 @@ void main() { ); await tester.pump(); - await tester.tap(find.text('Démarrer la séquence')); + await tester.tap(find.widgetWithText(FilledButton, 'Démarrer la séquence')); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200); await tester.pump(const Duration(milliseconds: 200)); @@ -1262,7 +1392,9 @@ void main() { ); await tester.pump(); - await tester.tap(find.text('Démarrer la séquence')); + await tester.tap( + find.widgetWithText(FilledButton, 'Démarrer la séquence'), + ); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200); await tester.pump(const Duration(milliseconds: 200)); @@ -1280,7 +1412,7 @@ void main() { ); expect(repository.stepProgressStates.single.startedAt, isNull); - await tester.tap(find.text('Démarrer le chrono')); + await tester.tap(find.widgetWithText(FilledButton, 'Démarrer le chrono')); await tester.pump(); expect( @@ -1438,6 +1570,8 @@ void main() { expect(repository.stepResults.single.stepIndex, 0); expect(repository.stepResults.single.status, SetResultStatus.skipped); + await tester.tap(find.byTooltip('Plus d’actions')); + await tester.pumpAndSettle(); await tester.tap(find.text('Passer ce passage')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Passer ce passage')); @@ -1445,6 +1579,8 @@ void main() { expect(find.text('Passage 2 / 2'), findsOneWidget); + await tester.tap(find.byTooltip('Plus d’actions')); + await tester.pumpAndSettle(); await tester.tap(find.text('Passer la séquence')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Passer la séquence')); @@ -1529,6 +1665,9 @@ WorkoutTemplateUseCases _workoutTemplateUseCases() { String _sessionSnapshot({ int setsCount = 3, int restSeconds = 0, + bool timeEnabled = true, + bool repsEnabled = true, + bool scoreEnabled = true, int targetTimeSeconds = 45, int targetReps = 10, double targetScore = 80, @@ -1552,9 +1691,9 @@ String _sessionSnapshot({ 'id': 'exercise-1', 'exerciseNameSnapshot': 'Squat', 'setsCount': setsCount, - 'timeEnabled': true, - 'repsEnabled': true, - 'scoreEnabled': true, + 'timeEnabled': timeEnabled, + 'repsEnabled': repsEnabled, + 'scoreEnabled': scoreEnabled, 'targetTimeSeconds': targetTimeSeconds, 'targetReps': targetReps, 'targetScore': targetScore, @@ -1660,6 +1799,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { final results = []; final restStates = []; final scoreStopwatchStates = []; + final setTimerStates = []; final stepProgressStates = []; final stepResults = []; @@ -1700,6 +1840,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return null; } + @override + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + for (final state in setTimerStates) { + if (state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex && + state.metadata.deletedAt == null) { + return state; + } + } + return null; + } + @override Future findExerciseStepProgressState({ required String sessionId, @@ -1740,6 +1899,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return results; } + @override + Future> listSetTimerStates(String sessionId) async { + return setTimerStates + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + @override Future> listExerciseStepProgressStates( String sessionId, @@ -1787,6 +1953,22 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { } } + @override + Future saveSetTimerState(ActiveSetTimerState state) async { + final index = setTimerStates.indexWhere( + (saved) => + saved.activeWorkoutSessionId == state.activeWorkoutSessionId && + saved.programIndex == state.programIndex && + saved.exerciseIndex == state.exerciseIndex && + saved.setIndex == state.setIndex, + ); + if (index == -1) { + setTimerStates.add(state); + } else { + setTimerStates[index] = state; + } + } + @override Future saveExerciseStepProgressState( ActiveExerciseStepProgressState state, diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index 8e92469..e585574 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -523,6 +523,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return null; } + @override + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return null; + } + @override Future findExerciseStepProgressState({ required String sessionId, @@ -545,6 +555,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return const []; } + @override + Future> listSetTimerStates(String sessionId) async { + return const []; + } + @override Future> listSetResults(String sessionId) async { return const []; @@ -576,6 +591,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async {} + @override + Future saveSetTimerState(ActiveSetTimerState state) async {} + @override Future saveExerciseStepProgressState( ActiveExerciseStepProgressState state,