feat(server): implemente synchronisation et serveur avec fixtures (#187)

- Implémente la couche de synchronisation avec le serveur
- Ajoute les fixtures versionnées pour les tests
- Met à jour Drift database et repositories pour le support sync
- Améliore les tests de synchronisation
- Corrige et améliore le watch companion pour la collecte de métriques

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 23:07:31 +02:00
parent d1edb2397e
commit fe3608cc4a
31 changed files with 5056 additions and 394 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -144,6 +144,7 @@ final class AppBootstrap implements AppDependencies {
);
final workoutTelemetryUseCases = WorkoutTelemetryUseCases(
repository: telemetryRepository,
sessionRepository: activeSessionRepository,
clock: clock,
ids: ids,
);

View File

@ -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';

View File

@ -938,6 +938,9 @@ abstract interface class WorkoutHistoryRepository {
abstract interface class WorkoutTelemetryRepository {
Future<bool> saveSample(WorkoutTelemetrySample sample);
Future<ActiveWorkoutTelemetryWindowState?> findWindowState(String sessionId);
Future<void> saveWindowState(ActiveWorkoutTelemetryWindowState state);
Future<void> deleteWindowState(String sessionId);
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId);
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
required String sessionId,

View File

@ -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'],
);

View File

@ -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<BusinessExerciseType> 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<String> tags;
final List<StarterExerciseStepSeed> 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<StarterProgramExerciseSeed> exercises;
final List<String> 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<String> 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],
);

View File

