Compare commits

..

6 Commits

Author SHA1 Message Date
Sylvain Bettinelli
19fbfd116e pbxproj : IntervalEngine était déclaré deux fois
Deux sessions ont ajouté le même fichier à la cible CoachWatch en parallèle —
l'une depuis Xcode sur le Mac (be1ffec), l'autre en éditant le pbxproj sous
Linux (a04dfc4). Le rebase a fusionné les deux sans conflit, puisqu'elles
touchaient des lignes différentes : le fichier se retrouvait déclaré deux fois
et Xcode aurait refusé de compiler pour redéclaration.

Les entrées Xcode sont conservées, les miennes retirées. RouteFilter et
LocationTracker n'étaient pas concernés.

Repère de contrôle, à refaire après toute fusion touchant le pbxproj : chaque
fichier doit rendre 2 pour `grep -c '<Nom>.swift in Sources'` — une seule
cible. 4 signale un doublon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:37:54 +00:00
Sylvain Bettinelli
eb523ff205 COWORK : les fichiers sont dans la cible, la manip Xcode n'a plus lieu d'être
La note de ce matin demandait un « Add Files » pour IntervalEngine. Les trois
fichiers du chantier sont désormais déclarés dans la cible CoachWatch
directement dans le pbxproj : suivre l'ancienne consigne créerait un doublon.

Ajout de l'ordre de vérification au premier build — la trace dans Santé et la
non-régression de la FC sur /live sont les deux points qui décident si le
chantier tient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:37:24 +00:00
Sylvain Bettinelli
acbb2c6896 La montre enregistre enfin sa trace GPS
La cible watchOS ne contenait pas une ligne de localisation : ni
CLLocationManager, ni HKWorkoutRouteBuilder, et aucune clé NSLocation dans son
Info.plist. Tant que les séances partaient de l'app Exercice d'Apple, c'est
elle qui écrivait la trace et CoachHealthRoute la relisait après coup. Dès
qu'une séance démarre depuis CoachWatch, plus personne ne l'écrit : la sortie
n'aurait ni carte, ni parcours dans Santé.

Le filtrage vit dans `RouteFilter`, en Foundation pur, donc testable ici :
rejet des points imprécis (> 50 m, seuil de l'exemple Apple), des sauts
impossibles, des doublons et des points arrivés dans le désordre, puis cumul de
la distance (haversine) et du dénivelé.

Le dénivelé a demandé deux passes. L'hystérésis seule laissait passer 297 m de
D+ sur un parcours PLAT : une oscillation d'amplitude égale au seuil est
comptée à chaque alternance, ce qui est inhérent à tout seuil. D'où un lissage
préalable, qui annule le bruit alternant. Contrepartie assumée et testée : une
pointe franchie en quelques points est écrêtée, donc sous-comptée — sans
conséquence, le D+ qui fait foi restant celui, barométrique, qu'Apple écrit
dans les métadonnées de la séance.

Quatre pièges documentés, tous traités dans le code :
- `allowsBackgroundLocationUpdates` sans `UIBackgroundModes` = `location`
  TERMINE l'app. Un garde-fou vérifie le plist avant d'armer le drapeau.
- Ne jamais demander « Always » sur watchOS : Apple décrit ce prompt comme
  « mostly a placeholder » et le parcours comme un comportement indéfini.
- Interdit de relancer la localisation depuis l'arrière-plan : la pause garde
  le flux ouvert et ignore les points, au lieu de couper le manager.
- Le CPU tue le GPS — d'où l'insertion des points par lots et la trace
  d'affichage bornée à 1500 points.

Corrigé après lecture de la doc : `finishRoute` s'appelle APRÈS `finishWorkout`
et reçoit le workout pour s'y associer. J'avais écrit l'inverse, avec nil, ce
qui aurait perdu l'association à chaque sortie. Et `finishWorkout()` rend nil
sans erreur quand la montre est verrouillée : on sauvegarde alors la trace sans
association plutôt que de la jeter.

15 tests, dont 4 vérifiés rouges en désactivant le lissage. Les trois fichiers
sont déclarés dans la cible CoachWatch (pbxproj sauvegardé en .bak-outdoor) :
aucun « Add Files » à faire sur le Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:37:24 +00:00
Sylvain
be1ffec5c1 IntervalEngine rejoint la cible CoachWatch 2026-08-20 18:35:47 +02:00
Sylvain Bettinelli
1a5b9cbcad COWORK : le moteur d'intervalles, et une commande qui n'existait pas
Deux corrections au fichier qui sert de canal entre le dev Linux et le Mac.

1. Le chantier IntervalEngine (f2fa3cc, ce matin) n'y figurait pas — or c'est
   par ce fichier que le poste Mac apprend ce qu'il y a à faire. Ajouté avec
   l'étape bloquante : le fichier n'est PAS membre de la cible CoachWatch
   (grep rend 0), donc Xcode l'ignore et le projet compile très bien sans lui.
   Même piège que VmaTestView.swift.

   Noté aussi que les 42 tests Swift passent sur Linux (./tests-linux/run.sh),
   dont les 17 du moteur : la logique est validée, seule l'intégration reste.

2. La procédure de build disait `open ios/App/App.xcworkspace`. Ce fichier
   n'existe pas : le dépôt ne contient que App.xcodeproj, Capacitor 8 étant
   passé à SPM. La commande échouait telle quelle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:24:12 +00:00
Sylvain Bettinelli
f2fa3cccb0 Le moteur d'intervalles, pour guider une séance dans notre app
WorkoutKit ne sait pas exécuter une séance structurée dans une app tierce :
son seul point d'exécution public est `WorkoutPlan.openInWorkoutApp()`, qui
ouvre l'app Exercice d'Apple. Guider les intervalles du plan coach au poignet
impose donc d'écrire la machine à états nous-mêmes.

`IntervalEngine` est volontairement pur — aucun HealthKit, aucun timer, aucune
horloge interne. Il répond à « où en sommes-nous ? » à partir du temps et de la
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
qui exclut déjà les pauses : le moteur n'a rien à savoir de la pause ni de
l'auto-pause. Et comme il est une fonction de la suite des ticks, rejouer
celle-ci redonne le même déroulé — ce qui rend la reprise après crash triviale.

Le point délicat est la dérive. Quand un tick arrive en retard (app suspendue
poignet baissé, collecte irrégulière), l'étape suivante démarre à la frontière
THÉORIQUE de la précédente, jamais à l'instant du tick. Sinon chaque
répétition offre quelques secondes de rab et l'erreur s'accumule : sur un
9×(1'/1'), le décalage final se compte en dizaines de secondes. Pour la même
raison `update` boucle au lieu de tester une fois — un tick manqué peut
franchir plusieurs étapes courtes d'un coup.

17 tests, dont 5 vérifiés rouges en neutralisant les frontières théoriques.
Rien n'est encore câblé : le fichier n'est pas membre de la cible CoachWatch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 07:50:27 +00:00
12 changed files with 2588 additions and 12 deletions

View File

@@ -76,9 +76,63 @@ ouvrait Safari**, où le cookie d'auth injecté dans la WKWebView n'existe pas :
`CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle `CoachLiveView` s'ouvre d'elle-même (le JS de `/live` appelle
`openNativeLive`). C'est le point d'entrée qui manquait. `openNativeLive`). C'est le point d'entrée qui manquait.
## 🆕 Chantier montre outdoor (2026-08-20, dev — non compilé)
Branche **`feat/watch-outdoor`**, commits `f2fa3cc` (moteur d'intervalles) et
`a04dfc4` (trace GPS). `IntervalEngine` guide les intervalles d'une séance au
poignet ; `RouteFilter` + `LocationTracker` enregistrent le parcours.
**Pourquoi ce code existe** : WorkoutKit ne sait pas exécuter une séance
structurée dans une app tierce — son seul point d'exécution public est
`WorkoutPlan.openInWorkoutApp()`, qui ouvre l'app Exercice d'Apple. Il faut donc
écrire la machine à états nous-mêmes.
**Le moteur est volontairement pur** : aucun HealthKit, aucun timer, aucune
horloge interne. Il répond à « où en sommes-nous ? » à partir du temps et de la
distance qu'on lui pousse. Le temps de référence est `elapsedTime` du builder,
qui exclut déjà les pauses.
**57 tests Swift verts sur Linux** (`./tests-linux/run.sh`, toolchain
`~/workspace/toolchains/bin`) : 17 pour `IntervalEngine`, 15 pour `RouteFilter`.
Les deux ont été **vérifiés rouges** en neutralisant leur correction (5 échecs
sans les frontières théoriques du moteur, 4 sans le lissage du dénivelé). La
logique est donc validée — reste l'intégration, que Linux ne peut pas compiler.
**Plus d'« Add Files » à faire** — correction du 2026-08-20 après-midi. Une
note antérieure de ce fichier annonçait `IntervalEngine` absent du projet : ce
n'est plus vrai. Les **trois** fichiers sont déclarés dans la cible `CoachWatch`
directement dans le `pbxproj` (sauvegarde `project.pbxproj.bak-outdoor`).
Contrôle avant de builder — chacun doit rendre **2** (déclaration + phase de
compilation, soit 1 cible) :
```bash
for f in IntervalEngine RouteFilter LocationTracker; do
echo "$f: $(grep -c "$f.swift in Sources" ios/App/App.xcodeproj/project.pbxproj)"
done
```
⚠️ **À vérifier au premier build, dans cet ordre** :
1. **Une séance extérieure demande la localisation** (feuille « lorsque l'app
est active »). Si rien n'apparaît, la trace ne partira pas.
2. **La sortie a bien un parcours dans Santé** à la fin. C'est le test qui
compte : `finishRoute` doit être appelé APRÈS `finishWorkout`, sinon la
trace existe sans être associée à la séance.
3. **La FC remonte toujours sur `/live`**`WorkoutManager` a été modifié,
c'est le test de non-régression prioritaire.
4. Écran éteint, poignet baissé : les positions continuent d'arriver. Si elles
s'arrêtent, chercher du côté du CPU (une boucle d'affichage trop rapide fait
suspendre l'app), pas du côté des autorisations.
⚠️ `Info.plist` de `CoachWatch` gagne `UIBackgroundModes = [location]` et
`NSLocationWhenInUseUsageDescription`. **La clé de background est vitale** :
armer `allowsBackgroundLocationUpdates` sans elle termine l'app. Un garde-fou
la vérifie au démarrage, mais si elle disparaissait du build, le suivi
retomberait silencieusement au premier plan seul.
## 👉 À reprendre ## 👉 À reprendre
-**Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`. -**Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
- **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcworkspace` → Clean Build Folder → Archive → Upload. - **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcodeproj` → Clean Build Folder → Archive → Upload.
- ⚠️ **Corrigé le 2026-08-20** : cette ligne disait `open ios/App/App.xcworkspace`. **Ce fichier n'existe pas** (vérifié : le dépôt ne contient que `App.xcodeproj`). Depuis **Capacitor 8, les dépendances passent par SPM** et il n'y a plus de workspace CocoaPods. La commande échouait donc telle quelle.
- ⚠️ **Fixes robustesse 2026-07-01 (dev, non compilés)** à valider au prochain build : `CoachWidgetBridge` (score lu en `NSNumber.intValue` → corrige un score float perdu ; `save` renvoie Bool → `call.reject` si App Group KO), `CoachAppleAuth` (double-tap : rejette l'ancien `pendingCall` ; erreur testée par domaine `ASAuthorizationError`). Swift pur, aucun nouveau fichier ni capability. - ⚠️ **Fixes robustesse 2026-07-01 (dev, non compilés)** à valider au prochain build : `CoachWidgetBridge` (score lu en `NSNumber.intValue` → corrige un score float perdu ; `save` renvoie Bool → `call.reject` si App Group KO), `CoachAppleAuth` (double-tap : rejette l'ancien `pendingCall` ; erreur testée par domaine `ASAuthorizationError`). Swift pur, aucun nouveau fichier ni capability.
- ⚠️ **Checklist pré-App-Store (audit sécu 2026-07-01)** : `ios/App/App/App.entitlements` a `aps-environment = development`. Pour la soumission **App Store/TestFlight**, l'APNs prod exige `production`. NON changé côté dev (casserait le push en dev device — arbitrage de signing à faire sur Mac : soit basculer `production` avant l'archive de distribution, soit laisser Xcode le gérer via le profil de distribution auto). À trancher/tester au moment du build release. - ⚠️ **Checklist pré-App-Store (audit sécu 2026-07-01)** : `ios/App/App/App.entitlements` a `aps-environment = development`. Pour la soumission **App Store/TestFlight**, l'APNs prod exige `production`. NON changé côté dev (casserait le push en dev device — arbitrage de signing à faire sur Mac : soit basculer `production` avant l'archive de distribution, soit laisser Xcode le gérer via le profil de distribution auto). À trancher/tester au moment du build release.
- **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`. - **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`.

