Merge branch 'feature/qa-serveur-sync-fixtures' into develop
This commit is contained in:
15
.gitignore
vendored
15
.gitignore
vendored
@ -81,16 +81,9 @@ coverage/
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# IdeA orchestration — état d'exécution transitoire (pas du code applicatif)
|
# IdeA orchestration — état local (agents, tickets, mémoire, run) : jamais versionné,
|
||||||
.ideai/run/
|
# conservé tel quel d'une branche à l'autre
|
||||||
.ideai/conversations/
|
.ideai/
|
||||||
.ideai/requests/
|
|
||||||
.ideai/background-tasks/
|
|
||||||
.ideai/live-state.json
|
|
||||||
.ideai/layouts.json
|
|
||||||
.ideai/permissions.json
|
|
||||||
.ideai/system-permissions.json
|
|
||||||
.ideai/build-env/
|
|
||||||
|
|
||||||
# Environnements locaux de build (SDK/Gradle/XDG/pub-cache par worktree) — jamais du code applicatif
|
# Environnements locaux de build (SDK/Gradle/XDG/pub-cache par worktree) — jamais du code applicatif
|
||||||
.android-local/
|
.android-local/
|
||||||
@ -105,6 +98,8 @@ coverage/
|
|||||||
.xdg-oldflow/
|
.xdg-oldflow/
|
||||||
.xdg/
|
.xdg/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
.build-home/
|
||||||
|
.pub-cache-local/
|
||||||
|
|
||||||
# Fichiers de heap dump parasites (JVM/Android crash)
|
# Fichiers de heap dump parasites (JVM/Android crash)
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|||||||
BIN
.gradle/caches/9.1.0/file-changes/last-build.bin
Normal file
BIN
.gradle/caches/9.1.0/file-changes/last-build.bin
Normal file
Binary file not shown.
Binary file not shown.
BIN
.gradle/caches/modules-2/modules-2.lock
Normal file
BIN
.gradle/caches/modules-2/modules-2.lock
Normal file
Binary file not shown.
BIN
.gradle/daemon/9.1.0/registry.bin
Normal file
BIN
.gradle/daemon/9.1.0/registry.bin
Normal file
Binary file not shown.
BIN
.gradle/daemon/9.1.0/registry.bin.lock
Normal file
BIN
.gradle/daemon/9.1.0/registry.bin.lock
Normal file
Binary file not shown.
@ -4,3 +4,4 @@ android.useAndroidX=true
|
|||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
# This builtInKotlin flag was added by the Flutter template
|
# This builtInKotlin flag was added by the Flutter template
|
||||||
android.builtInKotlin=false
|
android.builtInKotlin=false
|
||||||
|
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
|
||||||
|
|||||||
@ -144,6 +144,7 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
);
|
);
|
||||||
final workoutTelemetryUseCases = WorkoutTelemetryUseCases(
|
final workoutTelemetryUseCases = WorkoutTelemetryUseCases(
|
||||||
repository: telemetryRepository,
|
repository: telemetryRepository,
|
||||||
|
sessionRepository: activeSessionRepository,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
ids: ids,
|
ids: ids,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ library;
|
|||||||
export 'ports.dart';
|
export 'ports.dart';
|
||||||
export 'session_notification_use_cases.dart';
|
export 'session_notification_use_cases.dart';
|
||||||
export 'starter_content/basket_starter_seed_v1.dart';
|
export 'starter_content/basket_starter_seed_v1.dart';
|
||||||
|
export 'starter_content/basket_starter_seed_v2.dart';
|
||||||
export 'starter_content/starter_content.dart';
|
export 'starter_content/starter_content.dart';
|
||||||
export 'use_cases.dart';
|
export 'use_cases.dart';
|
||||||
export 'watch_companion_use_cases.dart';
|
export 'watch_companion_use_cases.dart';
|
||||||
|
|||||||
@ -938,6 +938,9 @@ abstract interface class WorkoutHistoryRepository {
|
|||||||
|
|
||||||
abstract interface class WorkoutTelemetryRepository {
|
abstract interface class WorkoutTelemetryRepository {
|
||||||
Future<bool> saveSample(WorkoutTelemetrySample sample);
|
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>> listSamples(String sessionId);
|
||||||
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
|
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
|
|||||||
570
lib/application/starter_content/basket_starter_seed_v2.dart
Normal file
570
lib/application/starter_content/basket_starter_seed_v2.dart
Normal 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'],
|
||||||
|
);
|
||||||
@ -1,14 +1,15 @@
|
|||||||
import '../../domain/domain.dart';
|
import '../../domain/domain.dart';
|
||||||
import '../ports.dart';
|
import '../ports.dart';
|
||||||
|
|
||||||
const int starterSeedVersion = 1;
|
const int starterSeedVersion = 2;
|
||||||
|
|
||||||
final class StarterExerciseSeed {
|
final class StarterExerciseSeed {
|
||||||
const StarterExerciseSeed({
|
const StarterExerciseSeed({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.description,
|
required this.description,
|
||||||
required this.category,
|
this.category = ExerciseCategory.uncategorized,
|
||||||
|
this.businessTypes = const [],
|
||||||
this.hasTimeMeasure = false,
|
this.hasTimeMeasure = false,
|
||||||
this.hasRepsMeasure = false,
|
this.hasRepsMeasure = false,
|
||||||
this.hasScoreMeasure = false,
|
this.hasScoreMeasure = false,
|
||||||
@ -19,6 +20,7 @@ final class StarterExerciseSeed {
|
|||||||
this.defaultTargetReps,
|
this.defaultTargetReps,
|
||||||
this.defaultTargetScore,
|
this.defaultTargetScore,
|
||||||
this.defaultTargetScoreTimeMs,
|
this.defaultTargetScoreTimeMs,
|
||||||
|
this.tags = const [],
|
||||||
this.steps = const [],
|
this.steps = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -26,6 +28,7 @@ final class StarterExerciseSeed {
|
|||||||
final String name;
|
final String name;
|
||||||
final String description;
|
final String description;
|
||||||
final ExerciseCategory category;
|
final ExerciseCategory category;
|
||||||
|
final List<BusinessExerciseType> businessTypes;
|
||||||
final bool hasTimeMeasure;
|
final bool hasTimeMeasure;
|
||||||
final bool hasRepsMeasure;
|
final bool hasRepsMeasure;
|
||||||
final bool hasScoreMeasure;
|
final bool hasScoreMeasure;
|
||||||
@ -36,6 +39,7 @@ final class StarterExerciseSeed {
|
|||||||
final int? defaultTargetReps;
|
final int? defaultTargetReps;
|
||||||
final double? defaultTargetScore;
|
final double? defaultTargetScore;
|
||||||
final int? defaultTargetScoreTimeMs;
|
final int? defaultTargetScoreTimeMs;
|
||||||
|
final List<String> tags;
|
||||||
final List<StarterExerciseStepSeed> steps;
|
final List<StarterExerciseStepSeed> steps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,12 +63,14 @@ final class StarterProgramSeed {
|
|||||||
required this.name,
|
required this.name,
|
||||||
required this.defaultRestSeconds,
|
required this.defaultRestSeconds,
|
||||||
required this.exercises,
|
required this.exercises,
|
||||||
|
this.tags = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final int defaultRestSeconds;
|
final int defaultRestSeconds;
|
||||||
final List<StarterProgramExerciseSeed> exercises;
|
final List<StarterProgramExerciseSeed> exercises;
|
||||||
|
final List<String> tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
final class StarterProgramExerciseSeed {
|
final class StarterProgramExerciseSeed {
|
||||||
@ -96,11 +102,13 @@ final class StarterWorkoutTemplateSeed {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.programSnapshotId,
|
required this.programSnapshotId,
|
||||||
|
this.tags = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String programSnapshotId;
|
final String programSnapshotId;
|
||||||
|
final List<String> tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
StarterContent buildStarterContent({
|
StarterContent buildStarterContent({
|
||||||
@ -127,7 +135,9 @@ StarterContent buildStarterContent({
|
|||||||
defaultTargetScore: seed.defaultTargetScore,
|
defaultTargetScore: seed.defaultTargetScore,
|
||||||
defaultTargetScoreTimeMs: seed.defaultTargetScoreTimeMs,
|
defaultTargetScoreTimeMs: seed.defaultTargetScoreTimeMs,
|
||||||
category: seed.category,
|
category: seed.category,
|
||||||
|
businessTypes: seed.businessTypes,
|
||||||
isExample: true,
|
isExample: true,
|
||||||
|
tags: seed.tags,
|
||||||
steps: [
|
steps: [
|
||||||
for (var index = 0; index < seed.steps.length; index += 1)
|
for (var index = 0; index < seed.steps.length; index += 1)
|
||||||
ExerciseStep(
|
ExerciseStep(
|
||||||
@ -170,6 +180,7 @@ StarterContent buildStarterContent({
|
|||||||
name: programSeed.name,
|
name: programSeed.name,
|
||||||
defaultRestSeconds: programSeed.defaultRestSeconds,
|
defaultRestSeconds: programSeed.defaultRestSeconds,
|
||||||
isExample: true,
|
isExample: true,
|
||||||
|
tags: programSeed.tags,
|
||||||
exercises: programExercises,
|
exercises: programExercises,
|
||||||
);
|
);
|
||||||
final templateProgram = WorkoutTemplateProgram.snapshotFromProgram(
|
final templateProgram = WorkoutTemplateProgram.snapshotFromProgram(
|
||||||
@ -182,6 +193,7 @@ StarterContent buildStarterContent({
|
|||||||
metadata: _metadata(templateSeed.id, now, originDeviceId),
|
metadata: _metadata(templateSeed.id, now, originDeviceId),
|
||||||
name: templateSeed.name,
|
name: templateSeed.name,
|
||||||
isExample: true,
|
isExample: true,
|
||||||
|
tags: templateSeed.tags,
|
||||||
programs: [templateProgram],
|
programs: [templateProgram],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
|||||||
|
|
||||||
import '../domain/domain.dart';
|
import '../domain/domain.dart';
|
||||||
import 'ports.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 'starter_content/starter_content.dart';
|
||||||
import 'watch_companion_use_cases.dart';
|
import 'watch_companion_use_cases.dart';
|
||||||
|
|
||||||
@ -277,9 +277,9 @@ final class SeedStarterContentUseCase {
|
|||||||
|
|
||||||
await contentRepository.insertStarterContent(
|
await contentRepository.insertStarterContent(
|
||||||
buildStarterContent(
|
buildStarterContent(
|
||||||
exerciseSeeds: basketStarterExerciseSeedsV1,
|
exerciseSeeds: basketStarterExerciseSeedsV2,
|
||||||
programSeed: basketStarterProgramSeedV1,
|
programSeed: basketStarterProgramSeedV2,
|
||||||
templateSeed: basketStarterWorkoutTemplateSeedV1,
|
templateSeed: basketStarterWorkoutTemplateSeedV2,
|
||||||
now: now,
|
now: now,
|
||||||
originDeviceId: originDeviceId,
|
originDeviceId: originDeviceId,
|
||||||
),
|
),
|
||||||
@ -5599,6 +5599,7 @@ final class CloseWorkoutSessionUseCase {
|
|||||||
final class WorkoutTelemetryUseCases {
|
final class WorkoutTelemetryUseCases {
|
||||||
const WorkoutTelemetryUseCases({
|
const WorkoutTelemetryUseCases({
|
||||||
required this.repository,
|
required this.repository,
|
||||||
|
this.sessionRepository,
|
||||||
required this.clock,
|
required this.clock,
|
||||||
required this.ids,
|
required this.ids,
|
||||||
});
|
});
|
||||||
@ -5606,6 +5607,7 @@ final class WorkoutTelemetryUseCases {
|
|||||||
static const persistedSampleInterval = Duration(seconds: 15);
|
static const persistedSampleInterval = Duration(seconds: 15);
|
||||||
|
|
||||||
final WorkoutTelemetryRepository repository;
|
final WorkoutTelemetryRepository repository;
|
||||||
|
final ActiveSessionRepository? sessionRepository;
|
||||||
final Clock clock;
|
final Clock clock;
|
||||||
final IdGenerator ids;
|
final IdGenerator ids;
|
||||||
|
|
||||||
@ -5622,6 +5624,14 @@ final class WorkoutTelemetryUseCases {
|
|||||||
return _telemetrySamplesFromHistorySnapshot(history);
|
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({
|
Future<WorkoutTelemetryGraphSeries> readGraphSeriesForHistory({
|
||||||
required WorkoutHistory history,
|
required WorkoutHistory history,
|
||||||
required WorkoutTelemetryAggregateScope scope,
|
required WorkoutTelemetryAggregateScope scope,
|
||||||
@ -5630,6 +5640,56 @@ final class WorkoutTelemetryUseCases {
|
|||||||
int? setIndex,
|
int? setIndex,
|
||||||
int? passageIndex,
|
int? passageIndex,
|
||||||
int? stepIndex,
|
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 {
|
}) async {
|
||||||
final sourceSessionId = history.sourceActiveWorkoutSessionId?.trim();
|
final sourceSessionId = history.sourceActiveWorkoutSessionId?.trim();
|
||||||
var samples = const <WorkoutTelemetrySample>[];
|
var samples = const <WorkoutTelemetrySample>[];
|
||||||
@ -5644,53 +5704,128 @@ final class WorkoutTelemetryUseCases {
|
|||||||
stepIndex: stepIndex,
|
stepIndex: stepIndex,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (samples.isEmpty) {
|
if (samples.isNotEmpty) {
|
||||||
samples = _telemetrySamplesFromHistorySnapshot(history)
|
return samples;
|
||||||
.where(
|
|
||||||
(sample) => _matchesTelemetryScope(
|
|
||||||
sample,
|
|
||||||
scope: scope,
|
|
||||||
programIndex: programIndex,
|
|
||||||
exerciseIndex: exerciseIndex,
|
|
||||||
setIndex: setIndex,
|
|
||||||
passageIndex: passageIndex,
|
|
||||||
stepIndex: stepIndex,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(growable: false);
|
|
||||||
}
|
}
|
||||||
return _telemetryGraphSeriesFromSamples(
|
return _telemetrySamplesFromHistorySnapshot(history)
|
||||||
samples,
|
.where(
|
||||||
scope: scope,
|
(sample) => _matchesTelemetryScope(
|
||||||
programIndex: programIndex,
|
sample,
|
||||||
exerciseIndex: exerciseIndex,
|
scope: scope,
|
||||||
setIndex: setIndex,
|
programIndex: programIndex,
|
||||||
passageIndex: passageIndex,
|
exerciseIndex: exerciseIndex,
|
||||||
stepIndex: stepIndex,
|
setIndex: setIndex,
|
||||||
);
|
passageIndex: passageIndex,
|
||||||
|
stepIndex: stepIndex,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<WorkoutTelemetryAggregate>> recordTelemetrySample(
|
Future<List<WorkoutTelemetryAggregate>> recordTelemetrySample(
|
||||||
WatchTelemetrySample sample,
|
WatchTelemetrySample sample,
|
||||||
) async {
|
) async {
|
||||||
final domainSample = _telemetrySampleFromWatch(sample);
|
final pendingSample = _telemetryWindowStateFromWatch(sample);
|
||||||
if (domainSample == null) {
|
if (pendingSample == null) {
|
||||||
return const [];
|
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) {
|
if (!inserted) {
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
final samples = await repository.listSamples(domainSample.sessionId);
|
final samples = await repository.listSamples(consumedSample.sessionId);
|
||||||
final aggregates = _telemetryAggregatesFromSamples(samples);
|
final aggregates = _telemetryAggregatesFromSamples(samples);
|
||||||
await repository.replaceAggregatesForSession(
|
await repository.replaceAggregatesForSession(
|
||||||
sessionId: domainSample.sessionId,
|
sessionId: consumedSample.sessionId,
|
||||||
aggregates: aggregates,
|
aggregates: aggregates,
|
||||||
);
|
);
|
||||||
return 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,
|
WatchTelemetrySample sample,
|
||||||
) {
|
) {
|
||||||
final sessionId = sample.sessionId.trim();
|
final sessionId = sample.sessionId.trim();
|
||||||
@ -5709,10 +5844,10 @@ final class WorkoutTelemetryUseCases {
|
|||||||
isUtc: true,
|
isUtc: true,
|
||||||
)
|
)
|
||||||
: clock.now();
|
: clock.now();
|
||||||
return WorkoutTelemetrySample(
|
return ActiveWorkoutTelemetryWindowState(
|
||||||
id: _telemetrySampleId(sample, sessionId, capturedAt),
|
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
capturedAt: capturedAt,
|
windowStartedActiveMs: 0,
|
||||||
|
latestCapturedAt: capturedAt,
|
||||||
programIndex: sample.programIndex,
|
programIndex: sample.programIndex,
|
||||||
exerciseIndex: sample.exerciseIndex,
|
exerciseIndex: sample.exerciseIndex,
|
||||||
setIndex: sample.setIndex,
|
setIndex: sample.setIndex,
|
||||||
@ -5727,16 +5862,8 @@ final class WorkoutTelemetryUseCases {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _telemetrySampleId(
|
String _telemetrySampleId(String sessionId, int windowStartedActiveMs) {
|
||||||
WatchTelemetrySample sample,
|
return 'telemetry:$sessionId:$windowStartedActiveMs';
|
||||||
String sessionId,
|
|
||||||
DateTime capturedAt,
|
|
||||||
) {
|
|
||||||
final bucketMs =
|
|
||||||
capturedAt.millisecondsSinceEpoch ~/
|
|
||||||
persistedSampleInterval.inMilliseconds *
|
|
||||||
persistedSampleInterval.inMilliseconds;
|
|
||||||
return 'telemetry:$sessionId:$bucketMs';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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(
|
Map<String, Object?> _workoutTelemetrySampleSnapshotJson(
|
||||||
WorkoutTelemetrySample sample,
|
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 {
|
final class _TelemetryAggregateBuilder {
|
||||||
_TelemetryAggregateBuilder(this.key);
|
_TelemetryAggregateBuilder(this.key);
|
||||||
|
|
||||||
@ -6099,6 +6401,7 @@ final class ActiveWorkoutSensorState {
|
|||||||
this.averageHeartRateBpm,
|
this.averageHeartRateBpm,
|
||||||
this.maxHeartRateBpm,
|
this.maxHeartRateBpm,
|
||||||
this.latestDistanceMeters,
|
this.latestDistanceMeters,
|
||||||
|
required this.latestDistanceAvailable,
|
||||||
this.latestCaloriesKcal,
|
this.latestCaloriesKcal,
|
||||||
required this.estimatedCaloriesKcal,
|
required this.estimatedCaloriesKcal,
|
||||||
});
|
});
|
||||||
@ -6112,6 +6415,7 @@ final class ActiveWorkoutSensorState {
|
|||||||
final double? averageHeartRateBpm;
|
final double? averageHeartRateBpm;
|
||||||
final int? maxHeartRateBpm;
|
final int? maxHeartRateBpm;
|
||||||
final double? latestDistanceMeters;
|
final double? latestDistanceMeters;
|
||||||
|
final bool latestDistanceAvailable;
|
||||||
final double? latestCaloriesKcal;
|
final double? latestCaloriesKcal;
|
||||||
final double estimatedCaloriesKcal;
|
final double estimatedCaloriesKcal;
|
||||||
}
|
}
|
||||||
@ -6191,6 +6495,7 @@ final class _ActiveWorkoutSensorAccumulator {
|
|||||||
int? _minHeartRateBpm;
|
int? _minHeartRateBpm;
|
||||||
int? _maxHeartRateBpm;
|
int? _maxHeartRateBpm;
|
||||||
double? _latestDistanceMeters;
|
double? _latestDistanceMeters;
|
||||||
|
var _latestDistanceAvailable = false;
|
||||||
double? _latestCaloriesKcal;
|
double? _latestCaloriesKcal;
|
||||||
|
|
||||||
ActiveWorkoutSensorState get snapshot {
|
ActiveWorkoutSensorState get snapshot {
|
||||||
@ -6207,6 +6512,7 @@ final class _ActiveWorkoutSensorAccumulator {
|
|||||||
averageHeartRateBpm: averageHeartRateBpm,
|
averageHeartRateBpm: averageHeartRateBpm,
|
||||||
maxHeartRateBpm: _maxHeartRateBpm,
|
maxHeartRateBpm: _maxHeartRateBpm,
|
||||||
latestDistanceMeters: _latestDistanceMeters,
|
latestDistanceMeters: _latestDistanceMeters,
|
||||||
|
latestDistanceAvailable: _latestDistanceAvailable,
|
||||||
latestCaloriesKcal: _latestCaloriesKcal,
|
latestCaloriesKcal: _latestCaloriesKcal,
|
||||||
estimatedCaloriesKcal: _estimatedCaloriesKcal(
|
estimatedCaloriesKcal: _estimatedCaloriesKcal(
|
||||||
averageHeartRateBpm: averageHeartRateBpm ?? 0,
|
averageHeartRateBpm: averageHeartRateBpm ?? 0,
|
||||||
@ -6253,6 +6559,7 @@ final class _ActiveWorkoutSensorAccumulator {
|
|||||||
distanceMeters >= _latestDistanceMeters!)) {
|
distanceMeters >= _latestDistanceMeters!)) {
|
||||||
_latestDistanceMeters = distanceMeters;
|
_latestDistanceMeters = distanceMeters;
|
||||||
}
|
}
|
||||||
|
_latestDistanceAvailable = distanceMeters != null;
|
||||||
if (caloriesKcal != null &&
|
if (caloriesKcal != null &&
|
||||||
(_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) {
|
(_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) {
|
||||||
_latestCaloriesKcal = caloriesKcal;
|
_latestCaloriesKcal = caloriesKcal;
|
||||||
|
|||||||
@ -1681,6 +1681,76 @@ final class WorkoutTelemetrySample {
|
|||||||
final double? caloriesKcal;
|
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 {
|
final class WorkoutTelemetryAggregate {
|
||||||
WorkoutTelemetryAggregate({
|
WorkoutTelemetryAggregate({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
@ -1793,6 +1863,51 @@ final class WorkoutTelemetryGraphSeries {
|
|||||||
final int? maxHeartRateBpm;
|
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 {
|
final class WorkoutHistorySetResult {
|
||||||
WorkoutHistorySetResult({
|
WorkoutHistorySetResult({
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ part 'app_database.g.dart';
|
|||||||
tables: [
|
tables: [
|
||||||
ActiveExerciseStepProgressStates,
|
ActiveExerciseStepProgressStates,
|
||||||
ActiveExerciseStepResults,
|
ActiveExerciseStepResults,
|
||||||
|
ActiveWorkoutTelemetryWindowStates,
|
||||||
ActiveRestStates,
|
ActiveRestStates,
|
||||||
ActiveManualScoreStates,
|
ActiveManualScoreStates,
|
||||||
ActiveScoreStopwatchStates,
|
ActiveScoreStopwatchStates,
|
||||||
@ -51,7 +52,7 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 25;
|
int get schemaVersion => 26;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@ -141,6 +142,9 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 25) {
|
if (from < 25) {
|
||||||
await _migrateToSchema25();
|
await _migrateToSchema25();
|
||||||
}
|
}
|
||||||
|
if (from < 26) {
|
||||||
|
await _migrateToSchema26(migrator);
|
||||||
|
}
|
||||||
await _createIndexes();
|
await _createIndexes();
|
||||||
},
|
},
|
||||||
beforeOpen: (details) async {
|
beforeOpen: (details) async {
|
||||||
@ -269,6 +273,11 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_samples_session '
|
'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_samples_session '
|
||||||
'ON workout_telemetry_samples (session_id, captured_at)',
|
'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(
|
await customStatement(
|
||||||
'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_aggregates_session '
|
'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_aggregates_session '
|
||||||
'ON workout_telemetry_aggregates (session_id, scope)',
|
'ON workout_telemetry_aggregates (session_id, scope)',
|
||||||
@ -472,6 +481,10 @@ extension on AppDatabase {
|
|||||||
await migrator.createTable(pendingShareActions);
|
await migrator.createTable(pendingShareActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateToSchema26(Migrator migrator) async {
|
||||||
|
await migrator.createTable(activeWorkoutTelemetryWindowStates);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _migrateToSchema13() async {
|
Future<void> _migrateToSchema13() async {
|
||||||
await customStatement('PRAGMA foreign_keys = OFF');
|
await customStatement('PRAGMA foreign_keys = OFF');
|
||||||
await customStatement('''
|
await customStatement('''
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -671,6 +671,7 @@ final class DriftLocalSyncChangeRepository
|
|||||||
.into(database.workoutHistoryStepResults)
|
.into(database.workoutHistoryStepResults)
|
||||||
.insertOnConflictUpdate(_workoutHistoryStepResultCompanion(result));
|
.insertOnConflictUpdate(_workoutHistoryStepResultCompanion(result));
|
||||||
}
|
}
|
||||||
|
await _replaceRemoteWorkoutTelemetry(history);
|
||||||
|
|
||||||
final activeResultIds = history.results
|
final activeResultIds = history.results
|
||||||
.map((result) => result.metadata.id)
|
.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({
|
Future<void> _softDeleteRemoteWorkoutHistoryChildren({
|
||||||
required String tableName,
|
required String tableName,
|
||||||
required String historyId,
|
required String historyId,
|
||||||
@ -2150,6 +2189,34 @@ final class DriftWorkoutTelemetryRepository
|
|||||||
return true;
|
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
|
@override
|
||||||
Future<List<domain.WorkoutTelemetrySample>> listSamples(
|
Future<List<domain.WorkoutTelemetrySample>> listSamples(
|
||||||
String sessionId,
|
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(
|
db.WorkoutTelemetryAggregatesCompanion _workoutTelemetryAggregateCompanion(
|
||||||
domain.WorkoutTelemetryAggregate aggregate,
|
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(
|
bool _telemetrySampleMatchesScope(
|
||||||
domain.WorkoutTelemetrySample sample, {
|
domain.WorkoutTelemetrySample sample, {
|
||||||
required domain.WorkoutTelemetryAggregateScope scope,
|
required domain.WorkoutTelemetryAggregateScope scope,
|
||||||
@ -5806,6 +5917,9 @@ Map<String, Object?> _localWorkoutHistoryPayload(
|
|||||||
'stepResults': history.stepResults
|
'stepResults': history.stepResults
|
||||||
.map(_workoutHistoryStepResultPayload)
|
.map(_workoutHistoryStepResultPayload)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
'telemetrySamples': _workoutTelemetrySamplesFromHistoryPayload(
|
||||||
|
history,
|
||||||
|
).map(_workoutTelemetrySamplePayload).toList(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Map<String, Object?> _workoutHistorySetResultPayload(
|
Map<String, Object?> _workoutHistorySetResultPayload(
|
||||||
@ -5875,6 +5989,248 @@ Map<String, Object?> _workoutHistoryStepResultPayload(
|
|||||||
'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot,
|
'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(
|
LocalBackupResource _backupResource(
|
||||||
domain.EntityMetadata metadata,
|
domain.EntityMetadata metadata,
|
||||||
Map<String, Object?> payload,
|
Map<String, Object?> payload,
|
||||||
@ -5937,6 +6293,7 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
|
|||||||
) {
|
) {
|
||||||
final payload = item.payload;
|
final payload = item.payload;
|
||||||
final metadata = _metadataFromPayload(item);
|
final metadata = _metadataFromPayload(item);
|
||||||
|
final historySnapshotJson = _historySnapshotJsonWithTelemetryPayload(payload);
|
||||||
return domain.WorkoutHistory(
|
return domain.WorkoutHistory(
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
sourceWorkoutTemplateId: payload['sourceWorkoutTemplateId'] as String?,
|
sourceWorkoutTemplateId: payload['sourceWorkoutTemplateId'] as String?,
|
||||||
@ -5948,8 +6305,7 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
|
|||||||
endedAt: _dateTimeFromPayload(payload['endedAt']) ?? item.clientUpdatedAt,
|
endedAt: _dateTimeFromPayload(payload['endedAt']) ?? item.clientUpdatedAt,
|
||||||
totalActiveMs: payload['totalActiveMs'] as int? ?? 0,
|
totalActiveMs: payload['totalActiveMs'] as int? ?? 0,
|
||||||
completed: payload['completed'] as bool? ?? false,
|
completed: payload['completed'] as bool? ?? false,
|
||||||
historySnapshotJson:
|
historySnapshotJson: historySnapshotJson,
|
||||||
payload['historySnapshotJson'] as String? ?? '{"programs":[]}',
|
|
||||||
minHeartRateBpm: payload['minHeartRateBpm'] as int?,
|
minHeartRateBpm: payload['minHeartRateBpm'] as int?,
|
||||||
averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(),
|
averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(),
|
||||||
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
|
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) {
|
domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
|
||||||
final payload = item.payload;
|
final payload = item.payload;
|
||||||
return domain.Exercise(
|
return domain.Exercise(
|
||||||
|
|||||||
@ -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 {
|
class WorkoutTelemetryAggregates extends Table {
|
||||||
@override
|
@override
|
||||||
String get tableName => 'workout_telemetry_aggregates';
|
String get tableName => 'workout_telemetry_aggregates';
|
||||||
|
|||||||
@ -18,6 +18,9 @@ final class HttpApiClient {
|
|||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static const androidEmulatorDefaultBaseUrl = 'http://10.0.2.2:8090';
|
||||||
|
static const localDefaultBaseUrl = 'http://localhost:8080';
|
||||||
|
|
||||||
static String get defaultBaseUrl =>
|
static String get defaultBaseUrl =>
|
||||||
defaultBaseUrlFor(isAndroid: Platform.isAndroid);
|
defaultBaseUrlFor(isAndroid: Platform.isAndroid);
|
||||||
|
|
||||||
@ -30,9 +33,9 @@ final class HttpApiClient {
|
|||||||
return configured;
|
return configured;
|
||||||
}
|
}
|
||||||
if (isAndroid) {
|
if (isAndroid) {
|
||||||
return 'http://10.0.2.2:8080';
|
return androidEmulatorDefaultBaseUrl;
|
||||||
}
|
}
|
||||||
return 'http://localhost:8080';
|
return localDefaultBaseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
final Uri baseUrl;
|
final Uri baseUrl;
|
||||||
|
|||||||
@ -43,6 +43,7 @@ final class ExerciseLibraryScreen extends StatefulWidget {
|
|||||||
|
|
||||||
final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
|
final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
|
var _selectedBusinessTypes = <BusinessExerciseType?>{};
|
||||||
var _selectedMeasures = <WorkoutMeasure>{};
|
var _selectedMeasures = <WorkoutMeasure>{};
|
||||||
var _selectedTags = <String>{};
|
var _selectedTags = <String>{};
|
||||||
late Future<List<Exercise>> _exercises;
|
late Future<List<Exercise>> _exercises;
|
||||||
@ -84,6 +85,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final exercises = snapshot.data ?? const <Exercise>[];
|
final exercises = snapshot.data ?? const <Exercise>[];
|
||||||
|
final typeFilterOptions = _typeFilterOptionsFor(exercises);
|
||||||
final tagSuggestions = tagSuggestionsFor(
|
final tagSuggestions = tagSuggestionsFor(
|
||||||
exercises,
|
exercises,
|
||||||
(exercise) => exercise.tags,
|
(exercise) => exercise.tags,
|
||||||
@ -110,6 +112,41 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
@ -169,7 +206,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
|
|||||||
child: _CenteredMessage(
|
child: _CenteredMessage(
|
||||||
title: 'Aucun exercice ne correspond',
|
title: 'Aucun exercice ne correspond',
|
||||||
message:
|
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',
|
actionLabel: 'Effacer les filtres',
|
||||||
onAction: _clearFilters,
|
onAction: _clearFilters,
|
||||||
),
|
),
|
||||||
@ -206,7 +243,15 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
|
|||||||
final matchesMeasures =
|
final matchesMeasures =
|
||||||
_selectedMeasures.isEmpty ||
|
_selectedMeasures.isEmpty ||
|
||||||
_selectedMeasures.every(exercise.availableMeasures.contains);
|
_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();
|
}).toList();
|
||||||
return filterByRequiredTags(
|
return filterByRequiredTags(
|
||||||
matchingQueryAndMeasures,
|
matchingQueryAndMeasures,
|
||||||
@ -217,6 +262,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
|
|||||||
|
|
||||||
bool get _filtersActive {
|
bool get _filtersActive {
|
||||||
return _searchController.text.trim().isNotEmpty ||
|
return _searchController.text.trim().isNotEmpty ||
|
||||||
|
_selectedBusinessTypes.isNotEmpty ||
|
||||||
_selectedMeasures.isNotEmpty ||
|
_selectedMeasures.isNotEmpty ||
|
||||||
_selectedTags.isNotEmpty;
|
_selectedTags.isNotEmpty;
|
||||||
}
|
}
|
||||||
@ -234,6 +280,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
|
|||||||
void _clearFilters() {
|
void _clearFilters() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
|
_selectedBusinessTypes = {};
|
||||||
_selectedMeasures = {};
|
_selectedMeasures = {};
|
||||||
_selectedTags = {};
|
_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 {
|
final class ExerciseListTile extends StatelessWidget {
|
||||||
const ExerciseListTile({
|
const ExerciseListTile({
|
||||||
required this.exercise,
|
required this.exercise,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2332,7 +2332,7 @@ final class _LiveSensorBar extends StatelessWidget {
|
|||||||
_LiveSensorPill(
|
_LiveSensorPill(
|
||||||
icon: Icons.directions_run,
|
icon: Icons.directions_run,
|
||||||
label: _distanceMetricLabel(sensorState),
|
label: _distanceMetricLabel(sensorState),
|
||||||
muted: _distanceLabel(sensorState) == null,
|
muted: !_isDistanceAvailable(sensorState),
|
||||||
),
|
),
|
||||||
_LiveSensorPill(
|
_LiveSensorPill(
|
||||||
icon: Icons.local_fire_department,
|
icon: Icons.local_fire_department,
|
||||||
@ -5332,6 +5332,9 @@ String _heartRateMetricLabel(ActiveWorkoutSensorState? state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _distanceMetricLabel(ActiveWorkoutSensorState? state) {
|
String _distanceMetricLabel(ActiveWorkoutSensorState? state) {
|
||||||
|
if (!_isDistanceAvailable(state)) {
|
||||||
|
return _sensorUnavailableLabel(state);
|
||||||
|
}
|
||||||
return _distanceLabel(state) ?? _sensorUnavailableLabel(state);
|
return _distanceLabel(state) ?? _sensorUnavailableLabel(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5357,6 +5360,12 @@ String? _distanceLabel(ActiveWorkoutSensorState? state) {
|
|||||||
return '${meters.round()} m';
|
return '${meters.round()} m';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isDistanceAvailable(ActiveWorkoutSensorState? state) {
|
||||||
|
return state != null &&
|
||||||
|
state.latestDistanceAvailable &&
|
||||||
|
_distanceLabel(state) != null;
|
||||||
|
}
|
||||||
|
|
||||||
String? _caloriesLabel(ActiveWorkoutSensorState? state) {
|
String? _caloriesLabel(ActiveWorkoutSensorState? state) {
|
||||||
final actualCalories = state?.latestCaloriesKcal;
|
final actualCalories = state?.latestCaloriesKcal;
|
||||||
if (actualCalories != null && actualCalories >= 0) {
|
if (actualCalories != null && actualCalories >= 0) {
|
||||||
|
|||||||
@ -69,6 +69,21 @@ Expected response:
|
|||||||
{"status":"ok"}
|
{"status":"ok"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Flutter Client URL
|
||||||
|
|
||||||
|
The Android emulator default client URL is `http://10.0.2.2:8090`, which maps to
|
||||||
|
port `8090` on the host machine. This matches the common Docker Compose setup
|
||||||
|
where `server/.env` exposes `API_PORT=8090` while the API container still
|
||||||
|
listens internally on `8080`.
|
||||||
|
|
||||||
|
Override the client URL at build or run time when the server uses another host
|
||||||
|
address or port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter run --dart-define=GAMETIME_API_BASE_URL=http://192.168.1.75:8090
|
||||||
|
flutter build apk --debug --dart-define=GAMETIME_API_BASE_URL=http://192.168.1.75:8090
|
||||||
|
```
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Ticket #48 adds account registration, login, logout and bearer-token request
|
Ticket #48 adds account registration, login, logout and bearer-token request
|
||||||
|
|||||||
@ -53,6 +53,13 @@ should be HTTPS through the reverse proxy.
|
|||||||
curl http://localhost:8080/health
|
curl http://localhost:8080/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- For an Android emulator client, confirm the APK was built with the default
|
||||||
|
`http://10.0.2.2:8090` URL or with an explicit server URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter build apk --debug --dart-define=GAMETIME_API_BASE_URL=http://192.168.1.75:8090
|
||||||
|
```
|
||||||
|
|
||||||
- Register a user with `POST /auth/register`.
|
- Register a user with `POST /auth/register`.
|
||||||
- Login with `POST /auth/login` and store the returned bearer token.
|
- Login with `POST /auth/login` and store the returned bearer token.
|
||||||
- Call a protected endpoint without a token and confirm `401`.
|
- Call a protected endpoint without a token and confirm `401`.
|
||||||
|
|||||||
22
server/test/fixtures/README.md
vendored
Normal file
22
server/test/fixtures/README.md
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Versioned sync fixtures
|
||||||
|
|
||||||
|
These JSON files are server contract fixtures. They intentionally model client
|
||||||
|
payloads as opaque snapshots: server sync tests must push them, pull them back
|
||||||
|
and compare the payloads strictly without teaching the server the client schema.
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
|
||||||
|
- `exercises/v1/`: first captured exercise fixture batch.
|
||||||
|
- `exercises/v2/`: second captured exercise fixture batch used to prove tests
|
||||||
|
can run multiple fixture generations side by side.
|
||||||
|
- `programs/v1/`, `workout_templates/v1/`, `workout_histories/v1/`: reusable
|
||||||
|
minimal sync batches for non-exercise resources.
|
||||||
|
|
||||||
|
Folder names such as `v1` and `v2` identify fixture batches, not the payload
|
||||||
|
schema itself. The authoritative client schema value remains the JSON
|
||||||
|
`schemaVersion` field inside each fixture, and tests assert that the server
|
||||||
|
preserves that value exactly.
|
||||||
|
|
||||||
|
When the client schema changes, add a new fixture batch folder instead of
|
||||||
|
rewriting older fixtures. Old fixture versions are kept to verify backward and
|
||||||
|
forward compatibility.
|
||||||
51
server/test/fixtures/exercises/v1/full_combo.json
vendored
Normal file
51
server/test/fixtures/exercises/v1/full_combo.json
vendored
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 5,
|
||||||
|
"id": "exercise-full-combo",
|
||||||
|
"name": "Intervals complex",
|
||||||
|
"type": "mixed",
|
||||||
|
"category": "full_body",
|
||||||
|
"media": {
|
||||||
|
"coverAssetId": "media-cover-1",
|
||||||
|
"videoAssetId": "media-video-1"
|
||||||
|
},
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "step-row",
|
||||||
|
"order": 0,
|
||||||
|
"title": "Row",
|
||||||
|
"body": "Complete calories before moving on.",
|
||||||
|
"defaultDurationSeconds": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step-thruster",
|
||||||
|
"order": 1,
|
||||||
|
"title": "Thruster",
|
||||||
|
"body": "Break sets only if form degrades.",
|
||||||
|
"defaultReps": 15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": {
|
||||||
|
"mode": "for_time",
|
||||||
|
"unit": "seconds",
|
||||||
|
"capSeconds": 900
|
||||||
|
},
|
||||||
|
"chrono": {
|
||||||
|
"mode": "elapsed",
|
||||||
|
"autoStart": true
|
||||||
|
},
|
||||||
|
"timers": {
|
||||||
|
"preparationSeconds": 20,
|
||||||
|
"workSeconds": 180,
|
||||||
|
"restSeconds": 60
|
||||||
|
},
|
||||||
|
"chainOverrides": {
|
||||||
|
"nextExerciseId": "exercise-cooldown",
|
||||||
|
"inheritRest": false
|
||||||
|
},
|
||||||
|
"legacy": {
|
||||||
|
"unknownClientField": {
|
||||||
|
"nested": ["preserve", 1, true, null]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updatedAt": "2026-07-19T10:20:00.000Z"
|
||||||
|
}
|
||||||
8
server/test/fixtures/exercises/v1/minimal.json
vendored
Normal file
8
server/test/fixtures/exercises/v1/minimal.json
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "exercise-minimal",
|
||||||
|
"name": "Air squat",
|
||||||
|
"type": "strength",
|
||||||
|
"category": "legs",
|
||||||
|
"updatedAt": "2026-07-19T10:00:00.000Z"
|
||||||
|
}
|
||||||
17
server/test/fixtures/exercises/v1/with_score_chrono.json
vendored
Normal file
17
server/test/fixtures/exercises/v1/with_score_chrono.json
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 3,
|
||||||
|
"id": "exercise-score-chrono",
|
||||||
|
"name": "Shuttle run",
|
||||||
|
"type": "conditioning",
|
||||||
|
"score": {
|
||||||
|
"mode": "rounds_reps",
|
||||||
|
"unit": "reps",
|
||||||
|
"target": 120
|
||||||
|
},
|
||||||
|
"chrono": {
|
||||||
|
"mode": "countdown",
|
||||||
|
"durationSeconds": 600,
|
||||||
|
"warningSeconds": [60, 10]
|
||||||
|
},
|
||||||
|
"updatedAt": "2026-07-19T10:10:00.000Z"
|
||||||
|
}
|
||||||
21
server/test/fixtures/exercises/v1/with_steps.json
vendored
Normal file
21
server/test/fixtures/exercises/v1/with_steps.json
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"id": "exercise-with-steps",
|
||||||
|
"name": "Kettlebell swing",
|
||||||
|
"type": "strength",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "step-setup",
|
||||||
|
"order": 0,
|
||||||
|
"title": "Setup",
|
||||||
|
"body": "Hinge with the bell slightly in front of the feet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "step-drive",
|
||||||
|
"order": 1,
|
||||||
|
"title": "Drive",
|
||||||
|
"body": "Extend the hips and let the bell float to chest height."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-07-19T10:05:00.000Z"
|
||||||
|
}
|
||||||
18
server/test/fixtures/exercises/v1/with_timers.json
vendored
Normal file
18
server/test/fixtures/exercises/v1/with_timers.json
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 4,
|
||||||
|
"id": "exercise-with-timers",
|
||||||
|
"name": "Tempo bench press",
|
||||||
|
"type": "strength",
|
||||||
|
"timers": {
|
||||||
|
"preparationSeconds": 15,
|
||||||
|
"workSeconds": 45,
|
||||||
|
"restSeconds": 90,
|
||||||
|
"transitionSeconds": 10
|
||||||
|
},
|
||||||
|
"defaultTargets": {
|
||||||
|
"sets": 5,
|
||||||
|
"reps": 5,
|
||||||
|
"weightKg": 80
|
||||||
|
},
|
||||||
|
"updatedAt": "2026-07-19T10:15:00.000Z"
|
||||||
|
}
|
||||||
16
server/test/fixtures/exercises/v2/minimal.json
vendored
Normal file
16
server/test/fixtures/exercises/v2/minimal.json
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 6,
|
||||||
|
"id": "exercise-minimal-v2",
|
||||||
|
"name": "Air squat",
|
||||||
|
"type": "strength",
|
||||||
|
"category": "legs",
|
||||||
|
"defaultTargets": {
|
||||||
|
"sets": 3,
|
||||||
|
"reps": 12
|
||||||
|
},
|
||||||
|
"clientFormat": {
|
||||||
|
"versionFolder": "v2",
|
||||||
|
"migratedFrom": "exercises/v1/minimal"
|
||||||
|
},
|
||||||
|
"updatedAt": "2026-07-20T10:00:00.000Z"
|
||||||
|
}
|
||||||
7
server/test/fixtures/programs/v1/minimal.json
vendored
Normal file
7
server/test/fixtures/programs/v1/minimal.json
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "program-minimal",
|
||||||
|
"name": "Starter strength",
|
||||||
|
"exerciseIds": ["exercise-minimal"],
|
||||||
|
"updatedAt": "2026-07-19T10:30:00.000Z"
|
||||||
|
}
|
||||||
101
server/test/fixtures/sync_fixtures.dart
vendored
Normal file
101
server/test/fixtures/sync_fixtures.dart
vendored
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
final class VersionedSyncFixture {
|
||||||
|
const VersionedSyncFixture({
|
||||||
|
required this.resourceType,
|
||||||
|
required this.name,
|
||||||
|
required this.schemaVersion,
|
||||||
|
required this.payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String resourceType;
|
||||||
|
final String name;
|
||||||
|
final int schemaVersion;
|
||||||
|
final Map<String, Object?> payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionedSyncFixture loadExerciseFixture(String name, {String version = 'v1'}) {
|
||||||
|
return _loadFixture(
|
||||||
|
resourceType: 'exercise',
|
||||||
|
path: 'test/fixtures/exercises/$version/$name.json',
|
||||||
|
name: name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionedSyncFixture loadProgramFixture(String name, {String version = 'v1'}) {
|
||||||
|
return _loadFixture(
|
||||||
|
resourceType: 'program',
|
||||||
|
path: 'test/fixtures/programs/$version/$name.json',
|
||||||
|
name: name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionedSyncFixture loadWorkoutTemplateFixture(
|
||||||
|
String name, {
|
||||||
|
String version = 'v1',
|
||||||
|
}) {
|
||||||
|
return _loadFixture(
|
||||||
|
resourceType: 'workoutTemplate',
|
||||||
|
path: 'test/fixtures/workout_templates/$version/$name.json',
|
||||||
|
name: name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionedSyncFixture loadWorkoutHistoryFixture(
|
||||||
|
String name, {
|
||||||
|
String version = 'v1',
|
||||||
|
}) {
|
||||||
|
return _loadFixture(
|
||||||
|
resourceType: 'workoutHistory',
|
||||||
|
path: 'test/fixtures/workout_histories/$version/$name.json',
|
||||||
|
name: name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<VersionedSyncFixture> loadExerciseFixtures({
|
||||||
|
String version = 'v1',
|
||||||
|
List<String> names = const [
|
||||||
|
'minimal',
|
||||||
|
'with_steps',
|
||||||
|
'with_score_chrono',
|
||||||
|
'with_timers',
|
||||||
|
'full_combo',
|
||||||
|
],
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
for (final name in names) loadExerciseFixture(name, version: version),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<VersionedSyncFixture> loadMinimalResourceFixtures({
|
||||||
|
String version = 'v1',
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
loadProgramFixture('minimal', version: version),
|
||||||
|
loadWorkoutTemplateFixture('minimal', version: version),
|
||||||
|
loadWorkoutHistoryFixture('minimal', version: version),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionedSyncFixture _loadFixture({
|
||||||
|
required String resourceType,
|
||||||
|
required String path,
|
||||||
|
required String name,
|
||||||
|
}) {
|
||||||
|
final raw = File(path).readAsStringSync();
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map<String, Object?>) {
|
||||||
|
throw StateError('Fixture $path must contain a JSON object.');
|
||||||
|
}
|
||||||
|
final schemaVersion = decoded['schemaVersion'];
|
||||||
|
if (schemaVersion is! int || schemaVersion <= 0) {
|
||||||
|
throw StateError('Fixture $path must contain a positive schemaVersion.');
|
||||||
|
}
|
||||||
|
return VersionedSyncFixture(
|
||||||
|
resourceType: resourceType,
|
||||||
|
name: name,
|
||||||
|
schemaVersion: schemaVersion,
|
||||||
|
payload: decoded,
|
||||||
|
);
|
||||||
|
}
|
||||||
19
server/test/fixtures/workout_histories/v1/minimal.json
vendored
Normal file
19
server/test/fixtures/workout_histories/v1/minimal.json
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "history-minimal",
|
||||||
|
"templateId": "template-minimal",
|
||||||
|
"startedAt": "2026-07-19T11:00:00.000Z",
|
||||||
|
"finishedAt": "2026-07-19T11:45:00.000Z",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"exerciseId": "exercise-minimal",
|
||||||
|
"sets": [
|
||||||
|
{
|
||||||
|
"reps": 8,
|
||||||
|
"weightKg": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-07-19T11:45:00.000Z"
|
||||||
|
}
|
||||||
13
server/test/fixtures/workout_templates/v1/minimal.json
vendored
Normal file
13
server/test/fixtures/workout_templates/v1/minimal.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "template-minimal",
|
||||||
|
"name": "Full body A",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"exerciseId": "exercise-minimal",
|
||||||
|
"sets": 3,
|
||||||
|
"reps": 8
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedAt": "2026-07-19T10:35:00.000Z"
|
||||||
|
}
|
||||||
@ -7,6 +7,8 @@ import 'package:gametime_server/domain/domain.dart';
|
|||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import 'fixtures/sync_fixtures.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('sync routes require bearer authentication', () async {
|
test('sync routes require bearer authentication', () async {
|
||||||
final handler = buildApiHandler(syncApi: _syncApi());
|
final handler = buildApiHandler(syncApi: _syncApi());
|
||||||
@ -63,6 +65,350 @@ void main() {
|
|||||||
expect(repository.items.single.clientId, 'exercise-1');
|
expect(repository.items.single.clientId, 'exercise-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'push and pull round-trip versioned exercise fixtures strictly',
|
||||||
|
() async {
|
||||||
|
final repository = _FakeSyncedResourceRepository();
|
||||||
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||||
|
final fixtures = loadExerciseFixtures();
|
||||||
|
|
||||||
|
final pushResponse = await handler(
|
||||||
|
Request(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('http://localhost/sync/push'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'deviceId': 'device-1',
|
||||||
|
'items': [
|
||||||
|
for (var index = 0; index < fixtures.length; index += 1)
|
||||||
|
{
|
||||||
|
'resourceType': fixtures[index].resourceType,
|
||||||
|
'clientId': 'exercise-${fixtures[index].name}',
|
||||||
|
'schemaVersion': fixtures[index].schemaVersion,
|
||||||
|
'clientUpdatedAt':
|
||||||
|
'2026-07-19T10:${index.toString().padLeft(2, '0')}:00Z',
|
||||||
|
'deletedAt': null,
|
||||||
|
'payload': fixtures[index].payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;
|
||||||
|
|
||||||
|
final pullResponse = await handler(
|
||||||
|
Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/sync/pull'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final pullBody = jsonDecode(await pullResponse.readAsString()) as Map;
|
||||||
|
final pulledItems = pullBody['items'] as List;
|
||||||
|
|
||||||
|
expect(pushResponse.statusCode, 200);
|
||||||
|
expect(
|
||||||
|
(pushBody['results'] as List).map((item) => (item as Map)['status']),
|
||||||
|
everyElement('accepted'),
|
||||||
|
);
|
||||||
|
expect(pullResponse.statusCode, 200);
|
||||||
|
expect(pulledItems, hasLength(fixtures.length));
|
||||||
|
for (final fixture in fixtures) {
|
||||||
|
final pulled = pulledItems.cast<Map>().singleWhere(
|
||||||
|
(item) => item['clientId'] == 'exercise-${fixture.name}',
|
||||||
|
);
|
||||||
|
expect(pulled['resourceType'], 'exercise');
|
||||||
|
expect(pulled['schemaVersion'], fixture.schemaVersion);
|
||||||
|
expect(pulled['payload'], fixture.payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('push and pull round-trip workoutTemplate fixture strictly', () async {
|
||||||
|
final repository = _FakeSyncedResourceRepository();
|
||||||
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||||
|
final fixture = loadWorkoutTemplateFixture('minimal');
|
||||||
|
|
||||||
|
final pushResponse = await handler(
|
||||||
|
Request(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('http://localhost/sync/push'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'deviceId': 'device-1',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'resourceType': fixture.resourceType,
|
||||||
|
'clientId': 'template-minimal',
|
||||||
|
'schemaVersion': fixture.schemaVersion,
|
||||||
|
'clientUpdatedAt': '2026-07-19T10:30:00Z',
|
||||||
|
'payload': fixture.payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;
|
||||||
|
|
||||||
|
final pullResponse = await handler(
|
||||||
|
Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/sync/pull'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final pullBody = jsonDecode(await pullResponse.readAsString()) as Map;
|
||||||
|
final pulled = (pullBody['items'] as List).cast<Map>().single;
|
||||||
|
|
||||||
|
expect(pushResponse.statusCode, 200);
|
||||||
|
expect(((pushBody['results'] as List).single as Map)['status'], 'accepted');
|
||||||
|
expect(pullResponse.statusCode, 200);
|
||||||
|
expect(pulled['resourceType'], 'workoutTemplate');
|
||||||
|
expect(pulled['clientId'], 'template-minimal');
|
||||||
|
expect(pulled['schemaVersion'], fixture.schemaVersion);
|
||||||
|
expect(pulled['payload'], fixture.payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'push and pull round-trip all reusable minimal resource fixtures',
|
||||||
|
() async {
|
||||||
|
final repository = _FakeSyncedResourceRepository();
|
||||||
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||||
|
final fixtures = loadMinimalResourceFixtures();
|
||||||
|
|
||||||
|
final pushResponse = await handler(
|
||||||
|
Request(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('http://localhost/sync/push'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'deviceId': 'device-1',
|
||||||
|
'items': [
|
||||||
|
for (var index = 0; index < fixtures.length; index += 1)
|
||||||
|
{
|
||||||
|
'resourceType': fixtures[index].resourceType,
|
||||||
|
'clientId': '${fixtures[index].resourceType}-minimal',
|
||||||
|
'schemaVersion': fixtures[index].schemaVersion,
|
||||||
|
'clientUpdatedAt':
|
||||||
|
'2026-07-19T11:${index.toString().padLeft(2, '0')}:00Z',
|
||||||
|
'payload': fixtures[index].payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;
|
||||||
|
|
||||||
|
final pullResponse = await handler(
|
||||||
|
Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/sync/pull'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final pulledItems =
|
||||||
|
(jsonDecode(await pullResponse.readAsString()) as Map)['items']
|
||||||
|
as List;
|
||||||
|
|
||||||
|
expect(pushResponse.statusCode, 200);
|
||||||
|
expect(
|
||||||
|
(pushBody['results'] as List).map((item) => (item as Map)['status']),
|
||||||
|
everyElement('accepted'),
|
||||||
|
);
|
||||||
|
expect(pullResponse.statusCode, 200);
|
||||||
|
for (final fixture in fixtures) {
|
||||||
|
final pulled = pulledItems.cast<Map>().singleWhere(
|
||||||
|
(item) => item['clientId'] == '${fixture.resourceType}-minimal',
|
||||||
|
);
|
||||||
|
expect(pulled['resourceType'], fixture.resourceType);
|
||||||
|
expect(pulled['schemaVersion'], fixture.schemaVersion);
|
||||||
|
expect(pulled['payload'], fixture.payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'push and pull preserve exercise fixture batches across versions',
|
||||||
|
() async {
|
||||||
|
final repository = _FakeSyncedResourceRepository();
|
||||||
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||||
|
final fixtures = [
|
||||||
|
loadExerciseFixture('minimal'),
|
||||||
|
loadExerciseFixture('minimal', version: 'v2'),
|
||||||
|
];
|
||||||
|
|
||||||
|
final pushResponse = await handler(
|
||||||
|
Request(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('http://localhost/sync/push'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'deviceId': 'device-1',
|
||||||
|
'items': [
|
||||||
|
for (var index = 0; index < fixtures.length; index += 1)
|
||||||
|
{
|
||||||
|
'resourceType': 'exercise',
|
||||||
|
'clientId': 'exercise-versioned-$index',
|
||||||
|
'schemaVersion': fixtures[index].schemaVersion,
|
||||||
|
'clientUpdatedAt':
|
||||||
|
'2026-07-19T12:${index.toString().padLeft(2, '0')}:00Z',
|
||||||
|
'payload': fixtures[index].payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final pushBody = jsonDecode(await pushResponse.readAsString()) as Map;
|
||||||
|
|
||||||
|
final pullResponse = await handler(
|
||||||
|
Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/sync/pull'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final pulledItems =
|
||||||
|
(jsonDecode(await pullResponse.readAsString()) as Map)['items']
|
||||||
|
as List;
|
||||||
|
|
||||||
|
expect(pushResponse.statusCode, 200);
|
||||||
|
expect(
|
||||||
|
(pushBody['results'] as List).map((item) => (item as Map)['status']),
|
||||||
|
everyElement('accepted'),
|
||||||
|
);
|
||||||
|
expect(pullResponse.statusCode, 200);
|
||||||
|
for (var index = 0; index < fixtures.length; index += 1) {
|
||||||
|
final pulled = pulledItems.cast<Map>().singleWhere(
|
||||||
|
(item) => item['clientId'] == 'exercise-versioned-$index',
|
||||||
|
);
|
||||||
|
expect(pulled['schemaVersion'], fixtures[index].schemaVersion);
|
||||||
|
expect(pulled['payload'], fixtures[index].payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('push delete stores deletedAt and pull returns the tombstone', () async {
|
||||||
|
final repository = _FakeSyncedResourceRepository();
|
||||||
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||||
|
final fixture = loadWorkoutTemplateFixture('minimal');
|
||||||
|
|
||||||
|
final response = await handler(
|
||||||
|
Request(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('http://localhost/sync/push'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'deviceId': 'device-1',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'resourceType': 'workoutTemplate',
|
||||||
|
'clientId': 'template-delete',
|
||||||
|
'schemaVersion': fixture.schemaVersion,
|
||||||
|
'clientUpdatedAt': '2026-07-19T10:30:00Z',
|
||||||
|
'deletedAt': null,
|
||||||
|
'payload': fixture.payload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'resourceType': 'workoutTemplate',
|
||||||
|
'clientId': 'template-delete',
|
||||||
|
'schemaVersion': fixture.schemaVersion,
|
||||||
|
'clientUpdatedAt': '2026-07-19T10:31:00Z',
|
||||||
|
'deletedAt': '2026-07-19T10:31:00Z',
|
||||||
|
'payload': fixture.payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final body = jsonDecode(await response.readAsString()) as Map;
|
||||||
|
final results = body['results'] as List;
|
||||||
|
|
||||||
|
final pullResponse = await handler(
|
||||||
|
Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/sync/pull'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final pullBody = jsonDecode(await pullResponse.readAsString()) as Map;
|
||||||
|
final pulled = (pullBody['items'] as List).cast<Map>().single;
|
||||||
|
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(results.map((item) => (item as Map)['status']), [
|
||||||
|
'accepted',
|
||||||
|
'accepted',
|
||||||
|
]);
|
||||||
|
expect(pullResponse.statusCode, 200);
|
||||||
|
expect(pulled['resourceType'], 'workoutTemplate');
|
||||||
|
expect(pulled['clientId'], 'template-delete');
|
||||||
|
expect(pulled['deletedAt'], '2026-07-19T10:31:00.000Z');
|
||||||
|
expect(pulled['schemaVersion'], fixture.schemaVersion);
|
||||||
|
expect(pulled['payload'], fixture.payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('push applies LWW accepted, older ignored and equal ignored', () async {
|
||||||
|
final repository = _FakeSyncedResourceRepository();
|
||||||
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||||
|
final original = loadExerciseFixture('with_score_chrono');
|
||||||
|
final stale = loadExerciseFixture('minimal');
|
||||||
|
final newer = loadExerciseFixture('full_combo');
|
||||||
|
|
||||||
|
final response = await handler(
|
||||||
|
Request(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('http://localhost/sync/push'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'deviceId': 'device-1',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'resourceType': 'exercise',
|
||||||
|
'clientId': 'exercise-lww',
|
||||||
|
'schemaVersion': original.schemaVersion,
|
||||||
|
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
||||||
|
'payload': original.payload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'resourceType': 'exercise',
|
||||||
|
'clientId': 'exercise-lww',
|
||||||
|
'schemaVersion': stale.schemaVersion,
|
||||||
|
'clientUpdatedAt': '2026-07-19T09:59:59Z',
|
||||||
|
'payload': stale.payload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'resourceType': 'exercise',
|
||||||
|
'clientId': 'exercise-lww',
|
||||||
|
'schemaVersion': stale.schemaVersion,
|
||||||
|
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
||||||
|
'payload': stale.payload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'resourceType': 'exercise',
|
||||||
|
'clientId': 'exercise-lww',
|
||||||
|
'schemaVersion': newer.schemaVersion,
|
||||||
|
'clientUpdatedAt': '2026-07-19T10:00:01Z',
|
||||||
|
'payload': newer.payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final body = jsonDecode(await response.readAsString()) as Map;
|
||||||
|
final results = body['results'] as List;
|
||||||
|
final stored = repository.items.single;
|
||||||
|
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(results.map((item) => (item as Map)['status']), [
|
||||||
|
'accepted',
|
||||||
|
'ignoredOlder',
|
||||||
|
'ignoredOlder',
|
||||||
|
'accepted',
|
||||||
|
]);
|
||||||
|
expect(stored.schemaVersion, newer.schemaVersion);
|
||||||
|
expect(stored.payloadJson, newer.payload);
|
||||||
|
});
|
||||||
|
|
||||||
test('pull returns synced items and filters them with since query', () async {
|
test('pull returns synced items and filters them with since query', () async {
|
||||||
final repository = _FakeSyncedResourceRepository()
|
final repository = _FakeSyncedResourceRepository()
|
||||||
..items.addAll([
|
..items.addAll([
|
||||||
@ -92,9 +438,7 @@ void main() {
|
|||||||
final response = await handler(
|
final response = await handler(
|
||||||
Request(
|
Request(
|
||||||
'GET',
|
'GET',
|
||||||
Uri.parse(
|
Uri.parse('http://localhost/sync/pull?since=2026-07-19T12:00:00Z'),
|
||||||
'http://localhost/sync/pull?since=2026-07-19T12:00:00Z',
|
|
||||||
),
|
|
||||||
headers: {'authorization': 'Bearer valid-token'},
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -121,12 +465,66 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(response.statusCode, 400);
|
expect(response.statusCode, 400);
|
||||||
expect(
|
expect(jsonDecode(await response.readAsString()), {
|
||||||
jsonDecode(await response.readAsString()),
|
'error': 'Invalid date format',
|
||||||
{'error': 'Invalid date format'},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('push returns 400 when request body is not a json object', () async {
|
||||||
|
final handler = buildApiHandler(syncApi: _syncApi());
|
||||||
|
|
||||||
|
final response = await handler(
|
||||||
|
Request(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('http://localhost/sync/push'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
body: jsonEncode(['not-an-object']),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.statusCode, 400);
|
||||||
|
expect(jsonDecode(await response.readAsString()), {
|
||||||
|
'error': 'Request body must be a JSON object.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'push rejects blank device id per item without storing payloads',
|
||||||
|
() async {
|
||||||
|
final repository = _FakeSyncedResourceRepository();
|
||||||
|
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||||
|
final fixture = loadExerciseFixture('minimal');
|
||||||
|
|
||||||
|
final response = await handler(
|
||||||
|
Request(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('http://localhost/sync/push'),
|
||||||
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'deviceId': ' ',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'resourceType': 'exercise',
|
||||||
|
'clientId': 'exercise-blank-device',
|
||||||
|
'schemaVersion': fixture.schemaVersion,
|
||||||
|
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
||||||
|
'payload': fixture.payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final body = jsonDecode(await response.readAsString()) as Map;
|
||||||
|
final results = body['results'] as List;
|
||||||
|
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect((results.single as Map)['status'], 'error');
|
||||||
|
expect((results.single as Map)['message'], 'deviceId must not be blank.');
|
||||||
|
expect(repository.items, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('exchange returns push results followed by pulled items', () async {
|
test('exchange returns push results followed by pulled items', () async {
|
||||||
final repository = _FakeSyncedResourceRepository()
|
final repository = _FakeSyncedResourceRepository()
|
||||||
..items.add(
|
..items.add(
|
||||||
@ -191,10 +589,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(response.statusCode, 400);
|
expect(response.statusCode, 400);
|
||||||
expect(
|
expect(jsonDecode(await response.readAsString()), {
|
||||||
jsonDecode(await response.readAsString()),
|
'error': 'items must be a JSON array.',
|
||||||
{'error': 'items must be a JSON array.'},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,6 +637,38 @@ final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
||||||
|
final existingIndex = items.indexWhere(
|
||||||
|
(item) =>
|
||||||
|
item.ownerUserId == resource.ownerUserId &&
|
||||||
|
item.resourceType == resource.resourceType &&
|
||||||
|
item.clientId == resource.clientId,
|
||||||
|
);
|
||||||
|
if (existingIndex != -1) {
|
||||||
|
final existing = items[existingIndex];
|
||||||
|
if (!resource.clientUpdatedAt.isAfter(existing.clientUpdatedAt)) {
|
||||||
|
return SyncWriteResult(
|
||||||
|
status: SyncWriteStatus.ignoredOlder,
|
||||||
|
resource: existing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final written = SyncedResource(
|
||||||
|
serverId: existing.serverId,
|
||||||
|
ownerUserId: existing.ownerUserId,
|
||||||
|
resourceType: existing.resourceType,
|
||||||
|
clientId: existing.clientId,
|
||||||
|
payloadJson: resource.payloadJson,
|
||||||
|
schemaVersion: resource.schemaVersion,
|
||||||
|
clientUpdatedAt: resource.clientUpdatedAt,
|
||||||
|
serverUpdatedAt: resource.serverUpdatedAt,
|
||||||
|
deletedAt: resource.deletedAt,
|
||||||
|
originDeviceId: resource.originDeviceId,
|
||||||
|
);
|
||||||
|
items[existingIndex] = written;
|
||||||
|
return SyncWriteResult(
|
||||||
|
status: SyncWriteStatus.accepted,
|
||||||
|
resource: written,
|
||||||
|
);
|
||||||
|
}
|
||||||
items.add(resource);
|
items.add(resource);
|
||||||
return SyncWriteResult(
|
return SyncWriteResult(
|
||||||
status: SyncWriteStatus.accepted,
|
status: SyncWriteStatus.accepted,
|
||||||
@ -252,10 +681,14 @@ final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
|||||||
required String ownerUserId,
|
required String ownerUserId,
|
||||||
DateTime? since,
|
DateTime? since,
|
||||||
}) async {
|
}) async {
|
||||||
return items
|
final result = items
|
||||||
.where((item) => item.ownerUserId == ownerUserId)
|
.where((item) => item.ownerUserId == ownerUserId)
|
||||||
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
|
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
|
||||||
.toList();
|
.toList();
|
||||||
|
result.sort(
|
||||||
|
(left, right) => left.serverUpdatedAt.compareTo(right.serverUpdatedAt),
|
||||||
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import 'package:gametime_server/application/application.dart';
|
|||||||
import 'package:gametime_server/domain/domain.dart';
|
import 'package:gametime_server/domain/domain.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import 'fixtures/sync_fixtures.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late _FakeSyncedResourceRepository resources;
|
late _FakeSyncedResourceRepository resources;
|
||||||
late _FakeClock clock;
|
late _FakeClock clock;
|
||||||
@ -89,6 +91,231 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('push and pull preserve versioned exercise fixtures strictly', () async {
|
||||||
|
final push = PushSyncUseCase(resources: resources, clock: clock, ids: ids);
|
||||||
|
final pull = PullSyncUseCase(resources: resources, clock: clock);
|
||||||
|
final fixtures = loadExerciseFixtures();
|
||||||
|
|
||||||
|
final pushResult = await push.execute(
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
items: [
|
||||||
|
for (var index = 0; index < fixtures.length; index += 1)
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: fixtures[index].resourceType,
|
||||||
|
clientId: 'exercise-${fixtures[index].name}',
|
||||||
|
schemaVersion: fixtures[index].schemaVersion,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10, index),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: fixtures[index].payload,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final pullResult = await pull.execute(ownerUserId: 'user-1');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
pushResult.results.map((item) => item.status),
|
||||||
|
everyElement('accepted'),
|
||||||
|
);
|
||||||
|
expect(pullResult.items, hasLength(fixtures.length));
|
||||||
|
for (final fixture in fixtures) {
|
||||||
|
final pulled = pullResult.items.singleWhere(
|
||||||
|
(item) => item.clientId == 'exercise-${fixture.name}',
|
||||||
|
);
|
||||||
|
expect(pulled.resourceType, SyncedResourceType.exercise);
|
||||||
|
expect(pulled.schemaVersion, fixture.schemaVersion);
|
||||||
|
expect(pulled.payloadJson, fixture.payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'push and pull preserve exercise fixture batches across versions',
|
||||||
|
() async {
|
||||||
|
final push = PushSyncUseCase(
|
||||||
|
resources: resources,
|
||||||
|
clock: clock,
|
||||||
|
ids: ids,
|
||||||
|
);
|
||||||
|
final pull = PullSyncUseCase(resources: resources, clock: clock);
|
||||||
|
final fixtures = [
|
||||||
|
loadExerciseFixture('minimal'),
|
||||||
|
loadExerciseFixture('minimal', version: 'v2'),
|
||||||
|
];
|
||||||
|
|
||||||
|
final pushResult = await push.execute(
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
items: [
|
||||||
|
for (var index = 0; index < fixtures.length; index += 1)
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: fixtures[index].resourceType,
|
||||||
|
clientId: 'exercise-versioned-$index',
|
||||||
|
schemaVersion: fixtures[index].schemaVersion,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10, index),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: fixtures[index].payload,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final pullResult = await pull.execute(ownerUserId: 'user-1');
|
||||||
|
|
||||||
|
expect(pushResult.results.map((item) => item.status), [
|
||||||
|
'accepted',
|
||||||
|
'accepted',
|
||||||
|
]);
|
||||||
|
for (var index = 0; index < fixtures.length; index += 1) {
|
||||||
|
final pulled = pullResult.items.singleWhere(
|
||||||
|
(item) => item.clientId == 'exercise-versioned-$index',
|
||||||
|
);
|
||||||
|
expect(pulled.schemaVersion, fixtures[index].schemaVersion);
|
||||||
|
expect(pulled.payloadJson, fixtures[index].payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'push and pull preserve reusable non-exercise fixtures strictly',
|
||||||
|
() async {
|
||||||
|
final push = PushSyncUseCase(
|
||||||
|
resources: resources,
|
||||||
|
clock: clock,
|
||||||
|
ids: ids,
|
||||||
|
);
|
||||||
|
final pull = PullSyncUseCase(resources: resources, clock: clock);
|
||||||
|
final fixtures = loadMinimalResourceFixtures();
|
||||||
|
|
||||||
|
final pushResult = await push.execute(
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
items: [
|
||||||
|
for (var index = 0; index < fixtures.length; index += 1)
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: fixtures[index].resourceType,
|
||||||
|
clientId: '${fixtures[index].resourceType}-minimal',
|
||||||
|
schemaVersion: fixtures[index].schemaVersion,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 11, index),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: fixtures[index].payload,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final pullResult = await pull.execute(ownerUserId: 'user-1');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
pushResult.results.map((item) => item.status),
|
||||||
|
everyElement('accepted'),
|
||||||
|
);
|
||||||
|
for (final fixture in fixtures) {
|
||||||
|
final pulled = pullResult.items.singleWhere(
|
||||||
|
(item) => item.clientId == '${fixture.resourceType}-minimal',
|
||||||
|
);
|
||||||
|
expect(pulled.resourceType.wireName, fixture.resourceType);
|
||||||
|
expect(pulled.schemaVersion, fixture.schemaVersion);
|
||||||
|
expect(pulled.payloadJson, fixture.payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'LWW ignores older and equal exercise fixture payloads strictly',
|
||||||
|
() async {
|
||||||
|
final original = loadExerciseFixture('full_combo');
|
||||||
|
final newer = loadExerciseFixture('with_timers');
|
||||||
|
final push = PushSyncUseCase(
|
||||||
|
resources: resources,
|
||||||
|
clock: clock,
|
||||||
|
ids: ids,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await push.execute(
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
items: [
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: original.resourceType,
|
||||||
|
clientId: 'exercise-lww',
|
||||||
|
schemaVersion: original.schemaVersion,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: original.payload,
|
||||||
|
),
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: newer.resourceType,
|
||||||
|
clientId: 'exercise-lww',
|
||||||
|
schemaVersion: newer.schemaVersion,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 9, 59),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: newer.payload,
|
||||||
|
),
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: newer.resourceType,
|
||||||
|
clientId: 'exercise-lww',
|
||||||
|
schemaVersion: newer.schemaVersion,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: newer.payload,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.results.map((item) => item.status), [
|
||||||
|
'accepted',
|
||||||
|
'ignoredOlder',
|
||||||
|
'ignoredOlder',
|
||||||
|
]);
|
||||||
|
final stored = resources.get(
|
||||||
|
'user-1',
|
||||||
|
SyncedResourceType.exercise,
|
||||||
|
'exercise-lww',
|
||||||
|
);
|
||||||
|
expect(stored?.schemaVersion, original.schemaVersion);
|
||||||
|
expect(stored?.payloadJson, original.payload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('push reports per-item malformations without storing them', () async {
|
||||||
|
final useCase = PushSyncUseCase(
|
||||||
|
resources: resources,
|
||||||
|
clock: clock,
|
||||||
|
ids: ids,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await useCase.execute(
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
items: [
|
||||||
|
PushSyncItemInput(
|
||||||
|
resourceType: 'exercise',
|
||||||
|
clientId: 'bad-schema',
|
||||||
|
schemaVersion: 0,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: {},
|
||||||
|
),
|
||||||
|
const PushSyncItemInput(
|
||||||
|
resourceType: 'exercise',
|
||||||
|
clientId: 'missing-updated-at',
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
payloadJson: {},
|
||||||
|
),
|
||||||
|
const PushSyncItemInput.invalid(
|
||||||
|
resourceType: 'exercise',
|
||||||
|
clientId: 'bad-payload',
|
||||||
|
message: 'payload must be a JSON object.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.results.map((item) => item.status), [
|
||||||
|
'error',
|
||||||
|
'error',
|
||||||
|
'error',
|
||||||
|
]);
|
||||||
|
expect(resources._items, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'pull returns resources newer than cursor including soft deletes',
|
'pull returns resources newer than cursor including soft deletes',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@ -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(
|
test(
|
||||||
'WorkoutTelemetryUseCases persists samples and aggregates by scope',
|
'WorkoutTelemetryUseCases persists samples and aggregates by scope',
|
||||||
() async {
|
() async {
|
||||||
@ -3335,6 +3379,10 @@ void main() {
|
|||||||
0,
|
0,
|
||||||
10,
|
10,
|
||||||
).millisecondsSinceEpoch,
|
).millisecondsSinceEpoch,
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
setIndex: 0,
|
||||||
|
stepIndex: 0,
|
||||||
heartRateBpm: 160,
|
heartRateBpm: 160,
|
||||||
distanceMeters: 530,
|
distanceMeters: 530,
|
||||||
caloriesKcal: 45,
|
caloriesKcal: 45,
|
||||||
@ -3378,20 +3426,42 @@ void main() {
|
|||||||
caloriesKcal: 48,
|
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(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.session,
|
||||||
WorkoutTelemetryAggregateScope.exercise,
|
WorkoutTelemetryAggregateScope.exercise,
|
||||||
WorkoutTelemetryAggregateScope.set,
|
WorkoutTelemetryAggregateScope.set,
|
||||||
WorkoutTelemetryAggregateScope.step,
|
WorkoutTelemetryAggregateScope.step,
|
||||||
]);
|
]);
|
||||||
expect(duplicate, isNotEmpty);
|
|
||||||
expect(olderInSameBucket, isEmpty);
|
|
||||||
expect(repository.samples, hasLength(2));
|
expect(repository.samples, hasLength(2));
|
||||||
expect(repository.samples.first.heartRateBpm, 160);
|
expect(repository.samples.first.heartRateBpm, 160);
|
||||||
|
|
||||||
final sessionAggregate = second.singleWhere(
|
final sessionAggregate = third.singleWhere(
|
||||||
(aggregate) =>
|
(aggregate) =>
|
||||||
aggregate.scope == WorkoutTelemetryAggregateScope.session,
|
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(
|
test(
|
||||||
'WorkoutTelemetryUseCases reads graph samples by scope with relative cumulative metrics',
|
'WorkoutTelemetryUseCases reads graph samples by scope with relative cumulative metrics',
|
||||||
() async {
|
() 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(
|
test(
|
||||||
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
|
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
|
||||||
() async {
|
() async {
|
||||||
@ -5400,6 +5660,7 @@ final class _FakeWorkoutTelemetryRepository
|
|||||||
implements WorkoutTelemetryRepository {
|
implements WorkoutTelemetryRepository {
|
||||||
final samples = <WorkoutTelemetrySample>[];
|
final samples = <WorkoutTelemetrySample>[];
|
||||||
final aggregates = <WorkoutTelemetryAggregate>[];
|
final aggregates = <WorkoutTelemetryAggregate>[];
|
||||||
|
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
||||||
@ -5416,6 +5677,23 @@ final class _FakeWorkoutTelemetryRepository
|
|||||||
return true;
|
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
|
@override
|
||||||
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
|
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
|
||||||
return samples
|
return samples
|
||||||
|
|||||||
@ -148,7 +148,7 @@ void main() {
|
|||||||
await columnNames('workout_telemetry_aggregates'),
|
await columnNames('workout_telemetry_aggregates'),
|
||||||
contains('sample_count'),
|
contains('sample_count'),
|
||||||
);
|
);
|
||||||
expect(database.schemaVersion, 25);
|
expect(database.schemaVersion, 26);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('exercise business types persist with category fallback', () async {
|
test('exercise business types persist with category fallback', () async {
|
||||||
@ -499,7 +499,7 @@ CREATE TABLE pending_share_actions (
|
|||||||
final inboxItems = await inboxRepository.listAll();
|
final inboxItems = await inboxRepository.listAll();
|
||||||
final pendingActions = await pendingRepository.listPending();
|
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-program-1'));
|
||||||
expect(inboxItems.map((item) => item.shareId), contains('share-pack-1'));
|
expect(inboxItems.map((item) => item.shareId), contains('share-pack-1'));
|
||||||
expect(
|
expect(
|
||||||
@ -710,6 +710,23 @@ CREATE TABLE pending_share_actions (
|
|||||||
maxHeartRateBpm: 150,
|
maxHeartRateBpm: 150,
|
||||||
totalDistanceMeters: 42,
|
totalDistanceMeters: 42,
|
||||||
totalCaloriesKcal: 12,
|
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['totalCaloriesKcal'], 12);
|
||||||
expect(payload['results'], hasLength(1));
|
expect(payload['results'], hasLength(1));
|
||||||
expect(payload['stepResults'], hasLength(1));
|
expect(payload['stepResults'], hasLength(1));
|
||||||
|
expect(payload['telemetrySamples'], hasLength(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('local sync pull restores exercise images and steps', () async {
|
test('local sync pull restores exercise images and steps', () async {
|
||||||
@ -804,6 +822,20 @@ CREATE TABLE pending_share_actions (
|
|||||||
'totalActiveMs': 300000,
|
'totalActiveMs': 300000,
|
||||||
'completed': true,
|
'completed': true,
|
||||||
'historySnapshotJson': '{"name":"remote-history-full"}',
|
'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,
|
'minHeartRateBpm': 95,
|
||||||
'averageHeartRateBpm': 125,
|
'averageHeartRateBpm': 125,
|
||||||
'maxHeartRateBpm': 155,
|
'maxHeartRateBpm': 155,
|
||||||
@ -884,6 +916,16 @@ CREATE TABLE pending_share_actions (
|
|||||||
expect(restored.totalCaloriesKcal, 24);
|
expect(restored.totalCaloriesKcal, 24);
|
||||||
expect(restored.results.single.actualScoreTimeMs, 12000);
|
expect(restored.results.single.actualScoreTimeMs, 12000);
|
||||||
expect(restored.stepResults.single.actualReps, 10);
|
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 {
|
test('local sync pull defaults missing tags to empty lists', () async {
|
||||||
@ -1610,7 +1652,7 @@ CREATE TABLE pending_share_actions (
|
|||||||
).run();
|
).run();
|
||||||
|
|
||||||
expect(result.status, StarterSeedStatus.inserted);
|
expect(result.status, StarterSeedStatus.inserted);
|
||||||
expect(await seedRepository.readAppliedStarterSeedVersion(), 1);
|
expect(await seedRepository.readAppliedStarterSeedVersion(), 2);
|
||||||
|
|
||||||
final exercises = await exerciseRepository.listActive();
|
final exercises = await exerciseRepository.listActive();
|
||||||
final programs = await programRepository.listActive();
|
final programs = await programRepository.listActive();
|
||||||
@ -1621,15 +1663,23 @@ CREATE TABLE pending_share_actions (
|
|||||||
expect(exercises.every((exercise) => exercise.isExample), isTrue);
|
expect(exercises.every((exercise) => exercise.isExample), isTrue);
|
||||||
expect(programs.single.isExample, isTrue);
|
expect(programs.single.isExample, isTrue);
|
||||||
expect(templates.single.isExample, isTrue);
|
expect(templates.single.isExample, isTrue);
|
||||||
expect(exercises.map((exercise) => exercise.category).toSet(), {
|
expect(
|
||||||
ExerciseCategory.shoot,
|
exercises.every(
|
||||||
ExerciseCategory.freeThrows,
|
(exercise) => exercise.category == ExerciseCategory.uncategorized,
|
||||||
ExerciseCategory.dribble,
|
),
|
||||||
ExerciseCategory.finishing,
|
isTrue,
|
||||||
ExerciseCategory.conditioning,
|
);
|
||||||
ExerciseCategory.defense,
|
expect(
|
||||||
ExerciseCategory.mobility,
|
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(
|
final secondRun = await SeedStarterContentUseCase(
|
||||||
seedStateRepository: seedRepository,
|
seedStateRepository: seedRepository,
|
||||||
@ -1665,7 +1715,7 @@ CREATE TABLE pending_share_actions (
|
|||||||
).run();
|
).run();
|
||||||
|
|
||||||
expect(result.status, StarterSeedStatus.skippedNotEmpty);
|
expect(result.status, StarterSeedStatus.skippedNotEmpty);
|
||||||
expect(await seedRepository.readAppliedStarterSeedVersion(), 1);
|
expect(await seedRepository.readAppliedStarterSeedVersion(), 2);
|
||||||
expect(await exerciseRepository.listActive(), hasLength(1));
|
expect(await exerciseRepository.listActive(), hasLength(1));
|
||||||
expect(await programRepository.listActive(), isEmpty);
|
expect(await programRepository.listActive(), isEmpty);
|
||||||
expect(await templateRepository.listActive(), isEmpty);
|
expect(await templateRepository.listActive(), isEmpty);
|
||||||
@ -2193,6 +2243,27 @@ CREATE TABLE pending_share_actions (
|
|||||||
|
|
||||||
for (final roundTripCase in cases) {
|
for (final roundTripCase in cases) {
|
||||||
test(roundTripCase.label, () async {
|
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);
|
await exerciseRepository.save(roundTripCase.exercise);
|
||||||
|
|
||||||
final restored = await exerciseRepository.findById(
|
final restored = await exerciseRepository.findById(
|
||||||
@ -3450,6 +3521,7 @@ WorkoutHistory _history({
|
|||||||
int? maxHeartRateBpm,
|
int? maxHeartRateBpm,
|
||||||
double? totalDistanceMeters,
|
double? totalDistanceMeters,
|
||||||
double? totalCaloriesKcal,
|
double? totalCaloriesKcal,
|
||||||
|
String? historySnapshotJson,
|
||||||
}) {
|
}) {
|
||||||
return WorkoutHistory(
|
return WorkoutHistory(
|
||||||
metadata: _metadata(id, startedAt),
|
metadata: _metadata(id, startedAt),
|
||||||
@ -3458,7 +3530,7 @@ WorkoutHistory _history({
|
|||||||
endedAt: startedAt.add(const Duration(minutes: 5)),
|
endedAt: startedAt.add(const Duration(minutes: 5)),
|
||||||
totalActiveMs: totalActiveMs,
|
totalActiveMs: totalActiveMs,
|
||||||
completed: completed,
|
completed: completed,
|
||||||
historySnapshotJson: '{"name":"$id"}',
|
historySnapshotJson: historySnapshotJson ?? '{"name":"$id"}',
|
||||||
results: results ?? [result!],
|
results: results ?? [result!],
|
||||||
stepResults: stepResults,
|
stepResults: stepResults,
|
||||||
minHeartRateBpm: minHeartRateBpm,
|
minHeartRateBpm: minHeartRateBpm,
|
||||||
|
|||||||
@ -18,7 +18,7 @@ void main() {
|
|||||||
test('defaultBaseUrl uses Android emulator host without env override', () {
|
test('defaultBaseUrl uses Android emulator host without env override', () {
|
||||||
expect(
|
expect(
|
||||||
HttpApiClient.defaultBaseUrlFor(isAndroid: true),
|
HttpApiClient.defaultBaseUrlFor(isAndroid: true),
|
||||||
'http://10.0.2.2:8080',
|
'http://10.0.2.2:8090',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -26,9 +26,9 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
HttpApiClient.defaultBaseUrlFor(
|
HttpApiClient.defaultBaseUrlFor(
|
||||||
isAndroid: true,
|
isAndroid: true,
|
||||||
configuredBaseUrl: ' http://192.168.1.42:8080 ',
|
configuredBaseUrl: ' http://192.168.1.75:8090 ',
|
||||||
),
|
),
|
||||||
'http://192.168.1.42:8080',
|
'http://192.168.1.75:8090',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import 'package:gametime/application/application.dart';
|
|||||||
import 'package:gametime/domain/domain.dart';
|
import 'package:gametime/domain/domain.dart';
|
||||||
import 'package:gametime/infrastructure/infrastructure.dart'
|
import 'package:gametime/infrastructure/infrastructure.dart'
|
||||||
hide
|
hide
|
||||||
|
ActiveWorkoutTelemetryWindowState,
|
||||||
WorkoutHistory,
|
WorkoutHistory,
|
||||||
WorkoutHistorySetResult,
|
WorkoutHistorySetResult,
|
||||||
WorkoutHistoryStepResult,
|
WorkoutHistoryStepResult,
|
||||||
@ -311,6 +312,25 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await Future<void>.delayed(Duration.zero);
|
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(
|
expect(
|
||||||
telemetryRepository.samples.single.id,
|
telemetryRepository.samples.single.id,
|
||||||
@ -658,6 +678,7 @@ final class _FakeWorkoutTelemetryRepository
|
|||||||
implements WorkoutTelemetryRepository {
|
implements WorkoutTelemetryRepository {
|
||||||
final samples = <WorkoutTelemetrySample>[];
|
final samples = <WorkoutTelemetrySample>[];
|
||||||
final aggregates = <WorkoutTelemetryAggregate>[];
|
final aggregates = <WorkoutTelemetryAggregate>[];
|
||||||
|
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
||||||
@ -668,6 +689,23 @@ final class _FakeWorkoutTelemetryRepository
|
|||||||
return true;
|
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
|
@override
|
||||||
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
|
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
|
||||||
return samples
|
return samples
|
||||||
|
|||||||
@ -224,6 +224,131 @@ void main() {
|
|||||||
expect(find.text('Dribble routine'), findsOneWidget);
|
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 l’unité et affiche le badge', (
|
testWidgets('basculer en chrono intégré masque l’unité et affiche le badge', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@ -79,6 +79,7 @@ void main() {
|
|||||||
home: HistoryDetailScreen(
|
home: HistoryDetailScreen(
|
||||||
history: _history(
|
history: _history(
|
||||||
id: 'history-1',
|
id: 'history-1',
|
||||||
|
withTelemetry: true,
|
||||||
minHeartRateBpm: 88,
|
minHeartRateBpm: 88,
|
||||||
averageHeartRateBpm: 126.4,
|
averageHeartRateBpm: 126.4,
|
||||||
maxHeartRateBpm: 171,
|
maxHeartRateBpm: 171,
|
||||||
@ -88,18 +89,28 @@ void main() {
|
|||||||
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
|
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
|
||||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||||
activeUseCases: _activeUseCases(),
|
activeUseCases: _activeUseCases(),
|
||||||
|
telemetryUseCases: _telemetryUseCases(),
|
||||||
closeUseCase: _closeUseCase(),
|
closeUseCase: _closeUseCase(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Stats montre'), findsOneWidget);
|
expect(find.text('Stats montre'), findsOneWidget);
|
||||||
expect(find.text('Fréquence cardiaque'), findsOneWidget);
|
expect(find.text('Fréquence cardiaque'), findsOneWidget);
|
||||||
expect(find.text('Min'), findsAtLeastNWidgets(1));
|
expect(find.text('Min'), findsAtLeastNWidgets(1));
|
||||||
expect(find.text('Moyenne'), findsOneWidget);
|
expect(find.text('Moyenne'), findsOneWidget);
|
||||||
expect(find.text('Max'), findsAtLeastNWidgets(1));
|
expect(find.text('Max'), findsAtLeastNWidgets(1));
|
||||||
expect(find.text('Distance'), findsOneWidget);
|
expect(find.text('Distance'), findsAtLeastNWidgets(1));
|
||||||
expect(find.text('Calories'), findsOneWidget);
|
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(
|
expect(
|
||||||
find.byKey(const ValueKey('history-watch-stats-graph')),
|
find.byKey(const ValueKey('history-watch-stats-graph')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
@ -269,6 +280,7 @@ WorkoutHistory _history({
|
|||||||
DateTime? startedAt,
|
DateTime? startedAt,
|
||||||
bool stopwatchScore = false,
|
bool stopwatchScore = false,
|
||||||
bool withStepResults = false,
|
bool withStepResults = false,
|
||||||
|
bool withTelemetry = false,
|
||||||
bool emptySnapshot = false,
|
bool emptySnapshot = false,
|
||||||
int? minHeartRateBpm,
|
int? minHeartRateBpm,
|
||||||
double? averageHeartRateBpm,
|
double? averageHeartRateBpm,
|
||||||
@ -318,6 +330,51 @@ WorkoutHistory _history({
|
|||||||
if (stopwatchScore) 'targetScoreTimeMsSnapshot': 45000,
|
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
|
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 {
|
final class _FakeClock implements Clock {
|
||||||
_FakeClock(this.value);
|
_FakeClock(this.value);
|
||||||
|
|
||||||
@ -724,3 +791,108 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> saveSetResult(ActiveSetResult result) async {}
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -932,9 +932,28 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|||||||
|
|
||||||
final class _FakeWorkoutTelemetryRepository
|
final class _FakeWorkoutTelemetryRepository
|
||||||
implements WorkoutTelemetryRepository {
|
implements WorkoutTelemetryRepository {
|
||||||
|
final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> saveSample(WorkoutTelemetrySample sample) async => true;
|
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
|
@override
|
||||||
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
|
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
|
||||||
return const [];
|
return const [];
|
||||||
|
|||||||
@ -237,6 +237,67 @@ void main() {
|
|||||||
expect(find.text('186 kcal'), findsOneWidget);
|
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', (
|
testWidgets('affiche les états capteur explicites avant la première mesure', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@ -80,4 +80,6 @@ dependencies {
|
|||||||
implementation("androidx.wear:wear-ongoing:1.0.0")
|
implementation("androidx.wear:wear-ongoing:1.0.0")
|
||||||
implementation("com.google.guava:guava:33.6.0-android")
|
implementation("com.google.guava:guava:33.6.0-android")
|
||||||
implementation("com.google.android.gms:play-services-wearable:19.0.0")
|
implementation("com.google.android.gms:play-services-wearable:19.0.0")
|
||||||
|
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,9 +42,6 @@ object WatchBridgePlugin {
|
|||||||
const val ACTION_OPEN_ACTIVE_SESSION = "com.gametime.watch.OPEN_ACTIVE_SESSION"
|
const val ACTION_OPEN_ACTIVE_SESSION = "com.gametime.watch.OPEN_ACTIVE_SESSION"
|
||||||
private const val SENSOR_PERMISSION_REQUEST = 4106
|
private const val SENSOR_PERMISSION_REQUEST = 4106
|
||||||
private const val SENSOR_PERMISSION_RETRY_DELAY_MS = 30000L
|
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 appContext: Context? = null
|
||||||
private var activity: Activity? = null
|
private var activity: Activity? = null
|
||||||
private var projectionSink: EventChannel.EventSink? = null
|
private var projectionSink: EventChannel.EventSink? = null
|
||||||
@ -570,24 +567,11 @@ object WatchBridgePlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun requiredSensorPermissions(): List<String> {
|
private fun requiredSensorPermissions(): List<String> {
|
||||||
val heartRatePermission = if (Build.VERSION.SDK_INT >= 36) {
|
return WatchSensorPermissionPolicy.requiredSensorPermissions(Build.VERSION.SDK_INT)
|
||||||
READ_HEART_RATE_PERMISSION
|
|
||||||
} else {
|
|
||||||
android.Manifest.permission.BODY_SENSORS
|
|
||||||
}
|
|
||||||
return listOf(
|
|
||||||
heartRatePermission,
|
|
||||||
android.Manifest.permission.ACTIVITY_RECOGNITION,
|
|
||||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requiredRuntimePermissions(): List<String> {
|
private fun requiredRuntimePermissions(): List<String> {
|
||||||
val permissions = requiredSensorPermissions().toMutableList()
|
return WatchSensorPermissionPolicy.requiredRuntimePermissions(Build.VERSION.SDK_INT)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
permissions.add(android.Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
return permissions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun telemetryContext(projection: Map<String, Any?>): Map<String, Any?> {
|
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?> {
|
private fun JSONObject.toMap(): Map<String, Any?> {
|
||||||
val output = linkedMapOf<String, Any?>()
|
val output = linkedMapOf<String, Any?>()
|
||||||
val keys = keys()
|
val keys = keys()
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import org.json.JSONObject
|
|||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val MAX_DISTANCE_SECURITY_RETRIES = 3
|
||||||
|
|
||||||
internal class WatchHeartRateCollector(
|
internal class WatchHeartRateCollector(
|
||||||
private val phoneCapability: String,
|
private val phoneCapability: String,
|
||||||
private val sensorSummaryPath: String,
|
private val sensorSummaryPath: String,
|
||||||
@ -36,12 +38,17 @@ internal class WatchHeartRateCollector(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "GTWatchHeartRate"
|
const val TAG = "GTWatchHeartRate"
|
||||||
const val SAMPLE_FLUSH_INTERVAL_MS = 1500L
|
const val SAMPLE_FLUSH_INTERVAL_MS = 1500L
|
||||||
|
const val SAMPLE_STALE_TIMEOUT_MS = 20000L
|
||||||
|
const val SAMPLE_WATCHDOG_INTERVAL_MS = 5000L
|
||||||
|
const val DISTANCE_RETRY_DELAY_MS = 30000L
|
||||||
const val NODE_CACHE_TTL_MS = 10000L
|
const val NODE_CACHE_TTL_MS = 10000L
|
||||||
}
|
}
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
private var pendingSample: Map<String, Any?>? = null
|
private var pendingSample: Map<String, Any?>? = null
|
||||||
private var sampleFlushRunnable: Runnable? = null
|
private var sampleFlushRunnable: Runnable? = null
|
||||||
|
private var sampleWatchdogRunnable: Runnable? = null
|
||||||
|
private var distanceRetryRunnable: Runnable? = null
|
||||||
private var cachedReachableNodes: List<Node> = emptyList()
|
private var cachedReachableNodes: List<Node> = emptyList()
|
||||||
private var cachedReachableNodesAtEpochMs = 0L
|
private var cachedReachableNodesAtEpochMs = 0L
|
||||||
private var nodeLookupInFlight = false
|
private var nodeLookupInFlight = false
|
||||||
@ -59,7 +66,10 @@ internal class WatchHeartRateCollector(
|
|||||||
private var exerciseMetricsStartInFlight = false
|
private var exerciseMetricsStartInFlight = false
|
||||||
private var exerciseHeartRateSupported = false
|
private var exerciseHeartRateSupported = false
|
||||||
private var exerciseHeartRateObserved = false
|
private var exerciseHeartRateObserved = false
|
||||||
|
private var distanceSecurityFailureCount = 0
|
||||||
|
private var shouldRetryDistanceAfterSecurityFailure = false
|
||||||
private var shouldAggregate = false
|
private var shouldAggregate = false
|
||||||
|
private var latestSampleAtEpochMs = 0L
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
|
|
||||||
private val measureCallback = object : MeasureCallback {
|
private val measureCallback = object : MeasureCallback {
|
||||||
@ -106,6 +116,11 @@ internal class WatchHeartRateCollector(
|
|||||||
}
|
}
|
||||||
var updated = false
|
var updated = false
|
||||||
var latestHeartRateBpm: Int? = null
|
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)) {
|
for (point in update.latestMetrics.getData(DataType.HEART_RATE_BPM)) {
|
||||||
latestHeartRateBpm = recordHeartRate(point.value)
|
latestHeartRateBpm = recordHeartRate(point.value)
|
||||||
}
|
}
|
||||||
@ -117,6 +132,8 @@ internal class WatchHeartRateCollector(
|
|||||||
val value = point.value
|
val value = point.value
|
||||||
if (value > 0) {
|
if (value > 0) {
|
||||||
distanceMeters = (distanceMeters ?: 0.0) + value
|
distanceMeters = (distanceMeters ?: 0.0) + value
|
||||||
|
distanceDeltaMeters += value
|
||||||
|
distancePointCount += 1
|
||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,12 +141,18 @@ internal class WatchHeartRateCollector(
|
|||||||
val value = point.value
|
val value = point.value
|
||||||
if (value > 0) {
|
if (value > 0) {
|
||||||
caloriesKcal = (caloriesKcal ?: 0.0) + value
|
caloriesKcal = (caloriesKcal ?: 0.0) + value
|
||||||
|
caloriesDeltaKcal += value
|
||||||
|
caloriesPointCount += 1
|
||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (latestHeartRateBpm != null || updated) {
|
if (latestHeartRateBpm != null || updated) {
|
||||||
logHotPath(
|
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)
|
sendSample(latestHeartRateBpm)
|
||||||
}
|
}
|
||||||
@ -179,6 +202,10 @@ internal class WatchHeartRateCollector(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
|
if (latestSampleAtEpochMs == 0L) {
|
||||||
|
latestSampleAtEpochMs = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
scheduleSampleWatchdog(context)
|
||||||
startMeasureHeartRateFallback(context)
|
startMeasureHeartRateFallback(context)
|
||||||
startExerciseMetrics(context)
|
startExerciseMetrics(context)
|
||||||
}
|
}
|
||||||
@ -186,6 +213,8 @@ internal class WatchHeartRateCollector(
|
|||||||
fun pause(context: Context) {
|
fun pause(context: Context) {
|
||||||
shouldAggregate = false
|
shouldAggregate = false
|
||||||
flushPendingSample(context, forceNodeRefresh = false)
|
flushPendingSample(context, forceNodeRefresh = false)
|
||||||
|
cancelSampleWatchdog()
|
||||||
|
cancelDistanceRetry()
|
||||||
unregister(context)
|
unregister(context)
|
||||||
stopExerciseMetrics(context)
|
stopExerciseMetrics(context)
|
||||||
}
|
}
|
||||||
@ -223,6 +252,7 @@ internal class WatchHeartRateCollector(
|
|||||||
val context = appContext ?: return
|
val context = appContext ?: return
|
||||||
sampleSequence += 1
|
sampleSequence += 1
|
||||||
val capturedAt = System.currentTimeMillis()
|
val capturedAt = System.currentTimeMillis()
|
||||||
|
latestSampleAtEpochMs = capturedAt
|
||||||
val sample = mapOf(
|
val sample = mapOf(
|
||||||
"schemaVersion" to 4,
|
"schemaVersion" to 4,
|
||||||
"sampleId" to "$activeSessionId-$capturedAt-$sampleSequence",
|
"sampleId" to "$activeSessionId-$capturedAt-$sampleSequence",
|
||||||
@ -317,7 +347,7 @@ internal class WatchHeartRateCollector(
|
|||||||
val requestedTypeNames = (
|
val requestedTypeNames = (
|
||||||
executionContext["healthServicesExerciseTypeStrategy"] as? List<*>
|
executionContext["healthServicesExerciseTypeStrategy"] as? List<*>
|
||||||
)?.filterIsInstance<String>().orEmpty()
|
)?.filterIsInstance<String>().orEmpty()
|
||||||
val configs = exerciseConfigsFromCapabilities(
|
val configs = WatchExerciseMetricsConfigFactory.fromCapabilities(
|
||||||
capabilities,
|
capabilities,
|
||||||
requestedTypeNames,
|
requestedTypeNames,
|
||||||
canUseGps = canStartGpsExercise(context),
|
canUseGps = canStartGpsExercise(context),
|
||||||
@ -379,11 +409,26 @@ internal class WatchHeartRateCollector(
|
|||||||
startFuture.get()
|
startFuture.get()
|
||||||
exerciseMetricsStartInFlight = false
|
exerciseMetricsStartInFlight = false
|
||||||
exerciseMetricsStarted = true
|
exerciseMetricsStarted = true
|
||||||
|
onExerciseMetricsStarted(context, config)
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}",
|
"exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}",
|
||||||
)
|
)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
|
val failedFromFineLocationSecurity = WatchExerciseMetricsRetryPolicy
|
||||||
|
.isDistanceSecurityFailure(config.dataTypes, error)
|
||||||
|
if (failedFromFineLocationSecurity) {
|
||||||
|
distanceSecurityFailureCount += 1
|
||||||
|
shouldRetryDistanceAfterSecurityFailure = WatchExerciseMetricsRetryPolicy
|
||||||
|
.canRetryDistance(distanceSecurityFailureCount)
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"distance exercise metrics rejected by security " +
|
||||||
|
"type=${config.exerciseType} attempt=$distanceSecurityFailureCount " +
|
||||||
|
"willRetry=$shouldRetryDistanceAfterSecurityFailure",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG,
|
||||||
"exercise metrics start failed type=${config.exerciseType} dataTypes=${config.dataTypes}",
|
"exercise metrics start failed type=${config.exerciseType} dataTypes=${config.dataTypes}",
|
||||||
@ -403,72 +448,16 @@ internal class WatchHeartRateCollector(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun exerciseConfigsFromCapabilities(
|
private fun onExerciseMetricsStarted(context: Context, config: ExerciseConfig) {
|
||||||
capabilities: androidx.health.services.client.data.ExerciseCapabilities,
|
if (DataType.DISTANCE in config.dataTypes) {
|
||||||
requestedTypeNames: List<String>,
|
distanceSecurityFailureCount = 0
|
||||||
canUseGps: Boolean,
|
shouldRetryDistanceAfterSecurityFailure = false
|
||||||
): List<ExerciseConfig> {
|
cancelDistanceRetry()
|
||||||
val requestedTypes = exerciseTypesFromNames(requestedTypeNames)
|
return
|
||||||
val distanceCaloriesConfigs = mutableListOf<ExerciseConfig>()
|
}
|
||||||
val caloriesConfigs = mutableListOf<ExerciseConfig>()
|
if (shouldRetryDistanceAfterSecurityFailure) {
|
||||||
val heartRateConfigs = mutableListOf<ExerciseConfig>()
|
scheduleDistanceRetry(context)
|
||||||
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 {
|
private fun canStartGpsExercise(context: Context): Boolean {
|
||||||
@ -489,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) {
|
private fun stopExerciseMetrics(context: Context) {
|
||||||
if (!exerciseMetricsStarted && !exerciseMetricsStartInFlight) {
|
if (!exerciseMetricsStarted && !exerciseMetricsStartInFlight) {
|
||||||
return
|
return
|
||||||
@ -528,6 +492,37 @@ internal class WatchHeartRateCollector(
|
|||||||
exerciseHeartRateSupported = false
|
exerciseHeartRateSupported = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun retryDistanceMetrics(context: Context) {
|
||||||
|
val activeSessionId = sessionId
|
||||||
|
if (
|
||||||
|
activeSessionId.isNullOrBlank() ||
|
||||||
|
!shouldAggregate ||
|
||||||
|
!shouldRetryDistanceAfterSecurityFailure ||
|
||||||
|
exerciseMetricsStartInFlight
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"retrying distance exercise metrics sessionId=$activeSessionId attempt=$distanceSecurityFailureCount",
|
||||||
|
)
|
||||||
|
stopExerciseMetrics(context)
|
||||||
|
startExerciseMetrics(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restartExerciseMetrics(context: Context) {
|
||||||
|
val activeSessionId = sessionId
|
||||||
|
if (activeSessionId.isNullOrBlank() || !shouldAggregate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.w(TAG, "sample watchdog restarting exercise metrics sessionId=$activeSessionId")
|
||||||
|
stopExerciseMetrics(context)
|
||||||
|
unregister(context)
|
||||||
|
latestSampleAtEpochMs = System.currentTimeMillis()
|
||||||
|
startMeasureHeartRateFallback(context)
|
||||||
|
startExerciseMetrics(context)
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearExerciseCallback(exerciseClient: ExerciseClient) {
|
private fun clearExerciseCallback(exerciseClient: ExerciseClient) {
|
||||||
try {
|
try {
|
||||||
exerciseClient.clearUpdateCallbackAsync(exerciseCallback)
|
exerciseClient.clearUpdateCallbackAsync(exerciseCallback)
|
||||||
@ -548,12 +543,17 @@ internal class WatchHeartRateCollector(
|
|||||||
distanceMeters = null
|
distanceMeters = null
|
||||||
caloriesKcal = null
|
caloriesKcal = null
|
||||||
sampleSequence = 0
|
sampleSequence = 0
|
||||||
|
latestSampleAtEpochMs = 0L
|
||||||
executionContext = emptyMap()
|
executionContext = emptyMap()
|
||||||
shouldAggregate = false
|
shouldAggregate = false
|
||||||
|
cancelSampleWatchdog()
|
||||||
|
cancelDistanceRetry()
|
||||||
exerciseMetricsStarted = false
|
exerciseMetricsStarted = false
|
||||||
exerciseMetricsStartInFlight = false
|
exerciseMetricsStartInFlight = false
|
||||||
exerciseHeartRateSupported = false
|
exerciseHeartRateSupported = false
|
||||||
exerciseHeartRateObserved = false
|
exerciseHeartRateObserved = false
|
||||||
|
distanceSecurityFailureCount = 0
|
||||||
|
shouldRetryDistanceAfterSecurityFailure = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startMeasureHeartRateFallback(context: Context) {
|
private fun startMeasureHeartRateFallback(context: Context) {
|
||||||
@ -585,6 +585,60 @@ internal class WatchHeartRateCollector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun scheduleSampleWatchdog(context: Context) {
|
||||||
|
if (sampleWatchdogRunnable != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
sampleWatchdogRunnable = Runnable {
|
||||||
|
sampleWatchdogRunnable = null
|
||||||
|
checkSampleFreshness(appContext)
|
||||||
|
}.also { runnable ->
|
||||||
|
mainHandler.postDelayed(runnable, SAMPLE_WATCHDOG_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelSampleWatchdog() {
|
||||||
|
sampleWatchdogRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
sampleWatchdogRunnable = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleDistanceRetry(context: Context) {
|
||||||
|
if (distanceRetryRunnable != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
distanceRetryRunnable = Runnable {
|
||||||
|
distanceRetryRunnable = null
|
||||||
|
retryDistanceMetrics(appContext)
|
||||||
|
}.also { runnable ->
|
||||||
|
mainHandler.postDelayed(runnable, DISTANCE_RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelDistanceRetry() {
|
||||||
|
distanceRetryRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
distanceRetryRunnable = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkSampleFreshness(context: Context) {
|
||||||
|
if (!shouldAggregate || sessionId.isNullOrBlank()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val latestSampleAt = latestSampleAtEpochMs
|
||||||
|
val elapsedMs = System.currentTimeMillis() - latestSampleAt
|
||||||
|
if (
|
||||||
|
latestSampleAt > 0L &&
|
||||||
|
elapsedMs >= SAMPLE_STALE_TIMEOUT_MS &&
|
||||||
|
!exerciseMetricsStartInFlight
|
||||||
|
) {
|
||||||
|
restartExerciseMetrics(context)
|
||||||
|
}
|
||||||
|
if (shouldAggregate && !sessionId.isNullOrBlank()) {
|
||||||
|
scheduleSampleWatchdog(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun flushPendingSample(context: Context, forceNodeRefresh: Boolean) {
|
private fun flushPendingSample(context: Context, forceNodeRefresh: Boolean) {
|
||||||
val sample = pendingSample ?: return
|
val sample = pendingSample ?: return
|
||||||
pendingSample = null
|
pendingSample = null
|
||||||
@ -651,3 +705,156 @@ internal class WatchHeartRateCollector(
|
|||||||
cachedReachableNodesAtEpochMs = System.currentTimeMillis()
|
cachedReachableNodesAtEpochMs = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<*, *>>,
|
||||||
|
error: Throwable,
|
||||||
|
): Boolean = DataType.DISTANCE in dataTypes && error.hasCause<SecurityException>()
|
||||||
|
|
||||||
|
fun canRetryDistance(securityFailureCount: Int): Boolean =
|
||||||
|
securityFailureCount in 1..MAX_DISTANCE_SECURITY_RETRIES
|
||||||
|
|
||||||
|
private inline fun <reified T : Throwable> Throwable.hasCause(): Boolean {
|
||||||
|
var current: Throwable? = this
|
||||||
|
while (current != null) {
|
||||||
|
if (current is T) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
current = current.cause
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,131 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
class WatchExerciseMetricsRetryPolicyTest {
|
||||||
|
@Test
|
||||||
|
fun distanceSecurityFailureMatchesWrappedSecurityException() {
|
||||||
|
val error = ExecutionException(
|
||||||
|
SecurityException("Missing permissions: [android.permission.ACCESS_FINE_LOCATION]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
WatchExerciseMetricsRetryPolicy.isDistanceSecurityFailure(
|
||||||
|
setOf(DataType.DISTANCE, DataType.CALORIES, DataType.HEART_RATE_BPM),
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonDistanceSecurityFailureDoesNotScheduleDistanceRetry() {
|
||||||
|
val error = ExecutionException(
|
||||||
|
SecurityException("Missing permissions: [android.permission.ACCESS_FINE_LOCATION]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
WatchExerciseMetricsRetryPolicy.isDistanceSecurityFailure(
|
||||||
|
setOf(DataType.CALORIES, DataType.HEART_RATE_BPM),
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun distanceNonSecurityFailureDoesNotScheduleDistanceRetry() {
|
||||||
|
val error = ExecutionException(IllegalStateException("Health Services busy"))
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
WatchExerciseMetricsRetryPolicy.isDistanceSecurityFailure(
|
||||||
|
setOf(DataType.DISTANCE, DataType.CALORIES, DataType.HEART_RATE_BPM),
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun distanceSecurityRetriesAreBounded() {
|
||||||
|
assertFalse(WatchExerciseMetricsRetryPolicy.canRetryDistance(0))
|
||||||
|
assertTrue(WatchExerciseMetricsRetryPolicy.canRetryDistance(1))
|
||||||
|
assertTrue(WatchExerciseMetricsRetryPolicy.canRetryDistance(2))
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,3 +2,4 @@
|
|||||||
android.builtInKotlin=false
|
android.builtInKotlin=false
|
||||||
# This newDsl flag was added automatically by Flutter migrator
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
|
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
|
||||||
|
|||||||
Reference in New Issue
Block a user