feat(bibliothèque): migration Drift v16, seed basket et seeding au démarrage (ticket #80)

Ajoute la migration Drift v16 (enum ExerciseCategory, champ isExample sur
exercises/programs/workout_templates, table local_seed_metadata), le
contenu seed basket déterministe (21 exercices, 1 programme, 1 séance-
modèle) dans lib/application/starter_content/, les ports
StarterSeedStateRepository/StarterContentRepository et le use case
SeedStarterContentUseCase branché dans AppBootstrap.create().

Validé GO par QA/Main : dart analyze propre, suite complète 167 tests
(4 échecs préexistants sans rapport avec ce lot), 4 nouveaux tests seed
tous verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 07:29:49 +02:00
parent 75f52200a2
commit 24e266c0e9
12 changed files with 2052 additions and 16 deletions

View File

@ -18,6 +18,7 @@ part 'app_database.g.dart';
Exercises,
ExerciseImages,
ExerciseSteps,
LocalSeedMetadata,
MediaAssets,
OnlineAccountSessions,
PendingShareActions,
@ -47,7 +48,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 15;
int get schemaVersion => 16;
@override
MigrationStrategy get migration {
@ -55,6 +56,7 @@ final class AppDatabase extends _$AppDatabase {
onCreate: (migrator) async {
await migrator.createAll();
await _migrateToSchema15();
await _migrateToSchema16(migrator);
await _createIndexes();
},
onUpgrade: (migrator, from, to) async {
@ -108,6 +110,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 15) {
await _migrateToSchema15();
}
if (from < 16) {
await _migrateToSchema16(migrator);
}
await _createIndexes();
},
beforeOpen: (details) async {
@ -148,6 +153,22 @@ final class AppDatabase extends _$AppDatabase {
'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_exercises_category '
'ON exercises (category)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_exercises_is_example '
'ON exercises (is_example)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_programs_is_example '
'ON programs (is_example)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_templates_is_example '
'ON workout_templates (is_example)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_online_account_sessions_logged_in '
'ON online_account_sessions (is_logged_in, updated_at)',
@ -637,6 +658,57 @@ CREATE TABLE IF NOT EXISTS active_set_timer_states (
}
}
Future<void> _migrateToSchema16(Migrator migrator) async {
await _addColumnIfMissing(
tableName: 'exercises',
columnName: 'category',
definition:
"category TEXT NOT NULL DEFAULT 'uncategorized' CHECK "
"(category IN ('shoot', 'freeThrows', 'dribble', 'finishing', "
"'conditioning', 'defense', 'mobility', 'uncategorized'))",
);
await _addColumnIfMissing(
tableName: 'exercises',
columnName: 'is_example',
definition:
'is_example INTEGER NOT NULL DEFAULT 0 '
'CHECK (is_example IN (0, 1))',
);
await _addColumnIfMissing(
tableName: 'programs',
columnName: 'is_example',
definition:
'is_example INTEGER NOT NULL DEFAULT 0 '
'CHECK (is_example IN (0, 1))',
);
await _addColumnIfMissing(
tableName: 'workout_templates',
columnName: 'is_example',
definition:
'is_example INTEGER NOT NULL DEFAULT 0 '
'CHECK (is_example IN (0, 1))',
);
await customStatement(
'CREATE TABLE IF NOT EXISTS local_seed_metadata ('
'key TEXT NOT NULL PRIMARY KEY, '
'version INTEGER NOT NULL CHECK (version >= 0), '
'applied_at INTEGER NOT NULL'
')',
);
}
Future<void> _addColumnIfMissing({
required String tableName,
required String columnName,
required String definition,
}) async {
final columns = await customSelect('PRAGMA table_info($tableName)').get();
final exists = columns.any((row) => row.data['name'] == columnName);
if (!exists) {
await customStatement('ALTER TABLE $tableName ADD COLUMN $definition');
}
}
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

@ -22,6 +22,7 @@ final class DriftExerciseRepository implements ExerciseRepository {
row,
await _imageMediaIdsForExercise(id),
await _stepsForExercise(id),
await _exerciseStarterMetadata(id),
);
}
@ -41,6 +42,7 @@ final class DriftExerciseRepository implements ExerciseRepository {
row,
await _imageMediaIdsForExercise(row.id),
await _stepsForExercise(row.id),
await _exerciseStarterMetadata(row.id),
),
);
}
@ -73,6 +75,7 @@ final class DriftExerciseRepository implements ExerciseRepository {
.into(database.exercises)
.insertOnConflictUpdate(_exerciseCompanion(exercise)),
);
await _writeExerciseStarterMetadata(database, exercise);
await _replaceExerciseImages(database, exercise);
await _replaceExerciseSteps(database, exercise);
});
@ -103,6 +106,113 @@ final class DriftExerciseRepository implements ExerciseRepository {
.get();
return rows.map(_exerciseStepFromRow).toList();
}
Future<_ExerciseStarterMetadata> _exerciseStarterMetadata(
String exerciseId,
) async {
final row = await database
.customSelect(
'SELECT category, is_example FROM exercises WHERE id = ? LIMIT 1',
variables: [Variable<String>(exerciseId)],
)
.getSingleOrNull();
if (row == null) {
return const _ExerciseStarterMetadata();
}
return _ExerciseStarterMetadata(
category: _exerciseCategoryFromDb(
row.readNullable<String>('category') ?? 'uncategorized',
),
isExample: (row.readNullable<int>('is_example') ?? 0) == 1,
);
}
}
final class DriftStarterSeedRepository
implements StarterSeedStateRepository, StarterContentRepository {
const DriftStarterSeedRepository(this.database);
static const _starterSeedKey = 'starter';
final db.AppDatabase database;
@override
Future<int> readAppliedStarterSeedVersion() async {
final row = await database
.customSelect(
'SELECT version FROM local_seed_metadata WHERE key = ? LIMIT 1',
variables: [Variable<String>(_starterSeedKey)],
)
.getSingleOrNull();
return row?.read<int>('version') ?? 0;
}
@override
Future<void> writeAppliedStarterSeedVersion(
int version,
DateTime appliedAt,
) async {
await database.customStatement(
'INSERT INTO local_seed_metadata (key, version, applied_at) '
'VALUES (?, ?, ?) '
'ON CONFLICT(key) DO UPDATE SET '
'version = excluded.version, applied_at = excluded.applied_at',
[_starterSeedKey, version, appliedAt.toUtc().millisecondsSinceEpoch],
);
}
@override
Future<bool> isLocalContentEmpty() async {
final exerciseCount = await _tableCount(database.exercises.actualTableName);
final programCount = await _tableCount(database.programs.actualTableName);
final templateCount = await _tableCount(
database.workoutTemplates.actualTableName,
);
return exerciseCount == 0 && programCount == 0 && templateCount == 0;
}
@override
Future<void> insertStarterContent(StarterContent content) async {
await database.transaction(() async {
for (final exercise in content.exercises) {
await database
.into(database.exercises)
.insertOnConflictUpdate(_exerciseCompanion(exercise));
await _writeExerciseStarterMetadata(database, exercise);
await _replaceExerciseSteps(database, exercise);
}
await database
.into(database.programs)
.insertOnConflictUpdate(_programCompanion(content.program));
await _writeProgramStarterMetadata(database, content.program);
for (final exercise in content.program.exercises) {
await database
.into(database.programExercises)
.insertOnConflictUpdate(_programExerciseCompanion(exercise));
}
await database
.into(database.workoutTemplates)
.insertOnConflictUpdate(
_workoutTemplateCompanion(content.workoutTemplate),
);
await _writeWorkoutTemplateStarterMetadata(
database,
content.workoutTemplate,
);
for (final program in content.workoutTemplate.programs) {
await database
.into(database.workoutTemplatePrograms)
.insertOnConflictUpdate(_workoutTemplateProgramCompanion(program));
}
});
}
Future<int> _tableCount(String tableName) async {
final row = await database
.customSelect('SELECT COUNT(*) AS count FROM $tableName')
.getSingle();
return row.read<int>('count');
}
}
final class DriftMediaAssetRepository implements MediaAssetRepository {
@ -381,11 +491,11 @@ final class DriftLocalSyncChangeRepository
Future<bool> _applyRemotePayload(RemoteSyncedItem item) async {
switch (item.resourceType) {
case SyncResourceType.exercise:
final exercise = _exerciseFromPayload(item);
await database
.into(database.exercises)
.insertOnConflictUpdate(
_exerciseCompanion(_exerciseFromPayload(item)),
);
.insertOnConflictUpdate(_exerciseCompanion(exercise));
await _writeExerciseStarterMetadata(database, exercise);
return true;
case SyncResourceType.mediaAsset:
await database
@ -400,6 +510,7 @@ final class DriftLocalSyncChangeRepository
await database
.into(database.programs)
.insertOnConflictUpdate(_programCompanion(program));
await _writeProgramStarterMetadata(database, program);
for (final exercise in program.exercises) {
await database
.into(database.programExercises)
@ -413,6 +524,7 @@ final class DriftLocalSyncChangeRepository
await database
.into(database.workoutTemplates)
.insertOnConflictUpdate(_workoutTemplateCompanion(template));
await _writeWorkoutTemplateStarterMetadata(database, template);
for (final program in template.programs) {
await database
.into(database.workoutTemplatePrograms)
@ -442,6 +554,7 @@ final class DriftLocalSyncChangeRepository
row,
await DriftExerciseRepository(database)._imageMediaIdsForExercise(id),
await DriftExerciseRepository(database)._stepsForExercise(id),
await DriftExerciseRepository(database)._exerciseStarterMetadata(id),
);
return _LocalSyncSnapshot.fromMetadata(
exercise.metadata,
@ -666,6 +779,7 @@ final class DriftProgramRepository implements ProgramRepository {
return _programFromRow(
row,
exercises.map(_programExerciseFromRow).toList(),
isExample: await _isProgramExample(id),
);
}
@ -687,7 +801,11 @@ final class DriftProgramRepository implements ProgramRepository {
..orderBy([(table) => OrderingTerm.asc(table.position)]))
.get();
programs.add(
_programFromRow(row, exercises.map(_programExerciseFromRow).toList()),
_programFromRow(
row,
exercises.map(_programExerciseFromRow).toList(),
isExample: await _isProgramExample(row.id),
),
);
}
return programs;
@ -705,6 +823,7 @@ final class DriftProgramRepository implements ProgramRepository {
.into(database.programs)
.insertOnConflictUpdate(_programCompanion(program)),
);
await _writeProgramStarterMetadata(database, program);
for (final exercise in program.exercises) {
await saveExercise(exercise);
}
@ -756,6 +875,16 @@ final class DriftProgramRepository implements ProgramRepository {
);
});
}
Future<bool> _isProgramExample(String id) async {
final row = await database
.customSelect(
'SELECT is_example FROM programs WHERE id = ? LIMIT 1',
variables: [Variable<String>(id)],
)
.getSingleOrNull();
return (row?.readNullable<int>('is_example') ?? 0) == 1;
}
}
final class DriftWorkoutTemplateRepository
@ -797,6 +926,7 @@ final class DriftWorkoutTemplateRepository
row,
programs.map(_workoutTemplateProgramFromRow).toList(),
overrides.map(_workoutTemplateExerciseOverrideFromRow).toList(),
isExample: await _isWorkoutTemplateExample(id),
);
}
@ -829,6 +959,7 @@ final class DriftWorkoutTemplateRepository
.into(database.workoutTemplates)
.insertOnConflictUpdate(_workoutTemplateCompanion(template)),
);
await _writeWorkoutTemplateStarterMetadata(database, template);
for (final program in template.programs) {
await saveProgram(program);
}
@ -942,6 +1073,16 @@ final class DriftWorkoutTemplateRepository
);
});
}
Future<bool> _isWorkoutTemplateExample(String id) async {
final row = await database
.customSelect(
'SELECT is_example FROM workout_templates WHERE id = ? LIMIT 1',
variables: [Variable<String>(id)],
)
.getSingleOrNull();
return (row?.readNullable<int>('is_example') ?? 0) == 1;
}
}
final class DriftActiveSessionRepository implements ActiveSessionRepository {
@ -1681,6 +1822,50 @@ String _exerciseImageId(String exerciseId, String mediaId) {
return 'exercise-image:$exerciseId:$mediaId';
}
final class _ExerciseStarterMetadata {
const _ExerciseStarterMetadata({
this.category = domain.ExerciseCategory.uncategorized,
this.isExample = false,
});
final domain.ExerciseCategory category;
final bool isExample;
}
Future<void> _writeExerciseStarterMetadata(
db.AppDatabase database,
domain.Exercise exercise,
) async {
await database.customStatement(
'UPDATE exercises SET category = ?, is_example = ? WHERE id = ?',
[
_exerciseCategoryToDb(exercise.category),
exercise.isExample ? 1 : 0,
exercise.metadata.id,
],
);
}
Future<void> _writeProgramStarterMetadata(
db.AppDatabase database,
domain.Program program,
) async {
await database.customStatement(
'UPDATE programs SET is_example = ? WHERE id = ?',
[program.isExample ? 1 : 0, program.metadata.id],
);
}
Future<void> _writeWorkoutTemplateStarterMetadata(
db.AppDatabase database,
domain.WorkoutTemplate template,
) async {
await database.customStatement(
'UPDATE workout_templates SET is_example = ? WHERE id = ?',
[template.isExample ? 1 : 0, template.metadata.id],
);
}
Future<String> _operationForMutation({
required db.AppDatabase database,
required String tableName,
@ -1994,6 +2179,7 @@ domain.Exercise _exerciseFromRow(
db.Exercise row,
List<String> imageMediaIds,
List<domain.ExerciseStep> steps,
_ExerciseStarterMetadata starterMetadata,
) {
return domain.Exercise(
metadata: _metadataFromRow(row),
@ -2013,6 +2199,8 @@ domain.Exercise _exerciseFromRow(
defaultTargetScore: row.defaultTargetScore,
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
autoStartNextTimedStep: row.autoStartNextTimedStep,
category: starterMetadata.category,
isExample: starterMetadata.isExample,
steps: steps,
archivedAt: _utcOrNull(row.archivedAt),
);
@ -2200,12 +2388,14 @@ db.ProgramsCompanion _programCompanion(domain.Program program) {
domain.Program _programFromRow(
db.Program row,
List<domain.ProgramExercise> exercises,
) {
List<domain.ProgramExercise> exercises, {
bool isExample = false,
}) {
return domain.Program(
metadata: _metadataFromRow(row),
name: row.name,
defaultRestSeconds: row.defaultRestSeconds,
isExample: isExample,
exercises: exercises,
);
}
@ -2328,12 +2518,14 @@ db.WorkoutTemplatesCompanion _workoutTemplateCompanion(
domain.WorkoutTemplate _workoutTemplateFromRow(
db.WorkoutTemplate row,
List<domain.WorkoutTemplateProgram> programs,
List<domain.WorkoutTemplateExerciseOverride> overrides,
) {
List<domain.WorkoutTemplateExerciseOverride> overrides, {
bool isExample = false,
}) {
return domain.WorkoutTemplate(
metadata: _metadataFromRow(row),
name: row.name,
lastStartedAt: _utcOrNull(row.lastStartedAt),
isExample: isExample,
programs: programs,
overrides: overrides,
);
@ -3146,6 +3338,8 @@ Map<String, Object?> _exercisePayload(domain.Exercise exercise) => {
'defaultTargetScore': exercise.defaultTargetScore,
'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs,
'autoStartNextTimedStep': exercise.autoStartNextTimedStep,
'category': exercise.category.name,
'isExample': exercise.isExample,
'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(),
'archivedAt': exercise.archivedAt?.toUtc().toIso8601String(),
};
@ -3170,6 +3364,7 @@ Map<String, Object?> _programPayload(domain.Program program) => {
'id': program.metadata.id,
'name': program.name,
'defaultRestSeconds': program.defaultRestSeconds,
'isExample': program.isExample,
'exercises': program.exercises
.map((exercise) => exercise.toSnapshotJson())
.toList(),
@ -3181,6 +3376,7 @@ Map<String, Object?> _workoutTemplatePayload(domain.WorkoutTemplate template) =>
'id': template.metadata.id,
'name': template.name,
'lastStartedAt': template.lastStartedAt?.toUtc().toIso8601String(),
'isExample': template.isExample,
'programs': template.programs
.map(
(program) => {
@ -3246,6 +3442,10 @@ domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
defaultTargetScore: (payload['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: payload['defaultTargetScoreTimeMs'] as int?,
autoStartNextTimedStep: payload['autoStartNextTimedStep'] as bool? ?? true,
category: _exerciseCategoryFromDb(
payload['category'] as String? ?? 'uncategorized',
),
isExample: payload['isExample'] as bool? ?? false,
steps: _stepsFromPayload(payload['steps']),
archivedAt: _dateTimeFromPayload(payload['archivedAt']),
);
@ -3275,6 +3475,7 @@ domain.Program _programFromPayload(RemoteSyncedItem item) {
metadata: metadata,
name: _stringFromPayload(payload, 'name', item.clientId),
defaultRestSeconds: payload['defaultRestSeconds'] as int? ?? 0,
isExample: payload['isExample'] as bool? ?? false,
exercises: _programExercisesFromPayload(payload['exercises'], metadata),
);
}
@ -3286,6 +3487,7 @@ domain.WorkoutTemplate _workoutTemplateFromPayload(RemoteSyncedItem item) {
metadata: metadata,
name: _stringFromPayload(payload, 'name', item.clientId),
lastStartedAt: _dateTimeFromPayload(payload['lastStartedAt']),
isExample: payload['isExample'] as bool? ?? false,
programs: _workoutTemplateProgramsFromPayload(
payload['programs'],
metadata,
@ -3693,6 +3895,31 @@ domain.ExerciseStepType _exerciseStepTypeFromDb(String value) =>
_ => throw domain.DomainException('Unknown exercise step type: $value'),
};
String _exerciseCategoryToDb(domain.ExerciseCategory category) =>
switch (category) {
domain.ExerciseCategory.shoot => 'shoot',
domain.ExerciseCategory.freeThrows => 'freeThrows',
domain.ExerciseCategory.dribble => 'dribble',
domain.ExerciseCategory.finishing => 'finishing',
domain.ExerciseCategory.conditioning => 'conditioning',
domain.ExerciseCategory.defense => 'defense',
domain.ExerciseCategory.mobility => 'mobility',
domain.ExerciseCategory.uncategorized => 'uncategorized',
};
domain.ExerciseCategory _exerciseCategoryFromDb(String value) =>
switch (value) {
'shoot' => domain.ExerciseCategory.shoot,
'freeThrows' => domain.ExerciseCategory.freeThrows,
'dribble' => domain.ExerciseCategory.dribble,
'finishing' => domain.ExerciseCategory.finishing,
'conditioning' => domain.ExerciseCategory.conditioning,
'defense' => domain.ExerciseCategory.defense,
'mobility' => domain.ExerciseCategory.mobility,
'uncategorized' => domain.ExerciseCategory.uncategorized,
_ => throw domain.DomainException('Unknown exercise category: $value'),
};
String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) {
domain.SetResultStatus.completed => 'completed',
domain.SetResultStatus.skipped => 'skipped',
@ -3776,7 +4003,7 @@ List<String> _decodeImageMediaIdsSnapshot(
String? fallbackImageMediaId,
) {
if (encoded == null || encoded.trim().isEmpty) {
return [if (fallbackImageMediaId != null) fallbackImageMediaId];
return [?fallbackImageMediaId];
}
final decoded = jsonDecode(encoded);
if (decoded is! List) {

View File

@ -193,6 +193,9 @@ class Exercises extends SyncableTable {
IntColumn get defaultTargetScoreTimeMs => integer().nullable()();
BoolColumn get autoStartNextTimedStep =>
boolean().withDefault(const Constant(true))();
TextColumn get category =>
text().withDefault(const Constant('uncategorized'))();
BoolColumn get isExample => boolean().withDefault(const Constant(false))();
DateTimeColumn get archivedAt => dateTime().nullable()();
@override
@ -209,6 +212,8 @@ class Exercises extends SyncableTable {
'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 (category IN ('shoot', 'freeThrows', 'dribble', 'finishing', "
"'conditioning', 'defense', 'mobility', 'uncategorized'))",
];
}
@ -278,6 +283,7 @@ class Programs extends SyncableTable {
TextColumn get name => text().withLength(min: 1)();
IntColumn get defaultRestSeconds => integer()();
BoolColumn get isExample => boolean().withDefault(const Constant(false))();
@override
List<String> get customConstraints => ['CHECK (default_rest_seconds >= 0)'];
@ -360,6 +366,20 @@ class WorkoutTemplates extends SyncableTable {
TextColumn get name => text().withLength(min: 1)();
DateTimeColumn get lastStartedAt => dateTime().nullable()();
BoolColumn get isExample => boolean().withDefault(const Constant(false))();
}
class LocalSeedMetadata extends Table {
@override
String get tableName => 'local_seed_metadata';
TextColumn get key => text()();
IntColumn get version =>
integer().customConstraint('NOT NULL CHECK (version >= 0)')();
DateTimeColumn get appliedAt => dateTime()();
@override
Set<Column> get primaryKey => {key};
}
class WorkoutTemplatePrograms extends SyncableTable {
@ -653,7 +673,7 @@ class ActiveExerciseStepResults extends SyncableTable {
'CHECK (step_index >= 0)',
"CHECK (step_type_snapshot IN ('time', 'reps'))",
'CHECK (target_value_snapshot > 0)',
"CHECK (score_input_mode_snapshot IS NULL OR "
'CHECK (score_input_mode_snapshot IS NULL OR '
"score_input_mode_snapshot IN ('manual', 'stopwatch'))",
"CHECK (status IN ('completed', 'skipped'))",
'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)',
@ -813,7 +833,7 @@ class WorkoutHistoryStepResults extends SyncableTable {
'CHECK (step_index >= 0)',
"CHECK (step_type_snapshot IN ('time', 'reps'))",
'CHECK (target_value_snapshot > 0)',
"CHECK (score_input_mode_snapshot IS NULL OR "
'CHECK (score_input_mode_snapshot IS NULL OR '
"score_input_mode_snapshot IN ('manual', 'stopwatch'))",
"CHECK (status IN ('completed', 'skipped'))",
'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)',