View File

@@ -10,7 +10,6 @@
01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; }; 01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; };
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; }; 0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; 19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; }; 1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; }; 21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; }; 23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; };
@@ -19,6 +18,8 @@
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; }; 2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; }; 436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
@@ -31,14 +32,13 @@
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; }; 548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; }; 60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; }; 6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; };
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; }; 694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; }; 79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; }; 880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; };
880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; }; 880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; };
880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; 880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; }; 88342F7D30376311002D1811 /* IntervalEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88342F7C30376311002D1811 /* IntervalEngine.swift */; };
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 88924CCD2FD2A733002F3A80 /* GoogleSignIn */; }; 88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 88924CCD2FD2A733002F3A80 /* GoogleSignIn */; };
88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; }; 88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; };
88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.swift */; }; 88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.swift */; };
@@ -50,14 +50,17 @@
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; }; 95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; }; 97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; 97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; }; 9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; }; A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; }; B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; }; C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; }; D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; }; D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; }; E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
@@ -152,6 +155,7 @@
880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
880A76E7302B634400F582D5 /* CoachWatchWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachWatchWidgetsExtension.entitlements; sourceTree = "<group>"; }; 880A76E7302B634400F582D5 /* CoachWatchWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachWatchWidgetsExtension.entitlements; sourceTree = "<group>"; };
88342F7C30376311002D1811 /* IntervalEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalEngine.swift; sourceTree = "<group>"; };
884A1F9D3020E3C100457184 /* CoachLiveActivity.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachLiveActivity.entitlements; sourceTree = "<group>"; }; 884A1F9D3020E3C100457184 /* CoachLiveActivity.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachLiveActivity.entitlements; sourceTree = "<group>"; };
88A837392FAA741C00DDD144 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; }; 88A837392FAA741C00DDD144 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
88ABE0023024C45A002636FD /* CoachQuickLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachQuickLog.swift; sourceTree = "<group>"; }; 88ABE0023024C45A002636FD /* CoachQuickLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachQuickLog.swift; sourceTree = "<group>"; };
@@ -160,21 +164,23 @@
88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; }; 88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; };
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; }; 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; 8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteFilter.swift; sourceTree = "<group>"; };
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationTracker.swift; sourceTree = "<group>"; };
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; }; 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; }; 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; };
969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = "<group>"; }; 969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = "<group>"; };
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.swift; sourceTree = "<group>"; }; 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.swift; sourceTree = "<group>"; };
9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VmaTestView.swift; sourceTree = "<group>"; };
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; }; A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; };
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; }; A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; }; A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; }; AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; }; B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; }; C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; };
C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; sourceTree = "<group>"; }; C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VmaTestView.swift; sourceTree = "<group>"; };
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; }; D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; };
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; }; D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
D61D7B2F5AFEF834423E07AB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; D61D7B2F5AFEF834423E07AB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
@@ -323,6 +329,7 @@
5A8EA0CF92316E437E9053A0 /* CoachWatch */ = { 5A8EA0CF92316E437E9053A0 /* CoachWatch */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
88342F7C30376311002D1811 /* IntervalEngine.swift */,
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */, F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */,
8A06CDFF5B4F7852B6329862 /* ContentView.swift */, 8A06CDFF5B4F7852B6329862 /* ContentView.swift */,
82122ACF7E5A608AD1D55D24 /* Info.plist */, 82122ACF7E5A608AD1D55D24 /* Info.plist */,
@@ -333,6 +340,8 @@
A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */, A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */,
969DB880395BDEB107C9F2FE /* RoutineStore.swift */, 969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */, 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */,
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */,
3342ACCE085010828CF63565 /* Assets.xcassets */, 3342ACCE085010828CF63565 /* Assets.xcassets */,
); );
path = CoachWatch; path = CoachWatch;
@@ -591,9 +600,12 @@
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */, B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
95D0478A499056FE922211EA /* RoutineView.swift in Sources */, 95D0478A499056FE922211EA /* RoutineView.swift in Sources */,
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */, 6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */,
88342F7D30376311002D1811 /* IntervalEngine.swift in Sources */,
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */, A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */,
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */, 23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */, 436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */,
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,18 @@
<string>Coach Hypnotruck lit ta fréquence cardiaque et tes calories pendant la séance pour les afficher en direct sur ton iPhone.</string> <string>Coach Hypnotruck lit ta fréquence cardiaque et tes calories pendant la séance pour les afficher en direct sur ton iPhone.</string>
<key>NSHealthUpdateUsageDescription</key> <key>NSHealthUpdateUsageDescription</key>
<string>Coach Hypnotruck enregistre ta séance dans l'app Santé.</string> <string>Coach Hypnotruck enregistre ta séance dans l'app Santé.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Coach Hypnotruck suit ta position pendant la séance pour tracer ton parcours, afficher la carte et mesurer le dénivelé.</string>
<!-- ⚠️ `location` est requis par `allowsBackgroundLocationUpdates` : armer ce
drapeau sans la clé TERMINE l'app (« is a fatal error that terminates
the app », doc Apple). `LocationTracker.backgroundModeDeclared` vérifie
donc cette clé avant d'armer quoi que ce soit. La clé qui porte
`location` sur watchOS est `UIBackgroundModes`, distincte de
`WKBackgroundModes` qui porte `workout-processing`. -->
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>
<key>WKApplication</key> <key>WKApplication</key>
<true/> <true/>
<key>WKBackgroundModes</key> <key>WKBackgroundModes</key>

View File

