Files
GameTime/lib/infrastructure/local/app_database.dart
Blomios ef2c3ffffd fix(exercice): sélection d'un score par défaut à 0 impossible (ticket #71)
Corrige la contrainte CHECK sur la table exercises (tables.dart) qui
empêchait à tort une valeur de score par défaut à 0, et ajoute la
migration Drift schemaVersion 12→13 reconstruisant la table avec la
contrainte corrigée (app_database.dart). build_runner OK, test de
régression ajouté.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 07:50:26 +02:00

473 lines
15 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,
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 => 13;
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (migrator) async {
await migrator.createAll();
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();
}
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_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_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');
}
}