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,