@@ -0,0 +1,360 @@
import Foundation
/* Déroulé d'une séance structurée au poignet.
*
* **Pourquoi ce fichier existe.** WorkoutKit ne sait pas exécuter une séance
* dans une app tierce : son seul point d'exécution public est
* `WorkoutPlan.openInWorkoutApp()`, qui ouvre l'app *Exercice* d'Apple. Pour
* guider les intervalles du plan coach dans NOTRE app, la machine à états doit
* être écrite ici.
*
* Le moteur est **volontairement pur** aucun import HealthKit, aucun timer,
* aucune horloge interne. Il ne fait que répondre à la question « où en
* sommes-nous ? » à partir du temps et de la distance que l'appelant lui
* pousse. Trois conséquences :
*
* - il se teste intégralement sur Linux (cf. `tests-linux/`), là où
* `WorkoutManager` ne le peut pas ;
* - le temps de référence est `HKLiveWorkoutBuilder.elapsedTime`, qui
* **exclut déjà les pauses** : le moteur n'a donc rien à savoir de la
* pause, ni de l'auto-pause de la montre ;
* - il est rejouable : réinjecter la même suite de ticks redonne le même
* déroulé, ce qui rend la reprise après crash triviale
* (`handleActiveWorkoutRecovery`).
*/
// MARK: - Modèle
/// Objectif de fin d'une étape.
///
/// `open` décrit une étape qui ne se termine que sur ordre de l'utilisateur
/// l'échauffement libre « pars quand tu es prêt », ou le retour au calme.
public enum StepGoal: Equatable, Sendable {
case time(TimeInterval)
case distance(Double) // mètres
case open
}
/// Une étape du plan, telle qu'elle arrive de l'iPhone.
///
/// Les bornes de FC sont transportées **en bpm ET en numéro de zone**. Les deux
/// sont nécessaires : la zone sert à l'affichage (« Z2 »), les bpm sont la seule
/// cible exécutable, et elles proviennent de `app.current_zones()` zones
/// Karvonen sous bêtabloquant, jamais un % de FCmax générique.
public struct CoachPlanStep: Codable, Equatable, Sendable {
public enum Kind: String, Codable, Sendable {
case warmup, work, recovery, cooldown
}
public var kind: Kind
public var durationSec: Double?
public var distanceM: Double?
public var hrZone: Int?
public var hrMinBpm: Double?
public var hrMaxBpm: Double?
public var label: String?
enum CodingKeys: String, CodingKey {
case kind
case durationSec = "duration_sec"
case distanceM = "distance_m"
case hrZone = "hr_zone"
case hrMinBpm = "hr_bpm_min"
case hrMaxBpm = "hr_bpm_max"
case label
}
public init(kind: Kind,
durationSec: Double? = nil,
distanceM: Double? = nil,
hrZone: Int? = nil,
hrMinBpm: Double? = nil,
hrMaxBpm: Double? = nil,
label: String? = nil) {
self.kind = kind
self.durationSec = durationSec
self.distanceM = distanceM
self.hrZone = hrZone
self.hrMinBpm = hrMinBpm
self.hrMaxBpm = hrMaxBpm
self.label = label
}
/// La durée prime sur la distance quand les deux sont fournies : le plan
/// coach est écrit en temps, la distance n'est qu'une alternative.
public var goal: StepGoal {
if let d = durationSec, d > 0 { return .time(d) }
if let m = distanceM, m > 0 { return .distance(m) }
return .open
}
/// Libellé affichable, avec repli sur le type d'étape. Le plan porte les
/// consignes réelles dans `label` (« cadence 170+, foulée courte ») : c'est
/// cette intention qu'on veut au poignet, pas un « Course » générique.
public var displayLabel: String {
if let l = label?.trimmingCharacters(in: .whitespacesAndNewlines), !l.isEmpty {
return l
}
switch kind {
case .warmup: return "Échauffement"
case .work: return "Effort"
case .recovery: return "Récupération"
case .cooldown: return "Retour au calme"
}
}
}
/// La séance du jour, poussée par l'iPhone.
public struct CoachSessionPlan: Codable, Equatable, Sendable {
public var date: String
public var sport: String
public var title: String?
public var steps: [CoachPlanStep]
enum CodingKeys: String, CodingKey {
case date, sport, title, steps
}
public init(date: String, sport: String, title: String? = nil, steps: [CoachPlanStep]) {
self.date = date
self.sport = sport
self.title = title
self.steps = steps
}
/// Durée totale prévue. `nil` dès qu'une étape est ouverte ou en distance :
/// mieux vaut ne rien annoncer qu'annoncer un total faux.
public var plannedDurationSec: TimeInterval? {
var total: TimeInterval = 0
for step in steps {
guard case .time(let d) = step.goal else { return nil }
total += d
}
return total
}
}
// MARK: - Moteur
/// Ce que le moteur signale à l'appelant entre deux ticks.
///
/// Les événements sont **rendus, jamais joués** par le moteur : c'est
/// `WorkoutManager` qui décide d'un haptique ou d'un `HKWorkoutEvent`. Cette
/// séparation est ce qui garde le moteur testable.
public enum IntervalEvent: Equatable, Sendable {
case stepStarted(index: Int)
case stepFinished(index: Int)
case planFinished
/// Émis une seule fois par étape, à mi-parcours d'une étape mesurable.
case halfway(index: Int)
/// Vrai si l'événement fait changer d'étape.
///
/// L'annonce de mi-parcours voyage dans le même flux que les transitions
/// pratique pour l'appelant, qui n'a qu'une boucle à écrire mais elle ne
/// déplace rien. Sans ce distinguo, « rien n'a bougé » et « aucun événement »
/// se confondent, et c'est exactement l'erreur que les premiers tests de ce
/// fichier ont commise.
public var isTransition: Bool {
switch self {
case .stepStarted, .stepFinished, .planFinished: return true
case .halfway: return false
}
}
}
extension Array where Element == IntervalEvent {
/// Les seuls événements qui déplacent le curseur de séance.
public var transitions: [IntervalEvent] { filter(\.isTransition) }
}
/// Instantané de progression, destiné à l'affichage.
public struct IntervalProgress: Equatable, Sendable {
public var stepIndex: Int
public var step: CoachPlanStep?
public var nextStep: CoachPlanStep?
/// Temps passé dans l'étape courante (secondes actives).
public var elapsedInStep: TimeInterval
/// Temps restant, `nil` pour une étape ouverte ou en distance.
public var remainingInStep: TimeInterval?
/// Distance restante en mètres, `nil` si l'étape n'est pas en distance.
public var remainingDistanceM: Double?
/// 01, `nil` pour une étape ouverte.
public var fraction: Double?
public var isFinished: Bool
}
/// Machine à états du déroulé. `struct` mutable : pas d'état caché, copiable,
/// donc inspectable dans les tests.
public struct IntervalEngine: Equatable, Sendable {
public let steps: [CoachPlanStep]
public private(set) var index: Int = 0
public private(set) var isFinished: Bool = false
/// Origine de l'étape courante, dans le référentiel du builder.
private var stepStartElapsed: TimeInterval = 0
private var stepStartDistance: Double = 0
private var lastElapsed: TimeInterval = 0
private var lastDistance: Double = 0
private var halfwayAnnounced = false
public init(steps: [CoachPlanStep]) {
self.steps = steps
self.isFinished = steps.isEmpty
}
public init(plan: CoachSessionPlan) {
self.init(steps: plan.steps)
}
public var currentStep: CoachPlanStep? {
guard !isFinished, steps.indices.contains(index) else { return nil }
return steps[index]
}
public var nextStep: CoachPlanStep? {
let n = index + 1
guard !isFinished, steps.indices.contains(n) else { return nil }
return steps[n]
}
/// Pousse l'état de la séance et récupère les transitions franchies.
///
/// `elapsed` est le temps **actif** (`HKLiveWorkoutBuilder.elapsedTime`),
/// `distance` la distance cumulée en mètres depuis le départ.
///
/// Une boucle, pas un `if` : un tick peut arriver en retard l'app est
/// suspendue poignet baissé, la collecte HealthKit est irrégulière et
/// franchir **plusieurs** étapes courtes d'un coup. Traiter une seule
/// transition par tick ferait dériver le déroulé sans que rien ne le dise.
@discardableResult
public mutating func update(elapsed: TimeInterval, distance: Double) -> [IntervalEvent] {
guard !isFinished else { return [] }
// Le temps actif ne recule pas ; la distance non plus. Un recul signale
// un appelant fautif : on borne plutôt que de produire des durées
// négatives qui se propageraient dans l'affichage.
lastElapsed = max(elapsed, lastElapsed)
lastDistance = max(distance, lastDistance)
var events: [IntervalEvent] = []
if !hasStarted {
hasStarted = true
events.append(.stepStarted(index: 0))
}
while !isFinished, let step = currentStep, isComplete(step) {
events.append(.stepFinished(index: index))
advanceIndex(at: lastElapsed, distance: lastDistance)
if isFinished {
events.append(.planFinished)
} else {
events.append(.stepStarted(index: index))
}
}
if !halfwayAnnounced, let f = progress().fraction, f >= 0.5, !isFinished {
halfwayAnnounced = true
events.append(.halfway(index: index))
}
return events
}
/// Termine l'étape courante sur ordre de l'utilisateur (bouton « suivant »,
/// ou fin d'une étape ouverte).
@discardableResult
public mutating func advanceManually() -> [IntervalEvent] {
guard !isFinished, currentStep != nil else { return [] }
var events: [IntervalEvent] = [.stepFinished(index: index)]
advanceIndex(at: lastElapsed, distance: lastDistance)
events.append(isFinished ? .planFinished : .stepStarted(index: index))
return events
}
public func progress() -> IntervalProgress {
guard !isFinished, let step = currentStep else {
return IntervalProgress(stepIndex: index, step: nil, nextStep: nil,
elapsedInStep: 0, remainingInStep: nil,
remainingDistanceM: nil, fraction: nil,
isFinished: true)
}
let inStep = max(0, lastElapsed - stepStartElapsed)
var remaining: TimeInterval?
var remainingDistance: Double?
var fraction: Double?
switch step.goal {
case .time(let target):
remaining = max(0, target - inStep)
fraction = target > 0 ? min(1, inStep / target) : nil
case .distance(let target):
let done = max(0, lastDistance - stepStartDistance)
remainingDistance = max(0, target - done)
fraction = target > 0 ? min(1, done / target) : nil
case .open:
break
}
return IntervalProgress(stepIndex: index,
step: step,
nextStep: nextStep,
elapsedInStep: inStep,
remainingInStep: remaining,
remainingDistanceM: remainingDistance,
fraction: fraction,
isFinished: false)
}
// MARK: Privé
private var hasStarted = false
private func isComplete(_ step: CoachPlanStep) -> Bool {
switch step.goal {
case .time(let target):
return (lastElapsed - stepStartElapsed) >= target
case .distance(let target):
return (lastDistance - stepStartDistance) >= target
case .open:
// Une étape ouverte ne se termine JAMAIS toute seule : seul
// `advanceManually()` la clôt. Sinon la séance défilerait d'un coup.
return false
}
}
/// Fait démarrer l'étape suivante à la frontière **théorique** de l'étape
/// qui vient de finir, pas à l'instant du tick.
///
/// C'est la subtilité de tout ce fichier. Si un tick arrive 3 s après la
/// fin d'un intervalle d'une minute, caler le départ de la suivante sur ce
/// tick lui offre 3 s de rab et l'erreur **s'accumule** à chaque
/// répétition. Sur un 9×(1'/1'), le décalage final se compte en dizaines de
/// secondes.
private mutating func advanceIndex(at elapsed: TimeInterval, distance: Double) {
if let step = currentStep {
switch step.goal {
case .time(let target):
stepStartElapsed += target
stepStartDistance = distance
case .distance(let target):
stepStartDistance += target
stepStartElapsed = elapsed
case .open:
stepStartElapsed = elapsed
stepStartDistance = distance
}
}
// Une étape ouverte ou terminée à la main ne peut pas laisser l'origine
// derrière le tick courant.
stepStartElapsed = min(stepStartElapsed, elapsed)
stepStartDistance = min(stepStartDistance, distance)
index += 1
halfwayAnnounced = false
if index >= steps.count {
isFinished = true
}
}
}

