Files
GameTime/lib/infrastructure/local/app_database.dart
Blomios 75f52200a2 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>
2026-07-21 22:26:28 +02:00

645 lines
21 KiB
Dart

import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'tables.dart';
part 'app_database.g.dart';
@DriftDatabase(
tables: [
ActiveExerciseStepProgressStates,
ActiveExerciseStepResults,
ActiveRestStates,
ActiveScoreStopwatchStates,
ActiveSetTimerStates,
ActiveSetResults,
ActiveWorkoutSessions,
ChangeLogEntries,
Exercises,
ExerciseImages,
ExerciseSteps,
MediaAssets,
OnlineAccountSessions,
PendingShareActions,
ProgramExercises,
Programs,
RemoteResourceMappings,
ShareInboxItems,
SyncMetadataEntries,
WorkoutHistories,
WorkoutHistorySetResults,
WorkoutHistoryStepResults,
WorkoutTemplateExerciseOverrides,
WorkoutTemplatePrograms,
WorkoutTemplates,
],
)
final class AppDatabase extends _$AppDatabase {
AppDatabase(super.executor);
factory AppDatabase.open() {
return AppDatabase(
driftDatabase(
name: 'gametime',
native: const DriftNativeOptions(shareAcrossIsolates: true),
),
);
}
@override
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 {
if (from < 2) {
await customStatement(
'ALTER TABLE active_set_results ADD COLUMN status TEXT NOT NULL '
"DEFAULT 'completed' CHECK (status IN ('completed', 'skipped'))",
);
await customStatement(
'ALTER TABLE workout_history_set_results ADD COLUMN status TEXT '
"NOT NULL DEFAULT 'completed' CHECK (status IN ('completed', "
"'skipped'))",
);
}
if (from < 3) {
await _migrateToSchema3(migrator);
}
if (from < 4) {
await _migrateToSchema4();
}
if (from < 5) {
await _migrateToSchema5(migrator);
}
if (from < 6) {
await _migrateToSchema6();
}
if (from < 7) {
await _migrateToSchema7();
}
if (from < 8) {
await _migrateToSchema8(migrator);
}
if (from < 9) {
await _migrateToSchema9(migrator);
}
if (from < 10) {
await _migrateToSchema10(migrator);
}
if (from < 11) {
await _migrateToSchema11(migrator);
}
if (from < 12) {
await _migrateToSchema12(migrator);
}
if (from < 13) {
await _migrateToSchema13();
}
if (from < 14) {
await _migrateToSchema14();
}
if (from < 15) {
await _migrateToSchema15();
}
await _createIndexes();
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
}
Future<void> _createIndexes() async {
for (final tableName in _syncableTableNames) {
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_${tableName}_deleted_at '
'ON $tableName (deleted_at)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_${tableName}_updated_at '
'ON $tableName (updated_at)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_${tableName}_sync_state '
'ON $tableName (sync_state)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_${tableName}_local_revision '
'ON $tableName (local_revision)',
);
}
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_program_exercises_program_id '
'ON program_exercises (program_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_exercise_images_exercise_id '
'ON exercise_images (exercise_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_exercise_steps_exercise_id_position '
'ON exercise_steps (exercise_id, position)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_online_account_sessions_logged_in '
'ON online_account_sessions (is_logged_in, updated_at)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_remote_resource_mappings_resource '
'ON remote_resource_mappings (resource_type, client_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_share_inbox_items_created_at '
'ON share_inbox_items (created_at)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_pending_share_actions_status '
'ON pending_share_actions (status, created_at)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
'ON workout_template_programs (workout_template_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_set_results_session_id '
'ON active_set_results (active_workout_session_id)',
);
await customStatement(
'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)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS '
'idx_active_exercise_step_progress_states_session_id '
'ON active_exercise_step_progress_states (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_exercise_step_results_session_id '
'ON active_exercise_step_results (active_workout_session_id)',
);
await customStatement(
'CREATE UNIQUE INDEX IF NOT EXISTS '
'idx_active_workout_sessions_single_open '
'ON active_workout_sessions ((1)) '
'WHERE deleted_at IS NULL '
"AND status IN ('running', 'paused', 'savedExit')",
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_history_started_at '
'ON workout_history (started_at)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id '
'ON workout_history_set_results (workout_history_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_history_step_results_history_id '
'ON workout_history_step_results (workout_history_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_change_log_entity '
'ON change_log (entity_type, entity_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_change_log_local_revision '
'ON change_log (local_revision)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_change_log_synced_at '
'ON change_log (synced_at)',
);
}
}
const _syncableTableNames = [
'active_exercise_step_progress_states',
'active_exercise_step_results',
'active_rest_states',
'active_score_stopwatch_states',
'active_set_timer_states',
'active_set_results',
'active_workout_sessions',
'exercises',
'exercise_images',
'exercise_steps',
'media_assets',
'program_exercises',
'programs',
'workout_history',
'workout_history_set_results',
'workout_history_step_results',
'workout_template_exercise_overrides',
'workout_template_programs',
'workout_templates',
];
extension on AppDatabase {
Future<void> _migrateToSchema3(Migrator migrator) async {
await customStatement(
'ALTER TABLE exercises ADD COLUMN score_input_mode TEXT NOT NULL '
"DEFAULT 'manual' CHECK (score_input_mode IN ('manual', 'stopwatch'))",
);
await customStatement(
'ALTER TABLE program_exercises ADD COLUMN score_input_mode_snapshot TEXT '
"NOT NULL DEFAULT 'manual' CHECK (score_input_mode_snapshot IN "
"('manual', 'stopwatch'))",
);
await customStatement(
'ALTER TABLE program_exercises ADD COLUMN target_score_time_ms INTEGER '
'CHECK (target_score_time_ms IS NULL OR target_score_time_ms > 0)',
);
await customStatement(
'ALTER TABLE workout_template_exercise_overrides ADD COLUMN '
'target_score_time_ms_override INTEGER CHECK '
'(target_score_time_ms_override IS NULL OR '
'target_score_time_ms_override > 0)',
);
await customStatement(
'ALTER TABLE active_set_results ADD COLUMN actual_score_time_ms INTEGER '
'CHECK (actual_score_time_ms IS NULL OR actual_score_time_ms >= 0)',
);
await customStatement(
'ALTER TABLE active_set_results ADD COLUMN score_input_mode_snapshot '
"TEXT NOT NULL DEFAULT 'manual' CHECK (score_input_mode_snapshot IN "
"('manual', 'stopwatch'))",
);
await customStatement(
'ALTER TABLE workout_history_set_results ADD COLUMN '
'score_input_mode_snapshot TEXT NOT NULL DEFAULT '
"'manual' CHECK (score_input_mode_snapshot IN ('manual', 'stopwatch'))",
);
await customStatement(
'ALTER TABLE workout_history_set_results ADD COLUMN '
'target_score_time_ms_snapshot INTEGER CHECK '
'(target_score_time_ms_snapshot IS NULL OR '
'target_score_time_ms_snapshot > 0)',
);
await customStatement(
'ALTER TABLE workout_history_set_results ADD COLUMN '
'actual_score_time_ms INTEGER CHECK (actual_score_time_ms IS NULL OR '
'actual_score_time_ms >= 0)',
);
await migrator.createTable(activeScoreStopwatchStates);
}
Future<void> _migrateToSchema4() async {
await customStatement(
'ALTER TABLE exercises ADD COLUMN default_target_time_seconds INTEGER '
'CHECK (default_target_time_seconds IS NULL OR '
'default_target_time_seconds > 0)',
);
await customStatement(
'ALTER TABLE exercises ADD COLUMN default_target_reps INTEGER '
'CHECK (default_target_reps IS NULL OR default_target_reps > 0)',
);
await customStatement(
'ALTER TABLE exercises ADD COLUMN default_target_score REAL '
'CHECK (default_target_score IS NULL OR default_target_score > 0)',
);
await customStatement(
'ALTER TABLE exercises ADD COLUMN default_target_score_time_ms INTEGER '
'CHECK (default_target_score_time_ms IS NULL OR '
'default_target_score_time_ms > 0)',
);
}
Future<void> _migrateToSchema5(Migrator migrator) async {
await migrator.createTable(exerciseImages);
await customStatement(
'INSERT OR IGNORE INTO exercise_images (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, exercise_id, media_asset_id, position) '
"SELECT 'exercise-image:' || id || ':0', created_at, updated_at, "
'deleted_at, schema_version, sync_state, local_revision, '
'origin_device_id, future_owner_profile_id, last_synced_at, '
'remote_revision, id, image_media_id, 0 FROM exercises '
'WHERE image_media_id IS NOT NULL',
);
}
Future<void> _migrateToSchema6() async {
await customStatement(
'ALTER TABLE program_exercises ADD COLUMN '
'exercise_image_media_ids_snapshot_json TEXT',
);
}
Future<void> _migrateToSchema7() async {
await customStatement(
'ALTER TABLE exercises ADD COLUMN icon_media_id TEXT '
'REFERENCES media_assets(id)',
);
}
Future<void> _migrateToSchema8(Migrator migrator) async {
await migrator.createTable(exerciseSteps);
await customStatement(
'ALTER TABLE program_exercises ADD COLUMN '
'exercise_steps_snapshot_json TEXT',
);
}
Future<void> _migrateToSchema9(Migrator migrator) async {
await migrator.createTable(activeExerciseStepProgressStates);
await migrator.createTable(activeExerciseStepResults);
await migrator.createTable(workoutHistoryStepResults);
}
Future<void> _migrateToSchema10(Migrator migrator) async {
await migrator.createTable(onlineAccountSessions);
}
Future<void> _migrateToSchema11(Migrator migrator) async {
await migrator.createTable(syncMetadataEntries);
await migrator.createTable(remoteResourceMappings);
}
Future<void> _migrateToSchema12(Migrator migrator) async {
await migrator.createTable(shareInboxItems);
await migrator.createTable(pendingShareActions);
}
Future<void> _migrateToSchema13() async {
await customStatement('PRAGMA foreign_keys = OFF');
await customStatement('''
CREATE TABLE exercises_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,
name TEXT NOT NULL,
description TEXT,
icon_media_id TEXT REFERENCES media_assets(id),
video_media_id TEXT REFERENCES media_assets(id),
has_time_measure INTEGER NOT NULL CHECK (has_time_measure IN (0, 1)),
has_reps_measure INTEGER NOT NULL CHECK (has_reps_measure IN (0, 1)),
has_score_measure INTEGER NOT NULL CHECK (has_score_measure IN (0, 1)),
score_input_mode TEXT NOT NULL DEFAULT 'manual',
score_label TEXT,
score_unit TEXT,
default_target_time_seconds INTEGER,
default_target_reps INTEGER,
default_target_score REAL,
default_target_score_time_ms INTEGER,
archived_at INTEGER,
CHECK (length(trim(origin_device_id)) > 0),
CHECK (length(trim(name)) > 0),
CHECK (has_time_measure OR has_reps_measure OR has_score_measure),
CHECK (score_input_mode IN ('manual', 'stopwatch')),
CHECK (has_score_measure OR score_input_mode = 'manual'),
CHECK (score_input_mode != 'manual' OR NOT has_score_measure OR (score_label IS NOT NULL AND length(trim(score_label)) > 0 AND score_unit IS NOT NULL AND length(trim(score_unit)) > 0)),
CHECK (default_target_time_seconds IS NULL OR default_target_time_seconds > 0),
CHECK (default_target_reps IS NULL OR default_target_reps > 0),
CHECK (default_target_score IS NULL OR default_target_score >= 0),
CHECK (default_target_score_time_ms IS NULL OR default_target_score_time_ms > 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)
)
''');
await customStatement('''
INSERT INTO exercises_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,
name,
description,
icon_media_id,
video_media_id,
has_time_measure,
has_reps_measure,
has_score_measure,
score_input_mode,
score_label,
score_unit,
default_target_time_seconds,
default_target_reps,
default_target_score,
default_target_score_time_ms,
archived_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,
name,
description,
icon_media_id,
video_media_id,
has_time_measure,
has_reps_measure,
has_score_measure,
score_input_mode,
score_label,
score_unit,
default_target_time_seconds,
default_target_reps,
default_target_score,
default_target_score_time_ms,
archived_at
FROM exercises
''');
await customStatement('DROP TABLE exercises');
await customStatement('ALTER TABLE exercises_new RENAME TO exercises');
await customStatement('PRAGMA foreign_keys = ON');
}
Future<void> _migrateToSchema14() async {
await customStatement(
'ALTER TABLE exercises ADD COLUMN auto_start_next_timed_step '
'INTEGER NOT NULL DEFAULT 1 CHECK (auto_start_next_timed_step IN (0, 1))',
);
await customStatement(
'ALTER TABLE program_exercises ADD COLUMN '
'auto_start_next_timed_step_snapshot INTEGER NOT NULL DEFAULT 1 '
'CHECK (auto_start_next_timed_step_snapshot IN (0, 1))',
);
await customStatement(
'ALTER TABLE program_exercises ADD COLUMN '
'auto_start_next_timed_step_override INTEGER '
'CHECK (auto_start_next_timed_step_override IN (0, 1))',
);
await customStatement(
'ALTER TABLE workout_template_exercise_overrides ADD COLUMN '
'auto_start_next_timed_step_override INTEGER '
'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);
}
}