diff --git a/.gradle/caches/9.1.0/file-changes/last-build.bin b/.gradle/caches/9.1.0/file-changes/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/caches/9.1.0/file-changes/last-build.bin differ diff --git a/.gradle/caches/9.1.0/kotlin-dsl/scripts/0d8334524d1cf1f34469055495bd9c45-8c66a2c5-da8a-48ab-a011-bc0ab44a32f7/classes/META-INF/buildscript.kotlin_module b/.gradle/caches/9.1.0/kotlin-dsl/scripts/0d8334524d1cf1f34469055495bd9c45-8c66a2c5-da8a-48ab-a011-bc0ab44a32f7/classes/META-INF/buildscript.kotlin_module new file mode 100644 index 0000000..ef915de Binary files /dev/null and b/.gradle/caches/9.1.0/kotlin-dsl/scripts/0d8334524d1cf1f34469055495bd9c45-8c66a2c5-da8a-48ab-a011-bc0ab44a32f7/classes/META-INF/buildscript.kotlin_module differ diff --git a/.gradle/caches/modules-2/modules-2.lock b/.gradle/caches/modules-2/modules-2.lock new file mode 100644 index 0000000..669e586 Binary files /dev/null and b/.gradle/caches/modules-2/modules-2.lock differ diff --git a/.gradle/daemon/9.1.0/registry.bin b/.gradle/daemon/9.1.0/registry.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/daemon/9.1.0/registry.bin differ diff --git a/.gradle/daemon/9.1.0/registry.bin.lock b/.gradle/daemon/9.1.0/registry.bin.lock new file mode 100644 index 0000000..0f11fc2 Binary files /dev/null and b/.gradle/daemon/9.1.0/registry.bin.lock differ diff --git a/android/gradle.properties b/android/gradle.properties index e96108c..3eea106 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -4,3 +4,4 @@ android.useAndroidX=true android.newDsl=false # This builtInKotlin flag was added by the Flutter template android.builtInKotlin=false +org.gradle.java.home=/usr/lib/jvm/java-21-openjdk diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index f5034d7..55dfc70 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -144,6 +144,7 @@ final class AppBootstrap implements AppDependencies { ); final workoutTelemetryUseCases = WorkoutTelemetryUseCases( repository: telemetryRepository, + sessionRepository: activeSessionRepository, clock: clock, ids: ids, ); diff --git a/lib/application/application.dart b/lib/application/application.dart index 7e739bc..4e5520c 100644 --- a/lib/application/application.dart +++ b/lib/application/application.dart @@ -7,6 +7,7 @@ library; export 'ports.dart'; export 'session_notification_use_cases.dart'; export 'starter_content/basket_starter_seed_v1.dart'; +export 'starter_content/basket_starter_seed_v2.dart'; export 'starter_content/starter_content.dart'; export 'use_cases.dart'; export 'watch_companion_use_cases.dart'; diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 1981b6e..3085bff 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -938,6 +938,9 @@ abstract interface class WorkoutHistoryRepository { abstract interface class WorkoutTelemetryRepository { Future saveSample(WorkoutTelemetrySample sample); + Future findWindowState(String sessionId); + Future saveWindowState(ActiveWorkoutTelemetryWindowState state); + Future deleteWindowState(String sessionId); Future> listSamples(String sessionId); Future> listSamplesForScope({ required String sessionId, diff --git a/lib/application/starter_content/basket_starter_seed_v2.dart b/lib/application/starter_content/basket_starter_seed_v2.dart new file mode 100644 index 0000000..c293a8d --- /dev/null +++ b/lib/application/starter_content/basket_starter_seed_v2.dart @@ -0,0 +1,570 @@ +import '../../domain/domain.dart'; +import 'starter_content.dart'; + +const basketStarterExerciseSeedsV2 = [ + StarterExerciseSeed( + id: 'starter-v2-exercise-form-shooting', + name: 'Form shooting proche panier', + description: 'Travail technique près du cercle, priorité au geste propre.', + businessTypes: [BusinessExerciseType.shoot], + tags: ['tir', 'technique'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'paniers', + defaultTargetReps: 25, + defaultTargetScore: 20, + ), + StarterExerciseSeed( + id: 'starter-v2-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.', + businessTypes: [BusinessExerciseType.shoot], + tags: ['tir', 'spots'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'paniers', + defaultTargetReps: 1, + defaultTargetScore: 15, + steps: [ + StarterExerciseStepSeed( + id: 'starter-v2-step-spot-shooting-right-corner', + name: 'Coin droit', + type: ExerciseStepType.reps, + defaultTargetValue: 5, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-spot-shooting-right-wing', + name: 'Aile droite', + type: ExerciseStepType.reps, + defaultTargetValue: 5, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-spot-shooting-top', + name: 'Face cercle', + type: ExerciseStepType.reps, + defaultTargetValue: 5, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-spot-shooting-left-wing', + name: 'Aile gauche', + type: ExerciseStepType.reps, + defaultTargetValue: 5, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-spot-shooting-left-corner', + name: 'Coin gauche', + type: ExerciseStepType.reps, + defaultTargetValue: 5, + ), + ], + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-free-throw-routine', + name: 'Routine lancers francs', + description: 'Série calme avec routine complète avant chaque tir.', + businessTypes: [BusinessExerciseType.freeThrows], + tags: ['lancers francs', 'routine'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'lancers', + defaultTargetReps: 20, + defaultTargetScore: 16, + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-fatigue-free-throws', + name: 'Lancers francs sous fatigue', + description: 'Lancers francs après effort court.', + businessTypes: [ + BusinessExerciseType.freeThrows, + BusinessExerciseType.highIntensity, + ], + tags: ['lancers francs', 'fatigue'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'lancers', + defaultTargetReps: 5, + defaultTargetScore: 8, + steps: [ + StarterExerciseStepSeed( + id: 'starter-v2-step-fatigue-free-throws-baseline-run', + name: 'Course ligne de fond', + type: ExerciseStepType.time, + defaultTargetValue: 15, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-fatigue-free-throws-two-shots', + name: '2 lancers francs', + type: ExerciseStepType.reps, + defaultTargetValue: 2, + ), + ], + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-catch-and-shoot', + name: 'Catch & shoot', + description: 'Recevoir, armer vite, tirer équilibré.', + businessTypes: [BusinessExerciseType.shoot], + tags: ['tir', 'réception'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'paniers', + defaultTargetReps: 20, + defaultTargetScore: 12, + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-pull-up-shooting', + name: 'Tir après dribble', + description: 'Création d\'espace puis tir en rythme.', + businessTypes: [BusinessExerciseType.shoot, BusinessExerciseType.dribble], + tags: ['tir', 'dribble'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'paniers', + defaultTargetReps: 20, + defaultTargetScore: 10, + steps: [ + StarterExerciseStepSeed( + id: 'starter-v2-step-pull-up-right', + name: 'Départ main droite', + type: ExerciseStepType.reps, + defaultTargetValue: 10, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-pull-up-left', + name: 'Départ main gauche', + type: ExerciseStepType.reps, + defaultTargetValue: 10, + ), + ], + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-stationary-dribble-alternating', + name: 'Dribble stationnaire alterné', + description: 'Contrôle de balle enchaîné main droite/main gauche.', + businessTypes: [BusinessExerciseType.dribble], + tags: ['dribble', 'maniement'], + hasTimeMeasure: true, + defaultTargetTimeSeconds: 60, + steps: [ + StarterExerciseStepSeed( + id: 'starter-v2-step-stationary-dribble-right-low', + name: 'Main droite basse', + type: ExerciseStepType.time, + defaultTargetValue: 15, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-stationary-dribble-left-low', + name: 'Main gauche basse', + type: ExerciseStepType.time, + defaultTargetValue: 15, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-stationary-dribble-crossovers', + name: 'Crossovers', + type: ExerciseStepType.time, + defaultTargetValue: 15, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-stationary-dribble-between-legs', + name: 'Entre les jambes', + type: ExerciseStepType.time, + defaultTargetValue: 15, + ), + ], + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-combo-change-of-hand', + name: 'Combo changement de main', + description: 'Enchaîner les changements de main avec régularité.', + businessTypes: [BusinessExerciseType.dribble], + tags: ['dribble', 'coordination'], + hasRepsMeasure: true, + defaultTargetReps: 10, + steps: [ + StarterExerciseStepSeed( + id: 'starter-v2-step-combo-crossover', + name: 'Crossover', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-combo-between-legs', + name: 'Entre les jambes', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-combo-behind-back', + name: 'Dans le dos', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-combo-explosive-start', + name: 'Départ explosif', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + ], + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-cone-slalom', + name: 'Slalom cônes', + description: 'Conduite de balle en vitesse sur parcours.', + businessTypes: [ + BusinessExerciseType.dribble, + BusinessExerciseType.highIntensity, + ], + tags: ['dribble', 'vitesse'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + scoreLabel: 'Temps parcours', + defaultTargetReps: 6, + defaultTargetScoreTimeMs: 12000, + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-mikan-drill', + name: 'Mikan drill', + description: 'Alternance de finitions près du cercle.', + businessTypes: [BusinessExerciseType.finishing], + tags: ['finition', 'proche cercle'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'paniers', + defaultTargetReps: 20, + defaultTargetScore: 16, + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-alternating-layups', + name: 'Layups alternés droite/gauche', + description: 'Alterner les côtés avec course courte.', + businessTypes: [BusinessExerciseType.finishing], + tags: ['finition', 'layup'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'paniers', + defaultTargetReps: 20, + defaultTargetScore: 16, + steps: [ + StarterExerciseStepSeed( + id: 'starter-v2-step-alternating-layup-right', + name: 'Layup côté droit', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-alternating-layup-left', + name: 'Layup côté gauche', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + ], + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-weak-hand-finishes', + name: 'Finitions main faible', + description: 'Layups et finitions contrôlées main faible.', + businessTypes: [BusinessExerciseType.finishing], + tags: ['finition', 'main faible'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'paniers', + defaultTargetReps: 20, + defaultTargetScore: 12, + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-contact-finishing', + name: 'Finition avec contact', + description: 'Absorber le contact puis finir près du cercle.', + businessTypes: [BusinessExerciseType.finishing], + tags: ['finition', 'contact'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'paniers', + defaultTargetReps: 12, + defaultTargetScore: 8, + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-half-court-suicides', + name: 'Suicides demi-terrain', + description: 'Aller-retour progressif sur lignes du terrain.', + businessTypes: [ + BusinessExerciseType.conditioning, + BusinessExerciseType.highIntensity, + ], + tags: ['condition physique', 'intense'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + scoreLabel: 'Temps total', + defaultTargetReps: 4, + defaultTargetScoreTimeMs: 45000, + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-baseline-sprint', + name: 'Sprint ligne de fond', + description: 'Départs explosifs répétés.', + businessTypes: [ + BusinessExerciseType.conditioning, + BusinessExerciseType.highIntensity, + ], + tags: ['vitesse', 'intense'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + scoreLabel: 'Meilleur sprint', + defaultTargetReps: 10, + defaultTargetScoreTimeMs: 5000, + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-line-hops', + name: 'Line hops', + description: 'Petits sauts rapides de part et d\'autre d\'une ligne.', + businessTypes: [ + BusinessExerciseType.conditioning, + BusinessExerciseType.highIntensity, + ], + tags: ['appuis', 'intense'], + hasTimeMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Contacts', + scoreUnit: 'touches', + defaultTargetTimeSeconds: 30, + defaultTargetScore: 50, + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-defensive-slides', + name: 'Slides défensifs', + description: 'Déplacements latéraux bas et contrôlés.', + businessTypes: [BusinessExerciseType.defense], + tags: ['défense', 'appuis'], + hasTimeMeasure: true, + defaultTargetTimeSeconds: 45, + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-closeout-slide', + name: 'Closeout + slide', + description: 'Fermer l\'espace puis contenir latéralement.', + businessTypes: [BusinessExerciseType.defense], + tags: ['défense', 'closeout'], + hasRepsMeasure: true, + defaultTargetReps: 10, + steps: [ + StarterExerciseStepSeed( + id: 'starter-v2-step-closeout-slide-closeout', + name: 'Closeout', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-closeout-slide-right', + name: 'Slide droite', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-closeout-slide-left', + name: 'Slide gauche', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + ], + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-rebound-outlet-pass', + name: 'Rebond + outlet pass', + description: 'Capturer le rebond puis ressortir proprement.', + businessTypes: [BusinessExerciseType.defense], + tags: ['défense', 'rebond'], + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreLabel: 'Réussites', + scoreUnit: 'actions', + defaultTargetReps: 12, + defaultTargetScore: 10, + steps: [ + StarterExerciseStepSeed( + id: 'starter-v2-step-rebound-outlet-rebound', + name: 'Rebond', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-rebound-outlet-pass', + name: 'Outlet pass', + type: ExerciseStepType.reps, + defaultTargetValue: 1, + ), + ], + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-basket-hip-mobility', + name: 'Mobilité hanches basket', + description: 'Préparer hanches, appuis et amplitude.', + businessTypes: [ + BusinessExerciseType.mobility, + BusinessExerciseType.recovery, + ], + tags: ['mobilité', 'échauffement'], + hasTimeMeasure: true, + defaultTargetTimeSeconds: 300, + steps: [ + StarterExerciseStepSeed( + id: 'starter-v2-step-hip-mobility-openers', + name: 'Ouverture hanches', + type: ExerciseStepType.time, + defaultTargetValue: 60, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-hip-mobility-lunges', + name: 'Fentes dynamiques', + type: ExerciseStepType.time, + defaultTargetValue: 60, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-hip-mobility-squat-hold', + name: 'Squat hold', + type: ExerciseStepType.time, + defaultTargetValue: 60, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-hip-mobility-adductors', + name: 'Adducteurs', + type: ExerciseStepType.time, + defaultTargetValue: 60, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-hip-mobility-breathing', + name: 'Respiration basse', + type: ExerciseStepType.time, + defaultTargetValue: 60, + ), + ], + ), + StarterExerciseSeed( + id: 'starter-v2-exercise-ankles-calves', + name: 'Chevilles et mollets', + description: 'Préparer les appuis, réceptions et changements de direction.', + businessTypes: [ + BusinessExerciseType.mobility, + BusinessExerciseType.recovery, + ], + tags: ['mobilité', 'appuis'], + hasTimeMeasure: true, + defaultTargetTimeSeconds: 180, + steps: [ + StarterExerciseStepSeed( + id: 'starter-v2-step-ankles-calves-right-ankle', + name: 'Mobilité cheville droite', + type: ExerciseStepType.time, + defaultTargetValue: 60, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-ankles-calves-left-ankle', + name: 'Mobilité cheville gauche', + type: ExerciseStepType.time, + defaultTargetValue: 60, + ), + StarterExerciseStepSeed( + id: 'starter-v2-step-ankles-calves-dynamic-calves', + name: 'Mollets dynamiques', + type: ExerciseStepType.time, + defaultTargetValue: 60, + ), + ], + ), +]; + +const basketStarterProgramSeedV2 = StarterProgramSeed( + id: 'starter-v2-program-basket-foundations-45-min', + name: 'Fondations basket - 45 min', + defaultRestSeconds: 45, + tags: ['fondations', 'basket'], + exercises: [ + StarterProgramExerciseSeed( + id: 'starter-v2-program-exercise-hip-mobility', + exerciseId: 'starter-v2-exercise-basket-hip-mobility', + setsCount: 1, + enabledMeasures: {WorkoutMeasure.time}, + targetTimeSeconds: 300, + restSecondsOverride: 30, + ), + StarterProgramExerciseSeed( + id: 'starter-v2-program-exercise-stationary-dribble', + exerciseId: 'starter-v2-exercise-stationary-dribble-alternating', + setsCount: 2, + enabledMeasures: {WorkoutMeasure.time}, + targetTimeSeconds: 60, + restSecondsOverride: 30, + ), + StarterProgramExerciseSeed( + id: 'starter-v2-program-exercise-combo-change-of-hand', + exerciseId: 'starter-v2-exercise-combo-change-of-hand', + setsCount: 2, + enabledMeasures: {WorkoutMeasure.reps}, + targetReps: 10, + restSecondsOverride: 45, + ), + StarterProgramExerciseSeed( + id: 'starter-v2-program-exercise-form-shooting', + exerciseId: 'starter-v2-exercise-form-shooting', + setsCount: 3, + enabledMeasures: {WorkoutMeasure.reps, WorkoutMeasure.score}, + targetReps: 25, + targetScore: 20, + restSecondsOverride: 45, + ), + StarterProgramExerciseSeed( + id: 'starter-v2-program-exercise-spot-shooting', + exerciseId: 'starter-v2-exercise-spot-shooting-5-positions', + setsCount: 3, + enabledMeasures: {WorkoutMeasure.reps, WorkoutMeasure.score}, + targetReps: 1, + targetScore: 15, + restSecondsOverride: 60, + ), + StarterProgramExerciseSeed( + id: 'starter-v2-program-exercise-alternating-layups', + exerciseId: 'starter-v2-exercise-alternating-layups', + setsCount: 3, + enabledMeasures: {WorkoutMeasure.reps, WorkoutMeasure.score}, + targetReps: 20, + targetScore: 16, + restSecondsOverride: 45, + ), + StarterProgramExerciseSeed( + id: 'starter-v2-program-exercise-free-throw-routine', + exerciseId: 'starter-v2-exercise-free-throw-routine', + setsCount: 4, + enabledMeasures: {WorkoutMeasure.reps, WorkoutMeasure.score}, + targetReps: 10, + targetScore: 8, + restSecondsOverride: 30, + ), + StarterProgramExerciseSeed( + id: 'starter-v2-program-exercise-defensive-slides', + exerciseId: 'starter-v2-exercise-defensive-slides', + setsCount: 4, + enabledMeasures: {WorkoutMeasure.time}, + targetTimeSeconds: 45, + ), + ], +); + +const basketStarterWorkoutTemplateSeedV2 = StarterWorkoutTemplateSeed( + id: 'starter-v2-workout-template-basket-foundations-example', + name: 'Séance exemple - Fondations basket', + programSnapshotId: 'starter-v2-workout-template-program-basket-foundations', + tags: ['fondations', 'séance'], +); diff --git a/lib/application/starter_content/starter_content.dart b/lib/application/starter_content/starter_content.dart index a2c9393..694b057 100644 --- a/lib/application/starter_content/starter_content.dart +++ b/lib/application/starter_content/starter_content.dart @@ -1,14 +1,15 @@ import '../../domain/domain.dart'; import '../ports.dart'; -const int starterSeedVersion = 1; +const int starterSeedVersion = 2; final class StarterExerciseSeed { const StarterExerciseSeed({ required this.id, required this.name, required this.description, - required this.category, + this.category = ExerciseCategory.uncategorized, + this.businessTypes = const [], this.hasTimeMeasure = false, this.hasRepsMeasure = false, this.hasScoreMeasure = false, @@ -19,6 +20,7 @@ final class StarterExerciseSeed { this.defaultTargetReps, this.defaultTargetScore, this.defaultTargetScoreTimeMs, + this.tags = const [], this.steps = const [], }); @@ -26,6 +28,7 @@ final class StarterExerciseSeed { final String name; final String description; final ExerciseCategory category; + final List businessTypes; final bool hasTimeMeasure; final bool hasRepsMeasure; final bool hasScoreMeasure; @@ -36,6 +39,7 @@ final class StarterExerciseSeed { final int? defaultTargetReps; final double? defaultTargetScore; final int? defaultTargetScoreTimeMs; + final List tags; final List steps; } @@ -59,12 +63,14 @@ final class StarterProgramSeed { required this.name, required this.defaultRestSeconds, required this.exercises, + this.tags = const [], }); final String id; final String name; final int defaultRestSeconds; final List exercises; + final List tags; } final class StarterProgramExerciseSeed { @@ -96,11 +102,13 @@ final class StarterWorkoutTemplateSeed { required this.id, required this.name, required this.programSnapshotId, + this.tags = const [], }); final String id; final String name; final String programSnapshotId; + final List tags; } StarterContent buildStarterContent({ @@ -127,7 +135,9 @@ StarterContent buildStarterContent({ defaultTargetScore: seed.defaultTargetScore, defaultTargetScoreTimeMs: seed.defaultTargetScoreTimeMs, category: seed.category, + businessTypes: seed.businessTypes, isExample: true, + tags: seed.tags, steps: [ for (var index = 0; index < seed.steps.length; index += 1) ExerciseStep( @@ -170,6 +180,7 @@ StarterContent buildStarterContent({ name: programSeed.name, defaultRestSeconds: programSeed.defaultRestSeconds, isExample: true, + tags: programSeed.tags, exercises: programExercises, ); final templateProgram = WorkoutTemplateProgram.snapshotFromProgram( @@ -182,6 +193,7 @@ StarterContent buildStarterContent({ metadata: _metadata(templateSeed.id, now, originDeviceId), name: templateSeed.name, isExample: true, + tags: templateSeed.tags, programs: [templateProgram], ); diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 38fca2b..4fb13f9 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -6,7 +6,7 @@ import 'package:watch_bridge_contract/watch_bridge_contract.dart'; import '../domain/domain.dart'; import 'ports.dart'; -import 'starter_content/basket_starter_seed_v1.dart'; +import 'starter_content/basket_starter_seed_v2.dart'; import 'starter_content/starter_content.dart'; import 'watch_companion_use_cases.dart'; @@ -277,9 +277,9 @@ final class SeedStarterContentUseCase { await contentRepository.insertStarterContent( buildStarterContent( - exerciseSeeds: basketStarterExerciseSeedsV1, - programSeed: basketStarterProgramSeedV1, - templateSeed: basketStarterWorkoutTemplateSeedV1, + exerciseSeeds: basketStarterExerciseSeedsV2, + programSeed: basketStarterProgramSeedV2, + templateSeed: basketStarterWorkoutTemplateSeedV2, now: now, originDeviceId: originDeviceId, ), @@ -5599,6 +5599,7 @@ final class CloseWorkoutSessionUseCase { final class WorkoutTelemetryUseCases { const WorkoutTelemetryUseCases({ required this.repository, + this.sessionRepository, required this.clock, required this.ids, }); @@ -5606,6 +5607,7 @@ final class WorkoutTelemetryUseCases { static const persistedSampleInterval = Duration(seconds: 15); final WorkoutTelemetryRepository repository; + final ActiveSessionRepository? sessionRepository; final Clock clock; final IdGenerator ids; @@ -5622,6 +5624,14 @@ final class WorkoutTelemetryUseCases { return _telemetrySamplesFromHistorySnapshot(history); } + Future> listScopeInstancesForHistory({ + required WorkoutHistory history, + required WorkoutTelemetryAggregateScope scope, + }) async { + final samples = await listSamplesForHistory(history); + return _scopeInstancesFromSamples(samples, scope: scope); + } + Future readGraphSeriesForHistory({ required WorkoutHistory history, required WorkoutTelemetryAggregateScope scope, @@ -5630,6 +5640,56 @@ final class WorkoutTelemetryUseCases { int? setIndex, int? passageIndex, int? stepIndex, + }) async { + final samples = await _listSamplesForHistoryScope( + history: history, + scope: scope, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + ); + return _telemetryGraphSeriesFromSamples( + samples, + scope: scope, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + ); + } + + Future> readScopeMarkersForHistory({ + required WorkoutHistory history, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }) async { + final samples = await _listSamplesForHistoryScope( + history: history, + scope: scope, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + ); + return _scopeMarkersFromSamples(samples, scope: scope); + } + + Future> _listSamplesForHistoryScope({ + required WorkoutHistory history, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, }) async { final sourceSessionId = history.sourceActiveWorkoutSessionId?.trim(); var samples = const []; @@ -5644,53 +5704,128 @@ final class WorkoutTelemetryUseCases { stepIndex: stepIndex, ); } - if (samples.isEmpty) { - samples = _telemetrySamplesFromHistorySnapshot(history) - .where( - (sample) => _matchesTelemetryScope( - sample, - scope: scope, - programIndex: programIndex, - exerciseIndex: exerciseIndex, - setIndex: setIndex, - passageIndex: passageIndex, - stepIndex: stepIndex, - ), - ) - .toList(growable: false); + if (samples.isNotEmpty) { + return samples; } - return _telemetryGraphSeriesFromSamples( - samples, - scope: scope, - programIndex: programIndex, - exerciseIndex: exerciseIndex, - setIndex: setIndex, - passageIndex: passageIndex, - stepIndex: stepIndex, - ); + return _telemetrySamplesFromHistorySnapshot(history) + .where( + (sample) => _matchesTelemetryScope( + sample, + scope: scope, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + ), + ) + .toList(growable: false); } Future> recordTelemetrySample( WatchTelemetrySample sample, ) async { - final domainSample = _telemetrySampleFromWatch(sample); - if (domainSample == null) { + final pendingSample = _telemetryWindowStateFromWatch(sample); + if (pendingSample == null) { return const []; } - final inserted = await repository.saveSample(domainSample); + final activeElapsedMs = await _activeElapsedMillisecondsForSample( + pendingSample.sessionId, + pendingSample.latestCapturedAt, + ); + if (activeElapsedMs == null) { + return const []; + } + final existing = await repository.findWindowState(pendingSample.sessionId); + if (existing != null && + !pendingSample.latestCapturedAt.isAfter(existing.latestCapturedAt)) { + return const []; + } + final nextPending = _telemetryWindowStateWithActiveAnchor( + pendingSample, + existing?.windowStartedActiveMs ?? activeElapsedMs, + ); + if (existing == null) { + await repository.saveWindowState(nextPending); + return const []; + } + if (activeElapsedMs - existing.windowStartedActiveMs < + persistedSampleInterval.inMilliseconds) { + await repository.saveWindowState(nextPending); + return const []; + } + + final consumedSample = existing.toSample( + _telemetrySampleId(existing.sessionId, existing.windowStartedActiveMs), + ); + final inserted = await repository.saveSample(consumedSample); + final nextWindowStartedActiveMs = + activeElapsedMs - existing.windowStartedActiveMs >= + persistedSampleInterval.inMilliseconds * 2 + ? activeElapsedMs + : existing.windowStartedActiveMs + + persistedSampleInterval.inMilliseconds; + await repository.saveWindowState( + _telemetryWindowStateWithActiveAnchor( + pendingSample, + nextWindowStartedActiveMs, + ), + ); if (!inserted) { return const []; } - final samples = await repository.listSamples(domainSample.sessionId); + final samples = await repository.listSamples(consumedSample.sessionId); final aggregates = _telemetryAggregatesFromSamples(samples); await repository.replaceAggregatesForSession( - sessionId: domainSample.sessionId, + sessionId: consumedSample.sessionId, aggregates: aggregates, ); return aggregates; } - WorkoutTelemetrySample? _telemetrySampleFromWatch( + Future _activeElapsedMillisecondsForSample( + String sessionId, + DateTime capturedAt, + ) async { + final repository = sessionRepository; + if (repository == null) { + return capturedAt.millisecondsSinceEpoch; + } + final session = await repository.findById(sessionId); + if (session == null || + session.status == ActiveWorkoutStatus.completed || + session.status == ActiveWorkoutStatus.abandoned) { + return null; + } + if (session.status == ActiveWorkoutStatus.paused) { + return null; + } + return session.elapsedActiveMillisecondsAt(capturedAt); + } + + ActiveWorkoutTelemetryWindowState _telemetryWindowStateWithActiveAnchor( + ActiveWorkoutTelemetryWindowState state, + int windowStartedActiveMs, + ) { + return ActiveWorkoutTelemetryWindowState( + sessionId: state.sessionId, + windowStartedActiveMs: windowStartedActiveMs, + latestCapturedAt: state.latestCapturedAt, + programIndex: state.programIndex, + exerciseIndex: state.exerciseIndex, + setIndex: state.setIndex, + passageIndex: state.passageIndex, + stepIndex: state.stepIndex, + programSnapshotId: state.programSnapshotId, + exerciseSnapshotId: state.exerciseSnapshotId, + stepSnapshotId: state.stepSnapshotId, + heartRateBpm: state.heartRateBpm, + distanceMeters: state.distanceMeters, + caloriesKcal: state.caloriesKcal, + ); + } + + ActiveWorkoutTelemetryWindowState? _telemetryWindowStateFromWatch( WatchTelemetrySample sample, ) { final sessionId = sample.sessionId.trim(); @@ -5709,10 +5844,10 @@ final class WorkoutTelemetryUseCases { isUtc: true, ) : clock.now(); - return WorkoutTelemetrySample( - id: _telemetrySampleId(sample, sessionId, capturedAt), + return ActiveWorkoutTelemetryWindowState( sessionId: sessionId, - capturedAt: capturedAt, + windowStartedActiveMs: 0, + latestCapturedAt: capturedAt, programIndex: sample.programIndex, exerciseIndex: sample.exerciseIndex, setIndex: sample.setIndex, @@ -5727,16 +5862,8 @@ final class WorkoutTelemetryUseCases { ); } - String _telemetrySampleId( - WatchTelemetrySample sample, - String sessionId, - DateTime capturedAt, - ) { - final bucketMs = - capturedAt.millisecondsSinceEpoch ~/ - persistedSampleInterval.inMilliseconds * - persistedSampleInterval.inMilliseconds; - return 'telemetry:$sessionId:$bucketMs'; + String _telemetrySampleId(String sessionId, int windowStartedActiveMs) { + return 'telemetry:$sessionId:$windowStartedActiveMs'; } } @@ -5828,6 +5955,168 @@ WorkoutTelemetryGraphSeries _telemetryGraphSeriesFromSamples( ); } +List _scopeInstancesFromSamples( + List samples, { + required WorkoutTelemetryAggregateScope scope, +}) { + final ordered = samples.toList() + ..sort((left, right) => left.capturedAt.compareTo(right.capturedAt)); + final firstCapturedAtByKey = <_TelemetryScopeKey, DateTime>{}; + for (final sample in ordered) { + final key = _scopeKeyForSample(sample, scope); + if (key == null) { + continue; + } + firstCapturedAtByKey.putIfAbsent(key, () => sample.capturedAt); + } + var ordinal = 0; + return [ + for (final entry in firstCapturedAtByKey.entries) + ScopeInstanceDescriptor( + scope: scope, + programIndex: entry.key.programIndex, + exerciseIndex: entry.key.exerciseIndex, + setIndex: entry.key.setIndex, + passageIndex: entry.key.passageIndex, + stepIndex: entry.key.stepIndex, + ordinal: ordinal += 1, + firstCapturedAt: entry.value, + ), + ]; +} + +List _scopeMarkersFromSamples( + List samples, { + required WorkoutTelemetryAggregateScope scope, +}) { + final ordered = samples.toList() + ..sort((left, right) => left.capturedAt.compareTo(right.capturedAt)); + if (ordered.isEmpty || scope == WorkoutTelemetryAggregateScope.step) { + return const []; + } + + final firstCapturedAt = ordered.first.capturedAt; + final groups = <_TelemetryScopeKey, _ScopeMarkerGroup>{}; + for (final sample in ordered) { + final key = _markerChildKeyForSample(sample, parentScope: scope); + if (key == null) { + continue; + } + groups.putIfAbsent(key, () => _ScopeMarkerGroup()).add(sample); + } + + var ordinal = 0; + final output = []; + for (final group in groups.values) { + ordinal += 1; + final label = _markerChildLabel(scope, ordinal); + output.add( + ScopeMarker( + elapsedMs: group.firstCapturedAt! + .difference(firstCapturedAt) + .inMilliseconds + .clamp(0, double.infinity) + .toInt(), + label: 'Déb. $label', + boundary: ScopeMarkerBoundary.start, + ), + ); + output.add( + ScopeMarker( + elapsedMs: group.lastCapturedAt! + .difference(firstCapturedAt) + .inMilliseconds + .clamp(0, double.infinity) + .toInt(), + label: 'Fin $label', + boundary: ScopeMarkerBoundary.end, + ), + ); + } + return output; +} + +_TelemetryScopeKey? _scopeKeyForSample( + WorkoutTelemetrySample sample, + WorkoutTelemetryAggregateScope scope, +) { + return switch (scope) { + WorkoutTelemetryAggregateScope.session => _TelemetryScopeKey( + sessionId: sample.sessionId, + scope: WorkoutTelemetryAggregateScope.session, + ), + WorkoutTelemetryAggregateScope.exercise => + sample.programIndex != null && sample.exerciseIndex != null + ? _TelemetryScopeKey( + sessionId: sample.sessionId, + scope: WorkoutTelemetryAggregateScope.exercise, + programIndex: sample.programIndex, + exerciseIndex: sample.exerciseIndex, + ) + : null, + WorkoutTelemetryAggregateScope.set => + sample.programIndex != null && + sample.exerciseIndex != null && + sample.setIndex != null + ? _TelemetryScopeKey( + sessionId: sample.sessionId, + scope: WorkoutTelemetryAggregateScope.set, + programIndex: sample.programIndex, + exerciseIndex: sample.exerciseIndex, + setIndex: sample.setIndex, + ) + : null, + WorkoutTelemetryAggregateScope.step => + sample.programIndex != null && + sample.exerciseIndex != null && + sample.setIndex != null && + sample.stepIndex != null + ? _TelemetryScopeKey( + sessionId: sample.sessionId, + scope: WorkoutTelemetryAggregateScope.step, + programIndex: sample.programIndex, + exerciseIndex: sample.exerciseIndex, + setIndex: sample.setIndex, + passageIndex: sample.passageIndex, + stepIndex: sample.stepIndex, + ) + : null, + }; +} + +_TelemetryScopeKey? _markerChildKeyForSample( + WorkoutTelemetrySample sample, { + required WorkoutTelemetryAggregateScope parentScope, +}) { + return switch (parentScope) { + WorkoutTelemetryAggregateScope.session => _scopeKeyForSample( + sample, + WorkoutTelemetryAggregateScope.exercise, + ), + WorkoutTelemetryAggregateScope.exercise => _scopeKeyForSample( + sample, + WorkoutTelemetryAggregateScope.set, + ), + WorkoutTelemetryAggregateScope.set => _scopeKeyForSample( + sample, + WorkoutTelemetryAggregateScope.step, + ), + WorkoutTelemetryAggregateScope.step => null, + }; +} + +String _markerChildLabel( + WorkoutTelemetryAggregateScope parentScope, + int ordinal, +) { + return switch (parentScope) { + WorkoutTelemetryAggregateScope.session => 'ex. $ordinal', + WorkoutTelemetryAggregateScope.exercise => 'série $ordinal', + WorkoutTelemetryAggregateScope.set => 'ét. $ordinal', + WorkoutTelemetryAggregateScope.step => '', + }; +} + Map _workoutTelemetrySampleSnapshotJson( WorkoutTelemetrySample sample, ) { @@ -6028,6 +6317,19 @@ final class _TelemetryScopeKey { ); } +final class _ScopeMarkerGroup { + DateTime? firstCapturedAt; + DateTime? lastCapturedAt; + + void add(WorkoutTelemetrySample sample) { + final capturedAt = sample.capturedAt; + firstCapturedAt ??= capturedAt; + if (lastCapturedAt == null || capturedAt.isAfter(lastCapturedAt!)) { + lastCapturedAt = capturedAt; + } + } +} + final class _TelemetryAggregateBuilder { _TelemetryAggregateBuilder(this.key); @@ -6099,6 +6401,7 @@ final class ActiveWorkoutSensorState { this.averageHeartRateBpm, this.maxHeartRateBpm, this.latestDistanceMeters, + required this.latestDistanceAvailable, this.latestCaloriesKcal, required this.estimatedCaloriesKcal, }); @@ -6112,6 +6415,7 @@ final class ActiveWorkoutSensorState { final double? averageHeartRateBpm; final int? maxHeartRateBpm; final double? latestDistanceMeters; + final bool latestDistanceAvailable; final double? latestCaloriesKcal; final double estimatedCaloriesKcal; } @@ -6191,6 +6495,7 @@ final class _ActiveWorkoutSensorAccumulator { int? _minHeartRateBpm; int? _maxHeartRateBpm; double? _latestDistanceMeters; + var _latestDistanceAvailable = false; double? _latestCaloriesKcal; ActiveWorkoutSensorState get snapshot { @@ -6207,6 +6512,7 @@ final class _ActiveWorkoutSensorAccumulator { averageHeartRateBpm: averageHeartRateBpm, maxHeartRateBpm: _maxHeartRateBpm, latestDistanceMeters: _latestDistanceMeters, + latestDistanceAvailable: _latestDistanceAvailable, latestCaloriesKcal: _latestCaloriesKcal, estimatedCaloriesKcal: _estimatedCaloriesKcal( averageHeartRateBpm: averageHeartRateBpm ?? 0, @@ -6253,6 +6559,7 @@ final class _ActiveWorkoutSensorAccumulator { distanceMeters >= _latestDistanceMeters!)) { _latestDistanceMeters = distanceMeters; } + _latestDistanceAvailable = distanceMeters != null; if (caloriesKcal != null && (_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) { _latestCaloriesKcal = caloriesKcal; diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index f942364..c8d94b5 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -1681,6 +1681,76 @@ final class WorkoutTelemetrySample { final double? caloriesKcal; } +final class ActiveWorkoutTelemetryWindowState { + ActiveWorkoutTelemetryWindowState({ + required String sessionId, + required this.windowStartedActiveMs, + required this.latestCapturedAt, + this.programIndex, + this.exerciseIndex, + this.setIndex, + this.passageIndex, + this.stepIndex, + this.programSnapshotId, + this.exerciseSnapshotId, + this.stepSnapshotId, + this.heartRateBpm, + this.distanceMeters, + this.caloriesKcal, + }) : sessionId = _nonBlank(sessionId, 'Telemetry window session id') { + _requireNonNegative(windowStartedActiveMs, 'Telemetry window active ms'); + _requireNullableNonNegative(programIndex, 'Program index'); + _requireNullableNonNegative(exerciseIndex, 'Exercise index'); + _requireNullableNonNegative(setIndex, 'Set index'); + _requireNullableNonNegative(passageIndex, 'Passage index'); + _requireNullableNonNegative(stepIndex, 'Step index'); + _requireNullablePositive(heartRateBpm, 'Heart rate bpm'); + _requireNullableNonNegativeDouble(distanceMeters, 'Distance meters'); + _requireNullableNonNegativeDouble(caloriesKcal, 'Calories kcal'); + if (heartRateBpm == null && + distanceMeters == null && + caloriesKcal == null) { + throw const DomainException( + 'Telemetry window state must contain at least one metric.', + ); + } + } + + final String sessionId; + final int windowStartedActiveMs; + final DateTime latestCapturedAt; + final int? programIndex; + final int? exerciseIndex; + final int? setIndex; + final int? passageIndex; + final int? stepIndex; + final String? programSnapshotId; + final String? exerciseSnapshotId; + final String? stepSnapshotId; + final int? heartRateBpm; + final double? distanceMeters; + final double? caloriesKcal; + + WorkoutTelemetrySample toSample(String id) { + return WorkoutTelemetrySample( + id: id, + sessionId: sessionId, + capturedAt: latestCapturedAt, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + programSnapshotId: programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId, + stepSnapshotId: stepSnapshotId, + heartRateBpm: heartRateBpm, + distanceMeters: distanceMeters, + caloriesKcal: caloriesKcal, + ); + } +} + final class WorkoutTelemetryAggregate { WorkoutTelemetryAggregate({ required String sessionId, @@ -1793,6 +1863,51 @@ final class WorkoutTelemetryGraphSeries { final int? maxHeartRateBpm; } +final class ScopeInstanceDescriptor { + ScopeInstanceDescriptor({ + required this.scope, + this.programIndex, + this.exerciseIndex, + this.setIndex, + this.passageIndex, + this.stepIndex, + required this.ordinal, + required DateTime firstCapturedAt, + }) : firstCapturedAt = firstCapturedAt.toUtc() { + _requireNullableNonNegative(programIndex, 'Program index'); + _requireNullableNonNegative(exerciseIndex, 'Exercise index'); + _requireNullableNonNegative(setIndex, 'Set index'); + _requireNullableNonNegative(passageIndex, 'Passage index'); + _requireNullableNonNegative(stepIndex, 'Step index'); + _requirePositive(ordinal, 'Scope instance ordinal'); + } + + final WorkoutTelemetryAggregateScope scope; + final int? programIndex; + final int? exerciseIndex; + final int? setIndex; + final int? passageIndex; + final int? stepIndex; + final int ordinal; + final DateTime firstCapturedAt; +} + +enum ScopeMarkerBoundary { start, end } + +final class ScopeMarker { + ScopeMarker({ + required this.elapsedMs, + required String label, + required this.boundary, + }) : label = _nonBlank(label, 'Scope marker label') { + _requireNonNegative(elapsedMs, 'Scope marker elapsed ms'); + } + + final int elapsedMs; + final String label; + final ScopeMarkerBoundary boundary; +} + final class WorkoutHistorySetResult { WorkoutHistorySetResult({ required this.metadata, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 1d7a52c..e54b187 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -9,6 +9,7 @@ part 'app_database.g.dart'; tables: [ ActiveExerciseStepProgressStates, ActiveExerciseStepResults, + ActiveWorkoutTelemetryWindowStates, ActiveRestStates, ActiveManualScoreStates, ActiveScoreStopwatchStates, @@ -51,7 +52,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 25; + int get schemaVersion => 26; @override MigrationStrategy get migration { @@ -141,6 +142,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 25) { await _migrateToSchema25(); } + if (from < 26) { + await _migrateToSchema26(migrator); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -269,6 +273,11 @@ final class AppDatabase extends _$AppDatabase { 'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_samples_session ' 'ON workout_telemetry_samples (session_id, captured_at)', ); + await customStatement( + 'CREATE INDEX IF NOT EXISTS ' + 'idx_active_workout_telemetry_window_states_session ' + 'ON active_workout_telemetry_window_states (session_id)', + ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_aggregates_session ' 'ON workout_telemetry_aggregates (session_id, scope)', @@ -472,6 +481,10 @@ extension on AppDatabase { await migrator.createTable(pendingShareActions); } + Future _migrateToSchema26(Migrator migrator) async { + await migrator.createTable(activeWorkoutTelemetryWindowStates); + } + Future _migrateToSchema13() async { await customStatement('PRAGMA foreign_keys = OFF'); await customStatement(''' diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 074f98d..1f87fb9 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -5640,6 +5640,917 @@ class ActiveExerciseStepResultsCompanion } } +class $ActiveWorkoutTelemetryWindowStatesTable + extends ActiveWorkoutTelemetryWindowStates + with + TableInfo< + $ActiveWorkoutTelemetryWindowStatesTable, + ActiveWorkoutTelemetryWindowState + > { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ActiveWorkoutTelemetryWindowStatesTable( + this.attachedDatabase, [ + this._alias, + ]); + static const VerificationMeta _sessionIdMeta = const VerificationMeta( + 'sessionId', + ); + @override + late final GeneratedColumn sessionId = GeneratedColumn( + 'session_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES active_workout_sessions (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _windowStartedActiveMsMeta = + const VerificationMeta('windowStartedActiveMs'); + @override + late final GeneratedColumn windowStartedActiveMs = GeneratedColumn( + 'window_started_active_ms', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _latestCapturedAtMeta = const VerificationMeta( + 'latestCapturedAt', + ); + @override + late final GeneratedColumn latestCapturedAt = + GeneratedColumn( + 'latest_captured_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _programIndexMeta = const VerificationMeta( + 'programIndex', + ); + @override + late final GeneratedColumn programIndex = GeneratedColumn( + 'program_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _exerciseIndexMeta = const VerificationMeta( + 'exerciseIndex', + ); + @override + late final GeneratedColumn exerciseIndex = GeneratedColumn( + 'exercise_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _setIndexMeta = const VerificationMeta( + 'setIndex', + ); + @override + late final GeneratedColumn setIndex = GeneratedColumn( + 'set_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _passageIndexMeta = const VerificationMeta( + 'passageIndex', + ); + @override + late final GeneratedColumn passageIndex = GeneratedColumn( + 'passage_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _stepIndexMeta = const VerificationMeta( + 'stepIndex', + ); + @override + late final GeneratedColumn stepIndex = GeneratedColumn( + 'step_index', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _programSnapshotIdMeta = const VerificationMeta( + 'programSnapshotId', + ); + @override + late final GeneratedColumn programSnapshotId = + GeneratedColumn( + 'program_snapshot_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _exerciseSnapshotIdMeta = + const VerificationMeta('exerciseSnapshotId'); + @override + late final GeneratedColumn exerciseSnapshotId = + GeneratedColumn( + 'exercise_snapshot_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _stepSnapshotIdMeta = const VerificationMeta( + 'stepSnapshotId', + ); + @override + late final GeneratedColumn stepSnapshotId = GeneratedColumn( + 'step_snapshot_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _heartRateBpmMeta = const VerificationMeta( + 'heartRateBpm', + ); + @override + late final GeneratedColumn heartRateBpm = GeneratedColumn( + 'heart_rate_bpm', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _distanceMetersMeta = const VerificationMeta( + 'distanceMeters', + ); + @override + late final GeneratedColumn distanceMeters = GeneratedColumn( + 'distance_meters', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _caloriesKcalMeta = const VerificationMeta( + 'caloriesKcal', + ); + @override + late final GeneratedColumn caloriesKcal = GeneratedColumn( + 'calories_kcal', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + sessionId, + windowStartedActiveMs, + latestCapturedAt, + programIndex, + exerciseIndex, + setIndex, + passageIndex, + stepIndex, + programSnapshotId, + exerciseSnapshotId, + stepSnapshotId, + heartRateBpm, + distanceMeters, + caloriesKcal, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'active_workout_telemetry_window_states'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('session_id')) { + context.handle( + _sessionIdMeta, + sessionId.isAcceptableOrUnknown(data['session_id']!, _sessionIdMeta), + ); + } else if (isInserting) { + context.missing(_sessionIdMeta); + } + if (data.containsKey('window_started_active_ms')) { + context.handle( + _windowStartedActiveMsMeta, + windowStartedActiveMs.isAcceptableOrUnknown( + data['window_started_active_ms']!, + _windowStartedActiveMsMeta, + ), + ); + } else if (isInserting) { + context.missing(_windowStartedActiveMsMeta); + } + if (data.containsKey('latest_captured_at')) { + context.handle( + _latestCapturedAtMeta, + latestCapturedAt.isAcceptableOrUnknown( + data['latest_captured_at']!, + _latestCapturedAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_latestCapturedAtMeta); + } + if (data.containsKey('program_index')) { + context.handle( + _programIndexMeta, + programIndex.isAcceptableOrUnknown( + data['program_index']!, + _programIndexMeta, + ), + ); + } + if (data.containsKey('exercise_index')) { + context.handle( + _exerciseIndexMeta, + exerciseIndex.isAcceptableOrUnknown( + data['exercise_index']!, + _exerciseIndexMeta, + ), + ); + } + if (data.containsKey('set_index')) { + context.handle( + _setIndexMeta, + setIndex.isAcceptableOrUnknown(data['set_index']!, _setIndexMeta), + ); + } + if (data.containsKey('passage_index')) { + context.handle( + _passageIndexMeta, + passageIndex.isAcceptableOrUnknown( + data['passage_index']!, + _passageIndexMeta, + ), + ); + } + if (data.containsKey('step_index')) { + context.handle( + _stepIndexMeta, + stepIndex.isAcceptableOrUnknown(data['step_index']!, _stepIndexMeta), + ); + } + if (data.containsKey('program_snapshot_id')) { + context.handle( + _programSnapshotIdMeta, + programSnapshotId.isAcceptableOrUnknown( + data['program_snapshot_id']!, + _programSnapshotIdMeta, + ), + ); + } + if (data.containsKey('exercise_snapshot_id')) { + context.handle( + _exerciseSnapshotIdMeta, + exerciseSnapshotId.isAcceptableOrUnknown( + data['exercise_snapshot_id']!, + _exerciseSnapshotIdMeta, + ), + ); + } + if (data.containsKey('step_snapshot_id')) { + context.handle( + _stepSnapshotIdMeta, + stepSnapshotId.isAcceptableOrUnknown( + data['step_snapshot_id']!, + _stepSnapshotIdMeta, + ), + ); + } + if (data.containsKey('heart_rate_bpm')) { + context.handle( + _heartRateBpmMeta, + heartRateBpm.isAcceptableOrUnknown( + data['heart_rate_bpm']!, + _heartRateBpmMeta, + ), + ); + } + if (data.containsKey('distance_meters')) { + context.handle( + _distanceMetersMeta, + distanceMeters.isAcceptableOrUnknown( + data['distance_meters']!, + _distanceMetersMeta, + ), + ); + } + if (data.containsKey('calories_kcal')) { + context.handle( + _caloriesKcalMeta, + caloriesKcal.isAcceptableOrUnknown( + data['calories_kcal']!, + _caloriesKcalMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {sessionId}; + @override + ActiveWorkoutTelemetryWindowState map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ActiveWorkoutTelemetryWindowState( + sessionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}session_id'], + )!, + windowStartedActiveMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}window_started_active_ms'], + )!, + latestCapturedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}latest_captured_at'], + )!, + programIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}program_index'], + ), + exerciseIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}exercise_index'], + ), + setIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}set_index'], + ), + passageIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}passage_index'], + ), + stepIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}step_index'], + ), + programSnapshotId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}program_snapshot_id'], + ), + exerciseSnapshotId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}exercise_snapshot_id'], + ), + stepSnapshotId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}step_snapshot_id'], + ), + heartRateBpm: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}heart_rate_bpm'], + ), + distanceMeters: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}distance_meters'], + ), + caloriesKcal: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}calories_kcal'], + ), + ); + } + + @override + $ActiveWorkoutTelemetryWindowStatesTable createAlias(String alias) { + return $ActiveWorkoutTelemetryWindowStatesTable(attachedDatabase, alias); + } +} + +class ActiveWorkoutTelemetryWindowState extends DataClass + implements Insertable { + final String sessionId; + final int windowStartedActiveMs; + final DateTime latestCapturedAt; + final int? programIndex; + final int? exerciseIndex; + final int? setIndex; + final int? passageIndex; + final int? stepIndex; + final String? programSnapshotId; + final String? exerciseSnapshotId; + final String? stepSnapshotId; + final int? heartRateBpm; + final double? distanceMeters; + final double? caloriesKcal; + const ActiveWorkoutTelemetryWindowState({ + required this.sessionId, + required this.windowStartedActiveMs, + required this.latestCapturedAt, + this.programIndex, + this.exerciseIndex, + this.setIndex, + this.passageIndex, + this.stepIndex, + this.programSnapshotId, + this.exerciseSnapshotId, + this.stepSnapshotId, + this.heartRateBpm, + this.distanceMeters, + this.caloriesKcal, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['session_id'] = Variable(sessionId); + map['window_started_active_ms'] = Variable(windowStartedActiveMs); + map['latest_captured_at'] = Variable(latestCapturedAt); + if (!nullToAbsent || programIndex != null) { + map['program_index'] = Variable(programIndex); + } + if (!nullToAbsent || exerciseIndex != null) { + map['exercise_index'] = Variable(exerciseIndex); + } + if (!nullToAbsent || setIndex != null) { + map['set_index'] = Variable(setIndex); + } + if (!nullToAbsent || passageIndex != null) { + map['passage_index'] = Variable(passageIndex); + } + if (!nullToAbsent || stepIndex != null) { + map['step_index'] = Variable(stepIndex); + } + if (!nullToAbsent || programSnapshotId != null) { + map['program_snapshot_id'] = Variable(programSnapshotId); + } + if (!nullToAbsent || exerciseSnapshotId != null) { + map['exercise_snapshot_id'] = Variable(exerciseSnapshotId); + } + if (!nullToAbsent || stepSnapshotId != null) { + map['step_snapshot_id'] = Variable(stepSnapshotId); + } + if (!nullToAbsent || heartRateBpm != null) { + map['heart_rate_bpm'] = Variable(heartRateBpm); + } + if (!nullToAbsent || distanceMeters != null) { + map['distance_meters'] = Variable(distanceMeters); + } + if (!nullToAbsent || caloriesKcal != null) { + map['calories_kcal'] = Variable(caloriesKcal); + } + return map; + } + + ActiveWorkoutTelemetryWindowStatesCompanion toCompanion(bool nullToAbsent) { + return ActiveWorkoutTelemetryWindowStatesCompanion( + sessionId: Value(sessionId), + windowStartedActiveMs: Value(windowStartedActiveMs), + latestCapturedAt: Value(latestCapturedAt), + programIndex: programIndex == null && nullToAbsent + ? const Value.absent() + : Value(programIndex), + exerciseIndex: exerciseIndex == null && nullToAbsent + ? const Value.absent() + : Value(exerciseIndex), + setIndex: setIndex == null && nullToAbsent + ? const Value.absent() + : Value(setIndex), + passageIndex: passageIndex == null && nullToAbsent + ? const Value.absent() + : Value(passageIndex), + stepIndex: stepIndex == null && nullToAbsent + ? const Value.absent() + : Value(stepIndex), + programSnapshotId: programSnapshotId == null && nullToAbsent + ? const Value.absent() + : Value(programSnapshotId), + exerciseSnapshotId: exerciseSnapshotId == null && nullToAbsent + ? const Value.absent() + : Value(exerciseSnapshotId), + stepSnapshotId: stepSnapshotId == null && nullToAbsent + ? const Value.absent() + : Value(stepSnapshotId), + heartRateBpm: heartRateBpm == null && nullToAbsent + ? const Value.absent() + : Value(heartRateBpm), + distanceMeters: distanceMeters == null && nullToAbsent + ? const Value.absent() + : Value(distanceMeters), + caloriesKcal: caloriesKcal == null && nullToAbsent + ? const Value.absent() + : Value(caloriesKcal), + ); + } + + factory ActiveWorkoutTelemetryWindowState.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ActiveWorkoutTelemetryWindowState( + sessionId: serializer.fromJson(json['sessionId']), + windowStartedActiveMs: serializer.fromJson( + json['windowStartedActiveMs'], + ), + latestCapturedAt: serializer.fromJson(json['latestCapturedAt']), + programIndex: serializer.fromJson(json['programIndex']), + exerciseIndex: serializer.fromJson(json['exerciseIndex']), + setIndex: serializer.fromJson(json['setIndex']), + passageIndex: serializer.fromJson(json['passageIndex']), + stepIndex: serializer.fromJson(json['stepIndex']), + programSnapshotId: serializer.fromJson( + json['programSnapshotId'], + ), + exerciseSnapshotId: serializer.fromJson( + json['exerciseSnapshotId'], + ), + stepSnapshotId: serializer.fromJson(json['stepSnapshotId']), + heartRateBpm: serializer.fromJson(json['heartRateBpm']), + distanceMeters: serializer.fromJson(json['distanceMeters']), + caloriesKcal: serializer.fromJson(json['caloriesKcal']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'sessionId': serializer.toJson(sessionId), + 'windowStartedActiveMs': serializer.toJson(windowStartedActiveMs), + 'latestCapturedAt': serializer.toJson(latestCapturedAt), + 'programIndex': serializer.toJson(programIndex), + 'exerciseIndex': serializer.toJson(exerciseIndex), + 'setIndex': serializer.toJson(setIndex), + 'passageIndex': serializer.toJson(passageIndex), + 'stepIndex': serializer.toJson(stepIndex), + 'programSnapshotId': serializer.toJson(programSnapshotId), + 'exerciseSnapshotId': serializer.toJson(exerciseSnapshotId), + 'stepSnapshotId': serializer.toJson(stepSnapshotId), + 'heartRateBpm': serializer.toJson(heartRateBpm), + 'distanceMeters': serializer.toJson(distanceMeters), + 'caloriesKcal': serializer.toJson(caloriesKcal), + }; + } + + ActiveWorkoutTelemetryWindowState copyWith({ + String? sessionId, + int? windowStartedActiveMs, + DateTime? latestCapturedAt, + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value passageIndex = const Value.absent(), + Value stepIndex = const Value.absent(), + Value programSnapshotId = const Value.absent(), + Value exerciseSnapshotId = const Value.absent(), + Value stepSnapshotId = const Value.absent(), + Value heartRateBpm = const Value.absent(), + Value distanceMeters = const Value.absent(), + Value caloriesKcal = const Value.absent(), + }) => ActiveWorkoutTelemetryWindowState( + sessionId: sessionId ?? this.sessionId, + windowStartedActiveMs: windowStartedActiveMs ?? this.windowStartedActiveMs, + latestCapturedAt: latestCapturedAt ?? this.latestCapturedAt, + programIndex: programIndex.present ? programIndex.value : this.programIndex, + exerciseIndex: exerciseIndex.present + ? exerciseIndex.value + : this.exerciseIndex, + setIndex: setIndex.present ? setIndex.value : this.setIndex, + passageIndex: passageIndex.present ? passageIndex.value : this.passageIndex, + stepIndex: stepIndex.present ? stepIndex.value : this.stepIndex, + programSnapshotId: programSnapshotId.present + ? programSnapshotId.value + : this.programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId.present + ? exerciseSnapshotId.value + : this.exerciseSnapshotId, + stepSnapshotId: stepSnapshotId.present + ? stepSnapshotId.value + : this.stepSnapshotId, + heartRateBpm: heartRateBpm.present ? heartRateBpm.value : this.heartRateBpm, + distanceMeters: distanceMeters.present + ? distanceMeters.value + : this.distanceMeters, + caloriesKcal: caloriesKcal.present ? caloriesKcal.value : this.caloriesKcal, + ); + ActiveWorkoutTelemetryWindowState copyWithCompanion( + ActiveWorkoutTelemetryWindowStatesCompanion data, + ) { + return ActiveWorkoutTelemetryWindowState( + sessionId: data.sessionId.present ? data.sessionId.value : this.sessionId, + windowStartedActiveMs: data.windowStartedActiveMs.present + ? data.windowStartedActiveMs.value + : this.windowStartedActiveMs, + latestCapturedAt: data.latestCapturedAt.present + ? data.latestCapturedAt.value + : this.latestCapturedAt, + programIndex: data.programIndex.present + ? data.programIndex.value + : this.programIndex, + exerciseIndex: data.exerciseIndex.present + ? data.exerciseIndex.value + : this.exerciseIndex, + setIndex: data.setIndex.present ? data.setIndex.value : this.setIndex, + passageIndex: data.passageIndex.present + ? data.passageIndex.value + : this.passageIndex, + stepIndex: data.stepIndex.present ? data.stepIndex.value : this.stepIndex, + programSnapshotId: data.programSnapshotId.present + ? data.programSnapshotId.value + : this.programSnapshotId, + exerciseSnapshotId: data.exerciseSnapshotId.present + ? data.exerciseSnapshotId.value + : this.exerciseSnapshotId, + stepSnapshotId: data.stepSnapshotId.present + ? data.stepSnapshotId.value + : this.stepSnapshotId, + heartRateBpm: data.heartRateBpm.present + ? data.heartRateBpm.value + : this.heartRateBpm, + distanceMeters: data.distanceMeters.present + ? data.distanceMeters.value + : this.distanceMeters, + caloriesKcal: data.caloriesKcal.present + ? data.caloriesKcal.value + : this.caloriesKcal, + ); + } + + @override + String toString() { + return (StringBuffer('ActiveWorkoutTelemetryWindowState(') + ..write('sessionId: $sessionId, ') + ..write('windowStartedActiveMs: $windowStartedActiveMs, ') + ..write('latestCapturedAt: $latestCapturedAt, ') + ..write('programIndex: $programIndex, ') + ..write('exerciseIndex: $exerciseIndex, ') + ..write('setIndex: $setIndex, ') + ..write('passageIndex: $passageIndex, ') + ..write('stepIndex: $stepIndex, ') + ..write('programSnapshotId: $programSnapshotId, ') + ..write('exerciseSnapshotId: $exerciseSnapshotId, ') + ..write('stepSnapshotId: $stepSnapshotId, ') + ..write('heartRateBpm: $heartRateBpm, ') + ..write('distanceMeters: $distanceMeters, ') + ..write('caloriesKcal: $caloriesKcal') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + sessionId, + windowStartedActiveMs, + latestCapturedAt, + programIndex, + exerciseIndex, + setIndex, + passageIndex, + stepIndex, + programSnapshotId, + exerciseSnapshotId, + stepSnapshotId, + heartRateBpm, + distanceMeters, + caloriesKcal, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ActiveWorkoutTelemetryWindowState && + other.sessionId == this.sessionId && + other.windowStartedActiveMs == this.windowStartedActiveMs && + other.latestCapturedAt == this.latestCapturedAt && + other.programIndex == this.programIndex && + other.exerciseIndex == this.exerciseIndex && + other.setIndex == this.setIndex && + other.passageIndex == this.passageIndex && + other.stepIndex == this.stepIndex && + other.programSnapshotId == this.programSnapshotId && + other.exerciseSnapshotId == this.exerciseSnapshotId && + other.stepSnapshotId == this.stepSnapshotId && + other.heartRateBpm == this.heartRateBpm && + other.distanceMeters == this.distanceMeters && + other.caloriesKcal == this.caloriesKcal); +} + +class ActiveWorkoutTelemetryWindowStatesCompanion + extends UpdateCompanion { + final Value sessionId; + final Value windowStartedActiveMs; + final Value latestCapturedAt; + final Value programIndex; + final Value exerciseIndex; + final Value setIndex; + final Value passageIndex; + final Value stepIndex; + final Value programSnapshotId; + final Value exerciseSnapshotId; + final Value stepSnapshotId; + final Value heartRateBpm; + final Value distanceMeters; + final Value caloriesKcal; + final Value rowid; + const ActiveWorkoutTelemetryWindowStatesCompanion({ + this.sessionId = const Value.absent(), + this.windowStartedActiveMs = const Value.absent(), + this.latestCapturedAt = const Value.absent(), + this.programIndex = const Value.absent(), + this.exerciseIndex = const Value.absent(), + this.setIndex = const Value.absent(), + this.passageIndex = const Value.absent(), + this.stepIndex = const Value.absent(), + this.programSnapshotId = const Value.absent(), + this.exerciseSnapshotId = const Value.absent(), + this.stepSnapshotId = const Value.absent(), + this.heartRateBpm = const Value.absent(), + this.distanceMeters = const Value.absent(), + this.caloriesKcal = const Value.absent(), + this.rowid = const Value.absent(), + }); + ActiveWorkoutTelemetryWindowStatesCompanion.insert({ + required String sessionId, + required int windowStartedActiveMs, + required DateTime latestCapturedAt, + this.programIndex = const Value.absent(), + this.exerciseIndex = const Value.absent(), + this.setIndex = const Value.absent(), + this.passageIndex = const Value.absent(), + this.stepIndex = const Value.absent(), + this.programSnapshotId = const Value.absent(), + this.exerciseSnapshotId = const Value.absent(), + this.stepSnapshotId = const Value.absent(), + this.heartRateBpm = const Value.absent(), + this.distanceMeters = const Value.absent(), + this.caloriesKcal = const Value.absent(), + this.rowid = const Value.absent(), + }) : sessionId = Value(sessionId), + windowStartedActiveMs = Value(windowStartedActiveMs), + latestCapturedAt = Value(latestCapturedAt); + static Insertable custom({ + Expression? sessionId, + Expression? windowStartedActiveMs, + Expression? latestCapturedAt, + Expression? programIndex, + Expression? exerciseIndex, + Expression? setIndex, + Expression? passageIndex, + Expression? stepIndex, + Expression? programSnapshotId, + Expression? exerciseSnapshotId, + Expression? stepSnapshotId, + Expression? heartRateBpm, + Expression? distanceMeters, + Expression? caloriesKcal, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (sessionId != null) 'session_id': sessionId, + if (windowStartedActiveMs != null) + 'window_started_active_ms': windowStartedActiveMs, + if (latestCapturedAt != null) 'latest_captured_at': latestCapturedAt, + if (programIndex != null) 'program_index': programIndex, + if (exerciseIndex != null) 'exercise_index': exerciseIndex, + if (setIndex != null) 'set_index': setIndex, + if (passageIndex != null) 'passage_index': passageIndex, + if (stepIndex != null) 'step_index': stepIndex, + if (programSnapshotId != null) 'program_snapshot_id': programSnapshotId, + if (exerciseSnapshotId != null) + 'exercise_snapshot_id': exerciseSnapshotId, + if (stepSnapshotId != null) 'step_snapshot_id': stepSnapshotId, + if (heartRateBpm != null) 'heart_rate_bpm': heartRateBpm, + if (distanceMeters != null) 'distance_meters': distanceMeters, + if (caloriesKcal != null) 'calories_kcal': caloriesKcal, + if (rowid != null) 'rowid': rowid, + }); + } + + ActiveWorkoutTelemetryWindowStatesCompanion copyWith({ + Value? sessionId, + Value? windowStartedActiveMs, + Value? latestCapturedAt, + Value? programIndex, + Value? exerciseIndex, + Value? setIndex, + Value? passageIndex, + Value? stepIndex, + Value? programSnapshotId, + Value? exerciseSnapshotId, + Value? stepSnapshotId, + Value? heartRateBpm, + Value? distanceMeters, + Value? caloriesKcal, + Value? rowid, + }) { + return ActiveWorkoutTelemetryWindowStatesCompanion( + sessionId: sessionId ?? this.sessionId, + windowStartedActiveMs: + windowStartedActiveMs ?? this.windowStartedActiveMs, + latestCapturedAt: latestCapturedAt ?? this.latestCapturedAt, + programIndex: programIndex ?? this.programIndex, + exerciseIndex: exerciseIndex ?? this.exerciseIndex, + setIndex: setIndex ?? this.setIndex, + passageIndex: passageIndex ?? this.passageIndex, + stepIndex: stepIndex ?? this.stepIndex, + programSnapshotId: programSnapshotId ?? this.programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId ?? this.exerciseSnapshotId, + stepSnapshotId: stepSnapshotId ?? this.stepSnapshotId, + heartRateBpm: heartRateBpm ?? this.heartRateBpm, + distanceMeters: distanceMeters ?? this.distanceMeters, + caloriesKcal: caloriesKcal ?? this.caloriesKcal, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (sessionId.present) { + map['session_id'] = Variable(sessionId.value); + } + if (windowStartedActiveMs.present) { + map['window_started_active_ms'] = Variable( + windowStartedActiveMs.value, + ); + } + if (latestCapturedAt.present) { + map['latest_captured_at'] = Variable(latestCapturedAt.value); + } + if (programIndex.present) { + map['program_index'] = Variable(programIndex.value); + } + if (exerciseIndex.present) { + map['exercise_index'] = Variable(exerciseIndex.value); + } + if (setIndex.present) { + map['set_index'] = Variable(setIndex.value); + } + if (passageIndex.present) { + map['passage_index'] = Variable(passageIndex.value); + } + if (stepIndex.present) { + map['step_index'] = Variable(stepIndex.value); + } + if (programSnapshotId.present) { + map['program_snapshot_id'] = Variable(programSnapshotId.value); + } + if (exerciseSnapshotId.present) { + map['exercise_snapshot_id'] = Variable(exerciseSnapshotId.value); + } + if (stepSnapshotId.present) { + map['step_snapshot_id'] = Variable(stepSnapshotId.value); + } + if (heartRateBpm.present) { + map['heart_rate_bpm'] = Variable(heartRateBpm.value); + } + if (distanceMeters.present) { + map['distance_meters'] = Variable(distanceMeters.value); + } + if (caloriesKcal.present) { + map['calories_kcal'] = Variable(caloriesKcal.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ActiveWorkoutTelemetryWindowStatesCompanion(') + ..write('sessionId: $sessionId, ') + ..write('windowStartedActiveMs: $windowStartedActiveMs, ') + ..write('latestCapturedAt: $latestCapturedAt, ') + ..write('programIndex: $programIndex, ') + ..write('exerciseIndex: $exerciseIndex, ') + ..write('setIndex: $setIndex, ') + ..write('passageIndex: $passageIndex, ') + ..write('stepIndex: $stepIndex, ') + ..write('programSnapshotId: $programSnapshotId, ') + ..write('exerciseSnapshotId: $exerciseSnapshotId, ') + ..write('stepSnapshotId: $stepSnapshotId, ') + ..write('heartRateBpm: $heartRateBpm, ') + ..write('distanceMeters: $distanceMeters, ') + ..write('caloriesKcal: $caloriesKcal, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $ActiveRestStatesTable extends ActiveRestStates with TableInfo<$ActiveRestStatesTable, ActiveRestState> { @override @@ -33531,6 +34442,10 @@ abstract class _$AppDatabase extends GeneratedDatabase { ); late final $ActiveExerciseStepResultsTable activeExerciseStepResults = $ActiveExerciseStepResultsTable(this); + late final $ActiveWorkoutTelemetryWindowStatesTable + activeWorkoutTelemetryWindowStates = $ActiveWorkoutTelemetryWindowStatesTable( + this, + ); late final $ActiveRestStatesTable activeRestStates = $ActiveRestStatesTable( this, ); @@ -33593,6 +34508,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { activeWorkoutSessions, activeExerciseStepProgressStates, activeExerciseStepResults, + activeWorkoutTelemetryWindowStates, activeRestStates, activeManualScoreStates, activeScoreStopwatchStates, @@ -33619,6 +34535,21 @@ abstract class _$AppDatabase extends GeneratedDatabase { workoutTemplatePrograms, workoutTemplateExerciseOverrides, ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'active_workout_sessions', + limitUpdateKind: UpdateKind.delete, + ), + result: [ + TableUpdate( + 'active_workout_telemetry_window_states', + kind: UpdateKind.delete, + ), + ], + ), + ]); } typedef $$WorkoutTemplatesTableCreateCompanionBuilder = @@ -34518,6 +35449,33 @@ final class $$ActiveWorkoutSessionsTableReferences ); } + static MultiTypedResultKey< + $ActiveWorkoutTelemetryWindowStatesTable, + List + > + _activeWorkoutTelemetryWindowStatesRefsTable( + _$AppDatabase db, + ) => MultiTypedResultKey.fromTable( + db.activeWorkoutTelemetryWindowStates, + aliasName: + 'active_workout_sessions__id__active_workout_telemetry_window_states__session_id', + ); + + $$ActiveWorkoutTelemetryWindowStatesTableProcessedTableManager + get activeWorkoutTelemetryWindowStatesRefs { + final manager = $$ActiveWorkoutTelemetryWindowStatesTableTableManager( + $_db, + $_db.activeWorkoutTelemetryWindowStates, + ).filter((f) => f.sessionId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _activeWorkoutTelemetryWindowStatesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + static MultiTypedResultKey<$ActiveRestStatesTable, List> _activeRestStatesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.activeRestStates, @@ -34877,6 +35835,35 @@ class $$ActiveWorkoutSessionsTableFilterComposer return f(composer); } + Expression activeWorkoutTelemetryWindowStatesRefs( + Expression Function( + $$ActiveWorkoutTelemetryWindowStatesTableFilterComposer f, + ) + f, + ) { + final $$ActiveWorkoutTelemetryWindowStatesTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.activeWorkoutTelemetryWindowStates, + getReferencedColumn: (t) => t.sessionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutTelemetryWindowStatesTableFilterComposer( + $db: $db, + $table: $db.activeWorkoutTelemetryWindowStates, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression activeRestStatesRefs( Expression Function($$ActiveRestStatesTableFilterComposer f) f, ) { @@ -35346,6 +36333,35 @@ class $$ActiveWorkoutSessionsTableAnnotationComposer return f(composer); } + Expression activeWorkoutTelemetryWindowStatesRefs( + Expression Function( + $$ActiveWorkoutTelemetryWindowStatesTableAnnotationComposer a, + ) + f, + ) { + final $$ActiveWorkoutTelemetryWindowStatesTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.activeWorkoutTelemetryWindowStates, + getReferencedColumn: (t) => t.sessionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutTelemetryWindowStatesTableAnnotationComposer( + $db: $db, + $table: $db.activeWorkoutTelemetryWindowStates, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression activeRestStatesRefs( Expression Function($$ActiveRestStatesTableAnnotationComposer a) f, ) { @@ -35521,6 +36537,7 @@ class $$ActiveWorkoutSessionsTableTableManager bool sourceWorkoutTemplateId, bool activeExerciseStepProgressStatesRefs, bool activeExerciseStepResultsRefs, + bool activeWorkoutTelemetryWindowStatesRefs, bool activeRestStatesRefs, bool activeManualScoreStatesRefs, bool activeScoreStopwatchStatesRefs, @@ -35665,6 +36682,7 @@ class $$ActiveWorkoutSessionsTableTableManager sourceWorkoutTemplateId = false, activeExerciseStepProgressStatesRefs = false, activeExerciseStepResultsRefs = false, + activeWorkoutTelemetryWindowStatesRefs = false, activeRestStatesRefs = false, activeManualScoreStatesRefs = false, activeScoreStopwatchStatesRefs = false, @@ -35679,6 +36697,8 @@ class $$ActiveWorkoutSessionsTableTableManager db.activeExerciseStepProgressStates, if (activeExerciseStepResultsRefs) db.activeExerciseStepResults, + if (activeWorkoutTelemetryWindowStatesRefs) + db.activeWorkoutTelemetryWindowStates, if (activeRestStatesRefs) db.activeRestStates, if (activeManualScoreStatesRefs) db.activeManualScoreStates, if (activeScoreStopwatchStatesRefs) @@ -35770,6 +36790,30 @@ class $$ActiveWorkoutSessionsTableTableManager ), typedResults: items, ), + if (activeWorkoutTelemetryWindowStatesRefs) + await $_getPrefetchedData< + ActiveWorkoutSession, + $ActiveWorkoutSessionsTable, + ActiveWorkoutTelemetryWindowState + >( + currentTable: table, + referencedTable: + $$ActiveWorkoutSessionsTableReferences + ._activeWorkoutTelemetryWindowStatesRefsTable( + db, + ), + managerFromTypedResult: (p0) => + $$ActiveWorkoutSessionsTableReferences( + db, + table, + p0, + ).activeWorkoutTelemetryWindowStatesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.sessionId == item.id, + ), + typedResults: items, + ), if (activeRestStatesRefs) await $_getPrefetchedData< ActiveWorkoutSession, @@ -35927,6 +36971,7 @@ typedef $$ActiveWorkoutSessionsTableProcessedTableManager = bool sourceWorkoutTemplateId, bool activeExerciseStepProgressStatesRefs, bool activeExerciseStepResultsRefs, + bool activeWorkoutTelemetryWindowStatesRefs, bool activeRestStatesRefs, bool activeManualScoreStatesRefs, bool activeScoreStopwatchStatesRefs, @@ -37651,6 +38696,554 @@ typedef $$ActiveExerciseStepResultsTableProcessedTableManager = ActiveExerciseStepResult, PrefetchHooks Function({bool activeWorkoutSessionId}) >; +typedef $$ActiveWorkoutTelemetryWindowStatesTableCreateCompanionBuilder = + ActiveWorkoutTelemetryWindowStatesCompanion Function({ + required String sessionId, + required int windowStartedActiveMs, + required DateTime latestCapturedAt, + Value programIndex, + Value exerciseIndex, + Value setIndex, + Value passageIndex, + Value stepIndex, + Value programSnapshotId, + Value exerciseSnapshotId, + Value stepSnapshotId, + Value heartRateBpm, + Value distanceMeters, + Value caloriesKcal, + Value rowid, + }); +typedef $$ActiveWorkoutTelemetryWindowStatesTableUpdateCompanionBuilder = + ActiveWorkoutTelemetryWindowStatesCompanion Function({ + Value sessionId, + Value windowStartedActiveMs, + Value latestCapturedAt, + Value programIndex, + Value exerciseIndex, + Value setIndex, + Value passageIndex, + Value stepIndex, + Value programSnapshotId, + Value exerciseSnapshotId, + Value stepSnapshotId, + Value heartRateBpm, + Value distanceMeters, + Value caloriesKcal, + Value rowid, + }); + +final class $$ActiveWorkoutTelemetryWindowStatesTableReferences + extends + BaseReferences< + _$AppDatabase, + $ActiveWorkoutTelemetryWindowStatesTable, + ActiveWorkoutTelemetryWindowState + > { + $$ActiveWorkoutTelemetryWindowStatesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $ActiveWorkoutSessionsTable _sessionIdTable( + _$AppDatabase db, + ) => db.activeWorkoutSessions.createAlias( + 'active_workout_telemetry_window_states__session_id__active_workout_sessions__id', + ); + + $$ActiveWorkoutSessionsTableProcessedTableManager get sessionId { + final $_column = $_itemColumn('session_id')!; + + final manager = $$ActiveWorkoutSessionsTableTableManager( + $_db, + $_db.activeWorkoutSessions, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_sessionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$ActiveWorkoutTelemetryWindowStatesTableFilterComposer + extends Composer<_$AppDatabase, $ActiveWorkoutTelemetryWindowStatesTable> { + $$ActiveWorkoutTelemetryWindowStatesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get windowStartedActiveMs => $composableBuilder( + column: $table.windowStartedActiveMs, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get latestCapturedAt => $composableBuilder( + column: $table.latestCapturedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get setIndex => $composableBuilder( + column: $table.setIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get passageIndex => $composableBuilder( + column: $table.passageIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get stepIndex => $composableBuilder( + column: $table.stepIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get programSnapshotId => $composableBuilder( + column: $table.programSnapshotId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get exerciseSnapshotId => $composableBuilder( + column: $table.exerciseSnapshotId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get stepSnapshotId => $composableBuilder( + column: $table.stepSnapshotId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get heartRateBpm => $composableBuilder( + column: $table.heartRateBpm, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get distanceMeters => $composableBuilder( + column: $table.distanceMeters, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get caloriesKcal => $composableBuilder( + column: $table.caloriesKcal, + builder: (column) => ColumnFilters(column), + ); + + $$ActiveWorkoutSessionsTableFilterComposer get sessionId { + final $$ActiveWorkoutSessionsTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.sessionId, + referencedTable: $db.activeWorkoutSessions, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutSessionsTableFilterComposer( + $db: $db, + $table: $db.activeWorkoutSessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ActiveWorkoutTelemetryWindowStatesTableOrderingComposer + extends Composer<_$AppDatabase, $ActiveWorkoutTelemetryWindowStatesTable> { + $$ActiveWorkoutTelemetryWindowStatesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get windowStartedActiveMs => $composableBuilder( + column: $table.windowStartedActiveMs, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get latestCapturedAt => $composableBuilder( + column: $table.latestCapturedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get setIndex => $composableBuilder( + column: $table.setIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get passageIndex => $composableBuilder( + column: $table.passageIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get stepIndex => $composableBuilder( + column: $table.stepIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get programSnapshotId => $composableBuilder( + column: $table.programSnapshotId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get exerciseSnapshotId => $composableBuilder( + column: $table.exerciseSnapshotId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get stepSnapshotId => $composableBuilder( + column: $table.stepSnapshotId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get heartRateBpm => $composableBuilder( + column: $table.heartRateBpm, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get distanceMeters => $composableBuilder( + column: $table.distanceMeters, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get caloriesKcal => $composableBuilder( + column: $table.caloriesKcal, + builder: (column) => ColumnOrderings(column), + ); + + $$ActiveWorkoutSessionsTableOrderingComposer get sessionId { + final $$ActiveWorkoutSessionsTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.sessionId, + referencedTable: $db.activeWorkoutSessions, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutSessionsTableOrderingComposer( + $db: $db, + $table: $db.activeWorkoutSessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ActiveWorkoutTelemetryWindowStatesTableAnnotationComposer + extends Composer<_$AppDatabase, $ActiveWorkoutTelemetryWindowStatesTable> { + $$ActiveWorkoutTelemetryWindowStatesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get windowStartedActiveMs => $composableBuilder( + column: $table.windowStartedActiveMs, + builder: (column) => column, + ); + + GeneratedColumn get latestCapturedAt => $composableBuilder( + column: $table.latestCapturedAt, + builder: (column) => column, + ); + + GeneratedColumn get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => column, + ); + + GeneratedColumn get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => column, + ); + + GeneratedColumn get setIndex => + $composableBuilder(column: $table.setIndex, builder: (column) => column); + + GeneratedColumn get passageIndex => $composableBuilder( + column: $table.passageIndex, + builder: (column) => column, + ); + + GeneratedColumn get stepIndex => + $composableBuilder(column: $table.stepIndex, builder: (column) => column); + + GeneratedColumn get programSnapshotId => $composableBuilder( + column: $table.programSnapshotId, + builder: (column) => column, + ); + + GeneratedColumn get exerciseSnapshotId => $composableBuilder( + column: $table.exerciseSnapshotId, + builder: (column) => column, + ); + + GeneratedColumn get stepSnapshotId => $composableBuilder( + column: $table.stepSnapshotId, + builder: (column) => column, + ); + + GeneratedColumn get heartRateBpm => $composableBuilder( + column: $table.heartRateBpm, + builder: (column) => column, + ); + + GeneratedColumn get distanceMeters => $composableBuilder( + column: $table.distanceMeters, + builder: (column) => column, + ); + + GeneratedColumn get caloriesKcal => $composableBuilder( + column: $table.caloriesKcal, + builder: (column) => column, + ); + + $$ActiveWorkoutSessionsTableAnnotationComposer get sessionId { + final $$ActiveWorkoutSessionsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.sessionId, + referencedTable: $db.activeWorkoutSessions, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutSessionsTableAnnotationComposer( + $db: $db, + $table: $db.activeWorkoutSessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ActiveWorkoutTelemetryWindowStatesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ActiveWorkoutTelemetryWindowStatesTable, + ActiveWorkoutTelemetryWindowState, + $$ActiveWorkoutTelemetryWindowStatesTableFilterComposer, + $$ActiveWorkoutTelemetryWindowStatesTableOrderingComposer, + $$ActiveWorkoutTelemetryWindowStatesTableAnnotationComposer, + $$ActiveWorkoutTelemetryWindowStatesTableCreateCompanionBuilder, + $$ActiveWorkoutTelemetryWindowStatesTableUpdateCompanionBuilder, + ( + ActiveWorkoutTelemetryWindowState, + $$ActiveWorkoutTelemetryWindowStatesTableReferences, + ), + ActiveWorkoutTelemetryWindowState, + PrefetchHooks Function({bool sessionId}) + > { + $$ActiveWorkoutTelemetryWindowStatesTableTableManager( + _$AppDatabase db, + $ActiveWorkoutTelemetryWindowStatesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ActiveWorkoutTelemetryWindowStatesTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$ActiveWorkoutTelemetryWindowStatesTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$ActiveWorkoutTelemetryWindowStatesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value sessionId = const Value.absent(), + Value windowStartedActiveMs = const Value.absent(), + Value latestCapturedAt = const Value.absent(), + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value passageIndex = const Value.absent(), + Value stepIndex = const Value.absent(), + Value programSnapshotId = const Value.absent(), + Value exerciseSnapshotId = const Value.absent(), + Value stepSnapshotId = const Value.absent(), + Value heartRateBpm = const Value.absent(), + Value distanceMeters = const Value.absent(), + Value caloriesKcal = const Value.absent(), + Value rowid = const Value.absent(), + }) => ActiveWorkoutTelemetryWindowStatesCompanion( + sessionId: sessionId, + windowStartedActiveMs: windowStartedActiveMs, + latestCapturedAt: latestCapturedAt, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + programSnapshotId: programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId, + stepSnapshotId: stepSnapshotId, + heartRateBpm: heartRateBpm, + distanceMeters: distanceMeters, + caloriesKcal: caloriesKcal, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String sessionId, + required int windowStartedActiveMs, + required DateTime latestCapturedAt, + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value passageIndex = const Value.absent(), + Value stepIndex = const Value.absent(), + Value programSnapshotId = const Value.absent(), + Value exerciseSnapshotId = const Value.absent(), + Value stepSnapshotId = const Value.absent(), + Value heartRateBpm = const Value.absent(), + Value distanceMeters = const Value.absent(), + Value caloriesKcal = const Value.absent(), + Value rowid = const Value.absent(), + }) => ActiveWorkoutTelemetryWindowStatesCompanion.insert( + sessionId: sessionId, + windowStartedActiveMs: windowStartedActiveMs, + latestCapturedAt: latestCapturedAt, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + programSnapshotId: programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId, + stepSnapshotId: stepSnapshotId, + heartRateBpm: heartRateBpm, + distanceMeters: distanceMeters, + caloriesKcal: caloriesKcal, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$ActiveWorkoutTelemetryWindowStatesTableReferences( + db, + table, + e, + ), + ), + ) + .toList(), + prefetchHooksCallback: ({sessionId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (sessionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.sessionId, + referencedTable: + $$ActiveWorkoutTelemetryWindowStatesTableReferences + ._sessionIdTable(db), + referencedColumn: + $$ActiveWorkoutTelemetryWindowStatesTableReferences + ._sessionIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$ActiveWorkoutTelemetryWindowStatesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ActiveWorkoutTelemetryWindowStatesTable, + ActiveWorkoutTelemetryWindowState, + $$ActiveWorkoutTelemetryWindowStatesTableFilterComposer, + $$ActiveWorkoutTelemetryWindowStatesTableOrderingComposer, + $$ActiveWorkoutTelemetryWindowStatesTableAnnotationComposer, + $$ActiveWorkoutTelemetryWindowStatesTableCreateCompanionBuilder, + $$ActiveWorkoutTelemetryWindowStatesTableUpdateCompanionBuilder, + ( + ActiveWorkoutTelemetryWindowState, + $$ActiveWorkoutTelemetryWindowStatesTableReferences, + ), + ActiveWorkoutTelemetryWindowState, + PrefetchHooks Function({bool sessionId}) + >; typedef $$ActiveRestStatesTableCreateCompanionBuilder = ActiveRestStatesCompanion Function({ required String id, @@ -53685,6 +55278,12 @@ class $AppDatabaseManager { _db, _db.activeExerciseStepResults, ); + $$ActiveWorkoutTelemetryWindowStatesTableTableManager + get activeWorkoutTelemetryWindowStates => + $$ActiveWorkoutTelemetryWindowStatesTableTableManager( + _db, + _db.activeWorkoutTelemetryWindowStates, + ); $$ActiveRestStatesTableTableManager get activeRestStates => $$ActiveRestStatesTableTableManager(_db, _db.activeRestStates); $$ActiveManualScoreStatesTableTableManager get activeManualScoreStates => diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 60b5e45..8ba7283 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -671,6 +671,7 @@ final class DriftLocalSyncChangeRepository .into(database.workoutHistoryStepResults) .insertOnConflictUpdate(_workoutHistoryStepResultCompanion(result)); } + await _replaceRemoteWorkoutTelemetry(history); final activeResultIds = history.results .map((result) => result.metadata.id) @@ -693,6 +694,44 @@ final class DriftLocalSyncChangeRepository }); } + Future _replaceRemoteWorkoutTelemetry( + domain.WorkoutHistory history, + ) async { + final samples = _workoutTelemetrySamplesFromHistoryPayload(history); + if (samples.isEmpty) { + return; + } + final sessionIds = samples.map((sample) => sample.sessionId).toSet(); + for (final sessionId in sessionIds) { + await (database.delete( + database.workoutTelemetrySamples, + )..where((table) => table.sessionId.equals(sessionId))).go(); + await (database.delete( + database.workoutTelemetryAggregates, + )..where((table) => table.sessionId.equals(sessionId))).go(); + } + await database.batch((batch) { + batch.insertAll( + database.workoutTelemetrySamples, + samples.map(_workoutTelemetrySampleCompanion).toList(), + ); + }); + for (final sessionId in sessionIds) { + final sessionSamples = samples + .where((sample) => sample.sessionId == sessionId) + .toList(growable: false); + final aggregates = _workoutTelemetryAggregatesFromSamples(sessionSamples); + if (aggregates.isNotEmpty) { + await database.batch((batch) { + batch.insertAll( + database.workoutTelemetryAggregates, + aggregates.map(_workoutTelemetryAggregateCompanion).toList(), + ); + }); + } + } + } + Future _softDeleteRemoteWorkoutHistoryChildren({ required String tableName, required String historyId, @@ -2150,6 +2189,34 @@ final class DriftWorkoutTelemetryRepository return true; } + @override + Future findWindowState( + String sessionId, + ) async { + final row = await (database.select( + database.activeWorkoutTelemetryWindowStates, + )..where((table) => table.sessionId.equals(sessionId))).getSingleOrNull(); + return row == null ? null : _activeWorkoutTelemetryWindowStateFromRow(row); + } + + @override + Future saveWindowState( + domain.ActiveWorkoutTelemetryWindowState state, + ) async { + await database + .into(database.activeWorkoutTelemetryWindowStates) + .insertOnConflictUpdate( + _activeWorkoutTelemetryWindowStateCompanion(state), + ); + } + + @override + Future deleteWindowState(String sessionId) async { + await (database.delete( + database.activeWorkoutTelemetryWindowStates, + )..where((table) => table.sessionId.equals(sessionId))).go(); + } + @override Future> listSamples( String sessionId, @@ -5323,6 +5390,28 @@ db.WorkoutTelemetrySamplesCompanion _workoutTelemetrySampleCompanion( ); } +db.ActiveWorkoutTelemetryWindowStatesCompanion +_activeWorkoutTelemetryWindowStateCompanion( + domain.ActiveWorkoutTelemetryWindowState state, +) { + return db.ActiveWorkoutTelemetryWindowStatesCompanion.insert( + sessionId: state.sessionId, + windowStartedActiveMs: state.windowStartedActiveMs, + latestCapturedAt: state.latestCapturedAt.toUtc(), + programIndex: Value(state.programIndex), + exerciseIndex: Value(state.exerciseIndex), + setIndex: Value(state.setIndex), + passageIndex: Value(state.passageIndex), + stepIndex: Value(state.stepIndex), + programSnapshotId: Value(state.programSnapshotId), + exerciseSnapshotId: Value(state.exerciseSnapshotId), + stepSnapshotId: Value(state.stepSnapshotId), + heartRateBpm: Value(state.heartRateBpm), + distanceMeters: Value(state.distanceMeters), + caloriesKcal: Value(state.caloriesKcal), + ); +} + db.WorkoutTelemetryAggregatesCompanion _workoutTelemetryAggregateCompanion( domain.WorkoutTelemetryAggregate aggregate, ) { @@ -5486,6 +5575,28 @@ domain.WorkoutTelemetrySample _workoutTelemetrySampleFromRow( ); } +domain.ActiveWorkoutTelemetryWindowState +_activeWorkoutTelemetryWindowStateFromRow( + db.ActiveWorkoutTelemetryWindowState row, +) { + return domain.ActiveWorkoutTelemetryWindowState( + sessionId: row.sessionId, + windowStartedActiveMs: row.windowStartedActiveMs, + latestCapturedAt: _utc(row.latestCapturedAt), + programIndex: row.programIndex, + exerciseIndex: row.exerciseIndex, + setIndex: row.setIndex, + passageIndex: row.passageIndex, + stepIndex: row.stepIndex, + programSnapshotId: row.programSnapshotId, + exerciseSnapshotId: row.exerciseSnapshotId, + stepSnapshotId: row.stepSnapshotId, + heartRateBpm: row.heartRateBpm, + distanceMeters: row.distanceMeters, + caloriesKcal: row.caloriesKcal, + ); +} + bool _telemetrySampleMatchesScope( domain.WorkoutTelemetrySample sample, { required domain.WorkoutTelemetryAggregateScope scope, @@ -5806,6 +5917,9 @@ Map _localWorkoutHistoryPayload( 'stepResults': history.stepResults .map(_workoutHistoryStepResultPayload) .toList(), + 'telemetrySamples': _workoutTelemetrySamplesFromHistoryPayload( + history, + ).map(_workoutTelemetrySamplePayload).toList(), }; Map _workoutHistorySetResultPayload( @@ -5875,6 +5989,248 @@ Map _workoutHistoryStepResultPayload( 'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot, }; +Map _workoutTelemetrySamplePayload( + domain.WorkoutTelemetrySample sample, +) => { + 'id': sample.id, + 'sessionId': sample.sessionId, + 'capturedAt': sample.capturedAt.toUtc().toIso8601String(), + 'programIndex': sample.programIndex, + 'exerciseIndex': sample.exerciseIndex, + 'setIndex': sample.setIndex, + 'passageIndex': sample.passageIndex, + 'stepIndex': sample.stepIndex, + 'programSnapshotId': sample.programSnapshotId, + 'exerciseSnapshotId': sample.exerciseSnapshotId, + 'stepSnapshotId': sample.stepSnapshotId, + 'heartRateBpm': sample.heartRateBpm, + 'distanceMeters': sample.distanceMeters, + 'caloriesKcal': sample.caloriesKcal, +}; + +List _workoutTelemetrySamplesFromHistoryPayload( + domain.WorkoutHistory history, +) { + final decoded = jsonDecode(history.historySnapshotJson); + final snapshot = decoded is Map + ? Map.from(decoded) + : const {}; + final rawSamples = snapshot['telemetrySamples']; + if (rawSamples is! List) { + return const []; + } + return _workoutTelemetrySamplesFromPayload(rawSamples); +} + +List _workoutTelemetrySamplesFromPayload( + Object? value, +) { + if (value is! List) { + return const []; + } + final output = []; + for (final rawSample in value) { + if (rawSample is! Map) { + continue; + } + final map = Map.from(rawSample); + final id = map['id'] as String?; + final sessionId = map['sessionId'] as String?; + final capturedAt = _dateTimeFromPayload(map['capturedAt']); + if (id == null || sessionId == null || capturedAt == null) { + continue; + } + output.add( + domain.WorkoutTelemetrySample( + id: id, + sessionId: sessionId, + capturedAt: capturedAt, + programIndex: map['programIndex'] as int?, + exerciseIndex: map['exerciseIndex'] as int?, + setIndex: map['setIndex'] as int?, + passageIndex: map['passageIndex'] as int?, + stepIndex: map['stepIndex'] as int?, + programSnapshotId: map['programSnapshotId'] as String?, + exerciseSnapshotId: map['exerciseSnapshotId'] as String?, + stepSnapshotId: map['stepSnapshotId'] as String?, + heartRateBpm: map['heartRateBpm'] as int?, + distanceMeters: (map['distanceMeters'] as num?)?.toDouble(), + caloriesKcal: (map['caloriesKcal'] as num?)?.toDouble(), + ), + ); + } + return output; +} + +List _workoutTelemetryAggregatesFromSamples( + List samples, +) { + final builders = <_TelemetryAggregateKey, _TelemetryAggregateBuilder>{}; + for (final sample in samples) { + for (final key in _telemetryAggregateKeys(sample)) { + builders + .putIfAbsent(key, () => _TelemetryAggregateBuilder(key)) + .add(sample); + } + } + return [for (final builder in builders.values) builder.build()]; +} + +List<_TelemetryAggregateKey> _telemetryAggregateKeys( + domain.WorkoutTelemetrySample sample, +) { + final keys = [ + _TelemetryAggregateKey( + sessionId: sample.sessionId, + scope: domain.WorkoutTelemetryAggregateScope.session, + ), + ]; + if (sample.programIndex != null && sample.exerciseIndex != null) { + keys.add( + _TelemetryAggregateKey( + sessionId: sample.sessionId, + scope: domain.WorkoutTelemetryAggregateScope.exercise, + programIndex: sample.programIndex, + exerciseIndex: sample.exerciseIndex, + ), + ); + } + if (sample.programIndex != null && + sample.exerciseIndex != null && + sample.setIndex != null) { + keys.add( + _TelemetryAggregateKey( + sessionId: sample.sessionId, + scope: domain.WorkoutTelemetryAggregateScope.set, + programIndex: sample.programIndex, + exerciseIndex: sample.exerciseIndex, + setIndex: sample.setIndex, + ), + ); + } + if (sample.programIndex != null && + sample.exerciseIndex != null && + sample.setIndex != null && + sample.stepIndex != null) { + keys.add( + _TelemetryAggregateKey( + sessionId: sample.sessionId, + scope: domain.WorkoutTelemetryAggregateScope.step, + programIndex: sample.programIndex, + exerciseIndex: sample.exerciseIndex, + setIndex: sample.setIndex, + passageIndex: sample.passageIndex, + stepIndex: sample.stepIndex, + ), + ); + } + return keys; +} + +final class _TelemetryAggregateKey { + const _TelemetryAggregateKey({ + required this.sessionId, + required this.scope, + this.programIndex, + this.exerciseIndex, + this.setIndex, + this.passageIndex, + this.stepIndex, + }); + + final String sessionId; + final domain.WorkoutTelemetryAggregateScope scope; + final int? programIndex; + final int? exerciseIndex; + final int? setIndex; + final int? passageIndex; + final int? stepIndex; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is _TelemetryAggregateKey && + sessionId == other.sessionId && + scope == other.scope && + programIndex == other.programIndex && + exerciseIndex == other.exerciseIndex && + setIndex == other.setIndex && + passageIndex == other.passageIndex && + stepIndex == other.stepIndex; + } + + @override + int get hashCode => Object.hash( + sessionId, + scope, + programIndex, + exerciseIndex, + setIndex, + passageIndex, + stepIndex, + ); +} + +final class _TelemetryAggregateBuilder { + _TelemetryAggregateBuilder(this.key); + + final _TelemetryAggregateKey key; + var sampleCount = 0; + var heartRateCount = 0; + var heartRateSum = 0.0; + int? minHeartRateBpm; + int? maxHeartRateBpm; + double? maxDistanceMeters; + double? maxCaloriesKcal; + + void add(domain.WorkoutTelemetrySample sample) { + sampleCount += 1; + final heartRate = sample.heartRateBpm; + if (heartRate != null) { + heartRateCount += 1; + heartRateSum += heartRate; + minHeartRateBpm = minHeartRateBpm == null + ? heartRate + : (heartRate < minHeartRateBpm! ? heartRate : minHeartRateBpm); + maxHeartRateBpm = maxHeartRateBpm == null + ? heartRate + : (heartRate > maxHeartRateBpm! ? heartRate : maxHeartRateBpm); + } + final distance = sample.distanceMeters; + if (distance != null) { + maxDistanceMeters = maxDistanceMeters == null + ? distance + : (distance > maxDistanceMeters! ? distance : maxDistanceMeters); + } + final calories = sample.caloriesKcal; + if (calories != null) { + maxCaloriesKcal = maxCaloriesKcal == null + ? calories + : (calories > maxCaloriesKcal! ? calories : maxCaloriesKcal); + } + } + + domain.WorkoutTelemetryAggregate build() { + return domain.WorkoutTelemetryAggregate( + sessionId: key.sessionId, + scope: key.scope, + programIndex: key.programIndex, + exerciseIndex: key.exerciseIndex, + setIndex: key.setIndex, + passageIndex: key.passageIndex, + stepIndex: key.stepIndex, + sampleCount: sampleCount, + minHeartRateBpm: minHeartRateBpm, + averageHeartRateBpm: heartRateCount == 0 + ? null + : heartRateSum / heartRateCount, + maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: maxDistanceMeters, + totalCaloriesKcal: maxCaloriesKcal, + ); + } +} + LocalBackupResource _backupResource( domain.EntityMetadata metadata, Map payload, @@ -5937,6 +6293,7 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload( ) { final payload = item.payload; final metadata = _metadataFromPayload(item); + final historySnapshotJson = _historySnapshotJsonWithTelemetryPayload(payload); return domain.WorkoutHistory( metadata: metadata, sourceWorkoutTemplateId: payload['sourceWorkoutTemplateId'] as String?, @@ -5948,8 +6305,7 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload( endedAt: _dateTimeFromPayload(payload['endedAt']) ?? item.clientUpdatedAt, totalActiveMs: payload['totalActiveMs'] as int? ?? 0, completed: payload['completed'] as bool? ?? false, - historySnapshotJson: - payload['historySnapshotJson'] as String? ?? '{"programs":[]}', + historySnapshotJson: historySnapshotJson, minHeartRateBpm: payload['minHeartRateBpm'] as int?, averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(), maxHeartRateBpm: payload['maxHeartRateBpm'] as int?, @@ -5963,6 +6319,20 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload( ); } +String _historySnapshotJsonWithTelemetryPayload(Map payload) { + final rawSnapshot = payload['historySnapshotJson'] as String?; + final rawSamples = payload['telemetrySamples']; + if (rawSamples is! List) { + return rawSnapshot ?? '{"programs":[]}'; + } + final decoded = rawSnapshot == null ? null : jsonDecode(rawSnapshot); + final snapshot = decoded is Map + ? Map.from(decoded) + : {'programs': const []}; + snapshot['telemetrySamples'] = rawSamples; + return jsonEncode(snapshot); +} + domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) { final payload = item.payload; return domain.Exercise( diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index bd5c385..1eb7284 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -804,6 +804,48 @@ class WorkoutTelemetrySamples extends Table { ]; } +class ActiveWorkoutTelemetryWindowStates extends Table { + @override + String get tableName => 'active_workout_telemetry_window_states'; + + TextColumn get sessionId => text().references( + ActiveWorkoutSessions, + #id, + onDelete: KeyAction.cascade, + )(); + IntColumn get windowStartedActiveMs => integer()(); + DateTimeColumn get latestCapturedAt => dateTime()(); + IntColumn get programIndex => integer().nullable()(); + IntColumn get exerciseIndex => integer().nullable()(); + IntColumn get setIndex => integer().nullable()(); + IntColumn get passageIndex => integer().nullable()(); + IntColumn get stepIndex => integer().nullable()(); + TextColumn get programSnapshotId => text().nullable()(); + TextColumn get exerciseSnapshotId => text().nullable()(); + TextColumn get stepSnapshotId => text().nullable()(); + IntColumn get heartRateBpm => integer().nullable()(); + RealColumn get distanceMeters => real().nullable()(); + RealColumn get caloriesKcal => real().nullable()(); + + @override + Set get primaryKey => {sessionId}; + + @override + List get customConstraints => [ + 'CHECK (window_started_active_ms >= 0)', + 'CHECK (program_index IS NULL OR program_index >= 0)', + 'CHECK (exercise_index IS NULL OR exercise_index >= 0)', + 'CHECK (set_index IS NULL OR set_index >= 0)', + 'CHECK (passage_index IS NULL OR passage_index >= 0)', + 'CHECK (step_index IS NULL OR step_index >= 0)', + 'CHECK (heart_rate_bpm IS NULL OR heart_rate_bpm > 0)', + 'CHECK (distance_meters IS NULL OR distance_meters >= 0)', + 'CHECK (calories_kcal IS NULL OR calories_kcal >= 0)', + 'CHECK (heart_rate_bpm IS NOT NULL OR distance_meters IS NOT NULL OR ' + 'calories_kcal IS NOT NULL)', + ]; +} + class WorkoutTelemetryAggregates extends Table { @override String get tableName => 'workout_telemetry_aggregates'; diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index c0e2102..258eaa9 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -43,6 +43,7 @@ final class ExerciseLibraryScreen extends StatefulWidget { final class _ExerciseLibraryScreenState extends State { final _searchController = TextEditingController(); + var _selectedBusinessTypes = {}; var _selectedMeasures = {}; var _selectedTags = {}; late Future> _exercises; @@ -84,6 +85,7 @@ final class _ExerciseLibraryScreenState extends State { } final exercises = snapshot.data ?? const []; + final typeFilterOptions = _typeFilterOptionsFor(exercises); final tagSuggestions = tagSuggestionsFor( exercises, (exercise) => exercise.tags, @@ -110,6 +112,41 @@ final class _ExerciseLibraryScreenState extends State { ), ), const SizedBox(height: 12), + if (typeFilterOptions.length > 1) ...[ + Text( + 'Type', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final type in typeFilterOptions) + FilterChip( + label: Text(_businessTypeFilterLabel(type)), + selected: _selectedBusinessTypes.contains( + type, + ), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedBusinessTypes = { + ..._selectedBusinessTypes, + type, + }; + } else { + _selectedBusinessTypes = { + ..._selectedBusinessTypes, + }..remove(type); + } + }); + }, + ), + ], + ), + const SizedBox(height: 12), + ], Wrap( spacing: 8, runSpacing: 8, @@ -169,7 +206,7 @@ final class _ExerciseLibraryScreenState extends State { child: _CenteredMessage( title: 'Aucun exercice ne correspond', message: - 'Modifie la recherche, les mesures ou les tags sélectionnés.', + 'Modifie la recherche, les types, les mesures ou les tags sélectionnés.', actionLabel: 'Effacer les filtres', onAction: _clearFilters, ), @@ -206,7 +243,15 @@ final class _ExerciseLibraryScreenState extends State { final matchesMeasures = _selectedMeasures.isEmpty || _selectedMeasures.every(exercise.availableMeasures.contains); - return matchesQuery && matchesMeasures; + final matchesTypes = + _selectedBusinessTypes.isEmpty || + _selectedBusinessTypes.any((type) { + final effectiveTypes = exercise.effectiveBusinessTypes; + return type == null + ? effectiveTypes.isEmpty + : effectiveTypes.contains(type); + }); + return matchesQuery && matchesTypes && matchesMeasures; }).toList(); return filterByRequiredTags( matchingQueryAndMeasures, @@ -217,6 +262,7 @@ final class _ExerciseLibraryScreenState extends State { bool get _filtersActive { return _searchController.text.trim().isNotEmpty || + _selectedBusinessTypes.isNotEmpty || _selectedMeasures.isNotEmpty || _selectedTags.isNotEmpty; } @@ -234,6 +280,7 @@ final class _ExerciseLibraryScreenState extends State { void _clearFilters() { setState(() { _searchController.clear(); + _selectedBusinessTypes = {}; _selectedMeasures = {}; _selectedTags = {}; }); @@ -314,6 +361,27 @@ final class _ExerciseLibraryScreenState extends State { } } +List _typeFilterOptionsFor(List exercises) { + final options = [ + for (final type in BusinessExerciseType.values) + if (exercises.any((exercise) { + return exercise.effectiveBusinessTypes.contains(type); + })) + type, + ]; + final hasUntypedExercise = exercises.any( + (exercise) => exercise.effectiveBusinessTypes.isEmpty, + ); + if (hasUntypedExercise) { + options.add(null); + } + return options; +} + +String _businessTypeFilterLabel(BusinessExerciseType? type) { + return type == null ? 'Libre / autre' : type.label; +} + final class ExerciseListTile extends StatelessWidget { const ExerciseListTile({ required this.exercise, diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index d78c808..0bc0a23 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -391,14 +391,6 @@ final class _HistoryWatchStatsSummary extends StatelessWidget { ), ], ), - if (averageHeartRate != null && maxHeartRate != null) ...[ - const SizedBox(height: 10), - _HistoryWatchStatsBars( - minHeartRateBpm: minHeartRate, - averageHeartRateBpm: averageHeartRate, - maxHeartRateBpm: maxHeartRate, - ), - ], ], if (distance != null || calories != null) ...[ const SizedBox(height: 12), @@ -429,7 +421,9 @@ final class _HistoryWatchStatsSummary extends StatelessWidget { } } -enum _TelemetryScope { session, exercise, set, step } +enum _TelemetryScope { step, set, exercise, session } + +enum _TelemetryMetric { heartRate, distance, calories } final class _HistoryWatchTelemetryGraph extends StatefulWidget { const _HistoryWatchTelemetryGraph({ @@ -448,96 +442,214 @@ final class _HistoryWatchTelemetryGraph extends StatefulWidget { final class _HistoryWatchTelemetryGraphState extends State<_HistoryWatchTelemetryGraph> { _TelemetryScope _scope = _TelemetryScope.session; + _TelemetryMetric _metric = _TelemetryMetric.heartRate; + int? _selectedExerciseOrdinal; + int? _selectedSetOrdinal; + int? _selectedStepOrdinal; @override Widget build(BuildContext context) { - return FutureBuilder>( - future: widget.telemetryUseCases.listSamplesForHistory(widget.history), + final tokens = courtBlazerTokensOf(context); + return FutureBuilder<_TelemetryGraphState>( + future: _loadGraphState(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const SizedBox( - height: 120, + height: 280, child: Center(child: CircularProgressIndicator()), ); } - final samples = snapshot.data ?? const []; - if (samples.isEmpty) { - return const _HistoryWatchMetric( - label: 'Courbe FC', - value: 'Donnée indisponible', - ); - } - final scopedSamples = _samplesForTelemetryScope(samples, _scope); - final heartRatePoints = scopedSamples - .where((sample) => sample.heartRateBpm != null) - .toList(); - final distance = _scopeDelta( - scopedSamples - .map((sample) => sample.distanceMeters) - .whereType(), - ); - final calories = _scopeDelta( - scopedSamples - .map((sample) => sample.caloriesKcal) - .whereType(), + final state = snapshot.data ?? _TelemetryGraphState.empty(_scope); + final metric = _metricSpec(_metric); + final metricPoints = state.points + .where((point) => metric.read(point) != null) + .toList(growable: false); + final displayValue = _displayValueForMetric( + state, + metric, + metricPoints, ); + final selectedExercise = state.selectedExercise; + final selectedSet = state.selectedSet; + final selectedStep = state.selectedStep; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SegmentedButton<_TelemetryScope>( - showSelectedIcon: false, - segments: const [ - ButtonSegment( - value: _TelemetryScope.session, - label: Text('Séance'), - ), - ButtonSegment( - value: _TelemetryScope.exercise, - label: Text('Exercice'), - ), - ButtonSegment(value: _TelemetryScope.set, label: Text('Série')), - ButtonSegment( - value: _TelemetryScope.step, - label: Text('Étape'), - ), - ], - selected: {_scope}, - onSelectionChanged: (selection) => - setState(() => _scope = selection.single), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton<_TelemetryMetric>( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: _TelemetryMetric.heartRate, + label: Text('FC'), + ), + ButtonSegment( + value: _TelemetryMetric.distance, + label: Text('Distance'), + ), + ButtonSegment( + value: _TelemetryMetric.calories, + label: Text('Calories'), + ), + ], + selected: {_metric}, + onSelectionChanged: (selection) => + setState(() => _metric = selection.single), + ), ), const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton<_TelemetryScope>( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: _TelemetryScope.session, + label: Text('Séance'), + ), + ButtonSegment( + value: _TelemetryScope.exercise, + label: Text('Exercice'), + ), + ButtonSegment( + value: _TelemetryScope.set, + label: Text('Série'), + ), + ButtonSegment( + value: _TelemetryScope.step, + label: Text('Étape'), + ), + ], + selected: {_scope}, + onSelectionChanged: (selection) { + setState(() { + _scope = selection.single; + _selectedExerciseOrdinal = null; + _selectedSetOrdinal = null; + _selectedStepOrdinal = null; + }); + }, + ), + ), + if (_scope != _TelemetryScope.session) ...[ + const SizedBox(height: 12), + _TelemetryInstanceSelectors( + scope: _scope, + exerciseInstances: state.exerciseInstances, + setInstances: state.setInstancesForSelectedExercise, + stepInstances: state.stepInstancesForSelectedSet, + selectedExercise: selectedExercise, + selectedSet: selectedSet, + selectedStep: selectedStep, + onExerciseChanged: (descriptor) { + setState(() { + _selectedExerciseOrdinal = descriptor.ordinal; + _selectedSetOrdinal = null; + _selectedStepOrdinal = null; + }); + }, + onSetChanged: (descriptor) { + setState(() { + _selectedSetOrdinal = descriptor.ordinal; + _selectedStepOrdinal = null; + }); + }, + onStepChanged: (descriptor) { + setState(() { + _selectedStepOrdinal = descriptor.ordinal; + }); + }, + ), + ], + const SizedBox(height: 12), + Text( + '${metric.title} · ${state.scopeLabel} · ' + '${_formatDurationMs(state.durationMs)}', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), SizedBox( - height: 160, - child: heartRatePoints.length < 2 - ? const Center(child: Text('Mesure en attente')) - : CustomPaint( + height: 220, + child: metricPoints.length < 2 + ? DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: tokens.border), + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + 'Aucune donnée exploitable pour ' + '${metric.title.toLowerCase()} sur ce scope.', + textAlign: TextAlign.center, + ), + ), + ) + : Semantics( + label: + 'Graphique ${metric.title}, temps en abscisse, ' + '${metric.unit} en ordonnée', key: const ValueKey('history-watch-stats-graph'), - painter: _HeartRateTelemetryPainter( - samples: heartRatePoints, - color: Theme.of(context).colorScheme.primary, + child: CustomPaint( + painter: _TelemetryChartPainter( + points: metricPoints, + readValue: metric.read, + valueFormatter: metric.axisFormat, + unit: metric.unit, + color: Theme.of(context).colorScheme.primary, + borderColor: tokens.border, + labelColor: tokens.mutedText, + minGuide: _metric == _TelemetryMetric.heartRate + ? state.minHeartRateBpm?.toDouble() + : null, + maxGuide: _metric == _TelemetryMetric.heartRate + ? state.maxHeartRateBpm?.toDouble() + : null, + markers: state.markers, + ), ), ), ), - const SizedBox(height: 8), - Row( + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 8, children: [ - Expanded( - child: _HistoryWatchMetric( - label: 'Distance', - value: distance == null - ? 'Donnée indisponible' - : _formatHistoryDistance(distance), - ), + _HistoryWatchCompactIndicator( + label: 'Période', + value: _formatDurationMs(state.durationMs), ), - const SizedBox(width: 8), - Expanded( - child: _HistoryWatchMetric( - label: 'Calories', - value: calories == null - ? 'Donnée indisponible' - : '${calories.round()} kcal', - ), + _HistoryWatchCompactIndicator( + label: 'Focale', + value: state.scopeLabel, ), + _HistoryWatchCompactIndicator( + label: metric.compactLabel, + value: displayValue == null + ? 'Indisponible' + : metric.format(displayValue), + ), + if (_metric == _TelemetryMetric.heartRate && + state.minHeartRateBpm != null) + _HistoryWatchCompactIndicator( + label: 'Min', + value: '${state.minHeartRateBpm} bpm', + ), + if (_metric == _TelemetryMetric.heartRate && + state.maxHeartRateBpm != null) + _HistoryWatchCompactIndicator( + label: 'Max', + value: '${state.maxHeartRateBpm} bpm', + ), + _HistoryWatchCompactIndicator( + label: 'Points', + value: state.scopeIndicator, + ), + if (state.markers.isNotEmpty) + _HistoryWatchCompactIndicator( + label: 'Repères', + value: '${state.markers.length}', + ), ], ), ], @@ -545,94 +657,455 @@ final class _HistoryWatchTelemetryGraphState }, ); } -} -List _samplesForTelemetryScope( - List samples, - _TelemetryScope scope, -) { - int? firstNonNull(int? Function(WorkoutTelemetrySample sample) read) { - for (final sample in samples) { - final value = read(sample); - if (value != null) return value; - } - return null; - } + Future<_TelemetryGraphState> _loadGraphState() async { + final aggregateScope = _aggregateScope(_scope); + final exerciseInstances = await widget.telemetryUseCases + .listScopeInstancesForHistory( + history: widget.history, + scope: WorkoutTelemetryAggregateScope.exercise, + ); + final setInstances = await widget.telemetryUseCases + .listScopeInstancesForHistory( + history: widget.history, + scope: WorkoutTelemetryAggregateScope.set, + ); + final stepInstances = await widget.telemetryUseCases + .listScopeInstancesForHistory( + history: widget.history, + scope: WorkoutTelemetryAggregateScope.step, + ); - final exercise = firstNonNull((sample) => sample.exerciseIndex); - final set = firstNonNull((sample) => sample.setIndex); - final step = firstNonNull((sample) => sample.stepIndex); - return samples.where((sample) { - return switch (scope) { - _TelemetryScope.session => true, - _TelemetryScope.exercise => sample.exerciseIndex == exercise, - _TelemetryScope.set => - sample.exerciseIndex == exercise && sample.setIndex == set, - _TelemetryScope.step => - sample.exerciseIndex == exercise && - sample.setIndex == set && - sample.stepIndex == step, + final selectedExercise = _selectedOrFirst( + exerciseInstances, + _selectedExerciseOrdinal, + ); + final selectedSet = _selectedOrFirst( + _filterChildren( + setInstances, + programIndex: selectedExercise?.programIndex, + exerciseIndex: selectedExercise?.exerciseIndex, + ), + _selectedSetOrdinal, + ); + final selectedStep = _selectedOrFirst( + _filterChildren( + stepInstances, + programIndex: selectedExercise?.programIndex, + exerciseIndex: selectedExercise?.exerciseIndex, + setIndex: selectedSet?.setIndex, + ), + _selectedStepOrdinal, + ); + final selectedInstance = switch (_scope) { + _TelemetryScope.session => null, + _TelemetryScope.exercise => selectedExercise, + _TelemetryScope.set => selectedSet, + _TelemetryScope.step => selectedStep, }; - }).toList(); -} - -double? _scopeDelta(Iterable values) { - final list = values.toList(); - if (list.isEmpty) return null; - return math.max(0, list.last - list.first); -} - -final class _HeartRateTelemetryPainter extends CustomPainter { - const _HeartRateTelemetryPainter({ - required this.samples, - required this.color, - }); - - final List samples; - final Color color; - - @override - void paint(Canvas canvas, Size size) { - final values = samples - .map((sample) => (at: sample.capturedAt, bpm: sample.heartRateBpm!)) - .toList(); - final minTime = values.first.at; - final maxTime = values.last.at.isAfter(minTime) - ? values.last.at - : minTime.add(const Duration(seconds: 1)); - final minBpm = values.map((value) => value.bpm).reduce(math.min); - final maxBpm = values.map((value) => value.bpm).reduce(math.max); - final chart = Rect.fromLTWH(0, 8, size.width, size.height - 16); - final range = math.max(1, maxBpm - minBpm); - Offset pointFor(({DateTime at, int bpm}) value) { - final xRatio = - value.at.difference(minTime).inMilliseconds / - maxTime.difference(minTime).inMilliseconds; - final yRatio = (value.bpm - minBpm) / range; - return Offset( - chart.left + chart.width * xRatio.clamp(0, 1), - chart.bottom - chart.height * yRatio.clamp(0, 1), + if (_scope != _TelemetryScope.session && selectedInstance == null) { + return _TelemetryGraphState.empty( + _scope, + exerciseInstances: exerciseInstances, + setInstances: setInstances, + stepInstances: stepInstances, ); } - - final paint = Paint() - ..color = color - ..strokeWidth = 2; - for (var index = 1; index < values.length; index += 1) { - final start = pointFor(values[index - 1]); - final end = pointFor(values[index]); - if (values[index].at.difference(values[index - 1].at) > - const Duration(minutes: 2)) { - _drawDashedLine(canvas, start, end, paint); - } else { - canvas.drawLine(start, end, paint); - } - } + final series = await widget.telemetryUseCases.readGraphSeriesForHistory( + history: widget.history, + scope: aggregateScope, + programIndex: selectedInstance?.programIndex, + exerciseIndex: selectedInstance?.exerciseIndex, + setIndex: selectedInstance?.setIndex, + passageIndex: selectedInstance?.passageIndex, + stepIndex: selectedInstance?.stepIndex, + ); + final markers = await widget.telemetryUseCases.readScopeMarkersForHistory( + history: widget.history, + scope: aggregateScope, + programIndex: selectedInstance?.programIndex, + exerciseIndex: selectedInstance?.exerciseIndex, + setIndex: selectedInstance?.setIndex, + passageIndex: selectedInstance?.passageIndex, + stepIndex: selectedInstance?.stepIndex, + ); + return _TelemetryGraphState.fromSeries( + series, + markers: markers, + exerciseInstances: exerciseInstances, + setInstances: setInstances, + stepInstances: stepInstances, + selectedExercise: selectedExercise, + selectedSet: selectedSet, + selectedStep: selectedStep, + ); } +} + +final class _TelemetryInstanceSelectors extends StatelessWidget { + const _TelemetryInstanceSelectors({ + required this.scope, + required this.exerciseInstances, + required this.setInstances, + required this.stepInstances, + required this.selectedExercise, + required this.selectedSet, + required this.selectedStep, + required this.onExerciseChanged, + required this.onSetChanged, + required this.onStepChanged, + }); + + final _TelemetryScope scope; + final List exerciseInstances; + final List setInstances; + final List stepInstances; + final ScopeInstanceDescriptor? selectedExercise; + final ScopeInstanceDescriptor? selectedSet; + final ScopeInstanceDescriptor? selectedStep; + final ValueChanged onExerciseChanged; + final ValueChanged onSetChanged; + final ValueChanged onStepChanged; @override - bool shouldRepaint(covariant _HeartRateTelemetryPainter oldDelegate) => - oldDelegate.samples != samples || oldDelegate.color != color; + Widget build(BuildContext context) { + final children = [ + _TelemetryInstanceDropdown( + label: 'Exercice', + instances: exerciseInstances, + selected: selectedExercise, + format: (instance) => 'Ex. ${instance.ordinal}', + onChanged: onExerciseChanged, + ), + ]; + if (scope == _TelemetryScope.set || scope == _TelemetryScope.step) { + children.add( + _TelemetryInstanceDropdown( + label: 'Série', + instances: setInstances, + selected: selectedSet, + format: (instance) => 'Série ${instance.ordinal}', + onChanged: onSetChanged, + ), + ); + } + if (scope == _TelemetryScope.step) { + children.add( + _TelemetryInstanceDropdown( + label: 'Étape', + instances: stepInstances, + selected: selectedStep, + format: (instance) => 'Étape ${instance.ordinal}', + onChanged: onStepChanged, + ), + ); + } + return Wrap(spacing: 8, runSpacing: 8, children: children); + } +} + +final class _TelemetryInstanceDropdown extends StatelessWidget { + const _TelemetryInstanceDropdown({ + required this.label, + required this.instances, + required this.selected, + required this.format, + required this.onChanged, + }); + + final String label; + final List instances; + final ScopeInstanceDescriptor? selected; + final String Function(ScopeInstanceDescriptor instance) format; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 150, + child: DropdownButtonFormField( + key: ValueKey( + '$label:${selected?.ordinal}:' + '${instances.map((instance) => instance.ordinal).join(',')}', + ), + isExpanded: true, + decoration: InputDecoration( + labelText: label, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + initialValue: selected?.ordinal, + items: [ + for (final instance in instances) + DropdownMenuItem( + value: instance.ordinal, + child: Text(format(instance), overflow: TextOverflow.ellipsis), + ), + ], + onChanged: instances.isEmpty + ? null + : (ordinal) { + final instance = instances.firstWhere( + (entry) => entry.ordinal == ordinal, + orElse: () => instances.first, + ); + onChanged(instance); + }, + ), + ); + } +} + +ScopeInstanceDescriptor? _selectedOrFirst( + List instances, + int? selectedOrdinal, +) { + if (instances.isEmpty) { + return null; + } + for (final instance in instances) { + if (instance.ordinal == selectedOrdinal) { + return instance; + } + } + return instances.first; +} + +List _filterChildren( + List instances, { + int? programIndex, + int? exerciseIndex, + int? setIndex, +}) { + if (programIndex == null || exerciseIndex == null) { + return const []; + } + return instances + .where( + (instance) => + instance.programIndex == programIndex && + instance.exerciseIndex == exerciseIndex && + (setIndex == null || instance.setIndex == setIndex), + ) + .toList(growable: false); +} + +WorkoutTelemetryAggregateScope _aggregateScope(_TelemetryScope scope) { + return switch (scope) { + _TelemetryScope.step => WorkoutTelemetryAggregateScope.step, + _TelemetryScope.set => WorkoutTelemetryAggregateScope.set, + _TelemetryScope.exercise => WorkoutTelemetryAggregateScope.exercise, + _TelemetryScope.session => WorkoutTelemetryAggregateScope.session, + }; +} + +_TelemetryScope _presentationScope(WorkoutTelemetryAggregateScope scope) { + return switch (scope) { + WorkoutTelemetryAggregateScope.step => _TelemetryScope.step, + WorkoutTelemetryAggregateScope.set => _TelemetryScope.set, + WorkoutTelemetryAggregateScope.exercise => _TelemetryScope.exercise, + WorkoutTelemetryAggregateScope.session => _TelemetryScope.session, + }; +} + +String _scopeLabel(_TelemetryScope scope) { + return switch (scope) { + _TelemetryScope.step => 'Étape', + _TelemetryScope.set => 'Série', + _TelemetryScope.exercise => 'Exercice', + _TelemetryScope.session => 'Séance', + }; +} + +final class _TelemetryGraphState { + const _TelemetryGraphState({ + required this.scope, + required this.points, + required this.markers, + required this.exerciseInstances, + required this.setInstances, + required this.stepInstances, + this.selectedExercise, + this.selectedSet, + this.selectedStep, + this.minHeartRateBpm, + this.averageHeartRateBpm, + this.maxHeartRateBpm, + }); + + factory _TelemetryGraphState.empty( + _TelemetryScope scope, { + List exerciseInstances = const [], + List setInstances = const [], + List stepInstances = const [], + }) { + return _TelemetryGraphState( + scope: _aggregateScope(scope), + points: const [], + markers: const [], + exerciseInstances: exerciseInstances, + setInstances: setInstances, + stepInstances: stepInstances, + ); + } + + factory _TelemetryGraphState.fromSeries( + WorkoutTelemetryGraphSeries series, { + required List markers, + required List exerciseInstances, + required List setInstances, + required List stepInstances, + ScopeInstanceDescriptor? selectedExercise, + ScopeInstanceDescriptor? selectedSet, + ScopeInstanceDescriptor? selectedStep, + }) { + final heartRates = series.points + .map((point) => point.heartRateBpm) + .whereType() + .toList(growable: false); + return _TelemetryGraphState( + scope: series.scope, + points: series.points, + markers: markers, + exerciseInstances: exerciseInstances, + setInstances: setInstances, + stepInstances: stepInstances, + selectedExercise: selectedExercise, + selectedSet: selectedSet, + selectedStep: selectedStep, + minHeartRateBpm: series.minHeartRateBpm, + averageHeartRateBpm: heartRates.isEmpty + ? null + : heartRates.reduce((left, right) => left + right) / + heartRates.length, + maxHeartRateBpm: series.maxHeartRateBpm, + ); + } + + final WorkoutTelemetryAggregateScope scope; + final List points; + final List markers; + final List exerciseInstances; + final List setInstances; + final List stepInstances; + final ScopeInstanceDescriptor? selectedExercise; + final ScopeInstanceDescriptor? selectedSet; + final ScopeInstanceDescriptor? selectedStep; + final int? minHeartRateBpm; + final double? averageHeartRateBpm; + final int? maxHeartRateBpm; + + List get setInstancesForSelectedExercise { + return _filterChildren( + setInstances, + programIndex: selectedExercise?.programIndex, + exerciseIndex: selectedExercise?.exerciseIndex, + ); + } + + List get stepInstancesForSelectedSet { + return _filterChildren( + stepInstances, + programIndex: selectedExercise?.programIndex, + exerciseIndex: selectedExercise?.exerciseIndex, + setIndex: selectedSet?.setIndex, + ); + } + + String get scopeLabel { + final label = _scopeLabel(_presentationScope(scope)); + final instance = switch (scope) { + WorkoutTelemetryAggregateScope.session => null, + WorkoutTelemetryAggregateScope.exercise => selectedExercise, + WorkoutTelemetryAggregateScope.set => selectedSet, + WorkoutTelemetryAggregateScope.step => selectedStep, + }; + if (instance == null) { + return label; + } + return '$label ${instance.ordinal}'; + } + + int get durationMs { + if (points.isEmpty) return 0; + return points.last.elapsedMs.clamp(0, double.infinity).toInt(); + } + + String get scopeIndicator { + final parts = []; + if (points.any((point) => point.heartRateBpm != null)) parts.add('FC'); + if (points.any((point) => point.distanceMeters != null)) { + parts.add('distance'); + } + if (points.any((point) => point.caloriesKcal != null)) { + parts.add('calories'); + } + return parts.isEmpty + ? '0 point' + : '${points.length} pts · ${parts.join(' + ')}'; + } +} + +final class _TelemetryMetricSpec { + const _TelemetryMetricSpec({ + required this.title, + required this.compactLabel, + required this.unit, + required this.read, + required this.format, + required this.axisFormat, + }); + + final String title; + final String compactLabel; + final String unit; + final double? Function(WorkoutTelemetryGraphPoint point) read; + final String Function(double value) format; + final String Function(double value) axisFormat; +} + +_TelemetryMetricSpec _metricSpec(_TelemetryMetric metric) { + return switch (metric) { + _TelemetryMetric.heartRate => _TelemetryMetricSpec( + title: 'Fréquence cardiaque', + compactLabel: 'Moyenne', + unit: 'bpm', + read: (point) => point.heartRateBpm?.toDouble(), + format: (value) => '${value.round()} bpm', + axisFormat: (value) => value.round().toString(), + ), + _TelemetryMetric.distance => _TelemetryMetricSpec( + title: 'Distance', + compactLabel: 'Distance', + unit: 'm', + read: (point) => point.distanceMeters, + format: _formatHistoryDistance, + axisFormat: (value) => value.round().toString(), + ), + _TelemetryMetric.calories => _TelemetryMetricSpec( + title: 'Calories', + compactLabel: 'Calories', + unit: 'kcal', + read: (point) => point.caloriesKcal, + format: (value) => '${value.round()} kcal', + axisFormat: (value) => value.round().toString(), + ), + }; +} + +double? _displayValueForMetric( + _TelemetryGraphState state, + _TelemetryMetricSpec metric, + List points, +) { + if (points.isEmpty) return null; + if (metric.unit == 'bpm' && state.averageHeartRateBpm != null) { + return state.averageHeartRateBpm; + } + return metric.read(points.last); } void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) { @@ -654,69 +1127,247 @@ void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) { } } -final class _HistoryWatchStatsBars extends StatelessWidget { - const _HistoryWatchStatsBars({ - required this.minHeartRateBpm, - required this.averageHeartRateBpm, - required this.maxHeartRateBpm, +final class _HistoryWatchCompactIndicator extends StatelessWidget { + const _HistoryWatchCompactIndicator({ + required this.label, + required this.value, }); - final int? minHeartRateBpm; - final double averageHeartRateBpm; - final int maxHeartRateBpm; + final String label; + final String value; @override Widget build(BuildContext context) { - final max = maxHeartRateBpm.toDouble().clamp(1, double.infinity); - final values = [ - if (minHeartRateBpm != null) ('Min', minHeartRateBpm!.toDouble()), - ('Moy', averageHeartRateBpm), - ('Max', maxHeartRateBpm.toDouble()), - ]; - return Semantics( - label: 'Graphique stats montre', - key: const ValueKey('history-watch-stats-graph'), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - for (final item in values) ...[ - Expanded( - child: Column( - children: [ - SizedBox( - height: 52, - child: Align( - alignment: Alignment.bottomCenter, - child: FractionallySizedBox( - heightFactor: (item.$2 / max).clamp(0.08, 1), - widthFactor: 0.62, - child: DecoratedBox( - decoration: BoxDecoration( - color: const Color(0xFFC9A24A), - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ), - ), - const SizedBox(height: 4), - Text( - item.$1, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ), + final tokens = courtBlazerTokensOf(context); + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: tokens.border), + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: tokens.mutedText), ), - if (item != values.last) const SizedBox(width: 8), + Text(value, style: Theme.of(context).textTheme.labelMedium), ], - ], + ), ), ); } } +final class _TelemetryChartPainter extends CustomPainter { + const _TelemetryChartPainter({ + required this.points, + required this.readValue, + required this.valueFormatter, + required this.unit, + required this.color, + required this.borderColor, + required this.labelColor, + required this.markers, + this.minGuide, + this.maxGuide, + }); + + final List points; + final double? Function(WorkoutTelemetryGraphPoint point) readValue; + final String Function(double value) valueFormatter; + final String unit; + final Color color; + final Color borderColor; + final Color labelColor; + final List markers; + final double? minGuide; + final double? maxGuide; + + @override + void paint(Canvas canvas, Size size) { + final values = points + .map((point) => (point: point, value: readValue(point))) + .where((entry) => entry.value != null) + .map((entry) => (point: entry.point, value: entry.value!)) + .toList(growable: false); + if (values.length < 2) return; + + final textPainter = TextPainter(textDirection: TextDirection.ltr); + final labelStyle = TextStyle(color: labelColor, fontSize: 10); + final chart = Rect.fromLTRB(58, 14, size.width - 8, size.height - 32); + final maxElapsed = math.max(1, values.last.point.elapsedMs); + final rawMin = values.map((entry) => entry.value).reduce(math.min); + final rawMax = values.map((entry) => entry.value).reduce(math.max); + final guideValues = [?minGuide, ?maxGuide]; + final guideMin = guideValues.isEmpty + ? rawMin + : guideValues.reduce(math.min); + final guideMax = guideValues.isEmpty + ? rawMax + : guideValues.reduce(math.max); + final minValue = math.min(rawMin, guideMin); + final maxValue = math.max(rawMax, guideMax); + final range = math.max(1, maxValue - minValue); + + Offset pointFor(WorkoutTelemetryGraphPoint point, double value) { + final xRatio = point.elapsedMs / maxElapsed; + final yRatio = (value - minValue) / range; + return Offset( + chart.left + chart.width * xRatio.clamp(0, 1), + chart.bottom - chart.height * yRatio.clamp(0, 1), + ); + } + + final axisPaint = Paint() + ..color = borderColor + ..strokeWidth = 1; + canvas.drawLine(chart.bottomLeft, chart.bottomRight, axisPaint); + canvas.drawLine(chart.bottomLeft, chart.topLeft, axisPaint); + + for (final tick in [0.0, 0.5, 1.0]) { + final y = chart.bottom - chart.height * tick; + canvas.drawLine( + Offset(chart.left - 3, y), + Offset(chart.right, y), + axisPaint, + ); + final value = minValue + range * tick; + _paintText( + canvas, + textPainter, + '${valueFormatter(value)} $unit', + Offset(0, y - 7), + labelStyle, + ); + } + for (final tick in [0.0, 0.5, 1.0]) { + final x = chart.left + chart.width * tick; + canvas.drawLine( + Offset(x, chart.bottom), + Offset(x, chart.bottom + 3), + axisPaint, + ); + _paintText( + canvas, + textPainter, + _formatAxisDuration((maxElapsed * tick).round()), + Offset(x - 14, chart.bottom + 6), + labelStyle, + ); + } + _paintText( + canvas, + textPainter, + 'temps m:ss', + Offset(math.max(chart.left, chart.right - 56), size.height - 14), + labelStyle, + ); + _paintText( + canvas, + textPainter, + unit, + Offset(chart.left + 2, 0), + labelStyle, + ); + + final guidePaint = Paint() + ..color = color.withAlpha(140) + ..strokeWidth = 1; + for (final guide in guideValues) { + final y = pointFor(values.first.point, guide).dy; + _drawDashedLine( + canvas, + Offset(chart.left, y), + Offset(chart.right, y), + guidePaint, + ); + } + + if (markers.isNotEmpty) { + final markerPaint = Paint() + ..color = labelColor.withAlpha(110) + ..strokeWidth = 1; + for (final marker in markers) { + final xRatio = marker.elapsedMs / maxElapsed; + final x = chart.left + chart.width * xRatio.clamp(0, 1); + _drawDashedLine( + canvas, + Offset(x, chart.top), + Offset(x, chart.bottom), + markerPaint, + ); + final labelOffset = marker.boundary == ScopeMarkerBoundary.start + ? Offset( + (x + 2).clamp(chart.left, chart.right - 44).toDouble(), + chart.top, + ) + : Offset( + (x - 42).clamp(chart.left, chart.right - 44).toDouble(), + chart.top + 12, + ); + _paintText(canvas, textPainter, marker.label, labelOffset, labelStyle); + } + } + + final linePaint = Paint() + ..color = color + ..strokeWidth = 2 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + for (var index = 1; index < values.length; index += 1) { + final previous = values[index - 1]; + final current = values[index]; + final start = pointFor(previous.point, previous.value); + final end = pointFor(current.point, current.value); + if (current.point.startsAfterGap) { + _drawDashedLine(canvas, start, end, linePaint); + } else { + canvas.drawLine(start, end, linePaint); + } + } + } + + @override + bool shouldRepaint(covariant _TelemetryChartPainter oldDelegate) { + return oldDelegate.points != points || + oldDelegate.color != color || + oldDelegate.borderColor != borderColor || + oldDelegate.labelColor != labelColor || + oldDelegate.minGuide != minGuide || + oldDelegate.maxGuide != maxGuide || + oldDelegate.markers != markers; + } +} + +void _paintText( + Canvas canvas, + TextPainter textPainter, + String value, + Offset offset, + TextStyle style, +) { + textPainter + ..text = TextSpan(text: value, style: style) + ..layout(maxWidth: 72); + textPainter.paint(canvas, offset); +} + +String _formatAxisDuration(int milliseconds) { + final duration = Duration(milliseconds: milliseconds); + if (duration.inHours > 0) { + return '${duration.inHours}:' + '${duration.inMinutes.remainder(60).toString().padLeft(2, '0')}'; + } + return '${duration.inMinutes}:' + '${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}'; +} + final class _HistoryWatchMetric extends StatelessWidget { const _HistoryWatchMetric({required this.label, required this.value}); diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index b421384..860969f 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -2332,7 +2332,7 @@ final class _LiveSensorBar extends StatelessWidget { _LiveSensorPill( icon: Icons.directions_run, label: _distanceMetricLabel(sensorState), - muted: _distanceLabel(sensorState) == null, + muted: !_isDistanceAvailable(sensorState), ), _LiveSensorPill( icon: Icons.local_fire_department, @@ -5332,6 +5332,9 @@ String _heartRateMetricLabel(ActiveWorkoutSensorState? state) { } String _distanceMetricLabel(ActiveWorkoutSensorState? state) { + if (!_isDistanceAvailable(state)) { + return _sensorUnavailableLabel(state); + } return _distanceLabel(state) ?? _sensorUnavailableLabel(state); } @@ -5357,6 +5360,12 @@ String? _distanceLabel(ActiveWorkoutSensorState? state) { return '${meters.round()} m'; } +bool _isDistanceAvailable(ActiveWorkoutSensorState? state) { + return state != null && + state.latestDistanceAvailable && + _distanceLabel(state) != null; +} + String? _caloriesLabel(ActiveWorkoutSensorState? state) { final actualCalories = state?.latestCaloriesKcal; if (actualCalories != null && actualCalories >= 0) { diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 266e0ed..884cfbf 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -3287,6 +3287,50 @@ void main() { }, ); + test( + 'ActiveWorkoutSensorUseCases marks distance unavailable when the latest sample omits it', + () async { + final useCase = ActiveWorkoutSensorUseCases( + clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)), + ); + + final withDistance = useCase.recordTelemetrySample( + WatchTelemetrySample( + sampleId: 'sample-1', + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 25, + 12, + ).millisecondsSinceEpoch, + heartRateBpm: 120, + distanceMeters: 500, + ), + ); + final withoutDistance = useCase.recordTelemetrySample( + WatchTelemetrySample( + sampleId: 'sample-2', + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 25, + 12, + 1, + ).millisecondsSinceEpoch, + heartRateBpm: 124, + ), + ); + + expect(withDistance?.latestDistanceMeters, 500); + expect(withDistance?.latestDistanceAvailable, isTrue); + expect(withoutDistance?.latestDistanceMeters, 500); + expect(withoutDistance?.latestDistanceAvailable, isFalse); + await useCase.dispose(); + }, + ); + test( 'WorkoutTelemetryUseCases persists samples and aggregates by scope', () async { @@ -3335,6 +3379,10 @@ void main() { 0, 10, ).millisecondsSinceEpoch, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + stepIndex: 0, heartRateBpm: 160, distanceMeters: 530, caloriesKcal: 45, @@ -3378,20 +3426,42 @@ void main() { caloriesKcal: 48, ), ); + final third = await useCase.recordTelemetrySample( + WatchTelemetrySample( + sampleId: 'sample-4', + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 28, + 10, + 0, + 40, + ).millisecondsSinceEpoch, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + stepIndex: 0, + heartRateBpm: 155, + distanceMeters: 650, + caloriesKcal: 51, + ), + ); expect(ignored, isEmpty); - expect(first.map((aggregate) => aggregate.scope), [ + expect(first, isEmpty); + expect(duplicate, isEmpty); + expect(olderInSameBucket, isEmpty); + expect(second.map((aggregate) => aggregate.scope), [ WorkoutTelemetryAggregateScope.session, WorkoutTelemetryAggregateScope.exercise, WorkoutTelemetryAggregateScope.set, WorkoutTelemetryAggregateScope.step, ]); - expect(duplicate, isNotEmpty); - expect(olderInSameBucket, isEmpty); expect(repository.samples, hasLength(2)); expect(repository.samples.first.heartRateBpm, 160); - final sessionAggregate = second.singleWhere( + final sessionAggregate = third.singleWhere( (aggregate) => aggregate.scope == WorkoutTelemetryAggregateScope.session, ); @@ -3415,6 +3485,69 @@ void main() { }, ); + test( + 'WorkoutTelemetryUseCases does not advance windows while paused', + () async { + final repository = _FakeWorkoutTelemetryRepository(); + final sessionRepository = _FakeActiveSessionRepository(); + final startedAt = DateTime.utc(2026, 7, 28, 10); + sessionRepository.session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + status: ActiveWorkoutStatus.running, + startedAt: startedAt, + lastPersistedAt: startedAt, + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: '{"programs":[]}', + ); + final useCase = WorkoutTelemetryUseCases( + repository: repository, + sessionRepository: sessionRepository, + clock: _FakeClock(startedAt), + ids: _FakeIds(), + ); + + await useCase.recordTelemetrySample( + WatchTelemetrySample( + sessionId: 'session-1', + capturedAtEpochMs: startedAt.millisecondsSinceEpoch, + heartRateBpm: 120, + ), + ); + sessionRepository.session = sessionRepository.session!.pause( + startedAt.add(const Duration(seconds: 10)), + ); + await useCase.recordTelemetrySample( + WatchTelemetrySample( + sessionId: 'session-1', + capturedAtEpochMs: startedAt + .add(const Duration(minutes: 5)) + .millisecondsSinceEpoch, + heartRateBpm: 150, + ), + ); + sessionRepository.session = sessionRepository.session!.resume( + startedAt.add(const Duration(minutes: 5)), + ); + final aggregates = await useCase.recordTelemetrySample( + WatchTelemetrySample( + sessionId: 'session-1', + capturedAtEpochMs: startedAt + .add(const Duration(minutes: 5, seconds: 5)) + .millisecondsSinceEpoch, + heartRateBpm: 130, + ), + ); + + expect(aggregates, isNotEmpty); + expect(repository.samples, hasLength(1)); + expect(repository.samples.single.heartRateBpm, 120); + expect(repository.samples.single.capturedAt, startedAt); + }, + ); + test( 'WorkoutTelemetryUseCases reads graph samples by scope with relative cumulative metrics', () async { @@ -3488,6 +3621,133 @@ void main() { }, ); + test( + 'WorkoutTelemetryUseCases lists selected instances and real scope markers', + () async { + final repository = _FakeWorkoutTelemetryRepository(); + final useCase = WorkoutTelemetryUseCases( + repository: repository, + clock: _FakeClock(DateTime.utc(2026, 7, 28, 10)), + ids: _FakeIds(), + ); + final history = WorkoutHistory( + metadata: _metadata('history-telemetry-markers'), + nameSnapshot: 'Séance', + startedAt: DateTime.utc(2026, 7, 28, 10), + endedAt: DateTime.utc(2026, 7, 28, 11), + totalActiveMs: 3600000, + completed: true, + historySnapshotJson: jsonEncode({ + 'telemetrySamples': [ + { + 'id': 'sample-set-1-a', + 'sessionId': 'session-1', + 'capturedAt': DateTime.utc(2026, 7, 28, 10).toIso8601String(), + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'stepIndex': 0, + 'heartRateBpm': 120, + }, + { + 'id': 'sample-set-1-b', + 'sessionId': 'session-1', + 'capturedAt': DateTime.utc( + 2026, + 7, + 28, + 10, + 0, + 30, + ).toIso8601String(), + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'stepIndex': 1, + 'heartRateBpm': 130, + }, + { + 'id': 'sample-set-2-a', + 'sessionId': 'session-1', + 'capturedAt': DateTime.utc(2026, 7, 28, 10, 1).toIso8601String(), + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 1, + 'stepIndex': 0, + 'heartRateBpm': 140, + }, + { + 'id': 'sample-set-2-b', + 'sessionId': 'session-1', + 'capturedAt': DateTime.utc( + 2026, + 7, + 28, + 10, + 1, + 30, + ).toIso8601String(), + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 1, + 'stepIndex': 1, + 'heartRateBpm': 150, + }, + { + 'id': 'sample-exercise-2', + 'sessionId': 'session-1', + 'capturedAt': DateTime.utc(2026, 7, 28, 10, 2).toIso8601String(), + 'programIndex': 0, + 'exerciseIndex': 1, + 'setIndex': 0, + 'stepIndex': 0, + 'heartRateBpm': 110, + }, + ], + }), + ); + + final exerciseInstances = await useCase.listScopeInstancesForHistory( + history: history, + scope: WorkoutTelemetryAggregateScope.exercise, + ); + final setInstances = await useCase.listScopeInstancesForHistory( + history: history, + scope: WorkoutTelemetryAggregateScope.set, + ); + final markers = await useCase.readScopeMarkersForHistory( + history: history, + scope: WorkoutTelemetryAggregateScope.exercise, + programIndex: 0, + exerciseIndex: 0, + ); + + expect(exerciseInstances, hasLength(2)); + expect(exerciseInstances.first.exerciseIndex, 0); + expect(exerciseInstances.last.exerciseIndex, 1); + expect(setInstances, hasLength(3)); + expect(setInstances.take(2).map((instance) => instance.setIndex), [0, 1]); + expect(markers.map((marker) => marker.elapsedMs), [ + 0, + 30000, + 60000, + 90000, + ]); + expect(markers.map((marker) => marker.boundary), [ + ScopeMarkerBoundary.start, + ScopeMarkerBoundary.end, + ScopeMarkerBoundary.start, + ScopeMarkerBoundary.end, + ]); + expect(markers.map((marker) => marker.label), [ + 'Déb. série 1', + 'Fin série 1', + 'Déb. série 2', + 'Fin série 2', + ]); + }, + ); + test( 'WorkoutHistoryUseCases ignores insufficient heart rate summary', () async { @@ -5400,6 +5660,7 @@ final class _FakeWorkoutTelemetryRepository implements WorkoutTelemetryRepository { final samples = []; final aggregates = []; + final windowStates = {}; @override Future saveSample(WorkoutTelemetrySample sample) async { @@ -5416,6 +5677,23 @@ final class _FakeWorkoutTelemetryRepository return true; } + @override + Future findWindowState( + String sessionId, + ) async { + return windowStates[sessionId]; + } + + @override + Future saveWindowState(ActiveWorkoutTelemetryWindowState state) async { + windowStates[state.sessionId] = state; + } + + @override + Future deleteWindowState(String sessionId) async { + windowStates.remove(sessionId); + } + @override Future> listSamples(String sessionId) async { return samples diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 888ee93..8adf012 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -148,7 +148,7 @@ void main() { await columnNames('workout_telemetry_aggregates'), contains('sample_count'), ); - expect(database.schemaVersion, 25); + expect(database.schemaVersion, 26); }); test('exercise business types persist with category fallback', () async { @@ -499,7 +499,7 @@ CREATE TABLE pending_share_actions ( final inboxItems = await inboxRepository.listAll(); final pendingActions = await pendingRepository.listPending(); - expect(version.data['user_version'], 24); + expect(version.data['user_version'], 26); expect(inboxItems.map((item) => item.shareId), contains('share-program-1')); expect(inboxItems.map((item) => item.shareId), contains('share-pack-1')); expect( @@ -710,6 +710,23 @@ CREATE TABLE pending_share_actions ( maxHeartRateBpm: 150, totalDistanceMeters: 42, totalCaloriesKcal: 12, + historySnapshotJson: jsonEncode({ + 'name': 'sync-history-full', + 'telemetrySamples': [ + { + 'id': 'telemetry:session-sync:0', + 'sessionId': 'session-sync', + 'capturedAt': now.toUtc().toIso8601String(), + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'stepIndex': 0, + 'heartRateBpm': 120, + 'distanceMeters': 42, + 'caloriesKcal': 12, + }, + ], + }), ), ); @@ -726,6 +743,7 @@ CREATE TABLE pending_share_actions ( expect(payload['totalCaloriesKcal'], 12); expect(payload['results'], hasLength(1)); expect(payload['stepResults'], hasLength(1)); + expect(payload['telemetrySamples'], hasLength(1)); }); test('local sync pull restores exercise images and steps', () async { @@ -804,6 +822,20 @@ CREATE TABLE pending_share_actions ( 'totalActiveMs': 300000, 'completed': true, 'historySnapshotJson': '{"name":"remote-history-full"}', + 'telemetrySamples': [ + { + 'id': 'telemetry:remote-session:0', + 'sessionId': 'remote-session', + 'capturedAt': now.toUtc().toIso8601String(), + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'stepIndex': 0, + 'heartRateBpm': 125, + 'distanceMeters': 84, + 'caloriesKcal': 24, + }, + ], 'minHeartRateBpm': 95, 'averageHeartRateBpm': 125, 'maxHeartRateBpm': 155, @@ -884,6 +916,16 @@ CREATE TABLE pending_share_actions ( expect(restored.totalCaloriesKcal, 24); expect(restored.results.single.actualScoreTimeMs, 12000); expect(restored.stepResults.single.actualReps, 10); + final telemetrySamples = await telemetryRepository.listSamples( + 'remote-session', + ); + final telemetryAggregate = await telemetryRepository.findAggregate( + sessionId: 'remote-session', + scope: WorkoutTelemetryAggregateScope.session, + ); + expect(telemetrySamples, hasLength(1)); + expect(telemetrySamples.single.heartRateBpm, 125); + expect(telemetryAggregate!.totalDistanceMeters, 84); }); test('local sync pull defaults missing tags to empty lists', () async { @@ -1610,7 +1652,7 @@ CREATE TABLE pending_share_actions ( ).run(); expect(result.status, StarterSeedStatus.inserted); - expect(await seedRepository.readAppliedStarterSeedVersion(), 1); + expect(await seedRepository.readAppliedStarterSeedVersion(), 2); final exercises = await exerciseRepository.listActive(); final programs = await programRepository.listActive(); @@ -1621,15 +1663,23 @@ CREATE TABLE pending_share_actions ( 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, - }); + expect( + exercises.every( + (exercise) => exercise.category == ExerciseCategory.uncategorized, + ), + isTrue, + ); + expect( + exercises.every((exercise) => exercise.businessTypes.isNotEmpty), + isTrue, + ); + expect(exercises.every((exercise) => exercise.tags.isNotEmpty), isTrue); + expect(programs.single.tags, ['fondations', 'basket']); + expect(templates.single.tags, ['fondations', 'séance']); + expect( + exercises.map((exercise) => exercise.metadata.id), + everyElement(startsWith('starter-v2-')), + ); final secondRun = await SeedStarterContentUseCase( seedStateRepository: seedRepository, @@ -1665,7 +1715,7 @@ CREATE TABLE pending_share_actions ( ).run(); expect(result.status, StarterSeedStatus.skippedNotEmpty); - expect(await seedRepository.readAppliedStarterSeedVersion(), 1); + expect(await seedRepository.readAppliedStarterSeedVersion(), 2); expect(await exerciseRepository.listActive(), hasLength(1)); expect(await programRepository.listActive(), isEmpty); expect(await templateRepository.listActive(), isEmpty); @@ -2193,6 +2243,27 @@ CREATE TABLE pending_share_actions ( for (final roundTripCase in cases) { test(roundTripCase.label, () async { + final referencedMediaIds = { + ...roundTripCase.exercise.imageMediaIds, + ...[ + roundTripCase.exercise.iconMediaId, + roundTripCase.exercise.videoMediaId, + ].nonNulls, + }; + for (final mediaId in referencedMediaIds) { + await mediaAssetRepository.save( + MediaAsset( + metadata: _metadata( + mediaId, + roundTripCase.exercise.metadata.createdAt, + ), + kind: mediaId.startsWith('video-') + ? MediaKind.video + : MediaKind.image, + localUri: 'file:///$mediaId', + ), + ); + } await exerciseRepository.save(roundTripCase.exercise); final restored = await exerciseRepository.findById( @@ -3450,6 +3521,7 @@ WorkoutHistory _history({ int? maxHeartRateBpm, double? totalDistanceMeters, double? totalCaloriesKcal, + String? historySnapshotJson, }) { return WorkoutHistory( metadata: _metadata(id, startedAt), @@ -3458,7 +3530,7 @@ WorkoutHistory _history({ endedAt: startedAt.add(const Duration(minutes: 5)), totalActiveMs: totalActiveMs, completed: completed, - historySnapshotJson: '{"name":"$id"}', + historySnapshotJson: historySnapshotJson ?? '{"name":"$id"}', results: results ?? [result!], stepResults: stepResults, minHeartRateBpm: minHeartRateBpm, diff --git a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart index 8aadf66..9351860 100644 --- a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart +++ b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart @@ -5,6 +5,7 @@ import 'package:gametime/application/application.dart'; import 'package:gametime/domain/domain.dart'; import 'package:gametime/infrastructure/infrastructure.dart' hide + ActiveWorkoutTelemetryWindowState, WorkoutHistory, WorkoutHistorySetResult, WorkoutHistoryStepResult, @@ -311,6 +312,25 @@ void main() { ), ); await Future.delayed(Duration.zero); + expect(telemetryRepository.samples, isEmpty); + + native.emitSensorSample( + WatchSensorSample( + sampleId: 'sample-2', + sessionId: 'session-1', + recordedAtEpochMs: _now + .add(const Duration(seconds: 20)) + .millisecondsSinceEpoch, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + stepIndex: 0, + heartRateBpm: 130, + distanceMeters: 520, + caloriesKcal: 45, + ), + ); + await Future.delayed(Duration.zero); expect( telemetryRepository.samples.single.id, @@ -658,6 +678,7 @@ final class _FakeWorkoutTelemetryRepository implements WorkoutTelemetryRepository { final samples = []; final aggregates = []; + final windowStates = {}; @override Future saveSample(WorkoutTelemetrySample sample) async { @@ -668,6 +689,23 @@ final class _FakeWorkoutTelemetryRepository return true; } + @override + Future findWindowState( + String sessionId, + ) async { + return windowStates[sessionId]; + } + + @override + Future saveWindowState(ActiveWorkoutTelemetryWindowState state) async { + windowStates[state.sessionId] = state; + } + + @override + Future deleteWindowState(String sessionId) async { + windowStates.remove(sessionId); + } + @override Future> listSamples(String sessionId) async { return samples diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index c8d04c4..c3fb5a5 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -224,6 +224,131 @@ void main() { expect(find.text('Dribble routine'), findsOneWidget); }); + testWidgets('filtre les exercices par types métier en OR', (tester) async { + final exerciseRepository = _FakeExerciseRepository() + ..exercises.addAll([ + Exercise( + metadata: _metadata('exercise-1'), + name: 'Tir hérité', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + category: ExerciseCategory.shoot, + ), + Exercise( + metadata: _metadata('exercise-2'), + name: 'Dribble appuyé', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + businessTypes: const [BusinessExerciseType.dribble], + ), + Exercise( + metadata: _metadata('exercise-3'), + name: 'Routine libre', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: ExerciseLibraryScreen( + exerciseUseCases: _exerciseUseCases(exerciseRepository), + mediaUseCases: _mediaUseCases(exerciseRepository), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.widgetWithText(FilterChip, 'Tir'), findsOneWidget); + expect(find.widgetWithText(FilterChip, 'Dribble'), findsOneWidget); + expect(find.widgetWithText(FilterChip, 'Libre / autre'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilterChip, 'Tir')); + await tester.pump(); + + expect(find.text('Tir hérité'), findsOneWidget); + expect(find.text('Dribble appuyé'), findsNothing); + expect(find.text('Routine libre'), findsNothing); + + await tester.tap(find.widgetWithText(FilterChip, 'Dribble')); + await tester.pump(); + + expect(find.text('Tir hérité'), findsOneWidget); + expect(find.text('Dribble appuyé'), findsOneWidget); + expect(find.text('Routine libre'), findsNothing); + + await tester.tap(find.widgetWithText(FilterChip, 'Libre / autre')); + await tester.pump(); + + expect(find.text('Tir hérité'), findsOneWidget); + expect(find.text('Dribble appuyé'), findsOneWidget); + expect(find.text('Routine libre'), findsOneWidget); + + await tester.tap(find.text('Effacer les filtres')); + await tester.pump(); + + expect( + tester + .widget(find.widgetWithText(FilterChip, 'Tir')) + .selected, + isFalse, + ); + expect( + tester + .widget(find.widgetWithText(FilterChip, 'Dribble')) + .selected, + isFalse, + ); + expect( + tester + .widget(find.widgetWithText(FilterChip, 'Libre / autre')) + .selected, + isFalse, + ); + }); + + testWidgets('masque le filtre type quand un seul type est disponible', ( + tester, + ) async { + final exerciseRepository = _FakeExerciseRepository() + ..exercises.addAll([ + Exercise( + metadata: _metadata('exercise-1'), + name: 'Tir proche', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + category: ExerciseCategory.shoot, + ), + Exercise( + metadata: _metadata('exercise-2'), + name: 'Tir loin', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + category: ExerciseCategory.shoot, + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: ExerciseLibraryScreen( + exerciseUseCases: _exerciseUseCases(exerciseRepository), + mediaUseCases: _mediaUseCases(exerciseRepository), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('Type'), findsNothing); + expect(find.widgetWithText(FilterChip, 'Tir'), findsNothing); + }); + testWidgets('basculer en chrono intégré masque l’unité et affiche le badge', ( tester, ) async { diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart index 83a18b6..f8fada2 100644 --- a/test/presentation/history_screen_test.dart +++ b/test/presentation/history_screen_test.dart @@ -79,6 +79,7 @@ void main() { home: HistoryDetailScreen( history: _history( id: 'history-1', + withTelemetry: true, minHeartRateBpm: 88, averageHeartRateBpm: 126.4, maxHeartRateBpm: 171, @@ -88,18 +89,28 @@ void main() { historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), workoutTemplateUseCases: _workoutTemplateUseCases(), activeUseCases: _activeUseCases(), + telemetryUseCases: _telemetryUseCases(), closeUseCase: _closeUseCase(), ), ), ); + await tester.pumpAndSettle(); expect(find.text('Stats montre'), findsOneWidget); expect(find.text('Fréquence cardiaque'), findsOneWidget); expect(find.text('Min'), findsAtLeastNWidgets(1)); expect(find.text('Moyenne'), findsOneWidget); expect(find.text('Max'), findsAtLeastNWidgets(1)); - expect(find.text('Distance'), findsOneWidget); - expect(find.text('Calories'), findsOneWidget); + expect(find.text('Distance'), findsAtLeastNWidgets(1)); + expect(find.text('Calories'), findsAtLeastNWidgets(1)); + expect(find.text('Étape'), findsOneWidget); + expect(find.text('Série'), findsAtLeastNWidgets(1)); + expect(find.text('Exercice'), findsAtLeastNWidgets(1)); + expect(find.text('Séance'), findsAtLeastNWidgets(1)); + expect(find.text('FC'), findsOneWidget); + expect(find.text('Période'), findsOneWidget); + expect(find.text('30:00'), findsAtLeastNWidgets(1)); + expect(find.text('Fréquence cardiaque · Séance · 03:00'), findsOneWidget); expect( find.byKey(const ValueKey('history-watch-stats-graph')), findsOneWidget, @@ -269,6 +280,7 @@ WorkoutHistory _history({ DateTime? startedAt, bool stopwatchScore = false, bool withStepResults = false, + bool withTelemetry = false, bool emptySnapshot = false, int? minHeartRateBpm, double? averageHeartRateBpm, @@ -318,6 +330,51 @@ WorkoutHistory _history({ if (stopwatchScore) 'targetScoreTimeMsSnapshot': 45000, }, ], + if (withTelemetry) + 'telemetrySamples': [ + { + 'id': 'telemetry-1', + 'sessionId': 'session-1', + 'capturedAt': start.toUtc().toIso8601String(), + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'stepIndex': 0, + 'heartRateBpm': 88, + 'distanceMeters': 0, + 'caloriesKcal': 0, + }, + { + 'id': 'telemetry-2', + 'sessionId': 'session-1', + 'capturedAt': start + .add(const Duration(minutes: 1, seconds: 30)) + .toUtc() + .toIso8601String(), + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'stepIndex': 0, + 'heartRateBpm': 126, + 'distanceMeters': 600, + 'caloriesKcal': 40, + }, + { + 'id': 'telemetry-3', + 'sessionId': 'session-1', + 'capturedAt': start + .add(const Duration(minutes: 3)) + .toUtc() + .toIso8601String(), + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'stepIndex': 0, + 'heartRateBpm': 171, + 'distanceMeters': 1234, + 'caloriesKcal': 83, + }, + ], }), stepResults: withStepResults ? [ @@ -491,6 +548,16 @@ CloseWorkoutSessionUseCase _closeUseCase() { ); } +WorkoutTelemetryUseCases _telemetryUseCases([ + _FakeWorkoutTelemetryRepository? repository, +]) { + return WorkoutTelemetryUseCases( + repository: repository ?? _FakeWorkoutTelemetryRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17)), + ids: _FakeIds(), + ); +} + final class _FakeClock implements Clock { _FakeClock(this.value); @@ -724,3 +791,108 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future saveSetResult(ActiveSetResult result) async {} } + +final class _FakeWorkoutTelemetryRepository + implements WorkoutTelemetryRepository { + final samples = []; + + @override + Future saveSample(WorkoutTelemetrySample sample) async { + samples.add(sample); + return true; + } + + @override + Future findWindowState( + String sessionId, + ) async => null; + + @override + Future saveWindowState(ActiveWorkoutTelemetryWindowState state) async {} + + @override + Future deleteWindowState(String sessionId) async {} + + @override + Future> listSamples(String sessionId) async { + return samples + .where((sample) => sample.sessionId == sessionId) + .toList(growable: false); + } + + @override + Future> listSamplesForScope({ + required String sessionId, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }) async { + return samples + .where( + (sample) => + sample.sessionId == sessionId && + _fakeTelemetrySampleMatchesScope( + sample, + scope: scope, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + ), + ) + .toList(growable: false); + } + + @override + Future replaceAggregatesForSession({ + required String sessionId, + required List aggregates, + }) async {} + + @override + Future> listAggregates( + String sessionId, + ) async => const []; + + @override + Future findAggregate({ + required String sessionId, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }) async => null; +} + +bool _fakeTelemetrySampleMatchesScope( + WorkoutTelemetrySample sample, { + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, +}) { + return switch (scope) { + WorkoutTelemetryAggregateScope.session => true, + WorkoutTelemetryAggregateScope.exercise => + sample.programIndex == programIndex && + sample.exerciseIndex == exerciseIndex, + WorkoutTelemetryAggregateScope.set => + sample.programIndex == programIndex && + sample.exerciseIndex == exerciseIndex && + sample.setIndex == setIndex, + WorkoutTelemetryAggregateScope.step => + sample.programIndex == programIndex && + sample.exerciseIndex == exerciseIndex && + sample.setIndex == setIndex && + (passageIndex == null || sample.passageIndex == passageIndex) && + sample.stepIndex == stepIndex, + }; +} diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index bb7cac7..a256b07 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -932,9 +932,28 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { final class _FakeWorkoutTelemetryRepository implements WorkoutTelemetryRepository { + final windowStates = {}; + @override Future saveSample(WorkoutTelemetrySample sample) async => true; + @override + Future findWindowState( + String sessionId, + ) async { + return windowStates[sessionId]; + } + + @override + Future saveWindowState(ActiveWorkoutTelemetryWindowState state) async { + windowStates[state.sessionId] = state; + } + + @override + Future deleteWindowState(String sessionId) async { + windowStates.remove(sessionId); + } + @override Future> listSamples(String sessionId) async { return const []; diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index c83e399..ea5a888 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -237,6 +237,67 @@ void main() { expect(find.text('186 kcal'), findsOneWidget); }); + testWidgets( + 'affiche la distance indisponible quand la dernière mesure live la coupe', + (tester) async { + final now = DateTime.now().toUtc(); + final clock = _FakeClock(now); + final repository = _FakeActiveSessionRepository(); + final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock) + ..recordTelemetrySample( + WatchSensorSample( + sessionId: 'session-1', + capturedAtEpochMs: now + .subtract(const Duration(seconds: 1)) + .millisecondsSinceEpoch, + heartRateBpm: 124, + distanceMeters: 840, + caloriesKcal: 186, + ), + ) + ..recordTelemetrySample( + WatchSensorSample( + sessionId: 'session-1', + capturedAtEpochMs: now.millisecondsSinceEpoch, + heartRateBpm: 126, + caloriesKcal: 188, + ), + ); + addTearDown(sensorUseCases.dispose); + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + sourceWorkoutTemplateId: 'template-1', + status: ActiveWorkoutStatus.running, + startedAt: now, + lastPersistedAt: now, + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot(), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + sensorUseCases: sensorUseCases, + ), + ), + ); + + expect(find.text('FC 126 bpm'), findsOneWidget); + expect(find.text('840 m'), findsNothing); + expect(find.text('Donnée indisponible'), findsOneWidget); + expect(find.text('188 kcal'), findsOneWidget); + }, + ); + testWidgets('affiche les états capteur explicites avant la première mesure', ( tester, ) async { diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt index a15e8f5..b1b21e3 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt @@ -42,9 +42,6 @@ object WatchBridgePlugin { const val ACTION_OPEN_ACTIVE_SESSION = "com.gametime.watch.OPEN_ACTIVE_SESSION" private const val SENSOR_PERMISSION_REQUEST = 4106 private const val SENSOR_PERMISSION_RETRY_DELAY_MS = 30000L - private const val READ_HEART_RATE_PERMISSION = - "android.permission.health.READ_HEART_RATE" - private var appContext: Context? = null private var activity: Activity? = null private var projectionSink: EventChannel.EventSink? = null @@ -570,24 +567,11 @@ object WatchBridgePlugin { } private fun requiredSensorPermissions(): List { - val heartRatePermission = if (Build.VERSION.SDK_INT >= 36) { - READ_HEART_RATE_PERMISSION - } else { - android.Manifest.permission.BODY_SENSORS - } - return listOf( - heartRatePermission, - android.Manifest.permission.ACTIVITY_RECOGNITION, - android.Manifest.permission.ACCESS_FINE_LOCATION, - ) + return WatchSensorPermissionPolicy.requiredSensorPermissions(Build.VERSION.SDK_INT) } private fun requiredRuntimePermissions(): List { - val permissions = requiredSensorPermissions().toMutableList() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissions.add(android.Manifest.permission.POST_NOTIFICATIONS) - } - return permissions + return WatchSensorPermissionPolicy.requiredRuntimePermissions(Build.VERSION.SDK_INT) } private fun telemetryContext(projection: Map): Map { @@ -631,6 +615,31 @@ object WatchBridgePlugin { } } +internal object WatchSensorPermissionPolicy { + private const val READ_HEART_RATE_PERMISSION = + "android.permission.health.READ_HEART_RATE" + + fun requiredSensorPermissions(sdkInt: Int): List { + val heartRatePermission = if (sdkInt >= 36) { + READ_HEART_RATE_PERMISSION + } else { + android.Manifest.permission.BODY_SENSORS + } + return listOf( + heartRatePermission, + android.Manifest.permission.ACTIVITY_RECOGNITION, + ) + } + + fun requiredRuntimePermissions(sdkInt: Int): List { + val permissions = requiredSensorPermissions(sdkInt).toMutableList() + if (sdkInt >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(android.Manifest.permission.POST_NOTIFICATIONS) + } + return permissions + } +} + private fun JSONObject.toMap(): Map { val output = linkedMapOf() val keys = keys() diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index 13aabfd..e3a0261 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -116,6 +116,11 @@ internal class WatchHeartRateCollector( } var updated = false var latestHeartRateBpm: Int? = null + var distanceDeltaMeters = 0.0 + var distancePointCount = 0 + var caloriesDeltaKcal = 0.0 + var caloriesPointCount = 0 + val receivedAtEpochMs = System.currentTimeMillis() for (point in update.latestMetrics.getData(DataType.HEART_RATE_BPM)) { latestHeartRateBpm = recordHeartRate(point.value) } @@ -127,6 +132,8 @@ internal class WatchHeartRateCollector( val value = point.value if (value > 0) { distanceMeters = (distanceMeters ?: 0.0) + value + distanceDeltaMeters += value + distancePointCount += 1 updated = true } } @@ -134,12 +141,18 @@ internal class WatchHeartRateCollector( val value = point.value if (value > 0) { caloriesKcal = (caloriesKcal ?: 0.0) + value + caloriesDeltaKcal += value + caloriesPointCount += 1 updated = true } } if (latestHeartRateBpm != null || updated) { logHotPath( - "exercise metrics received sessionId=$sessionId bpm=$latestHeartRateBpm distance=$distanceMeters calories=$caloriesKcal", + "exercise metrics received sessionId=$sessionId " + + "receivedAtEpochMs=$receivedAtEpochMs bpm=$latestHeartRateBpm " + + "distanceDelta=$distanceDeltaMeters distancePoints=$distancePointCount " + + "distanceTotal=$distanceMeters caloriesDelta=$caloriesDeltaKcal " + + "caloriesPoints=$caloriesPointCount caloriesTotal=$caloriesKcal", ) sendSample(latestHeartRateBpm) } @@ -334,7 +347,7 @@ internal class WatchHeartRateCollector( val requestedTypeNames = ( executionContext["healthServicesExerciseTypeStrategy"] as? List<*> )?.filterIsInstance().orEmpty() - val configs = exerciseConfigsFromCapabilities( + val configs = WatchExerciseMetricsConfigFactory.fromCapabilities( capabilities, requestedTypeNames, canUseGps = canStartGpsExercise(context), @@ -447,74 +460,6 @@ internal class WatchHeartRateCollector( } } - private fun exerciseConfigsFromCapabilities( - capabilities: androidx.health.services.client.data.ExerciseCapabilities, - requestedTypeNames: List, - canUseGps: Boolean, - ): List { - val requestedTypes = exerciseTypesFromNames(requestedTypeNames) - val distanceCaloriesConfigs = mutableListOf() - val caloriesConfigs = mutableListOf() - val heartRateConfigs = mutableListOf() - for (exerciseType in requestedTypes) { - if (exerciseType !in capabilities.supportedExerciseTypes) { - continue - } - val supported = capabilities.getExerciseTypeCapabilities(exerciseType) - .supportedDataTypes - val supportsHeartRate = DataType.HEART_RATE_BPM in supported - if ( - canUseGps && - DataType.DISTANCE in supported && - DataType.CALORIES in supported - ) { - val dataTypes = mutableSetOf>( - DataType.DISTANCE, - DataType.CALORIES, - ) - if (supportsHeartRate) { - dataTypes.add(DataType.HEART_RATE_BPM) - } - distanceCaloriesConfigs.add( - ExerciseConfig.builder(exerciseType) - .setDataTypes(dataTypes) - .setIsAutoPauseAndResumeEnabled(false) - .setIsGpsEnabled(true) - .build(), - ) - } - if (DataType.CALORIES in supported) { - val dataTypes = mutableSetOf>( - DataType.CALORIES, - ) - if (supportsHeartRate) { - dataTypes.add(DataType.HEART_RATE_BPM) - } - caloriesConfigs.add( - ExerciseConfig.builder(exerciseType) - .setDataTypes(dataTypes) - .setIsAutoPauseAndResumeEnabled(false) - .setIsGpsEnabled(false) - .build(), - ) - } else if (supportsHeartRate) { - heartRateConfigs.add( - ExerciseConfig.builder(exerciseType) - .setDataTypes(setOf(DataType.HEART_RATE_BPM)) - .setIsAutoPauseAndResumeEnabled(false) - .setIsGpsEnabled(false) - .build(), - ) - } else { - Log.w( - TAG, - "exercise type lacks usable metrics type=$exerciseType supported=$supported", - ) - } - } - return distanceCaloriesConfigs + caloriesConfigs + heartRateConfigs - } - private fun canStartGpsExercise(context: Context): Boolean { if ( context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) != @@ -533,31 +478,6 @@ internal class WatchHeartRateCollector( } } - private fun exerciseTypesFromNames(names: List): List { - val mapped = names.mapNotNull { name -> - when (name) { - "RUNNING" -> ExerciseType.RUNNING - "WALKING" -> ExerciseType.WALKING - "HIGH_INTENSITY_INTERVAL_TRAINING" -> - ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING - "WORKOUT" -> ExerciseType.WORKOUT - else -> null - } - }.toMutableList() - val fallback = listOf( - ExerciseType.RUNNING, - ExerciseType.WALKING, - ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING, - ExerciseType.WORKOUT, - ) - for (exerciseType in fallback) { - if (exerciseType !in mapped) { - mapped.add(exerciseType) - } - } - return mapped.distinct() - } - private fun stopExerciseMetrics(context: Context) { if (!exerciseMetricsStarted && !exerciseMetricsStartInFlight) { return @@ -786,6 +706,138 @@ internal class WatchHeartRateCollector( } } +internal object WatchExerciseMetricsConfigFactory { + fun fromCapabilities( + capabilities: androidx.health.services.client.data.ExerciseCapabilities, + requestedTypeNames: List, + canUseGps: Boolean, + ): List { + return plansFromCapabilities(capabilities, requestedTypeNames, canUseGps) + .map { plan -> + buildConfig(plan.exerciseType, plan.dataTypes, enableGps = plan.enableGps) + } + } + + fun plansFromCapabilities( + capabilities: androidx.health.services.client.data.ExerciseCapabilities, + requestedTypeNames: List, + canUseGps: Boolean, + ): List { + val gpsDistanceConfigs = mutableListOf() + val distanceConfigs = mutableListOf() + val caloriesConfigs = mutableListOf() + val heartRateConfigs = mutableListOf() + for (exerciseType in exerciseTypesFromNames(requestedTypeNames)) { + if (exerciseType !in capabilities.supportedExerciseTypes) { + continue + } + val supported = capabilities.getExerciseTypeCapabilities(exerciseType) + .supportedDataTypes + val dataTypes = usableDataTypes(supported) + when { + DataType.DISTANCE in dataTypes -> { + if (canUseGps) { + gpsDistanceConfigs.add( + WatchExerciseMetricsConfigPlan( + exerciseType, + dataTypes, + enableGps = true, + ), + ) + } + distanceConfigs.add( + WatchExerciseMetricsConfigPlan( + exerciseType, + dataTypes, + enableGps = false, + ), + ) + } + DataType.CALORIES in dataTypes -> + caloriesConfigs.add( + WatchExerciseMetricsConfigPlan( + exerciseType, + dataTypes, + enableGps = false, + ), + ) + DataType.HEART_RATE_BPM in dataTypes -> + heartRateConfigs.add( + WatchExerciseMetricsConfigPlan( + exerciseType, + dataTypes, + enableGps = false, + ), + ) + else -> Log.w( + "GTWatchHeartRate", + "exercise type lacks usable metrics type=$exerciseType supported=$supported", + ) + } + } + return gpsDistanceConfigs + distanceConfigs + caloriesConfigs + heartRateConfigs + } + + private fun usableDataTypes( + supported: Set>, + ): Set> { + val dataTypes = mutableSetOf>() + if (DataType.DISTANCE in supported) { + dataTypes.add(DataType.DISTANCE) + } + if (DataType.CALORIES in supported) { + dataTypes.add(DataType.CALORIES) + } + if (DataType.HEART_RATE_BPM in supported) { + dataTypes.add(DataType.HEART_RATE_BPM) + } + return dataTypes + } + + private fun buildConfig( + exerciseType: ExerciseType, + dataTypes: Set>, + enableGps: Boolean, + ): ExerciseConfig { + return ExerciseConfig.builder(exerciseType) + .setDataTypes(dataTypes) + .setIsAutoPauseAndResumeEnabled(false) + .setIsGpsEnabled(enableGps) + .build() + } + + private fun exerciseTypesFromNames(names: List): List { + val mapped = names.mapNotNull { name -> + when (name) { + "RUNNING" -> ExerciseType.RUNNING + "WALKING" -> ExerciseType.WALKING + "HIGH_INTENSITY_INTERVAL_TRAINING" -> + ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING + "WORKOUT" -> ExerciseType.WORKOUT + else -> null + } + }.toMutableList() + val fallback = listOf( + ExerciseType.RUNNING, + ExerciseType.WALKING, + ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING, + ExerciseType.WORKOUT, + ) + for (exerciseType in fallback) { + if (exerciseType !in mapped) { + mapped.add(exerciseType) + } + } + return mapped.distinct() + } +} + +internal data class WatchExerciseMetricsConfigPlan( + val exerciseType: ExerciseType, + val dataTypes: Set>, + val enableGps: Boolean, +) + internal object WatchExerciseMetricsRetryPolicy { fun isDistanceSecurityFailure( dataTypes: Set>, diff --git a/watch_app/android/app/src/test/kotlin/com/gametime/watch/bridge/WatchExerciseMetricsRetryPolicyTest.kt b/watch_app/android/app/src/test/kotlin/com/gametime/watch/bridge/WatchExerciseMetricsRetryPolicyTest.kt index 1977cbd..aa135b2 100644 --- a/watch_app/android/app/src/test/kotlin/com/gametime/watch/bridge/WatchExerciseMetricsRetryPolicyTest.kt +++ b/watch_app/android/app/src/test/kotlin/com/gametime/watch/bridge/WatchExerciseMetricsRetryPolicyTest.kt @@ -1,8 +1,12 @@ package com.gametime.watch.bridge import androidx.health.services.client.data.DataType +import androidx.health.services.client.data.ExerciseCapabilities +import androidx.health.services.client.data.ExerciseType +import androidx.health.services.client.data.ExerciseTypeCapabilities import java.util.concurrent.ExecutionException import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -55,4 +59,73 @@ class WatchExerciseMetricsRetryPolicyTest { assertTrue(WatchExerciseMetricsRetryPolicy.canRetryDistance(3)) assertFalse(WatchExerciseMetricsRetryPolicy.canRetryDistance(4)) } + + @Test + fun distanceConfigDoesNotRequireGpsOrCalories() { + val configs = WatchExerciseMetricsConfigFactory.plansFromCapabilities( + capabilities( + ExerciseType.RUNNING to setOf(DataType.DISTANCE, DataType.HEART_RATE_BPM), + ), + requestedTypeNames = listOf("RUNNING"), + canUseGps = false, + ) + + assertEquals(1, configs.size) + assertEquals(ExerciseType.RUNNING, configs.single().exerciseType) + assertFalse(configs.single().enableGps) + assertEquals( + setOf(DataType.DISTANCE, DataType.HEART_RATE_BPM), + configs.single().dataTypes, + ) + } + + @Test + fun gpsDistanceConfigKeepsNonGpsFallback() { + val configs = WatchExerciseMetricsConfigFactory.plansFromCapabilities( + capabilities( + ExerciseType.RUNNING to setOf( + DataType.DISTANCE, + DataType.CALORIES, + DataType.HEART_RATE_BPM, + ), + ), + requestedTypeNames = listOf("RUNNING"), + canUseGps = true, + ) + + assertEquals(2, configs.size) + assertTrue(configs[0].enableGps) + assertFalse(configs[1].enableGps) + assertEquals(configs[0].dataTypes, configs[1].dataTypes) + assertTrue(DataType.DISTANCE in configs[0].dataTypes) + assertTrue(DataType.CALORIES in configs[0].dataTypes) + } + + @Test + fun runtimeSensorPermissionsDoNotRequireFineLocation() { + val permissions = WatchSensorPermissionPolicy.requiredRuntimePermissions(sdkInt = 35) + + assertTrue(android.Manifest.permission.BODY_SENSORS in permissions) + assertTrue(android.Manifest.permission.ACTIVITY_RECOGNITION in permissions) + assertTrue(android.Manifest.permission.POST_NOTIFICATIONS in permissions) + assertFalse(android.Manifest.permission.ACCESS_FINE_LOCATION in permissions) + } + + private fun capabilities( + vararg supportedTypes: Pair< + ExerciseType, + Set>, + >, + ): ExerciseCapabilities { + return ExerciseCapabilities( + supportedTypes.associate { (exerciseType, dataTypes) -> + exerciseType to ExerciseTypeCapabilities( + dataTypes, + emptyMap(), + emptyMap(), + false, + ) + }, + ) + } } diff --git a/watch_app/android/gradle.properties b/watch_app/android/gradle.properties index b70d9fe..824d658 100644 --- a/watch_app/android/gradle.properties +++ b/watch_app/android/gradle.properties @@ -2,3 +2,4 @@ android.builtInKotlin=false # This newDsl flag was added automatically by Flutter migrator android.newDsl=false +org.gradle.java.home=/usr/lib/jvm/java-21-openjdk