View File

@@ -0,0 +1,318 @@
import Foundation
import CoreLocation
import HealthKit
import os
private let locationLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "location")
/* GPS de la séance : flux de positions, trace HealthKit, distance et D+.
*
* Le filtrage vit dans `RouteFilter` (Foundation pur, testé sur Linux). Ce
* fichier ne fait que le brancher sur CoreLocation et HealthKit c'est la
* seule partie qui exige un poignet pour être validée.
*
* **Pourquoi enregistrer la trace nous-mêmes.** Jusqu'ici les traces venaient
* de l'app *Exercice* d'Apple, relues après coup côté iPhone par
* `CoachHealthRoute`. Dès qu'une séance est lancée depuis CoachWatch, plus
* personne n'écrit de `HKWorkoutRoute` : sans ce fichier, la sortie n'aurait
* aucune trace, ni sur la carte ni dans Santé.
*
* **Quatre pièges, tous documentés et tous coûteux.**
*
* 1. `allowsBackgroundLocationUpdates = true` **sans** `UIBackgroundModes` =
* `location` dans l'Info.plist **termine l'app** Apple : « is a fatal
* error that terminates the app ». D'où le garde-fou `backgroundModeDeclared`
* ci-dessous, qui vérifie le plist avant d'armer le drapeau.
* 2. **Ne jamais demander « Always » sur watchOS.** Un ingénieur DTS d'Apple
* décrit ce prompt comme « mostly a placeholder » et le parcours comme
* « undefined behavior » : le statut boucle jusqu'à revenir à
* `.notDetermined`. `requestWhenInUseAuthorization()` suffit pour continuer
* à recevoir des positions en arrière-plan.
* 3. **Démarrer au premier plan, et ne plus jamais arrêter.** watchOS interdit
* de relancer les mises à jour depuis l'arrière-plan : une pause qui
* couperait le GPS ne pourrait pas le rallumer avant le retour à l'écran.
* On garde donc le flux ouvert et on ignore les points pendant la pause.
* 4. **Le CPU tue le GPS.** watchOS suspend une app qui consomme trop en
* arrière-plan, et les positions s'arrêtent sans la moindre erreur un
* développeur a perdu les siennes à cause d'un rafraîchissement d'écran à
* 1/100 s. D'où l'insertion des points par lots plutôt qu'un par un.
*/
@MainActor
final class LocationTracker: NSObject, ObservableObject {
static let shared = LocationTracker()
/// Trace décimée pour l'affichage carte. Bornée : une sortie de 3 h à 1 Hz
/// produirait plus de 10 000 points, que la montre ne peut pas redessiner
/// à chaque rafraîchissement sans se faire suspendre (piège 4).
@Published private(set) var trackForMap: [GeoFix] = []
@Published private(set) var lastFix: GeoFix?
@Published private(set) var distanceMeters: Double = 0
@Published private(set) var ascentMeters: Double = 0
@Published private(set) var descentMeters: Double = 0
/// Rayon d'incertitude du dernier point reçu, accepté ou non c'est la
/// jauge « qualité GPS » de l'écran.
@Published private(set) var horizontalAccuracy: Double?
@Published private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined
@Published private(set) var isTracking = false
private let manager = CLLocationManager()
private var filter = RouteFilter()
private var routeBuilder: HKWorkoutRouteBuilder?
/// Points en attente d'écriture dans HealthKit.
private var pendingRoute: [CLLocation] = []
/// Positions ignorées tant que la séance est en pause : le flux reste
/// ouvert (piège 3) mais la trace ne doit pas traverser l'arrêt.
private var isPaused = false
private let maxMapPoints = 1_500
private let routeFlushThreshold = 25
private override init() {
super.init()
manager.delegate = self
authorizationStatus = manager.authorizationStatus
}
// MARK: Autorisation
/// À appeler **au premier plan**, avant de démarrer la séance.
func requestAuthorization() {
// Volontairement pas `requestAlwaysAuthorization()` : voir piège 2.
guard manager.authorizationStatus == .notDetermined else { return }
manager.requestWhenInUseAuthorization()
}
var isAuthorized: Bool {
switch authorizationStatus {
case .authorizedWhenInUse, .authorizedAlways: return true
default: return false
}
}
// MARK: Cycle de vie
/// Démarre le suivi. **Doit être appelé pendant que l'app est au premier
/// plan** watchOS refuse de démarrer la localisation depuis l'arrière-plan.
///
/// - Parameter routeBuilder: fourni par `HKLiveWorkoutBuilder`, via
/// `seriesBuilder(for: HKSeriesType.workoutRoute())`. `nil` pour un suivi
/// d'affichage seul, sans écriture dans Santé.
func start(activity: HKWorkoutActivityType, routeBuilder: HKWorkoutRouteBuilder?) {
guard !isTracking else { return }
// On n'exige pas que l'autorisation soit DÉJÀ accordée : quand elle est
// encore en attente, le manager reste armé et CoreLocation délivre les
// positions dès l'acceptation. Refuser ici priverait de trace toute
// première séance, celle-là même où la demande apparaît.
if authorizationStatus == .denied || authorizationStatus == .restricted {
locationLog.error("localisation refusée par l'utilisateur : séance sans trace")
return
}
self.routeBuilder = routeBuilder
filter = RouteFilter(maxPlausibleSpeed: Self.maxSpeed(for: activity))
trackForMap.removeAll()
pendingRoute.removeAll()
distanceMeters = 0
ascentMeters = 0
descentMeters = 0
isPaused = false
// Le défaut de watchOS est `kCLLocationAccuracyHundredMeters` inutile
// pour une trace. `BestForNavigation` est réservé par Apple aux
// appareils branchés : trop gourmand pour une séance au poignet.
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = kCLDistanceFilterNone
manager.activityType = Self.activityType(for: activity)
if Self.backgroundModeDeclared {
manager.allowsBackgroundLocationUpdates = true
} else {
// Armer le drapeau sans la clé Info.plist tuerait l'app (piège 1).
// Mieux vaut une trace qui s'arrête écran éteint qu'un crash.
locationLog.error("UIBackgroundModes/location absent du plist : suivi limité au premier plan")
}
manager.startUpdatingLocation()
isTracking = true
locationLog.info("suivi GPS démarré")
}
/// Suspend l'enregistrement **sans couper le flux** (piège 3).
func setPaused(_ paused: Bool) {
guard isTracking else { return }
isPaused = paused
if paused {
// Le point qui suivra la reprise serait à des centaines de mètres :
// on repart d'une origine neuve pour ne pas tracer la pause.
pendingRoute.removeAll()
}
}
/// Coupe le flux GPS. À appeler dès la fin de séance ; la trace, elle, ne
/// se clôt qu'après la sauvegarde du workout voir `finishRoute(with:)`.
func stopTracking() {
guard isTracking else { return }
manager.stopUpdatingLocation()
isTracking = false
locationLog.info("suivi GPS arrete")
}
/// Clôt la trace et l'associe à la séance sauvegardée.
///
/// **L'ordre n'est pas négociable.** Apple : « After saving the workout,
/// add any remaining locations to the route builder and call finishRoute. »
/// Appeler `finishRoute` avant `finishWorkout` empêche l'association, et ne
/// pas l'appeler du tout fait perdre **toute** la trace le builder est
/// invalidé à sa libération.
///
/// `workout` peut être `nil` : `finishWorkout()` réussit mais ne rend
/// pas l'objet **quand la montre est verrouillée** (confirmé par un
/// ingénieur Apple). On sauvegarde alors la trace sans association plutôt
/// que de la jeter une route orpheline vaut mieux qu'une sortie sans
/// parcours. Une route ne peut être associée qu'une fois, et jamais après
/// coup.
///
/// Échoue aussi si aucune position n'a été insérée cas normal d'une
/// séance en salle, à ne pas remonter comme une anomalie.
func finishRoute(with workout: HKWorkout?) async {
await flushPendingRoute()
guard let builder = routeBuilder else { return }
self.routeBuilder = nil
guard filter.acceptedCount > 0 else {
locationLog.info("aucune position retenue : pas de trace a clore")
return
}
do {
_ = try await builder.finishRoute(with: workout, metadata: nil)
locationLog.info("trace close : \(self.filter.acceptedCount) points, \(Int(self.filter.distanceM)) m")
} catch {
locationLog.error("finishRoute a echoue : \(error.localizedDescription)")
}
}
// MARK: Écriture HealthKit
/// Écrit les points en attente. Par lots : une écriture par position
/// gaspillerait le CPU que watchOS surveille (piège 4).
private func flushPendingRoute() async {
guard let builder = routeBuilder, !pendingRoute.isEmpty else { return }
let batch = pendingRoute
pendingRoute.removeAll()
do {
try await builder.insertRouteData(batch)
} catch {
// Les points sont perdus, pas la séance : on ne remet pas le lot
// dans la file, sinon un échec persistant la ferait enfler
// indéfiniment en mémoire.
locationLog.error("insertRouteData a echoue (\(batch.count) points) : \(error.localizedDescription)")
}
}
// MARK: Réglages par activité
/// Plafond de vitesse plausible, en m/s. Le VTT électrique descend
/// largement au-dessus d'un coureur : un seuil unique écarterait des points
/// parfaitement valides.
private static func maxSpeed(for activity: HKWorkoutActivityType) -> Double {
switch activity {
case .cycling: return 30 // 108 km/h, descente comprise
case .running: return 12 // 43 km/h
default: return 8 // marche, randonnée
}
}
private static func activityType(for activity: HKWorkoutActivityType) -> CLActivityType {
switch activity {
// `.fitness` fait désactiver le positionnement intérieur et peut
// provoquer des pauses automatiques ; Apple recommande
// `.otherNavigation` pour le vélo et le hors-route.
case .cycling: return .otherNavigation
default: return .fitness
}
}
/// Le plist déclare-t-il le mode de fond `location` ?
///
/// watchOS porte deux clés distinctes : `WKBackgroundModes` pour
/// `workout-processing`, et `UIBackgroundModes` pour `location`. On accepte
/// les deux, la documentation d'Apple n'étant pas univoque sur ce point.
private static var backgroundModeDeclared: Bool {
let keys = ["UIBackgroundModes", "WKBackgroundModes"]
for key in keys {
if let modes = Bundle.main.object(forInfoDictionaryKey: key) as? [String],
modes.contains("location") {
return true
}
}
return false
}
}
extension LocationTracker: CLLocationManagerDelegate {
nonisolated func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
let fixes = locations.map { GeoFix(location: $0) }
let raw = locations
Task { @MainActor in
self.ingest(fixes: fixes, raw: raw)
}
}
nonisolated func locationManager(_ manager: CLLocationManager,
didFailWithError error: Error) {
locationLog.error("CoreLocation a echoue : \(error.localizedDescription)")
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
self.authorizationStatus = status
}
}
private func ingest(fixes: [GeoFix], raw: [CLLocation]) {
horizontalAccuracy = fixes.last?.horizontalAccuracy
guard !isPaused else { return }
for (fix, location) in zip(fixes, raw) {
guard case .accepted = filter.add(fix) else { continue }
lastFix = fix
pendingRoute.append(location)
appendToMap(fix)
}
distanceMeters = filter.distanceM
ascentMeters = filter.ascentM
descentMeters = filter.descentM
if pendingRoute.count >= routeFlushThreshold {
Task { await flushPendingRoute() }
}
}
/// Ajoute à la trace d'affichage, en décimant une fois le plafond atteint :
/// on garde un point sur deux, ce qui divise la densité sans déformer le
/// tracé ni faire enfler la mémoire sur une longue sortie.
private func appendToMap(_ fix: GeoFix) {
trackForMap.append(fix)
if trackForMap.count > maxMapPoints {
trackForMap = trackForMap.enumerated()
.compactMap { $0.offset.isMultiple(of: 2) ? $0.element : nil }
}
}
}
extension GeoFix {
/// Conversion depuis CoreLocation. Isolée ici pour que `RouteFilter` reste
/// testable hors d'un Mac.
init(location: CLLocation) {
self.init(lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
altitude: location.altitude,
horizontalAccuracy: location.horizontalAccuracy,
verticalAccuracy: location.verticalAccuracy,
speed: location.speed,
timestamp: location.timestamp)
}
}

