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

@ -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
.into(database.activeRestStates)
.insertOnConflictUpdate(_activeRestStateCompanion(restState)),
write: () async {
await database
.into(database.activeRestStates)
.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)]))
.get();
return rows.map(_activeRestStateFromRow).toList();
final rows = await database
.customSelect(
'SELECT * FROM active_rest_states '
'WHERE active_workout_session_id = ? '
'ORDER BY started_at',
variables: [Variable<String>(sessionId)],
)
.get();
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) {