@ -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<List<ScopeInstanceDescriptor>> listScopeInstancesForHistory({
required WorkoutHistory history,
required WorkoutTelemetryAggregateScope scope,
}) async {
final samples = await listSamplesForHistory(history);
return _scopeInstancesFromSamples(samples, scope: scope);
}
Future<WorkoutTelemetryGraphSeries> 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<List<ScopeMarker>> 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<List<WorkoutTelemetrySample>> _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 <WorkoutTelemetrySample>[];
@ -5644,8 +5704,10 @@ final class WorkoutTelemetryUseCases {
stepIndex: stepIndex,
);
}
if (samples.isEmpty) {
samples = _telemetrySamplesFromHistorySnapshot(history)
if (samples.isNotEmpty) {
return samples;
}
return _telemetrySamplesFromHistorySnapshot(history)
.where(
(sample) => _matchesTelemetryScope(
sample,
@ -5659,38 +5721,111 @@ final class WorkoutTelemetryUseCases {
)
.toList(growable: false);
}
return _telemetryGraphSeriesFromSamples(
samples,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
);
}
Future<List<WorkoutTelemetryAggregate>> 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<int?> _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<ScopeInstanceDescriptor> _scopeInstancesFromSamples(
List<WorkoutTelemetrySample> 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<ScopeMarker> _scopeMarkersFromSamples(
List<WorkoutTelemetrySample> 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 = <ScopeMarker>[];
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<String, Object?> _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;

View File

@ -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,

View File

@ -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<void> _migrateToSchema26(Migrator migrator) async {
await migrator.createTable(activeWorkoutTelemetryWindowStates);
}
Future<void> _migrateToSchema13() async {
await customStatement('PRAGMA foreign_keys = OFF');
await customStatement('''

File diff suppressed because it is too large Load Diff

View File

@ -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<void> _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<void> _softDeleteRemoteWorkoutHistoryChildren({
required String tableName,
required String historyId,
@ -2150,6 +2189,34 @@ final class DriftWorkoutTelemetryRepository
return true;
}
@override
Future<domain.ActiveWorkoutTelemetryWindowState?> 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<void> saveWindowState(
domain.ActiveWorkoutTelemetryWindowState state,
) async {
await database
.into(database.activeWorkoutTelemetryWindowStates)
.insertOnConflictUpdate(
_activeWorkoutTelemetryWindowStateCompanion(state),
);
}
@override
Future<void> deleteWindowState(String sessionId) async {
await (database.delete(
database.activeWorkoutTelemetryWindowStates,
)..where((table) => table.sessionId.equals(sessionId))).go();
}
@override
Future<List<domain.WorkoutTelemetrySample>> 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<String, Object?> _localWorkoutHistoryPayload(
'stepResults': history.stepResults
.map(_workoutHistoryStepResultPayload)
.toList(),
'telemetrySamples': _workoutTelemetrySamplesFromHistoryPayload(
history,
).map(_workoutTelemetrySamplePayload).toList(),
};
Map<String, Object?> _workoutHistorySetResultPayload(
@ -5875,6 +5989,248 @@ Map<String, Object?> _workoutHistoryStepResultPayload(
'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot,
};
Map<String, Object?> _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<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromHistoryPayload(
domain.WorkoutHistory history,
) {
final decoded = jsonDecode(history.historySnapshotJson);
final snapshot = decoded is Map
? Map<String, Object?>.from(decoded)
: const <String, Object?>{};
final rawSamples = snapshot['telemetrySamples'];
if (rawSamples is! List) {
return const [];
}
return _workoutTelemetrySamplesFromPayload(rawSamples);
}
List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromPayload(
Object? value,
) {
if (value is! List) {
return const [];
}
final output = <domain.WorkoutTelemetrySample>[];
for (final rawSample in value) {
if (rawSample is! Map) {
continue;
}
final map = Map<String, Object?>.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<domain.WorkoutTelemetryAggregate> _workoutTelemetryAggregatesFromSamples(
List<domain.WorkoutTelemetrySample> 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<String, Object?> 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<String, Object?> 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<String, Object?>.from(decoded)
: <String, Object?>{'programs': const []};
snapshot['telemetrySamples'] = rawSamples;
return jsonEncode(snapshot);
}
domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
final payload = item.payload;
return domain.Exercise(

View File

@ -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<Column> get primaryKey => {sessionId};
@override
List<String> 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';

View File

@ -43,6 +43,7 @@ final class ExerciseLibraryScreen extends StatefulWidget {
final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
final _searchController = TextEditingController();
var _selectedBusinessTypes = <BusinessExerciseType?>{};
var _selectedMeasures = <WorkoutMeasure>{};
var _selectedTags = <String>{};
late Future<List<Exercise>> _exercises;
@ -84,6 +85,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
}
final exercises = snapshot.data ?? const <Exercise>[];
final typeFilterOptions = _typeFilterOptionsFor(exercises);
final tagSuggestions = tagSuggestionsFor(
exercises,
(exercise) => exercise.tags,
@ -110,6 +112,41 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
),
),
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<ExerciseLibraryScreen> {
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<ExerciseLibraryScreen> {
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<ExerciseLibraryScreen> {
bool get _filtersActive {
return _searchController.text.trim().isNotEmpty ||
_selectedBusinessTypes.isNotEmpty ||
_selectedMeasures.isNotEmpty ||
_selectedTags.isNotEmpty;
}
@ -234,6 +280,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
void _clearFilters() {
setState(() {
_searchController.clear();
_selectedBusinessTypes = {};
_selectedMeasures = {};
_selectedTags = {};
});
@ -314,6 +361,27 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
}
}
List<BusinessExerciseType?> _typeFilterOptionsFor(List<Exercise> exercises) {
final options = <BusinessExerciseType?>[
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,

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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 = <WorkoutTelemetrySample>[];
final aggregates = <WorkoutTelemetryAggregate>[];
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
@ -5416,6 +5677,23 @@ final class _FakeWorkoutTelemetryRepository
return true;
}
@override
Future<ActiveWorkoutTelemetryWindowState?> findWindowState(
String sessionId,
) async {
return windowStates[sessionId];
}
@override
Future<void> saveWindowState(ActiveWorkoutTelemetryWindowState state) async {
windowStates[state.sessionId] = state;
}
@override
Future<void> deleteWindowState(String sessionId) async {
windowStates.remove(sessionId);
}
@override
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
return samples

View File

@ -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,

View File

@ -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<void>.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<void>.delayed(Duration.zero);
expect(
telemetryRepository.samples.single.id,
@ -658,6 +678,7 @@ final class _FakeWorkoutTelemetryRepository
implements WorkoutTelemetryRepository {
final samples = <WorkoutTelemetrySample>[];
final aggregates = <WorkoutTelemetryAggregate>[];
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
@ -668,6 +689,23 @@ final class _FakeWorkoutTelemetryRepository
return true;
}
@override
Future<ActiveWorkoutTelemetryWindowState?> findWindowState(
String sessionId,
) async {
return windowStates[sessionId];
}
@override
Future<void> saveWindowState(ActiveWorkoutTelemetryWindowState state) async {
windowStates[state.sessionId] = state;
}
@override
Future<void> deleteWindowState(String sessionId) async {
windowStates.remove(sessionId);
}
@override
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
return samples

View File

@ -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<FilterChip>(find.widgetWithText(FilterChip, 'Tir'))
.selected,
isFalse,
);
expect(
tester
.widget<FilterChip>(find.widgetWithText(FilterChip, 'Dribble'))
.selected,
isFalse,
);
expect(
tester
.widget<FilterChip>(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 lunité et affiche le badge', (
tester,
) async {

View File

@ -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<void> saveSetResult(ActiveSetResult result) async {}
}
final class _FakeWorkoutTelemetryRepository
implements WorkoutTelemetryRepository {
final samples = <WorkoutTelemetrySample>[];
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
samples.add(sample);
return true;
}
@override
Future<ActiveWorkoutTelemetryWindowState?> findWindowState(
String sessionId,
) async => null;
@override
Future<void> saveWindowState(ActiveWorkoutTelemetryWindowState state) async {}
@override
Future<void> deleteWindowState(String sessionId) async {}
@override
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
return samples
.where((sample) => sample.sessionId == sessionId)
.toList(growable: false);
}
@override
Future<List<WorkoutTelemetrySample>> 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<void> replaceAggregatesForSession({
required String sessionId,
required List<WorkoutTelemetryAggregate> aggregates,
}) async {}
@override
Future<List<WorkoutTelemetryAggregate>> listAggregates(
String sessionId,
) async => const [];
@override
Future<WorkoutTelemetryAggregate?> 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,
};
}

View File

@ -932,9 +932,28 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
final class _FakeWorkoutTelemetryRepository
implements WorkoutTelemetryRepository {
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
@override
Future<bool> saveSample(WorkoutTelemetrySample sample) async => true;
@override
Future<ActiveWorkoutTelemetryWindowState?> findWindowState(
String sessionId,
) async {
return windowStates[sessionId];
}
@override
Future<void> saveWindowState(ActiveWorkoutTelemetryWindowState state) async {
windowStates[state.sessionId] = state;
}
@override
Future<void> deleteWindowState(String sessionId) async {
windowStates.remove(sessionId);
}
@override
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
return const [];

View File

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

View File

@ -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<String> {
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<String> {
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<String, Any?>): Map<String, Any?> {
@ -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<String> {
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<String> {
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<String, Any?> {
val output = linkedMapOf<String, Any?>()
val keys = keys()

View File

@ -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<String>().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<String>,
canUseGps: Boolean,
): List<ExerciseConfig> {
val requestedTypes = exerciseTypesFromNames(requestedTypeNames)
val distanceCaloriesConfigs = mutableListOf<ExerciseConfig>()
val caloriesConfigs = mutableListOf<ExerciseConfig>()
val heartRateConfigs = mutableListOf<ExerciseConfig>()
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<androidx.health.services.client.data.DataType<*, *>>(
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<androidx.health.services.client.data.DataType<*, *>>(
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<String>): List<ExerciseType> {
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<String>,
canUseGps: Boolean,
): List<ExerciseConfig> {
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<String>,
canUseGps: Boolean,
): List<WatchExerciseMetricsConfigPlan> {
val gpsDistanceConfigs = mutableListOf<WatchExerciseMetricsConfigPlan>()
val distanceConfigs = mutableListOf<WatchExerciseMetricsConfigPlan>()
val caloriesConfigs = mutableListOf<WatchExerciseMetricsConfigPlan>()
val heartRateConfigs = mutableListOf<WatchExerciseMetricsConfigPlan>()
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<androidx.health.services.client.data.DataType<*, *>>,
): Set<androidx.health.services.client.data.DataType<*, *>> {
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>()
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<androidx.health.services.client.data.DataType<*, *>>,
enableGps: Boolean,
): ExerciseConfig {
return ExerciseConfig.builder(exerciseType)
.setDataTypes(dataTypes)
.setIsAutoPauseAndResumeEnabled(false)
.setIsGpsEnabled(enableGps)
.build()
}
private fun exerciseTypesFromNames(names: List<String>): List<ExerciseType> {
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<androidx.health.services.client.data.DataType<*, *>>,
val enableGps: Boolean,
)
internal object WatchExerciseMetricsRetryPolicy {
fun isDistanceSecurityFailure(
dataTypes: Set<androidx.health.services.client.data.DataType<*, *>>,

View File

@ -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<androidx.health.services.client.data.DataType<*, *>>,
>,
): ExerciseCapabilities {
return ExerciseCapabilities(
supportedTypes.associate { (exerciseType, dataTypes) ->
exerciseType to ExerciseTypeCapabilities(
dataTypes,
emptyMap(),
emptyMap(),
false,
)
},
)
}
}

View File

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