View File

@@ -0,0 +1,211 @@
import Foundation
/* Filtrage de la trace GPS et dénivelé, en Foundation pur.
*
* **Pourquoi ce fichier est séparé de CoreLocation.** `CLLocation` n'existe pas
* sur Linux : tant que le filtrage vit dans la classe qui parle au GPS, il ne
* se teste que sur un poignet, en marchant. Ici, `GeoFix` recopie les seuls
* champs dont le filtrage a besoin ; `LocationTracker` fait la conversion et
* reste, lui, non testable hors Xcode.
*
* **Ce que ce fichier NE fait PAS.** Il ne produit pas la distance officielle
* de la séance. HealthKit collecte `distanceWalkingRunning` / `distanceCycling`
* par fusion GPS + accéléromètre, et fait autorité c'est déjà ce que lit
* `WorkoutManager`. La distance calculée ici sert à la carte et au découpage
* des étapes en distance, où l'on a besoin d'une valeur à chaque point plutôt
* qu'à chaque échantillon HealthKit.
*/
/// Un point GPS, réduit à ce dont le filtrage a besoin.
public struct GeoFix: Equatable, Sendable {
public var lat: Double
public var lon: Double
/// Mètres au-dessus du niveau de la mer.
public var altitude: Double
/// Rayon d'incertitude horizontale en mètres. **Négatif = fix invalide**,
/// convention Apple.
public var horizontalAccuracy: Double
/// Incertitude verticale. Négatif = altitude non fiable.
public var verticalAccuracy: Double
/// Vitesse instantanée en m/s. **Négatif = inconnue**, convention Apple.
public var speed: Double
public var timestamp: Date
public init(lat: Double, lon: Double, altitude: Double = 0,
horizontalAccuracy: Double = 5, verticalAccuracy: Double = 5,
speed: Double = -1, timestamp: Date) {
self.lat = lat
self.lon = lon
self.altitude = altitude
self.horizontalAccuracy = horizontalAccuracy
self.verticalAccuracy = verticalAccuracy
self.speed = speed
self.timestamp = timestamp
}
}
/// Pourquoi un point a été écarté. Renvoyé plutôt que jeté en silence : sur le
/// terrain, « la trace est trouée » sans plus d'explication ne se diagnostique
/// pas la répartition des motifs, elle, se lit.
public enum FixRejection: String, Equatable, Sendable {
case invalidAccuracy // horizontalAccuracy <= 0 : fix inutilisable
case tooImprecise // au-delà du rayon toléré
case outOfOrder // arrivé après un point plus récent
case duplicate // même horodatage que le précédent
case implausibleSpeed // saut impossible entre deux points
}
public enum FixOutcome: Equatable, Sendable {
case accepted
case rejected(FixRejection)
}
/// Accumule une trace propre : filtre les points douteux, cumule distance et
/// dénivelé.
///
/// `struct` mutable et sans horloge interne, comme `IntervalEngine` : rejouer
/// la même suite de points redonne le même résultat.
public struct RouteFilter: Equatable, Sendable {
/// Rayon d'incertitude maximum toléré, en mètres.
///
/// 50 m est le seuil retenu par l'exemple de trace d'Apple. Plus strict
/// perdrait les premières secondes sous couvert forestier ; plus laxiste
/// laisserait entrer les points « sautés » qui gonflent la distance.
public var maxHorizontalAccuracy: Double
/// Vitesse au-delà de laquelle un déplacement entre deux points est jugé
/// impossible, en m/s. 30 m/s 108 km/h : au-dessus, c'est une
/// téléportation GPS ou un trajet en voiture, pas une séance.
public var maxPlausibleSpeed: Double
/// Écart vertical à franchir avant de compter une montée ou une descente.
///
/// Sans ce seuil, le bruit de l'altimètre s'accumule : quelques
/// dizaines de centimètres d'oscillation à chaque point produisent des
/// centaines de mètres de D+ sur une sortie plate. L'hystérésis ne compte
/// que les changements de sens confirmés.
///
/// **Le seuil seul ne suffit pas** mesuré en test : un bruit alternant
/// de ±1,5 m, soit exactement l'amplitude du seuil, était compté à chaque
/// alternance et produisait 297 m de D+ sur un parcours plat. C'est
/// inhérent à tout seuil : une oscillation d'amplitude seuil passe. D'où
/// le lissage préalable ci-dessous, qui est le vrai remède.
public var elevationThreshold: Double
/// Nombre de points de la moyenne glissante appliquée à l'altitude avant
/// l'hystérésis. `1` désactive le lissage.
///
/// Le bruit alternant s'annule dans la moyenne, une vraie pente la
/// traverse. Contrepartie assumée : la moyenne **retarde** le signal d'une
/// demi-fenêtre, ce qui ampute le D+ de quelques mètres en fin de trace.
/// Sans conséquence ici ce D+ sert l'affichage pendant la séance, alors
/// que la valeur qui fait foi est le D+ barométrique d'Apple, relu après
/// coup depuis les métadonnées HealthKit.
public var elevationSmoothing: Int
public private(set) var distanceM: Double = 0
public private(set) var ascentM: Double = 0
public private(set) var descentM: Double = 0
public private(set) var acceptedCount: Int = 0
public private(set) var rejectedCount: Int = 0
public private(set) var lastAccepted: GeoFix?
/// Altitude de référence de l'hystérésis.
private var pivotAltitude: Double?
/// Fenêtre glissante des altitudes retenues.
private var altWindow: [Double] = []
public init(maxHorizontalAccuracy: Double = 50,
maxPlausibleSpeed: Double = 30,
elevationThreshold: Double = 3,
elevationSmoothing: Int = 5) {
self.maxHorizontalAccuracy = maxHorizontalAccuracy
self.maxPlausibleSpeed = maxPlausibleSpeed
self.elevationThreshold = elevationThreshold
self.elevationSmoothing = max(1, elevationSmoothing)
}
/// Soumet un point. Renvoie s'il a été retenu, et pourquoi sinon.
@discardableResult
public mutating func add(_ fix: GeoFix) -> FixOutcome {
if fix.horizontalAccuracy <= 0 {
rejectedCount += 1
return .rejected(.invalidAccuracy)
}
if fix.horizontalAccuracy > maxHorizontalAccuracy {
rejectedCount += 1
return .rejected(.tooImprecise)
}
if let previous = lastAccepted {
let dt = fix.timestamp.timeIntervalSince(previous.timestamp)
if dt == 0 {
rejectedCount += 1
return .rejected(.duplicate)
}
if dt < 0 {
// CoreLocation peut livrer des points dans le désordre après
// une reprise ; les intégrer ferait reculer la distance.
rejectedCount += 1
return .rejected(.outOfOrder)
}
let step = Self.distance(from: previous, to: fix)
if step / dt > maxPlausibleSpeed {
rejectedCount += 1
return .rejected(.implausibleSpeed)
}
distanceM += step
}
accumulateElevation(fix)
lastAccepted = fix
acceptedCount += 1
return .accepted
}
/// Distance orthodromique entre deux points, en mètres (formule de
/// haversine). Volontairement 2D : c'est la convention de toutes les
/// plateformes, et intégrer la pente gonflerait la distance par rapport à
/// ce qu'affichent Santé, Strava ou la montre.
public static func distance(from a: GeoFix, to b: GeoFix) -> Double {
// Rayon moyen terrestre WGS84.
let radius = 6_371_008.8
let φ1 = a.lat * .pi / 180
let φ2 = b.lat * .pi / 180
let = (b.lat - a.lat) * .pi / 180
let = (b.lon - a.lon) * .pi / 180
let h = sin( / 2) * sin( / 2)
+ cos(φ1) * cos(φ2) * sin( / 2) * sin( / 2)
return 2 * radius * asin(min(1, sqrt(h)))
}
/// Hystérésis du dénivelé : on ne valide un sens qu'après avoir franchi le
/// seuil depuis le dernier point de bascule.
private mutating func accumulateElevation(_ fix: GeoFix) {
// Une altitude sans incertitude fiable ne doit pas nourrir le D+ : le
// GPS seul dérive de plusieurs mètres, l'altimètre barométrique non.
guard fix.verticalAccuracy > 0 else { return }
// Lissage d'abord : le bruit alternant s'annule dans la moyenne. On
// n'attend pas que la fenêtre soit pleine sinon les premiers points
// seraient perdus, et avec eux le début de la première montée.
altWindow.append(fix.altitude)
if altWindow.count > elevationSmoothing { altWindow.removeFirst() }
let smoothed = altWindow.reduce(0, +) / Double(altWindow.count)
guard let pivot = pivotAltitude else {
pivotAltitude = smoothed
return
}
let delta = smoothed - pivot
if delta >= elevationThreshold {
ascentM += delta
pivotAltitude = smoothed
} else if delta <= -elevationThreshold {
descentM += -delta
pivotAltitude = smoothed
}
// Entre les deux : oscillation, on ne bouge pas le pivot.
}
}

