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

@ -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
.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) {

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