diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 2e327ad..491465c 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -1408,28 +1408,76 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { Future saveScoreStopwatchState( domain.ActiveScoreStopwatchState state, ) async { - await _upsertWithChangeLog( - database: database, - tableName: 'active_score_stopwatch_states', - entityType: 'ActiveScoreStopwatchState', - metadata: state.metadata, - write: () => database - .into(database.activeScoreStopwatchStates) - .insertOnConflictUpdate(_activeScoreStopwatchStateCompanion(state)), - ); + await database.transaction(() async { + final existing = await _findActiveScoreStopwatchStateRowByPosition( + database, + state.activeWorkoutSessionId, + state.programIndex, + state.exerciseIndex, + state.setIndex, + ); + final effectiveState = existing == null + ? state + : state.copyWith( + metadata: _metadataWithStoredIdentity( + state.metadata, + _metadataFromRow(existing), + ), + ); + final operation = existing == null + ? 'insert' + : _operationForExistingMutation( + existingDeletedAt: existing.deletedAt, + deletedAt: effectiveState.metadata.deletedAt, + ); + await _upsertActiveScoreStopwatchState(database, effectiveState); + await _writeChangeLog( + database: database, + entityType: 'ActiveScoreStopwatchState', + entityId: effectiveState.metadata.id, + operation: operation, + localRevision: effectiveState.metadata.localRevision, + originDeviceId: effectiveState.metadata.originDeviceId, + createdAt: effectiveState.metadata.updatedAt, + ); + }); } @override Future saveManualScoreState(domain.ActiveManualScoreState state) async { - await _upsertWithChangeLog( - database: database, - tableName: 'active_manual_score_states', - entityType: 'ActiveManualScoreState', - metadata: state.metadata, - write: () => database - .into(database.activeManualScoreStates) - .insertOnConflictUpdate(_activeManualScoreStateCompanion(state)), - ); + await database.transaction(() async { + final existing = await _findActiveManualScoreStateRowByPosition( + database, + state.activeWorkoutSessionId, + state.programIndex, + state.exerciseIndex, + state.setIndex, + ); + final effectiveState = existing == null + ? state + : state.copyWith( + metadata: _metadataWithStoredIdentity( + state.metadata, + _metadataFromRow(existing), + ), + ); + final operation = existing == null + ? 'insert' + : _operationForExistingMutation( + existingDeletedAt: existing.deletedAt, + deletedAt: effectiveState.metadata.deletedAt, + ); + await _upsertActiveManualScoreState(database, effectiveState); + await _writeChangeLog( + database: database, + entityType: 'ActiveManualScoreState', + entityId: effectiveState.metadata.id, + operation: operation, + localRevision: effectiveState.metadata.localRevision, + originDeviceId: effectiveState.metadata.originDeviceId, + createdAt: effectiveState.metadata.updatedAt, + ); + }); } @override @@ -1486,14 +1534,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { return; } final revision = row.localRevision + 1; - await (database.delete( + await (database.update( database.activeScoreStopwatchStates, - )..where((table) => table.id.equals(row.id))).go(); + )..where((table) => table.id.equals(row.id))).write( + db.ActiveScoreStopwatchStatesCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); await _writeChangeLog( database: database, entityType: 'ActiveScoreStopwatchState', entityId: row.id, - operation: 'delete', + operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, @@ -1522,14 +1577,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { return; } final revision = row.localRevision + 1; - await (database.delete( + await (database.update( database.activeManualScoreStates, - )..where((table) => table.id.equals(row.id))).go(); + )..where((table) => table.id.equals(row.id))).write( + db.ActiveManualScoreStatesCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); await _writeChangeLog( database: database, entityType: 'ActiveManualScoreState', entityId: row.id, - operation: 'delete', + operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, @@ -4277,6 +4339,184 @@ Future _updateActiveRestPauseFields( ); } +Future +_findActiveScoreStopwatchStateRowByPosition( + db.AppDatabase database, + String sessionId, + int programIndex, + int exerciseIndex, + int setIndex, +) { + return (database.select(database.activeScoreStopwatchStates)..where( + (table) => + table.activeWorkoutSessionId.equals(sessionId) & + table.programIndex.equals(programIndex) & + table.exerciseIndex.equals(exerciseIndex) & + table.setIndex.equals(setIndex), + )) + .getSingleOrNull(); +} + +Future _findActiveManualScoreStateRowByPosition( + db.AppDatabase database, + String sessionId, + int programIndex, + int exerciseIndex, + int setIndex, +) { + return (database.select(database.activeManualScoreStates)..where( + (table) => + table.activeWorkoutSessionId.equals(sessionId) & + table.programIndex.equals(programIndex) & + table.exerciseIndex.equals(exerciseIndex) & + table.setIndex.equals(setIndex), + )) + .getSingleOrNull(); +} + +domain.EntityMetadata _metadataWithStoredIdentity( + domain.EntityMetadata metadata, + domain.EntityMetadata stored, +) { + return domain.EntityMetadata( + id: stored.id, + createdAt: stored.createdAt, + updatedAt: metadata.updatedAt, + deletedAt: metadata.deletedAt, + schemaVersion: metadata.schemaVersion, + syncState: metadata.syncState, + localRevision: metadata.localRevision, + originDeviceId: metadata.originDeviceId, + futureOwnerProfileId: metadata.futureOwnerProfileId, + lastSyncedAt: metadata.lastSyncedAt, + remoteRevision: metadata.remoteRevision, + ); +} + +String _operationForExistingMutation({ + required DateTime? existingDeletedAt, + required DateTime? deletedAt, +}) { + if (deletedAt != null) { + return 'softDelete'; + } + if (existingDeletedAt != null) { + return 'restore'; + } + return 'update'; +} + +Future _upsertActiveScoreStopwatchState( + db.AppDatabase database, + domain.ActiveScoreStopwatchState state, +) async { + final metadata = state.metadata; + await database.customInsert( + 'INSERT INTO active_score_stopwatch_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' + ') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(active_workout_session_id, program_index, exercise_index, ' + 'set_index) DO UPDATE SET ' + 'id = active_score_stopwatch_states.id, ' + 'created_at = active_score_stopwatch_states.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', + 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(_scoreStopwatchStatusToDb(state.status)), + Variable(state.startedAt.toUtc()), + Variable(state.accumulatedMs), + Variable(_utcOrNull(state.stoppedAt)), + ], + ); +} + +Future _upsertActiveManualScoreState( + db.AppDatabase database, + domain.ActiveManualScoreState state, +) async { + final metadata = state.metadata; + await database.customInsert( + 'INSERT INTO active_manual_score_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, value, score_updated_at' + ') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(active_workout_session_id, program_index, exercise_index, ' + 'set_index) DO UPDATE SET ' + 'id = active_manual_score_states.id, ' + 'created_at = active_manual_score_states.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, ' + 'value = excluded.value, ' + 'score_updated_at = excluded.score_updated_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(state.value), + Variable(state.updatedAt.toUtc()), + ], + ); +} + Future _upsertActiveSetTimerState( db.AppDatabase database, domain.ActiveSetTimerState state, @@ -4459,58 +4699,6 @@ String? _activeMeasuresPredicate(ActivePerformanceMeasures measures) { }; } -db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion( - domain.ActiveScoreStopwatchState state, -) { - final values = _metadataValues(state.metadata); - return db.ActiveScoreStopwatchStatesCompanion( - id: values[0] as Value, - createdAt: values[1] as Value, - updatedAt: values[2] as Value, - deletedAt: values[3] as Value, - schemaVersion: values[4] as Value, - syncState: values[5] as Value, - localRevision: values[6] as Value, - originDeviceId: values[7] as Value, - futureOwnerProfileId: values[8] as Value, - lastSyncedAt: values[9] as Value, - remoteRevision: values[10] as Value, - activeWorkoutSessionId: Value(state.activeWorkoutSessionId), - programIndex: Value(state.programIndex), - exerciseIndex: Value(state.exerciseIndex), - setIndex: Value(state.setIndex), - status: Value(_scoreStopwatchStatusToDb(state.status)), - startedAt: Value(state.startedAt.toUtc()), - accumulatedMs: Value(state.accumulatedMs), - stoppedAt: Value(_utcOrNull(state.stoppedAt)), - ); -} - -db.ActiveManualScoreStatesCompanion _activeManualScoreStateCompanion( - domain.ActiveManualScoreState state, -) { - final values = _metadataValues(state.metadata); - return db.ActiveManualScoreStatesCompanion( - id: values[0] as Value, - createdAt: values[1] as Value, - updatedAt: values[2] as Value, - deletedAt: values[3] as Value, - schemaVersion: values[4] as Value, - syncState: values[5] as Value, - localRevision: values[6] as Value, - originDeviceId: values[7] as Value, - futureOwnerProfileId: values[8] as Value, - lastSyncedAt: values[9] as Value, - remoteRevision: values[10] as Value, - activeWorkoutSessionId: Value(state.activeWorkoutSessionId), - programIndex: Value(state.programIndex), - exerciseIndex: Value(state.exerciseIndex), - setIndex: Value(state.setIndex), - value: Value(state.value), - scoreUpdatedAt: Value(state.updatedAt.toUtc()), - ); -} - domain.ActiveManualScoreState _activeManualScoreStateFromRow( db.ActiveManualScoreState row, ) { diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index ea33cf3..94bf1d2 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -12,7 +12,7 @@ import 'exercise_step_audio.dart'; import 'history_screen.dart'; import 'theme.dart'; -enum WorkoutExecutionMode { active, rest, paused, finished } +enum WorkoutExecutionMode { active, rest, paused, finished, resumingSavedExit } typedef VideoMediaBuilder = Widget Function(BuildContext context, MediaAsset asset); @@ -117,9 +117,7 @@ final class _WorkoutExecutionScreenState extends State { (widget.stepUseCases == null ? const NoOpExerciseStepAudioCuePlayer() : AudioplayersExerciseStepAudioCuePlayer()); - _mode = _session.status == ActiveWorkoutStatus.running - ? WorkoutExecutionMode.active - : WorkoutExecutionMode.paused; + _mode = _modeForSessionStatus(_session.status); _reps = _initialRepsFor(_exercise); _performanceReference = _loadPerformanceReference(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { @@ -136,11 +134,16 @@ final class _WorkoutExecutionScreenState extends State { } setState(() => _sensorState = state); }); - unawaited(_loadSetTimer()); - unawaited(_loadScoreStopwatch()); - unawaited(_syncManualScoreInput(force: true)); - unawaited(_loadStepProgress()); - unawaited(_restoreActiveRest()); + final resumingSavedExit = _session.status == ActiveWorkoutStatus.savedExit; + if (resumingSavedExit) { + unawaited(_resumeSavedExitSession()); + } else { + unawaited(_loadSetTimer()); + unawaited(_loadScoreStopwatch()); + unawaited(_syncManualScoreInput(force: true)); + unawaited(_loadStepProgress()); + unawaited(_restoreActiveRest()); + } } @override @@ -225,7 +228,8 @@ final class _WorkoutExecutionScreenState extends State { onPressed: _openWorkoutPlan, icon: const Icon(Icons.list_alt), ), - TextButton(onPressed: _pause, child: const Text('Pause')), + if (_canPauseFromNavigation) + TextButton(onPressed: _pause, child: const Text('Pause')), ], ), bottomNavigationBar: _mode == WorkoutExecutionMode.active @@ -243,6 +247,7 @@ final class _WorkoutExecutionScreenState extends State { WorkoutExecutionMode.rest => _buildRest(), WorkoutExecutionMode.paused => _buildPaused(), WorkoutExecutionMode.finished => _buildFinished(), + WorkoutExecutionMode.resumingSavedExit => _buildResumingSavedExit(), }, ), ); @@ -510,6 +515,22 @@ final class _WorkoutExecutionScreenState extends State { ); } + Widget _buildResumingSavedExit() { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Reprise de la séance...'), + ], + ), + ), + ); + } + Widget _buildFinished() { final elapsed = Duration( milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(_session), @@ -1221,7 +1242,9 @@ final class _WorkoutExecutionScreenState extends State { } Future _syncExternalSessionChanges() async { - if (_externalSyncInFlight || !mounted) { + if (_externalSyncInFlight || + !mounted || + _mode == WorkoutExecutionMode.resumingSavedExit) { return; } _externalSyncInFlight = true; @@ -1250,16 +1273,7 @@ final class _WorkoutExecutionScreenState extends State { return; } setState(() { - _mode = switch (session.status) { - ActiveWorkoutStatus.running => - _activeRestStateId == null - ? WorkoutExecutionMode.active - : WorkoutExecutionMode.rest, - ActiveWorkoutStatus.paused => WorkoutExecutionMode.paused, - ActiveWorkoutStatus.completed || - ActiveWorkoutStatus.abandoned || - ActiveWorkoutStatus.savedExit => WorkoutExecutionMode.finished, - }; + _mode = _modeForSessionStatus(session.status); }); _refreshScoreStopwatchTicker(); _refreshStepTicker(); @@ -1400,6 +1414,19 @@ final class _WorkoutExecutionScreenState extends State { _mode == WorkoutExecutionMode.rest; } + WorkoutExecutionMode _modeForSessionStatus(ActiveWorkoutStatus status) { + return switch (status) { + ActiveWorkoutStatus.running => + _activeRestStateId == null + ? WorkoutExecutionMode.active + : WorkoutExecutionMode.rest, + ActiveWorkoutStatus.paused => WorkoutExecutionMode.paused, + ActiveWorkoutStatus.savedExit => WorkoutExecutionMode.resumingSavedExit, + ActiveWorkoutStatus.completed || + ActiveWorkoutStatus.abandoned => WorkoutExecutionMode.finished, + }; + } + void _handleSystemBack(bool didPop, Object? result) { if (didPop || !_canPauseFromNavigation) return; unawaited(_pause()); @@ -1443,6 +1470,13 @@ final class _WorkoutExecutionScreenState extends State { _refreshStepTicker(); } + Future _resumeSavedExitSession() async { + if (!mounted || _mode != WorkoutExecutionMode.resumingSavedExit) { + return; + } + await _resume(); + } + Future _quitAndSave() async { await widget.activeUseCases.quitAndSave(_session.metadata.id); if (!mounted) return; @@ -2777,6 +2811,8 @@ final class _CurrentStepPane extends StatelessWidget { step.type == ExerciseStepType.time && step.hasScore && step.scoreInputMode == ScoreInputMode.manual; + final useCompactRepsActions = + step.type == ExerciseStepType.reps && constraints.maxHeight < 180; if (hasRepsWithStopwatchScore || hasTimedWithManualScore) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2900,7 +2936,8 @@ final class _CurrentStepPane extends StatelessWidget { ), ], const SizedBox(height: 4), - if (step.type == ExerciseStepType.reps) ...[ + if (step.type == ExerciseStepType.reps && + !useCompactRepsActions) ...[ FilledButton.icon( onPressed: onCompleteStep, style: FilledButton.styleFrom( @@ -2922,6 +2959,19 @@ final class _CurrentStepPane extends StatelessWidget { child: const Text('Passer l’étape'), ), ), + if (useCompactRepsActions) ...[ + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + onPressed: onCompleteStep, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + icon: const Icon(Icons.check), + label: const Text('Étape suivante'), + ), + ), + ], const SizedBox(width: 8), PopupMenuButton<_StepSkipAction>( tooltip: 'Plus d’actions', diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 7cfdb99..6350b11 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -2067,6 +2067,188 @@ CREATE TABLE pending_share_actions ( }, ); + test( + 'active score states are soft deleted with softDelete change log entries', + () async { + final now = DateTime.utc(2026, 7, 17, 12); + await activeRepository.save( + ActiveWorkoutSession( + metadata: _metadata('session-score-delete', now), + status: ActiveWorkoutStatus.running, + startedAt: now, + lastPersistedAt: now, + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _resolvedSnapshot(), + ), + ); + await activeRepository.saveScoreStopwatchState( + ActiveScoreStopwatchState( + metadata: _metadata('score-stopwatch-delete', now), + activeWorkoutSessionId: 'session-score-delete', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveScoreStopwatchStatus.stopped, + startedAt: now, + accumulatedMs: 12000, + stoppedAt: now.add(const Duration(seconds: 12)), + ), + ); + await activeRepository.saveManualScoreState( + ActiveManualScoreState( + metadata: _metadata('manual-score-delete', now), + activeWorkoutSessionId: 'session-score-delete', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + value: 3, + updatedAt: now, + ), + ); + + final deletedAt = now.add(const Duration(minutes: 1)); + await activeRepository.deleteScoreStopwatchState( + sessionId: 'session-score-delete', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + deletedAt: deletedAt, + ); + await activeRepository.deleteManualScoreState( + sessionId: 'session-score-delete', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + deletedAt: deletedAt, + ); + + final stopwatchRow = + await (database.select(database.activeScoreStopwatchStates) + ..where((table) => table.id.equals('score-stopwatch-delete'))) + .getSingle(); + final manualRow = await (database.select( + database.activeManualScoreStates, + )..where((table) => table.id.equals('manual-score-delete'))).getSingle(); + final changes = + await (database.select(database.changeLogEntries)..where( + (table) => + table.entityId.isIn([ + 'score-stopwatch-delete', + 'manual-score-delete', + ]) & + table.localRevision.equals(1), + )) + .get(); + + expect(stopwatchRow.deletedAt?.toUtc(), deletedAt); + expect(stopwatchRow.syncState, 'deleted'); + expect(manualRow.deletedAt?.toUtc(), deletedAt); + expect(manualRow.syncState, 'deleted'); + expect(changes.map((change) => change.operation), [ + 'softDelete', + 'softDelete', + ]); + }, + ); + + test( + 'active score states keep stored identity when upserted by position', + () async { + final now = DateTime.utc(2026, 7, 17, 12); + await activeRepository.save( + ActiveWorkoutSession( + metadata: _metadata('session-score-upsert', now), + status: ActiveWorkoutStatus.running, + startedAt: now, + lastPersistedAt: now, + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _resolvedSnapshot(), + ), + ); + await activeRepository.saveScoreStopwatchState( + ActiveScoreStopwatchState( + metadata: _metadata('score-stopwatch-original', now), + activeWorkoutSessionId: 'session-score-upsert', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveScoreStopwatchStatus.running, + startedAt: now, + accumulatedMs: 0, + ), + ); + await activeRepository.saveManualScoreState( + ActiveManualScoreState( + metadata: _metadata('manual-score-original', now), + activeWorkoutSessionId: 'session-score-upsert', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + value: 1, + updatedAt: now, + ), + ); + + final updatedAt = now.add(const Duration(minutes: 1)); + await activeRepository.saveScoreStopwatchState( + ActiveScoreStopwatchState( + metadata: _metadata('score-stopwatch-new-id', updatedAt, 1), + activeWorkoutSessionId: 'session-score-upsert', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveScoreStopwatchStatus.paused, + startedAt: updatedAt, + accumulatedMs: 30000, + stoppedAt: updatedAt, + ), + ); + await activeRepository.saveManualScoreState( + ActiveManualScoreState( + metadata: _metadata('manual-score-new-id', updatedAt, 1), + activeWorkoutSessionId: 'session-score-upsert', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + value: 4, + updatedAt: updatedAt, + ), + ); + + final stopwatchRow = + await (database.select(database.activeScoreStopwatchStates) + ..where((table) => table.id.equals('score-stopwatch-original'))) + .getSingle(); + final manualRow = + await (database.select(database.activeManualScoreStates) + ..where((table) => table.id.equals('manual-score-original'))) + .getSingle(); + final changes = + await (database.select(database.changeLogEntries)..where( + (table) => + table.entityId.isIn([ + 'score-stopwatch-original', + 'manual-score-original', + ]) & + table.operation.equals('update'), + )) + .get(); + + expect(stopwatchRow.createdAt.toUtc(), now); + expect(stopwatchRow.status, 'paused'); + expect(stopwatchRow.accumulatedMs, 30000); + expect(manualRow.createdAt.toUtc(), now); + expect(manualRow.value, 4); + expect(changes.map((change) => change.localRevision), [1, 1]); + }, + ); + test( 'closing a session stores autonomous history rows with set snapshots', () async { diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 2dc3706..1c4a041 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -88,6 +89,60 @@ void main() { expect(repository.session?.status, ActiveWorkoutStatus.abandoned); }); + testWidgets( + 'une séance sauvegardée est reprise sans afficher un faux état terminé', + (tester) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15)); + final resumeGate = Completer(); + final repository = _FakeActiveSessionRepository() + ..findByIdBlocker = resumeGate.future; + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + sourceWorkoutTemplateId: 'template-1', + status: ActiveWorkoutStatus.savedExit, + startedAt: DateTime.utc(2026, 7, 17, 11, 59), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 30000, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot(), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + await tester.pump(); + + expect(find.text('Reprise de la séance...'), findsOneWidget); + expect(find.text('Pause'), findsNothing); + expect(find.text('Séance terminée'), findsNothing); + + await tester.pump(const Duration(seconds: 2)); + + expect(find.text('Reprise de la séance...'), findsOneWidget); + expect(find.text('Séance terminée'), findsNothing); + expect(repository.session?.status, ActiveWorkoutStatus.savedExit); + + resumeGate.complete(); + await tester.pump(); + await tester.pump(); + + expect(repository.session?.status, ActiveWorkoutStatus.running); + expect(find.text('Squat'), findsOneWidget); + expect(find.text('Séance terminée'), findsNothing); + }, + ); + testWidgets('les répétitions sont initialisées avec la cible', ( tester, ) async { @@ -2678,6 +2733,7 @@ final class _FakeExercisePerformanceReferenceRepository final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; + Future? findByIdBlocker; final results = []; final restStates = []; final scoreStopwatchStates = []; @@ -2688,6 +2744,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future findById(String id) async { + await findByIdBlocker; return session?.metadata.id == id ? session : null; }