View File

@@ -49,8 +49,18 @@ final class WorkoutManager: NSObject, ObservableObject {
// ne la produit pas, les stats restent vides : `ingest` retombe alors // ne la produit pas, les stats restent vides : `ingest` retombe alors
// sur la vitesse moyenne dérivée de la distance. // sur la vitesse moyenne dérivée de la distance.
let speedType = Self.speedType(for: activityType) let speedType = Self.speedType(for: activityType)
let read: Set<HKObjectType> = [hr, energy, distanceType, speedType] // La trace exige d'être demandée en lecture ET en écriture Apple :
let share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType] // « you must request permission to read and share both HKWorkout and
// HKWorkoutRoute samples ». Sans elle, `finishRoute` échoue et la
// sortie n'a pas de parcours.
let route = HKSeriesType.workoutRoute()
let outdoor = (locationType == .outdoor)
var read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
var share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
if outdoor {
read.insert(route)
share.insert(route)
}
do { do {
try await healthStore.requestAuthorization(toShare: share, read: read) try await healthStore.requestAuthorization(toShare: share, read: read)
@@ -66,6 +76,12 @@ final class WorkoutManager: NSObject, ObservableObject {
return return
} }
// Demandée ici, donc au premier plan : watchOS n'affiche pas la feuille
// d'autorisation depuis l'arrière-plan. Volontairement « quand
// l'app est active » et jamais « toujours » sur watchOS ce dernier
// mène à un parcours d'autorisation qui boucle.
if outdoor { LocationTracker.shared.requestAuthorization() }
let config = HKWorkoutConfiguration() let config = HKWorkoutConfiguration()
config.activityType = activityType config.activityType = activityType
config.locationType = locationType config.locationType = locationType
@@ -87,6 +103,19 @@ final class WorkoutManager: NSObject, ObservableObject {
isRunning = true isRunning = true
statusMessage = nil statusMessage = nil
workoutLog.info("beginCollection OK, isRunning=true") workoutLog.info("beginCollection OK, isRunning=true")
// GPS uniquement en extérieur : en salle il ne ferait que vider la
// batterie pour une trace immobile.
if outdoor {
// Apple déconseille d'instancier un HKWorkoutRouteBuilder
// directement : celui du builder de séance associe la trace.
let routeBuilder = builder.seriesBuilder(for: HKSeriesType.workoutRoute())
as? HKWorkoutRouteBuilder
if routeBuilder == nil {
workoutLog.error("seriesBuilder n'a pas rendu un HKWorkoutRouteBuilder")
}
LocationTracker.shared.start(activity: activityType, routeBuilder: routeBuilder)
}
} catch { } catch {
self.session = nil self.session = nil
self.builder = nil self.builder = nil
@@ -264,14 +293,26 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
let paused = (toState == .paused) let paused = (toState == .paused)
Task { @MainActor in Task { @MainActor in
self.isPaused = paused self.isPaused = paused
// Le flux GPS reste ouvert pendant la pause : watchOS interdit
// de redémarrer la localisation depuis l'arrière-plan, donc on
// ignore les points au lieu de couper le manager.
LocationTracker.shared.setPaused(paused)
ConnectivityManager.shared.sendPaused(paused) ConnectivityManager.shared.sendPaused(paused)
} }
} }
guard toState == .ended else { return } guard toState == .ended else { return }
let builder = workoutSession.associatedWorkoutBuilder() let builder = workoutSession.associatedWorkoutBuilder()
Task { Task { @MainActor in
// Ordre imposé par Apple : couper le GPS, clore la collecte,
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
// l'associant au workout obtenu.
LocationTracker.shared.stopTracking()
try? await builder.endCollection(at: date) try? await builder.endCollection(at: date)
_ = try? await builder.finishWorkout() // `finishWorkout()` rend `nil` SANS erreur quand la montre est
// verrouillée : ce n'est pas un échec. On poursuit avec `nil`, la
// trace sera sauvegardée sans association plutôt que perdue.
let workout = try? await builder.finishWorkout()
await LocationTracker.shared.finishRoute(with: workout)
} }
Task { @MainActor in Task { @MainActor in
self.isRunning = false self.isRunning = false

View File

@@ -0,0 +1 @@
../../../ios/App/CoachWatch/IntervalEngine.swift

View File

@@ -0,0 +1 @@
../../../ios/App/CoachWatch/RouteFilter.swift

View File

@@ -0,0 +1,267 @@
import XCTest
@testable import CoachModel
/* Le déroulé d'une séance structurée au poignet.
*
* Ces tests portent sur ce qu'aucun build Xcode ne rattrape : la dérive
* silencieuse. Un intervalle qui démarre trois secondes trop tard ne casse
* rien, ne lève rien, et se voit seulement au neuvième tour sur le terrain,
* pas en relecture.
*/
final class IntervalEngineTests: XCTestCase {
// Séance réelle du plan (CDC · reprise) : 5' marche, 3×(1' course / 1'
// marche), 5' marche. C'est le gabarit le plus fréquent chez l'utilisateur.
private func cdcPlan(reps: Int = 3) -> [CoachPlanStep] {
var steps: [CoachPlanStep] = [
CoachPlanStep(kind: .warmup, durationSec: 300, hrZone: 1, label: "5 min marche")
]
for _ in 0..<reps {
steps.append(CoachPlanStep(kind: .work, durationSec: 60, hrZone: 2,
label: "1' course, cadence 170+"))
steps.append(CoachPlanStep(kind: .recovery, durationSec: 60, hrZone: 1,
label: "1' marche"))
}
steps.append(CoachPlanStep(kind: .cooldown, durationSec: 300, hrZone: 1,
label: "5 min marche"))
return steps
}
// MARK: Déroulé nominal
func testFirstStepIsAnnouncedOnFirstTick() {
var engine = IntervalEngine(steps: cdcPlan())
let events = engine.update(elapsed: 0, distance: 0)
XCTAssertEqual(events, [.stepStarted(index: 0)])
XCTAssertEqual(engine.index, 0)
XCTAssertEqual(engine.currentStep?.label, "5 min marche")
XCTAssertEqual(engine.nextStep?.kind, .work)
}
func testStepBoundaryEmitsFinishThenStart() {
var engine = IntervalEngine(steps: cdcPlan())
engine.update(elapsed: 0, distance: 0)
XCTAssertTrue(engine.update(elapsed: 299, distance: 0).transitions.isEmpty,
"Une seconde avant la fin, rien ne doit basculer")
let events = engine.update(elapsed: 300, distance: 0)
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
XCTAssertEqual(engine.index, 1)
}
func testWholeSessionRunsToCompletion() {
var engine = IntervalEngine(steps: cdcPlan())
var finished = false
// 16 min de séance échantillonnées à la seconde.
for t in stride(from: 0.0, through: 960.0, by: 1.0) {
if engine.update(elapsed: t, distance: 0).contains(.planFinished) {
finished = true
XCTAssertEqual(t, 960, "5 min + 3×(1'+1') + 5 min = 16 min = 960 s")
break
}
}
XCTAssertTrue(finished)
XCTAssertTrue(engine.isFinished)
XCTAssertNil(engine.currentStep)
}
// MARK: La dérive le cœur du sujet
func testLateTickDoesNotGrantExtraTimeToTheNextStep() {
// L'app est suspendue poignet baissé : le tick suivant arrive 3 s en
// retard. L'intervalle d'une minute qui démarre ne doit PAS hériter de
// ces 3 secondes.
var engine = IntervalEngine(steps: cdcPlan())
engine.update(elapsed: 0, distance: 0)
engine.update(elapsed: 303, distance: 0) // 3 s de retard sur la frontière
XCTAssertEqual(engine.index, 1)
XCTAssertEqual(engine.progress().elapsedInStep, 3, accuracy: 0.001,
"L'étape a commencé à 300 s, pas à 303 s")
XCTAssertEqual(engine.progress().remainingInStep ?? -1, 57, accuracy: 0.001)
// Et la frontière suivante tombe bien à 360 s, pas à 363 s.
XCTAssertTrue(engine.update(elapsed: 359, distance: 0).transitions.isEmpty)
XCTAssertEqual(engine.update(elapsed: 360, distance: 0),
[.stepFinished(index: 1), .stepStarted(index: 2)])
}
func testNoCumulativeDriftOverManyRepetitions() {
// Neuf répétitions, chaque tick arrivant systématiquement en retard de
// 2,5 s. Sans frontières théoriques, l'erreur s'accumulerait à ~22 s.
var engine = IntervalEngine(steps: cdcPlan(reps: 9))
var t = 0.0
var lastStart: TimeInterval = 0
var events: [IntervalEvent] = []
while !engine.isFinished && t < 3000 {
events = engine.update(elapsed: t, distance: 0)
if events.contains(where: { if case .stepStarted = $0 { return true }; return false }) {
lastStart = t
}
t += 2.5
}
XCTAssertTrue(engine.isFinished)
// Dernière étape (retour au calme) : démarre à 1380 s en théorie.
// Avec des ticks tous les 2,5 s, on la détecte au plus tard 2,5 s après.
XCTAssertEqual(lastStart, 1380, accuracy: 2.5,
"La fin de séance ne doit pas dériver avec les répétitions")
}
func testSeveralShortStepsCrossedInASingleTick() {
// Tick manqué de 30 s sur des intervalles de 10 s : trois étapes
// franchies d'un coup. Traiter une seule transition par tick ferait
// décrocher le déroulé sans rien signaler.
let steps = (0..<6).map { i in
CoachPlanStep(kind: i.isMultiple(of: 2) ? .work : .recovery,
durationSec: 10, label: "step\(i)")
}
var engine = IntervalEngine(steps: steps)
engine.update(elapsed: 0, distance: 0)
let events = engine.update(elapsed: 30, distance: 0)
XCTAssertEqual(engine.index, 3)
XCTAssertEqual(events, [
.stepFinished(index: 0), .stepStarted(index: 1),
.stepFinished(index: 1), .stepStarted(index: 2),
.stepFinished(index: 2), .stepStarted(index: 3),
])
}
// MARK: Étapes en distance et étapes ouvertes
func testDistanceStepCompletesOnDistanceNotTime() {
let steps = [
CoachPlanStep(kind: .work, distanceM: 1000, label: "1 km"),
CoachPlanStep(kind: .recovery, durationSec: 60, label: "récup"),
]
var engine = IntervalEngine(steps: steps)
engine.update(elapsed: 0, distance: 0)
XCTAssertTrue(engine.update(elapsed: 600, distance: 999).transitions.isEmpty,
"Dix minutes ne terminent pas une étape en distance")
XCTAssertEqual(engine.progress().remainingDistanceM ?? -1, 1, accuracy: 0.001)
let events = engine.update(elapsed: 610, distance: 1000)
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
}
func testOpenStepNeverEndsByItself() {
let steps = [
CoachPlanStep(kind: .warmup, label: "Pars quand tu es prêt"),
CoachPlanStep(kind: .work, durationSec: 60, label: "effort"),
]
var engine = IntervalEngine(steps: steps)
engine.update(elapsed: 0, distance: 0)
XCTAssertTrue(engine.update(elapsed: 3600, distance: 8000).isEmpty,
"Une étape ouverte ne se termine que sur ordre")
XCTAssertNil(engine.progress().fraction)
XCTAssertNil(engine.progress().remainingInStep)
let events = engine.advanceManually()
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
// L'effort démarre maintenant, pas au début de la séance.
XCTAssertEqual(engine.progress().remainingInStep ?? -1, 60, accuracy: 0.001)
}
func testManualSkipStartsNextStepNow() {
var engine = IntervalEngine(steps: cdcPlan())
engine.update(elapsed: 0, distance: 0)
engine.update(elapsed: 120, distance: 0) // au milieu de l'échauffement
let events = engine.advanceManually()
XCTAssertEqual(events, [.stepFinished(index: 0), .stepStarted(index: 1)])
XCTAssertEqual(engine.progress().elapsedInStep, 0, accuracy: 0.001,
"Sauter une étape ne doit pas amputer la suivante")
XCTAssertEqual(engine.progress().remainingInStep ?? -1, 60, accuracy: 0.001)
}
func testManualAdvanceOnLastStepFinishesThePlan() {
var engine = IntervalEngine(steps: [CoachPlanStep(kind: .work, label: "libre")])
engine.update(elapsed: 0, distance: 0)
XCTAssertEqual(engine.advanceManually(), [.stepFinished(index: 0), .planFinished])
XCTAssertTrue(engine.isFinished)
XCTAssertTrue(engine.advanceManually().isEmpty, "Plus rien après la fin")
}
// MARK: Robustesse
func testHalfwayAnnouncedOncePerStep() {
var engine = IntervalEngine(steps: cdcPlan())
engine.update(elapsed: 0, distance: 0)
XCTAssertTrue(engine.update(elapsed: 149, distance: 0).isEmpty)
XCTAssertEqual(engine.update(elapsed: 150, distance: 0), [.halfway(index: 0)])
XCTAssertTrue(engine.update(elapsed: 151, distance: 0).isEmpty,
"La mi-parcours ne s'annonce qu'une fois")
XCTAssertTrue(engine.update(elapsed: 200, distance: 0).isEmpty)
}
func testGoingBackwardsInTimeIsClamped() {
// Un appelant fautif ou une reprise après crash peut réinjecter un
// temps antérieur. Mieux vaut borner que produire des durées négatives
// qui remonteraient jusqu'à l'affichage.
var engine = IntervalEngine(steps: cdcPlan())
engine.update(elapsed: 0, distance: 0)
engine.update(elapsed: 200, distance: 500)
engine.update(elapsed: 10, distance: 5)
XCTAssertEqual(engine.progress().elapsedInStep, 200, accuracy: 0.001)
XCTAssertEqual(engine.index, 0)
}
func testEmptyPlanIsFinishedImmediately() {
var engine = IntervalEngine(steps: [])
XCTAssertTrue(engine.isFinished)
XCTAssertTrue(engine.update(elapsed: 0, distance: 0).isEmpty)
XCTAssertTrue(engine.progress().isFinished)
}
func testReplayingTicksReproducesTheSameState() {
// Propriété qui rend la reprise après crash triviale : le moteur est
// une fonction de la suite des ticks, sans horloge interne.
let ticks = stride(from: 0.0, through: 700.0, by: 3.0)
var a = IntervalEngine(steps: cdcPlan())
var b = IntervalEngine(steps: cdcPlan())
for t in ticks { a.update(elapsed: t, distance: t * 2) }
for t in ticks { b.update(elapsed: t, distance: t * 2) }
XCTAssertEqual(a, b)
XCTAssertEqual(a.progress(), b.progress())
}
// MARK: Décodage du plan poussé par l'iPhone
func testDecodesPlanFromPhonePayload() throws {
let json = """
{
"date": "2026-08-21",
"sport": "running",
"title": "CDC · Reprise séance 1",
"steps": [
{"kind": "warmup", "duration_sec": 300, "hr_zone": 1,
"hr_bpm_min": 90, "hr_bpm_max": 105, "label": "5 min marche"},
{"kind": "work", "duration_sec": 60, "hr_zone": 2,
"hr_bpm_min": 110, "hr_bpm_max": 125, "label": "1' course, cadence 170+"}
]
}
"""
let plan = try JSONDecoder().decode(CoachSessionPlan.self, from: Data(json.utf8))
XCTAssertEqual(plan.steps.count, 2)
XCTAssertEqual(plan.steps[0].hrMaxBpm, 105)
XCTAssertEqual(plan.steps[1].displayLabel, "1' course, cadence 170+")
XCTAssertEqual(plan.plannedDurationSec, 360)
XCTAssertEqual(plan.steps[1].goal, .time(60))
}
func testPlannedDurationIsNilWhenAStepIsOpen() {
let plan = CoachSessionPlan(date: "2026-08-21", sport: "running", steps: [
CoachPlanStep(kind: .warmup, durationSec: 300),
CoachPlanStep(kind: .work, label: "libre"),
])
XCTAssertNil(plan.plannedDurationSec,
"Mieux vaut ne rien annoncer qu'annoncer un total faux")
}
func testMissingLabelFallsBackToStepKind() {
XCTAssertEqual(CoachPlanStep(kind: .recovery).displayLabel, "Récupération")
XCTAssertEqual(CoachPlanStep(kind: .work, label: " ").displayLabel, "Effort")
}
}

View File

@@ -0,0 +1,206 @@
import XCTest
@testable import CoachModel
/* Filtrage de la trace GPS.
*
* Les cas testés ne sont pas théoriques : ce sont les quatre façons dont une
* trace se salit réellement acquisition GPS des premières secondes, couvert
* forestier, points livrés dans le désordre après une reprise, et bruit
* d'altimètre sur terrain plat.
*/
final class RouteFilterTests: XCTestCase {
private let t0 = Date(timeIntervalSince1970: 1_755_000_000)
private func fix(_ lat: Double, _ lon: Double,
at seconds: TimeInterval,
alt: Double = 600,
hAcc: Double = 5,
vAcc: Double = 3) -> GeoFix {
GeoFix(lat: lat, lon: lon, altitude: alt,
horizontalAccuracy: hAcc, verticalAccuracy: vAcc,
speed: -1, timestamp: t0.addingTimeInterval(seconds))
}
// MARK: Distance
func testHaversineMatchesAKnownDistance() {
// Blonay Vevey, deux points relevés sur la carte. La distance réelle
// à vol d'oiseau est d'environ 3,4 km.
let blonay = fix(46.4667, 6.9167, at: 0)
let vevey = fix(46.4628, 6.8419, at: 60)
let d = RouteFilter.distance(from: blonay, to: vevey)
XCTAssertEqual(d, 5760, accuracy: 200,
"Écart BlonayVevey en longitude : ~5,8 km")
}
func testDistanceIsZeroForTheSamePoint() {
let a = fix(46.4667, 6.9167, at: 0)
XCTAssertEqual(RouteFilter.distance(from: a, to: a), 0, accuracy: 0.0001)
}
func testDistanceAccumulatesAlongATrack() {
var filter = RouteFilter()
// Cinq points espacés d'environ 100 m vers l'est.
for i in 0..<5 {
let lon = 6.9167 + Double(i) * 0.0013
XCTAssertEqual(filter.add(fix(46.4667, lon, at: Double(i) * 30)), .accepted)
}
XCTAssertEqual(filter.acceptedCount, 5)
XCTAssertEqual(filter.distanceM, 400, accuracy: 40)
}
// MARK: Rejets
func testFixWithNegativeAccuracyIsRejected() {
var filter = RouteFilter()
// Convention Apple : une précision négative signale un fix inutilisable.
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0, hAcc: -1)),
.rejected(.invalidAccuracy))
XCTAssertEqual(filter.acceptedCount, 0)
XCTAssertEqual(filter.distanceM, 0)
}
func testImpreciseFixesAreRejected() {
var filter = RouteFilter()
// Les premières secondes d'une séance : le GPS n'a pas encore convergé.
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0, hAcc: 165)),
.rejected(.tooImprecise))
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 3, hAcc: 65)),
.rejected(.tooImprecise))
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 8, hAcc: 12)), .accepted)
XCTAssertEqual(filter.rejectedCount, 2)
XCTAssertEqual(filter.acceptedCount, 1)
}
func testTeleportationIsRejected() {
var filter = RouteFilter()
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0)), .accepted)
// Point sauté de ~5,8 km en une seconde : impossible à pied.
XCTAssertEqual(filter.add(fix(46.4628, 6.8419, at: 1)),
.rejected(.implausibleSpeed))
XCTAssertEqual(filter.distanceM, 0, accuracy: 0.001,
"Un saut GPS ne doit pas gonfler la distance")
// Le point suivant, plausible, repart du dernier point ACCEPTÉ.
XCTAssertEqual(filter.add(fix(46.4667, 6.9180, at: 10)), .accepted)
XCTAssertEqual(filter.distanceM, 100, accuracy: 20)
}
func testOutOfOrderAndDuplicateFixesAreRejected() {
var filter = RouteFilter()
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 100)), .accepted)
XCTAssertEqual(filter.add(fix(46.4667, 6.9168, at: 100)), .rejected(.duplicate))
XCTAssertEqual(filter.add(fix(46.4667, 6.9168, at: 90)), .rejected(.outOfOrder))
XCTAssertEqual(filter.acceptedCount, 1)
}
// MARK: Dénivelé
func testFlatNoisyTerrainProducesNoElevationGain() {
// Le piège central : ±1,5 m de bruit d'altimètre sur 200 points. Sans
// hystérésis, on cumulerait des centaines de mètres de D+ sur du plat.
var filter = RouteFilter()
for i in 0..<200 {
let noise = (i % 2 == 0) ? 1.5 : -1.5
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0002,
at: Double(i) * 5, alt: 600 + noise))
}
XCTAssertEqual(filter.ascentM, 0, accuracy: 0.001,
"Le bruit d'altimètre ne doit pas produire de D+")
XCTAssertEqual(filter.descentM, 0, accuracy: 0.001)
}
func testRealClimbIsCounted() {
// Cadence réaliste : un point par seconde, +0,5 m par point soit une
// montée de 100 m en 3 min 20, ce qui est déjà raide.
var filter = RouteFilter()
for i in 0...200 {
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.00005,
at: Double(i), alt: 600 + Double(i) * 0.5))
}
XCTAssertEqual(filter.ascentM, 100, accuracy: 5)
XCTAssertEqual(filter.descentM, 0, accuracy: 0.001)
}
func testDescentIsCountedSeparately() {
var filter = RouteFilter()
for i in 0...200 { // 600 700
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.00005,
at: Double(i), alt: 600 + Double(i) * 0.5))
}
for i in 1...200 { // 700 600
_ = filter.add(fix(46.4667, 6.9267 + Double(i) * 0.00005,
at: 200 + Double(i), alt: 700 - Double(i) * 0.5))
}
XCTAssertEqual(filter.ascentM, 100, accuracy: 5)
XCTAssertEqual(filter.descentM, 100, accuracy: 5)
}
func testSmoothingClipsASharpPeak() {
// Comportement assumé, pas un bug : une pointe franchie en quelques
// points est arrondie par la moyenne glissante, donc sous-comptée. Ce
// test existe pour que la perte soit connue et surveillée le D+ qui
// fait foi reste celui, barométrique, qu'Apple écrit dans les
// métadonnées de la séance.
var filter = RouteFilter()
for i in 0...10 {
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0005,
at: Double(i) * 20, alt: 600 + Double(i) * 10))
}
for i in 1...10 {
_ = filter.add(fix(46.4667, 6.9217 + Double(i) * 0.0005,
at: 200 + Double(i) * 20, alt: 700 - Double(i) * 10))
}
XCTAssertLessThan(filter.ascentM, 100)
XCTAssertLessThan(filter.descentM, 100)
XCTAssertGreaterThan(filter.ascentM, 60,
"L'écrêtage doit rester partiel, pas effacer la montée")
}
func testAltitudeWithoutVerticalAccuracyIsIgnored() {
// Le GPS seul dérive verticalement de plusieurs mètres. Apple signale
// une altitude non fiable par une incertitude négative.
var filter = RouteFilter()
for i in 0...20 {
_ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0005,
at: Double(i) * 20,
alt: 600 + Double(i) * 5, vAcc: -1))
}
XCTAssertEqual(filter.ascentM, 0, accuracy: 0.001)
XCTAssertEqual(filter.acceptedCount, 21,
"Le point reste valide pour la trace, seule l'altitude est écartée")
}
// MARK: Robustesse
func testFirstFixEstablishesTheOriginWithoutDistance() {
var filter = RouteFilter()
XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0)), .accepted)
XCTAssertEqual(filter.distanceM, 0)
XCTAssertNotNil(filter.lastAccepted)
}
func testReplayingTheSameTrackGivesTheSameResult() {
let track = (0..<50).map { i in
fix(46.4667 + Double(i) * 0.0001, 6.9167 + Double(i) * 0.0003,
at: Double(i) * 4, alt: 600 + Double(i) * 2)
}
var a = RouteFilter(), b = RouteFilter()
for f in track { a.add(f) }
for f in track { b.add(f) }
XCTAssertEqual(a, b)
}
func testThresholdsAreConfigurable() {
// Une sortie à VTT électrique dépasse allègrement les 30 km/h : le
// seuil doit pouvoir s'ajuster sans toucher au filtre.
var strict = RouteFilter(maxPlausibleSpeed: 5)
strict.add(fix(46.4667, 6.9167, at: 0))
XCTAssertEqual(strict.add(fix(46.4667, 6.9180, at: 1)),
.rejected(.implausibleSpeed))
var loose = RouteFilter(maxPlausibleSpeed: 200)
loose.add(fix(46.4667, 6.9167, at: 0))
XCTAssertEqual(loose.add(fix(46.4667, 6.9180, at: 1)), .accepted)
}
}