merge(develop): integre correctifs integrite session valides QA (#190 #191 #192)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 09:43:48 +02:00
4 changed files with 575 additions and 98 deletions

View File

@ -1408,28 +1408,76 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
Future<void> saveScoreStopwatchState( Future<void> saveScoreStopwatchState(
domain.ActiveScoreStopwatchState state, domain.ActiveScoreStopwatchState state,
) async { ) async {
await _upsertWithChangeLog( await database.transaction(() async {
database: database, final existing = await _findActiveScoreStopwatchStateRowByPosition(
tableName: 'active_score_stopwatch_states', database,
entityType: 'ActiveScoreStopwatchState', state.activeWorkoutSessionId,
metadata: state.metadata, state.programIndex,
write: () => database state.exerciseIndex,
.into(database.activeScoreStopwatchStates) state.setIndex,
.insertOnConflictUpdate(_activeScoreStopwatchStateCompanion(state)), );
); 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 @override
Future<void> saveManualScoreState(domain.ActiveManualScoreState state) async { Future<void> saveManualScoreState(domain.ActiveManualScoreState state) async {
await _upsertWithChangeLog( await database.transaction(() async {
database: database, final existing = await _findActiveManualScoreStateRowByPosition(
tableName: 'active_manual_score_states', database,
entityType: 'ActiveManualScoreState', state.activeWorkoutSessionId,
metadata: state.metadata, state.programIndex,
write: () => database state.exerciseIndex,
.into(database.activeManualScoreStates) state.setIndex,
.insertOnConflictUpdate(_activeManualScoreStateCompanion(state)), );
); 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 @override
@ -1486,14 +1534,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return; return;
} }
final revision = row.localRevision + 1; final revision = row.localRevision + 1;
await (database.delete( await (database.update(
database.activeScoreStopwatchStates, 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( await _writeChangeLog(
database: database, database: database,
entityType: 'ActiveScoreStopwatchState', entityType: 'ActiveScoreStopwatchState',
entityId: row.id, entityId: row.id,
operation: 'delete', operation: 'softDelete',
localRevision: revision, localRevision: revision,
originDeviceId: row.originDeviceId, originDeviceId: row.originDeviceId,
createdAt: deletedAt, createdAt: deletedAt,
@ -1522,14 +1577,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return; return;
} }
final revision = row.localRevision + 1; final revision = row.localRevision + 1;
await (database.delete( await (database.update(
database.activeManualScoreStates, 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( await _writeChangeLog(
database: database, database: database,
entityType: 'ActiveManualScoreState', entityType: 'ActiveManualScoreState',
entityId: row.id, entityId: row.id,
operation: 'delete', operation: 'softDelete',
localRevision: revision, localRevision: revision,
originDeviceId: row.originDeviceId, originDeviceId: row.originDeviceId,
createdAt: deletedAt, createdAt: deletedAt,
@ -4277,6 +4339,184 @@ Future<void> _updateActiveRestPauseFields(
); );
} }
Future<db.ActiveScoreStopwatchState?>
_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<db.ActiveManualScoreState?> _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<void> _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<String>(metadata.id),
Variable<DateTime>(metadata.createdAt.toUtc()),
Variable<DateTime>(metadata.updatedAt.toUtc()),
Variable<DateTime>(_utcOrNull(metadata.deletedAt)),
Variable<int>(metadata.schemaVersion),
Variable<String>(_syncStateToDb(metadata.syncState)),
Variable<int>(metadata.localRevision),
Variable<String>(metadata.originDeviceId),
Variable<String>(metadata.futureOwnerProfileId),
Variable<DateTime>(_utcOrNull(metadata.lastSyncedAt)),
Variable<String>(metadata.remoteRevision),
Variable<String>(state.activeWorkoutSessionId),
Variable<int>(state.programIndex),
Variable<int>(state.exerciseIndex),
Variable<int>(state.setIndex),
Variable<String>(_scoreStopwatchStatusToDb(state.status)),
Variable<DateTime>(state.startedAt.toUtc()),
Variable<int>(state.accumulatedMs),
Variable<DateTime>(_utcOrNull(state.stoppedAt)),
],
);
}
Future<void> _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<String>(metadata.id),
Variable<DateTime>(metadata.createdAt.toUtc()),
Variable<DateTime>(metadata.updatedAt.toUtc()),
Variable<DateTime>(_utcOrNull(metadata.deletedAt)),
Variable<int>(metadata.schemaVersion),
Variable<String>(_syncStateToDb(metadata.syncState)),
Variable<int>(metadata.localRevision),
Variable<String>(metadata.originDeviceId),
Variable<String>(metadata.futureOwnerProfileId),
Variable<DateTime>(_utcOrNull(metadata.lastSyncedAt)),
Variable<String>(metadata.remoteRevision),
Variable<String>(state.activeWorkoutSessionId),
Variable<int>(state.programIndex),
Variable<int>(state.exerciseIndex),
Variable<int>(state.setIndex),
Variable<double>(state.value),
Variable<DateTime>(state.updatedAt.toUtc()),
],
);
}
Future<void> _upsertActiveSetTimerState( Future<void> _upsertActiveSetTimerState(
db.AppDatabase database, db.AppDatabase database,
domain.ActiveSetTimerState state, 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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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( domain.ActiveManualScoreState _activeManualScoreStateFromRow(
db.ActiveManualScoreState row, db.ActiveManualScoreState row,
) { ) {

View File

@ -12,7 +12,7 @@ import 'exercise_step_audio.dart';
import 'history_screen.dart'; import 'history_screen.dart';
import 'theme.dart'; import 'theme.dart';
enum WorkoutExecutionMode { active, rest, paused, finished } enum WorkoutExecutionMode { active, rest, paused, finished, resumingSavedExit }
typedef VideoMediaBuilder = typedef VideoMediaBuilder =
Widget Function(BuildContext context, MediaAsset asset); Widget Function(BuildContext context, MediaAsset asset);
@ -117,9 +117,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
(widget.stepUseCases == null (widget.stepUseCases == null
? const NoOpExerciseStepAudioCuePlayer() ? const NoOpExerciseStepAudioCuePlayer()
: AudioplayersExerciseStepAudioCuePlayer()); : AudioplayersExerciseStepAudioCuePlayer());
_mode = _session.status == ActiveWorkoutStatus.running _mode = _modeForSessionStatus(_session.status);
? WorkoutExecutionMode.active
: WorkoutExecutionMode.paused;
_reps = _initialRepsFor(_exercise); _reps = _initialRepsFor(_exercise);
_performanceReference = _loadPerformanceReference(); _performanceReference = _loadPerformanceReference();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) { _ticker = Timer.periodic(const Duration(seconds: 1), (_) {
@ -136,11 +134,16 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
} }
setState(() => _sensorState = state); setState(() => _sensorState = state);
}); });
unawaited(_loadSetTimer()); final resumingSavedExit = _session.status == ActiveWorkoutStatus.savedExit;
unawaited(_loadScoreStopwatch()); if (resumingSavedExit) {
unawaited(_syncManualScoreInput(force: true)); unawaited(_resumeSavedExitSession());
unawaited(_loadStepProgress()); } else {
unawaited(_restoreActiveRest()); unawaited(_loadSetTimer());
unawaited(_loadScoreStopwatch());
unawaited(_syncManualScoreInput(force: true));
unawaited(_loadStepProgress());
unawaited(_restoreActiveRest());
}
} }
@override @override
@ -225,7 +228,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
onPressed: _openWorkoutPlan, onPressed: _openWorkoutPlan,
icon: const Icon(Icons.list_alt), 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 bottomNavigationBar: _mode == WorkoutExecutionMode.active
@ -243,6 +247,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
WorkoutExecutionMode.rest => _buildRest(), WorkoutExecutionMode.rest => _buildRest(),
WorkoutExecutionMode.paused => _buildPaused(), WorkoutExecutionMode.paused => _buildPaused(),
WorkoutExecutionMode.finished => _buildFinished(), WorkoutExecutionMode.finished => _buildFinished(),
WorkoutExecutionMode.resumingSavedExit => _buildResumingSavedExit(),
}, },
), ),
); );
@ -510,6 +515,22 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
); );
} }
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() { Widget _buildFinished() {
final elapsed = Duration( final elapsed = Duration(
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(_session), milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(_session),
@ -1221,7 +1242,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
} }
Future<void> _syncExternalSessionChanges() async { Future<void> _syncExternalSessionChanges() async {
if (_externalSyncInFlight || !mounted) { if (_externalSyncInFlight ||
!mounted ||
_mode == WorkoutExecutionMode.resumingSavedExit) {
return; return;
} }
_externalSyncInFlight = true; _externalSyncInFlight = true;
@ -1250,16 +1273,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
return; return;
} }
setState(() { setState(() {
_mode = switch (session.status) { _mode = _modeForSessionStatus(session.status);
ActiveWorkoutStatus.running =>
_activeRestStateId == null
? WorkoutExecutionMode.active
: WorkoutExecutionMode.rest,
ActiveWorkoutStatus.paused => WorkoutExecutionMode.paused,
ActiveWorkoutStatus.completed ||
ActiveWorkoutStatus.abandoned ||
ActiveWorkoutStatus.savedExit => WorkoutExecutionMode.finished,
};
}); });
_refreshScoreStopwatchTicker(); _refreshScoreStopwatchTicker();
_refreshStepTicker(); _refreshStepTicker();
@ -1400,6 +1414,19 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_mode == WorkoutExecutionMode.rest; _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) { void _handleSystemBack(bool didPop, Object? result) {
if (didPop || !_canPauseFromNavigation) return; if (didPop || !_canPauseFromNavigation) return;
unawaited(_pause()); unawaited(_pause());
@ -1443,6 +1470,13 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_refreshStepTicker(); _refreshStepTicker();
} }
Future<void> _resumeSavedExitSession() async {
if (!mounted || _mode != WorkoutExecutionMode.resumingSavedExit) {
return;
}
await _resume();
}
Future<void> _quitAndSave() async { Future<void> _quitAndSave() async {
await widget.activeUseCases.quitAndSave(_session.metadata.id); await widget.activeUseCases.quitAndSave(_session.metadata.id);
if (!mounted) return; if (!mounted) return;
@ -2777,6 +2811,8 @@ final class _CurrentStepPane extends StatelessWidget {
step.type == ExerciseStepType.time && step.type == ExerciseStepType.time &&
step.hasScore && step.hasScore &&
step.scoreInputMode == ScoreInputMode.manual; step.scoreInputMode == ScoreInputMode.manual;
final useCompactRepsActions =
step.type == ExerciseStepType.reps && constraints.maxHeight < 180;
if (hasRepsWithStopwatchScore || hasTimedWithManualScore) { if (hasRepsWithStopwatchScore || hasTimedWithManualScore) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -2900,7 +2936,8 @@ final class _CurrentStepPane extends StatelessWidget {
), ),
], ],
const SizedBox(height: 4), const SizedBox(height: 4),
if (step.type == ExerciseStepType.reps) ...[ if (step.type == ExerciseStepType.reps &&
!useCompactRepsActions) ...[
FilledButton.icon( FilledButton.icon(
onPressed: onCompleteStep, onPressed: onCompleteStep,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
@ -2922,6 +2959,19 @@ final class _CurrentStepPane extends StatelessWidget {
child: const Text('Passer létape'), 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), const SizedBox(width: 8),
PopupMenuButton<_StepSkipAction>( PopupMenuButton<_StepSkipAction>(
tooltip: 'Plus dactions', tooltip: 'Plus dactions',

View File

@ -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( test(
'closing a session stores autonomous history rows with set snapshots', 'closing a session stores autonomous history rows with set snapshots',
() async { () async {

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@ -88,6 +89,60 @@ void main() {
expect(repository.session?.status, ActiveWorkoutStatus.abandoned); 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<void>();
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', ( testWidgets('les répétitions sont initialisées avec la cible', (
tester, tester,
) async { ) async {
@ -2678,6 +2733,7 @@ final class _FakeExercisePerformanceReferenceRepository
final class _FakeActiveSessionRepository implements ActiveSessionRepository { final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session; ActiveWorkoutSession? session;
Future<void>? findByIdBlocker;
final results = <ActiveSetResult>[]; final results = <ActiveSetResult>[];
final restStates = <ActiveRestState>[]; final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[]; final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
@ -2688,6 +2744,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override @override
Future<ActiveWorkoutSession?> findById(String id) async { Future<ActiveWorkoutSession?> findById(String id) async {
await findByIdBlocker;
return session?.metadata.id == id ? session : null; return session?.metadata.id == id ? session : null;
} }