feat(exécution): refonte des chronos de séance et carte exercice actif (ticket #90)

Réorganise l'écran d'exécution autour d'une carte "Exercice actif" sous
le compteur SÉRIE X/Y (nom, médias, temps de série, action "Démarrer
l'exercice"). Le timer de série devient un état persistant dédié (plus
un DateTime UI volatile), pause-aware, et "Démarrer l'exercice" lance
en une action tous les chronos qui doivent démarrer en début de série
(série, première étape temps, score chrono si stopwatch).

Implémentation restée non commitée depuis sa réalisation ; QA a validé
les vérifications statiques exécutables en sandbox (dart analyze,
git diff --check). flutter analyze / flutter test restent à relancer
dans un environnement avec cache Flutter SDK accessible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 22:26:28 +02:00
parent 73cfa644c2
commit 75f52200a2
16 changed files with 4615 additions and 364 deletions

View File

@ -388,6 +388,7 @@ abstract interface class ActiveSessionRepository {
Future<ActiveWorkoutSession?> findOpen();
Future<void> save(ActiveWorkoutSession session);
Future<void> saveSetResult(ActiveSetResult result);
Future<void> saveSetTimerState(ActiveSetTimerState state);
Future<void> saveRestState(ActiveRestState restState);
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
Future<void> saveExerciseStepProgressState(
@ -408,6 +409,12 @@ abstract interface class ActiveSessionRepository {
required int exerciseIndex,
required int setIndex,
});
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
});
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
@ -415,6 +422,7 @@ abstract interface class ActiveSessionRepository {
required int setIndex,
});
Future<List<ActiveSetResult>> listSetResults(String sessionId);
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId);
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
);

View File

@ -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<ActiveWorkoutSession> 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<SetExecutionTimerStartResult> 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<SetExecutionTimerStartResult> startCurrentExerciseTimers({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
return startSetExecution(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
}
Future<ActiveSetTimerState?> 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<ActiveSetTimerState?> 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<ActiveSetResult> recordSetResult(ActiveSetResult result) async {
await sessionRepository.saveSetResult(result);
return result;
@ -1981,6 +2254,24 @@ final class ActiveWorkoutSessionUseCases {
return state.elapsedMillisecondsAt(clock.now());
}
Future<ActiveSetTimerState?> 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<List<SetResultPositionState>> 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<ActiveSetTimerState> _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<ActiveSetTimerState?> _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<ActiveScoreStopwatchState> _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<ActiveExerciseStepProgressState?> _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<ActiveWorkoutSession> _requiredSession(String sessionId) async {
final session = await sessionRepository.findById(sessionId);
if (session == null) {

View File

@ -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,
);
}
}

View File

@ -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<void> _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<bool> _hasColumn(String tableName, String columnName) async {
final rows = await customSelect('PRAGMA table_info($tableName)').get();
return rows.any((row) => row.data['name'] == columnName);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -998,6 +998,17 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> 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<void> 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
write: () async {
await database
.into(database.activeRestStates)
.insertOnConflictUpdate(_activeRestStateCompanion(restState)),
.insertOnConflictUpdate(_activeRestStateCompanion(restState));
await _updateActiveRestPauseFields(database, restState);
},
);
}
@override
Future<domain.ActiveRestState?> 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<String>(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<domain.ActiveSetTimerState?> 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<String>(sessionId),
Variable<int>(programIndex),
Variable<int>(exerciseIndex),
Variable<int>(setIndex),
],
)
.getSingleOrNull();
return row == null ? null : _activeSetTimerStateFromCustomRow(row);
}
@override
Future<domain.ActiveExerciseStepProgressState?>
findExerciseStepProgressState({
@ -1158,6 +1202,22 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return rows.map(_activeSetResultFromRow).toList();
}
@override
Future<List<domain.ActiveSetTimerState>> 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<String>(sessionId)],
)
.get();
return rows.map(_activeSetTimerStateFromCustomRow).toList();
}
@override
Future<List<domain.ActiveExerciseStepProgressState>>
listExerciseStepProgressStates(String sessionId) async {
@ -1201,12 +1261,15 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
@override
Future<List<domain.ActiveRestState>> listRestStates(String sessionId) async {
final rows =
await (database.select(database.activeRestStates)
..where((table) => table.activeWorkoutSessionId.equals(sessionId))
..orderBy([(table) => OrderingTerm.asc(table.startedAt)]))
final rows = await database
.customSelect(
'SELECT * FROM active_rest_states '
'WHERE active_workout_session_id = ? '
'ORDER BY started_at',
variables: [Variable<String>(sessionId)],
)
.get();
return rows.map(_activeRestStateFromRow).toList();
return rows.map(_activeRestStateFromCustomRow).toList();
}
@override
@ -1839,6 +1902,47 @@ domain.EntityMetadata _metadataFromRow(dynamic row) {
);
}
domain.EntityMetadata _metadataFromData(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<dynamic> _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<void> _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<DateTime>(_utcOrNull(restState.pausedAt)),
Variable<int>(restState.accumulatedPausedMs),
Variable<String>(restState.metadata.id),
],
);
}
Future<void> _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<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>(_setTimerStatusToDb(state.status)),
Variable<DateTime>(_utcOrNull(state.startedAt)),
Variable<int>(state.accumulatedMs),
Variable<DateTime>(_utcOrNull(state.stoppedAt)),
Variable<DateTime>(_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) {

View File

@ -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<String> 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<String> 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)',
];
}

