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

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