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

@ -68,6 +68,7 @@ final class AppBootstrap implements AppDependencies {
final syncMetadataRepository = DriftSyncMetadataRepository(database);
final mappingRepository = DriftRemoteResourceMappingRepository(database);
final localSyncChangeRepository = DriftLocalSyncChangeRepository(database);
final starterSeedRepository = DriftStarterSeedRepository(database);
final shareInboxRepository = DriftShareInboxRepository(database);
final pendingShareActionRepository = DriftPendingShareActionRepository(
database,
@ -79,6 +80,12 @@ final class AppBootstrap implements AppDependencies {
final ids = LocalIdGenerator();
const clock = SystemClock();
const originDeviceId = 'local-device';
await SeedStarterContentUseCase(
seedStateRepository: starterSeedRepository,
contentRepository: starterSeedRepository,
clock: clock,
originDeviceId: originDeviceId,
).run();
final remoteAuthApi = HttpRemoteAuthApi(
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
);

View File

@ -5,4 +5,6 @@
library;
export 'ports.dart';
export 'starter_content/basket_starter_seed_v1.dart';
export 'starter_content/starter_content.dart';
export 'use_cases.dart';

View File

@ -15,6 +15,28 @@ abstract interface class IdGenerator {
String newId();
}
final class StarterContent {
const StarterContent({
required this.exercises,
required this.program,
required this.workoutTemplate,
});
final List<Exercise> exercises;
final Program program;
final WorkoutTemplate workoutTemplate;
}
abstract interface class StarterSeedStateRepository {
Future<int> readAppliedStarterSeedVersion();
Future<void> writeAppliedStarterSeedVersion(int version, DateTime appliedAt);
}
abstract interface class StarterContentRepository {
Future<bool> isLocalContentEmpty();
Future<void> insertStarterContent(StarterContent content);
}
enum RemoteAuthFailure {
invalidCredentials,
emailAlreadyUsed,

View File

@ -0,0 +1,526 @@
import '../../domain/domain.dart';
import 'starter_content.dart';
const basketStarterExerciseSeedsV1 = [
StarterExerciseSeed(
id: 'starter-v1-exercise-form-shooting',
name: 'Form shooting proche panier',
description: 'Travail technique près du cercle, priorité au geste propre.',
category: ExerciseCategory.shoot,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'paniers',
defaultTargetReps: 25,
defaultTargetScore: 20,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-spot-shooting-5-positions',
name: 'Spot shooting 5 positions',
description: '5 tirs depuis 5 spots autour de la raquette ou du périmètre.',
category: ExerciseCategory.shoot,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'paniers',
defaultTargetReps: 1,
defaultTargetScore: 15,
steps: [
StarterExerciseStepSeed(
id: 'starter-v1-step-spot-shooting-right-corner',
name: 'Coin droit',
type: ExerciseStepType.reps,
defaultTargetValue: 5,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-spot-shooting-right-wing',
name: 'Aile droite',
type: ExerciseStepType.reps,
defaultTargetValue: 5,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-spot-shooting-top',
name: 'Face cercle',
type: ExerciseStepType.reps,
defaultTargetValue: 5,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-spot-shooting-left-wing',
name: 'Aile gauche',
type: ExerciseStepType.reps,
defaultTargetValue: 5,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-spot-shooting-left-corner',
name: 'Coin gauche',
type: ExerciseStepType.reps,
defaultTargetValue: 5,
),
],
),
StarterExerciseSeed(
id: 'starter-v1-exercise-catch-and-shoot',
name: 'Catch & shoot',
description: 'Recevoir, armer vite, tirer équilibré.',
category: ExerciseCategory.shoot,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'paniers',
defaultTargetReps: 20,
defaultTargetScore: 12,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-pull-up-shooting',
name: 'Tir après dribble',
description: 'Création d\'espace puis tir en rythme.',
category: ExerciseCategory.shoot,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'paniers',
defaultTargetReps: 20,
defaultTargetScore: 10,
steps: [
StarterExerciseStepSeed(
id: 'starter-v1-step-pull-up-right',
name: 'Départ main droite',
type: ExerciseStepType.reps,
defaultTargetValue: 10,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-pull-up-left',
name: 'Départ main gauche',
type: ExerciseStepType.reps,
defaultTargetValue: 10,
),
],
),
StarterExerciseSeed(
id: 'starter-v1-exercise-free-throw-routine',
name: 'Routine lancers francs',
description: 'Série calme avec routine complète avant chaque tir.',
category: ExerciseCategory.freeThrows,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'lancers',
defaultTargetReps: 20,
defaultTargetScore: 16,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-fatigue-free-throws',
name: 'Lancers francs sous fatigue',
description: 'Lancers francs après effort court.',
category: ExerciseCategory.freeThrows,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'lancers',
defaultTargetReps: 5,
defaultTargetScore: 8,
steps: [
StarterExerciseStepSeed(
id: 'starter-v1-step-fatigue-free-throws-baseline-run',
name: 'Course ligne de fond',
type: ExerciseStepType.time,
defaultTargetValue: 15,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-fatigue-free-throws-two-shots',
name: '2 lancers francs',
type: ExerciseStepType.reps,
defaultTargetValue: 2,
),
],
),
StarterExerciseSeed(
id: 'starter-v1-exercise-stationary-dribble-alternating',
name: 'Dribble stationnaire alterné',
description: 'Contrôle de balle enchaîné main droite/main gauche.',
category: ExerciseCategory.dribble,
hasTimeMeasure: true,
defaultTargetTimeSeconds: 60,
steps: [
StarterExerciseStepSeed(
id: 'starter-v1-step-stationary-dribble-right-low',
name: 'Main droite basse',
type: ExerciseStepType.time,
defaultTargetValue: 15,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-stationary-dribble-left-low',
name: 'Main gauche basse',
type: ExerciseStepType.time,
defaultTargetValue: 15,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-stationary-dribble-crossovers',
name: 'Crossovers',
type: ExerciseStepType.time,
defaultTargetValue: 15,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-stationary-dribble-between-legs',
name: 'Entre les jambes',
type: ExerciseStepType.time,
defaultTargetValue: 15,
),
],
),
StarterExerciseSeed(
id: 'starter-v1-exercise-combo-change-of-hand',
name: 'Combo changement de main',
description: 'Enchaîner les changements de main avec régularité.',
category: ExerciseCategory.dribble,
hasRepsMeasure: true,
defaultTargetReps: 10,
steps: [
StarterExerciseStepSeed(
id: 'starter-v1-step-combo-crossover',
name: 'Crossover',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-combo-between-legs',
name: 'Entre les jambes',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-combo-behind-back',
name: 'Dans le dos',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-combo-explosive-start',
name: 'Départ explosif',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
],
),
StarterExerciseSeed(
id: 'starter-v1-exercise-cone-slalom',
name: 'Slalom cônes',
description: 'Conduite de balle en vitesse sur parcours.',
category: ExerciseCategory.dribble,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
scoreLabel: 'Temps parcours',
defaultTargetReps: 6,
defaultTargetScoreTimeMs: 12000,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-mikan-drill',
name: 'Mikan drill',
description: 'Alternance de finitions près du cercle.',
category: ExerciseCategory.finishing,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'paniers',
defaultTargetReps: 20,
defaultTargetScore: 16,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-weak-hand-finishes',
name: 'Finitions main faible',
description: 'Layups et finitions contrôlées main faible.',
category: ExerciseCategory.finishing,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'paniers',
defaultTargetReps: 20,
defaultTargetScore: 12,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-alternating-layups',
name: 'Layups alternés droite/gauche',
description: 'Alterner les côtés avec course courte.',
category: ExerciseCategory.finishing,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'paniers',
defaultTargetReps: 20,
defaultTargetScore: 16,
steps: [
StarterExerciseStepSeed(
id: 'starter-v1-step-alternating-layup-right',
name: 'Layup côté droit',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-alternating-layup-left',
name: 'Layup côté gauche',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
],
),
StarterExerciseSeed(
id: 'starter-v1-exercise-contact-finishing',
name: 'Finition avec contact',
description: 'Absorber le contact puis finir près du cercle.',
category: ExerciseCategory.finishing,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'paniers',
defaultTargetReps: 12,
defaultTargetScore: 8,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-half-court-suicides',
name: 'Suicides demi-terrain',
description: 'Aller-retour progressif sur lignes du terrain.',
category: ExerciseCategory.conditioning,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
scoreLabel: 'Temps total',
defaultTargetReps: 4,
defaultTargetScoreTimeMs: 45000,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-line-hops',
name: 'Line hops',
description: 'Petits sauts rapides de part et d\'autre d\'une ligne.',
category: ExerciseCategory.conditioning,
hasTimeMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Contacts',
scoreUnit: 'touches',
defaultTargetTimeSeconds: 30,
defaultTargetScore: 50,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-baseline-sprint',
name: 'Sprint ligne de fond',
description: 'Départs explosifs répétés.',
category: ExerciseCategory.conditioning,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
scoreLabel: 'Meilleur sprint',
defaultTargetReps: 10,
defaultTargetScoreTimeMs: 5000,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-defensive-slides',
name: 'Slides défensifs',
description: 'Déplacements latéraux bas et contrôlés.',
category: ExerciseCategory.defense,
hasTimeMeasure: true,
defaultTargetTimeSeconds: 45,
),
StarterExerciseSeed(
id: 'starter-v1-exercise-closeout-slide',
name: 'Closeout + slide',
description: 'Fermer l\'espace puis contenir latéralement.',
category: ExerciseCategory.defense,
hasRepsMeasure: true,
defaultTargetReps: 10,
steps: [
StarterExerciseStepSeed(
id: 'starter-v1-step-closeout-slide-closeout',
name: 'Closeout',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-closeout-slide-right',
name: 'Slide droite',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-closeout-slide-left',
name: 'Slide gauche',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
],
),
StarterExerciseSeed(
id: 'starter-v1-exercise-rebound-outlet-pass',
name: 'Rebond + outlet pass',
description: 'Capturer le rebond puis ressortir proprement.',
category: ExerciseCategory.defense,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Réussites',
scoreUnit: 'actions',
defaultTargetReps: 12,
defaultTargetScore: 10,
steps: [
StarterExerciseStepSeed(
id: 'starter-v1-step-rebound-outlet-rebound',
name: 'Rebond',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-rebound-outlet-pass',
name: 'Outlet pass',
type: ExerciseStepType.reps,
defaultTargetValue: 1,
),
],
),
StarterExerciseSeed(
id: 'starter-v1-exercise-basket-hip-mobility',
name: 'Mobilité hanches basket',
description: 'Préparer hanches, appuis et amplitude.',
category: ExerciseCategory.mobility,
hasTimeMeasure: true,
defaultTargetTimeSeconds: 300,
steps: [
StarterExerciseStepSeed(
id: 'starter-v1-step-hip-mobility-openers',
name: 'Ouverture hanches',
type: ExerciseStepType.time,
defaultTargetValue: 60,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-hip-mobility-lunges',
name: 'Fentes dynamiques',
type: ExerciseStepType.time,
defaultTargetValue: 60,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-hip-mobility-squat-hold',
name: 'Squat hold',
type: ExerciseStepType.time,
defaultTargetValue: 60,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-hip-mobility-adductors',
name: 'Adducteurs',
type: ExerciseStepType.time,
defaultTargetValue: 60,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-hip-mobility-breathing',
name: 'Respiration basse',
type: ExerciseStepType.time,
defaultTargetValue: 60,
),
],
),
StarterExerciseSeed(
id: 'starter-v1-exercise-ankles-calves',
name: 'Chevilles et mollets',
description: 'Préparer les appuis, réceptions et changements de direction.',
category: ExerciseCategory.mobility,
hasTimeMeasure: true,
defaultTargetTimeSeconds: 180,
steps: [
StarterExerciseStepSeed(
id: 'starter-v1-step-ankles-calves-right-ankle',
name: 'Mobilité cheville droite',
type: ExerciseStepType.time,
defaultTargetValue: 60,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-ankles-calves-left-ankle',
name: 'Mobilité cheville gauche',
type: ExerciseStepType.time,
defaultTargetValue: 60,
),
StarterExerciseStepSeed(
id: 'starter-v1-step-ankles-calves-dynamic-calves',
name: 'Mollets dynamiques',
type: ExerciseStepType.time,
defaultTargetValue: 60,
),
],
),
];
const basketStarterProgramSeedV1 = StarterProgramSeed(
id: 'starter-v1-program-basket-foundations-45-min',
name: 'Fondations basket - 45 min',
defaultRestSeconds: 45,
exercises: [
StarterProgramExerciseSeed(
id: 'starter-v1-program-exercise-hip-mobility',
exerciseId: 'starter-v1-exercise-basket-hip-mobility',
setsCount: 1,
enabledMeasures: {WorkoutMeasure.time},
targetTimeSeconds: 300,
restSecondsOverride: 30,
),
StarterProgramExerciseSeed(
id: 'starter-v1-program-exercise-stationary-dribble',
exerciseId: 'starter-v1-exercise-stationary-dribble-alternating',
setsCount: 2,
enabledMeasures: {WorkoutMeasure.time},
targetTimeSeconds: 60,
restSecondsOverride: 30,
),
StarterProgramExerciseSeed(
id: 'starter-v1-program-exercise-combo-change-of-hand',
exerciseId: 'starter-v1-exercise-combo-change-of-hand',
setsCount: 2,
enabledMeasures: {WorkoutMeasure.reps},
targetReps: 10,
restSecondsOverride: 45,
),
StarterProgramExerciseSeed(
id: 'starter-v1-program-exercise-form-shooting',
exerciseId: 'starter-v1-exercise-form-shooting',
setsCount: 3,
enabledMeasures: {WorkoutMeasure.reps, WorkoutMeasure.score},
targetReps: 25,
targetScore: 20,
restSecondsOverride: 45,
),
StarterProgramExerciseSeed(
id: 'starter-v1-program-exercise-spot-shooting',
exerciseId: 'starter-v1-exercise-spot-shooting-5-positions',
setsCount: 3,
enabledMeasures: {WorkoutMeasure.reps, WorkoutMeasure.score},
targetReps: 1,
targetScore: 15,
restSecondsOverride: 60,
),
StarterProgramExerciseSeed(
id: 'starter-v1-program-exercise-alternating-layups',
exerciseId: 'starter-v1-exercise-alternating-layups',
setsCount: 3,
enabledMeasures: {WorkoutMeasure.reps, WorkoutMeasure.score},
targetReps: 20,
targetScore: 16,
restSecondsOverride: 45,
),
StarterProgramExerciseSeed(
id: 'starter-v1-program-exercise-free-throw-routine',
exerciseId: 'starter-v1-exercise-free-throw-routine',
setsCount: 4,
enabledMeasures: {WorkoutMeasure.reps, WorkoutMeasure.score},
targetReps: 10,
targetScore: 8,
restSecondsOverride: 30,
),
StarterProgramExerciseSeed(
id: 'starter-v1-program-exercise-defensive-slides',
exerciseId: 'starter-v1-exercise-defensive-slides',
setsCount: 4,
enabledMeasures: {WorkoutMeasure.time},
targetTimeSeconds: 45,
),
],
);
const basketStarterWorkoutTemplateSeedV1 = StarterWorkoutTemplateSeed(
id: 'starter-v1-workout-template-basket-foundations-example',
name: 'Séance exemple - Fondations basket',
programSnapshotId: 'starter-v1-workout-template-program-basket-foundations',
);

View File

@ -0,0 +1,204 @@
import '../../domain/domain.dart';
import '../ports.dart';
const int starterSeedVersion = 1;
final class StarterExerciseSeed {
const StarterExerciseSeed({
required this.id,
required this.name,
required this.description,
required this.category,
this.hasTimeMeasure = false,
this.hasRepsMeasure = false,
this.hasScoreMeasure = false,
this.scoreInputMode = ScoreInputMode.manual,
this.scoreLabel,
this.scoreUnit,
this.defaultTargetTimeSeconds,
this.defaultTargetReps,
this.defaultTargetScore,
this.defaultTargetScoreTimeMs,
this.steps = const [],
});
final String id;
final String name;
final String description;
final ExerciseCategory category;
final bool hasTimeMeasure;
final bool hasRepsMeasure;
final bool hasScoreMeasure;
final ScoreInputMode scoreInputMode;
final String? scoreLabel;
final String? scoreUnit;
final int? defaultTargetTimeSeconds;
final int? defaultTargetReps;
final double? defaultTargetScore;
final int? defaultTargetScoreTimeMs;
final List<StarterExerciseStepSeed> steps;
}
final class StarterExerciseStepSeed {
const StarterExerciseStepSeed({
required this.id,
required this.name,
required this.type,
required this.defaultTargetValue,
});
final String id;
final String name;
final ExerciseStepType type;
final int defaultTargetValue;
}
final class StarterProgramSeed {
const StarterProgramSeed({
required this.id,
required this.name,
required this.defaultRestSeconds,
required this.exercises,
});
final String id;
final String name;
final int defaultRestSeconds;
final List<StarterProgramExerciseSeed> exercises;
}
final class StarterProgramExerciseSeed {
const StarterProgramExerciseSeed({
required this.id,
required this.exerciseId,
required this.setsCount,
required this.enabledMeasures,
this.targetTimeSeconds,
this.targetReps,
this.targetScore,
this.targetScoreTimeMs,
this.restSecondsOverride,
});
final String id;
final String exerciseId;
final int setsCount;
final Set<WorkoutMeasure> enabledMeasures;
final int? targetTimeSeconds;
final int? targetReps;
final double? targetScore;
final int? targetScoreTimeMs;
final int? restSecondsOverride;
}
final class StarterWorkoutTemplateSeed {
const StarterWorkoutTemplateSeed({
required this.id,
required this.name,
required this.programSnapshotId,
});
final String id;
final String name;
final String programSnapshotId;
}
StarterContent buildStarterContent({
required List<StarterExerciseSeed> exerciseSeeds,
required StarterProgramSeed programSeed,
required StarterWorkoutTemplateSeed templateSeed,
required DateTime now,
required String originDeviceId,
}) {
final exercisesById = <String, Exercise>{};
for (final seed in exerciseSeeds) {
exercisesById[seed.id] = Exercise(
metadata: _metadata(seed.id, now, originDeviceId),
name: seed.name,
description: seed.description,
hasTimeMeasure: seed.hasTimeMeasure,
hasRepsMeasure: seed.hasRepsMeasure,
hasScoreMeasure: seed.hasScoreMeasure,
scoreInputMode: seed.scoreInputMode,
scoreLabel: seed.scoreLabel,
scoreUnit: seed.scoreUnit,
defaultTargetTimeSeconds: seed.defaultTargetTimeSeconds,
defaultTargetReps: seed.defaultTargetReps,
defaultTargetScore: seed.defaultTargetScore,
defaultTargetScoreTimeMs: seed.defaultTargetScoreTimeMs,
category: seed.category,
isExample: true,
steps: [
for (var index = 0; index < seed.steps.length; index += 1)
ExerciseStep(
id: seed.steps[index].id,
position: index,
name: seed.steps[index].name,
type: seed.steps[index].type,
defaultTargetValue: seed.steps[index].defaultTargetValue,
),
],
);
}
final programExercises = <ProgramExercise>[];
for (var index = 0; index < programSeed.exercises.length; index += 1) {
final seed = programSeed.exercises[index];
final exercise = exercisesById[seed.exerciseId];
if (exercise == null) {
throw DomainException('Unknown starter exercise id: ${seed.exerciseId}');
}
programExercises.add(
ProgramExercise.snapshotFromExercise(
metadata: _metadata(seed.id, now, originDeviceId),
programId: programSeed.id,
exercise: exercise,
position: index,
setsCount: seed.setsCount,
enabledMeasures: seed.enabledMeasures,
targetTimeSeconds: seed.targetTimeSeconds,
targetReps: seed.targetReps,
targetScore: seed.targetScore,
targetScoreTimeMs: seed.targetScoreTimeMs,
restSecondsOverride: seed.restSecondsOverride,
),
);
}
final program = Program(
metadata: _metadata(programSeed.id, now, originDeviceId),
name: programSeed.name,
defaultRestSeconds: programSeed.defaultRestSeconds,
isExample: true,
exercises: programExercises,
);
final templateProgram = WorkoutTemplateProgram.snapshotFromProgram(
metadata: _metadata(templateSeed.programSnapshotId, now, originDeviceId),
workoutTemplateId: templateSeed.id,
program: program,
position: 0,
);
final template = WorkoutTemplate(
metadata: _metadata(templateSeed.id, now, originDeviceId),
name: templateSeed.name,
isExample: true,
programs: [templateProgram],
);
return StarterContent(
exercises: exercisesById.values.toList(growable: false),
program: program,
workoutTemplate: template,
);
}
EntityMetadata _metadata(String id, DateTime now, String originDeviceId) {
return EntityMetadata(
id: id,
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
syncState: SyncState.localOnly,
localRevision: 0,
);
}

View File

@ -2,9 +2,76 @@ import 'dart:convert';
import '../domain/domain.dart';
import 'ports.dart';
import 'starter_content/basket_starter_seed_v1.dart';
import 'starter_content/starter_content.dart';
const Object _useCaseUnchanged = Object();
enum StarterSeedStatus { inserted, skippedAlreadyApplied, skippedNotEmpty }
final class StarterSeedResult {
const StarterSeedResult({required this.status, required this.appliedVersion});
final StarterSeedStatus status;
final int appliedVersion;
}
final class SeedStarterContentUseCase {
const SeedStarterContentUseCase({
required this.seedStateRepository,
required this.contentRepository,
required this.clock,
required this.originDeviceId,
});
final StarterSeedStateRepository seedStateRepository;
final StarterContentRepository contentRepository;
final Clock clock;
final String originDeviceId;
Future<StarterSeedResult> run() async {
final appliedVersion = await seedStateRepository
.readAppliedStarterSeedVersion();
if (appliedVersion >= starterSeedVersion) {
return const StarterSeedResult(
status: StarterSeedStatus.skippedAlreadyApplied,
appliedVersion: starterSeedVersion,
);
}
final now = clock.now();
final isEmpty = await contentRepository.isLocalContentEmpty();
if (!isEmpty) {
await seedStateRepository.writeAppliedStarterSeedVersion(
starterSeedVersion,
now,
);
return const StarterSeedResult(
status: StarterSeedStatus.skippedNotEmpty,
appliedVersion: starterSeedVersion,
);
}
await contentRepository.insertStarterContent(
buildStarterContent(
exerciseSeeds: basketStarterExerciseSeedsV1,
programSeed: basketStarterProgramSeedV1,
templateSeed: basketStarterWorkoutTemplateSeedV1,
now: now,
originDeviceId: originDeviceId,
),
);
await seedStateRepository.writeAppliedStarterSeedVersion(
starterSeedVersion,
now,
);
return const StarterSeedResult(
status: StarterSeedStatus.inserted,
appliedVersion: starterSeedVersion,
);
}
}
final class AuthUseCases {
const AuthUseCases({
required this.tokenStore,