View File

@ -405,7 +405,6 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
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<ExerciseFormScreen> {
_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<ExerciseFormScreen> {
_stepError = null;
_stepDrafts.insert(
index + 1,
_stepDrafts[index].duplicate('step-draft-${_nextStepDraftId++}'),
_stepDrafts[index].duplicate(widget.exerciseUseCases.ids.newId()),
);
});
}

File diff suppressed because it is too large Load Diff

View File

@ -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 = <ActiveSetResult>[];
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -2864,6 +3054,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.firstOrNull;
}
@override
Future<ActiveSetTimerState?> 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<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
@ -2910,6 +3119,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.metadata.deletedAt == null,
)
.toList();
}
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
@ -2938,6 +3158,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
restStates[restState.metadata.id] = restState;
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates[state.metadata.id] = state;
}
@override
Future<void> 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<ExerciseStep> 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(),

View File

@ -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);

View File

@ -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);

View File

@ -437,6 +437,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required int setIndex,
}) async => null;
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
@ -457,6 +465,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return const [];
@ -485,6 +498,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,

View File

@ -474,6 +474,7 @@ final class _FakePendingShareActionRepository
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
final restStates = <ActiveRestState>[];
final setTimerStates = <ActiveSetTimerState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
final results = <ActiveSetResult>[];
@ -498,6 +499,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
@ -520,6 +531,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return scoreStopwatchStates;
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates;
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results;
@ -554,6 +570,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.add(state);
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates.add(state);
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,

View File

@ -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 lexercice'), 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 lexercice'), findsOneWidget);
await tester.tap(find.text('Voir médias'));
await tester.tap(find.byTooltip('Voir les médias de lexercice'));
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 lexercice'));
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 nas pas démarré le chrono de cette série."),
findsOneWidget,
);
expect(
find.widgetWithText(TextButton, 'Démarrer lexercice'),
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 lexercice'));
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 lexercice'));
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 lexercice'));
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 nas pas démarré le chrono de cette série."),
findsOneWidget,
);
expect(
find.widgetWithText(TextButton, 'Démarrer lexercice'),
findsOneWidget,
);
expect(find.text('Terminer sans chrono'), findsOneWidget);
expect(repository.results, isEmpty);
},
);
testWidgets('deux étapes temps consécutives senchaî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 dactions'));
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 dactions'));
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 = <ActiveSetResult>[];
final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
final setTimerStates = <ActiveSetTimerState>[];
final stepProgressStates = <ActiveExerciseStepProgressState>[];
final stepResults = <ActiveExerciseStepResult>[];
@ -1700,6 +1840,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveSetTimerState?> 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<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
@ -1740,6 +1899,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return results;
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
@ -1787,6 +1953,22 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
}
@override
Future<void> 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<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,

View File

@ -523,6 +523,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
@ -545,6 +555,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return const [];
@ -576,6 +591,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,