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:
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user