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

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,53 +5704,128 @@ final class WorkoutTelemetryUseCases {
stepIndex: stepIndex,
);
}
if (samples.isEmpty) {
samples = _telemetrySamplesFromHistorySnapshot(history)
.where(
(sample) => _matchesTelemetryScope(
sample,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
),
)
.toList(growable: false);
if (samples.isNotEmpty) {
return samples;
}
return _telemetryGraphSeriesFromSamples(
samples,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
);
return _telemetrySamplesFromHistorySnapshot(history)
.where(
(sample) => _matchesTelemetryScope(
sample,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
),
)
.toList(growable: false);
}
Future<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) {