diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 490b872..643bed8 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -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)), ); diff --git a/lib/application/application.dart b/lib/application/application.dart index 32e79f7..c115371 100644 --- a/lib/application/application.dart +++ b/lib/application/application.dart @@ -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'; diff --git a/lib/application/ports.dart b/lib/application/ports.dart index db61a3c..807af7e 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -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 exercises; + final Program program; + final WorkoutTemplate workoutTemplate; +} + +abstract interface class StarterSeedStateRepository { + Future readAppliedStarterSeedVersion(); + Future writeAppliedStarterSeedVersion(int version, DateTime appliedAt); +} + +abstract interface class StarterContentRepository { + Future isLocalContentEmpty(); + Future insertStarterContent(StarterContent content); +} + enum RemoteAuthFailure { invalidCredentials, emailAlreadyUsed, diff --git a/lib/application/starter_content/basket_starter_seed_v1.dart b/lib/application/starter_content/basket_starter_seed_v1.dart new file mode 100644 index 0000000..d126e8f --- /dev/null +++ b/lib/application/starter_content/basket_starter_seed_v1.dart @@ -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', +); diff --git a/lib/application/starter_content/starter_content.dart b/lib/application/starter_content/starter_content.dart new file mode 100644 index 0000000..a2c9393 --- /dev/null +++ b/lib/application/starter_content/starter_content.dart @@ -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 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 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 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 exerciseSeeds, + required StarterProgramSeed programSeed, + required StarterWorkoutTemplateSeed templateSeed, + required DateTime now, + required String originDeviceId, +}) { + final exercisesById = {}; + 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 = []; + 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, + ); +} diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 42a829b..7692779 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -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 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, diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 787ed48..acfd83d 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -10,6 +10,17 @@ enum ScoreInputMode { manual, stopwatch } enum ExerciseStepType { time, reps } +enum ExerciseCategory { + shoot, + freeThrows, + dribble, + finishing, + conditioning, + defense, + mobility, + uncategorized, +} + enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned } enum SetResultStatus { completed, skipped } @@ -321,6 +332,8 @@ final class Exercise { this.defaultTargetScore, this.defaultTargetScoreTimeMs, this.autoStartNextTimedStep = true, + this.category = ExerciseCategory.uncategorized, + this.isExample = false, List steps = const [], this.archivedAt, }) : name = _nonBlank(name, 'Exercise name'), @@ -379,6 +392,8 @@ final class Exercise { final double? defaultTargetScore; final int? defaultTargetScoreTimeMs; final bool autoStartNextTimedStep; + final ExerciseCategory category; + final bool isExample; final List steps; final DateTime? archivedAt; @@ -414,6 +429,8 @@ final class Exercise { Object? defaultTargetScore = _unchanged, Object? defaultTargetScoreTimeMs = _unchanged, bool? autoStartNextTimedStep, + ExerciseCategory? category, + bool? isExample, Object? steps = _unchanged, Object? archivedAt = _unchanged, }) { @@ -463,6 +480,8 @@ final class Exercise { : defaultTargetScoreTimeMs as int?, autoStartNextTimedStep: autoStartNextTimedStep ?? this.autoStartNextTimedStep, + category: category ?? this.category, + isExample: isExample ?? this.isExample, steps: steps == _unchanged ? this.steps : steps as List, archivedAt: archivedAt == _unchanged ? this.archivedAt @@ -532,6 +551,7 @@ final class Program { required this.metadata, required String name, required this.defaultRestSeconds, + this.isExample = false, this.exercises = const [], }) : name = _nonBlank(name, 'Program name') { _requireNonNegative(defaultRestSeconds, 'Default rest seconds'); @@ -540,6 +560,7 @@ final class Program { final EntityMetadata metadata; final String name; final int defaultRestSeconds; + final bool isExample; final List exercises; Program copyWith({ @@ -547,11 +568,13 @@ final class Program { EntityMetadata? metadata, String? name, int? defaultRestSeconds, + bool? isExample, }) { return Program( metadata: metadata ?? this.metadata, name: name ?? this.name, defaultRestSeconds: defaultRestSeconds ?? this.defaultRestSeconds, + isExample: isExample ?? this.isExample, exercises: exercises ?? this.exercises, ); } @@ -745,6 +768,7 @@ final class WorkoutTemplate { required this.metadata, required String name, this.lastStartedAt, + this.isExample = false, this.programs = const [], this.overrides = const [], }) : name = _nonBlank(name, 'Workout template name'); @@ -752,6 +776,7 @@ final class WorkoutTemplate { final EntityMetadata metadata; final String name; final DateTime? lastStartedAt; + final bool isExample; final List programs; final List overrides; } diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 1003880..7f9a2d4 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -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 _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 _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 _hasColumn(String tableName, String columnName) async { final rows = await customSelect('PRAGMA table_info($tableName)').get(); return rows.any((row) => row.data['name'] == columnName); diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 8268540..bdc0ca2 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -155,6 +155,21 @@ class $WorkoutTemplatesTable extends WorkoutTemplates type: DriftSqlType.dateTime, requiredDuringInsert: false, ); + static const VerificationMeta _isExampleMeta = const VerificationMeta( + 'isExample', + ); + @override + late final GeneratedColumn isExample = GeneratedColumn( + 'is_example', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_example" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); @override List get $columns => [ id, @@ -170,6 +185,7 @@ class $WorkoutTemplatesTable extends WorkoutTemplates remoteRevision, name, lastStartedAt, + isExample, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -293,6 +309,12 @@ class $WorkoutTemplatesTable extends WorkoutTemplates ), ); } + if (data.containsKey('is_example')) { + context.handle( + _isExampleMeta, + isExample.isAcceptableOrUnknown(data['is_example']!, _isExampleMeta), + ); + } return context; } @@ -354,6 +376,10 @@ class $WorkoutTemplatesTable extends WorkoutTemplates DriftSqlType.dateTime, data['${effectivePrefix}last_started_at'], ), + isExample: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_example'], + )!, ); } @@ -377,6 +403,7 @@ class WorkoutTemplate extends DataClass implements Insertable { final String? remoteRevision; final String name; final DateTime? lastStartedAt; + final bool isExample; const WorkoutTemplate({ required this.id, required this.createdAt, @@ -391,6 +418,7 @@ class WorkoutTemplate extends DataClass implements Insertable { this.remoteRevision, required this.name, this.lastStartedAt, + required this.isExample, }); @override Map toColumns(bool nullToAbsent) { @@ -418,6 +446,7 @@ class WorkoutTemplate extends DataClass implements Insertable { if (!nullToAbsent || lastStartedAt != null) { map['last_started_at'] = Variable(lastStartedAt); } + map['is_example'] = Variable(isExample); return map; } @@ -446,6 +475,7 @@ class WorkoutTemplate extends DataClass implements Insertable { lastStartedAt: lastStartedAt == null && nullToAbsent ? const Value.absent() : Value(lastStartedAt), + isExample: Value(isExample), ); } @@ -470,6 +500,7 @@ class WorkoutTemplate extends DataClass implements Insertable { remoteRevision: serializer.fromJson(json['remoteRevision']), name: serializer.fromJson(json['name']), lastStartedAt: serializer.fromJson(json['lastStartedAt']), + isExample: serializer.fromJson(json['isExample']), ); } @override @@ -489,6 +520,7 @@ class WorkoutTemplate extends DataClass implements Insertable { 'remoteRevision': serializer.toJson(remoteRevision), 'name': serializer.toJson(name), 'lastStartedAt': serializer.toJson(lastStartedAt), + 'isExample': serializer.toJson(isExample), }; } @@ -506,6 +538,7 @@ class WorkoutTemplate extends DataClass implements Insertable { Value remoteRevision = const Value.absent(), String? name, Value lastStartedAt = const Value.absent(), + bool? isExample, }) => WorkoutTemplate( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -526,6 +559,7 @@ class WorkoutTemplate extends DataClass implements Insertable { lastStartedAt: lastStartedAt.present ? lastStartedAt.value : this.lastStartedAt, + isExample: isExample ?? this.isExample, ); WorkoutTemplate copyWithCompanion(WorkoutTemplatesCompanion data) { return WorkoutTemplate( @@ -556,6 +590,7 @@ class WorkoutTemplate extends DataClass implements Insertable { lastStartedAt: data.lastStartedAt.present ? data.lastStartedAt.value : this.lastStartedAt, + isExample: data.isExample.present ? data.isExample.value : this.isExample, ); } @@ -574,7 +609,8 @@ class WorkoutTemplate extends DataClass implements Insertable { ..write('lastSyncedAt: $lastSyncedAt, ') ..write('remoteRevision: $remoteRevision, ') ..write('name: $name, ') - ..write('lastStartedAt: $lastStartedAt') + ..write('lastStartedAt: $lastStartedAt, ') + ..write('isExample: $isExample') ..write(')')) .toString(); } @@ -594,6 +630,7 @@ class WorkoutTemplate extends DataClass implements Insertable { remoteRevision, name, lastStartedAt, + isExample, ); @override bool operator ==(Object other) => @@ -611,7 +648,8 @@ class WorkoutTemplate extends DataClass implements Insertable { other.lastSyncedAt == this.lastSyncedAt && other.remoteRevision == this.remoteRevision && other.name == this.name && - other.lastStartedAt == this.lastStartedAt); + other.lastStartedAt == this.lastStartedAt && + other.isExample == this.isExample); } class WorkoutTemplatesCompanion extends UpdateCompanion { @@ -628,6 +666,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { final Value remoteRevision; final Value name; final Value lastStartedAt; + final Value isExample; final Value rowid; const WorkoutTemplatesCompanion({ this.id = const Value.absent(), @@ -643,6 +682,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { this.remoteRevision = const Value.absent(), this.name = const Value.absent(), this.lastStartedAt = const Value.absent(), + this.isExample = const Value.absent(), this.rowid = const Value.absent(), }); WorkoutTemplatesCompanion.insert({ @@ -659,6 +699,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { this.remoteRevision = const Value.absent(), required String name, this.lastStartedAt = const Value.absent(), + this.isExample = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -681,6 +722,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { Expression? remoteRevision, Expression? name, Expression? lastStartedAt, + Expression? isExample, Expression? rowid, }) { return RawValuesInsertable({ @@ -698,6 +740,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { if (remoteRevision != null) 'remote_revision': remoteRevision, if (name != null) 'name': name, if (lastStartedAt != null) 'last_started_at': lastStartedAt, + if (isExample != null) 'is_example': isExample, if (rowid != null) 'rowid': rowid, }); } @@ -716,6 +759,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { Value? remoteRevision, Value? name, Value? lastStartedAt, + Value? isExample, Value? rowid, }) { return WorkoutTemplatesCompanion( @@ -732,6 +776,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { remoteRevision: remoteRevision ?? this.remoteRevision, name: name ?? this.name, lastStartedAt: lastStartedAt ?? this.lastStartedAt, + isExample: isExample ?? this.isExample, rowid: rowid ?? this.rowid, ); } @@ -780,6 +825,9 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { if (lastStartedAt.present) { map['last_started_at'] = Variable(lastStartedAt.value); } + if (isExample.present) { + map['is_example'] = Variable(isExample.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -802,6 +850,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { ..write('remoteRevision: $remoteRevision, ') ..write('name: $name, ') ..write('lastStartedAt: $lastStartedAt, ') + ..write('isExample: $isExample, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -12827,6 +12876,33 @@ class $ExercisesTable extends Exercises ), defaultValue: const Constant(true), ); + static const VerificationMeta _categoryMeta = const VerificationMeta( + 'category', + ); + @override + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('uncategorized'), + ); + static const VerificationMeta _isExampleMeta = const VerificationMeta( + 'isExample', + ); + @override + late final GeneratedColumn isExample = GeneratedColumn( + 'is_example', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_example" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); static const VerificationMeta _archivedAtMeta = const VerificationMeta( 'archivedAt', ); @@ -12866,6 +12942,8 @@ class $ExercisesTable extends Exercises defaultTargetScore, defaultTargetScoreTimeMs, autoStartNextTimedStep, + category, + isExample, archivedAt, ]; @override @@ -13107,6 +13185,18 @@ class $ExercisesTable extends Exercises ), ); } + if (data.containsKey('category')) { + context.handle( + _categoryMeta, + category.isAcceptableOrUnknown(data['category']!, _categoryMeta), + ); + } + if (data.containsKey('is_example')) { + context.handle( + _isExampleMeta, + isExample.isAcceptableOrUnknown(data['is_example']!, _isExampleMeta), + ); + } if (data.containsKey('archived_at')) { context.handle( _archivedAtMeta, @@ -13226,6 +13316,14 @@ class $ExercisesTable extends Exercises DriftSqlType.bool, data['${effectivePrefix}auto_start_next_timed_step'], )!, + category: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category'], + )!, + isExample: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_example'], + )!, archivedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}archived_at'], @@ -13266,6 +13364,8 @@ class Exercise extends DataClass implements Insertable { final double? defaultTargetScore; final int? defaultTargetScoreTimeMs; final bool autoStartNextTimedStep; + final String category; + final bool isExample; final DateTime? archivedAt; const Exercise({ required this.id, @@ -13294,6 +13394,8 @@ class Exercise extends DataClass implements Insertable { this.defaultTargetScore, this.defaultTargetScoreTimeMs, required this.autoStartNextTimedStep, + required this.category, + required this.isExample, this.archivedAt, }); @override @@ -13355,6 +13457,8 @@ class Exercise extends DataClass implements Insertable { ); } map['auto_start_next_timed_step'] = Variable(autoStartNextTimedStep); + map['category'] = Variable(category); + map['is_example'] = Variable(isExample); if (!nullToAbsent || archivedAt != null) { map['archived_at'] = Variable(archivedAt); } @@ -13415,6 +13519,8 @@ class Exercise extends DataClass implements Insertable { ? const Value.absent() : Value(defaultTargetScoreTimeMs), autoStartNextTimedStep: Value(autoStartNextTimedStep), + category: Value(category), + isExample: Value(isExample), archivedAt: archivedAt == null && nullToAbsent ? const Value.absent() : Value(archivedAt), @@ -13463,6 +13569,8 @@ class Exercise extends DataClass implements Insertable { autoStartNextTimedStep: serializer.fromJson( json['autoStartNextTimedStep'], ), + category: serializer.fromJson(json['category']), + isExample: serializer.fromJson(json['isExample']), archivedAt: serializer.fromJson(json['archivedAt']), ); } @@ -13500,6 +13608,8 @@ class Exercise extends DataClass implements Insertable { defaultTargetScoreTimeMs, ), 'autoStartNextTimedStep': serializer.toJson(autoStartNextTimedStep), + 'category': serializer.toJson(category), + 'isExample': serializer.toJson(isExample), 'archivedAt': serializer.toJson(archivedAt), }; } @@ -13531,6 +13641,8 @@ class Exercise extends DataClass implements Insertable { Value defaultTargetScore = const Value.absent(), Value defaultTargetScoreTimeMs = const Value.absent(), bool? autoStartNextTimedStep, + String? category, + bool? isExample, Value archivedAt = const Value.absent(), }) => Exercise( id: id ?? this.id, @@ -13572,6 +13684,8 @@ class Exercise extends DataClass implements Insertable { : this.defaultTargetScoreTimeMs, autoStartNextTimedStep: autoStartNextTimedStep ?? this.autoStartNextTimedStep, + category: category ?? this.category, + isExample: isExample ?? this.isExample, archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, ); Exercise copyWithCompanion(ExercisesCompanion data) { @@ -13640,6 +13754,8 @@ class Exercise extends DataClass implements Insertable { autoStartNextTimedStep: data.autoStartNextTimedStep.present ? data.autoStartNextTimedStep.value : this.autoStartNextTimedStep, + category: data.category.present ? data.category.value : this.category, + isExample: data.isExample.present ? data.isExample.value : this.isExample, archivedAt: data.archivedAt.present ? data.archivedAt.value : this.archivedAt, @@ -13675,6 +13791,8 @@ class Exercise extends DataClass implements Insertable { ..write('defaultTargetScore: $defaultTargetScore, ') ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs, ') ..write('autoStartNextTimedStep: $autoStartNextTimedStep, ') + ..write('category: $category, ') + ..write('isExample: $isExample, ') ..write('archivedAt: $archivedAt') ..write(')')) .toString(); @@ -13708,6 +13826,8 @@ class Exercise extends DataClass implements Insertable { defaultTargetScore, defaultTargetScoreTimeMs, autoStartNextTimedStep, + category, + isExample, archivedAt, ]); @override @@ -13740,6 +13860,8 @@ class Exercise extends DataClass implements Insertable { other.defaultTargetScore == this.defaultTargetScore && other.defaultTargetScoreTimeMs == this.defaultTargetScoreTimeMs && other.autoStartNextTimedStep == this.autoStartNextTimedStep && + other.category == this.category && + other.isExample == this.isExample && other.archivedAt == this.archivedAt); } @@ -13770,6 +13892,8 @@ class ExercisesCompanion extends UpdateCompanion { final Value defaultTargetScore; final Value defaultTargetScoreTimeMs; final Value autoStartNextTimedStep; + final Value category; + final Value isExample; final Value archivedAt; final Value rowid; const ExercisesCompanion({ @@ -13799,6 +13923,8 @@ class ExercisesCompanion extends UpdateCompanion { this.defaultTargetScore = const Value.absent(), this.defaultTargetScoreTimeMs = const Value.absent(), this.autoStartNextTimedStep = const Value.absent(), + this.category = const Value.absent(), + this.isExample = const Value.absent(), this.archivedAt = const Value.absent(), this.rowid = const Value.absent(), }); @@ -13829,6 +13955,8 @@ class ExercisesCompanion extends UpdateCompanion { this.defaultTargetScore = const Value.absent(), this.defaultTargetScoreTimeMs = const Value.absent(), this.autoStartNextTimedStep = const Value.absent(), + this.category = const Value.absent(), + this.isExample = const Value.absent(), this.archivedAt = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), @@ -13868,6 +13996,8 @@ class ExercisesCompanion extends UpdateCompanion { Expression? defaultTargetScore, Expression? defaultTargetScoreTimeMs, Expression? autoStartNextTimedStep, + Expression? category, + Expression? isExample, Expression? archivedAt, Expression? rowid, }) { @@ -13903,6 +14033,8 @@ class ExercisesCompanion extends UpdateCompanion { 'default_target_score_time_ms': defaultTargetScoreTimeMs, if (autoStartNextTimedStep != null) 'auto_start_next_timed_step': autoStartNextTimedStep, + if (category != null) 'category': category, + if (isExample != null) 'is_example': isExample, if (archivedAt != null) 'archived_at': archivedAt, if (rowid != null) 'rowid': rowid, }); @@ -13935,6 +14067,8 @@ class ExercisesCompanion extends UpdateCompanion { Value? defaultTargetScore, Value? defaultTargetScoreTimeMs, Value? autoStartNextTimedStep, + Value? category, + Value? isExample, Value? archivedAt, Value? rowid, }) { @@ -13968,6 +14102,8 @@ class ExercisesCompanion extends UpdateCompanion { defaultTargetScoreTimeMs ?? this.defaultTargetScoreTimeMs, autoStartNextTimedStep: autoStartNextTimedStep ?? this.autoStartNextTimedStep, + category: category ?? this.category, + isExample: isExample ?? this.isExample, archivedAt: archivedAt ?? this.archivedAt, rowid: rowid ?? this.rowid, ); @@ -14062,6 +14198,12 @@ class ExercisesCompanion extends UpdateCompanion { autoStartNextTimedStep.value, ); } + if (category.present) { + map['category'] = Variable(category.value); + } + if (isExample.present) { + map['is_example'] = Variable(isExample.value); + } if (archivedAt.present) { map['archived_at'] = Variable(archivedAt.value); } @@ -14100,6 +14242,8 @@ class ExercisesCompanion extends UpdateCompanion { ..write('defaultTargetScore: $defaultTargetScore, ') ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs, ') ..write('autoStartNextTimedStep: $autoStartNextTimedStep, ') + ..write('category: $category, ') + ..write('isExample: $isExample, ') ..write('archivedAt: $archivedAt, ') ..write('rowid: $rowid') ..write(')')) @@ -16245,6 +16389,276 @@ class ExerciseStepsCompanion extends UpdateCompanion { } } +class $LocalSeedMetadataTable extends LocalSeedMetadata + with TableInfo<$LocalSeedMetadataTable, LocalSeedMetadataData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $LocalSeedMetadataTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _keyMeta = const VerificationMeta('key'); + @override + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _versionMeta = const VerificationMeta( + 'version', + ); + @override + late final GeneratedColumn version = GeneratedColumn( + 'version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL CHECK (version >= 0)', + ); + static const VerificationMeta _appliedAtMeta = const VerificationMeta( + 'appliedAt', + ); + @override + late final GeneratedColumn appliedAt = GeneratedColumn( + 'applied_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [key, version, appliedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_seed_metadata'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('key')) { + context.handle( + _keyMeta, + key.isAcceptableOrUnknown(data['key']!, _keyMeta), + ); + } else if (isInserting) { + context.missing(_keyMeta); + } + if (data.containsKey('version')) { + context.handle( + _versionMeta, + version.isAcceptableOrUnknown(data['version']!, _versionMeta), + ); + } else if (isInserting) { + context.missing(_versionMeta); + } + if (data.containsKey('applied_at')) { + context.handle( + _appliedAtMeta, + appliedAt.isAcceptableOrUnknown(data['applied_at']!, _appliedAtMeta), + ); + } else if (isInserting) { + context.missing(_appliedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {key}; + @override + LocalSeedMetadataData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalSeedMetadataData( + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + version: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}version'], + )!, + appliedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}applied_at'], + )!, + ); + } + + @override + $LocalSeedMetadataTable createAlias(String alias) { + return $LocalSeedMetadataTable(attachedDatabase, alias); + } +} + +class LocalSeedMetadataData extends DataClass + implements Insertable { + final String key; + final int version; + final DateTime appliedAt; + const LocalSeedMetadataData({ + required this.key, + required this.version, + required this.appliedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + map['version'] = Variable(version); + map['applied_at'] = Variable(appliedAt); + return map; + } + + LocalSeedMetadataCompanion toCompanion(bool nullToAbsent) { + return LocalSeedMetadataCompanion( + key: Value(key), + version: Value(version), + appliedAt: Value(appliedAt), + ); + } + + factory LocalSeedMetadataData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalSeedMetadataData( + key: serializer.fromJson(json['key']), + version: serializer.fromJson(json['version']), + appliedAt: serializer.fromJson(json['appliedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'version': serializer.toJson(version), + 'appliedAt': serializer.toJson(appliedAt), + }; + } + + LocalSeedMetadataData copyWith({ + String? key, + int? version, + DateTime? appliedAt, + }) => LocalSeedMetadataData( + key: key ?? this.key, + version: version ?? this.version, + appliedAt: appliedAt ?? this.appliedAt, + ); + LocalSeedMetadataData copyWithCompanion(LocalSeedMetadataCompanion data) { + return LocalSeedMetadataData( + key: data.key.present ? data.key.value : this.key, + version: data.version.present ? data.version.value : this.version, + appliedAt: data.appliedAt.present ? data.appliedAt.value : this.appliedAt, + ); + } + + @override + String toString() { + return (StringBuffer('LocalSeedMetadataData(') + ..write('key: $key, ') + ..write('version: $version, ') + ..write('appliedAt: $appliedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, version, appliedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalSeedMetadataData && + other.key == this.key && + other.version == this.version && + other.appliedAt == this.appliedAt); +} + +class LocalSeedMetadataCompanion + extends UpdateCompanion { + final Value key; + final Value version; + final Value appliedAt; + final Value rowid; + const LocalSeedMetadataCompanion({ + this.key = const Value.absent(), + this.version = const Value.absent(), + this.appliedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + LocalSeedMetadataCompanion.insert({ + required String key, + required int version, + required DateTime appliedAt, + this.rowid = const Value.absent(), + }) : key = Value(key), + version = Value(version), + appliedAt = Value(appliedAt); + static Insertable custom({ + Expression? key, + Expression? version, + Expression? appliedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (version != null) 'version': version, + if (appliedAt != null) 'applied_at': appliedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + LocalSeedMetadataCompanion copyWith({ + Value? key, + Value? version, + Value? appliedAt, + Value? rowid, + }) { + return LocalSeedMetadataCompanion( + key: key ?? this.key, + version: version ?? this.version, + appliedAt: appliedAt ?? this.appliedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (version.present) { + map['version'] = Variable(version.value); + } + if (appliedAt.present) { + map['applied_at'] = Variable(appliedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalSeedMetadataCompanion(') + ..write('key: $key, ') + ..write('version: $version, ') + ..write('appliedAt: $appliedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $OnlineAccountSessionsTable extends OnlineAccountSessions with TableInfo<$OnlineAccountSessionsTable, OnlineAccountSession> { @override @@ -17590,6 +18004,21 @@ class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> { type: DriftSqlType.int, requiredDuringInsert: true, ); + static const VerificationMeta _isExampleMeta = const VerificationMeta( + 'isExample', + ); + @override + late final GeneratedColumn isExample = GeneratedColumn( + 'is_example', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_example" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); @override List get $columns => [ id, @@ -17605,6 +18034,7 @@ class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> { remoteRevision, name, defaultRestSeconds, + isExample, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -17730,6 +18160,12 @@ class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> { } else if (isInserting) { context.missing(_defaultRestSecondsMeta); } + if (data.containsKey('is_example')) { + context.handle( + _isExampleMeta, + isExample.isAcceptableOrUnknown(data['is_example']!, _isExampleMeta), + ); + } return context; } @@ -17791,6 +18227,10 @@ class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> { DriftSqlType.int, data['${effectivePrefix}default_rest_seconds'], )!, + isExample: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_example'], + )!, ); } @@ -17814,6 +18254,7 @@ class Program extends DataClass implements Insertable { final String? remoteRevision; final String name; final int defaultRestSeconds; + final bool isExample; const Program({ required this.id, required this.createdAt, @@ -17828,6 +18269,7 @@ class Program extends DataClass implements Insertable { this.remoteRevision, required this.name, required this.defaultRestSeconds, + required this.isExample, }); @override Map toColumns(bool nullToAbsent) { @@ -17853,6 +18295,7 @@ class Program extends DataClass implements Insertable { } map['name'] = Variable(name); map['default_rest_seconds'] = Variable(defaultRestSeconds); + map['is_example'] = Variable(isExample); return map; } @@ -17879,6 +18322,7 @@ class Program extends DataClass implements Insertable { : Value(remoteRevision), name: Value(name), defaultRestSeconds: Value(defaultRestSeconds), + isExample: Value(isExample), ); } @@ -17903,6 +18347,7 @@ class Program extends DataClass implements Insertable { remoteRevision: serializer.fromJson(json['remoteRevision']), name: serializer.fromJson(json['name']), defaultRestSeconds: serializer.fromJson(json['defaultRestSeconds']), + isExample: serializer.fromJson(json['isExample']), ); } @override @@ -17922,6 +18367,7 @@ class Program extends DataClass implements Insertable { 'remoteRevision': serializer.toJson(remoteRevision), 'name': serializer.toJson(name), 'defaultRestSeconds': serializer.toJson(defaultRestSeconds), + 'isExample': serializer.toJson(isExample), }; } @@ -17939,6 +18385,7 @@ class Program extends DataClass implements Insertable { Value remoteRevision = const Value.absent(), String? name, int? defaultRestSeconds, + bool? isExample, }) => Program( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -17957,6 +18404,7 @@ class Program extends DataClass implements Insertable { : this.remoteRevision, name: name ?? this.name, defaultRestSeconds: defaultRestSeconds ?? this.defaultRestSeconds, + isExample: isExample ?? this.isExample, ); Program copyWithCompanion(ProgramsCompanion data) { return Program( @@ -17987,6 +18435,7 @@ class Program extends DataClass implements Insertable { defaultRestSeconds: data.defaultRestSeconds.present ? data.defaultRestSeconds.value : this.defaultRestSeconds, + isExample: data.isExample.present ? data.isExample.value : this.isExample, ); } @@ -18005,7 +18454,8 @@ class Program extends DataClass implements Insertable { ..write('lastSyncedAt: $lastSyncedAt, ') ..write('remoteRevision: $remoteRevision, ') ..write('name: $name, ') - ..write('defaultRestSeconds: $defaultRestSeconds') + ..write('defaultRestSeconds: $defaultRestSeconds, ') + ..write('isExample: $isExample') ..write(')')) .toString(); } @@ -18025,6 +18475,7 @@ class Program extends DataClass implements Insertable { remoteRevision, name, defaultRestSeconds, + isExample, ); @override bool operator ==(Object other) => @@ -18042,7 +18493,8 @@ class Program extends DataClass implements Insertable { other.lastSyncedAt == this.lastSyncedAt && other.remoteRevision == this.remoteRevision && other.name == this.name && - other.defaultRestSeconds == this.defaultRestSeconds); + other.defaultRestSeconds == this.defaultRestSeconds && + other.isExample == this.isExample); } class ProgramsCompanion extends UpdateCompanion { @@ -18059,6 +18511,7 @@ class ProgramsCompanion extends UpdateCompanion { final Value remoteRevision; final Value name; final Value defaultRestSeconds; + final Value isExample; final Value rowid; const ProgramsCompanion({ this.id = const Value.absent(), @@ -18074,6 +18527,7 @@ class ProgramsCompanion extends UpdateCompanion { this.remoteRevision = const Value.absent(), this.name = const Value.absent(), this.defaultRestSeconds = const Value.absent(), + this.isExample = const Value.absent(), this.rowid = const Value.absent(), }); ProgramsCompanion.insert({ @@ -18090,6 +18544,7 @@ class ProgramsCompanion extends UpdateCompanion { this.remoteRevision = const Value.absent(), required String name, required int defaultRestSeconds, + this.isExample = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -18113,6 +18568,7 @@ class ProgramsCompanion extends UpdateCompanion { Expression? remoteRevision, Expression? name, Expression? defaultRestSeconds, + Expression? isExample, Expression? rowid, }) { return RawValuesInsertable({ @@ -18131,6 +18587,7 @@ class ProgramsCompanion extends UpdateCompanion { if (name != null) 'name': name, if (defaultRestSeconds != null) 'default_rest_seconds': defaultRestSeconds, + if (isExample != null) 'is_example': isExample, if (rowid != null) 'rowid': rowid, }); } @@ -18149,6 +18606,7 @@ class ProgramsCompanion extends UpdateCompanion { Value? remoteRevision, Value? name, Value? defaultRestSeconds, + Value? isExample, Value? rowid, }) { return ProgramsCompanion( @@ -18165,6 +18623,7 @@ class ProgramsCompanion extends UpdateCompanion { remoteRevision: remoteRevision ?? this.remoteRevision, name: name ?? this.name, defaultRestSeconds: defaultRestSeconds ?? this.defaultRestSeconds, + isExample: isExample ?? this.isExample, rowid: rowid ?? this.rowid, ); } @@ -18213,6 +18672,9 @@ class ProgramsCompanion extends UpdateCompanion { if (defaultRestSeconds.present) { map['default_rest_seconds'] = Variable(defaultRestSeconds.value); } + if (isExample.present) { + map['is_example'] = Variable(isExample.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -18235,6 +18697,7 @@ class ProgramsCompanion extends UpdateCompanion { ..write('remoteRevision: $remoteRevision, ') ..write('name: $name, ') ..write('defaultRestSeconds: $defaultRestSeconds, ') + ..write('isExample: $isExample, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -29719,6 +30182,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ExercisesTable exercises = $ExercisesTable(this); late final $ExerciseImagesTable exerciseImages = $ExerciseImagesTable(this); late final $ExerciseStepsTable exerciseSteps = $ExerciseStepsTable(this); + late final $LocalSeedMetadataTable localSeedMetadata = + $LocalSeedMetadataTable(this); late final $OnlineAccountSessionsTable onlineAccountSessions = $OnlineAccountSessionsTable(this); late final $PendingShareActionsTable pendingShareActions = @@ -29765,6 +30230,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { exercises, exerciseImages, exerciseSteps, + localSeedMetadata, onlineAccountSessions, pendingShareActions, programs, @@ -29795,6 +30261,7 @@ typedef $$WorkoutTemplatesTableCreateCompanionBuilder = Value remoteRevision, required String name, Value lastStartedAt, + Value isExample, Value rowid, }); typedef $$WorkoutTemplatesTableUpdateCompanionBuilder = @@ -29812,6 +30279,7 @@ typedef $$WorkoutTemplatesTableUpdateCompanionBuilder = Value remoteRevision, Value name, Value lastStartedAt, + Value isExample, Value rowid, }); @@ -29984,6 +30452,11 @@ class $$WorkoutTemplatesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get isExample => $composableBuilder( + column: $table.isExample, + builder: (column) => ColumnFilters(column), + ); + Expression activeWorkoutSessionsRefs( Expression Function($$ActiveWorkoutSessionsTableFilterComposer f) f, ) { @@ -30135,6 +30608,11 @@ class $$WorkoutTemplatesTableOrderingComposer column: $table.lastStartedAt, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get isExample => $composableBuilder( + column: $table.isExample, + builder: (column) => ColumnOrderings(column), + ); } class $$WorkoutTemplatesTableAnnotationComposer @@ -30199,6 +30677,9 @@ class $$WorkoutTemplatesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get isExample => + $composableBuilder(column: $table.isExample, builder: (column) => column); + Expression activeWorkoutSessionsRefs( Expression Function($$ActiveWorkoutSessionsTableAnnotationComposer a) f, ) { @@ -30325,6 +30806,7 @@ class $$WorkoutTemplatesTableTableManager Value remoteRevision = const Value.absent(), Value name = const Value.absent(), Value lastStartedAt = const Value.absent(), + Value isExample = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutTemplatesCompanion( id: id, @@ -30340,6 +30822,7 @@ class $$WorkoutTemplatesTableTableManager remoteRevision: remoteRevision, name: name, lastStartedAt: lastStartedAt, + isExample: isExample, rowid: rowid, ), createCompanionCallback: @@ -30357,6 +30840,7 @@ class $$WorkoutTemplatesTableTableManager Value remoteRevision = const Value.absent(), required String name, Value lastStartedAt = const Value.absent(), + Value isExample = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutTemplatesCompanion.insert( id: id, @@ -30372,6 +30856,7 @@ class $$WorkoutTemplatesTableTableManager remoteRevision: remoteRevision, name: name, lastStartedAt: lastStartedAt, + isExample: isExample, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -37778,6 +38263,8 @@ typedef $$ExercisesTableCreateCompanionBuilder = Value defaultTargetScore, Value defaultTargetScoreTimeMs, Value autoStartNextTimedStep, + Value category, + Value isExample, Value archivedAt, Value rowid, }); @@ -37809,6 +38296,8 @@ typedef $$ExercisesTableUpdateCompanionBuilder = Value defaultTargetScore, Value defaultTargetScoreTimeMs, Value autoStartNextTimedStep, + Value category, + Value isExample, Value archivedAt, Value rowid, }); @@ -38037,6 +38526,16 @@ class $$ExercisesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isExample => $composableBuilder( + column: $table.isExample, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => ColumnFilters(column), @@ -38293,6 +38792,16 @@ class $$ExercisesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get category => $composableBuilder( + column: $table.category, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isExample => $composableBuilder( + column: $table.isExample, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => ColumnOrderings(column), @@ -38460,6 +38969,12 @@ class $$ExercisesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get category => + $composableBuilder(column: $table.category, builder: (column) => column); + + GeneratedColumn get isExample => + $composableBuilder(column: $table.isExample, builder: (column) => column); + GeneratedColumn get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => column, @@ -38647,6 +39162,8 @@ class $$ExercisesTableTableManager Value defaultTargetScore = const Value.absent(), Value defaultTargetScoreTimeMs = const Value.absent(), Value autoStartNextTimedStep = const Value.absent(), + Value category = const Value.absent(), + Value isExample = const Value.absent(), Value archivedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ExercisesCompanion( @@ -38676,6 +39193,8 @@ class $$ExercisesTableTableManager defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, autoStartNextTimedStep: autoStartNextTimedStep, + category: category, + isExample: isExample, archivedAt: archivedAt, rowid: rowid, ), @@ -38707,6 +39226,8 @@ class $$ExercisesTableTableManager Value defaultTargetScore = const Value.absent(), Value defaultTargetScoreTimeMs = const Value.absent(), Value autoStartNextTimedStep = const Value.absent(), + Value category = const Value.absent(), + Value isExample = const Value.absent(), Value archivedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ExercisesCompanion.insert( @@ -38736,6 +39257,8 @@ class $$ExercisesTableTableManager defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, autoStartNextTimedStep: autoStartNextTimedStep, + category: category, + isExample: isExample, archivedAt: archivedAt, rowid: rowid, ), @@ -40161,6 +40684,181 @@ typedef $$ExerciseStepsTableProcessedTableManager = ExerciseStep, PrefetchHooks Function({bool exerciseId}) >; +typedef $$LocalSeedMetadataTableCreateCompanionBuilder = + LocalSeedMetadataCompanion Function({ + required String key, + required int version, + required DateTime appliedAt, + Value rowid, + }); +typedef $$LocalSeedMetadataTableUpdateCompanionBuilder = + LocalSeedMetadataCompanion Function({ + Value key, + Value version, + Value appliedAt, + Value rowid, + }); + +class $$LocalSeedMetadataTableFilterComposer + extends Composer<_$AppDatabase, $LocalSeedMetadataTable> { + $$LocalSeedMetadataTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get version => $composableBuilder( + column: $table.version, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get appliedAt => $composableBuilder( + column: $table.appliedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$LocalSeedMetadataTableOrderingComposer + extends Composer<_$AppDatabase, $LocalSeedMetadataTable> { + $$LocalSeedMetadataTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get version => $composableBuilder( + column: $table.version, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get appliedAt => $composableBuilder( + column: $table.appliedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$LocalSeedMetadataTableAnnotationComposer + extends Composer<_$AppDatabase, $LocalSeedMetadataTable> { + $$LocalSeedMetadataTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get key => + $composableBuilder(column: $table.key, builder: (column) => column); + + GeneratedColumn get version => + $composableBuilder(column: $table.version, builder: (column) => column); + + GeneratedColumn get appliedAt => + $composableBuilder(column: $table.appliedAt, builder: (column) => column); +} + +class $$LocalSeedMetadataTableTableManager + extends + RootTableManager< + _$AppDatabase, + $LocalSeedMetadataTable, + LocalSeedMetadataData, + $$LocalSeedMetadataTableFilterComposer, + $$LocalSeedMetadataTableOrderingComposer, + $$LocalSeedMetadataTableAnnotationComposer, + $$LocalSeedMetadataTableCreateCompanionBuilder, + $$LocalSeedMetadataTableUpdateCompanionBuilder, + ( + LocalSeedMetadataData, + BaseReferences< + _$AppDatabase, + $LocalSeedMetadataTable, + LocalSeedMetadataData + >, + ), + LocalSeedMetadataData, + PrefetchHooks Function() + > { + $$LocalSeedMetadataTableTableManager( + _$AppDatabase db, + $LocalSeedMetadataTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$LocalSeedMetadataTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$LocalSeedMetadataTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$LocalSeedMetadataTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value key = const Value.absent(), + Value version = const Value.absent(), + Value appliedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => LocalSeedMetadataCompanion( + key: key, + version: version, + appliedAt: appliedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String key, + required int version, + required DateTime appliedAt, + Value rowid = const Value.absent(), + }) => LocalSeedMetadataCompanion.insert( + key: key, + version: version, + appliedAt: appliedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$LocalSeedMetadataTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $LocalSeedMetadataTable, + LocalSeedMetadataData, + $$LocalSeedMetadataTableFilterComposer, + $$LocalSeedMetadataTableOrderingComposer, + $$LocalSeedMetadataTableAnnotationComposer, + $$LocalSeedMetadataTableCreateCompanionBuilder, + $$LocalSeedMetadataTableUpdateCompanionBuilder, + ( + LocalSeedMetadataData, + BaseReferences< + _$AppDatabase, + $LocalSeedMetadataTable, + LocalSeedMetadataData + >, + ), + LocalSeedMetadataData, + PrefetchHooks Function() + >; typedef $$OnlineAccountSessionsTableCreateCompanionBuilder = OnlineAccountSessionsCompanion Function({ required String id, @@ -40783,6 +41481,7 @@ typedef $$ProgramsTableCreateCompanionBuilder = Value remoteRevision, required String name, required int defaultRestSeconds, + Value isExample, Value rowid, }); typedef $$ProgramsTableUpdateCompanionBuilder = @@ -40800,6 +41499,7 @@ typedef $$ProgramsTableUpdateCompanionBuilder = Value remoteRevision, Value name, Value defaultRestSeconds, + Value isExample, Value rowid, }); @@ -40930,6 +41630,11 @@ class $$ProgramsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get isExample => $composableBuilder( + column: $table.isExample, + builder: (column) => ColumnFilters(column), + ); + Expression programExercisesRefs( Expression Function($$ProgramExercisesTableFilterComposer f) f, ) { @@ -41055,6 +41760,11 @@ class $$ProgramsTableOrderingComposer column: $table.defaultRestSeconds, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get isExample => $composableBuilder( + column: $table.isExample, + builder: (column) => ColumnOrderings(column), + ); } class $$ProgramsTableAnnotationComposer @@ -41119,6 +41829,9 @@ class $$ProgramsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get isExample => + $composableBuilder(column: $table.isExample, builder: (column) => column); + Expression programExercisesRefs( Expression Function($$ProgramExercisesTableAnnotationComposer a) f, ) { @@ -41216,6 +41929,7 @@ class $$ProgramsTableTableManager Value remoteRevision = const Value.absent(), Value name = const Value.absent(), Value defaultRestSeconds = const Value.absent(), + Value isExample = const Value.absent(), Value rowid = const Value.absent(), }) => ProgramsCompanion( id: id, @@ -41231,6 +41945,7 @@ class $$ProgramsTableTableManager remoteRevision: remoteRevision, name: name, defaultRestSeconds: defaultRestSeconds, + isExample: isExample, rowid: rowid, ), createCompanionCallback: @@ -41248,6 +41963,7 @@ class $$ProgramsTableTableManager Value remoteRevision = const Value.absent(), required String name, required int defaultRestSeconds, + Value isExample = const Value.absent(), Value rowid = const Value.absent(), }) => ProgramsCompanion.insert( id: id, @@ -41263,6 +41979,7 @@ class $$ProgramsTableTableManager remoteRevision: remoteRevision, name: name, defaultRestSeconds: defaultRestSeconds, + isExample: isExample, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -47887,6 +48604,8 @@ class $AppDatabaseManager { $$ExerciseImagesTableTableManager(_db, _db.exerciseImages); $$ExerciseStepsTableTableManager get exerciseSteps => $$ExerciseStepsTableTableManager(_db, _db.exerciseSteps); + $$LocalSeedMetadataTableTableManager get localSeedMetadata => + $$LocalSeedMetadataTableTableManager(_db, _db.localSeedMetadata); $$OnlineAccountSessionsTableTableManager get onlineAccountSessions => $$OnlineAccountSessionsTableTableManager(_db, _db.onlineAccountSessions); $$PendingShareActionsTableTableManager get pendingShareActions => diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index bfe76a3..be0e66e 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -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(exerciseId)], + ) + .getSingleOrNull(); + if (row == null) { + return const _ExerciseStarterMetadata(); + } + return _ExerciseStarterMetadata( + category: _exerciseCategoryFromDb( + row.readNullable('category') ?? 'uncategorized', + ), + isExample: (row.readNullable('is_example') ?? 0) == 1, + ); + } +} + +final class DriftStarterSeedRepository + implements StarterSeedStateRepository, StarterContentRepository { + const DriftStarterSeedRepository(this.database); + + static const _starterSeedKey = 'starter'; + + final db.AppDatabase database; + + @override + Future readAppliedStarterSeedVersion() async { + final row = await database + .customSelect( + 'SELECT version FROM local_seed_metadata WHERE key = ? LIMIT 1', + variables: [Variable(_starterSeedKey)], + ) + .getSingleOrNull(); + return row?.read('version') ?? 0; + } + + @override + Future 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 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 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 _tableCount(String tableName) async { + final row = await database + .customSelect('SELECT COUNT(*) AS count FROM $tableName') + .getSingle(); + return row.read('count'); + } } final class DriftMediaAssetRepository implements MediaAssetRepository { @@ -381,11 +491,11 @@ final class DriftLocalSyncChangeRepository Future _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 _isProgramExample(String id) async { + final row = await database + .customSelect( + 'SELECT is_example FROM programs WHERE id = ? LIMIT 1', + variables: [Variable(id)], + ) + .getSingleOrNull(); + return (row?.readNullable('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 _isWorkoutTemplateExample(String id) async { + final row = await database + .customSelect( + 'SELECT is_example FROM workout_templates WHERE id = ? LIMIT 1', + variables: [Variable(id)], + ) + .getSingleOrNull(); + return (row?.readNullable('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 _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 _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 _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 _operationForMutation({ required db.AppDatabase database, required String tableName, @@ -1994,6 +2179,7 @@ domain.Exercise _exerciseFromRow( db.Exercise row, List imageMediaIds, List 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 exercises, -) { + List 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 programs, - List overrides, -) { + List 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 _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 _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 _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 _decodeImageMediaIdsSnapshot( String? fallbackImageMediaId, ) { if (encoded == null || encoded.trim().isEmpty) { - return [if (fallbackImageMediaId != null) fallbackImageMediaId]; + return [?fallbackImageMediaId]; } final decoded = jsonDecode(encoded); if (decoded is! List) { diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 47ca44e..3fbad7b 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -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 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 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)', diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 5b8c3b0..76eef36 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -98,6 +98,151 @@ void main() { expect(restored!.defaultTargetScore, 0); }); + test('starter seed populates a fresh database once', () async { + final seedRepository = local.DriftStarterSeedRepository(database); + final result = await SeedStarterContentUseCase( + seedStateRepository: seedRepository, + contentRepository: seedRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)), + originDeviceId: 'local-device', + ).run(); + + expect(result.status, StarterSeedStatus.inserted); + expect(await seedRepository.readAppliedStarterSeedVersion(), 1); + + final exercises = await exerciseRepository.listActive(); + final programs = await programRepository.listActive(); + final templates = await templateRepository.listActive(); + expect(exercises, hasLength(21)); + expect(programs, hasLength(1)); + expect(templates, hasLength(1)); + expect(exercises.every((exercise) => exercise.isExample), isTrue); + expect(programs.single.isExample, isTrue); + expect(templates.single.isExample, isTrue); + expect(exercises.map((exercise) => exercise.category).toSet(), { + ExerciseCategory.shoot, + ExerciseCategory.freeThrows, + ExerciseCategory.dribble, + ExerciseCategory.finishing, + ExerciseCategory.conditioning, + ExerciseCategory.defense, + ExerciseCategory.mobility, + }); + + final secondRun = await SeedStarterContentUseCase( + seedStateRepository: seedRepository, + contentRepository: seedRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 21, 9)), + originDeviceId: 'local-device', + ).run(); + expect(secondRun.status, StarterSeedStatus.skippedAlreadyApplied); + expect(await exerciseRepository.listActive(), hasLength(21)); + }); + + test( + 'starter seed marks non-empty database without inserting examples', + () async { + final now = DateTime.utc(2026, 7, 21, 8); + await exerciseRepository.save( + Exercise( + metadata: _metadata('user-exercise-1', now), + name: 'Exercice utilisateur', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + ), + ); + final seedRepository = local.DriftStarterSeedRepository(database); + + final result = await SeedStarterContentUseCase( + seedStateRepository: seedRepository, + contentRepository: seedRepository, + clock: _FakeClock(now), + originDeviceId: 'local-device', + ).run(); + + expect(result.status, StarterSeedStatus.skippedNotEmpty); + expect(await seedRepository.readAppliedStarterSeedVersion(), 1); + expect(await exerciseRepository.listActive(), hasLength(1)); + expect(await programRepository.listActive(), isEmpty); + expect(await templateRepository.listActive(), isEmpty); + }, + ); + + test('starter seed does not reappear after example deletion', () async { + final seedRepository = local.DriftStarterSeedRepository(database); + await SeedStarterContentUseCase( + seedStateRepository: seedRepository, + contentRepository: seedRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)), + originDeviceId: 'local-device', + ).run(); + + final exercise = (await exerciseRepository.listActive()).first; + await exerciseRepository.save( + exercise.copyWith( + metadata: exercise.metadata.markDeleted(DateTime.utc(2026, 7, 21, 9)), + ), + ); + await SeedStarterContentUseCase( + seedStateRepository: seedRepository, + contentRepository: seedRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 21, 10)), + originDeviceId: 'local-device', + ).run(); + + expect(await exerciseRepository.listActive(), hasLength(20)); + expect(await exerciseRepository.findById(exercise.metadata.id), isNotNull); + }); + + test( + 'starter program and workout template snapshot exercise steps', + () async { + final seedRepository = local.DriftStarterSeedRepository(database); + await SeedStarterContentUseCase( + seedStateRepository: seedRepository, + contentRepository: seedRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)), + originDeviceId: 'local-device', + ).run(); + + final program = (await programRepository.listActive()).single; + expect(program.name, 'Fondations basket - 45 min'); + expect(program.exercises, hasLength(8)); + expect( + program.exercises + .map((exercise) => exercise.setsCount) + .reduce((total, count) => total + count), + 22, + ); + final spotShooting = program.exercises.singleWhere( + (exercise) => + exercise.exerciseNameSnapshot == 'Spot shooting 5 positions', + ); + expect(spotShooting.exerciseStepsSnapshot, hasLength(5)); + expect(spotShooting.exerciseStepsSnapshot.map((step) => step.name), [ + 'Coin droit', + 'Aile droite', + 'Face cercle', + 'Aile gauche', + 'Coin gauche', + ]); + + final template = (await templateRepository.listActive()).single; + expect(template.name, 'Séance exemple - Fondations basket'); + expect(template.programs, hasLength(1)); + expect(template.programs.single.sourceProgramId, program.metadata.id); + final snapshot = + jsonDecode(template.programs.single.programSnapshotJson) + as Map; + expect(snapshot['programId'], program.metadata.id); + expect(snapshot['name'], program.name); + expect(snapshot['exercises'], isA>()); + expect(snapshot['exercises'] as List, hasLength(8)); + }, + ); + test( 'step chaining settings round-trip through drift repositories', () async {