Compare commits
57 Commits
19fbfd116e
...
feat/watch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aa7265147 | ||
|
|
d41e8a457b | ||
|
|
0d34908f4f | ||
|
|
98b1cd0c89 | ||
|
|
3b5b61268c | ||
|
|
a794323314 | ||
|
|
7e7be4a1d1 | ||
|
|
8e0c2738ce | ||
|
|
6031be8c1c | ||
|
|
8973a26e47 | ||
|
|
b15867273c | ||
|
|
31467851d9 | ||
|
|
2c9af6c684 | ||
|
|
a5cbb70210 | ||
|
|
adb8177f2f | ||
|
|
157250b3eb | ||
|
|
bb1261dfd8 | ||
|
|
316a35f33b | ||
|
|
446b611afd | ||
|
|
e5c8967ea9 | ||
|
|
3bd90c8bb7 | ||
|
|
3755cafa1f | ||
|
|
d5330b8f1a | ||
|
|
c16b92ce29 | ||
|
|
a29569c05c | ||
|
|
b1e7cf6d47 | ||
|
|
e6cdc67451 | ||
|
|
263a8f694f | ||
|
|
150e78efa0 | ||
|
|
3d939b8c3c | ||
|
|
9903424235 | ||
|
|
0a721c249b | ||
|
|
2a4274b1ad | ||
|
|
422b6ac3e6 | ||
|
|
7d68605e83 | ||
|
|
474afce348 | ||
|
|
5fc19bf095 | ||
|
|
adcf4088f3 | ||
|
|
3cc7d935ad | ||
|
|
60dd22eaeb | ||
|
|
eef76daa45 | ||
|
|
753d7b248e | ||
|
|
0f96122cca | ||
|
|
01607ed75c | ||
|
|
94b1f6243d | ||
|
|
81582ce969 | ||
|
|
1389edb1f7 | ||
|
|
3de149591e | ||
|
|
14133efba9 | ||
|
|
6deb61e86e | ||
|
|
179528d563 | ||
|
|
0110368e17 | ||
|
|
184c08a447 | ||
|
|
a44fa0046a | ||
|
|
3e657f5917 | ||
|
|
2f2d7696fa | ||
|
|
891a8bddce |
@@ -15,7 +15,8 @@ Instructions pour Claude Code sur ce projet. Lis ce fichier en début de session
|
||||
## 🔒 Règles non négociables
|
||||
|
||||
1. **Le code web n'est PAS ici** — pour modifier l'UI/UX, c'est `coach_sportif/web/`. Ce repo ne touche QUE le natif (Swift, Kotlin, plugins Capacitor, config).
|
||||
2. **Cookie inject AppDelegate** (iOS) : mécanisme critique pour l'auth WKWebView. Toute modif `AppDelegate.swift` doit préserver ce flux.
|
||||
2. **Cookie inject AppDelegate** (iOS) : mécanisme critique pour l'auth WKWebView. Toute modif `AppDelegate.swift` doit préserver ce flux. Il vit dans `didFinishLaunchingWithOptions`, qui **reste appelé** en cycle de vie par scènes et **avant** la connexion de la scène — Apple documente la séquence, l'ordre cookie → WebView est donc garanti.
|
||||
2bis. **⚠️ Cycle de vie UIScene depuis le 22/09/2026** (obligatoire iOS 27, Capacitor ≥ 8.5.2). Trois callbacks ont quitté `AppDelegate.swift` pour `SceneDelegate.swift`, et UIKit **ne les appelle plus sur l'AppDelegate même si on les y remet** : retour OAuth Google (`scene(_:openURLContexts:)`), Universal Links (`scene(_:continue:)`), flush des saisies widget (`sceneDidBecomeActive`). Aucun ne produit d'erreur de compilation s'il est mal placé — la panne est silencieuse. Le `SceneDelegate` instancie **`MainViewController`**, jamais un `CAPBridgeViewController` nu comme le template Capacitor : la sous-classe porte l'enregistrement des plugins et la config WKWebView. ⛔ Ne pas lancer `npx cap migrate` sur ce projet (le guide officiel exclut les AppDelegate à gestion d'URL personnalisée), ni `npm audit fix --force` (il redescend le CLI en v7 et supprime le support UIScene).
|
||||
3. **Health Connect Android** : nécessite la privacy policy URL dans le manifest (validation Play Console).
|
||||
4. **Pas d'extrapolation** : root cause vérifiable avant tout fix natif. iOS+Android = doubles surfaces de bug.
|
||||
5. **Default yes** : décider et exécuter (sauf actions destructives).
|
||||
@@ -125,5 +126,6 @@ l'emplacement de la toolchain.
|
||||
## ⚠️ Limites connues sync
|
||||
|
||||
- **Apple Watch sync** : impossible via API tierce — le wrapper HealthKit dans cette app est obligatoire pour récupérer les données Watch.
|
||||
- **⚠️ `@capgo/capacitor-health` ne déplie pas les séries condensées** (`HKQuantitySample.count > 1`, `Health.swift::readSamples` lit `quantity.doubleValue` seul). HealthKit condense les séances Apple — et depuis iOS/watchOS 27 en quelques heures : les pas d'une course sont devenus 4 tranches de 12 min (22/09/2026). `CoachHealthRoute.getStepSamples` déplie avec `HKQuantitySeriesSampleQuery` ; tout autre type lu par Capgo (FC en tête) peut subir le même sort — même remède si ça arrive. `getWorkoutDetails` expose aussi `activities` (une `HKWorkoutActivity` par étape d'un exercice personnalisé, constaté le même jour).
|
||||
- **Polar AccessLink** : pas d'historique pré-enregistrement (limitation API).
|
||||
- **Push training-targets vers Polar** : nécessiterait migration AccessLink v4 (backlog).
|
||||
|
||||
126
COWORK.md
126
COWORK.md
@@ -129,12 +129,130 @@ 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.
|
||||
|
||||
## 🔜 Câbler IntervalEngine — analyse du 2026-08-20 (rien de codé)
|
||||
|
||||
`IntervalEngine` **compile sur device** (build validé le 20/08, l'app tourne),
|
||||
mais **aucune vue ne l'appelle** : le moteur est un guide sans itinéraire.
|
||||
|
||||
### Le chaînon manquant
|
||||
|
||||
Le moteur attend un `CoachSessionPlan` (liste d'étapes : durée, zone, bornes
|
||||
bpm). **Rien ne le lui fournit** — `ConnectivityManager` reçoit les zones, le
|
||||
`CoachWidgetSnapshot` et la routine, mais aucun plan structuré. Vérifié par
|
||||
lecture du fichier, pas supposé.
|
||||
|
||||
En revanche **le serveur sait déjà produire ces étapes** :
|
||||
`coach_sportif/web/fitness_export.py::_blocks_from_session` déplie une séance
|
||||
en blocs chronométrés, répétitions comprises (un bug de prod du 12/08 avait
|
||||
fait disparaître un 7×(1'C/1'M) : les `repeat` sont désormais dépliés), et
|
||||
`_zone_bpm(zone, hr_zones)` convertit une zone en bornes bpm. Ces briques
|
||||
servent déjà à l'export TCX Workout.
|
||||
|
||||
Indice que le chemin était prévu : les `CodingKeys` de `CoachPlanStep` sont en
|
||||
snake_case (`duration_sec`, `hr_zone`, `hr_bpm_min`…) — le type a été écrit
|
||||
pour décoder du JSON venant du serveur.
|
||||
|
||||
### Les trois couches, dans l'ordre
|
||||
|
||||
1. ✅ **Serveur — FAIT le 2026-08-21** (`coach_sportif` `c8d1541`, déployé et
|
||||
vérifié en prod). **`GET /api/plan/session[?date=YYYY-MM-DD][&type=...]`**
|
||||
rend la séance dépliée au format `CoachSessionPlan` : clés snake_case,
|
||||
`hr_zone` en entier, durées en secondes. Mesuré en prod le 21/08 sur la
|
||||
séance du jour (CDC J13, 15×(1'/1')) : **32 étapes, 40 min**, alternance
|
||||
`work`/`recovery` correcte, bornes Z1 100-109 / Z2 109-118.
|
||||
- Les bornes BPM viennent des **zones datées** (`current_zones`), jamais des
|
||||
`hr_min`/`hr_max` du plan (hérités d'avant l'unification du 12/08).
|
||||
- Les `repeat` sont **dépliés** — le moteur ne sait pas répéter.
|
||||
- `404` = pas de séance ce jour · `422` = séance non chronométrée (repos,
|
||||
durée ouverte) : rien à dérouler, à distinguer côté montre.
|
||||
- ⚠️ Route volontairement **hors `/api/v1/*`** (JWT que la WebView n'a pas).
|
||||
Un test le verrouille : ne pas la « ranger » dans l'API native.
|
||||
2. **iPhone** — pousser ce plan vers la montre par `WCSession`, à côté de ce
|
||||
qui part déjà.
|
||||
3. **Montre** — `ConnectivityManager` publie un `sessionPlan` ; une vue appelle
|
||||
`engine.update(elapsed:distance:)` à chaque tick et joue une haptique sur
|
||||
`events.transitions`.
|
||||
|
||||
### ⚠️ Le piège à trancher AVANT de câbler
|
||||
|
||||
L'en-tête d'`IntervalEngine.swift` affirmait que
|
||||
`HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les pauses ». **La doc Apple
|
||||
dit l'inverse**, vérifié à la source le 20/08 : *« The elapsed time for the
|
||||
workout based on the builder's current contents, including pauses. »*
|
||||
|
||||
Câbler le moteur dessus telle quelle ferait avancer le déroulé **pendant les
|
||||
pauses** — un fractionné mis en pause pour traverser une route se déroulerait
|
||||
à l'arrêt. La propriété qui exclut réellement les pauses est
|
||||
`HKWorkoutBuilder.elapsedTime(at:)`. Les deux textes d'Apple se contredisent
|
||||
frontalement ; le commentaire du fichier a été corrigé, **le choix de la source
|
||||
de temps reste à faire**.
|
||||
|
||||
⚠️ Et jamais un `Timer` de vue SwiftUI : il ne tourne pas écran éteint, donc
|
||||
pendant l'essentiel d'une séance (même famille de piège que `VmaTestView`).
|
||||
|
||||
## 📌 Point du 2026-08-21 — build OK, vérifs GPS EN SUSPENS
|
||||
|
||||
**Build Xcode passé sur iPhone (iOS 26.6)**, après un `git pull` qui partait de
|
||||
`be1ffec` : le Mac n'avait donc **ni `LocationTracker`, ni `RouteFilter`, ni le
|
||||
correctif du doublon pbxproj** (`19fbfd1`) — le build du 20/08 ne les contenait
|
||||
pas. Contrôles avant build : 2/2/2 déclarations, `UIBackgroundModes = [location]`
|
||||
et `NSLocationWhenInUseUsageDescription` présents, un seul `@main` par cible.
|
||||
|
||||
**Une erreur de compilation, corrigée (`3e657f5`)** :
|
||||
`finishRoute(with:)` **n'accepte pas d'optionnel** — vérifié dans la doc Apple :
|
||||
`func finishRoute(with workout: HKWorkout, metadata:)`, « You must have already
|
||||
saved this workout to the HealthKit store ». Le commentaire du fichier
|
||||
promettait une trace « sauvegardée sans association » : **c'est impossible**,
|
||||
aucune API ne clôt une route orpheline. Traité en amont : quand
|
||||
`finishWorkout()` rend `nil` (montre verrouillée), la séance EST dans HealthKit
|
||||
et `WorkoutManager.recentlySavedWorkout()` va la rechercher — dernier workout de
|
||||
`HKSource.default()` **croisant** les 5 dernières minutes (chevauchement, jamais
|
||||
`startDate` : filtrer sur le début raterait toute séance longue). Sinon
|
||||
`discardRoute()` jette la trace explicitement et le journalise comme une perte.
|
||||
|
||||
⏳ **Les 4 vérifications au poignet n'ont PAS encore été faites** — la séance
|
||||
doit être lancée **depuis l'app sur la montre**, les logs du 21/08 ne montrent
|
||||
que l'app iPhone (sync HealthKit complète et saine : 7 séances, 7 traces, 13
|
||||
scores d'effort, App Group en écriture `ok:true`).
|
||||
|
||||
Relevé au passage, non traité : avertissement `UIScene lifecycle will soon be
|
||||
required` (Capacitor, échéance future) ; un échantillon de pas venant de
|
||||
`"iPhone de …"` et non de la Watch — à surveiller, **la cadence étant dérivée
|
||||
des pas relus par séance**, deux sources sur une même séance la fausseraient.
|
||||
|
||||
## 🖥️ Vues d'entraînement au choix (WorkOutDoors) — cadré le 21/08, rien codé
|
||||
|
||||
Cadrage complet : **`docs/watch-training-screens.md`**. En deux lignes :
|
||||
`LiveWorkoutView` a **cinq métriques en dur**, aucune notion de champ ni de
|
||||
page. Décision prise : la configuration s'édite **côté web** (réutiliser
|
||||
`TileManager`) et voyage par `WCSession` — donc **aucun rebuild pour changer ses
|
||||
écrans**. Le modèle et le catalogue sont du Foundation pur, donc écrits et
|
||||
testés sur Linux ; seul le rendu `TabView` exige Xcode. Châssis ≈ 1 jour.
|
||||
|
||||
## 📋 Pré-requis `feat/watch-outdoor` — checklist dev (2026-09-20)
|
||||
|
||||
✅ 34 commits accumulés depuis `main`. Code **complet & testé** (57 tests verts Linux).
|
||||
|
||||
- `IntervalEngine.swift` + `RouteFilter.swift` + `LocationTracker.swift` — déclarés dans `CoachWatch` (3 fichiers × 2 déclarations = 6 attendues)
|
||||
- `CoachWorkoutKit.swift` et `CoachWorkoutObserver.swift` — modifiés pour route (enregistrement, finition, sauvegarde)
|
||||
- `Info.plist` (CoachWatch) — `UIBackgroundModes = [location]` + `NSLocationWhenInUseUsageDescription` présents
|
||||
- Toutes les corrections BLE/Polar revertées (chantier jeté, n'impacte pas feat/watch-outdoor)
|
||||
- Branche stable, dernière corr le 20/08 soir
|
||||
|
||||
**À tester côté Mac avant toute release** (4 points critiques) :
|
||||
1. **Permission localisation** — une sortie extérieure demande "Localisation lorsque l'app est active". Si rien, pas de trace.
|
||||
2. **Trace dans Santé** — à la fin du workout, vérifier `Santé > Données > Athlétisme > Trajets` : la sortie doit apparaître.
|
||||
3. **FC remonte sur `/live`** — `WorkoutManager` modifié, **test de régression prioritaire**.
|
||||
4. **Écran verrouillé** — positions continuent d'arriver (test suivi GPS en background). S'arrêtent = boucle d'affichage trop rapide (CPU), pas permission.
|
||||
|
||||
---
|
||||
|
||||
## 👉 À 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`.
|
||||
- **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.
|
||||
- ⚠️ **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.
|
||||
- **iOS `feat/watch-outdoor` — prêt Mac** : **builder + tester 4 points critiques** (voir checklist ci-dessus). Workflow exact ligne 20.
|
||||
- Corrigé le 2026-08-20 : `open ios/App/App.xcworkspace` **n'existe pas**. Depuis Capacitor 8 : **`open ios/App/App.xcodeproj`** (SPM, pas CocoaPods).
|
||||
- Fixes robustesse 2026-07-01 (non compilés avant) à valider : `CoachWidgetBridge` (score Float ↔ NSNumber.intValue ; KO App Group → reject), `CoachAppleAuth` (double-tap → rejette ancien call).
|
||||
- **Checklist pré-App-Store** : `ios/App/App/App.entitlements` a `aps-environment = development`. **TestFlight réclame `production`** — à basculer avant archive (Xcode peut le gérer via profil dist auto, à trancher).
|
||||
- **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`.
|
||||
- **Android — bloqué externe** : Play Console ($25 validé), upload AAB + Internal Testing depuis machine avec Android SDK.
|
||||
- Backend/web : voir `coach_sportif/COWORK.md`.
|
||||
|
||||
543
docs/AUDIT-2026-09-22.md
Normal file
543
docs/AUDIT-2026-09-22.md
Normal file
@@ -0,0 +1,543 @@
|
||||
# Audit complet — app mobile Coach Hypnotruck
|
||||
|
||||
**Date** : 2026-09-22 · **Portée** : `coach-ios` (natif iOS/watchOS) + `coach_sportif/web`
|
||||
(l'UI réelle, servie dans la WKWebView) · **Branche auditée** : `feat/watch-outdoor` @ `e6cdc67`
|
||||
|
||||
## Comment lire ce document
|
||||
|
||||
Chaque constat porte son statut de vérification. La distinction n'est pas
|
||||
cosmétique : c'est une app de santé, et un constat non vérifié relayé comme un
|
||||
fait est exactement la dérive que la règle 3 interdit.
|
||||
|
||||
| Marque | Signification |
|
||||
|---|---|
|
||||
| **[vérifié]** | Lu ligne à ligne dans le fichier cité, ou calculé, pendant cet audit |
|
||||
| **[rapporté]** | Remonté par une revue automatisée, **non revérifié** — à confirmer avant d'agir |
|
||||
| **[doc]** | Adossé à une source Apple citée verbatim, URL donnée |
|
||||
|
||||
⚠️ Ce document date du jour de son écriture. Il constate, il ne prescrit pas un
|
||||
état. `git log` fait foi pour ce qui a bougé depuis.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ce qui va bien, et qu'il ne faut pas casser
|
||||
|
||||
Le socle est sain, et plusieurs points sont au-dessus de la moyenne du domaine.
|
||||
|
||||
- **[vérifié] Aucun secret n'a jamais été commité.** `git log --all` sur
|
||||
`coach-ios.local.json` et `ios/App/App/CoachAuth.swift` est vide. Le
|
||||
`.gitignore` les couvre (lignes 23-24).
|
||||
- **[vérifié] `npm audit` : 0 vulnérabilité.** Capacitor 8.3.1 homogène sur
|
||||
tous les paquets.
|
||||
- **[vérifié] 88 tests Swift verts sur Linux** (10 DayRollover + 7 FormeBand +
|
||||
17 IntervalEngine + 31 PolarPftpStep + 8 QuickDrink + 15 RouteFilter). Le
|
||||
chiffre de 57 qui circulait est faux.
|
||||
- **[vérifié] La politique de confidentialité existe** — `HTTP 200` sur
|
||||
`/privacy`, liée depuis `settings.html:796` et `landing.html:207`. Le TODO du
|
||||
README (« à héberger ») est **périmé**, de même que la ligne « missing
|
||||
privacy policy » de tout audit antérieur.
|
||||
- **[vérifié] La suppression de compte existe** (`settings.html:797` →
|
||||
`POST /account/delete`, `routers/auth.py:313`). C'est l'exigence 5.1.1(v),
|
||||
motif de rejet fréquent : elle est déjà satisfaite.
|
||||
- **[vérifié] Universal Links opérationnels** — AASA servi en
|
||||
`application/json`, Team ID `TZ2PVTRKNY`, routes `/live` `/routine` `/forme`
|
||||
`/calendar`.
|
||||
- **[vérifié] Purpose strings HealthKit précises et en français**, nommant les
|
||||
types de données et leur finalité. Conforme à la lettre de 5.1.1(ii).
|
||||
- **[rapporté] `CoachWorkoutKit.swift` est le fichier le plus sain du chemin
|
||||
santé** : autorisation vérifiée deux fois, échec remonté jusqu'à l'écran,
|
||||
relecture post-envoi qui rend ce qui est constaté et non ce qui est supposé.
|
||||
- **[rapporté] Aucune confusion kcal/kJ, m/s ↔ km/h, ni mètres/miles** dans le
|
||||
code Swift. Aucun `@MainActor` manquant, aucun cycle de rétention.
|
||||
- **[vérifié] La barre d'onglets web est le seul composant d'UI protégé par un
|
||||
test** (`tests/test_bottom_tabs.py`) — et c'est le seul qui n'a pas dérivé.
|
||||
Le lien de cause à effet mérite d'être noté.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gravité 1 — sécurité des données de santé
|
||||
|
||||
### 1.1 [vérifié] La FC au poignet se fige sur la dernière valeur connue, sans le dire
|
||||
|
||||
`ios/App/CoachWatch/WorkoutManager.swift:199` et `:217-224`
|
||||
|
||||
```swift
|
||||
if let hr { heartRate = hr } // 199 — hr nil → l'ancienne valeur reste
|
||||
...
|
||||
ConnectivityManager.shared.sendSample([
|
||||
"heartRate": heartRate, // 218 — la propriété stockée, PAS `hr`
|
||||
...
|
||||
"ts": now.timeIntervalSince1970 // 223 — horodatage NEUF
|
||||
])
|
||||
```
|
||||
|
||||
`didCollectDataOf` se déclenche dès qu'*un* type est collecté. Quand le capteur
|
||||
optique décroche — bracelet desserré, froid, arythmie : **la FA thermoablée est
|
||||
précisément le terrain à risque** — les calories continuent d'arriver, `hr` vaut
|
||||
`nil`, et la ligne 218 réémet la dernière FC connue avec un horodatage frais.
|
||||
|
||||
Conséquence en trois étages :
|
||||
1. Au poignet, l'écran affiche une FC stable et sa zone colorée, indéfiniment.
|
||||
2. Sur l'iPhone, `LiveStore.ingest` pose `lastSampleAt = Date()` et
|
||||
`status = .live`. Le watchdog **ne basculera jamais en `.lost`** : il reçoit
|
||||
des samples frais.
|
||||
3. Aucun log, aucun compteur.
|
||||
|
||||
**C'est le défaut le plus grave de l'audit.** Un utilisateur post-infarctus sous
|
||||
bêtabloquants lit 138 bpm en Z3, conclut qu'il peut tenir la pente, et sa FC
|
||||
réelle est inconnue. Ce n'est pas une erreur de logique : c'est une information
|
||||
absente du modèle. `heartRate` a besoin d'un `heartRateAt: Date` posé uniquement
|
||||
quand `hr != nil`, d'un seuil d'âge au-delà duquel la vue affiche « — » (elle
|
||||
sait déjà le faire, `LiveStore.heartRateText:152`), et `sendSample` d'un champ
|
||||
d'âge pour que l'iPhone applique la même règle.
|
||||
|
||||
### 1.2 [rapporté] Les zones cardiaques peuvent ne jamais atteindre la montre
|
||||
|
||||
`ios/App/App/CoachWidgetBridge.swift:183` — `try? session.updateApplicationContext(...)`
|
||||
|
||||
`try?` avale l'échec (payload trop gros, session non activée, montre non
|
||||
appairée). Ce snapshot transporte les bornes de zones affichées pendant
|
||||
l'effort. Deux fichiers plus loin, `CoachRoutineBridge.pushToWatch:92-101`
|
||||
appelle **la même API** avec `do/catch`, log et `call.reject`.
|
||||
|
||||
Le chemin instrumenté est celui de la routine de gainage ; le chemin muet est
|
||||
celui qui porte les bornes cardiaques d'un patient post-infarctus. Aggravant :
|
||||
`HeartRateZones` ne porte aucune date de validité, donc après une révision des
|
||||
bornes la montre peut afficher les anciennes indéfiniment. C'est le motif
|
||||
« zones datées » déjà tranché côté serveur (`app.current_zones()`), non
|
||||
répercuté au poignet.
|
||||
|
||||
### 1.3 [rapporté] « Accès refusé » et « aucune donnée » sont rendus identiques
|
||||
|
||||
`ios/App/App/CoachSleep.swift:92-96` — l'en-tête du fichier (ligne 30) promet
|
||||
`available = false` si non autorisé ; le code ne teste jamais l'autorisation et
|
||||
rend `{available: true, samples: []}`.
|
||||
|
||||
Si l'utilisateur retire « Sommeil » dans Réglages → Santé, le backend enregistre
|
||||
des nuits vides et `sleep_score.py` calcule une régularité sur des trous. Rien
|
||||
ne distingue « il n'a pas dormi » de « on ne nous laisse plus regarder ».
|
||||
HealthKit ne distingue effectivement pas refusé de vide en lecture, mais
|
||||
`authorizationStatus(for:)` couvre `.notDetermined`, qui est le cas fréquent
|
||||
(réinstallation, permission jamais accordée). Même famille :
|
||||
`CoachHealthRoute.swift:173` et `:277`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gravité 2 — perte silencieuse de données
|
||||
|
||||
Le projet a déjà vécu ce scénario côté backend : un cron a privé quatre mois de
|
||||
séances de leur charge cardiaque en répondant « rien de neuf » — ce qui était
|
||||
vrai de son point de vue. Les trois défauts ci-dessous ont la même forme.
|
||||
|
||||
### 2.1 [rapporté] Séances multiples : toutes sauf une sont perdues définitivement
|
||||
|
||||
`ios/App/App/CoachWorkoutObserver.swift:297-319`. L'ancre est sauvegardée
|
||||
(l. 298) **avant** que seul `latest` (l. 315) soit traité. Or
|
||||
`scheduleNotification` est le seul appelant de `reportToRoutineIfStrength` et
|
||||
`reportPlanOrigin`. Renfo le matin + vélo le soir, iPhone resté à la maison :
|
||||
au premier fire, le vélo est notifié, le renfo n'est jamais POSTé, et l'ancre
|
||||
ayant avancé, il ne reviendra jamais. Le log dit « 2 new workout(s), notifying
|
||||
latest » — exact, et parfaitement muet sur ce qui vient d'être jeté.
|
||||
|
||||
**Correction** : boucler sur `workouts` pour les rapports serveur ; garder
|
||||
`latest` uniquement pour la notification locale.
|
||||
|
||||
### 2.2 [rapporté] Les POST natifs partent, puis l'app se déclare suspendable
|
||||
|
||||
Même fichier, `:266-268`, `:352-382`, `:398-438`. Le `defer { completion() }`
|
||||
s'exécute dès que le corps rend la main, alors que deux requêtes sont en vol sur
|
||||
`URLSession.shared`, sans `beginBackgroundTask`. iOS est libre de suspendre.
|
||||
|
||||
**Aggravant, indépendamment vérifiable** : le statut HTTP n'est jamais lu
|
||||
(l. 368 et 431). Un 401 sur token rotaté, un 500, un 404 ne produisent pas
|
||||
d'`error` — et `plan-origin envoyé` est journalisé comme un succès. Le bon
|
||||
pattern existe déjà à deux fichiers de là (`CoachRoutineBridge.postDay:154-159`
|
||||
vérifie `(200..<300).contains(http.statusCode)`).
|
||||
|
||||
### 2.3 [rapporté] Une séance entière peut disparaître sans un seul log
|
||||
|
||||
`ios/App/CoachWatch/WorkoutManager.swift:310-324`. `try? await
|
||||
builder.endCollection(...)` puis `try? await builder.finishWorkout()` : si le
|
||||
premier lève, le second lève aussi, la récupération ne trouve rien, et le chemin
|
||||
finit sur `discardRoute()` qui logue la perte de la *trace* mais pas de la
|
||||
*séance*. `statusMessage` n'est pas touché : l'utilisateur voit l'écran de fin
|
||||
normal après 2 h de sortie.
|
||||
|
||||
### 2.4 [rapporté] `didFailWithError` ne clôt rien et laisse le GPS allumé
|
||||
|
||||
Même fichier, `:377-389`. Comparé au chemin `.ended`, il manque
|
||||
`stopTracking()`, `endCollection`, `finishWorkout`, `finishRoute`/`discardRoute`.
|
||||
Tout ce que le builder a collecté est perdu sans tentative de sauvegarde, et le
|
||||
GPS continue de tourner après la fin apparente de la séance.
|
||||
|
||||
### 2.5 [vérifié] Chaque pause efface jusqu'à 24 points de trace *antérieurs*
|
||||
|
||||
`ios/App/CoachWatch/LocationTracker.swift:147` — `pendingRoute.removeAll()`.
|
||||
|
||||
Le commentaire vise les points *postérieurs* à la pause. Mais `pendingRoute` ne
|
||||
contient que des points **déjà acceptés par le filtre et déjà comptés dans la
|
||||
distance affichée**, en attente d'écriture (seuil de flush : 25). À 1 Hz, c'est
|
||||
une douzaine de points en moyenne, jusqu'à 24. L'écart est donc visible : la
|
||||
distance les inclut, la trace HealthKit non. Six arrêts sur une sortie VTTAE →
|
||||
jusqu'à 144 points disparus, et un D+ recalculé qui diverge de celui affiché.
|
||||
|
||||
**Correction** : `await flushPendingRoute()` avant de vider, ou ne pas vider —
|
||||
le point de reprise est de toute façon filtré par `implausibleSpeed`.
|
||||
|
||||
### 2.6 [rapporté] Accès concurrent non protégé sur le tampon GPS
|
||||
|
||||
`ios/App/App/CoachHealthRoute.swift:182-203` : `all` et `pluginErr` sont mutés
|
||||
depuis les callbacks de N requêtes livrées sur des files HealthKit
|
||||
indépendantes, sans verrou — alors que `getEffortScores:282` et
|
||||
`getWorkoutDetails:379` du **même fichier** utilisent un `NSLock` pour ce motif
|
||||
exact. Déclencheur : un workout portant plusieurs `HKWorkoutRoute` (multisport,
|
||||
reprise après interruption).
|
||||
|
||||
---
|
||||
|
||||
## 4. Gravité 3 — justesse et cohérence
|
||||
|
||||
| # | Constat | Emplacement |
|
||||
|---|---|---|
|
||||
| 3.1 | **[rapporté]** `humidity_pct` vaut 0 ou 1, jamais 65 — `HKUnit.percent()` est une fraction (1.0 = 100 %), le `× 100` manque | `CoachHealthRoute.swift:415-417` |
|
||||
| 3.2 | **[rapporté]** Une séance `traditionalStrengthTraining` est POSTée en `functionalStrengthTraining` (valeur en dur l. 363 alors que le garde accepte deux types). Le payload n'a ni UUID ni horodatage de début → serveur incapable de dédupliquer | `CoachWorkoutObserver.swift:352-364` |
|
||||
| 3.3 | **[rapporté]** `value > 0` confond un zéro mesuré avec une absence de mesure, alors que le commentaire l. 399 promet l'inverse. L'erreur de la requête est jetée | `CoachHealthRoute.swift:449-462` |
|
||||
| 3.4 | **[rapporté]** `startObserving` répond `observing: true` même sur refus (le `success` est ignoré, et Apple le met à `true` dès l'affichage de la feuille). `getStatus` montre trois voyants verts et zéro donnée | `CoachWorkoutObserver.swift:218-228` |
|
||||
| 3.5 | **[rapporté]** La garde anti-doublon `lastNotifiedKey` est écrite (l. 317) et **jamais lue**. Le commentaire affirme une protection absente | `CoachWorkoutObserver.swift:60-63` |
|
||||
| 3.6 | **[rapporté]** Un cookie d'auth périmé ne peut jamais être remplacé : les deux injections testent le *nom* du cookie, jamais sa valeur ni son expiration. Les appels natifs continuent de partir avec la constante compilée | `AppDelegate.swift:31-34`, `MainViewController.swift:63-65` |
|
||||
| 3.7 | **[vérifié]** `STYLE_GUIDE.md:233` annonce `--text-secondary` à 9,7:1 ; le calcul donne **6,36:1** sur noir, 5,95:1 sur `.ap-card` | `docs/STYLE_GUIDE.md` |
|
||||
|
||||
Le flux d'injection du cookie lui-même (3.6) est correct et bien documenté — la
|
||||
règle non négociable n° 2 du CLAUDE.md est respectée. Le défaut est le critère
|
||||
de remplacement, pas le mécanisme.
|
||||
|
||||
---
|
||||
|
||||
## 5. Conformité App Store — sources Apple
|
||||
|
||||
Méthode : `developer.apple.com` uniquement, verbatim + URL. La distinction
|
||||
exige / recommande / **ne dit pas** est le cœur de cette section.
|
||||
|
||||
### 5.1 [doc] `aps-environment = development` — non-problème, sous réserve d'une vérification
|
||||
|
||||
`App.entitlements` porte `development`. La
|
||||
[doc Apple](https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment)
|
||||
dit verbatim :
|
||||
|
||||
> « **Xcode sets the value of the entitlement based on your app's current
|
||||
> provisioning profile.** […] Production provisioning profile and [prerelease
|
||||
> versions and beta testers] use `production`. »
|
||||
|
||||
TestFlight utilise donc `production`, et [TN2265](https://developer.apple.com/library/archive/technotes/tn2265/_index.html)
|
||||
ajoute que Xcode **re-signe** à la soumission avec le profil de distribution.
|
||||
|
||||
**Ce que la doc ne dit pas** : si Xcode réécrit le *fichier source*
|
||||
`.entitlements`. N'éditez pas le fichier sur la foi d'un blog. Apple publie une
|
||||
procédure de vérification, et c'est la seule qui conclut :
|
||||
|
||||
> Organizer → archive → Distribute/Validate → feuille Summary → dépliant
|
||||
> **Binary and Entitlements** → lire la valeur réelle dans le binaire signé.
|
||||
|
||||
**Le risque APNs supposé est sans objet [vérifié le 2026-09-22]** : il n'y a
|
||||
pas d'APNs. `web/push_notify.py` envoie via **ntfy**, en utilisant le `token`
|
||||
du device comme *nom de topic*. L'en-tête du fichier annonce « V2+ : remplacer
|
||||
par APNs (iOS) + FCM (Android) » — ce n'est pas fait. Voir §5.1bis, qui est le
|
||||
vrai sujet.
|
||||
|
||||
### 5.1bis [vérifié] Les rappels sont natifs et fonctionnent ; c'est le chemin ntfy qui est résiduel
|
||||
|
||||
⚠️ **Correction d'une première version de cette section, qui affirmait que « la
|
||||
chaîne de notifications natives n'existe pas ». C'était faux**, et la méthode
|
||||
qui a produit l'erreur mérite d'être notée : j'avais cherché
|
||||
`PushNotifications` (le plugin *distant*) sans chercher `LocalNotifications`.
|
||||
Conclure d'un grep négatif qu'une fonctionnalité est absente suppose d'avoir
|
||||
cherché le bon terme.
|
||||
|
||||
**Ce qui marche, en natif** — `web/static/local-notifications.js` programme via
|
||||
`Capacitor.Plugins.LocalNotifications` :
|
||||
|
||||
- rappel « Séance pas encore faite » (l. 97) ;
|
||||
- résumé du jour : sport + durée, « Jour OFF », « Peu récupéré » (l. 150-171) ;
|
||||
- hydratation à 10 h / 13 h / 16 h (l. 184) ;
|
||||
- « Heure de te coucher ? » à 23 h (l. 197).
|
||||
|
||||
Ce sont de vraies notifications locales iOS, planifiées sur l'appareil, sans
|
||||
serveur. **Le README a donc raison sur le fond** (les rappels de séance sont
|
||||
natifs), mais il attribue la fonction au mauvais plugin : c'est
|
||||
`@capacitor/local-notifications` qui la rend, pas
|
||||
`@capacitor/push-notifications`.
|
||||
|
||||
**Ce qui reste du chemin ntfy** — `web/push_notify.py` envoie via ntfy (le
|
||||
`token` du device sert de *nom de topic*), et il a deux appelants vivants :
|
||||
|
||||
| Appelant | Ce qu'il expose |
|
||||
|---|---|
|
||||
| `web/routers/api_notifications.py:16` | `POST /register-device`, `GET /devices`, `DELETE /devices/{id}`, `POST /test` |
|
||||
| `tools/notify_morning.py:24` | notification matinale, déclenchée par cron |
|
||||
|
||||
L'intention est que **plus rien ne passe par ntfy** (confirmé par Sylvain le
|
||||
2026-09-22). Ce chemin est donc du code résiduel qui double une fonction déjà
|
||||
assurée en natif.
|
||||
|
||||
⚠️ **Ne pas supprimer avant d'avoir vérifié le cron sur nexus.** Si
|
||||
`notify_morning.py` y est encore planifié, retirer le module casserait le cron
|
||||
— et un cron cassé dans ce projet a déjà tenu quatre mois sans être vu. La
|
||||
vérification n'a **pas** pu être faite depuis le conteneur de dev (pas de
|
||||
résolution DNS pour `nexus`) :
|
||||
|
||||
```
|
||||
ssh nexus "crontab -l | grep -i notify; systemctl list-timers --all | grep -i notify"
|
||||
```
|
||||
|
||||
**Ce qui reste vrai de la remarque App Store** : `@capacitor/push-notifications`
|
||||
est installé et `aps-environment` déclaré, alors que **rien n'appelle ce
|
||||
plugin** (aucun enregistrement de token APNs côté serveur, aucun appel
|
||||
`PushNotifications` côté web). Une capability déclarée sans usage tombe sous
|
||||
2.3.1(a) (« hidden, dormant, or undocumented features »). Les rappels étant
|
||||
couverts par les notifications locales, le plus simple est de **retirer le
|
||||
plugin distant et l'entitlement** — sauf si des push serveur sont prévues.
|
||||
|
||||
### 5.2 [doc] Ce qu'Apple exige, et où on en est
|
||||
|
||||
| Exigence | Source | État |
|
||||
|---|---|---|
|
||||
| Privacy policy URL dans App Store Connect | 5.1.1(i) + ASC Help, « required for all apps » | à renseigner à la soumission |
|
||||
| Privacy policy accessible **dans** l'app | 5.1.1(i), « in an easily accessible manner » | **[vérifié] fait** — `settings.html:796` |
|
||||
| Privacy policy pour toute app HealthKit | [doc HealthKit](https://developer.apple.com/documentation/healthkit/protecting-user-privacy) | **[vérifié] fait** |
|
||||
| Contenu : collecte + usages + tiers + **rétention/suppression et révocation** | 5.1.1(i) | à relire — c'est le point que les policies génériques oublient |
|
||||
| Suppression de compte dans l'app | 5.1.1(v) | **[vérifié] fait** — `POST /account/delete` |
|
||||
| Purpose strings précises | 5.1.1(ii) + doc HealthKit | **[vérifié] fait** |
|
||||
| Nutrition labels renseignés | « required to submit » | **à faire** : Health, Fitness, Precise Location, identifiants, photos — tous « Linked to You » (il y a un compte) |
|
||||
| Aucune donnée HealthKit en pub/marketing | 5.1.3(i), 5.1.2(vi), 2.5.18 | conforme |
|
||||
| Aucune donnée de santé dans iCloud | 5.1.3(ii) | **[vérifié]** aucun entitlement iCloud |
|
||||
| Notes for Review spécifiques + compte de démo **avec backend allumé** | 2.3.1(a), 2.1(a) | à préparer |
|
||||
|
||||
⚠️ **Piège propre à l'architecture webview**, [User Privacy and Data Use](https://developer.apple.com/app-store/user-privacy-and-data-use/) :
|
||||
|
||||
> « **If you are using a webview for app functionality, it should be treated the
|
||||
> same way as native functionality in your app** »
|
||||
|
||||
Tout ce que fait `coach.hypnotruck.ch` dans la WKWebView compte comme du natif
|
||||
pour les nutrition labels **et** pour l'ATT. Si le site embarque le moindre
|
||||
analytics tiers, il doit être déclaré.
|
||||
|
||||
### 5.3 [doc] Ce que la doc Apple **ne tranche pas** — à connaître, pas à corriger
|
||||
|
||||
- **Guideline 4.2 n'a aucun critère mesurable.** « Useful, unique, app-like »
|
||||
n'est pas quantifié. Personne ne peut certifier que HealthKit + widgets +
|
||||
Live Activity + watchOS + BLE « suffit ». Le levier réel est 2.3.1(a) :
|
||||
**décrire nommément les fonctions natives dans les Notes for Review**.
|
||||
- **2.5.2** (« may not download […] code which introduces or changes features »)
|
||||
n'a pas d'exception publiée pour une UI web distante chargée via `server.url`.
|
||||
Zone non tranchée — c'est le risque documentaire réel de l'architecture, et il
|
||||
est structurel, pas corrigeable.
|
||||
- **« Third party »** n'est pas défini : la doc ne dit pas si le backend du
|
||||
développeur en est un. Aucune exigence technique publiée sur le stockage
|
||||
(chiffrement, juridiction, rétention).
|
||||
- **`limitsNavigationsToAppBoundDomains: false`** : la page Apple ne décrit
|
||||
**aucune** conséquence, et `WKAppBoundDomains` est absente de la référence
|
||||
Info.plist. Aucun lien publié entre app-bound domains et données de santé.
|
||||
La config actuelle est cohérente (pas de `WKAppBoundDomains` + flag `false` =
|
||||
comportement pré-iOS 14, sans restriction), et passer à `true` casserait
|
||||
`evaluateJavaScript` et `messageHandlers`, dont Capacitor a besoin.
|
||||
**Ne pas y toucher pour des raisons de conformité.**
|
||||
- **Aucune clé Info.plist de privacy policy n'existe.** Si une source en
|
||||
réclame une, c'est inventé.
|
||||
|
||||
### 5.4 [doc] Signal de positionnement
|
||||
|
||||
5.1.1(ix) vise les « highly regulated fields (such as […] healthcare) » avec un
|
||||
« should » (pas « must »). Mais 1.4.1 prévient :
|
||||
|
||||
> « **Medical apps that could provide inaccurate data or information […] may be
|
||||
> reviewed with greater scrutiny.** »
|
||||
|
||||
Le vocabulaire de la fiche App Store a donc un effet documenté sur le niveau de
|
||||
scrutin : « coaching sportif » et « suivi cardiaque post-infarctus » ne
|
||||
déclenchent pas la même lecture.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ergonomie — l'app est-elle utilisable **par un sportif** ?
|
||||
|
||||
C'est l'exigence produit : simple, ergonomique, pour des sportifs. Rappel
|
||||
d'architecture — l'UI réelle vit dans `coach_sportif/web/`, le wrapper ne fait
|
||||
que l'afficher. Les constats ci-dessous portent donc sur le web.
|
||||
|
||||
### 6.1 [vérifié] Le geste central du sportif n'existe pas
|
||||
|
||||
Le mot « Démarrer » n'apparaît que **deux fois** dans tout `web/templates/`, et
|
||||
les deux fois pour la routine anti-douleurs (`routine.html:27,42`). La troisième
|
||||
occurrence est un commentaire qui décrit une fonctionnalité absente :
|
||||
`widgets/today-session.html:6` annonce un CTA « Démarrer » / « Voir détails »
|
||||
que le markup ne contient pas.
|
||||
|
||||
| Action | Taps |
|
||||
|---|---|
|
||||
| Voir la séance du jour | 0 (mais ~420 px sous le haut de page) |
|
||||
| Voir ses étapes et sa zone cardiaque cible | 2 |
|
||||
| Envoyer la séance à la Watch | 3 + scroll dans la modale |
|
||||
| **Démarrer la séance** | **impossible** — `/live` est passif, et hors onglets par défaut |
|
||||
|
||||
Avant l'effort : 3 taps pour préparer. Pendant l'effort : l'app ne sert à rien
|
||||
sans la montre. Pour une app « prévue pour les sportifs », c'est le trou central.
|
||||
|
||||
### 6.2 [vérifié] La typographie descend à 8 px sur l'information la plus sensible
|
||||
|
||||
17 règles CSS descendent à ≤ 0,62 rem, la plupart en `--text-tertiary` :
|
||||
|
||||
| Sélecteur | Taille | Ce que ça porte |
|
||||
|---|---|---|
|
||||
| `.ap-tile__pill` (`style.css:7053`) | **0,5 rem = 8 px** | **l'intensité de la séance**, « ADAPTÉ IA », « FAIT ✓ » |
|
||||
| `.ap-tile__meta` (`:7068`) | 0,62 rem ≈ 9,9 px | durée de la séance, km, FC |
|
||||
| `.entry-src`, `.entry-ecart` | 0,58 rem ≈ 9,3 px | provenance / écart d'une mesure |
|
||||
|
||||
**L'intensité d'effort d'un post-infarctus est affichée en majuscules de 8 px,
|
||||
en gris, sur noir.** Au total, 127 règles combinent `--text-tertiary` avec une
|
||||
police < 0,78 rem.
|
||||
|
||||
**Ratios réels [vérifié par calcul]**, fonction validée sur le témoin WCAG
|
||||
officiel (`#767676`/blanc = 4,54) :
|
||||
|
||||
| Token | sur `#000` | sur `.ap-card` | sur `--bg-tertiary` |
|
||||
|---|---|---|---|
|
||||
| `--text-secondary` | **6,36** (le guide annonce 9,7 — faux) | 5,95 | 5,27 |
|
||||
| `--text-tertiary` | 5,42 | 5,22 | **4,68** (limite AA) |
|
||||
|
||||
Les tokens passent AA, mais avec deux fois moins de marge que le guide ne le
|
||||
croit. Et le problème n'est pas le ratio : c'est la taille. **Aucune
|
||||
échappatoire** : pas de mode clair (`_layout.html:20` force
|
||||
`background:#000 !important`, et les tokens clairs de `critical.css:153-166`
|
||||
sont du code mort), pas de Dynamic Type (`-webkit-text-size-adjust: 100%` +
|
||||
`rem` sur racine fixe), aucun réglage de taille dans `/settings`.
|
||||
|
||||
Un thème sombre est le pire cas en extérieur : c'est le fond — la plus grande
|
||||
surface — qui est noyé par la lumière réfléchie.
|
||||
|
||||
### 6.3 [rapporté] Les cibles tactiles fautives sont exactement les actions fréquentes
|
||||
|
||||
Les composants génériques respectent le 44×44 du style guide (`.ap-icon-btn`,
|
||||
`.tab-item`, `.more-item`, `.coach-fab`). Mais :
|
||||
|
||||
| Élément | Taille | Rôle |
|
||||
|---|---|---|
|
||||
| `.qr-action` | ~**29 × 35** | ajout rapide d'un favori — le geste le plus fréquent |
|
||||
| `.hero-tab` | ~**23 px** | sélecteur primaire de `/meals` |
|
||||
| `.ap-home-hero__cta-mini` | **32 × 32** | **le CTA du hero d'accueil** |
|
||||
| `.meal-section__add-btn` | **34 × 34** | porte d'entrée de la saisie de repas |
|
||||
| `.ap-icon-btn.--sm` | **36 × 36** | navigation jour de `/meals` — usage **primaire**, ce que §2 du guide interdit |
|
||||
|
||||
Saisir un repas connu = 3 taps, **dont 2 sous le seuil**. Pire : dans
|
||||
`.quick-row`, le bouton favori (étoile) et le bouton ajouter (+) sont adjacents,
|
||||
même taille ~29 px : avec des doigts moites ou des gants, on met en favori au
|
||||
lieu d'ajouter, **sans retour d'erreur** puisque les deux « marchent ».
|
||||
|
||||
Aucun test ne verrouille le 44×44 ni le contraste — alors que le CLAUDE.md pose
|
||||
« un garde-fou contre la récidive → un test, pas une phrase ». Les règles
|
||||
d'ergonomie sont les seules du projet à n'être qu'une phrase. D'où la dérive.
|
||||
|
||||
### 6.4 [rapporté] Densité et navigation
|
||||
|
||||
- **Accueil : 28 chiffres, dont un seul concerne la séance à faire** (sa durée,
|
||||
en 9,9 px gris). Les 27 autres sont du constat sur le passé.
|
||||
- **`/workout` : 20 tuiles toutes visibles par défaut**, 100-130 valeurs avec
|
||||
les tableaux. `/sleep` : 8 tuiles.
|
||||
- **19 sections de navigation, 4 visibles**, avec des doublons sémantiques
|
||||
réels : `/mesures` + `/body` + `/profile` pour le corps ; `/calendar` +
|
||||
`/week` + `/dashboards` + `/reviews` pour la planification.
|
||||
- **La place la plus accessible au pouce** — le FAB 56 px, fixe, sur toutes les
|
||||
pages — **est occupée par le chat IA**, pas par « logger » ou « démarrer ».
|
||||
|
||||
### 6.5 Le meilleur travail d'ergonomie de l'app est éteint
|
||||
|
||||
`drinks-quick.html` et `meal-capture.html` ont des boutons `min-height: 44px`,
|
||||
un geste unique, et un commentaire qui pose le bon raisonnement (« boire se note
|
||||
vingt fois par jour ; un geste qui coûte deux navigations ne se fait pas »).
|
||||
**Ils ne sont ni dans le layout par défaut, ni en production.** Les allumer dans
|
||||
`_default_home_layout()` est un gain immédiat pour un coût nul.
|
||||
|
||||
### 6.6 La tension honnêteté / ergonomie, à trancher
|
||||
|
||||
Le hero affiche TSB signé, HRV en ms, FC repos + écart % — quatre grandeurs à
|
||||
interpréter, dont aucune ne dit quoi faire. La seule phrase actionnable est
|
||||
suivie d'un encadré de 40 mots à 11,2 px expliquant qu'aucune publication ne la
|
||||
fonde.
|
||||
|
||||
Ce disclaimer est **juste** : c'est la règle 3ter, et sur une app de santé il
|
||||
n'est pas négociable. Mais son coût est payé au pire endroit. La question de
|
||||
conception est : *comment rester honnête sans occuper le premier écran ?* Le
|
||||
composant existe déjà — `<details class="settings-fold">`, dont §3c du guide
|
||||
décrit les deux règles.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ce qui n'a pas été audité
|
||||
|
||||
Par honnêteté sur la couverture :
|
||||
|
||||
- **Aucun build Xcode** — impossible depuis Linux. WidgetKit, SwiftUI, HealthKit
|
||||
et WatchKit ne compilent pas ici. Rien de ce qui touche à l'UI native, à la
|
||||
signature ou au comportement sur device n'a été vérifié autrement que par
|
||||
lecture.
|
||||
- **`CoachPolarBLE.swift`** (644 L, 86 commits de diagnostic entre le 11/08 et
|
||||
le 20/09) n'a pas été revu. C'est le chantier le plus instable du dépôt.
|
||||
- **Android** : seul le manifest a été lu. Health Connect n'est pas intégré, et
|
||||
la validation Play Console reste en attente.
|
||||
- **Le backend** (`coach_sportif/web`) n'a été audité que sur l'ergonomie et
|
||||
deux points de conformité. Ni sécurité serveur, ni justesse des formules.
|
||||
- **Le contenu de la politique de confidentialité** n'a pas été relu contre les
|
||||
trois exigences de 5.1.1(i) — son existence a été vérifiée, pas son contenu.
|
||||
|
||||
*(`web/push_notify.py` a été lu après coup : voir §5.1bis, qui remplace le
|
||||
risque APNs initialement supposé.)*
|
||||
|
||||
---
|
||||
|
||||
## 8. Ordre de traitement proposé
|
||||
|
||||
**D'abord, parce que la conséquence est une décision d'intensité prise sur un
|
||||
chiffre mort :**
|
||||
|
||||
1. **§1.1 — horodater la FC** et afficher « — » au-delà d'un seuil d'âge, des
|
||||
deux côtés du pont. Correctif petit et local.
|
||||
2. **§6.2 — remonter le plancher typographique** : interdire < 0,7 rem, bannir
|
||||
`--text-tertiary` sous 0,78 rem. En priorité `.ap-tile__pill` (l'intensité de
|
||||
séance en 8 px).
|
||||
|
||||
**Ensuite, la perte de données :**
|
||||
|
||||
3. **§2.1 + §2.2 ensemble** — même scénario, même correctif structurel : boucler
|
||||
sur le lot, tenir un `beginBackgroundTask`, lire le statut HTTP comme
|
||||
`CoachRoutineBridge` le fait déjà.
|
||||
4. **§2.3 + §2.4** — deux faces de la même omission dans le cycle de vie de la
|
||||
séance watchOS.
|
||||
5. **§2.5** — une ligne.
|
||||
|
||||
**Puis le produit :**
|
||||
|
||||
6. **§6.1 — un bouton « Démarrer »** ≥ 44 px sur la tuile du jour, avec la zone
|
||||
cible lisible à côté.
|
||||
7. **§6.5 — allumer `drinks-quick` et `meal-capture`.** Coût nul.
|
||||
8. **§6.3 — porter les cibles primaires à 44 px**, et écarter l'étoile du `+`.
|
||||
|
||||
**Avant soumission :**
|
||||
|
||||
9. **Nettoyer le résiduel de notifications (§5.1bis)** — vérifier le cron
|
||||
`notify_morning` sur nexus, puis retirer le chemin ntfy et le plugin
|
||||
`@capacitor/push-notifications` + son entitlement, que rien n'appelle.
|
||||
Corriger la ligne du README qui attribue les rappels au mauvais plugin.
|
||||
Puis vérifier `aps-environment` dans le Summary de l'archive (§5.1).
|
||||
10. Nutrition labels, Notes for Review nommant les fonctions natives, compte de
|
||||
démo avec backend allumé (§5.2).
|
||||
|
||||
**Garde-fous, parce que « un test, pas une phrase » :**
|
||||
|
||||
11. Deux tests web : 44×44 sur les classes primaires, ratio de contraste des
|
||||
tokens. Ce sont les seules règles du projet non verrouillées, et ce sont
|
||||
celles qui ont dérivé.
|
||||
|
||||
---
|
||||
|
||||
*Audit mené par lecture du code et des sources Apple. Les constats **[rapporté]**
|
||||
proviennent de revues automatisées et n'ont pas été revérifiés ligne à ligne :
|
||||
les confirmer avant d'agir.*
|
||||
404
docs/DIAGNOSTIC-NATIF-2026-09-24.md
Normal file
404
docs/DIAGNOSTIC-NATIF-2026-09-24.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Diagnostic natif — les cinq constats ouverts de l'audit, vérifiés
|
||||
|
||||
> ## ⚠️ § 1.2 A ÉTÉ CORRIGÉ le 24/09, après ce diagnostic
|
||||
>
|
||||
> Sylvain a levé le gel sur ce point précis (« tu attends quoi ? »), puis sur
|
||||
> **les quatre autres** (« pour le reste vas-y »). Les cinq sont donc corrigés :
|
||||
> § 1.2 buildé et vérifié sur l'appareil, § 1.3 / 2.3 / 2.4 / 2.6 écrits et
|
||||
> vérifiés en syntaxe, **pas encore compilés**.
|
||||
>
|
||||
> ⚠️ **§ 2.3 et § 2.4 touchent `WorkoutManager.swift`**, c'est-à-dire le chemin
|
||||
> de fin de séance du live workout — ce que la consigne du 22/09 demandait de ne
|
||||
> pas approcher. Fait sur demande explicite, en ne changeant aucune logique :
|
||||
> les `try?` deviennent des `do/catch` qui journalisent, et le chemin d'échec
|
||||
> gagne la clôture qui lui manquait. **À éprouver en séance réelle avant de
|
||||
> considérer que ça tient.**
|
||||
>
|
||||
> ✅ **BUILD FAIT ET VÉRIFIÉ SUR L'APPAREIL, le 24/09.** Le diagnostic de
|
||||
> `/settings` (« Diagnostiquer et pousser ») affiche sur l'iPhone de Sylvain :
|
||||
>
|
||||
> ```
|
||||
> Montre : snapshot reçu
|
||||
> Zones envoyées : version frozen du 2026-08-12
|
||||
> ```
|
||||
>
|
||||
> Les quatre causes d'échec silencieux sont donc écartées : session activée,
|
||||
> montre appairée, app Watch installée, `updateApplicationContext` sans erreur.
|
||||
> Et la date de version arrive au poignet — elle se perdait au transport.
|
||||
>
|
||||
> ⚠️ **Ce que cela ne prouve pas** : `updateApplicationContext` est asynchrone.
|
||||
> iOS a *accepté* le contexte, il le livre quand il peut. Ce qui est établi,
|
||||
> c'est que l'iPhone a réussi à le transmettre au système — pas que la montre
|
||||
> affiche les bonnes bornes. Seul le poignet le dira, pendant une séance.
|
||||
|
||||
**24/09/2026. Diagnostic mené en lecture seule** — aucune ligne de Swift n'avait
|
||||
été modifiée à sa rédaction.
|
||||
Demandé par Sylvain après l'audit du 22/09, dont cinq constats étaient marqués
|
||||
**[rapporté]** — issus de revues automatisées, jamais reconfrontés au code.
|
||||
L'audit lui-même le disait : « les confirmer avant d'agir ».
|
||||
|
||||
Ce document dit ce que le code fait **aujourd'hui**, ce que l'audit décrivait
|
||||
mal, et ce qu'il faudra corriger le jour où le gel sur le natif sera levé. Il ne
|
||||
propose aucun geste : la correction demande un build Mac, et rien n'est réel
|
||||
tant que ce build n'a pas tourné.
|
||||
|
||||
⚠️ **Les numéros de ligne de l'audit ne valent plus.** Les correctifs du 22/09
|
||||
au soir ont décalé `WorkoutManager.swift` et `CoachHealthRoute.swift`. Les
|
||||
références ci-dessous sont celles du dépôt à `6031be8`.
|
||||
|
||||
---
|
||||
|
||||
## Verdict d'ensemble
|
||||
|
||||
**Les cinq sont confirmés.** Aucun faux positif. Deux sont plus graves que
|
||||
l'audit ne le disait, un est plus nuancé.
|
||||
|
||||
| # | Constat | Vérifié | Écart avec l'audit |
|
||||
|---|---|---|---|
|
||||
| 1.2 | Zones cardiaques muettes vers la montre | ✅ | **Aggravé** : 4 sorties muettes, pas 1 |
|
||||
| 1.3 | « Refusé » ≡ « aucune donnée » (sommeil) | ✅ | Exact |
|
||||
| 2.3 | Séance perdue sans log | ✅ | **Nuancé** : `nil` n'est pas toujours une perte |
|
||||
| 2.4 | `didFailWithError` laisse le GPS allumé | ✅ | **Aggravé** : `builder = nil` rend la récupération impossible |
|
||||
| 2.6 | Tampon GPS sans verrou | ✅ | Exact |
|
||||
|
||||
---
|
||||
|
||||
## 1.2 — Les zones cardiaques peuvent ne jamais atteindre la montre
|
||||
|
||||
**`ios/App/App/CoachWidgetBridge.swift:175-183`** (`pushToWatch`)
|
||||
|
||||
L'audit ne signalait que le `try?`. Il y a **quatre** sorties silencieuses sur
|
||||
sept lignes :
|
||||
|
||||
```swift
|
||||
guard WCSession.isSupported() else { return } // 1. muet
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated, // 2. muet
|
||||
let data = try? JSONEncoder().encode(snapshot) else { return } // 3. muet
|
||||
try? session.updateApplicationContext(["widgetSnapshot": data]) // 4. avalé
|
||||
```
|
||||
|
||||
Aucun log, aucune remontée au JS. Le snapshot porte `zones: HeartRateZones?`
|
||||
(`CoachWidgetSnapshot.swift:96`) — les bornes Z1…Z5 et `clinicalTargetBpm`, la
|
||||
cible sous bêtabloquant.
|
||||
|
||||
**Ce que fait le chemin voisin, dans le même dépôt**
|
||||
(`CoachRoutineBridge.swift:87-101`) pour la routine de gainage : il vérifie
|
||||
`isPaired` **et** `isWatchAppInstalled` avec une raison explicite, entoure
|
||||
l'appel d'un `do/catch`, journalise l'échec et appelle `call.reject`.
|
||||
|
||||
Le chemin instrumenté porte des exercices de mobilité. Le chemin muet porte les
|
||||
bornes cardiaques d'un patient post-infarctus.
|
||||
|
||||
### L'aggravant est réel, et il se compose avec le premier
|
||||
|
||||
`HeartRateZones` (`ios/App/App/HeartRateZones.swift`) ne porte **aucune date** :
|
||||
ni `version`, ni `validFrom`, ni `computedAt`. Or le backend sert une version
|
||||
**figée et datée** (`data/hr_zones_history.json`, via `app.current_zones()`) —
|
||||
c'est même une règle non négociable du projet web (règle 9 du CLAUDE.md).
|
||||
|
||||
**La date existe côté serveur et se perd au transport.**
|
||||
|
||||
Et `CoachWidgetSnapshot.asOf(_:)` périme l'eau, le café, les kcal, la routine,
|
||||
l'activité du jour et le score de forme — mais **pas `zones`**, délibérément
|
||||
(« version figée, valable des semaines »). Le raisonnement est bon pour une
|
||||
péremption journalière ; il laisse ouvert le cas d'une **révision** des bornes.
|
||||
|
||||
Conséquence composée : après un `POST /api/cardiac-zones/confirm`, si le push
|
||||
échoue silencieusement, la montre affiche les anciennes bornes **sans limite de
|
||||
temps et sans que rien ne le signale**, ni au poignet ni à l'iPhone.
|
||||
|
||||
**À corriger, le jour venu** : aligner `pushToWatch` sur `CoachRoutineBridge`
|
||||
(do/catch, log, `isPaired`/`isWatchAppInstalled`), et faire voyager la date de
|
||||
version des zones pour que la montre puisse dire « bornes du 12/08 » plutôt que
|
||||
d'afficher un chiffre sans âge.
|
||||
|
||||
---
|
||||
|
||||
## 1.3 — « Accès refusé » et « aucune donnée » sont rendus identiques
|
||||
|
||||
**`ios/App/App/CoachSleep.swift`** — en-tête ligne 30 :
|
||||
|
||||
> `// r.available = false si HealthKit est indisponible ou non autorisé`
|
||||
|
||||
Le code teste `HKHealthStore.isHealthDataAvailable()` (l. 59, 63) et la
|
||||
disponibilité du type (l. 68). **`authorizationStatus(for:)` n'apparaît nulle
|
||||
part dans le fichier.** La requête aboutit et rend :
|
||||
|
||||
```swift
|
||||
call.resolve(["available": true, "samples": out, ...]) // l. 92-96
|
||||
```
|
||||
|
||||
La documentation du fichier promet donc quelque chose que le code ne fait pas.
|
||||
|
||||
**La nuance de l'audit est juste** : HealthKit ne distingue pas volontairement
|
||||
« refusé » de « vide » **en lecture** — c'est une protection de vie privée
|
||||
documentée par Apple. Mais `authorizationStatus(for:)` renvoie `.notDetermined`
|
||||
de façon fiable, et c'est le cas fréquent : réinstallation de l'app,
|
||||
autorisation jamais accordée. C'est celui-là qu'on peut distinguer.
|
||||
|
||||
**Conséquence côté serveur** : `sleep_score.py` calcule une régularité de
|
||||
coucher sur des nuits absentes, sans savoir qu'elles sont absentes parce qu'on
|
||||
ne nous laisse plus regarder. Une valeur fausse, pas une valeur manquante.
|
||||
|
||||
Même famille dans `CoachHealthRoute.swift` (l. 173 et 277 selon l'audit — à
|
||||
revérifier, les lignes ont bougé).
|
||||
|
||||
---
|
||||
|
||||
## 2.3 — Une séance peut disparaître sans un seul log
|
||||
|
||||
**`ios/App/CoachWatch/WorkoutManager.swift:383-398`**
|
||||
|
||||
```swift
|
||||
LocationTracker.shared.stopTracking()
|
||||
try? await builder.endCollection(at: date)
|
||||
let workout = try? await builder.finishWorkout()
|
||||
if let workout { … } else if let recovered = await self.recentlySavedWorkout() { … }
|
||||
else { LocationTracker.shared.discardRoute() }
|
||||
```
|
||||
|
||||
**Ce que l'audit ne disait pas, et qui change la lecture** : le commentaire du
|
||||
code explique que `finishWorkout()` rend `nil` **sans erreur** quand la montre
|
||||
est verrouillée, et que dans ce cas la séance **est** écrite dans HealthKit —
|
||||
seul l'objet manque. C'est pour ça que `recentlySavedWorkout()` existe. Un `nil`
|
||||
n'est donc pas une perte en soi, et le code le sait.
|
||||
|
||||
**Le défaut est plus précis que « la séance est perdue » :** le double `try?`
|
||||
efface la différence entre deux situations opposées —
|
||||
|
||||
| Ce qui s'est passé | `workout` | La séance est-elle sauvée ? |
|
||||
|---|---|---|
|
||||
| Montre verrouillée à l'arrêt | `nil` | **Oui**, `recentlySavedWorkout()` la retrouve |
|
||||
| `endCollection` a levé | `nil` | **Non**, il n'y a rien à retrouver |
|
||||
|
||||
Les deux chemins convergent vers le même `else`, et `discardRoute()` ne
|
||||
journalise que la perte de la **trace**. `statusMessage` n'est pas touché :
|
||||
après deux heures de sortie, l'utilisateur voit l'écran de fin habituel.
|
||||
|
||||
**À corriger** : remplacer les deux `try?` par un `do/catch` qui journalise
|
||||
l'erreur — sans changer la logique de récupération, qui est bonne.
|
||||
|
||||
---
|
||||
|
||||
## 2.4 — `didFailWithError` ne clôt rien et laisse le GPS allumé
|
||||
|
||||
**`ios/App/CoachWatch/WorkoutManager.swift:451-464`**
|
||||
|
||||
```swift
|
||||
workoutLog.error("session didFailWithError domain=… code=…: \(message)")
|
||||
Task { @MainActor in
|
||||
self.statusMessage = "Erreur séance : \(message)"
|
||||
self.isRunning = false
|
||||
self.session = nil
|
||||
self.builder = nil
|
||||
ConnectivityManager.shared.sendEnded(reason: "session-failed")
|
||||
}
|
||||
```
|
||||
|
||||
Comparé au chemin `.ended` (l. 383-398), il manque **tout** : `stopTracking()`,
|
||||
`endCollection`, `finishWorkout`, `finishRoute`/`discardRoute`.
|
||||
|
||||
**Plus grave que l'audit ne le dit** : `self.builder = nil` détruit la seule
|
||||
référence au builder. Après cette ligne, plus aucun code ne peut tenter de
|
||||
sauver ce qu'il avait collecté — la perte devient définitive dans la même
|
||||
instruction qui l'ignore.
|
||||
|
||||
Deux conséquences distinctes :
|
||||
1. **Le GPS continue de tourner.** `LocationTracker` n'est jamais arrêté, alors
|
||||
que la séance est finie du point de vue de l'interface. Sur une montre, ça se
|
||||
paie en batterie, et rien ne l'indique.
|
||||
2. **Les données collectées sont jetées sans tentative de sauvegarde.**
|
||||
|
||||
⚠️ À la différence de § 2.3, ici `statusMessage` **est** renseigné : l'utilisateur
|
||||
voit « Erreur séance ». Il sait qu'il s'est passé quelque chose ; il ne sait pas
|
||||
qu'il a perdu la séance, et le GPS tourne toujours.
|
||||
|
||||
---
|
||||
|
||||
## 2.6 — Accès concurrent non protégé sur le tampon GPS
|
||||
|
||||
**`ios/App/App/CoachHealthRoute.swift:183-205`** (`collectLocations`)
|
||||
|
||||
```swift
|
||||
var all: [CLLocation] = []
|
||||
let group = DispatchGroup()
|
||||
var pluginErr: Error?
|
||||
for route in routes {
|
||||
group.enter()
|
||||
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
|
||||
if let err = err { pluginErr = err; group.leave(); return }
|
||||
if let locs = locations { all.append(contentsOf: locs) } // ⚠️ sans verrou
|
||||
…
|
||||
```
|
||||
|
||||
`all` et `pluginErr` sont mutés depuis les callbacks de N requêtes, livrées sur
|
||||
des files HealthKit indépendantes. **Aucun verrou.**
|
||||
|
||||
**Le même fichier utilise `NSLock` pour ce motif exact à quatre endroits** :
|
||||
lignes 283, 380, 442 et 565. Ce n'est donc pas une convention absente du
|
||||
projet — c'est une omission à un seul endroit.
|
||||
|
||||
**Déclencheur** : un workout portant plusieurs `HKWorkoutRoute` (multisport, ou
|
||||
reprise après interruption). Avec une seule route, la boucle n'a qu'une
|
||||
itération et le défaut est inobservable.
|
||||
|
||||
⚠️ **On ne sait pas s'il s'est déjà produit** : les routes vivent dans
|
||||
HealthKit, pas sur le serveur, et rien ne journalise `routes.count`. Une
|
||||
corruption de tableau en Swift ne lève pas forcément — elle peut donner une
|
||||
trace tronquée ou un crash, sans rien qui désigne la cause.
|
||||
|
||||
---
|
||||
|
||||
## Ce que ce diagnostic ne dit pas
|
||||
|
||||
- **Aucune de ces corrections n'a été écrite.** Le gel du 22/09 sur le natif
|
||||
tient ; Sylvain a autorisé la lecture, pas la modification.
|
||||
- **Rien n'est vérifiable sans build Mac.** Ces cinq constats sont établis par
|
||||
lecture du code, pas par exécution. Une lecture peut manquer un chemin.
|
||||
- **La dette assumée du 22/09 reste ouverte** : le `defer { completion() }` de
|
||||
`CoachWorkoutObserver` déclare la livraison traitée alors que les requêtes
|
||||
sont encore en vol, sans `beginBackgroundTask`. Elle n'est pas revue ici, elle
|
||||
était déjà documentée et assumée.
|
||||
- **Le § 4 de l'audit (gravité 3, justesse et cohérence) n'a pas été revérifié.**
|
||||
|
||||
## Si un seul devait être corrigé
|
||||
|
||||
**§ 1.2**, et pas parce qu'il est le plus spectaculaire : parce que c'est le seul
|
||||
dont la conséquence est un **chiffre faux affiché pendant l'effort**, chez
|
||||
quelqu'un qui règle son intensité dessus. Les quatre autres perdent de la donnée
|
||||
ou de la batterie — c'est réparable après coup. Une borne de zone périmée au
|
||||
poignet ne se rattrape pas : elle a déjà servi à décider.
|
||||
|
||||
---
|
||||
|
||||
## Ce qui a été corrigé (§ 1.2, le 24/09 après-midi)
|
||||
|
||||
Trois couches, parce que le défaut en traversait trois.
|
||||
|
||||
### 1. L'envoi ne se tait plus — `CoachWidgetBridge.swift`
|
||||
|
||||
`pushToWatch` rend désormais la **raison** de son échec, ou `nil` s'il a réussi,
|
||||
et journalise dans `Logger(subsystem: "ch.hypnotruck.coach", category: "widget")`.
|
||||
Les quatre sorties muettes deviennent **six sorties nommées** :
|
||||
WatchConnectivity non supportée · session non activée · **aucune montre
|
||||
appairée** · **app Watch non installée** · encodage impossible · envoi refusé.
|
||||
Les deux gardes en gras viennent de `CoachRoutineBridge` : sans elles, l'absence
|
||||
de montre se confondait avec un échec d'envoi.
|
||||
|
||||
Le log de succès nomme ce qui compte : `zones=oui depuis=2026-08-12`. Savoir
|
||||
quelles bornes sont au poignet, et depuis quand, sans ouvrir la montre.
|
||||
|
||||
⚠️ **Un échec ne fait PAS échouer `setSnapshot`**, contrairement à
|
||||
`CoachRoutineBridge` où l'envoi *est* l'opération. Ici l'App Group est déjà écrit
|
||||
et les widgets iPhone rafraîchis : une montre verrouillée n'invalide rien. D'où
|
||||
un `resolve` enrichi (`watchPushed`, `watchReason`) et non un `reject` — `ok`
|
||||
est conservé, aucun appelant JS existant ne casse.
|
||||
|
||||
### 2. La date de version voyage — `HeartRateZones.swift` + `widget-bridge.js`
|
||||
|
||||
`zonesSince` (date de la version figée) et `zonesFrom` (`"frozen"` ou `"live"`)
|
||||
rejoignent le modèle, en optionnels — un snapshot écrit avant reste décodable.
|
||||
|
||||
**Le serveur les servait déjà** : `/api/cardiac-zones` rend `zones_since` et
|
||||
`zones_from` depuis `hr_zones_history.effective()`. Rien à changer côté backend ;
|
||||
c'est `widget-bridge.js` qui ne les faisait pas monter dans le snapshot. Deux
|
||||
lignes.
|
||||
|
||||
La montre peut donc maintenant distinguer des bornes **validées** de bornes
|
||||
**provisoires**, et dire leur âge. Ce qu'elle en affiche reste à décider — c'est
|
||||
de l'UI watchOS, hors de ce correctif.
|
||||
|
||||
### 3. Le garde-fou — `tests-linux/`
|
||||
|
||||
Trois tests dans `DayRolloverTests` : la date survit au passage de minuit
|
||||
(`asOf`), elle survit à l'aller-retour JSON (ce qui n'est pas encodé n'arrive
|
||||
pas au poignet), et un snapshot **antérieur** à ces champs se décode toujours —
|
||||
un décodage qui lèverait laisserait la montre sans zones du tout, pire que des
|
||||
zones sans date. La doublure `HeartRateZonesStub.swift` est tenue à l'identique.
|
||||
|
||||
91 tests verts (88 avant).
|
||||
|
||||
### Ce que ce correctif ne fait pas
|
||||
|
||||
- **Il ne prouve pas que le push fonctionne.** Il rend son échec *visible*. Si
|
||||
l'envoi échouait pour une raison qu'on ignore, on le saura au prochain build —
|
||||
c'était impossible jusqu'ici.
|
||||
- **Il ne périme pas les zones.** Après une révision, la montre affichera
|
||||
toujours les anciennes bornes tant qu'un push n'a pas réussi ; elle saura
|
||||
seulement les dater. Décider qu'une borne de plus de N jours n'est plus
|
||||
affichable est un choix produit, pas une correction de défaut.
|
||||
- **Il ne touche pas au flux course à pied** (WorkoutKit, live workout FC, app
|
||||
Exercice), conformément à la consigne du 22/09.
|
||||
- ✅ **Compilé et vérifié le 24/09** — sans Console : le diagnostic de
|
||||
`/settings` rend le statut en clair (`Montre : snapshot reçu` · `Zones
|
||||
envoyées : version frozen du 2026-08-12`), parce que le plugin remonte
|
||||
désormais `watchPushed` / `watchReason` jusqu'au JS.
|
||||
⏳ **Reste à voir au poignet** : que la montre affiche ces bornes pendant une
|
||||
séance. L'envoi est accepté par iOS ; la livraison est asynchrone.
|
||||
|
||||
---
|
||||
|
||||
## Les quatre autres, corrigés le 24/09 (« pour le reste vas-y »)
|
||||
|
||||
⛔ **Aucun n'est compilé.** Syntaxe vérifiée (`swiftc -parse`), 91 tests du banc
|
||||
Linux verts — mais ces fichiers importent HealthKit, WatchKit et Capacitor,
|
||||
absents de Swift pour Linux. Le banc ne les couvre pas et ne peut pas les
|
||||
couvrir.
|
||||
|
||||
### § 1.3 — `.notDetermined` n'est plus confondu avec une nuit vide
|
||||
|
||||
`CoachSleep.readSamples` teste désormais `authorizationStatus(for: sleepType)`
|
||||
et rend `{available: false, reason: "authorization not determined"}`.
|
||||
|
||||
⚠️ **Ce qui n'est PAS corrigé, et ne peut pas l'être** : un refus *explicite*
|
||||
reste indiscernable d'une absence de données. HealthKit ne le révèle pas en
|
||||
lecture, par conception — c'est une protection de vie privée, pas un oubli
|
||||
d'Apple. Seul `.notDetermined` est fiable, et c'est le cas fréquent
|
||||
(réinstallation, permission jamais demandée). L'en-tête du fichier dit
|
||||
maintenant exactement ce que la promesse couvre, au lieu de promettre plus.
|
||||
|
||||
### § 2.3 — « séance sauvée » et « rien de sauvé » ne se confondent plus
|
||||
|
||||
Les deux `try?` deviennent des `do/catch`. **La logique de récupération n'a pas
|
||||
changé** — `recentlySavedWorkout()` reste le repli quand `finishWorkout()` rend
|
||||
`nil` sur montre verrouillée. Ce qui change : un `endCollection` qui lève est
|
||||
journalisé, et le `else` final distingue les deux cas, dont un seul est une
|
||||
perte. Dans ce cas seulement, `statusMessage` le dit à l'écran — une montre
|
||||
verrouillée n'est pas une perte, et l'annoncer serait une fausse alerte.
|
||||
|
||||
### § 2.4 — le GPS s'arrête, et la séance est sauvée si elle peut l'être
|
||||
|
||||
`didFailWithError` commence par `stopTracking()` : c'est le seul geste qui
|
||||
réussit à coup sûr, et le seul dont l'omission se paie en batterie pendant des
|
||||
heures. Puis une tentative de clôture en best-effort (`endCollection`,
|
||||
`finishWorkout`), avec `recentlySavedWorkout()` en second recours — une session
|
||||
qui a échoué refusera souvent de clore, mais ne pas essayer garantissait la
|
||||
perte. `builder = nil` n'intervient plus qu'à la fin, après les tentatives.
|
||||
|
||||
Trois issues, trois messages distincts : « Séance interrompue, mais
|
||||
enregistrée », « Séance perdue : … », ou le message d'erreur d'origine.
|
||||
|
||||
### § 2.6 — le tampon GPS est verrouillé
|
||||
|
||||
`collectLocations` protège `all` et `pluginErr` par un `NSLock`, comme les
|
||||
quatre autres endroits du même fichier le faisaient déjà.
|
||||
|
||||
⚠️ **Invérifiable sans le déclencheur** : il faut un workout portant plusieurs
|
||||
`HKWorkoutRoute` (multisport, reprise après interruption). Avec une seule route,
|
||||
la boucle n'a qu'une itération — c'est pourquoi le défaut a pu vivre longtemps
|
||||
sans se manifester, et pourquoi sa correction ne se constatera pas non plus.
|
||||
|
||||
### Ce qu'il faut regarder au prochain build
|
||||
|
||||
| Quoi | Où | Ce qui prouve |
|
||||
|---|---|---|
|
||||
| § 1.3 | `/settings` → synchro santé | Retirer « Sommeil » dans Réglages → Santé, réinstaller : la synchro doit dire « non autorisé », pas enregistrer des nuits vides |
|
||||
| § 2.3 | Console, catégorie `workout` | Une séance normale ne doit RIEN journaliser de neuf. Les nouveaux logs ne sortent qu'en cas d'échec |
|
||||
| § 2.4 | Batterie, après une séance interrompue | Le GPS ne doit plus tourner une fois l'écran de fin affiché |
|
||||
| § 2.6 | — | Rien à voir : le correctif supprime une corruption qui ne se manifestait pas de façon lisible |
|
||||
|
||||
⚠️ **§ 2.3 et § 2.4 sont sur le chemin de fin de séance du live workout.** Le
|
||||
test qui compte n'est pas un log, c'est **une séance réelle menée jusqu'au bout,
|
||||
puis retrouvée dans Santé et dans coach**. Tant qu'elle n'a pas eu lieu, ces
|
||||
deux correctifs sont des intentions.
|
||||
479
docs/polar-ble-runbook-mac.md
Normal file
479
docs/polar-ble-runbook-mac.md
Normal file
@@ -0,0 +1,479 @@
|
||||
# Écrire la séance sur la Polar en Bluetooth — ce qui reste à faire sur le Mac
|
||||
|
||||
Le code est écrit et poussé. Ce qui suit ne peut se faire que sur le Mac mini,
|
||||
Xcode ouvert, montre à portée. À lire en entier avant le premier envoi : la
|
||||
première tentative est celle qui peut bloquer la montre.
|
||||
|
||||
## Pourquoi ce chemin, et pas un autre
|
||||
|
||||
Contrainte posée le 2026-08-31 : **`coach → la montre`, sans intermédiaire.**
|
||||
Pas de TrainingPeaks, pas de Polar Flow, pas d'Intervals.icu. Ça élimine toutes
|
||||
les autres voies, qui passent toutes par un tiers. Reste l'écriture directe du
|
||||
fichier d'objectif sur la montre par PFTP — prouvée sur la Vantage V3 le
|
||||
2026-08-17, en USB. Le Bluetooth est ce qui la rend utilisable.
|
||||
|
||||
Le détail de ce qui a été mesuré sur la montre est dans le dépôt `coach_sportif`,
|
||||
`tools/polar/README.md`. Il fait foi.
|
||||
|
||||
## 1. Ajouter le SDK Polar au projet
|
||||
|
||||
⚠️ **Pas dans `ios/App/CapApp-SPM/Package.swift`** : ce fichier porte
|
||||
« DO NOT MODIFY — managed by Capacitor CLI » et sera réécrit. Ajouter le paquet
|
||||
au `.xcodeproj` :
|
||||
|
||||
Xcode → File → Add Package Dependencies…
|
||||
https://github.com/polarofficial/polar-ble-sdk
|
||||
→ produit « PolarBleSdk », cible « App »
|
||||
|
||||
Tant que le paquet n'est pas là, `CoachPolarBLE.isAvailable()` répond
|
||||
`{available: false, reason: "PolarBleSdk absent de la cible"}` — le fichier
|
||||
compile quand même, tout le code radio est derrière `#if canImport(PolarBleSdk)`.
|
||||
|
||||
## 2. Target Membership
|
||||
|
||||
Deux fichiers, cible **App** :
|
||||
|
||||
- `ios/App/App/CoachPolarBLE.swift` — le plugin
|
||||
- `ios/App/App/PolarPftpStep.swift` — les garde-fous et l'en-tête PFTP
|
||||
|
||||
Le second n'importe que Foundation : il est aussi lié dans
|
||||
`tests-linux/Sources/CoachModel/` et couvert par 12 tests qui tournent sur
|
||||
Linux (`./tests-linux/run.sh`).
|
||||
|
||||
## 3. Permission Bluetooth
|
||||
|
||||
`NSBluetoothAlwaysUsageDescription` est dans `Info.plist`, en français.
|
||||
|
||||
⚠️ **La clé ne suffit pas à déclencher la demande.** iOS ne sollicite
|
||||
l'autorisation qu'à la création d'un `CBCentralManager`. Or
|
||||
`CBDeviceListenerImpl.search()` commence par
|
||||
`monitorBleState().filter { $0 == .poweredOn }` : sans manager, cet état
|
||||
n'arrive jamais, le flux n'émet rien, le scan ne démarre pas — et le timeout
|
||||
conclut « aucun appareil ». Mesuré le 31/08 : 30 s de scan, aucune alerte, et
|
||||
« Bluetooth » absent de Réglages → coach (seuls Position, Caméra, Mouvement,
|
||||
Actualisation, Données cellulaires y figuraient).
|
||||
|
||||
C'est pourquoi `PolarPsFtpWriter` commence par une `SondeBluetooth` : elle crée
|
||||
le manager, attend son premier état, et traduit ce qu'il dit — autorisation
|
||||
refusée, Bluetooth éteint, matériel absent. **Au premier lancement après ce
|
||||
correctif, iOS affichera l'alerte d'autorisation : l'accepter.**
|
||||
|
||||
## 4. Le premier envoi — en `dryRun`, toujours
|
||||
|
||||
Depuis la console Safari attachée à la WebView :
|
||||
|
||||
```js
|
||||
const r = await fetch('/api/plan/polar-target?date=2026-08-31&hour=18')
|
||||
const plan = await r.json()
|
||||
await window.Capacitor.Plugins.CoachPolarBLE.send({ ...plan, dryRun: true })
|
||||
```
|
||||
|
||||
Attendu :
|
||||
|
||||
```
|
||||
{ sent: false, dryRun: true, log: [
|
||||
"mkdir /U/0/20260831/TST/ (0 o)",
|
||||
"mkdir /U/0/20260831/TST/180000/ (0 o)",
|
||||
"put /U/0/20260831/TST/180000/TST.BPB (240 o)",
|
||||
"put /U/0/20260831/TST/180000/ID.BPB (51 o)" ] }
|
||||
```
|
||||
|
||||
**Lire ce journal avant d'ôter `dryRun`.** Un `mkdir` avec des octets, ou un
|
||||
`put` vers un chemin terminé par « / », est la requête exacte qui a bloqué la
|
||||
montre le 2026-08-17 — le plugin la refuse, mais la voir refusée vaut mieux que
|
||||
de la découvrir en radio.
|
||||
|
||||
## 5. L'envoi réel
|
||||
|
||||
Retirer `dryRun`. Fermer l'app Polar Flow d'abord : **le canal BLE ne se
|
||||
partage pas**, et une synchro Flow en cours empêchera la connexion.
|
||||
|
||||
En cas d'échec, le message distingue **trois causes opposées** — la distinction
|
||||
a été ajoutée le 31/08 après un premier essai où un message unique ne permettait
|
||||
pas de conclure :
|
||||
|
||||
| Message | Cause probable | Geste |
|
||||
|---|---|---|
|
||||
| « aucun appareil Bluetooth détecté » | Bluetooth éteint, **autorisation refusée à coach**, montre hors de portée | Réglages → coach → Bluetooth |
|
||||
| « N appareil(s) vu(s), M portant PsFTP mais sans réponse » | canal déjà occupé | fermer **complètement** Polar Flow |
|
||||
| « N appareil(s) vu(s), aucun n'expose PsFTP » | la V3 n'annonce pas le service tant qu'elle est appairée à Flow | c'est l'inconnue de fond |
|
||||
|
||||
⚠️ Le scan se fait par **fenêtres successives** de 4 s jusqu'au timeout, et non
|
||||
en une passe : CoreBluetooth peut n'être pas encore `poweredOn` au premier
|
||||
appel, et une fenêtre unique conclurait à tort qu'aucun appareil n'existe.
|
||||
|
||||
## L'heure de l'objectif — ce qu'on sait, et ce qu'on ne sait pas
|
||||
|
||||
Un objectif est rangé sous `/U/0/<AAAAMMJJ>/TST/<HHMMSS>/`, et le `start_time`
|
||||
écrit dans le fichier doit concorder avec ce dossier : c'est le couple qui situe
|
||||
la séance dans la journée. `build_session.py --hour` et le paramètre `hour` des
|
||||
routes produisent les deux ensemble, il n'y a donc rien à accorder à la main.
|
||||
|
||||
⚠️ **En revanche, on ignore si la montre conditionne l'affichage à cette
|
||||
heure.** Propose-t-elle l'objectif toute la journée, ou seulement à son
|
||||
approche ? Le test du 2026-08-17 ne l'a pas mesuré. À observer au premier envoi
|
||||
réussi : écrire un objectif daté 18 h et regarder s'il apparaît immédiatement.
|
||||
|
||||
- S'il apparaît tout de suite → l'heure n'est qu'une étiquette, en fixer une par
|
||||
défaut suffit.
|
||||
- S'il n'apparaît qu'à l'approche → il faut écrire à l'heure du départ. Sans
|
||||
conséquence pratique : l'envoi se fait de toute façon juste avant de sortir.
|
||||
|
||||
## 🔴 Où ça bloque au 31/08 : le scan du SDK ne démarre pas
|
||||
|
||||
Mesuré, pas supposé : **43 appareils vus par un scan CoreBluetooth nu, 0 remonté
|
||||
par le SDK Polar.** La radio, l'autorisation et la montre sont donc hors de
|
||||
cause — le problème est dans notre usage du SDK.
|
||||
|
||||
Ce qui a été écarté **sur les sources**, pour ne pas le refaire :
|
||||
|
||||
- `servicesToScanFor` à `nil` : c'est aussi ce que fait `PolarBleApiImpl`.
|
||||
- `scanPreFilter` à `nil` : **accepte** les appareils. La condition est
|
||||
`if self.session(peripheral) == nil, let filter = self.scanPreFilter` — sans
|
||||
filtre, le `if let` échoue et le rejet est sauté.
|
||||
- `addClient()` suffit à lancer le scan : `scanningNeeded()` rend vrai dès que
|
||||
`clientCount != 0`, aucune action « admin » n'est requise.
|
||||
|
||||
**CAUSE RACINE TROUVÉE** — confirmée par l'instrumentation : le publisher de
|
||||
`search()` n'émettait **rien du tout**, ni valeur, ni complétion, ni erreur.
|
||||
|
||||
`CBDeviceListenerImpl.manager` est une **`lazy var`** : le `CBCentralManager`
|
||||
n'existe qu'au premier accès.
|
||||
|
||||
```swift
|
||||
fileprivate lazy var manager: SDKCBCentralManager = { … }()
|
||||
```
|
||||
|
||||
Or `search()` commence par `monitorBleState().filter { $0 == .poweredOn }`, et
|
||||
`monitorBleState()` se contente de rendre `bleStateSubject` — il ne touche
|
||||
jamais `manager`. Les seuls accès du chemin de recherche
|
||||
(`retrieveConnectedPeripherals`, `retrievePeripherals`) sont **à l'intérieur du
|
||||
`flatMap`**, donc après le filtre. Le manager n'était donc jamais instancié,
|
||||
`centralManagerDidUpdateState` jamais appelé, le sujet jamais alimenté : le
|
||||
filtre bloquait pour toujours.
|
||||
|
||||
**Deuxième cause, découverte juste après** : sans `scanPreFilter`, le listener
|
||||
remonte **tous** les appareils BLE alentour — 43 mesurés. Le code ouvrait alors
|
||||
une session sur chacun à tour de rôle, dont l'Apple Watch
|
||||
(`API MISUSE: … name = Apple Watch de Sylvain … can only accept commands while
|
||||
in the connected state`), à 12 s d'échéance chacun : l'envoi paraissait ne
|
||||
jamais finir. Le SDK officiel pose ce filtre (`deviceFilter` dans
|
||||
`PolarBleApiImpl`) ; ne pas le poser était l'omission.
|
||||
|
||||
**Correctif** : appeler `listener.blePowered()` — `return manager.state ==
|
||||
.poweredOn`, le seul accès public exécuté immédiatement — **après** s'être
|
||||
abonné à `search()`. L'ordre est critique : si `bleStateSubject` est un
|
||||
`PassthroughSubject`, un état émis avant l'abonnement serait perdu et on
|
||||
retomberait sur le même silence.
|
||||
|
||||
## Le démarrage du SDK — deux détails qui décident de tout
|
||||
|
||||
Lus dans les sources, après plusieurs essais infructueux le 31/08 :
|
||||
|
||||
**1. `bleStateSubject` est un `CurrentValueSubject<BleState, Never>(.unknown)`**,
|
||||
pas un PassthroughSubject. Il **rejoue sa valeur à chaque abonnement** : il est
|
||||
donc inutile d'être abonné au moment de l'émission. Un correctif intermédiaire
|
||||
réveillait le manager *après* s'être abonné « pour ne pas rater l'événement » —
|
||||
raisonnement juste pour un PassthroughSubject, faux ici, et qui exposait au
|
||||
piège suivant.
|
||||
|
||||
**2. `centralManagerDidUpdateState` fait
|
||||
`BleState(rawValue: self.manager.state.rawValue)`** — il accède à la `lazy var`
|
||||
depuis le délégué. Si CoreBluetooth appelle le délégué avant que l'initialisation
|
||||
de la lazy soit terminée, la propriété se réentre.
|
||||
|
||||
**3. `blePowered()` et `search()` ne lisent pas la même chose.** `blePowered()`
|
||||
rend `manager.state == .poweredOn` — l'état de CoreBluetooth. `search()` filtre
|
||||
sur `bleStateSubject`, qui n'est alimenté que par `centralManagerDidUpdateState`.
|
||||
Le manager peut donc être allumé sans que le délégué ait publié quoi que ce
|
||||
soit : le sujet reste à `.unknown` et le filtre bloque. Attendre `blePowered()`
|
||||
ne prouve rien sur ce que `search()` verra.
|
||||
|
||||
⇒ **Séquence correcte** : créer le listener, poser `scanPreFilter`, appeler
|
||||
`blePowered()` **une fois** (son seul rôle : instancier la lazy pour que le
|
||||
délégué puisse tourner), puis attendre sur **`monitorBleState()`** — public, et
|
||||
c'est la source que `search()` lit — jusqu'à `.poweredOn`. Seulement ensuite,
|
||||
`search()`, une fois, sur toute la durée. Un abonnement par tranche relancerait le cycle
|
||||
`addClient()`/`removeClient()` du scanner et l'empêcherait de découvrir quoi que
|
||||
ce soit.
|
||||
|
||||
**Ce qui a été corrigé et vérifié en chemin** (ne pas refaire) :
|
||||
|
||||
| Cause | Preuve |
|
||||
|---|---|
|
||||
| Autorisation Bluetooth jamais demandée | pas de ligne « Bluetooth » dans Réglages → coach |
|
||||
| Manager `lazy` jamais réveillé | publisher muet ; `blePowered()` le crée |
|
||||
| Aucun filtre de scan | 43 appareils, sessions ouvertes jusque sur l'Apple Watch |
|
||||
| Attentes sans limite de temps | envoi figé sans message |
|
||||
| Fenêtres de scan successives | `bleStateSubject` ne publie que sur changement |
|
||||
|
||||
**⚠️ Ce que ça enseigne** : instancier `CBDeviceListenerImpl` soi-même revient à
|
||||
réimplémenter ce que `PolarBleApiImpl` fait, sans en voir le détail. On avance
|
||||
d'un cran à chaque essai, et chaque essai coûte un cycle Xcode.
|
||||
|
||||
### Piste pour la reprise : passer par l'API publique
|
||||
|
||||
Plutôt que de piloter le listener à la main, utiliser
|
||||
`PolarBleApiDefaultImpl.polarImplementation(…)` — le chemin que Polar supporte —
|
||||
pour la découverte et la connexion (`searchForDevice()`, `connectToDevice(_:)`),
|
||||
puis n'atteindre `BlePsFtpClient` qu'une fois la session établie par le SDK.
|
||||
|
||||
⚠️ À vérifier d'abord, et c'est le point bloquant de cette piste :
|
||||
`PolarBleApiImpl` garde son `listener` privé. Il faut donc chercher si une API
|
||||
publique donne accès à la session ou au client PsFTP — sinon cette voie est
|
||||
fermée elle aussi, et il faudra soit forker le SDK, soit renoncer au Bluetooth
|
||||
et garder le câble.
|
||||
|
||||
**Le câble, lui, fonctionne intégralement** : `tools/polar/README.md` du dépôt
|
||||
`coach_sportif`. Rien de ce qui a été livré côté serveur n'est perdu.
|
||||
|
||||
## 🔑 La montre ne s'annonce pas : elle est déjà connectée
|
||||
|
||||
Mesuré le 31/08 : **4020 advertisements vus** (HomePod, Furbo…), **aucune
|
||||
Polar**. Le scan fonctionne parfaitement — la Vantage ne diffuse simplement pas.
|
||||
|
||||
C'est le comportement normal du BLE : **un appareil connecté cesse d'émettre**.
|
||||
La montre est liée à l'iPhone par l'app Flow, donc elle est invisible au scan,
|
||||
par conception. L'attendre était sans espoir, et tous les correctifs de scan qui
|
||||
ont précédé ne pouvaient rien y changer.
|
||||
|
||||
`search()` prévoit ce cas, mais seulement quand on lui passe des UUID :
|
||||
|
||||
```swift
|
||||
foundPeripherals = self.manager.retrieveConnectedPeripherals(withServices: uuids!)
|
||||
```
|
||||
|
||||
⇒ **Appeler `search([BlePsFtpClient.PSFTP_SERVICE], identifiers: nil,
|
||||
fetchKnownDevices: true)`**, et non `search(nil, …)` : c'est cette branche qui
|
||||
rend les périphériques déjà connectés exposant PsFTP et leur fabrique une
|
||||
session.
|
||||
|
||||
⚠️ Dédupliquer aussi les sessions vues : `AllowDuplicates` est armé côté SDK, le
|
||||
même appareil revient à chaque advertisement.
|
||||
|
||||
## ⛔ RÉPONSE DE FOND (31/08) : la V3 refuse la connexion tierce
|
||||
|
||||
La question ouverte depuis le 17/08 est tranchée, par la mesure.
|
||||
|
||||
Une fois la montre cherchée au bon endroit (`retrieveConnectedPeripherals`), le
|
||||
plugin **l'atteint** : « 12 appareils vus, **1 portant PsFTP** ». Le service FEEE
|
||||
est donc bien exposé et `fetchGattClient` rend un `BlePsFtpClient`.
|
||||
|
||||
Mais `waitPsFtpReady` n'aboutit pas, et **la montre affiche « connexion
|
||||
impossible »** pendant que les réglages iOS la disent toujours connectée à Flow.
|
||||
Elle refuse donc la seconde session.
|
||||
|
||||
⇒ **Le canal PsFTP de la Vantage V3 n'est pas partageable.** Tant qu'elle est
|
||||
liée à l'app Flow, aucune app tierce ne peut ouvrir de session — ce qui est
|
||||
cohérent avec l'USB, où il fallait déjà fermer Flow.
|
||||
|
||||
⚠️ **Ce qui n'a PAS été tenté, et qui est la dernière porte** : dissocier la
|
||||
montre de Flow (oublier l'appareil côté iOS) pour voir si elle accepte alors
|
||||
notre connexion. Non tenté délibérément — cela reviendrait à sacrifier la
|
||||
synchronisation quotidienne, qui alimente coach en données, pour un confort
|
||||
d'envoi. Le rapport n'y est pas.
|
||||
|
||||
⚠️ Après un essai de connexion refusé, **redémarrer la montre** (appui long sur
|
||||
OK) pour libérer son canal, puis rouvrir Flow.
|
||||
|
||||
## Ce qui reste inconnu
|
||||
|
||||
1. ~~La V3 accepte-t-elle une connexion BLE tierce ?~~ **Non** — mesuré le
|
||||
31/08, voir ci-dessus.
|
||||
2. **Le filtrage de la montre** se fait sur « expose le service PsFTP (FEEE) »,
|
||||
pas sur le nom : ce que la V3 met dans son advertisement n'a pas été observé,
|
||||
et s'appuyer dessus aurait été une supposition. À resserrer une fois vu. Un
|
||||
capteur H10 s'écarte de lui-même, il ne porte pas PsFTP.
|
||||
3. **Le firmware plante sur requête malformée.** Les garde-fous couvrent les
|
||||
formes connues ; ils ne prouvent pas qu'il n'en existe pas d'autres.
|
||||
|
||||
## 🟠 CE QUE L'ESSAI BLE A COÛTÉ (31/08) — corrigé le 01/09
|
||||
|
||||
⚠️ **Cette section a d'abord été écrite plus alarmante que les faits.** Sylvain
|
||||
l'a corrigée le 01/09 : **la réinitialisation était de son propre chef**, pas une
|
||||
nécessité constatée. Distinguer ce qui a été observé de ce qui en a été déduit.
|
||||
|
||||
**Observé** — après la tentative de connexion Bluetooth refusée par la montre
|
||||
(« connexion impossible » à l'écran), **son interface USB ne répondait plus** :
|
||||
plus aucun `/dev/cu.usbmodem*`, et `system_profiler` ne listait plus rien —
|
||||
alors que la montre chargeait normalement, et que c'était le même câble, le même
|
||||
port et la même montre qu'une heure plus tôt.
|
||||
|
||||
**Déduit à tort** — « les redémarrages n'ont rien changé, il a *fallu* une
|
||||
réinitialisation ». La réinitialisation a été **choisie**, pas imposée : aucune
|
||||
autre voie n'a été épuisée avant. On ne sait donc pas si l'USB serait revenu
|
||||
seul, après un délai, un autre port ou un autre câble.
|
||||
|
||||
⇒ **Ce qui reste établi** : un essai BLE refusé peut laisser l'USB muet dans la
|
||||
foulée. ⇒ **Ce qui ne l'est pas** : que ce soit durable, ni qu'une
|
||||
réinitialisation soit le remède. Si le cas se reproduit, épuiser d'abord les
|
||||
gestes réversibles — débrancher/rebrancher, changer de port et de câble,
|
||||
redémarrer la montre, laisser reposer — et **noter lequel a marché**. C'est
|
||||
cette mesure qui manque.
|
||||
|
||||
⚠️ **Conséquence à assumer, dans sa juste mesure** : une tentative de connexion
|
||||
PsFTP refusée peut laisser l'USB muet — observé une fois. Que le firmware
|
||||
« dégrade durablement son état » était une extrapolation depuis cette
|
||||
observation unique : **retirée**. Le plantage du 17/08 sur requête malformée,
|
||||
lui, reste établi et documenté plus haut.
|
||||
|
||||
⇒ **Ne pas multiplier les essais sans raison**, et prévoir que le câble puisse
|
||||
ne pas répondre juste après. Écrire la séance PAR CÂBLE D'ABORD quand c'est
|
||||
possible — non parce que la casse est certaine, mais parce qu'un aller-retour de
|
||||
diagnostic avant une sortie coûte plus que deux minutes d'anticipation.
|
||||
|
||||
## Si la montre se bloque
|
||||
|
||||
Logo Polar puis écran noir. **Appui long sur OK, 10-15 s.** À défaut,
|
||||
BACK + DOWN pendant 10 s. En dernier recours, réinitialisation d'usine par
|
||||
FlowSync — les données déjà synchronisées dans Flow sont préservées.
|
||||
|
||||
## Après l'envoi
|
||||
|
||||
L'objectif est **éphémère** : une synchro Flow supprime ce qui est déposé à une
|
||||
date qu'elle ignore (mesuré). C'est pour ça que l'envoi se fait juste avant de
|
||||
sortir, et c'est pour ça que le Bluetooth était la condition — brancher la
|
||||
montre au Mac cinq minutes avant de courir n'était pas un usage.
|
||||
|
||||
⚠️ Les cibles écrites sont des **numéros de zone**, pas des battements : la
|
||||
montre les exécute selon les zones réglées dans Flow. Vérifiées alignées le
|
||||
2026-08-17, à 1 bpm près sur la frontière Z2/Z3. La réponse du serveur porte les
|
||||
bornes de `current_zones()` pour ce contrôle — les afficher avant l'envoi.
|
||||
|
||||
## 2026-09-01 — Après réinitialisation, le BLE marche. Et ANCS n'est pas la voie.
|
||||
|
||||
### Fait neuf : l'écriture BLE a abouti
|
||||
|
||||
Rapporté par Sylvain : **après la réinitialisation complète de la montre** (celle
|
||||
qu'a imposée l'incident USB), l'envoi de l'exercice par Bluetooth **a
|
||||
fonctionné**. Il ajoute lui-même : « ce n'est pas viable ».
|
||||
|
||||
Ça confirme le diagnostic du 31/08 plutôt que de l'infirmer : le refus ne vient
|
||||
pas d'une interdiction des apps tierces, mais de **l'occupation du canal PsFTP
|
||||
par Flow**. Montre neuve, pas encore liée à Flow ⇒ le canal est libre ⇒ notre
|
||||
session s'ouvre. Dès que Flow reprend la main, elle se referme.
|
||||
|
||||
⇒ **Le prix d'accès au BLE reste le même** : ne pas avoir Flow. Et depuis le
|
||||
01/09 ce prix a augmenté — la Polar est devenue la montre de sport, donc Flow
|
||||
est le seul canal par lequel les séances entrent dans HealthKit puis dans coach.
|
||||
|
||||
### La question de Sylvain : « et le protocole des notifications iOS ? »
|
||||
|
||||
Bonne intuition — les notifications iOS arrivent bien à la montre **pendant que
|
||||
Flow est connecté**. Mais le canal ne peut pas porter une séance, et la raison
|
||||
est structurelle.
|
||||
|
||||
**ANCS — Apple Notification Center Service** ([spec Apple][ancs]) :
|
||||
|
||||
- **Les rôles sont inversés par rapport à PsFTP.** « The publisher of the ANCS
|
||||
service (**the iOS device**) shall be referred to as the *Notification
|
||||
Provider* » ; « any client of the ANCS service (**an accessory**) shall be
|
||||
referred to as a *Notification Consumer* ». C'est l'**iPhone** qui est
|
||||
serveur, la montre qui est cliente — d'où l'absence de conflit avec Flow : la
|
||||
montre ne cède rien, elle consomme.
|
||||
- **Trois caractéristiques, et rien d'autre** : Notification Source
|
||||
(`9FBF120D-…`), Control Point (`69D1D8F3-…`), Data Source (`22EAC6E9-…`).
|
||||
- **Aucun transport de binaire.** La spec ne décrit aucun mécanisme de transfert
|
||||
de fichier ni de payload applicatif : uniquement des métadonnées de
|
||||
notification et des commandes d'action prédéfinies.
|
||||
|
||||
⇒ **On ne fera pas passer un `.BPB` par ANCS.** Ce n'est pas une limite de notre
|
||||
implémentation, c'est ce que le protocole est.
|
||||
|
||||
### Ce qu'ANCS permet quand même, et qui n'est pas rien
|
||||
|
||||
ANCS transporte du **texte affiché au poignet**, sans câble, sans dissocier, et
|
||||
**sans toucher à Flow**. Une notification locale iOS émise par coach est relayée
|
||||
par ANCS et s'affiche sur la montre. Le canal existe déjà côté projet :
|
||||
`@capacitor/local-notifications` est installé, et `web/static/local-notifications.js`
|
||||
est chargé par `_layout.html` — **aucun build Xcode nécessaire**.
|
||||
|
||||
⚠️ Ce que ça ne fait PAS, et il faut le dire avant de le construire : pas
|
||||
d'objectif structuré, **pas d'alerte de zone**, pas de guidage par étape, pas de
|
||||
comparaison à l'exécution. C'est un **pense-bête au poignet**, pas un
|
||||
remplacement du `.BPB`. La longueur réellement affichable par la V3 n'est pas
|
||||
documentée et devra être mesurée.
|
||||
|
||||
⇒ Le seul chemin qui écrit un vrai objectif **en préservant Flow** reste le
|
||||
**câble**.
|
||||
|
||||
[ancs]: https://developer.apple.com/library/archive/documentation/CoreBluetooth/Reference/AppleNotificationCenterServiceSpecification/Specification/Specification.html
|
||||
|
||||
### La séquence du 01/09, et ce qu'elle apprend sur la fenêtre
|
||||
|
||||
Trois envois consécutifs, trois résultats différents — c'est la chronologie qui
|
||||
explique tout :
|
||||
|
||||
| # | résultat | lecture |
|
||||
|---|---|---|
|
||||
| 1 | **réussi** | montre fraîchement réinitialisée, **pas encore liée à Flow** : canal libre |
|
||||
| 2 | **errorcode 104** `DIRECTORY_EXISTS` | canal **toujours** libre, la session s'ouvre — seul le `mkdir` bute sur un dossier déjà créé au 1ᵉʳ envoi |
|
||||
| 3 | **« canal probablement occupé »** | Flow a repris la main entre-temps |
|
||||
|
||||
⇒ Le canal n'est pas ouvert ou fermé « en général » : il y a une **fenêtre**,
|
||||
qui commence quand la montre n'est liée à personne et se referme dès que Flow
|
||||
se reconnecte. C'est cohérent avec le 31/08 et avec l'USB, où il faut fermer
|
||||
Flow.
|
||||
|
||||
⚠️ **Le message d'erreur du plugin conseillait « fermer complètement l'app Polar
|
||||
Flow ». C'est faux, et ça l'était déjà le 31/08** : la montre affichait
|
||||
« connexion impossible » pendant que les réglages iOS la disaient **toujours
|
||||
connectée** à Flow. iOS maintient le lien d'un accessoire appairé, app fermée ou
|
||||
non. Message corrigé le 01/09 — il nomme désormais le geste qui libère
|
||||
réellement le canal (redémarrer la montre) et rappelle que le câble est le
|
||||
chemin fiable.
|
||||
|
||||
**Hypothèse ouverte, non vérifiée** : redémarrer la montre puis écrire *dans la
|
||||
foulée*, avant que Flow ne se reconnecte, pourrait suffire. Ça expliquerait les
|
||||
envois 1 et 2. Ce n'est pas une méthode tant que ça n'a pas été reproduit
|
||||
plusieurs fois — et ⛔ chaque essai a un coût matériel avéré (l'USB désactivé le
|
||||
31/08). **Écrire la séance par câble AVANT tout essai.**
|
||||
|
||||
## ⛔ Déconnecter Flow automatiquement : iOS l'interdit (01/09)
|
||||
|
||||
Question de Sylvain : peut-on déconnecter Polar Flow avant chaque envoi, sans
|
||||
geste manuel ? **Non, et ce n'est pas contournable** — c'est une garantie
|
||||
d'isolation entre apps, pas une lacune de notre code.
|
||||
|
||||
Apple, `cancelPeripheralConnection(_:)` :
|
||||
|
||||
> « Because other apps may still have a connection to the peripheral, canceling
|
||||
> a local connection **does not guarantee that the underlying physical link is
|
||||
> immediately disconnected**. »
|
||||
|
||||
Une app n'annule que **sa propre** connexion. Rien dans CoreBluetooth ni dans le
|
||||
SDK Polar ne permet de rompre celle d'une autre app, et une app ne peut pas non
|
||||
plus couper le Bluetooth du système. Le `disconnectFromDevice` du SDK ne ferme
|
||||
que notre session.
|
||||
|
||||
### Le geste manuel le plus court : le mode avion de la montre
|
||||
|
||||
Plus rapide qu'un redémarrage, et documenté par Polar
|
||||
([Vantage V3, Réglages rapides][qs]) : « Le mode avion coupe toute communication
|
||||
sans fil sur votre montre […] vous ne pouvez pas synchroniser vos données avec
|
||||
l'application Polar Flow ».
|
||||
|
||||
**Mode avion ON → OFF**, puis envoyer **immédiatement**. Le ON coupe Flow ; le
|
||||
OFF rouvre une fenêtre pendant laquelle le canal PsFTP est libre, avant que Flow
|
||||
ne se reconnecte. ⚠️ Pendant le ON, notre app ne peut pas se connecter non plus :
|
||||
tout le sans-fil est coupé, c'est bien un ON *puis* OFF.
|
||||
|
||||
⚠️ **Cette course n'a pas été reproduite plusieurs fois** — c'est une hypothèse
|
||||
cohérente avec les envois réussis du 01/09, pas une méthode établie.
|
||||
|
||||
### Pourquoi il n'y a pas de bouton « Réessayer » dans l'UI
|
||||
|
||||
⚠️ **Écrit sur une prémisse trop forte, corrigée le 01/09.** L'argument était
|
||||
« chaque tentative refusée a un coût matériel avéré — celle du 31/08 a désactivé
|
||||
l'USB jusqu'à une réinitialisation ». Or la réinitialisation était **un choix de
|
||||
Sylvain**, pas une nécessité constatée. Ce qui reste : l'USB a été muet après un
|
||||
essai, une fois, et personne n'a cherché s'il serait revenu seul.
|
||||
|
||||
Le bouton n'est donc pas refusé sur un risque « avéré ». Il reste absent parce
|
||||
que **la voie BLE elle-même n'est pas viable** (course contre Flow, geste manuel
|
||||
sur la montre à chaque envoi) : outiller la répétition d'un chemin qu'on
|
||||
n'emprunte pas serait du travail à contre-emploi. **C'est à Sylvain de trancher
|
||||
s'il veut ce bouton** — la décision lui revient, et l'argument matériel ne doit
|
||||
plus peser plus qu'il ne vaut.
|
||||
|
||||
[qs]: https://support.polar.com/e_manuals/vantage-v3/polar-vantage-v3-user-manual-francais/quick-settings.htm
|
||||
107
docs/siri-et-widget-watch-runbook-mac.md
Normal file
107
docs/siri-et-widget-watch-runbook-mac.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Runbook Mac — Siri + widget Hydratation de la montre
|
||||
|
||||
**Écrit le 25/09/2026, depuis Linux.** Le code Swift est committé ; il reste les
|
||||
étapes Xcode, qui ne peuvent pas se faire d'ici.
|
||||
|
||||
> ⚠️ **Nom des cibles, vérifié le 25/09** : la cible watchOS s'appelle
|
||||
> **`CoachWatchWidgetsExtension`** — le DOSSIER, lui, s'appelle
|
||||
> `CoachWatchWidgets`. Ce runbook disait d'abord le nom du dossier ; corrigé.
|
||||
> Les quatre cibles réelles : `App`, `CoachLiveActivity`, `CoachWatch`,
|
||||
> `CoachWatchWidgetsExtension`.
|
||||
|
||||
> ⛔ **Rien de ce code n'a été compilé.** `AppIntents`, `WidgetKit` et `SwiftUI`
|
||||
> sont des frameworks Apple, absents de Swift pour Linux — le CLAUDE.md de ce
|
||||
> dépôt le dit. Ce qui a été vérifié à la place, par lecture croisée des
|
||||
> sources : que chaque symbole appelé existe (`CoachQuickLog.append`,
|
||||
> `todayTotal`, `pending`, `CoachWidgetStore.load`, `nextMidnight`,
|
||||
> `fallbackQuickDrinks`, `resolvedQuickDrinks`, `waterMlToday`, `waterGoalMl`,
|
||||
> `asOf`), et qu'aucun symbole n'est déclaré deux fois. **Les erreurs de type,
|
||||
> de concurrence et d'API n'apparaîtront qu'au build.**
|
||||
|
||||
## Ce que ça apporte
|
||||
|
||||
| | Avant | Après |
|
||||
|---|---|---|
|
||||
| Siri | rien — aucun `AppShortcutsProvider` | 5 raccourcis, 16 phrases |
|
||||
| Montre | complications d'affichage | widget Smart Stack qui **note un verre** |
|
||||
| Intents | 1, enfermé dans l'extension iPhone | 3, partagés entre 3 cibles |
|
||||
|
||||
## Fichiers ajoutés / modifiés
|
||||
|
||||
- **`ios/App/App/CoachIntents.swift`** *(nouveau)* — `LogDrinkIntent` (déplacé),
|
||||
`HydrationStatusIntent`, `TodaySessionIntent`, `CoachQuickError`.
|
||||
- **`ios/App/App/CoachAppShortcuts.swift`** *(nouveau)* — les phrases Siri.
|
||||
- **`ios/App/CoachWatchWidgets/CoachWatchHydration.swift`** *(nouveau)* — widget
|
||||
Smart Stack de la montre.
|
||||
- `ios/App/CoachLiveActivity/CoachQuickWidget.swift` — l'intent en a été
|
||||
**retiré** (il vivait là, invisible des deux autres cibles).
|
||||
- `ios/App/CoachWatchWidgets/CoachWatchComplications.swift` — le widget rejoint
|
||||
le `WidgetBundle`.
|
||||
|
||||
## Étapes Xcode
|
||||
|
||||
⚠️ **Quitter Xcode puis `git pull` d'abord** (règle COWORK : jamais de pull
|
||||
Xcode ouvert).
|
||||
|
||||
### 1. Target Membership — c'est l'étape qui décide de tout
|
||||
|
||||
| Fichier | App | CoachLiveActivity | CoachWatchWidgetsExtension |
|
||||
|---|:--:|:--:|:--:|
|
||||
| `CoachIntents.swift` | ✅ | ✅ | ✅ |
|
||||
| `CoachAppShortcuts.swift` | ✅ | ❌ | ❌ |
|
||||
| `CoachWatchHydration.swift` | ❌ | ❌ | ✅ |
|
||||
| `CoachQuickLog.swift` | ✅ | ✅ | ✅ *(à vérifier)* |
|
||||
| `CoachWidgetSnapshot.swift` | ✅ | ✅ | ✅ *(déjà le cas)* |
|
||||
|
||||
⚠️ **`CoachAppShortcuts.swift` dans la cible App UNIQUEMENT.** Apple attend un
|
||||
seul `AppShortcutsProvider` par application ; un second dans une extension
|
||||
produirait au mieux un doublon.
|
||||
|
||||
⚠️ **`CoachQuickLog.swift` doit être dans `CoachWatchWidgetsExtension`**, sinon le widget
|
||||
de la montre compile mais n'écrit nulle part. Son en-tête le réclamait déjà.
|
||||
|
||||
### 2. Vérifier l'App Group sur la cible Watch
|
||||
|
||||
`group.ch.hypnotruck.coach` sur **CoachWatch** ET **CoachWatchWidgetsExtension**.
|
||||
Vérifié depuis Linux le 25/09 : les quatre `.entitlements` le portent et les
|
||||
`CODE_SIGN_ENTITLEMENTS` sont câblés. À reconfirmer dans Signing & Capabilities.
|
||||
|
||||
### 3. Build, puis les vérifications qui comptent
|
||||
|
||||
1. **Siri** — dire « Note un verre d'eau dans Coach ». Attendu : « Noté.
|
||||
X,XX L d'eau aujourd'hui », **sans que l'app s'ouvre**.
|
||||
2. **Raccourcis** — l'app *Raccourcis* doit lister les 5 actions sous « Coach ».
|
||||
3. **Smart Stack** — ajouter le widget Hydratation, toucher « +25 cl », vérifier
|
||||
que le total monte **et** que le badge des saisies en attente s'incrémente.
|
||||
4. **Synchro** — ouvrir l'app iPhone, vérifier que la saisie faite au poignet
|
||||
remonte sur `coach.hypnotruck.ch`.
|
||||
5. **Minuit** — le total doit repartir de zéro (c'est `asOf(_:)` qui l'assure,
|
||||
pas le réveil de la timeline : le piège a déjà été payé côté iPhone).
|
||||
|
||||
## Les trois questions que seul le build tranchera
|
||||
|
||||
1. **Une complication de CADRAN accepte-t-elle un bouton d'App Intent, ou est-ce
|
||||
réservé à la Smart Stack ?** La doc Apple n'était pas récupérable depuis
|
||||
Linux. Par prudence, `.accessoryCircular` n'est ici qu'un **affichage** ; si
|
||||
le cadran accepte l'interactivité, on pourra l'enrichir.
|
||||
2. **Les phrases Siri passent-elles la validation ?** Chaque phrase contient
|
||||
`\(.applicationName)`, comme Apple l'exige, et le nom supposé est « Coach »
|
||||
(`CFBundleDisplayName`). Si le nom affiché diffère, les phrases ne se
|
||||
déclencheront pas — c'est silencieux.
|
||||
3. **`ProvidesDialog` sur watchOS.** Les intents rendent
|
||||
`some IntentResult & ProvidesDialog` pour que Siri réponde à voix haute. À
|
||||
confirmer que la cible watchOS l'accepte sans ajustement.
|
||||
|
||||
## Ce qui a été volontairement écarté
|
||||
|
||||
- ⛔ **Saisie des repas depuis la montre.** Pas de caméra, pas de scanner de
|
||||
code-barres, pas de saisie de texte confortable : un bouton « repas » au
|
||||
poignet ne ferait que renvoyer vers l'iPhone. Décidé avec Sylvain le 25/09.
|
||||
- ⛔ **Un paramètre libre de volume dans les phrases Siri.** Un intent
|
||||
paramétré oblige Siri à poser une question — ce qui coûte plus de temps que
|
||||
de sortir le téléphone, et vide le raccourci de son intérêt. Les volumes sont
|
||||
donc fixés (250 ml, 500 ml, café 60 ml).
|
||||
- ⛔ **Toute borne en bpm dans les réponses de Siri.** `TodaySession` n'en porte
|
||||
pas, et il ne faut pas en ajouter : une fourchette cardiaque annoncée par
|
||||
haut-parleur, hors de la recommandation du jour et de ses réserves,
|
||||
reviendrait à prescrire une intensité à un post-infarctus.
|
||||
83
docs/watch-plan-origin-runbook-mac.md
Normal file
83
docs/watch-plan-origin-runbook-mac.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Runbook Mac — le build qui rend l'envoi vérifiable
|
||||
|
||||
**Écrit le 03/09/2026, à suivre sur le Mac mini.** Compte 20 minutes, dont la
|
||||
moitié d'attente. Ce build ne change rien à ce que la montre reçoit : il rend
|
||||
l'app capable de **dire** ce qu'elle a fait, ce qu'elle ne savait pas faire.
|
||||
|
||||
Le dossier complet — deux épisodes de « la montre ne dit plus rien », leur
|
||||
chronologie et ce qu'elle élimine — est dans `coach_sportif`,
|
||||
[`docs/GUIDE-MONTRE.md`](../../coach_sportif/docs/GUIDE-MONTRE.md) §5quater et
|
||||
§5quinquies. Ici : les gestes.
|
||||
|
||||
## Ce qui change
|
||||
|
||||
Deux fichiers, **déjà déclarés dans la target App** — donc rien à ajouter dans
|
||||
Xcode, pas de Target Membership à cocher, pas de nouveau framework à lier
|
||||
(WorkoutKit était déjà importé par `CoachWorkoutKit.swift`) :
|
||||
|
||||
- `ios/App/App/CoachWorkoutKit.swift`
|
||||
- `ios/App/App/CoachWorkoutObserver.swift`
|
||||
|
||||
Branche : `feat/watch-outdoor`. Le côté serveur est **déjà en prod**, il attend
|
||||
juste que l'app l'appelle.
|
||||
|
||||
## 1. Récupérer et compiler
|
||||
|
||||
```bash
|
||||
cd ~/coach-ios # ou l'emplacement du clone sur le Mac
|
||||
git checkout feat/watch-outdoor
|
||||
git pull
|
||||
npx cap sync ios
|
||||
open ios/App/App.xcworkspace
|
||||
```
|
||||
|
||||
Dans Xcode : cible **App**, ton iPhone connecté, ▶︎. Rien d'autre à toucher.
|
||||
|
||||
⚠️ **Ce code n'a jamais été compilé** — WorkoutKit et HealthKit n'existent pas
|
||||
dans Swift pour Linux, et `tests-linux/` ne couvre que ce qui n'importe que
|
||||
Foundation. Les signatures Apple ont été vérifiées une par une dans la doc DocC
|
||||
avant d'être écrites, mais si quelque chose casse, c'est là que ça cassera :
|
||||
|
||||
| symptôme | où regarder |
|
||||
|---|---|
|
||||
| `value of type 'HKWorkout' has no member 'workoutPlan'` | l'extension vient de WorkoutKit : vérifier que `import WorkoutKit` est bien pris (il est sous `#if canImport`) |
|
||||
| erreur sur `case .custom(let custom)` | `WorkoutPlan.workout` est l'enum `WorkoutPlan.Workout` — cas `custom`, `goal`, `pacer`, `swimBikeRun` |
|
||||
| erreur de conversion sur `sw.date` | c'est un `DateComponents`, pas une `Date` — d'où le `Calendar.current.date(from:)` |
|
||||
|
||||
## 2. Vérifier sans courir (2 minutes, dans le canapé)
|
||||
|
||||
1. Ouvre **Programme**, la séance du jour, et appuie sur l'envoi Apple Watch.
|
||||
Le message doit maintenant **nommer la séance programmée** :
|
||||
« Programmée sur la montre : CDC · … ».
|
||||
- S'il dit « la montre ne la liste pas encore » : le scheduler n'a pas encore
|
||||
publié — regarde *Exercice → Programmées* au poignet avant de conclure.
|
||||
- S'il dit « Autorisation « Séances programmées » absente » : **c'est la
|
||||
réponse au silence**. Réglages iPhone → coach → autoriser, puis renvoyer.
|
||||
C'est exactement ce qu'une réinstallation de l'app remet à zéro, et
|
||||
l'ancienne version affichait « Envoyé » quand même.
|
||||
|
||||
2. Console Xcode (facultatif), filtre `CoachWorkoutKit` : `schedule OK`,
|
||||
`authorizationState`.
|
||||
|
||||
## 3. Après ta prochaine séance
|
||||
|
||||
Le plugin remonte tout seul, en fin de séance, si elle venait de la séance
|
||||
programmée ou d'un départ à la main. Pour le lire :
|
||||
|
||||
```bash
|
||||
# sur nexus, là où vivent les données (node n'y est pas installé)
|
||||
venv/bin/python3 tools/watch_alert_check.py <AAAA-MM-JJ> --dump /tmp/wac.json
|
||||
# puis en local, là où node existe
|
||||
python3 tools/watch_alert_check.py --from /tmp/wac.json
|
||||
```
|
||||
|
||||
Trois réponses possibles, et une seule était disponible jusqu'ici :
|
||||
|
||||
- **« Séance lancée depuis la séance programmée »** → les alertes comptées
|
||||
étaient bien dues. Si tu n'as rien entendu, le défaut est ailleurs que dans
|
||||
l'envoi et le lancement.
|
||||
- **« Séance lancée À LA MAIN »** → aucune alerte n'était possible, le silence
|
||||
est normal. C'est le cas que rien ne savait détecter, et qui devient plausible
|
||||
depuis que la Polar est la montre de sport.
|
||||
- **« Origine inconnue »** → séance antérieure au build, ou lecture en échec.
|
||||
Ce n'est **pas** un « non » : l'app préfère se taire qu'affirmer à tort.
|
||||
107
docs/watch-training-screens.md
Normal file
107
docs/watch-training-screens.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Vues d'entraînement au choix sur la montre — cadrage du 2026-08-21
|
||||
|
||||
Chantier **non commencé**. Ce document fixe ce qui a été établi le 21/08 pour
|
||||
qu'une session ultérieure reprenne sans refaire l'analyse.
|
||||
|
||||
Objectif : des écrans de séance configurables façon **WorkOutDoors** — plusieurs
|
||||
pages, des champs au choix, un profil par sport.
|
||||
|
||||
## Point de départ réel (vérifié, pas supposé)
|
||||
|
||||
`LiveWorkoutView` (`ios/App/CoachWatch/ContentView.swift`, ~l.392-441) est un
|
||||
`ScrollView` avec **cinq métriques écrites en dur** : FC + zone, calories,
|
||||
distance, vitesse km/h, durée — puis l'état iPhone et les boutons
|
||||
Pause / Terminer.
|
||||
|
||||
**Il n'existe aujourd'hui aucune notion de champ, de page, ni de configuration.**
|
||||
Tout est à créer.
|
||||
|
||||
## Les quatre briques manquantes
|
||||
|
||||
### 1. Un catalogue de champs
|
||||
|
||||
Chaque métrique doit devenir une valeur identifiable (`id` stable, libellé,
|
||||
unité, couleur, formatage) et non une ligne de vue. C'est ce qui permet à une
|
||||
page de déclarer `["hr", "pace", "distance"]` sans que la vue connaisse les
|
||||
champs à l'avance.
|
||||
|
||||
**Disponibles immédiatement, aucune collecte nouvelle :**
|
||||
|
||||
| Champ | Source |
|
||||
|---|---|
|
||||
| FC + zone | `WorkoutManager.heartRate` + `ConnectivityManager.zones` |
|
||||
| Calories | `WorkoutManager.activeEnergyKcal` |
|
||||
| Distance | `WorkoutManager.distanceMeters` |
|
||||
| Vitesse km/h | `WorkoutManager.speedKmh` |
|
||||
| Durée | `WorkoutManager.elapsedSec` |
|
||||
| **Allure min/km** | dérivée de `speedKmh` (rien à collecter) |
|
||||
| **D+ / D−** | `LocationTracker.ascentMeters` / `descentMeters` |
|
||||
| **Précision GPS** | `LocationTracker.horizontalAccuracy` |
|
||||
| **Étape du fractionné** | `IntervalEngine`, dès qu'il est câblé (cf. COWORK) |
|
||||
|
||||
**Demandent du travail :**
|
||||
|
||||
- **FC moyenne** et **temps passé par zone** : rien ne les accumule aujourd'hui.
|
||||
- **Cadence** : non collectée sur la montre.
|
||||
- **Puissance de course** (`runningPower`, watchOS 9+).
|
||||
- **Tours / laps** : aucun `HKWorkoutEvent` posé aujourd'hui.
|
||||
|
||||
### 2. Un modèle de page
|
||||
|
||||
```
|
||||
WorkoutScreenConfig { sport: String, pages: [WorkoutPage] }
|
||||
WorkoutPage { id: String, fields: [String] }
|
||||
```
|
||||
|
||||
Rendu par un `TabView` paginé (défilement vertical + Digital Crown, watchOS 10+).
|
||||
1 à 4 champs par page selon la taille d'écran — l'Ultra en tient davantage.
|
||||
|
||||
💡 **Ce modèle est du Foundation pur** : il s'écrit et se teste **sur Linux**
|
||||
(`./tests-linux/run.sh`), comme `IntervalEngine` et ses 17 tests. Seul le rendu
|
||||
SwiftUI exigera Xcode.
|
||||
|
||||
### 3. Où l'utilisateur choisit — décision prise le 21/08
|
||||
|
||||
**La configuration s'édite côté web et se pousse à la montre.** Pas d'écran de
|
||||
réglages sur la montre.
|
||||
|
||||
- `TileManager` (long-press, drag, toggle) existe déjà côté web : le paradigme
|
||||
est là, éprouvé sur les tuiles.
|
||||
- Même chemin de transport que la routine et les zones : `WCSession` →
|
||||
`ConnectivityManager`.
|
||||
- **Conséquence décisive** : changer ses écrans ne demanderait **aucun rebuild
|
||||
Xcode**. C'est exactement ce qui rend la routine agréable aujourd'hui — « les
|
||||
blocs voyagent avec l'état, rien n'est codé en dur côté watchOS ».
|
||||
- Un **profil par sport** (course ≠ vélo ≠ renfo), comme WorkOutDoors.
|
||||
|
||||
⚠️ **Repli obligatoire** : sans configuration reçue, la montre affiche les cinq
|
||||
champs actuels. Jamais d'écran vide — la config peut ne pas être arrivée, et une
|
||||
séance ne s'interrompt pas pour ça.
|
||||
|
||||
### 4. Deux contraintes watchOS, à tenir dès la conception
|
||||
|
||||
- **Le CPU tue le GPS** (piège n°4 documenté dans `LocationTracker.swift`) :
|
||||
watchOS suspend une app trop gourmande et les positions s'arrêtent **sans
|
||||
aucune erreur**. ⇒ rafraîchissement **1 Hz maximum**, et ne recalculer **que
|
||||
la page visible**, jamais les quatre. Ne pas utiliser un `Timer` de vue.
|
||||
- **Écran always-on** : en luminance réduite (`@Environment(\.isLuminanceReduced)`),
|
||||
espacer les mises à jour, sinon la batterie fond sur une sortie longue.
|
||||
|
||||
## Effort estimé
|
||||
|
||||
| Lot | Estimation |
|
||||
|---|---|
|
||||
| Châssis (catalogue + modèle + config web + push + `TabView`) | ~1 jour |
|
||||
| Chaque métrique dérivée supplémentaire | 30 min à 2 h |
|
||||
| Always-on propre | ½ jour |
|
||||
|
||||
## Ordre proposé
|
||||
|
||||
1. **Modèle + catalogue en Foundation pur**, testés sur Linux — sans Mac.
|
||||
2. **Endpoint serveur + éditeur web** (réutiliser `TileManager`).
|
||||
3. **Push `WCSession`** et publication dans `ConnectivityManager`.
|
||||
4. **Rendu `TabView`** dans `LiveWorkoutView`, avec le repli en dur.
|
||||
5. Métriques accumulées (FC moyenne, temps en zone) — chacune isolément.
|
||||
|
||||
⚠️ Ne pas commencer par le rendu : c'est la seule partie qui exige le Mac, et
|
||||
elle ne se juge qu'une fois les données au bon format.
|
||||
@@ -18,8 +18,6 @@
|
||||
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
|
||||
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, ); }; };
|
||||
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 */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
@@ -31,9 +29,13 @@
|
||||
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; };
|
||||
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
|
||||
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
|
||||
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */; };
|
||||
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; };
|
||||
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
|
||||
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
|
||||
8807F51A3045719400A4D4E8 /* PolarPftpStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8807F5193045719400A4D4E8 /* PolarPftpStep.swift */; };
|
||||
8807F51B3045719400A4D4E8 /* CoachPolarBLE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */; };
|
||||
8807F51D3045721100A4D4E8 /* PolarBleSdk in Frameworks */ = {isa = PBXBuildFile; productRef = 8807F51C3045721100A4D4E8 /* PolarBleSdk */; };
|
||||
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.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, ); }; };
|
||||
@@ -50,7 +52,7 @@
|
||||
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
|
||||
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
|
||||
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
|
||||
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
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 */; };
|
||||
@@ -61,6 +63,8 @@
|
||||
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 */; };
|
||||
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
|
||||
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
|
||||
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
|
||||
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
|
||||
@@ -106,15 +110,15 @@
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */ = {
|
||||
C4916F81E61FABAAD62EE4F9 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */,
|
||||
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = {
|
||||
@@ -151,6 +155,8 @@
|
||||
60061E60061E60061E060002 /* CoachGoogleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachGoogleAuth.swift; sourceTree = "<group>"; };
|
||||
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveStore.swift; sourceTree = "<group>"; };
|
||||
82122ACF7E5A608AD1D55D24 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachPolarBLE.swift; sourceTree = "<group>"; };
|
||||
8807F5193045719400A4D4E8 /* PolarPftpStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolarPftpStep.swift; sourceTree = "<group>"; };
|
||||
880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachWatchWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
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; };
|
||||
@@ -164,8 +170,6 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -177,9 +181,12 @@
|
||||
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>"; };
|
||||
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
|
||||
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.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>"; };
|
||||
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.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>"; };
|
||||
C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; 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>"; };
|
||||
@@ -212,6 +219,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
||||
8807F51D3045721100A4D4E8 /* PolarBleSdk in Frameworks */,
|
||||
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -294,6 +302,8 @@
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */,
|
||||
8807F5193045719400A4D4E8 /* PolarPftpStep.swift */,
|
||||
88ABE0023024C45A002636FD /* CoachQuickLog.swift */,
|
||||
88ABE0033024C45A002636FD /* CoachQuickSync.swift */,
|
||||
88A837392FAA741C00DDD144 /* App.entitlements */,
|
||||
@@ -322,6 +332,7 @@
|
||||
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
|
||||
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
|
||||
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */,
|
||||
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -394,7 +405,7 @@
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */,
|
||||
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */,
|
||||
C4916F81E61FABAAD62EE4F9 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -406,6 +417,7 @@
|
||||
packageProductDependencies = (
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
||||
88924CCD2FD2A733002F3A80 /* GoogleSignIn */,
|
||||
8807F51C3045721100A4D4E8 /* PolarBleSdk */,
|
||||
);
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
@@ -460,7 +472,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 2660;
|
||||
LastUpgradeCheck = 2650;
|
||||
LastUpgradeCheck = 2700;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
@@ -484,6 +496,7 @@
|
||||
packageReferences = (
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
||||
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */,
|
||||
8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */,
|
||||
);
|
||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -570,6 +583,8 @@
|
||||
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */,
|
||||
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */,
|
||||
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
|
||||
8807F51A3045719400A4D4E8 /* PolarPftpStep.swift in Sources */,
|
||||
8807F51B3045719400A4D4E8 /* CoachPolarBLE.swift in Sources */,
|
||||
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
|
||||
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
|
||||
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */,
|
||||
@@ -577,6 +592,7 @@
|
||||
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
|
||||
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */,
|
||||
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */,
|
||||
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -658,10 +674,11 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
|
||||
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = CoachWatch/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -676,7 +693,7 @@
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -685,6 +702,7 @@
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
@@ -748,6 +766,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
@@ -809,10 +828,10 @@
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -834,10 +853,10 @@
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -858,10 +877,11 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
|
||||
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = CoachWatch/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -877,7 +897,7 @@
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -891,12 +911,13 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -932,12 +953,13 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -968,7 +990,7 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = TZ2PVTRKNY;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -997,7 +1019,7 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = TZ2PVTRKNY;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -1078,6 +1100,14 @@
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/polarofficial/polar-ble-sdk";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 8.2.0;
|
||||
};
|
||||
};
|
||||
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/google/GoogleSignIn-iOS";
|
||||
@@ -1094,6 +1124,11 @@
|
||||
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
||||
productName = "CapApp-SPM";
|
||||
};
|
||||
8807F51C3045721100A4D4E8 /* PolarBleSdk */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */;
|
||||
productName = PolarBleSdk;
|
||||
};
|
||||
88924CCD2FD2A733002F3A80 /* GoogleSignIn */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "a07fb3cab82bafe1a29362b82b6680a574f65c3772bc11737e36d9b15949d1dd",
|
||||
"originHash" : "71d869ed5304dbc1f922c9e16fb695db4fb02f0cacefa12d4a10572958e936b8",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "app-check",
|
||||
@@ -24,8 +24,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
|
||||
"state" : {
|
||||
"revision" : "f1a8fadf1437c23b825c818fb6509c9dbbae2f61",
|
||||
"version" : "8.3.1"
|
||||
"revision" : "0b6882e9a3288342aacf36348e5a94e4f1dd7b13",
|
||||
"version" : "8.5.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -73,6 +73,15 @@
|
||||
"version" : "2.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "polar-ble-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/polarofficial/polar-ble-sdk",
|
||||
"state" : {
|
||||
"revision" : "3d15da61dd0c63e6be2582d03fd80f6c7ba02e11",
|
||||
"version" : "8.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "promises",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -81,6 +90,24 @@
|
||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
||||
"version" : "2.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-protobuf",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8",
|
||||
"version" : "1.38.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "zip",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/marmelroy/Zip.git",
|
||||
"state" : {
|
||||
"revision" : "67fa55813b9e7b3b9acee9c0ae501def28746d76",
|
||||
"version" : "2.1.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2650"
|
||||
LastUpgradeVersion = "2700"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2650"
|
||||
LastUpgradeVersion = "2700"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
@@ -55,7 +55,6 @@
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
|
||||
BuildableName = "CoachWatch.app"
|
||||
BlueprintName = "CoachWatch"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
@@ -72,7 +71,6 @@
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
|
||||
BuildableName = "CoachWatch.app"
|
||||
BlueprintName = "CoachWatch"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
#if canImport(GoogleSignIn)
|
||||
import GoogleSignIn
|
||||
#endif
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
// Conservée comme dans le template Capacitor 8.5. En cycle par scènes,
|
||||
// c'est le SceneDelegate qui possède la fenêtre affichée.
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
@@ -47,28 +46,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
HTTPCookieStorage.shared.setCookie(cookie)
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {}
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {}
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {}
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Les saisies faites depuis le widget (eau, café) attendent dans l'App
|
||||
// Group : une extension n'a ni session ni droit de tenir une requête.
|
||||
// On les transmet ici, à chaque retour au premier plan.
|
||||
Task { await CoachQuickSync.flush() }
|
||||
// ─── Cycle de vie par scènes (obligatoire iOS 27) ───────────────────
|
||||
//
|
||||
// ⚠️ Depuis l'adoption du `UIApplicationSceneManifest`, UIKit n'appelle
|
||||
// PLUS `applicationDidBecomeActive`, `application(_:open:options:)` ni
|
||||
// `application(_:continue:restorationHandler:)` — « even if you keep them
|
||||
// implemented » (Apple, *Transitioning to the UIKit scene-based life
|
||||
// cycle*). Les trois ont déménagé dans `SceneDelegate.swift` :
|
||||
//
|
||||
// applicationDidBecomeActive -> sceneDidBecomeActive (CoachQuickSync)
|
||||
// application(_:open:options:) -> scene(_:openURLContexts:) (OAuth Google)
|
||||
// application(_:continue:…) -> scene(_:continue:) (Universal Links)
|
||||
//
|
||||
// Elles ne sont pas conservées ici en doublon : une méthode morte qui a
|
||||
// l'air vivante est exactement ce qui fait chercher la panne au mauvais
|
||||
// endroit. Les callbacks de notification distante, eux, restent sur
|
||||
// l'AppDelegate — `UISceneDelegate` n'en a aucun équivalent.
|
||||
func application(_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
let config = UISceneConfiguration(name: "Default Configuration",
|
||||
sessionRole: connectingSceneSession.role)
|
||||
config.delegateClass = SceneDelegate.self
|
||||
return config
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
#if canImport(GoogleSignIn)
|
||||
// Retour du flux OAuth Google (reversed client id URL scheme).
|
||||
if GIDSignIn.sharedInstance.handle(url) {
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
}
|
||||
|
||||
89
ios/App/App/CoachAppShortcuts.swift
Normal file
89
ios/App/App/CoachAppShortcuts.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
// CoachAppShortcuts.swift
|
||||
// Les phrases que Siri écoute. C'est ce qui manquait pour « lier Siri à
|
||||
// l'app » : les App Intents existaient (`CoachIntents.swift`), mais sans
|
||||
// `AppShortcutsProvider` aucune phrase ne les déclenche — Siri ne sait pas
|
||||
// quoi écouter, et les actions ne remontent que dans l'app Raccourcis.
|
||||
//
|
||||
// ⚠️ **Cible App UNIQUEMENT**, pas les extensions.
|
||||
// Apple attend un seul `AppShortcutsProvider` par application ; en déclarer un
|
||||
// second dans une extension widget produirait au mieux un doublon.
|
||||
// Les INTENTS, eux, sont partagés (cf. `CoachIntents.swift`).
|
||||
//
|
||||
// ⚠️ **Chaque phrase doit contenir `\(.applicationName)`** — Apple le refuse
|
||||
// autrement, et le raccourci n'apparaît simplement pas. Le nom vient de
|
||||
// `CFBundleDisplayName` ; les variantes ci-dessous supposent « Coach ».
|
||||
//
|
||||
// ⚠️ **Dix phrases maximum par intent**, et les premières comptent le plus :
|
||||
// ce sont celles que Siri propose spontanément.
|
||||
import AppIntents
|
||||
|
||||
@available(iOS 17.0, watchOS 10.0, *)
|
||||
struct CoachAppShortcuts: AppShortcutsProvider {
|
||||
|
||||
/// Couleur de la tuile dans l'app Raccourcis.
|
||||
static var shortcutTileColor: ShortcutTileColor = .teal
|
||||
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
// ── Noter un verre d'eau ────────────────────────────────────────
|
||||
// Le geste à une vingtaine de fois par jour : c'est lui qui justifie
|
||||
// Siri. Les paramètres sont FIXÉS ici (250 ml) — un intent avec
|
||||
// paramètre libre oblige Siri à poser une question, ce qui coûte plus
|
||||
// de temps que de sortir le téléphone.
|
||||
AppShortcut(
|
||||
intent: LogDrinkIntent(ref: "water", amountMl: 250),
|
||||
phrases: [
|
||||
"Note un verre d'eau dans \(.applicationName)",
|
||||
"J'ai bu un verre d'eau dans \(.applicationName)",
|
||||
"Ajoute un verre d'eau dans \(.applicationName)",
|
||||
"Un verre d'eau dans \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Un verre d'eau",
|
||||
systemImageName: "drop.fill"
|
||||
)
|
||||
|
||||
AppShortcut(
|
||||
intent: LogDrinkIntent(ref: "water", amountMl: 500),
|
||||
phrases: [
|
||||
"Note une bouteille d'eau dans \(.applicationName)",
|
||||
"J'ai bu une bouteille dans \(.applicationName)",
|
||||
"Ajoute une grande eau dans \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Une bouteille d'eau",
|
||||
systemImageName: "waterbottle.fill"
|
||||
)
|
||||
|
||||
AppShortcut(
|
||||
intent: LogDrinkIntent(ref: "coffee", amountMl: 60),
|
||||
phrases: [
|
||||
"Note un café dans \(.applicationName)",
|
||||
"J'ai bu un café dans \(.applicationName)",
|
||||
"Ajoute un café dans \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Un café",
|
||||
systemImageName: "cup.and.saucer.fill"
|
||||
)
|
||||
|
||||
// ── Consulter ───────────────────────────────────────────────────
|
||||
AppShortcut(
|
||||
intent: HydrationStatusIntent(),
|
||||
phrases: [
|
||||
"Combien j'ai bu dans \(.applicationName)",
|
||||
"Où j'en suis côté hydratation dans \(.applicationName)",
|
||||
"Mon hydratation dans \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Mon hydratation",
|
||||
systemImageName: "drop.circle"
|
||||
)
|
||||
|
||||
AppShortcut(
|
||||
intent: TodaySessionIntent(),
|
||||
phrases: [
|
||||
"Quelle est ma séance du jour dans \(.applicationName)",
|
||||
"Ma séance dans \(.applicationName)",
|
||||
"Qu'est-ce que je fais aujourd'hui dans \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Ma séance du jour",
|
||||
systemImageName: "figure.run"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getStepSamples", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
private lazy var store = HKHealthStore()
|
||||
@@ -183,17 +184,36 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
var all: [CLLocation] = []
|
||||
let group = DispatchGroup()
|
||||
var pluginErr: Error?
|
||||
/* ⚠️ `all` et `pluginErr` sont mutés depuis les callbacks de N requêtes,
|
||||
livrées sur des files HealthKit indépendantes. Sans verrou, deux
|
||||
`append` concurrents corrompent le tableau — silencieusement, ou par
|
||||
un crash sans rapport apparent avec la cause.
|
||||
|
||||
Ce fichier verrouillait déjà ce motif à quatre autres endroits
|
||||
(`getEffortScores`, `getWorkoutDetails`, et deux fois plus bas) :
|
||||
c'était une omission ponctuelle, pas une convention absente.
|
||||
§ 2.6 du diagnostic du 24/09/2026.
|
||||
|
||||
Déclencheur : un workout portant PLUSIEURS `HKWorkoutRoute`
|
||||
(multisport, reprise après interruption). Avec une seule route la
|
||||
boucle n'a qu'une itération, et le défaut est inobservable — c'est
|
||||
pourquoi il a pu vivre longtemps sans se manifester. */
|
||||
let lock = NSLock()
|
||||
|
||||
for route in routes {
|
||||
group.enter()
|
||||
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
|
||||
if let err = err {
|
||||
lock.lock()
|
||||
pluginErr = err
|
||||
lock.unlock()
|
||||
group.leave()
|
||||
return
|
||||
}
|
||||
if let locs = locations {
|
||||
lock.lock()
|
||||
all.append(contentsOf: locs)
|
||||
lock.unlock()
|
||||
}
|
||||
if done {
|
||||
group.leave()
|
||||
@@ -396,6 +416,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
store.execute(wq)
|
||||
}
|
||||
|
||||
/// Pas d'une fenêtre de temps, séries condensées **dépliées**.
|
||||
///
|
||||
/// Apple, *Accessing condensed workout samples* : « HealthKit enumerates the
|
||||
/// data associated with a workout and adds that data to one or more
|
||||
/// quantity series samples. After saving all the data to series objects,
|
||||
/// HealthKit deletes the originals » — « while still preserving data
|
||||
/// resolution ». Un échantillon condensé a `count > 1`, et son détail se
|
||||
/// lit avec `HKQuantitySeriesSampleQuery`. Depuis watchOS 27 (constaté le
|
||||
/// 21/09/2026), les pas d'une course arrivent en 4 à 7 tranches de 5 à
|
||||
/// 12 min au lieu de ~850 échantillons de 2,5 s ; le plugin Capgo lit
|
||||
/// `quantity.doubleValue` sans regarder `count`. Cette méthode dit si le
|
||||
/// détail existe encore, et le rend s'il existe. Lecture seule.
|
||||
@objc func getStepSamples(_ call: CAPPluginCall) {
|
||||
guard HKHealthStore.isHealthDataAvailable(),
|
||||
let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) else {
|
||||
call.resolve(["samples": [], "reason": "HealthKit unavailable"])
|
||||
return
|
||||
}
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let isoPlain = ISO8601DateFormatter()
|
||||
func date(_ s: String?) -> Date? {
|
||||
guard let s = s else { return nil }
|
||||
return iso.date(from: s) ?? isoPlain.date(from: s)
|
||||
}
|
||||
guard let start = date(call.getString("startDate")),
|
||||
let end = date(call.getString("endDate")), end > start else {
|
||||
call.reject("startDate et endDate requis (ISO 8601), fin après début")
|
||||
return
|
||||
}
|
||||
|
||||
let window = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
|
||||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||||
let query = HKSampleQuery(sampleType: stepType, predicate: window,
|
||||
limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { [weak self] _, results, error in
|
||||
guard let self = self else { return }
|
||||
if let error = error {
|
||||
call.reject("Step fetch failed: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
let samples = (results as? [HKQuantitySample]) ?? []
|
||||
let group = DispatchGroup()
|
||||
let lock = NSLock()
|
||||
var rows: [[String: Any]] = []
|
||||
var nSeries = 0
|
||||
var nExpanded = 0
|
||||
|
||||
for s in samples {
|
||||
let src = s.sourceRevision.source
|
||||
let base: [String: Any] = ["source": src.name, "sourceId": src.bundleIdentifier]
|
||||
if s.count <= 1 {
|
||||
var row = base
|
||||
row["startDate"] = iso.string(from: s.startDate)
|
||||
row["endDate"] = iso.string(from: s.endDate)
|
||||
row["qty"] = s.quantity.doubleValue(for: .count())
|
||||
lock.lock(); rows.append(row); lock.unlock()
|
||||
continue
|
||||
}
|
||||
nSeries += 1
|
||||
group.enter()
|
||||
// Une série : ses quantités individuelles, chacune avec son
|
||||
// intervalle. `done` marque le dernier appel du handler.
|
||||
let inSeries = HKQuery.predicateForObject(with: s.uuid)
|
||||
let detail = HKQuantitySeriesSampleQuery(quantityType: stepType,
|
||||
predicate: inSeries) { _, quantity, interval, _, done, err in
|
||||
if let quantity = quantity, let interval = interval {
|
||||
var row = base
|
||||
row["startDate"] = iso.string(from: interval.start)
|
||||
row["endDate"] = iso.string(from: interval.end)
|
||||
row["qty"] = quantity.doubleValue(for: .count())
|
||||
row["fromSeries"] = true
|
||||
lock.lock(); rows.append(row); nExpanded += 1; lock.unlock()
|
||||
}
|
||||
if done || err != nil { group.leave() }
|
||||
}
|
||||
self.store.execute(detail)
|
||||
}
|
||||
|
||||
group.notify(queue: .global()) {
|
||||
rows.sort { ($0["startDate"] as? String ?? "") < ($1["startDate"] as? String ?? "") }
|
||||
NSLog("[CoachHealthRoute] pas : %d échantillons, %d séries, %d dépliés", samples.count, nSeries, nExpanded)
|
||||
call.resolve(["samples": rows, "raw": samples.count, "series": nSeries, "expanded": nExpanded])
|
||||
}
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
|
||||
/// Assemble métadonnées + statistiques d'une séance. Les champs absents ne
|
||||
/// sont pas émis : le backend distingue « pas mesuré » de « zéro ».
|
||||
private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) {
|
||||
@@ -423,6 +530,38 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
out["average_mets"] = (mets.doubleValue(for: unit) * 10).rounded() / 10
|
||||
}
|
||||
|
||||
// Activités de la séance — lecture seule, pour VOIR ce qu'Exercice écrit.
|
||||
// Apple : « you can use workout activities to […] represent the active
|
||||
// and rest periods during interval training. All HKWorkout instances
|
||||
// have at least one associated HKWorkoutActivity » (doc HKWorkoutActivity,
|
||||
// lue le 22/09/2026). Apple ne dit PAS que l'app Exercice en écrit une
|
||||
// par étape d'un exercice personnalisé : c'est ce que ce champ permet
|
||||
// de constater. Le serveur ne s'en sert pas tant que ce n'est pas vu.
|
||||
if #available(iOS 16.0, *) {
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
var activities: [[String: Any]] = []
|
||||
for a in workout.workoutActivities {
|
||||
var row: [String: Any] = [
|
||||
"start": iso.string(from: a.startDate),
|
||||
"duration_s": (a.duration * 10).rounded() / 10,
|
||||
"activity_type": Int(a.workoutConfiguration.activityType.rawValue),
|
||||
"location": Int(a.workoutConfiguration.locationType.rawValue),
|
||||
"events": a.workoutEvents.count,
|
||||
]
|
||||
if let end = a.endDate { row["end"] = iso.string(from: end) }
|
||||
// Métadonnées en clair uniquement (String/Number/Bool) : une
|
||||
// HKQuantity ne se sérialise pas, et on ne veut rien deviner.
|
||||
var plain: [String: Any] = [:]
|
||||
for (k, v) in a.metadata ?? [:] {
|
||||
if v is String || v is NSNumber { plain[k] = v }
|
||||
}
|
||||
if !plain.isEmpty { row["metadata"] = plain }
|
||||
activities.append(row)
|
||||
}
|
||||
out["activities"] = activities
|
||||
}
|
||||
|
||||
// Statistiques agrégées sur la fenêtre exacte de la séance.
|
||||
let samplePredicate = HKQuery.predicateForObjects(from: workout)
|
||||
var requests: [(key: String, id: HKQuantityTypeIdentifier, unit: HKUnit, option: HKStatisticsOptions, scale: Double)] = []
|
||||
|
||||
182
ios/App/App/CoachIntents.swift
Normal file
182
ios/App/App/CoachIntents.swift
Normal file
@@ -0,0 +1,182 @@
|
||||
// CoachIntents.swift
|
||||
// App Intents partagés : app iPhone, widget iPhone, widget Watch, Siri.
|
||||
//
|
||||
// ⚠️ **Ce fichier doit appartenir à TOUTES les cibles qui s'en servent** :
|
||||
// App + CoachLiveActivity + CoachWatchWidgets
|
||||
// (cf. docs/widgets-runbook-mac.md, étape « Target Membership »).
|
||||
//
|
||||
// Pourquoi il existe
|
||||
// ------------------
|
||||
// `LogDrinkIntent` vivait dans `CoachQuickWidget.swift`, qui n'appartient qu'à
|
||||
// l'extension widget iPhone. Deux conséquences, constatées le 25/09/2026 :
|
||||
//
|
||||
// - **Siri ne pouvait pas y accéder.** Les raccourcis (`AppShortcutsProvider`)
|
||||
// se déclarent dans l'application principale, qui ne voyait pas cet intent.
|
||||
// - **La montre non plus.** La cible `CoachWatchWidgets` ne portait que des
|
||||
// complications d'affichage : aucune saisie n'était possible au poignet,
|
||||
// alors que c'est le geste que Sylvain fait une vingtaine de fois par jour.
|
||||
//
|
||||
// L'extraire ici, plutôt que le dupliquer, évite deux implémentations qui
|
||||
// divergeraient — le défaut déjà payé sur l'envoi vers la Watch.
|
||||
//
|
||||
// ⛔ Aucun appel réseau ici, et c'est structurel : une extension n'a ni session
|
||||
// authentifiée ni droit de tenir une requête. L'intent écrit dans la file de
|
||||
// `CoachQuickLog` (App Group), et l'application la transmet au serveur à sa
|
||||
// prochaine ouverture. Rien n'est déclaré synchronisé tant qu'il ne l'est pas.
|
||||
import AppIntents
|
||||
import Foundation
|
||||
#if canImport(WidgetKit)
|
||||
import WidgetKit
|
||||
#endif
|
||||
|
||||
// MARK: - Erreurs
|
||||
|
||||
enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible {
|
||||
case unavailable
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
"Stockage partagé indisponible — ouvrez l'app une fois."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noter une boisson
|
||||
|
||||
/// Ajoute une boisson sans quitter l'écran d'accueil, le cadran, ou en dictant
|
||||
/// à Siri.
|
||||
@available(iOS 17.0, watchOS 10.0, *)
|
||||
struct LogDrinkIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Noter une boisson"
|
||||
static var description = IntentDescription(
|
||||
"Ajoute un verre d'eau ou un café au journal, sans ouvrir l'application."
|
||||
)
|
||||
/// `false` : tout l'intérêt est de NE PAS ouvrir l'app.
|
||||
static var openAppWhenRun: Bool = false
|
||||
|
||||
@Parameter(title: "Type") var ref: String
|
||||
@Parameter(title: "Volume (ml)") var amountMl: Double
|
||||
|
||||
init() {}
|
||||
init(ref: String, amountMl: Double) {
|
||||
self.ref = ref
|
||||
self.amountMl = amountMl
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let ok = CoachQuickLog.append(
|
||||
CoachQuickEntry(kind: .drink, ref: ref, amount: amountMl)
|
||||
)
|
||||
#if canImport(WidgetKit)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
#endif
|
||||
// Un échec d'App Group doit remonter : afficher « ajouté » sur une
|
||||
// saisie perdue est pire que ne rien afficher.
|
||||
guard ok else { throw CoachQuickError.unavailable }
|
||||
|
||||
// ⚠️ Le dialogue annonce ce qui a été NOTÉ, pas ce qui a été envoyé au
|
||||
// serveur — la file part à la prochaine ouverture de l'app. Dire
|
||||
// « enregistré sur ton compte » serait faux pendant plusieurs heures.
|
||||
let total = CoachQuickLog.todayTotal(
|
||||
kind: "water",
|
||||
syncedMl: CoachWidgetStore.load()?.asOf(Date()).waterMlToday ?? 0
|
||||
)
|
||||
if ref == "water" {
|
||||
return .result(dialog: "Noté. \(Self.enLitres(total)) d'eau aujourd'hui.")
|
||||
}
|
||||
return .result(dialog: "Noté.")
|
||||
}
|
||||
|
||||
/// « 1,25 L » plutôt que « 1250 ml » : Siri le lit à voix haute.
|
||||
static func enLitres(_ ml: Double) -> String {
|
||||
let litres = ml / 1000
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.maximumFractionDigits = litres < 1 ? 2 : 1
|
||||
return (f.string(from: NSNumber(value: litres)) ?? "\(litres)") + " L"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Où j'en suis
|
||||
|
||||
/// Répond « combien j'ai bu aujourd'hui », sans ouvrir l'app.
|
||||
///
|
||||
/// ⚠️ Cet intent ne fait que LIRE ce que l'appareil sait déjà : le snapshot
|
||||
/// poussé par l'app plus la file locale. Il n'interroge pas le serveur — une
|
||||
/// extension n'en a pas le droit, et Siri doit répondre tout de suite.
|
||||
@available(iOS 17.0, watchOS 10.0, *)
|
||||
struct HydrationStatusIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Où j'en suis côté hydratation"
|
||||
static var description = IntentDescription(
|
||||
"Dit combien d'eau a été notée aujourd'hui, et la cible du jour."
|
||||
)
|
||||
static var openAppWhenRun: Bool = false
|
||||
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
let snapshot = CoachWidgetStore.load()?.asOf(Date())
|
||||
let total = CoachQuickLog.todayTotal(
|
||||
kind: "water", syncedMl: snapshot?.waterMlToday ?? 0)
|
||||
guard let objectif = snapshot?.waterGoalMl, objectif > 0 else {
|
||||
// Pas de cible connue : on donne le total, sans inventer de reste.
|
||||
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) d'eau aujourd'hui.")
|
||||
}
|
||||
let reste = max(0, objectif - total)
|
||||
if reste <= 0 {
|
||||
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) : objectif atteint.")
|
||||
}
|
||||
// ⚠️ UN SEUL littéral interpolé, jamais de concaténation.
|
||||
// `IntentDialog` est `ExpressibleByStringInterpolation` : un littéral
|
||||
// passe, mais l'opérateur `+` produit une `String`, que le compilateur
|
||||
// refuse — « Cannot convert value of type 'String' to expected argument
|
||||
// type 'IntentDialog' ». Erreur rencontrée au premier build, 25/09.
|
||||
let phrase = "\(LogDrinkIntent.enLitres(total)) sur \(LogDrinkIntent.enLitres(objectif)). Il reste \(LogDrinkIntent.enLitres(reste))."
|
||||
return .result(dialog: IntentDialog(stringLiteral: phrase))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - La séance du jour
|
||||
|
||||
/// Dit la séance prévue aujourd'hui, telle que l'app l'a poussée.
|
||||
///
|
||||
/// ⛔ **Aucune borne en bpm n'est prononcée.** `TodaySession` n'en porte pas —
|
||||
/// vérifié, elle a `sport`, `title`, `subtitle`, `done` — et il ne faut pas en
|
||||
/// ajouter ici : une fourchette cardiaque dite par haut-parleur, hors de la
|
||||
/// recommandation du jour et de ses réserves, reviendrait à prescrire une
|
||||
/// intensité à un post-infarctus. Siri annonce QUOI ; l'app dit COMMENT.
|
||||
/// `subtitle` (« 1h30 · Z2 ») est répété tel que l'app l'affiche, pas
|
||||
/// reformulé : une zone nommée n'est pas une cible en battements.
|
||||
@available(iOS 17.0, watchOS 10.0, *)
|
||||
struct TodaySessionIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Ma séance du jour"
|
||||
static var description = IntentDescription(
|
||||
"Annonce le sport et la durée prévus aujourd'hui."
|
||||
)
|
||||
static var openAppWhenRun: Bool = false
|
||||
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
guard let today = CoachWidgetStore.load()?.asOf(Date()).today else {
|
||||
// Idem : pas de `+` dans un dialogue (cf. HydrationStatusIntent).
|
||||
return .result(dialog: "Je n'ai pas encore ta séance du jour. Ouvre l'app une fois.")
|
||||
}
|
||||
let sport = Self.sportEnFrancais(today.sport)
|
||||
if today.done {
|
||||
return .result(dialog: "\(sport) : \(today.title). Déjà faite aujourd'hui.")
|
||||
}
|
||||
// `subtitle` porte l'objectif court tel que l'app l'affiche
|
||||
// (« 1h30 · Z2 ») : on le répète, on ne le reformule pas.
|
||||
if let objectif = today.subtitle, !objectif.isEmpty {
|
||||
return .result(dialog: "\(sport) : \(today.title), \(objectif).")
|
||||
}
|
||||
return .result(dialog: "\(sport) : \(today.title).")
|
||||
}
|
||||
|
||||
static func sportEnFrancais(_ sport: String) -> String {
|
||||
switch sport {
|
||||
case "running": return "Course"
|
||||
case "cycling": return "Vélo"
|
||||
case "walking": return "Marche"
|
||||
case "hiking": return "Randonnée"
|
||||
case "strength": return "Renforcement"
|
||||
case "mobility": return "Mobilité"
|
||||
case "rest": return "Repos"
|
||||
default: return sport.capitalized
|
||||
}
|
||||
}
|
||||
}
|
||||
644
ios/App/App/CoachPolarBLE.swift
Normal file
644
ios/App/App/CoachPolarBLE.swift
Normal file
@@ -0,0 +1,644 @@
|
||||
// CoachPolarBLE.swift
|
||||
// Écrit une séance du plan sur une Polar Vantage V3, en Bluetooth, sans cloud
|
||||
// ni compte ni API tierce.
|
||||
//
|
||||
// POURQUOI CE PLUGIN EXISTE
|
||||
//
|
||||
// L'écriture d'un objectif sur la montre par le protocole PFTP est prouvée
|
||||
// depuis le 2026-08-17 : la montre accepte le fichier et l'affiche. Mais elle
|
||||
// ne l'était qu'en USB, donc il fallait brancher la montre à un Mac cinq
|
||||
// minutes avant de sortir — ce qui n'est pas un usage.
|
||||
//
|
||||
// Le Bluetooth n'est donc pas un confort : c'est ce qui rend le chemin
|
||||
// utilisable. Et c'est le seul chemin qui satisfasse la contrainte posée le
|
||||
// 2026-08-31 — `coach → la montre`, sans TrainingPeaks, sans Polar Flow, sans
|
||||
// Intervals.icu. Toutes les autres voies passent par quelqu'un d'autre.
|
||||
//
|
||||
// CE QUE CE PLUGIN FAIT, ET CE QU'IL NE FAIT PAS
|
||||
//
|
||||
// Il transporte. Rien d'autre. Les octets viennent du serveur
|
||||
// (`GET /api/plan/polar-target`), qui les produit avec un encodeur verrouillé
|
||||
// octet pour octet contre des fichiers relus sur la montre. **Ne jamais
|
||||
// réencoder un objectif ici** : ce serait perdre la seule garantie dont on
|
||||
// dispose que le firmware acceptera le fichier.
|
||||
//
|
||||
// ⚠️ LE FIRMWARE PLANTE SUR REQUÊTE MALFORMÉE
|
||||
//
|
||||
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » — donc
|
||||
// « écrire du contenu dans un dossier » — a bloqué une Vantage V3 : logo Polar
|
||||
// puis écran noir, récupérée par un appui long sur OK. Le firmware ne refuse
|
||||
// pas proprement, il s'effondre. Le BLE utilise LE MÊME protocole PFTP : le
|
||||
// risque est identique ici.
|
||||
//
|
||||
// D'où deux verrous, repris de `tools/polar/polar_ftp.py` :
|
||||
// - un chemin terminé par « / » est un DOSSIER : contenu obligatoirement vide ;
|
||||
// - un chemin sans « / » final est un FICHIER : contenu obligatoirement non vide.
|
||||
// Et un mode `dryRun` qui construit chaque requête et la décrit sans rien
|
||||
// émettre — l'équivalent du `--dry-run` qui a manqué le jour du plantage.
|
||||
//
|
||||
// CHAÎNE SDK — vérifiée sur les sources le 2026-08-31, tout est public
|
||||
//
|
||||
// CBDeviceListenerImpl(queue, clients:identifier:) ble/endpoints/corebluetooth/central
|
||||
// listener.search(_:identifiers:fetchKnownDevices:) -> AnyPublisher<BleDeviceSession, Error>
|
||||
// listener.openSessionDirect(_:)
|
||||
// session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE) // CBUUID("FEEE")
|
||||
// client.waitPsFtpReady(_:) async throws
|
||||
// client.write(_ header: NSData, data: InputStream) -> AsyncThrowingStream<UInt, Error>
|
||||
//
|
||||
// Aucun fork, aucun symbole interne.
|
||||
//
|
||||
// ⚠️ Le cadrage série `[0x05, taille, taille]` de la version USB N'A PAS SA
|
||||
// PLACE ICI. Il appartient au transport RFC76 sur CDC-ACM ; en Bluetooth c'est
|
||||
// le SDK qui s'en charge. On ne passe que l'en-tête PbPFtpOperation et les
|
||||
// données brutes. Reporter le cadrage produirait une requête malformée — c'est-
|
||||
// à-dire précisément ce qui plante la montre.
|
||||
//
|
||||
// USAGE CÔTÉ JS
|
||||
//
|
||||
// await window.Capacitor.Plugins.CoachPolarBLE.isAvailable()
|
||||
// → { available: bool }
|
||||
//
|
||||
// await window.Capacitor.Plugins.CoachPolarBLE.send({
|
||||
// mkdir: ['/U/0/20260831/TST/', '/U/0/20260831/TST/180000/'],
|
||||
// dir: '/U/0/20260831/TST/180000/',
|
||||
// files: [{ name: 'TST.BPB', b64: '...' }, { name: 'ID.BPB', b64: '...' }],
|
||||
// dryRun: true, // par défaut FALSE, mais à utiliser au premier essai
|
||||
// timeoutSec: 30,
|
||||
// })
|
||||
// → { sent: bool, dryRun: bool, log: [string] }
|
||||
//
|
||||
// Le corps de `send` est exactement la réponse de `/api/plan/polar-target` :
|
||||
// le JS n'a rien à recomposer.
|
||||
|
||||
import Foundation
|
||||
import Capacitor
|
||||
#if canImport(PolarBleSdk)
|
||||
import PolarBleSdk
|
||||
import CoreBluetooth
|
||||
import Combine
|
||||
#endif
|
||||
|
||||
@objc(CoachPolarBLEPlugin)
|
||||
public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// CAPBridgedPlugin : sans cette déclaration explicite, Capacitor 8
|
||||
// n'expose pas les méthodes au bridge et `Capacitor.Plugins.CoachPolarBLE`
|
||||
// reste undefined (même piège que CoachWorkoutKit).
|
||||
public let identifier = "CoachPolarBLEPlugin"
|
||||
public let jsName = "CoachPolarBLE"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
@objc func isAvailable(_ call: CAPPluginCall) {
|
||||
#if canImport(PolarBleSdk)
|
||||
call.resolve(["available": true])
|
||||
#else
|
||||
call.resolve(["available": false,
|
||||
"reason": "PolarBleSdk absent de la cible — ajouter le paquet dans Xcode"])
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc func send(_ call: CAPPluginCall) {
|
||||
guard let etapes = Self.parseSteps(call) else {
|
||||
call.reject("payload invalide : `dir`, `files[]` (name + b64) requis")
|
||||
return
|
||||
}
|
||||
let dryRun = call.getBool("dryRun") ?? false
|
||||
let timeout = call.getDouble("timeoutSec") ?? 30
|
||||
|
||||
// Les verrous d'abord, la radio ensuite. Une requête refusée ici est une
|
||||
// montre qui ne plante pas.
|
||||
do {
|
||||
try etapes.forEach { try $0.validate() }
|
||||
} catch {
|
||||
call.reject("\(error)")
|
||||
return
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
call.resolve(["sent": false, "dryRun": true,
|
||||
"log": etapes.map { $0.describe }])
|
||||
return
|
||||
}
|
||||
|
||||
#if canImport(PolarBleSdk)
|
||||
Task {
|
||||
do {
|
||||
let issue = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout)
|
||||
call.resolve(["sent": true, "dryRun": false,
|
||||
"log": issue.journal,
|
||||
// L'UI s'en sert pour prévenir d'un renvoi : la
|
||||
// montre garde son index en cache, et l'objectif
|
||||
// réécrit n'apparaît qu'après un redémarrage.
|
||||
"alreadyExisted": issue.dossierPreexistant])
|
||||
} catch {
|
||||
call.reject("\(error)")
|
||||
}
|
||||
}
|
||||
#else
|
||||
call.reject("PolarBleSdk absent de la cible — ajouter le paquet dans Xcode")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Traduit le corps JS en étapes PFTP, dans l'ordre où elles doivent partir.
|
||||
static func parseSteps(_ call: CAPPluginCall) -> [PftpStep]? {
|
||||
guard let dir = call.getString("dir"), !dir.isEmpty else { return nil }
|
||||
var etapes: [PftpStep] = []
|
||||
|
||||
// Les dossiers d'abord, du parent vers l'enfant : `/U/0/<date>/TST/` puis
|
||||
// `<heure>/`. Aucun des deux n'existe d'avance pour une date neuve, et un
|
||||
// mkdir dans le désordre échoue.
|
||||
for chemin in call.getArray("mkdir", String.self) ?? [] {
|
||||
etapes.append(PftpStep(path: chemin, data: Data()))
|
||||
}
|
||||
|
||||
guard let fichiers = call.getArray("files") as? [[String: Any]], !fichiers.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
for fichier in fichiers {
|
||||
guard let nom = fichier["name"] as? String,
|
||||
let b64 = fichier["b64"] as? String,
|
||||
let octets = Data(base64Encoded: b64) else { return nil }
|
||||
etapes.append(PftpStep(path: dir + nom, data: octets))
|
||||
}
|
||||
return etapes
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Le transport
|
||||
|
||||
#if canImport(PolarBleSdk)
|
||||
|
||||
/// Ouvre une session PsFTP sur la première montre qui répond, et écrit.
|
||||
///
|
||||
/// ⚠️ Le filtrage se fait sur « expose PsFTP », pas sur le nom ni l'identifiant
|
||||
/// annoncé : ce que la V3 met dans son advertisement n'a pas été observé, et
|
||||
/// s'appuyer dessus serait une supposition. À resserrer une fois qu'on l'aura vu
|
||||
/// — en attendant, un capteur Polar (H10) ne portant pas PsFTP est écarté de
|
||||
/// lui-même.
|
||||
final class PolarPsFtpWriter {
|
||||
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.polarble")
|
||||
private var abonnements = Set<AnyCancellable>()
|
||||
/// Le publisher de `search()` a-t-il émis quoi que ce soit — valeur, fin ou
|
||||
/// erreur ? S'il reste muet, `monitorBleState()` n'a jamais émis
|
||||
/// `.poweredOn` et le scan n'a jamais démarré : ce n'est pas « aucun
|
||||
/// appareil », c'est « on n'a jamais cherché ».
|
||||
private var publisherAParle = false
|
||||
private var derniereErreurSdk: String?
|
||||
|
||||
/// Écrit les étapes et rend le journal, plus un drapeau : le dossier de
|
||||
/// destination existait-il déjà ? Ce drapeau remonte jusqu'à l'UI, qui doit
|
||||
/// avertir — un objectif réécrit n'apparaît qu'après redémarrage de la
|
||||
/// montre.
|
||||
func ecrire(_ etapes: [PftpStep], timeout: Double) async throws
|
||||
-> (journal: [String], dossierPreexistant: Bool) {
|
||||
// ⚠️ PRÉAMBULE INDISPENSABLE — sans lui, rien ne se passe et rien ne le
|
||||
// dit. `CBDeviceListenerImpl.search()` commence par
|
||||
// `monitorBleState().filter { $0 == .poweredOn }` : tant que cet état
|
||||
// n'arrive pas, le flux n'émet jamais, le scan ne démarre pas, et on
|
||||
// conclut « aucun appareil » au bout du timeout.
|
||||
//
|
||||
// Or l'état ne peut arriver que si un `CBCentralManager` existe — et
|
||||
// c'est sa création qui déclenche l'alerte d'autorisation d'iOS.
|
||||
// Mesuré le 2026-08-31 : le scan a tourné 30 s sans qu'iOS demande quoi
|
||||
// que ce soit, et « Bluetooth » n'apparaissait même pas dans les
|
||||
// réglages de l'app. L'autorisation n'avait jamais été sollicitée.
|
||||
//
|
||||
// On crée donc le manager nous-mêmes, on attend son premier état, et on
|
||||
// traduit ce qu'il dit — au lieu de laisser un silence passer pour une
|
||||
// absence de montre.
|
||||
let sonde = SondeBluetooth()
|
||||
try await sonde.attendreEtatUtilisable(timeout: min(timeout, 15))
|
||||
|
||||
let listener = CBDeviceListenerImpl(
|
||||
queue,
|
||||
clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }],
|
||||
identifier: 1)
|
||||
|
||||
// ⚠️ NE PAS FILTRER À L'AVEUGLE — on trie après avoir vu.
|
||||
//
|
||||
// Une version précédente posait `scanPreFilter` sur « polarDeviceId non
|
||||
// vide OU nom contenant polar ». Si la Vantage ne s'annonce pas ainsi,
|
||||
// elle était rejetée par NOUS, aucune session n'était créée, et le
|
||||
// publisher de `search()` ne disait rien — ce que le message
|
||||
// interprétait à tort comme « le scan n'a pas démarré ».
|
||||
//
|
||||
// On laisse donc tout remonter et on trie dans la boucle : les appareils
|
||||
// qui ressemblent à du Polar d'abord, et on n'ouvre de session que sur
|
||||
// ceux-là. Si aucun ne ressemble, on RAPPORTE ce qu'on a vu au lieu de
|
||||
// conclure.
|
||||
|
||||
// ⚠️ RÉVEILLER LA LAZY, PUIS ATTENDRE — dans cet ordre, et avant tout
|
||||
// abonnement.
|
||||
//
|
||||
// `CBDeviceListenerImpl.manager` est une `lazy var` : le
|
||||
// CBCentralManager n'existe qu'au premier accès, et `search()` ne le
|
||||
// touche jamais avant son filtre `$0 == .poweredOn`. Sans réveil, on
|
||||
// attend un état que rien ne peut produire.
|
||||
//
|
||||
// Deux détails, tirés des sources, qui décident du succès :
|
||||
//
|
||||
// 1. `bleStateSubject` est un **CurrentValueSubject**, initialisé à
|
||||
// `.unknown`. Il REJOUE sa valeur à chaque abonnement — inutile donc
|
||||
// d'être abonné au moment de l'émission. Une version précédente
|
||||
// réveillait après l'abonnement « pour ne pas rater l'événement » :
|
||||
// raisonnement de PassthroughSubject, faux ici, et qui exposait au
|
||||
// piège suivant.
|
||||
//
|
||||
// 2. `centralManagerDidUpdateState` fait
|
||||
// `BleState(rawValue: self.manager.state.rawValue)` — il accède à la
|
||||
// lazy DEPUIS le délégué. Si CoreBluetooth appelle le délégué avant
|
||||
// que l'initialisation de la lazy soit terminée, la propriété se
|
||||
// réentre. D'où : réveiller tôt, hors de tout abonnement, et laisser
|
||||
// l'état se poser.
|
||||
//
|
||||
// ⚠️ ET SURTOUT : attendre le SUJET, pas l'état du manager.
|
||||
//
|
||||
// `blePowered()` lit `manager.state` — l'état de CoreBluetooth. Mais
|
||||
// `search()` filtre sur `bleStateSubject`, qui n'est alimenté que par
|
||||
// `centralManagerDidUpdateState`. Le manager peut donc être allumé
|
||||
// (`blePowered()` vrai) sans que le délégué ait encore publié quoi que
|
||||
// ce soit : le sujet reste à `.unknown`, le filtre bloque, et le
|
||||
// publisher se tait. C'est exactement ce qui a été mesuré le
|
||||
// 2026-08-31, y compris après avoir attendu `blePowered()`.
|
||||
//
|
||||
// On attend donc sur `monitorBleState()`, qui est public et qui EST la
|
||||
// source lue par `search()`. `blePowered()` ne sert plus qu'à une
|
||||
// chose : instancier la lazy pour que le délégué puisse tourner.
|
||||
_ = listener.blePowered()
|
||||
let etat = await listener.premierEtatPret(timeout: min(timeout, 12))
|
||||
if !etat {
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"le listener du SDK Polar n'a jamais publié l'état « prêt » "
|
||||
+ "(son délégué n'a pas été appelé). Bluetooth actif côté "
|
||||
+ "système, mais inutilisable par le SDK.")
|
||||
}
|
||||
|
||||
let (client, session) = try await trouverClient(listener, timeout: timeout,
|
||||
sonde: sonde)
|
||||
defer { listener.closeSessionDirect(session) }
|
||||
|
||||
var journal: [String] = []
|
||||
var dossierPreexistant = false
|
||||
for etape in etapes {
|
||||
// Revalidé juste avant l'émission : entre la validation d'entrée et
|
||||
// ce point, rien ne doit avoir introduit un chemin de dossier avec
|
||||
// du contenu.
|
||||
try etape.validate()
|
||||
do {
|
||||
try await put(client, etape)
|
||||
journal.append("✔ " + etape.describe)
|
||||
} catch BlePsFtpException.responseError(let code)
|
||||
where etape.isDirectory && PftpCode.mkdirEstSatisfait(par: code) {
|
||||
// `mkdir -p` : le dossier est là, c'est tout ce qu'on demandait.
|
||||
//
|
||||
// ⚠️ Rapporté par Sylvain le 01/09/2026 sous la forme
|
||||
// « errorcode 104 » — ce qui donnait à croire à un refus de la
|
||||
// montre alors que la session BLE s'était parfaitement ouverte.
|
||||
// Le commentaire de `parseSteps` disait « aucun des deux
|
||||
// n'existe d'avance pour une date neuve » : c'est vrai d'une
|
||||
// date NEUVE, et faux dès le second envoi vers la même date.
|
||||
//
|
||||
// Le renvoi est signalé plus haut, jamais avalé en silence :
|
||||
// réécrire un objectif déjà présent n'est visible qu'après un
|
||||
// redémarrage de la montre (index en cache, constaté le 31/08).
|
||||
dossierPreexistant = true
|
||||
journal.append("• " + etape.describe + " — existait déjà")
|
||||
}
|
||||
}
|
||||
return (journal, dossierPreexistant)
|
||||
}
|
||||
|
||||
private func trouverClient(_ listener: CBDeviceListenerImpl,
|
||||
timeout: Double,
|
||||
sonde: SondeBluetooth) async throws
|
||||
-> (BlePsFtpClient, BleDeviceSession) {
|
||||
let debut = Date()
|
||||
var vues = 0 // appareils BLE aperçus, tous confondus
|
||||
var candidats = 0 // ceux qui ressemblent à un Polar
|
||||
var noms: [String] = []
|
||||
var sansPsFtp = 0 // aperçus mais ne portant pas le service FEEE
|
||||
var muettes = 0 // portant FEEE mais dont waitPsFtpReady échoue
|
||||
|
||||
// ⚠️ UN SEUL ABONNEMENT, SUR TOUTE LA DURÉE — pas des fenêtres.
|
||||
//
|
||||
// Non pas parce qu'une émission serait ratée — `bleStateSubject` est un
|
||||
// CurrentValueSubject, il rejoue sa valeur à chaque abonnement — mais
|
||||
// parce que chaque abonnement relance le cycle `addClient()` /
|
||||
// `removeClient()` du scanner. Redémarrer un scan toutes les 4 s
|
||||
// l'empêche de découvrir quoi que ce soit, et la découverte BLE demande
|
||||
// plusieurs secondes ininterrompues.
|
||||
//
|
||||
// L'état, lui, est acquis avant d'arriver ici : `blePowered()` a été
|
||||
// sondé jusqu'à devenir vrai.
|
||||
do {
|
||||
// `AllowDuplicates` est armé côté SDK : le même appareil revient à
|
||||
// chaque advertisement (4020 émissions pour une poignée d'appareils
|
||||
// réels). On déduplique avant de compter et d'ouvrir quoi que ce soit.
|
||||
var dejaVues = Set<String>()
|
||||
let toutes = try await sessionsVues(listener, fenetre: timeout)
|
||||
.filter { dejaVues.insert(Self.etiquette($0)).inserted }
|
||||
vues = toutes.count
|
||||
noms = toutes.map { Self.etiquette($0) }
|
||||
|
||||
// Ouvrir une session coûte plusieurs secondes : on ne les tente que
|
||||
// sur ce qui ressemble à un Polar, sinon 43 appareils feraient des
|
||||
// minutes d'attente (mesuré, avec des erreurs sur l'Apple Watch).
|
||||
let sessions = toutes.filter { Self.ressembleAPolar($0) }
|
||||
candidats = sessions.count
|
||||
for session in sessions {
|
||||
// Marge au-delà du scan : ouvrir une session prend du temps, et
|
||||
// couper ici ferait échouer la seule montre trouvée.
|
||||
if Date().timeIntervalSince(debut) > timeout * 2 { break }
|
||||
listener.openSessionDirect(session)
|
||||
guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE)
|
||||
as? BlePsFtpClient else {
|
||||
sansPsFtp += 1
|
||||
listener.closeSessionDirect(session)
|
||||
continue
|
||||
}
|
||||
do {
|
||||
// ⚠️ `waitPsFtpReady` n'a AUCUNE limite de temps. Si la
|
||||
// montre ne finit pas la négociation — canal déjà pris,
|
||||
// écran éteint, appairage en cours — l'attente ne rend
|
||||
// jamais la main et l'envoi paraît figé, sans message.
|
||||
// Constaté le 2026-08-31 : « envoi » tournant sans fin.
|
||||
// On lui donne donc une échéance, et un dépassement compte
|
||||
// comme une montre muette : c'est ce qu'il est.
|
||||
try await Self.avecEcheance(seconds: 12) {
|
||||
try await client.waitPsFtpReady(true)
|
||||
}
|
||||
return (client, session)
|
||||
} catch {
|
||||
muettes += 1
|
||||
listener.closeSessionDirect(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
if vues > 0 && candidats == 0 {
|
||||
// Le scan marche, mais rien ne ressemble à un Polar. Dire ce qu'on
|
||||
// a vu : c'est la seule façon de savoir sous quel nom la montre
|
||||
// s'annonce — ou si elle ne s'annonce pas du tout.
|
||||
throw PolarPftpError.aucunPolarParmi(vues: vues,
|
||||
exemples: Array(noms.prefix(8)))
|
||||
}
|
||||
if vues == 0 {
|
||||
// Le SDK n'a rien remonté : est-ce la radio, ou notre usage du SDK ?
|
||||
// Un scan nu tranche, et son résultat part dans le message.
|
||||
let bruts = await sonde.compterAppareils(pendant: 6)
|
||||
throw PolarPftpError.sdkSilencieux(vusParCoreBluetooth: bruts,
|
||||
publisherAParle: publisherAParle,
|
||||
erreurSdk: derniereErreurSdk)
|
||||
}
|
||||
throw PolarPftpError.watchNotFound(timeout, vues: vues,
|
||||
sansPsFtp: sansPsFtp, muettes: muettes)
|
||||
}
|
||||
|
||||
/// Les appareils vus pendant une fenêtre de recherche, appairés compris.
|
||||
private func sessionsVues(_ listener: CBDeviceListenerImpl,
|
||||
fenetre: Double) async throws -> [BleDeviceSession] {
|
||||
try await withCheckedThrowingContinuation { suite in
|
||||
var vues: [BleDeviceSession] = []
|
||||
var rendu = false
|
||||
let rendre = {
|
||||
guard !rendu else { return }
|
||||
rendu = true
|
||||
suite.resume(returning: vues)
|
||||
}
|
||||
// ⚠️ PASSER L'UUID DU SERVICE — c'est ce qui trouve une montre DÉJÀ
|
||||
// CONNECTÉE.
|
||||
//
|
||||
// Mesuré le 2026-08-31 : 4020 advertisements vus (HomePod, Furbo…),
|
||||
// aucune Polar. Normal — **un appareil BLE connecté cesse
|
||||
// d'émettre**. La Vantage est liée à l'iPhone par l'app Flow : elle
|
||||
// est donc invisible au scan, par conception, et l'attendre était
|
||||
// sans espoir.
|
||||
//
|
||||
// Avec un `uuids` non nil, `search()` emprunte une autre branche :
|
||||
// `manager.retrieveConnectedPeripherals(withServices: uuids!)`, qui
|
||||
// rend les périphériques déjà connectés au système exposant ce
|
||||
// service, et leur fabrique une session. Passer `nil` — ce que
|
||||
// faisait ce code — n'exécutait jamais cette branche.
|
||||
listener.search([BlePsFtpClient.PSFTP_SERVICE],
|
||||
identifiers: nil, fetchKnownDevices: true)
|
||||
.sink(receiveCompletion: { [weak self] fin in
|
||||
// Distinguer « le publisher a terminé » de « il n'a
|
||||
// jamais rien dit » : dans le second cas, `flatMap`
|
||||
// n'a pas été atteint, donc `monitorBleState()` n'a
|
||||
// jamais émis `.poweredOn` — le scan n'a pas démarré.
|
||||
if case .failure(let e) = fin {
|
||||
self?.derniereErreurSdk = String(describing: e)
|
||||
}
|
||||
self?.publisherAParle = true
|
||||
rendre()
|
||||
},
|
||||
receiveValue: { [weak self] session in
|
||||
self?.publisherAParle = true
|
||||
vues.append(session)
|
||||
})
|
||||
.store(in: &abonnements)
|
||||
|
||||
|
||||
// La recherche ne se termine pas d'elle-même : on lui donne une
|
||||
// fenêtre, puis on travaille avec ce qu'on a vu.
|
||||
queue.asyncAfter(deadline: .now() + fenetre) { rendre() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Ce qu'on peut dire d'un appareil vu, pour l'afficher.
|
||||
static func etiquette(_ session: BleDeviceSession) -> String {
|
||||
let c = session.advertisementContent
|
||||
let nom = c.name.isEmpty ? "(sans nom)" : c.name
|
||||
return c.polarDeviceId.isEmpty ? nom : "\(nom) [\(c.polarDeviceId)]"
|
||||
}
|
||||
|
||||
/// Vrai si l'appareil a une chance d'être une montre Polar.
|
||||
///
|
||||
/// ⚠️ Volontairement LARGE : mieux vaut tenter une session de trop que
|
||||
/// d'exclure la montre et conclure qu'elle n'existe pas.
|
||||
static func ressembleAPolar(_ session: BleDeviceSession) -> Bool {
|
||||
let c = session.advertisementContent
|
||||
if !c.polarDeviceId.isEmpty { return true }
|
||||
if !c.polarDeviceType.isEmpty { return true }
|
||||
let nom = c.name.lowercased()
|
||||
return nom.contains("polar") || nom.contains("vantage") || nom.contains("grit")
|
||||
}
|
||||
|
||||
/// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout
|
||||
/// du délai.
|
||||
///
|
||||
/// ⚠️ Conservé pour d'autres usages, mais NE PAS s'en servir pour l'état
|
||||
/// BLE : `blePowered()` lit le manager, pas le sujet que `search()` écoute.
|
||||
///
|
||||
/// Sonder plutôt qu'écouter : l'état du SDK se lit (`blePowered()`), et
|
||||
/// c'est justement cette lecture qui instancie le CBCentralManager. Le
|
||||
/// premier appel réveille, les suivants observent.
|
||||
static func attendre(seconds: Double, _ condition: @escaping () -> Bool) async -> Bool {
|
||||
let echeance = Date().addingTimeInterval(seconds)
|
||||
while Date() < echeance {
|
||||
if condition() { return true }
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
|
||||
/// Court `operation` avec une échéance, et lève si elle est dépassée.
|
||||
///
|
||||
/// Le SDK Polar rend des `async` sans limite de temps : une montre qui ne
|
||||
/// répond pas fige l'appel indéfiniment. Un envoi qui n'aboutit pas doit
|
||||
/// dire pourquoi, pas tourner en silence.
|
||||
static func avecEcheance<T: Sendable>(seconds: Double,
|
||||
_ operation: @escaping @Sendable () async throws -> T)
|
||||
async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { groupe in
|
||||
groupe.addTask { try await operation() }
|
||||
groupe.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw PolarPftpError.echeanceDepassee(seconds)
|
||||
}
|
||||
guard let premier = try await groupe.next() else {
|
||||
throw PolarPftpError.echeanceDepassee(seconds)
|
||||
}
|
||||
groupe.cancelAll()
|
||||
return premier
|
||||
}
|
||||
}
|
||||
|
||||
private func put(_ client: BlePsFtpClient, _ etape: PftpStep) async throws {
|
||||
let entree = InputStream(data: etape.data)
|
||||
// Même raison que pour waitPsFtpReady : une écriture qui n'aboutit pas
|
||||
// doit échouer, pas figer l'app.
|
||||
// `write` rend un flux de progression ; on le consomme jusqu'au bout,
|
||||
// et l'absence d'erreur vaut acquittement — c'est l'équivalent BLE du
|
||||
// `05 00 00` observé en USB.
|
||||
try await Self.avecEcheance(seconds: 20) {
|
||||
for try await _ in client.write(etape.header() as NSData, data: entree) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
/// Vérifie que le Bluetooth est utilisable, et dit pourquoi il ne l'est pas.
|
||||
///
|
||||
/// Ne scanne rien : elle instancie un `CBCentralManager` et lit son premier
|
||||
/// état. C'est cette instanciation qui provoque la demande d'autorisation iOS
|
||||
/// — et donc l'apparition de la ligne « Bluetooth » dans les réglages de l'app.
|
||||
private final class SondeBluetooth: NSObject, CBCentralManagerDelegate {
|
||||
private var manager: CBCentralManager?
|
||||
private var suite: CheckedContinuation<CBManagerState, Never>?
|
||||
private var repondu = false
|
||||
private var vus = Set<UUID>()
|
||||
|
||||
func attendreEtatUtilisable(timeout: Double) async throws {
|
||||
let etat = await withCheckedContinuation { (c: CheckedContinuation<CBManagerState, Never>) in
|
||||
suite = c
|
||||
// `showPowerAlert: false` : c'est nous qui expliquons, pas une
|
||||
// alerte système au milieu d'un envoi.
|
||||
manager = CBCentralManager(delegate: self, queue: nil,
|
||||
options: [CBCentralManagerOptionShowPowerAlertKey: false])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in
|
||||
self?.repondre(self?.manager?.state ?? .unknown)
|
||||
}
|
||||
}
|
||||
switch etat {
|
||||
case .poweredOn:
|
||||
return
|
||||
case .unauthorized:
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"coach n'a pas l'autorisation d'utiliser le Bluetooth. "
|
||||
+ "Réglages → coach → activer Bluetooth.")
|
||||
case .poweredOff:
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"le Bluetooth est désactivé sur l'iPhone.")
|
||||
case .unsupported:
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"cet appareil ne prend pas en charge le Bluetooth LE.")
|
||||
default:
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"le Bluetooth n'a pas répondu (état « \(etat.rawValue) »). "
|
||||
+ "Réessayer ; si ça persiste, redémarrer l'app.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Compte les appareils BLE qu'un scan CoreBluetooth NU voit, sans le SDK.
|
||||
///
|
||||
/// Sert à trancher un diagnostic, pas à travailler : si CoreBluetooth voit
|
||||
/// des appareils et que le SDK n'en remonte aucun, le problème est dans
|
||||
/// notre usage du SDK ; si les deux voient zéro, il est dans la radio ou
|
||||
/// l'environnement. Sans cette mesure on ne peut que deviner — ce qui a
|
||||
/// coûté trois allers-retours le 2026-08-31.
|
||||
func compterAppareils(pendant: Double) async -> Int {
|
||||
vus.removeAll()
|
||||
manager?.scanForPeripherals(withServices: nil,
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
||||
try? await Task.sleep(nanoseconds: UInt64(pendant * 1_000_000_000))
|
||||
manager?.stopScan()
|
||||
return vus.count
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
vus.insert(peripheral.identifier)
|
||||
}
|
||||
|
||||
private func repondre(_ etat: CBManagerState) {
|
||||
guard !repondu else { return }
|
||||
repondu = true
|
||||
suite?.resume(returning: etat)
|
||||
suite = nil
|
||||
}
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
// `.unknown` est l'état transitoire du démarrage : ne pas conclure
|
||||
// dessus, le vrai état suit.
|
||||
if central.state != .unknown { repondre(central.state) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension CBDeviceListenerImpl {
|
||||
/// Vrai dès que le listener PUBLIE l'état `.poweredOn` — c'est-à-dire dès
|
||||
/// que `search()` pourra franchir son filtre.
|
||||
///
|
||||
/// `monitorBleState()` rend un `CurrentValueSubject`, donc l'abonnement
|
||||
/// reçoit immédiatement la valeur courante (`.unknown` au départ) puis les
|
||||
/// suivantes. On attend la première qui vaut `.poweredOn`.
|
||||
/// Porte son échéance elle-même plutôt que d'être enveloppée : le listener
|
||||
/// n'est pas `Sendable`, et le faire traverser une TaskGroup se heurterait
|
||||
/// à la concurrence stricte de Swift 6.
|
||||
func premierEtatPret(timeout: Double) async -> Bool {
|
||||
await withCheckedContinuation { suite in
|
||||
let boite = BoiteJeton()
|
||||
boite.jeton = monitorBleState()
|
||||
.sink(receiveCompletion: { _ in boite.rendre(false, suite) },
|
||||
receiveValue: { etat in
|
||||
if etat == .poweredOn { boite.rendre(true, suite) }
|
||||
})
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
|
||||
boite.rendre(false, suite)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Garde l'abonnement en vie et garantit une reprise unique de la continuation
|
||||
/// — la reprendre deux fois est un crash, et il y a ici deux chemins de sortie
|
||||
/// concurrents : l'état publié et l'échéance.
|
||||
private final class BoiteJeton: @unchecked Sendable {
|
||||
var jeton: AnyCancellable?
|
||||
private var rendu = false
|
||||
private let verrou = NSLock()
|
||||
|
||||
func rendre(_ valeur: Bool, _ suite: CheckedContinuation<Bool, Never>) {
|
||||
verrou.lock()
|
||||
defer { verrou.unlock() }
|
||||
guard !rendu else { return }
|
||||
rendu = true
|
||||
suite.resume(returning: valeur)
|
||||
jeton?.cancel()
|
||||
jeton = nil
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,10 @@
|
||||
// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z',
|
||||
// });
|
||||
// // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...]
|
||||
// // r.available = false si HealthKit est indisponible ou non autorisé
|
||||
// // r.available = false si HealthKit est indisponible, si le type manque,
|
||||
// // ou si l'autorisation n'a jamais été demandée (`.notDetermined`).
|
||||
// // ⚠️ Un refus EXPLICITE reste indiscernable d'une absence de données :
|
||||
// // HealthKit ne le révèle pas en lecture, par conception (cf. readSamples).
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
@@ -68,6 +71,28 @@ public class CoachSleepPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"])
|
||||
return
|
||||
}
|
||||
/* ⚠️ Sans ce test, « on ne nous laisse plus regarder » et « il n'a pas
|
||||
dormi » rendaient la MÊME réponse : `{available: true, samples: []}`.
|
||||
Le serveur enregistrait alors des nuits vides, et `sleep_score.py`
|
||||
calculait une régularité de coucher sur des trous — une valeur fausse,
|
||||
pas une valeur manquante. L'en-tête de ce fichier promettait pourtant
|
||||
`available = false` si non autorisé. § 1.3 du diagnostic du 24/09/2026.
|
||||
|
||||
Ce que HealthKit permet, et ce qu'il ne permet pas : Apple ne
|
||||
distingue PAS volontairement « refusé » de « vide » en LECTURE, pour
|
||||
ne pas révéler qu'une donnée existe. `.sharingDenied` n'est donc pas
|
||||
fiable pour un type lu. En revanche `.notDetermined` l'est, et c'est
|
||||
le cas fréquent : réinstallation de l'app, permission jamais demandée.
|
||||
On ne traite que celui-là — le reste serait une affirmation qu'Apple
|
||||
ne garantit pas. */
|
||||
if store.authorizationStatus(for: sleepType) == .notDetermined {
|
||||
call.resolve([
|
||||
"available": false,
|
||||
"samples": [],
|
||||
"reason": "authorization not determined",
|
||||
])
|
||||
return
|
||||
}
|
||||
guard let start = Self.parseISO(call.getString("startDate")),
|
||||
let end = Self.parseISO(call.getString("endDate")) else {
|
||||
call.reject("startDate et endDate requis (ISO 8601)")
|
||||
|
||||
@@ -13,10 +13,18 @@
|
||||
// forme: { score: 72, label:"Prêt" }
|
||||
// });
|
||||
import Foundation
|
||||
import os
|
||||
import Capacitor
|
||||
import WidgetKit
|
||||
import WatchConnectivity
|
||||
|
||||
/// ⚠️ Ce pont transporte les bornes de zones cardiaques vers la montre. Il était
|
||||
/// le seul chemin WatchConnectivity du projet à ne rien journaliser — quatre
|
||||
/// sorties silencieuses sur sept lignes — alors que `CoachRoutineBridge`, qui
|
||||
/// porte des exercices de mobilité, journalise et remonte ses échecs.
|
||||
/// Diagnostic du 24/09/2026 : `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
|
||||
private let widgetLog = Logger(subsystem: "ch.hypnotruck.coach", category: "widget")
|
||||
|
||||
@objc(CoachWidgetBridgePlugin)
|
||||
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "CoachWidgetBridgePlugin"
|
||||
@@ -164,8 +172,15 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
||||
pushToWatch(snapshot)
|
||||
call.resolve(["ok": true])
|
||||
// Le statut d'envoi remonte au JS : jusqu'ici, la page n'avait aucun
|
||||
// moyen de savoir si les bornes cardiaques étaient arrivées au poignet.
|
||||
// Champs AJOUTÉS, `ok` conservé : aucun appelant existant ne casse.
|
||||
let raison = pushToWatch(snapshot)
|
||||
call.resolve([
|
||||
"ok": true,
|
||||
"watchPushed": raison == nil,
|
||||
"watchReason": raison ?? "",
|
||||
])
|
||||
}
|
||||
|
||||
/// Envoie le snapshot à l'Apple Watch pour ses complications. L'App Group
|
||||
@@ -173,14 +188,50 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
/// de SA montre, alimenté ici via WatchConnectivity.
|
||||
/// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente
|
||||
/// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée.
|
||||
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) {
|
||||
guard WCSession.isSupported() else { return }
|
||||
/// Rend la raison de l'échec, ou `nil` si le snapshot est parti.
|
||||
///
|
||||
/// ⚠️ Un échec ici ne fait PAS échouer `setSnapshot` : l'App Group est déjà
|
||||
/// écrit et les widgets iPhone rafraîchis. Une montre absente ou verrouillée
|
||||
/// n'invalide pas l'opération — d'où un `resolve` enrichi plutôt qu'un
|
||||
/// `reject`, contrairement à `CoachRoutineBridge` où l'envoi EST l'opération.
|
||||
@discardableResult
|
||||
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) -> String? {
|
||||
guard WCSession.isSupported() else {
|
||||
widgetLog.info("snapshot non poussé : WatchConnectivity non supporté")
|
||||
return "WatchConnectivity non supporté"
|
||||
}
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated,
|
||||
let data = try? JSONEncoder().encode(snapshot) else { return }
|
||||
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent
|
||||
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
|
||||
try? session.updateApplicationContext(["widgetSnapshot": data])
|
||||
guard session.activationState == .activated else {
|
||||
widgetLog.error("snapshot non poussé : session non activée (état \(session.activationState.rawValue, privacy: .public))")
|
||||
return "session WatchConnectivity non activée"
|
||||
}
|
||||
// `isPaired` / `isWatchAppInstalled` comme dans CoachRoutineBridge : sans
|
||||
// eux, l'absence de montre se confond avec un échec d'envoi.
|
||||
guard session.isPaired else {
|
||||
widgetLog.info("snapshot non poussé : aucune montre appairée")
|
||||
return "aucune Watch appairée"
|
||||
}
|
||||
guard session.isWatchAppInstalled else {
|
||||
widgetLog.info("snapshot non poussé : app Watch non installée")
|
||||
return "app Watch non installée"
|
||||
}
|
||||
guard let data = try? JSONEncoder().encode(snapshot) else {
|
||||
widgetLog.error("snapshot non poussé : encodage JSON impossible")
|
||||
return "encodage du snapshot impossible"
|
||||
}
|
||||
do {
|
||||
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent
|
||||
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
|
||||
try session.updateApplicationContext(["widgetSnapshot": data])
|
||||
// Les zones sont la raison d'être de ce log : savoir QUELLES bornes
|
||||
// sont au poignet, et depuis quand, sans avoir à ouvrir la montre.
|
||||
let z = snapshot.zones
|
||||
widgetLog.info("snapshot poussé vers la Watch · zones=\(z != nil ? "oui" : "non", privacy: .public) depuis=\(z?.zonesSince ?? "?", privacy: .public)")
|
||||
return nil
|
||||
} catch {
|
||||
widgetLog.error("updateApplicationContext a échoué : \(error.localizedDescription, privacy: .public)")
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Relit le snapshot RÉELLEMENT stocké dans l'App Group.
|
||||
|
||||
@@ -69,6 +69,21 @@ struct CoachWidgetSnapshot: Codable {
|
||||
var kcalToday: Double? = nil
|
||||
var kcalGoal: Double? = nil
|
||||
|
||||
/// Macros du jour et leurs cibles (25/09/2026, pour le suivi au poignet).
|
||||
/// Mêmes règles que l'énergie : les totaux viennent du journal, les cibles
|
||||
/// de `nutrition_goals.py` — rien n'est écrit en dur, pour qu'un changement
|
||||
/// d'objectif suive.
|
||||
/// ⚠️ Les cibles portent côté serveur une forme `{target, min, max}` :
|
||||
/// `widget-bridge.js` n'en pousse que `target`. Une jauge sur une cible de
|
||||
/// type « min » (les protéines) se lit donc comme un plancher atteint, pas
|
||||
/// comme un plafond à ne pas dépasser.
|
||||
var proteinToday: Double? = nil
|
||||
var proteinGoal: Double? = nil
|
||||
var fatToday: Double? = nil
|
||||
var fatGoal: Double? = nil
|
||||
var carbsToday: Double? = nil
|
||||
var carbsGoal: Double? = nil
|
||||
|
||||
/// Boutons de saisie rapide, poussés depuis les préréglages de boisson.
|
||||
/// `nil` ⇒ repli aligné sur les préréglages par défaut du serveur.
|
||||
var quickDrinks: [QuickDrink]? = nil
|
||||
@@ -237,6 +252,13 @@ extension CoachWidgetSnapshot {
|
||||
s.waterMlToday = 0
|
||||
s.coffeeCountToday = 0
|
||||
s.kcalToday = 0
|
||||
// ⚠️ Les macros se périment avec l'énergie. Les oublier ici afficherait
|
||||
// les grammes d'hier sous la date du jour — exactement le défaut que
|
||||
// `asOf` existe pour éviter, et qui avait survécu un temps sur les
|
||||
// totaux de boisson.
|
||||
s.proteinToday = 0
|
||||
s.fatToday = 0
|
||||
s.carbsToday = 0
|
||||
s.routine = nil
|
||||
// L'activité de la veille n'est pas celle du jour, et le score de forme
|
||||
// du jour n'est calculé qu'après la nuit : afficher celui d'hier comme
|
||||
|
||||
@@ -40,13 +40,43 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "sendInterval", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "scheduledWorkouts", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
@objc func isAvailable(_ call: CAPPluginCall) {
|
||||
if #available(iOS 17.0, *) {
|
||||
call.resolve(["available": true, "min_ios": "17.0"])
|
||||
} else {
|
||||
guard #available(iOS 17.0, *) else {
|
||||
call.resolve(["available": false, "reason": "iOS 17+ required for WorkoutKit"])
|
||||
return
|
||||
}
|
||||
// `available` disait « iOS 17+ », rien de plus : une autorisation
|
||||
// révoquée ou jamais accordée rendait exactement la même réponse.
|
||||
Task {
|
||||
let state = await WorkoutScheduler.shared.authorizationState
|
||||
call.resolve([
|
||||
"available": true,
|
||||
"min_ios": "17.0",
|
||||
"supported": WorkoutScheduler.isSupported,
|
||||
"authorization": Self.describe(state),
|
||||
"authorized": state == .authorized,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Ce que la montre a réellement en attente, interrogeable sans rien envoyer.
|
||||
@objc func scheduledWorkouts(_ call: CAPPluginCall) {
|
||||
guard #available(iOS 17.0, *) else {
|
||||
call.reject("WorkoutKit requires iOS 17.0 or later")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
let state = await WorkoutScheduler.shared.authorizationState
|
||||
let scheduled = await Self.scheduledSummaries()
|
||||
call.resolve([
|
||||
"authorization": Self.describe(state),
|
||||
"authorized": state == .authorized,
|
||||
"count": scheduled.count,
|
||||
"scheduled": scheduled,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +161,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await sendCustomWorkout(
|
||||
let scheduled = try await sendCustomWorkout(
|
||||
activity: activity,
|
||||
displayName: displayName,
|
||||
warmupMin: warmupMin,
|
||||
@@ -143,9 +173,12 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
"sent": true,
|
||||
"activityUsed": Int(activity.rawValue),
|
||||
"activityFallback": activity != requestedActivity,
|
||||
// Ce que le scheduler liste APRÈS l'envoi : c'est la preuve,
|
||||
// le reste n'est qu'une intention.
|
||||
"scheduled": scheduled,
|
||||
])
|
||||
} catch {
|
||||
call.reject("Failed to send workout: \(error.localizedDescription)")
|
||||
call.reject(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +232,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange<Double>?, isWork: Bool, label: String?)],
|
||||
repeats: Int,
|
||||
cooldownMin: Double
|
||||
) async throws {
|
||||
) async throws -> [[String: Any]] {
|
||||
|
||||
// 1. Warmup — un WorkoutStep optionnel. Si warmupMin == 0, on passe
|
||||
// nil au CustomWorkout (la signature accepte WorkoutStep?). Passer
|
||||
@@ -273,11 +306,24 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
// 8. Demande d'autorisation explicite (idempotent — déclenche dialog
|
||||
// iOS la 1re fois, return immédiat si déjà accordé).
|
||||
//
|
||||
// ⚠️ L'échec ne se ravale plus. Il l'était jusqu'au 03/09/2026 : un
|
||||
// NSLog, puis on continuait comme si de rien n'était. Une
|
||||
// réinstallation de l'app remet l'autorisation à `notDetermined`
|
||||
// (celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre du
|
||||
// silence de la montre — cf. GUIDE-MONTRE.md §5quater), et rien à
|
||||
// l'écran n'aurait dit que plus aucune séance n'arrivait au poignet.
|
||||
do {
|
||||
try await WorkoutScheduler.shared.requestAuthorization()
|
||||
NSLog("[CoachWorkoutKit] requestAuthorization OK")
|
||||
} catch {
|
||||
NSLog("[CoachWorkoutKit] requestAuthorization failed: \(error)")
|
||||
throw CoachWorkoutKitError.authorization(error.localizedDescription)
|
||||
}
|
||||
let authState = await WorkoutScheduler.shared.authorizationState
|
||||
guard authState == .authorized else {
|
||||
NSLog("[CoachWorkoutKit] authorizationState = %@", String(describing: authState))
|
||||
throw CoachWorkoutKitError.notAuthorized(Self.describe(authState))
|
||||
}
|
||||
|
||||
// 8bis. Efface les workouts déjà programmés par l'app avant d'en
|
||||
@@ -299,5 +345,67 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
)
|
||||
try await WorkoutScheduler.shared.schedule(plan, at: comps)
|
||||
NSLog("[CoachWorkoutKit] schedule OK at \(scheduleDate)")
|
||||
|
||||
// 10. Relecture : ce qui compte n'est pas que `schedule()` soit rentré
|
||||
// sans erreur, c'est que la séance soit RÉELLEMENT dans la liste du
|
||||
// scheduler. C'est la seule chose qui réponde, sans aller regarder
|
||||
// la montre, à « est-ce que ma séance est programmée ? ».
|
||||
// ⚠️ Une liste vide n'est PAS traitée comme un échec : le scheduler
|
||||
// est asynchrone et rien chez Apple ne garantit qu'il ait publié la
|
||||
// séance à l'instant où on le relit. Inventer une erreur à chaque
|
||||
// envoi coûterait plus cher que le silence qu'on cherche. On rend
|
||||
// ce qu'on voit, l'appelant le dit.
|
||||
return await Self.scheduledSummaries()
|
||||
}
|
||||
|
||||
/// Les séances programmées PAR CETTE APP, telles que le scheduler les rend.
|
||||
@available(iOS 17.0, *)
|
||||
private static func scheduledSummaries() async -> [[String: Any]] {
|
||||
let plans = await WorkoutScheduler.shared.scheduledWorkouts
|
||||
return plans.map { sw in
|
||||
var out: [String: Any] = [
|
||||
"date": ISO8601DateFormatter().string(
|
||||
from: Calendar.current.date(from: sw.date) ?? Date()
|
||||
),
|
||||
"complete": sw.complete,
|
||||
]
|
||||
if case let .custom(custom) = sw.plan.workout {
|
||||
// `displayName` est optionnel côté Apple : ne jamais poser un
|
||||
// Optional dans le dictionnaire rendu au JS, il n'est pas
|
||||
// sérialisable par le bridge Capacitor.
|
||||
out["displayName"] = custom.displayName ?? ""
|
||||
out["blocks"] = custom.blocks.count
|
||||
out["steps"] = custom.blocks.reduce(0) { $0 + $1.steps.count }
|
||||
out["iterations"] = custom.blocks.map { $0.iterations }
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private static func describe(_ state: WorkoutScheduler.AuthorizationState) -> String {
|
||||
switch state {
|
||||
case .authorized: return "authorized"
|
||||
case .denied: return "denied"
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Erreurs qui doivent remonter jusqu'à l'écran, pas jusqu'au seul NSLog.
|
||||
enum CoachWorkoutKitError: LocalizedError {
|
||||
case authorization(String)
|
||||
case notAuthorized(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .authorization(let detail):
|
||||
return "Autorisation « Séances programmées » refusée par iOS : \(detail)"
|
||||
case .notAuthorized(let state):
|
||||
return "Autorisation « Séances programmées » absente (\(state)) — "
|
||||
+ "Réglages iPhone → coach → Séances, puis renvoyer la séance."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,13 @@ import Foundation
|
||||
import Capacitor
|
||||
import HealthKit
|
||||
import UserNotifications
|
||||
// Pour l'extension `HKWorkout.workoutPlan` (iOS 17+), qui rend la composition
|
||||
// du plan dont une séance est issue — ou nil si elle a été lancée à la main.
|
||||
// Même garde que `CoachWorkoutKit.swift` : sur un SDK sans WorkoutKit, le
|
||||
// fichier doit continuer à compiler, quitte à ne rien remonter.
|
||||
#if canImport(WorkoutKit)
|
||||
import WorkoutKit
|
||||
#endif
|
||||
|
||||
@objc(CoachWorkoutObserverPlugin)
|
||||
public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
@@ -303,12 +310,29 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts")
|
||||
return
|
||||
}
|
||||
// Notifier le plus récent (par endDate) — généralement il y
|
||||
// en a 1 mais Apple peut grouper plusieurs samples.
|
||||
// ⚠️ Les rapports serveur partent pour TOUTES les séances du lot.
|
||||
// Ils n'étaient envoyés que pour `latest`, alors que l'ancre venait
|
||||
// d'être avancée quelques lignes plus haut : les autres séances du
|
||||
// lot n'étaient donc jamais rapportées, et ne reviendraient jamais.
|
||||
//
|
||||
// Le cas se produit dès que l'iPhone a manqué un réveil : renfo le
|
||||
// matin, vélo le soir, téléphone resté à la maison — au premier
|
||||
// fire, la requête ancrée rend les deux, le vélo est notifié, et la
|
||||
// routine du matin n'est jamais cochée. Le log disait « 2 new
|
||||
// workout(s), notifying latest » : exact, et parfaitement muet sur
|
||||
// ce qu'il venait de jeter.
|
||||
for seance in workouts {
|
||||
self.reportToRoutineIfStrength(seance)
|
||||
self.reportPlanOrigin(seance)
|
||||
}
|
||||
|
||||
// Une seule notification en revanche : un « 🎉 » par séance d'un
|
||||
// même lot serait du bruit. On garde la plus récente.
|
||||
let latest = workouts.max(by: { $0.endDate < $1.endDate })!
|
||||
let uuid = latest.uuid.uuidString
|
||||
UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey)
|
||||
NSLog("[CoachWorkoutObserver] %d new workout(s), notifying latest %@", workouts.count, uuid)
|
||||
NSLog("[CoachWorkoutObserver] %d nouvelle(s) séance(s) rapportée(s), notification pour %@",
|
||||
workouts.count, uuid)
|
||||
self.scheduleNotification(for: latest)
|
||||
}
|
||||
healthStore.execute(query)
|
||||
@@ -335,6 +359,37 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie une requête et **lit le statut HTTP**.
|
||||
///
|
||||
/// ⚠️ Les deux POST de ce fichier jetaient la réponse (`{ data, _, error in }`)
|
||||
/// et ne regardaient que `error`. Or un 401 sur token rotaté, un 404 ou un
|
||||
/// 500 ne produisent PAS d'`error` : `reportPlanOrigin` journalisait donc
|
||||
/// « plan-origin envoyé » sur une requête refusée, et `auto-complete`
|
||||
/// sortait en silence au `guard` de parsing. Le bon pattern existait déjà
|
||||
/// deux fichiers plus loin — `CoachRoutineBridge.postDay` vérifie
|
||||
/// `(200..<300)` et journalise le code.
|
||||
///
|
||||
/// `traiter` ne reçoit le corps que sur un statut de succès.
|
||||
private func envoyer(_ req: URLRequest, libelle: String,
|
||||
traiter: ((Data) -> Void)? = nil) {
|
||||
URLSession.shared.dataTask(with: req) { data, reponse, error in
|
||||
if let error = error {
|
||||
NSLog("[CoachWorkoutObserver] %@ : %@", libelle, error.localizedDescription)
|
||||
return
|
||||
}
|
||||
guard let http = reponse as? HTTPURLResponse else {
|
||||
NSLog("[CoachWorkoutObserver] %@ : réponse sans statut HTTP", libelle)
|
||||
return
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
NSLog("[CoachWorkoutObserver] %@ : HTTP %d — non pris en compte",
|
||||
libelle, http.statusCode)
|
||||
return
|
||||
}
|
||||
traiter?(data ?? Data())
|
||||
}.resume()
|
||||
}
|
||||
|
||||
/// Remonte au serveur la fin d'une séance de renfo, qui décide seul si elle
|
||||
/// correspond à une routine poussée dans l'app Exercice.
|
||||
///
|
||||
@@ -352,19 +407,31 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
|
||||
// ⚠️ L'activité était écrite en dur à « functionalStrengthTraining »
|
||||
// alors que le garde ci-dessus accepte AUSSI traditionalStrengthTraining :
|
||||
// une séance de musculation classique arrivait au serveur étiquetée
|
||||
// fonctionnelle. Si la règle de correspondance discrimine sur ce champ
|
||||
// — et le commentaire ci-dessus dit que les règles vivent là-bas —,
|
||||
// l'appariement était faussé.
|
||||
//
|
||||
// L'UUID et l'heure de début manquaient aussi : sans eux le serveur ne
|
||||
// peut pas dédupliquer, et il doit évaluer sa fenêtre de 12 h depuis
|
||||
// l'heure de RÉCEPTION. Une séance remontée avec six heures de retard
|
||||
// (iPhone resté à la maison) était donc datée de son arrivée.
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||
"activity": "functionalStrengthTraining",
|
||||
"activity": activity == .traditionalStrengthTraining
|
||||
? "traditionalStrengthTraining" : "functionalStrengthTraining",
|
||||
"duration_min": workout.duration / 60,
|
||||
"uuid": workout.uuid.uuidString,
|
||||
"start": ISO8601DateFormatter().string(from: workout.startDate),
|
||||
])
|
||||
req.timeoutInterval = 15
|
||||
|
||||
URLSession.shared.dataTask(with: req) { data, _, error in
|
||||
if let error = error {
|
||||
NSLog("[CoachWorkoutObserver] auto-complete routine: %@", error.localizedDescription)
|
||||
envoyer(req, libelle: "auto-complete routine") { data in
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
NSLog("[CoachWorkoutObserver] auto-complete : réponse illisible")
|
||||
return
|
||||
}
|
||||
guard let data = data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
||||
if (json["matched"] as? Bool) == true {
|
||||
let n = (json["done"] as? [String])?.count ?? 0
|
||||
NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)",
|
||||
@@ -372,11 +439,68 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
} else {
|
||||
NSLog("[CoachWorkoutObserver] pas une routine (%@)", (json["reason"] as? String) ?? "?")
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Dit au serveur si la séance vient d'une **séance programmée** ou d'un
|
||||
/// démarrage à la main dans l'app Exercice.
|
||||
///
|
||||
/// C'est la seule chose qui distingue « la montre s'est tue alors qu'elle
|
||||
/// devait parler » de « la séance n'a jamais été celle qu'on avait
|
||||
/// poussée » : `tools/watch_alert_check.py` compte les alertes attendues à
|
||||
/// partir du plan ENVOYÉ, mais rien, jusqu'ici, ne disait ce qui avait été
|
||||
/// LANCÉ (cf. `coach_sportif/docs/GUIDE-MONTRE.md` §5bis, « angle mort »).
|
||||
/// Le nom HealthKit ne le dit pas : il vaut « Course extérieure » dans les
|
||||
/// deux cas.
|
||||
///
|
||||
/// Même parti pris que `reportToRoutineIfStrength` : on n'envoie que des
|
||||
/// faits bruts, l'interprétation vit côté serveur donc sans rebuild iOS.
|
||||
private func reportPlanOrigin(_ workout: HKWorkout) {
|
||||
#if canImport(WorkoutKit)
|
||||
guard #available(iOS 17.0, *) else { return }
|
||||
Task {
|
||||
var fromPlan = false
|
||||
var planName = ""
|
||||
do {
|
||||
if let plan = try await workout.workoutPlan {
|
||||
fromPlan = true
|
||||
if case let .custom(custom) = plan.workout {
|
||||
planName = custom.displayName ?? ""
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Une lecture qui échoue n'est PAS un « lancé à la main » :
|
||||
// sans réponse, on n'envoie rien plutôt qu'un faux négatif.
|
||||
NSLog("[CoachWorkoutObserver] workoutPlan illisible: %@", error.localizedDescription)
|
||||
return
|
||||
}
|
||||
guard let url = URL(string: "https://coach.hypnotruck.ch/api/workout/plan-origin") else { return }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||
"uuid": workout.uuid.uuidString,
|
||||
"start": ISO8601DateFormatter().string(from: workout.startDate),
|
||||
"duration_min": workout.duration / 60,
|
||||
"activity": Int(workout.workoutActivityType.rawValue),
|
||||
"from_plan": fromPlan,
|
||||
"plan_name": planName,
|
||||
])
|
||||
req.timeoutInterval = 15
|
||||
// Le log de succès ne part plus que sur un 2xx : il s'écrivait
|
||||
// auparavant sur un 401 ou un 500, puisque seul `error` était lu.
|
||||
self.envoyer(req, libelle: "plan-origin") { _ in
|
||||
NSLog("[CoachWorkoutObserver] plan-origin accepté (from_plan=%@)",
|
||||
fromPlan ? "true" : "false")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func scheduleNotification(for workout: HKWorkout) {
|
||||
reportToRoutineIfStrength(workout)
|
||||
reportPlanOrigin(workout)
|
||||
let durationMin = Int(workout.duration / 60)
|
||||
var bodyParts: [String] = ["\(durationMin) min"]
|
||||
if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 {
|
||||
|
||||
@@ -32,6 +32,23 @@ struct HeartRateZones: Codable, Equatable, Sendable {
|
||||
/// vers Z2-Z3. C'est le repère clinique, celui qui compte ici.
|
||||
var clinicalTargetBpm: Int?
|
||||
|
||||
/// Date de la version FIGÉE dont viennent ces bornes (`YYYY-MM-DD`), telle
|
||||
/// que servie par `/api/cardiac-zones` (`zones_since`).
|
||||
///
|
||||
/// ⚠️ Sans elle, la montre affichait des bornes sans âge. Le backend tient
|
||||
/// une version datée (`data/hr_zones_history.json`, règle 9 du CLAUDE.md
|
||||
/// web) parce qu'une référence qui bouge avec son propre bruit ne permet
|
||||
/// pas de juger une séance ; **cette date se perdait au transport**. Après
|
||||
/// une révision des bornes, rien au poignet ne distinguait les nouvelles
|
||||
/// des anciennes — et si le push échouait en silence (cf. § 1.2 du
|
||||
/// diagnostic du 24/09/2026), les anciennes restaient indéfiniment.
|
||||
var zonesSince: String? = nil
|
||||
|
||||
/// `"frozen"` (version confirmée) ou `"live"` (calcul glissant, avant la
|
||||
/// première version figée). Distingue « bornes validées » de « bornes
|
||||
/// provisoires » — la montre ne pouvait pas faire la différence.
|
||||
var zonesFrom: String? = nil
|
||||
|
||||
/// L'effort est-il dans la zone d'entraînement utile ?
|
||||
func isUseful(bpm: Int) -> Bool {
|
||||
guard let target = clinicalTargetBpm else { return false }
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Coach Hypnotruck se connecte en Bluetooth à votre montre Polar pour y déposer la séance du jour, avec ses phases et ses zones cardiaques. La connexion ne sert qu'à cet envoi et aucune donnée n'est lue sur la montre.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Coach Hypnotruck utilise l'appareil photo pour scanner les codes-barres des aliments, photographier vos assiettes et photographier les étiquettes nutritionnelles. Ces images servent à identifier l'aliment et à estimer ses valeurs nutritionnelles, puis à alimenter votre journal alimentaire.</string>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
@@ -48,6 +50,25 @@
|
||||
<string>location</string>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
@@ -36,6 +36,11 @@ class MainViewController: CAPBridgeViewController {
|
||||
// l'app watchOS, et relaie les coches faites sur la montre vers
|
||||
// /api/routine/day (cf. CoachRoutineBridge.swift).
|
||||
bridge?.registerPluginInstance(CoachRoutineBridgePlugin())
|
||||
// Écriture d'un objectif sur la Polar Vantage V3 en Bluetooth (PFTP).
|
||||
// Ne transporte que des octets produits par le serveur, et refuse toute
|
||||
// requête malformée avant d'ouvrir la radio — le firmware plante au lieu
|
||||
// de refuser (cf. CoachPolarBLE.swift et PolarPftpStep.swift).
|
||||
bridge?.registerPluginInstance(CoachPolarBLEPlugin())
|
||||
}
|
||||
|
||||
/// Injecte le cookie d'auth dans le magasin de la WKWebView.
|
||||
|
||||
246
ios/App/App/PolarPftpStep.swift
Normal file
246
ios/App/App/PolarPftpStep.swift
Normal file
@@ -0,0 +1,246 @@
|
||||
// PolarPftpStep.swift
|
||||
// Une opération d'écriture PFTP, et les garde-fous qui empêchent de planter la
|
||||
// montre. N'importe QUE Foundation, délibérément.
|
||||
//
|
||||
// POURQUOI CE FICHIER EST SÉPARÉ DU PLUGIN
|
||||
//
|
||||
// `CoachPolarBLE.swift` importe Capacitor et le SDK Polar : il ne peut être
|
||||
// compilé que sur un Mac, dans Xcode. Or ce qui est ici est exactement la
|
||||
// partie qu'il faut pouvoir vérifier — la sérialisation de l'en-tête, et les
|
||||
// deux règles qui décident si une requête part ou non vers le firmware.
|
||||
//
|
||||
// Lié dans `tests-linux/Sources/CoachModel/` par un lien symbolique : les tests
|
||||
// portent sur le fichier livré, pas sur une copie qui dériverait en silence.
|
||||
//
|
||||
// ⚠️ CE QUE CES GARDE-FOUS PROTÈGENT
|
||||
//
|
||||
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » a bloqué
|
||||
// une Polar Vantage V3 : logo Polar puis écran noir, récupérée par un appui
|
||||
// long sur OK (10-15 s). Le firmware ne renvoie pas d'erreur applicative sur
|
||||
// une requête malformée — il s'effondre. Le Bluetooth utilise le même protocole
|
||||
// PFTP que l'USB : changer de transport n'enlève rien au risque.
|
||||
//
|
||||
// Les mêmes règles existent dans `tools/polar/polar_ftp.py::pftp_put()` du
|
||||
// dépôt coach_sportif. Les deux doivent dire la même chose.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Codes d'erreur du protocole PFTP, tels que Polar les publie.
|
||||
///
|
||||
/// Source : `pftp_error.proto` du SDK officiel — 100 `UNIDENTIFIED_HOST_ERROR`,
|
||||
/// 101 `INVALID_COMMAND`, 102 `INVALID_PARAMETER`, 103 `NO_SUCH_FILE_OR_DIRECTORY`,
|
||||
/// **104 `DIRECTORY_EXISTS`**, 105 `FILE_EXISTS`, 106 `OPERATION_NOT_PERMITTED`,
|
||||
/// 107 `NO_SUCH_USER`, 108 `TIMEOUT`.
|
||||
/// https://github.com/polarofficial/polar-ble-sdk/blob/master/sources/Android/android-communications/library/src/sdk/proto/pftp_error.proto
|
||||
///
|
||||
/// Le SDK iOS les remonte tels quels par
|
||||
/// `BlePsFtpException.responseError(errorCode: Int)`.
|
||||
public enum PftpCode {
|
||||
/// Le dossier visé par un `mkdir` existe déjà.
|
||||
///
|
||||
/// ⚠️ **Ce n'est pas un échec d'envoi.** Rencontré le 01/09/2026 sur un
|
||||
/// second envoi vers la même date : la session BLE s'était bien ouverte, et
|
||||
/// c'est notre séquence de `mkdir` qui n'était pas idempotente. Le message
|
||||
/// « errorcode 104 » donnait donc à croire à un refus de la montre alors
|
||||
/// que le transport fonctionnait.
|
||||
public static let directoryExists = 104
|
||||
|
||||
/// Codes sur lesquels un `mkdir` peut être considéré comme satisfait : le
|
||||
/// dossier est là, c'est tout ce qu'on lui demandait. L'équivalent de
|
||||
/// `mkdir -p`.
|
||||
///
|
||||
/// ⚠️ Strictement limité à la création de dossier. Un `put` de fichier ne
|
||||
/// doit JAMAIS être avalé de la sorte — 105 `FILE_EXISTS` signifierait que
|
||||
/// l'objectif est déjà écrit, ce que l'appelant doit savoir.
|
||||
public static func mkdirEstSatisfait(par code: Int) -> Bool {
|
||||
code == directoryExists
|
||||
}
|
||||
}
|
||||
|
||||
/// Un PUT PFTP : un chemin, un contenu. Un contenu vide crée un dossier.
|
||||
public struct PftpStep: Equatable {
|
||||
public let path: String
|
||||
public let data: Data
|
||||
|
||||
public init(path: String, data: Data) {
|
||||
self.path = path
|
||||
self.data = data
|
||||
}
|
||||
|
||||
/// Un chemin terminé par « / » désigne un dossier, jamais un fichier.
|
||||
public var isDirectory: Bool { path.hasSuffix("/") }
|
||||
|
||||
public var describe: String {
|
||||
"\(isDirectory ? "mkdir" : "put ") \(path) (\(data.count) o)"
|
||||
}
|
||||
|
||||
/// Refuse les formes qui ont fait, ou feraient, planter la montre.
|
||||
///
|
||||
/// Dernier rempart avant l'émission : à appeler à l'entrée du plugin ET
|
||||
/// juste avant chaque écriture.
|
||||
public func validate() throws {
|
||||
if isDirectory && !data.isEmpty {
|
||||
throw PolarPftpError.malformed(
|
||||
"refus d'écrire \(data.count) octets vers « \(path) » : un chemin "
|
||||
+ "terminé par « / » désigne un dossier. C'est cette requête exacte "
|
||||
+ "qui a fait planter une Vantage V3 le 2026-08-17.")
|
||||
}
|
||||
if !isDirectory && data.isEmpty {
|
||||
throw PolarPftpError.malformed(
|
||||
"refus d'écrire un fichier vide vers « \(path) » : ajouter « / » "
|
||||
+ "pour créer un dossier, ou fournir un contenu.")
|
||||
}
|
||||
if !path.hasPrefix("/U/0/") {
|
||||
throw PolarPftpError.malformed(
|
||||
"chemin « \(path) » hors de /U/0/ : refusé par précaution, rien "
|
||||
+ "d'autre n'a jamais été écrit sur cette montre.")
|
||||
}
|
||||
}
|
||||
|
||||
/// `PbPFtpOperation { command = 1 (varint), path = 2 (string) }`.
|
||||
///
|
||||
/// Sérialisation identique à `encode_operation()` de `polar_ftp.py` :
|
||||
/// `0x08` (champ 1, varint) + commande, puis `0x12` (champ 2, délimité) +
|
||||
/// longueur + chemin. `PUT` vaut 1, d'après `pftp_request.proto` du SDK
|
||||
/// (`enum Command { GET = 0; PUT = 1; MERGE = 2; REMOVE = 3; }`).
|
||||
///
|
||||
/// ⚠️ Pas de cadrage `[0x05, taille, taille]` ici : celui-là appartient au
|
||||
/// transport série RFC76 de la version USB. En Bluetooth, le SDK cadre
|
||||
/// lui-même — l'ajouter produirait une requête malformée, c'est-à-dire
|
||||
/// exactement ce qui plante la montre.
|
||||
public func header() -> Data {
|
||||
var bytes = Data([0x08, 0x01, 0x12])
|
||||
let path = Array(self.path.utf8)
|
||||
bytes.append(contentsOf: PftpStep.varint(path.count))
|
||||
bytes.append(contentsOf: path)
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// Varint protobuf, 7 bits par octet, bit de poids fort = continuation.
|
||||
public static func varint(_ value: Int) -> [UInt8] {
|
||||
var n = value, out: [UInt8] = []
|
||||
repeat {
|
||||
var b = UInt8(n & 0x7F)
|
||||
n >>= 7
|
||||
if n > 0 { b |= 0x80 }
|
||||
out.append(b)
|
||||
} while n > 0
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
|
||||
case malformed(String)
|
||||
/// `vues` : appareils BLE aperçus · `sansPsFtp` : sans le service FEEE ·
|
||||
/// `muettes` : avec FEEE mais qui n'ont pas répondu.
|
||||
case watchNotFound(Double, vues: Int, sansPsFtp: Int, muettes: Int)
|
||||
case psftpUnavailable
|
||||
/// La radio elle-même est inutilisable : autorisation, Bluetooth éteint,
|
||||
/// matériel. Distinct de `watchNotFound` — ici on n'a même pas pu chercher.
|
||||
case bluetoothUnusable(String)
|
||||
/// Le SDK n'a remonté aucune session, mais CoreBluetooth, interrogé
|
||||
/// directement, a vu `vusParCoreBluetooth` appareils. Deux diagnostics
|
||||
/// opposés selon ce nombre.
|
||||
/// Une opération du SDK n'a pas rendu la main dans le délai imparti.
|
||||
case echeanceDepassee(Double)
|
||||
/// Le scan a vu des appareils, mais aucun ne ressemble à un Polar. Les
|
||||
/// exemples servent à voir sous quel nom la montre s'annonce réellement.
|
||||
case aucunPolarParmi(vues: Int, exemples: [String])
|
||||
case sdkSilencieux(vusParCoreBluetooth: Int,
|
||||
publisherAParle: Bool = true,
|
||||
erreurSdk: String? = nil)
|
||||
|
||||
/// ⚠️ Un échec de connexion a plusieurs causes OPPOSÉES, et un message
|
||||
/// unique les confond — c'est ce qui s'est produit au premier essai du
|
||||
/// 2026-08-31. Ne jamais fusionner ces branches.
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .malformed(let why):
|
||||
return why
|
||||
case .psftpUnavailable:
|
||||
return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu"
|
||||
case .bluetoothUnusable(let why):
|
||||
return why
|
||||
|
||||
case .aucunPolarParmi(let vues, let exemples):
|
||||
return "\(vues) appareil(s) Bluetooth vus, aucun ne ressemble à une "
|
||||
+ "montre Polar. Vus : \(exemples.joined(separator: ", ")). "
|
||||
+ "Si la montre est dans cette liste sous un autre nom, c'est le "
|
||||
+ "tri qui est trop strict ; si elle n'y est pas, elle ne "
|
||||
+ "s'annonce pas — probablement parce qu'elle est déjà liée à "
|
||||
+ "Polar Flow."
|
||||
|
||||
case .echeanceDepassee(let seconds):
|
||||
return "la montre n'a pas répondu en \(Int(seconds)) s. Réveiller son "
|
||||
+ "écran et la rapprocher de l'iPhone ; vérifier que Polar Flow "
|
||||
+ "est bien fermée."
|
||||
|
||||
|
||||
case .sdkSilencieux(_, false, _):
|
||||
// ⚠️ Ne PAS conclure « le scan n'a pas démarré » : le publisher de
|
||||
// `search()` reste également muet quand le scan tourne mais ne
|
||||
// découvre rien — `scanSubject` n'émet que sur découverte, et le
|
||||
// `prepend(knownSessions)` d'une liste vide n'émet pas. Une version
|
||||
// précédente affirmait le contraire et a fait chercher au mauvais
|
||||
// endroit pendant trois itérations.
|
||||
return "le SDK Polar n'a remonté aucun appareil : soit son scan n'a "
|
||||
+ "pas démarré, soit il tourne sans rien découvrir. L'état "
|
||||
+ "Bluetooth, lui, a bien été atteint avant le scan."
|
||||
|
||||
case .sdkSilencieux(_, _, .some(let erreur)):
|
||||
return "le SDK Polar a répondu par une erreur : \(erreur)"
|
||||
|
||||
case .sdkSilencieux(0, _, _):
|
||||
return "aucun appareil Bluetooth alentour, même en scan direct. "
|
||||
+ "La radio fonctionne mais ne voit rien : montre éteinte, hors "
|
||||
+ "de portée, ou déjà connectée à un autre appareil de façon "
|
||||
+ "exclusive."
|
||||
|
||||
case .sdkSilencieux(let bruts, _, _):
|
||||
return "\(bruts) appareil(s) Bluetooth vus en scan direct, mais le SDK "
|
||||
+ "Polar n'en remonte aucun. La radio va bien : le problème est "
|
||||
+ "dans l'intégration du SDK, pas dans la montre ni dans l'iPhone."
|
||||
|
||||
|
||||
case .watchNotFound(let seconds, 0, _, _):
|
||||
// Rien du tout : le problème est en amont de la montre.
|
||||
return "aucun appareil Bluetooth détecté en \(Int(seconds)) s. "
|
||||
+ "Vérifier, dans l'ordre : le Bluetooth activé sur l'iPhone ; "
|
||||
+ "l'autorisation Bluetooth accordée à coach (Réglages → coach) ; "
|
||||
+ "la montre allumée et à portée."
|
||||
|
||||
case .watchNotFound(let seconds, let vues, _, let muettes) where muettes > 0:
|
||||
// Le service est là mais ne répond pas : canal déjà pris.
|
||||
//
|
||||
// ⚠️ Ce message conseillait « fermer l'app Polar Flow ». C'était
|
||||
// faux, et mesuré comme tel le 31/08/2026 : la montre affichait
|
||||
// « connexion impossible » pendant que les réglages iOS la
|
||||
// disaient TOUJOURS connectée à Flow. iOS maintient le lien d'un
|
||||
// accessoire appairé, app fermée ou non — fermer l'app ne rend pas
|
||||
// le canal.
|
||||
//
|
||||
// Ce qui l'a rendu, le 01/09 : une montre fraîchement
|
||||
// réinitialisée, donc pas encore liée à Flow. Redémarrer la montre
|
||||
// libère aussi son canal, mais Flow le reprend.
|
||||
return "\(vues) appareil(s) vu(s), \(muettes) portant PsFTP mais sans "
|
||||
+ "réponse en \(Int(seconds)) s. Le canal PsFTP de la Vantage "
|
||||
+ "n'accepte qu'une session, et Polar Flow la détient tant que "
|
||||
+ "la montre lui est appairée — fermer l'app n'y change rien, "
|
||||
+ "iOS maintient le lien. Redémarrer la montre (appui long sur "
|
||||
+ "OK) libère le canal ; il faut écrire dans la foulée, avant "
|
||||
+ "que Flow ne le reprenne. Chemin fiable : le câble."
|
||||
|
||||
case .watchNotFound(let seconds, let vues, _, _):
|
||||
// Des appareils, mais aucun ne porte le service.
|
||||
// ⚠️ Ce message parlait d'« inconnue restante ». Elle a été levée le
|
||||
// 31/08/2026 : la montre EST atteignable et expose bien PsFTP
|
||||
// (« 12 appareils vus, 1 portant PsFTP ») — quand elle est
|
||||
// connectée. N'en voir aucun est donc autre chose : montre éteinte,
|
||||
// hors de portée, ou pas encore connectée à l'iPhone.
|
||||
return "\(vues) appareil(s) Bluetooth vu(s) en \(Int(seconds)) s, aucun "
|
||||
+ "n'expose PsFTP. La Vantage expose bien ce service quand elle "
|
||||
+ "est connectée (vérifié le 31/08) : la chercher plutôt du côté "
|
||||
+ "de la montre — allumée, à portée, et reliée à l'iPhone."
|
||||
}
|
||||
}
|
||||
}
|
||||
84
ios/App/App/SceneDelegate.swift
Normal file
84
ios/App/App/SceneDelegate.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
#if canImport(GoogleSignIn)
|
||||
import GoogleSignIn
|
||||
#endif
|
||||
|
||||
/// Cycle de vie par scènes, **obligatoire** depuis iOS 27 : « apps built with
|
||||
/// the latest SDK must adopt the scene-based life cycle or they fail to launch »
|
||||
/// (Apple, *Transitioning to the UIKit scene-based life cycle*). Sans ce
|
||||
/// fichier et le `UIApplicationSceneManifest` de l'Info.plist, l'app ne démarre
|
||||
/// pas du tout — c'est un assert au lancement, pas un avertissement.
|
||||
///
|
||||
/// ⚠️ **Ce qui arrive ici arrive en SILENCE.** Une fois le manifeste en place,
|
||||
/// UIKit cesse d'appeler `application(_:open:options:)`,
|
||||
/// `application(_:continue:restorationHandler:)` et `applicationDidBecomeActive`
|
||||
/// sur l'AppDelegate — « even if you keep them implemented » (Apple). Rien ne
|
||||
/// casse à la compilation : le retour OAuth Google part vers Safari et ne
|
||||
/// revient jamais, et les saisies du widget restent bloquées dans l'App Group.
|
||||
/// Les trois sont donc repris ci-dessous.
|
||||
///
|
||||
/// Ce que la migration NE change PAS, et c'est ce qui la rendait acceptable :
|
||||
/// `application(_:didFinishLaunchingWithOptions:)` reste appelé, et toujours
|
||||
/// **avant** que la scène ne se connecte — donc avant que la WKWebView charge.
|
||||
/// L'injection du cookie d'auth garde son ordre (Apple documente la séquence :
|
||||
/// launch → création des scènes → `scene(_:willConnectTo:)`). Les callbacks de
|
||||
/// notification distante ne bougent pas non plus : `UISceneDelegate` n'a aucun
|
||||
/// équivalent, et ils ne sont pas dépréciés.
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
|
||||
// ⚠️ Ne PAS conditionner cette création à `window == nil`.
|
||||
//
|
||||
// C'était ma première version, par prudence : le manifeste déclarant
|
||||
// `UISceneStoryboardFile = Main`, je supposais qu'UIKit avait déjà
|
||||
// monté la fenêtre et qu'il ne fallait pas l'écraser. Résultat sur
|
||||
// appareil : **écran noir permanent, sans la moindre erreur**. UIKit
|
||||
// fournit bien une fenêtre, mais ne la rend pas visible à notre place ;
|
||||
// la garde sautait donc `makeKeyAndVisible()` et rien ne s'affichait.
|
||||
//
|
||||
// Le template de Capacitor crée la fenêtre sans condition, et c'est le
|
||||
// chemin éprouvé. Le seul écart nécessaire est `MainViewController` au
|
||||
// lieu d'un `CAPBridgeViewController` nu : notre sous-classe enregistre
|
||||
// les neuf plugins in-app dans `capacitorDidLoad()`. Elle ne dépend de
|
||||
// rien du storyboard, donc l'instancier ici est sans perte.
|
||||
let fenetre = UIWindow(windowScene: windowScene)
|
||||
fenetre.rootViewController = MainViewController()
|
||||
window = fenetre
|
||||
fenetre.makeKeyAndVisible()
|
||||
|
||||
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
// Retour du flux OAuth Google. `handle(url)` prend UNE url alors que la
|
||||
// scène en livre un ensemble : on laisse à Capacitor ce que Google n'a
|
||||
// pas consommé, plutôt que de tout lui passer ou de tout lui cacher.
|
||||
var restants = URLContexts
|
||||
#if canImport(GoogleSignIn)
|
||||
restants = URLContexts.filter { !GIDSignIn.sharedInstance.handle($0.url) }
|
||||
#endif
|
||||
guard !restants.isEmpty else { return }
|
||||
SceneDelegateProxy.shared.scene(scene, openURLContexts: restants)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
// Universal Links : les .widgetURL des widgets, de la Live Activity et
|
||||
// des complications Watch passent par là. Sans ce relais, ils
|
||||
// ouvriraient Safari, où le cookie injecté dans la WKWebView n'existe
|
||||
// pas.
|
||||
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
// Reprend `applicationDidBecomeActive`, qu'UIKit n'appelle plus. Les
|
||||
// saisies faites depuis le widget (eau, café) attendent dans l'App
|
||||
// Group : une extension n'a ni session ni droit de tenir une requête.
|
||||
Task { await CoachQuickSync.flush() }
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import PackageDescription
|
||||
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
||||
let package = Package(
|
||||
name: "CapApp-SPM",
|
||||
platforms: [.iOS(.v15)],
|
||||
platforms: [.iOS(.v"$)],
|
||||
products: [
|
||||
.library(
|
||||
name: "CapApp-SPM",
|
||||
|
||||
@@ -17,41 +17,14 @@ import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Intents
|
||||
|
||||
/// Ajoute une boisson sans quitter l'écran d'accueil.
|
||||
@available(iOS 17.0, watchOS 10.0, *)
|
||||
struct LogDrinkIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Noter une boisson"
|
||||
/// `false` : tout l'intérêt est de NE PAS ouvrir l'app.
|
||||
static var openAppWhenRun: Bool = false
|
||||
|
||||
@Parameter(title: "Type") var ref: String
|
||||
@Parameter(title: "Volume (ml)") var amountMl: Double
|
||||
|
||||
init() {}
|
||||
init(ref: String, amountMl: Double) {
|
||||
self.ref = ref
|
||||
self.amountMl = amountMl
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
let ok = CoachQuickLog.append(
|
||||
CoachQuickEntry(kind: .drink, ref: ref, amount: amountMl)
|
||||
)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
// Un échec d'App Group doit remonter : afficher « ajouté » sur une
|
||||
// saisie perdue est pire que ne rien afficher.
|
||||
guard ok else { throw CoachQuickError.unavailable }
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible {
|
||||
case unavailable
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
"Stockage partagé indisponible — ouvrez l'app une fois."
|
||||
}
|
||||
}
|
||||
//
|
||||
// ⚠️ `LogDrinkIntent` et `CoachQuickError` ont DÉMÉNAGÉ dans
|
||||
// `ios/App/App/CoachIntents.swift` le 25/09/2026, pour être partagés entre
|
||||
// trois cibles : ce widget, l'app (d'où Siri les découvre) et le widget Watch.
|
||||
// Les laisser ici les rendait invisibles aux deux autres — Siri n'avait aucun
|
||||
// intent à écouter, et la montre ne savait pas noter un verre.
|
||||
// ⛔ Ne pas les redéclarer ici : deux définitions du même symbole dans une
|
||||
// cible qui compile les deux fichiers, et le build casse.
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
|
||||
@@ -15,9 +15,32 @@ import Foundation
|
||||
*
|
||||
* - 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 ;
|
||||
* - le temps de référence est poussé par l'appelant ;
|
||||
*
|
||||
* ⚠️ **CE COMMENTAIRE AFFIRMAIT LE CONTRAIRE DE SA SOURCE** (relevé le
|
||||
* 2026-08-20, avant tout câblage). Il disait que
|
||||
* `HKLiveWorkoutBuilder.elapsedTime` « exclut déjà les pauses », donc que
|
||||
* le moteur n'avait rien à savoir de la pause. La doc Apple dit
|
||||
* l'inverse, mot pour mot : « The elapsed time for the workout based on
|
||||
* the builder's current contents, **including pauses**. »
|
||||
* (developer.apple.com/documentation/healthkit/hkliveworkoutbuilder/elapsedtime)
|
||||
*
|
||||
* Conséquence si on câble le moteur sur cette propriété telle quelle :
|
||||
* une pause de 5 min ferait avancer le déroulé de 5 min d'effort. Un
|
||||
* fractionné mis en pause pour traverser une route se déroulerait tout
|
||||
* seul, à l'arrêt.
|
||||
*
|
||||
* La propriété qui exclut réellement les pauses est
|
||||
* `HKWorkoutBuilder.elapsedTime(at:)` — « The duration of a workout
|
||||
* doesn't include intervals between pause and resume events. » Ce n'est
|
||||
* PAS la même API, et les deux textes d'Apple se contredisent
|
||||
* frontalement sur ce point : à trancher avant le câblage.
|
||||
*
|
||||
* Le moteur, lui, reste correct : il ne fait qu'intégrer ce qu'on lui
|
||||
* pousse. C'est l'appelant qui devra fournir un temps réellement actif ;
|
||||
*
|
||||
* - il ignore donc l'auto-pause de la montre, à condition que la source de
|
||||
* temps ci-dessus soit correcte ;
|
||||
* - 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`).
|
||||
|
||||
@@ -142,9 +142,21 @@ final class LocationTracker: NSObject, ObservableObject {
|
||||
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()
|
||||
// ⚠️ Ici se jouait une perte silencieuse. `pendingRoute` ne contient
|
||||
// que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
|
||||
// `distanceMeters` — ils attendent seulement leur écriture, qui ne
|
||||
// part qu'au seuil de `routeFlushThreshold` (25). À 1 Hz, c'est une
|
||||
// douzaine de points en moyenne, jusqu'à 24. Les jeter creusait un
|
||||
// écart visible entre la distance affichée et la trace enregistrée :
|
||||
// six arrêts sur une sortie, et jusqu'à 144 points manquaient à la
|
||||
// HKWorkoutRoute, avec un D+ recalculé qui divergeait d'autant.
|
||||
//
|
||||
// On les écrit donc au lieu de les perdre. Le raisonnement du
|
||||
// commentaire d'origine visait le point qui SUIT la reprise, à des
|
||||
// centaines de mètres de là — mais celui-là n'est pas encore dans
|
||||
// le tampon, et c'est `implausibleSpeed` qui l'écarte. Nettoyer la
|
||||
// pause est l'affaire du filtre, pas du tampon d'écriture.
|
||||
Task { await flushPendingRoute() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,21 +177,30 @@ final class LocationTracker: NSObject, ObservableObject {
|
||||
/// 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.
|
||||
/// ⚠️ **Une trace ne peut PAS être sauvegardée sans workout.** Une version
|
||||
/// antérieure de ce commentaire l'affirmait ; c'est faux, vérifié à la
|
||||
/// source le 21/08 : la signature est
|
||||
/// `finishRoute(with workout: HKWorkout, metadata:)` — non optionnelle —
|
||||
/// et Apple précise « You must have already saved this workout to the
|
||||
/// HealthKit store ». Il n'existe aucune API pour clore une route
|
||||
/// orpheline. Le cas « montre verrouillée », où `finishWorkout()` rend
|
||||
/// `nil` sans erreur, se traite donc **en amont** : `WorkoutManager` va
|
||||
/// rechercher dans HealthKit le workout qui vient d'y être écrit. Ici, si
|
||||
/// aucun workout n'arrive, il ne reste qu'à jeter la trace explicitement
|
||||
/// (`discardRoute()`) — un builder abandonné sans `discard()` laisse ses
|
||||
/// données en suspens.
|
||||
///
|
||||
/// Échoue aussi si aucune position n'a été insérée — cas normal d'une
|
||||
/// Ne fait rien 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 {
|
||||
func finishRoute(with workout: HKWorkout) async {
|
||||
await flushPendingRoute()
|
||||
guard let builder = routeBuilder else { return }
|
||||
self.routeBuilder = nil
|
||||
|
||||
guard filter.acceptedCount > 0 else {
|
||||
// Séance en salle : rien à clore. Volontairement SANS `discard()` —
|
||||
// c'est le chemin le plus fréquent, et il fonctionnait tel quel ;
|
||||
// on n'y introduit pas un appel qu'aucun build n'a validé.
|
||||
locationLog.info("aucune position retenue : pas de trace a clore")
|
||||
return
|
||||
}
|
||||
@@ -191,6 +212,19 @@ final class LocationTracker: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Abandonne la trace en cours.
|
||||
///
|
||||
/// Seul recours quand aucun workout n'a pu être associé : sans `discard()`,
|
||||
/// le builder garde ses données côté HealthKit et « any further calls to
|
||||
/// the builder raise an exception ». La sortie existera alors sans
|
||||
/// parcours — perte réelle, à tracer comme telle plutôt qu'à masquer.
|
||||
func discardRoute() {
|
||||
guard let builder = routeBuilder else { return }
|
||||
self.routeBuilder = nil
|
||||
builder.discard()
|
||||
locationLog.error("trace abandonnee : aucun workout a associer (\(self.filter.acceptedCount) points perdus)")
|
||||
}
|
||||
|
||||
// MARK: Écriture HealthKit
|
||||
|
||||
/// Écrit les points en attente. Par lots : une écriture par position
|
||||
|
||||
@@ -31,8 +31,59 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
private let minSendInterval: TimeInterval = 0.5
|
||||
private var simTask: Task<Void, Never>?
|
||||
|
||||
/// Date du dernier échantillon de FC **réellement mesuré**. Posée nulle
|
||||
/// part ailleurs que là où HealthKit nous donne une valeur.
|
||||
private var heartRateAt: Date?
|
||||
|
||||
/// Au-delà de ce délai sans nouvel échantillon, on cesse d'afficher et
|
||||
/// d'émettre la dernière FC connue.
|
||||
///
|
||||
/// ⚠️ Cette valeur est un **choix d'ingénierie, pas une donnée
|
||||
/// physiologique** : Apple ne publie pas de cadence garantie pour
|
||||
/// l'échantillonnage de la FC en séance. Elle est posée au-dessus du
|
||||
/// `lostThreshold` de 8 s de `LiveStore` côté iPhone, pour que la FC ne se
|
||||
/// périme jamais *avant* que la connexion elle-même soit déclarée perdue —
|
||||
/// deux signaux contradictoires seraient illisibles au poignet.
|
||||
///
|
||||
/// Si « — » clignote en usage normal, c'est cette valeur qu'il faut
|
||||
/// augmenter, pas la péremption qu'il faut retirer.
|
||||
///
|
||||
/// ⚠️ Limite assumée : la péremption est évaluée dans `ingest`, donc elle
|
||||
/// suppose qu'un échantillon — d'un type quelconque — continue d'arriver.
|
||||
/// C'est le cas du scénario visé (le capteur FC décroche pendant que
|
||||
/// calories et distance continuent). Si plus RIEN n'arrive, l'écran entier
|
||||
/// se fige, chronomètre compris, ce qui se voit ; un timer dédié coûterait
|
||||
/// de la batterie pour couvrir un cas déjà visible autrement.
|
||||
private let heartRateStaleAfter: TimeInterval = 10
|
||||
|
||||
/// La FC connue si elle est encore fraîche, 0 sinon.
|
||||
///
|
||||
/// 0 est la convention « inconnu » partagée par toute la chaîne :
|
||||
/// `HeartRateMetric` n'affiche alors plus de zone, et `heartRateText` côté
|
||||
/// iPhone rend « — ».
|
||||
private var freshHeartRate: Double {
|
||||
guard let at = heartRateAt,
|
||||
Date().timeIntervalSince(at) <= heartRateStaleAfter else { return 0 }
|
||||
return heartRate
|
||||
}
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
/// Remet les mesures à « inconnu » avant une nouvelle séance.
|
||||
///
|
||||
/// `WorkoutManager` est un singleton : sans ça, les valeurs de la séance
|
||||
/// précédente restaient affichées jusqu'au premier échantillon de la
|
||||
/// nouvelle — une FC d'hier présentée comme celle de maintenant.
|
||||
private func resetMetrics() {
|
||||
heartRate = 0
|
||||
heartRateAt = nil
|
||||
activeEnergyKcal = 0
|
||||
distanceMeters = 0
|
||||
speedKmh = 0
|
||||
elapsedSec = 0
|
||||
lastSentAt = .distantPast
|
||||
}
|
||||
|
||||
func startWorkout(activityType: HKWorkoutActivityType,
|
||||
locationType: HKWorkoutSessionLocationType) async {
|
||||
guard !isRunning, session == nil else { return }
|
||||
@@ -40,6 +91,7 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
statusMessage = "HealthKit indisponible sur cet appareil"
|
||||
return
|
||||
}
|
||||
resetMetrics()
|
||||
let distanceType = Self.distanceType(for: activityType)
|
||||
let hr = HKQuantityType(.heartRate)
|
||||
let energy = HKQuantityType(.activeEnergyBurned)
|
||||
@@ -164,6 +216,7 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
/// Mode test : FC simulée, sans HealthKit, pour valider le flux watch -> iPhone -> /live.
|
||||
func startSimulation() {
|
||||
guard !isRunning else { return }
|
||||
resetMetrics()
|
||||
isRunning = true
|
||||
statusMessage = "Mode test — FC simulée"
|
||||
let startedAt = Date()
|
||||
@@ -175,6 +228,7 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
let kcal = elapsed / 6
|
||||
let dist = elapsed * 2.6
|
||||
self.heartRate = hr
|
||||
self.heartRateAt = Date() // sinon la FC simulée se périme
|
||||
self.activeEnergyKcal = kcal
|
||||
self.distanceMeters = dist
|
||||
self.elapsedSec = elapsed
|
||||
@@ -196,7 +250,24 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
/// Appelé sur le main depuis le callback de collecte (hors-main).
|
||||
private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?,
|
||||
elapsed: TimeInterval) {
|
||||
if let hr { heartRate = hr }
|
||||
// ⚠️ `didCollectDataOf` se déclenche dès qu'UN type est collecté. Quand
|
||||
// le capteur optique décroche — bracelet desserré, froid, arythmie —
|
||||
// les calories continuent d'arriver et `hr` vaut nil. Sans la date
|
||||
// ci-dessous, la dernière FC connue restait affichée, et repartait vers
|
||||
// l'iPhone avec un horodatage NEUF : le watchdog de `LiveStore`, qui
|
||||
// ne regarde que l'arrivée de samples, ne pouvait alors jamais la
|
||||
// déclarer perdue. Une FC morte s'affichait stable, avec sa zone
|
||||
// colorée, indéfiniment — de quoi valider un effort sur un chiffre qui
|
||||
// n'existe plus.
|
||||
if let hr {
|
||||
heartRate = hr
|
||||
heartRateAt = Date()
|
||||
} else if freshHeartRate == 0 {
|
||||
// Périmée : on retombe sur la convention « inconnu » plutôt que de
|
||||
// laisser la vue afficher une valeur que plus rien ne soutient.
|
||||
heartRate = 0
|
||||
heartRateAt = nil
|
||||
}
|
||||
if let kcal { activeEnergyKcal = kcal }
|
||||
if let dist { distanceMeters = dist }
|
||||
elapsedSec = elapsed
|
||||
@@ -215,7 +286,10 @@ final class WorkoutManager: NSObject, ObservableObject {
|
||||
guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval else { return }
|
||||
lastSentAt = now
|
||||
ConnectivityManager.shared.sendSample([
|
||||
"heartRate": heartRate,
|
||||
// `freshHeartRate`, pas `heartRate` : ne jamais envoyer une valeur
|
||||
// périmée sous un horodatage neuf. L'iPhone ne peut pas deviner
|
||||
// l'âge d'un chiffre qu'on lui présente comme actuel.
|
||||
"heartRate": freshHeartRate,
|
||||
"activeEnergyKcal": activeEnergyKcal,
|
||||
"distanceMeters": distanceMeters,
|
||||
"speedKmh": speedKmh,
|
||||
@@ -307,12 +381,53 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
||||
// 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)
|
||||
/* ⚠️ Les deux `try?` d'avant rendaient indiscernables deux
|
||||
situations OPPOSÉES, toutes deux menant à `workout == nil` :
|
||||
· montre verrouillée à l'arrêt → la séance EST écrite ;
|
||||
· `endCollection` a levé → il n'y a rien à retrouver.
|
||||
Elles tombaient dans le même `else`, et seul le sort de la TRACE
|
||||
était journalisé — pas celui de la séance. Après deux heures de
|
||||
sortie, l'écran de fin restait le même.
|
||||
§ 2.3 du diagnostic du 24/09/2026. La logique de récupération
|
||||
ci-dessous n'a pas changé : seul l'échec devient visible. */
|
||||
var collecteClose = true
|
||||
do {
|
||||
try await builder.endCollection(at: date)
|
||||
} catch {
|
||||
collecteClose = false
|
||||
workoutLog.error("endCollection a echoue : \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
// ⚠️ `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)
|
||||
// verrouillée : ce n'est pas un échec, la séance EST écrite dans
|
||||
// HealthKit — seul l'objet manque. Comme `finishRoute(with:)`
|
||||
// exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
|
||||
// on va rechercher celle qui vient d'être sauvegardée.
|
||||
var workout: HKWorkout?
|
||||
do {
|
||||
workout = try await builder.finishWorkout()
|
||||
} catch {
|
||||
workoutLog.error("finishWorkout a echoue : \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
if let workout {
|
||||
await LocationTracker.shared.finishRoute(with: workout)
|
||||
} else if let recovered = await self.recentlySavedWorkout() {
|
||||
workoutLog.info("workout recupere apres un finishWorkout() nil")
|
||||
await LocationTracker.shared.finishRoute(with: recovered)
|
||||
} else {
|
||||
LocationTracker.shared.discardRoute()
|
||||
if collecteClose {
|
||||
// La collecte s'est close proprement mais rien n'est
|
||||
// retrouvable : anormal, et jamais journalisé jusqu'ici.
|
||||
workoutLog.error("seance introuvable apres un finishWorkout() nil : rien a associer a la trace")
|
||||
} else {
|
||||
workoutLog.error("SEANCE PERDUE : endCollection a leve, rien n'a ete ecrit dans HealthKit")
|
||||
/* Le dire à l'écran. C'est le cœur du constat : l'utilisateur
|
||||
voyait l'écran de fin habituel après une séance perdue.
|
||||
Uniquement dans ce cas — une montre verrouillée n'est pas
|
||||
une perte, et l'annoncer serait une fausse alerte. */
|
||||
self.statusMessage = "Séance non enregistrée — rien n'a pu être écrit dans Santé"
|
||||
}
|
||||
}
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.isRunning = false
|
||||
@@ -325,14 +440,88 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// La séance que HealthKit vient d'enregistrer, quand `finishWorkout()`
|
||||
/// n'a rien rendu.
|
||||
///
|
||||
/// ⚠️ La fenêtre porte sur le **chevauchement**, pas sur la date de début :
|
||||
/// sans `.strictStartDate`, un workout est retenu dès qu'il croise
|
||||
/// l'intervalle. Filtrer sur son `startDate` raterait toute séance de plus
|
||||
/// de quelques minutes — c'est le piège qui avait rendu muettes les
|
||||
/// notifications de fin de séance côté iPhone.
|
||||
///
|
||||
/// Restreinte à ce que **cette app** a écrit (`HKSource.default()`) : sans
|
||||
/// ça, une séance enregistrée en parallèle par l'app Exercice d'Apple
|
||||
/// pourrait récupérer notre trace.
|
||||
private func recentlySavedWorkout() async -> HKWorkout? {
|
||||
let recent = HKQuery.predicateForSamples(
|
||||
withStart: Date(timeIntervalSinceNow: -Self.recoveryWindow), end: nil)
|
||||
// Type écrit en toutes lettres : `predicateForObjects(from:)` a cinq
|
||||
// surcharges (HKSource, Set<HKSource>, HKWorkout, Set<HKSourceRevision>,
|
||||
// Set<HKDevice>) et un `.default()` abrégé s'y résout mal.
|
||||
let mine = HKQuery.predicateForObjects(from: HKSource.default())
|
||||
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [recent, mine])
|
||||
let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
let query = HKSampleQuery(sampleType: .workoutType(),
|
||||
predicate: predicate,
|
||||
limit: 1,
|
||||
sortDescriptors: [newestFirst]) { _, samples, error in
|
||||
if let error {
|
||||
workoutLog.error("recuperation du workout impossible : \(error.localizedDescription)")
|
||||
}
|
||||
continuation.resume(returning: samples?.first as? HKWorkout)
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fenêtre de recherche du workout de repli. Large assez pour couvrir une
|
||||
/// sauvegarde lente, courte assez pour ne pas ramasser la séance d'avant.
|
||||
private static let recoveryWindow: TimeInterval = 5 * 60
|
||||
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
|
||||
didFailWithError error: Error) {
|
||||
let message = error.localizedDescription
|
||||
let ns = error as NSError
|
||||
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
|
||||
let builder = workoutSession.associatedWorkoutBuilder()
|
||||
Task { @MainActor in
|
||||
self.statusMessage = "Erreur séance : \(message)"
|
||||
/* ⚠️ Ce chemin ne clôturait RIEN. Comparé à `.ended`, il manquait
|
||||
`stopTracking()`, `endCollection`, `finishWorkout` et
|
||||
`finishRoute`/`discardRoute` — et `self.builder = nil` détruisait
|
||||
la seule référence au builder : la perte devenait définitive dans
|
||||
l'instruction même qui l'ignorait. Le GPS, lui, continuait de
|
||||
tourner après la fin apparente de la séance, sans que rien ne
|
||||
l'indique. § 2.4 du diagnostic du 24/09/2026.
|
||||
|
||||
Le GPS d'abord : c'est le seul geste qui réussit à coup sûr, et
|
||||
le seul dont l'omission se paie en batterie pendant des heures.
|
||||
La sauvegarde ensuite, en best-effort — une session qui a échoué
|
||||
refusera souvent de clore, mais ne pas essayer garantit la perte. */
|
||||
LocationTracker.shared.stopTracking()
|
||||
var sauvee: HKWorkout?
|
||||
do {
|
||||
try await builder.endCollection(at: Date())
|
||||
sauvee = try await builder.finishWorkout()
|
||||
} catch {
|
||||
workoutLog.error("cloture apres echec impossible : \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
if let sauvee {
|
||||
workoutLog.info("seance sauvee malgre l'echec de session")
|
||||
await LocationTracker.shared.finishRoute(with: sauvee)
|
||||
self.statusMessage = "Séance interrompue, mais enregistrée"
|
||||
} else if let recovered = await self.recentlySavedWorkout() {
|
||||
workoutLog.info("seance retrouvee dans HealthKit apres l'echec de session")
|
||||
await LocationTracker.shared.finishRoute(with: recovered)
|
||||
self.statusMessage = "Séance interrompue, mais enregistrée"
|
||||
} else {
|
||||
LocationTracker.shared.discardRoute()
|
||||
workoutLog.error("SEANCE PERDUE apres didFailWithError : rien n'a pu etre sauve")
|
||||
self.statusMessage = "Séance perdue : \(message)"
|
||||
}
|
||||
self.isRunning = false
|
||||
self.isPaused = false
|
||||
self.session = nil
|
||||
self.builder = nil
|
||||
ConnectivityManager.shared.sendEnded(reason: "session-failed")
|
||||
|
||||
@@ -300,5 +300,11 @@ struct CoachWatchWidgetsBundle: WidgetBundle {
|
||||
CoachWatchFormeWidget()
|
||||
CoachWatchSessionWidget()
|
||||
CoachWatchRoutineWidget()
|
||||
// Smart Stack : le seul widget de la montre qui fasse AGIR — noter un
|
||||
// verre sans sortir le téléphone. Ajouté le 25/09/2026.
|
||||
if #available(watchOS 10.0, *) {
|
||||
CoachWatchHydrationWidget()
|
||||
CoachWatchNutritionWidget()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
327
ios/App/CoachWatchWidgets/CoachWatchHydration.swift
Normal file
327
ios/App/CoachWatchWidgets/CoachWatchHydration.swift
Normal file
@@ -0,0 +1,327 @@
|
||||
// CoachWatchHydration.swift
|
||||
// Widget « Hydratation » pour la Smart Stack de l'Apple Watch : voir où l'on
|
||||
// en est, et **noter un verre sans sortir le téléphone**.
|
||||
//
|
||||
// ⚠️ Cible : CoachWatchWidgets. Dépend de `CoachIntents.swift`,
|
||||
// `CoachQuickLog.swift` et `CoachWidgetSnapshot.swift`, qui doivent appartenir
|
||||
// à CETTE cible aussi (cf. docs/widgets-runbook-mac.md).
|
||||
//
|
||||
// Pourquoi ce widget, et pas un de plus pour les repas
|
||||
// -----------------------------------------------------
|
||||
// Tranché avec Sylvain le 25/09/2026. Boire se note une vingtaine de fois par
|
||||
// jour : c'est le seul geste où la montre bat vraiment le téléphone — le
|
||||
// poignet est déjà levé. Les repas, non : pas de caméra, pas de scanner de
|
||||
// code-barres, pas de saisie de texte confortable. Un bouton « repas » au
|
||||
// poignet ne ferait que renvoyer vers l'iPhone, pour du code à maintenir.
|
||||
//
|
||||
// ⚠️ **Rien ne part sur le réseau ici.** Comme sur l'iPhone, l'intent écrit
|
||||
// dans la file de `CoachQuickLog` (App Group de la montre) et l'application la
|
||||
// transmet à sa prochaine ouverture. Le widget affiche donc son propre total —
|
||||
// snapshot serveur + file locale — sinon il afficherait un chiffre périmé juste
|
||||
// après une saisie, et on douterait de ce qu'on vient de faire.
|
||||
//
|
||||
// ⚠️ **Interactivité : Smart Stack, pas cadran.** Les boutons d'App Intent
|
||||
// demandent watchOS 10+. Reste à confirmer AU BUILD si une complication de
|
||||
// cadran les accepte : dans le doute, la famille `.accessoryCircular` ci-dessous
|
||||
// n'est qu'un AFFICHAGE (un tap ouvre l'app), et seule `.accessoryRectangular`
|
||||
// porte les boutons.
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
struct CoachWatchHydrationEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let waterMl: Double
|
||||
let goalMl: Double?
|
||||
/// Saisies encore en attente d'envoi, tous jours confondus.
|
||||
let pending: Int
|
||||
let quickDrinks: [CoachWidgetSnapshot.QuickDrink]
|
||||
|
||||
/// 0…1, borné. Sans cible connue, pas de jauge inventée.
|
||||
var fraction: Double? {
|
||||
guard let goal = goalMl, goal > 0 else { return nil }
|
||||
return min(1, max(0, waterMl / goal))
|
||||
}
|
||||
}
|
||||
|
||||
struct CoachWatchHydrationProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> CoachWatchHydrationEntry {
|
||||
CoachWatchHydrationEntry(
|
||||
date: Date(), waterMl: 1250, goalMl: 2750, pending: 0,
|
||||
quickDrinks: CoachWidgetSnapshot.fallbackQuickDrinks)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context,
|
||||
completion: @escaping (CoachWatchHydrationEntry) -> Void) {
|
||||
completion(current())
|
||||
}
|
||||
|
||||
/// Réveil à minuit, comme le widget iPhone : les totaux du jour doivent
|
||||
/// repartir de zéro. ⚠️ `asOf(_:)` est ce qui le rend effectif — sans lui
|
||||
/// le rechargement a lieu mais réaffiche les chiffres de la veille, défaut
|
||||
/// déjà payé côté iPhone.
|
||||
func getTimeline(in context: Context,
|
||||
completion: @escaping (Timeline<CoachWatchHydrationEntry>) -> Void) {
|
||||
let now = Date()
|
||||
let minuit = CoachWidgetSnapshot.nextMidnight(after: now)
|
||||
completion(Timeline(entries: [current(at: now), current(at: minuit)],
|
||||
policy: .after(minuit.addingTimeInterval(60))))
|
||||
}
|
||||
|
||||
private func current(at day: Date = Date()) -> CoachWatchHydrationEntry {
|
||||
let snapshot = CoachWidgetStore.load()?.asOf(day)
|
||||
return CoachWatchHydrationEntry(
|
||||
date: day,
|
||||
waterMl: CoachQuickLog.todayTotal(
|
||||
kind: "water", syncedMl: snapshot?.waterMlToday ?? 0, day: day),
|
||||
goalMl: snapshot?.waterGoalMl,
|
||||
pending: CoachQuickLog.pending().count,
|
||||
quickDrinks: snapshot?.resolvedQuickDrinks
|
||||
?? CoachWidgetSnapshot.fallbackQuickDrinks)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vues
|
||||
|
||||
/// Les deux premières boissons à base d'eau, sinon le repli. Deux et pas trois :
|
||||
/// sur 160 pt de large, un troisième bouton descend les cibles sous le seuil
|
||||
/// tactile, et c'est un geste qu'on fait en marchant.
|
||||
private func deuxBoutonsEau(_ drinks: [CoachWidgetSnapshot.QuickDrink])
|
||||
-> [CoachWidgetSnapshot.QuickDrink] {
|
||||
let eau = drinks.filter { $0.ref == "water" }
|
||||
let retenus = eau.isEmpty ? CoachWidgetSnapshot.fallbackQuickDrinks
|
||||
.filter({ $0.ref == "water" }) : eau
|
||||
return Array(retenus.prefix(2))
|
||||
}
|
||||
|
||||
private func enLitres(_ ml: Double) -> String {
|
||||
let l = ml / 1000
|
||||
return String(format: l < 1 ? "%.2f L" : "%.1f L", l)
|
||||
}
|
||||
|
||||
@available(watchOS 10.0, *)
|
||||
struct CoachWatchHydrationView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: CoachWatchHydrationEntry
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryRectangular:
|
||||
rectangulaire
|
||||
case .accessoryInline:
|
||||
Text("Eau \(enLitres(entry.waterMl))")
|
||||
default:
|
||||
circulaire
|
||||
}
|
||||
}
|
||||
|
||||
/// La seule famille qui porte les boutons : il y faut la place.
|
||||
private var rectangulaire: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "drop.fill")
|
||||
Text(enLitres(entry.waterMl))
|
||||
.font(.headline)
|
||||
if let goal = entry.goalMl {
|
||||
Text("/ \(enLitres(goal))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// Ce qui attend d'être envoyé se dit, plutôt que de laisser
|
||||
// croire que tout est synchronisé.
|
||||
if entry.pending > 0 {
|
||||
Text("·\(entry.pending)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let f = entry.fraction {
|
||||
ProgressView(value: f).progressViewStyle(.linear)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
ForEach(deuxBoutonsEau(entry.quickDrinks), id: \.volumeMl) { d in
|
||||
Button(intent: LogDrinkIntent(ref: d.ref, amountMl: d.volumeMl)) {
|
||||
Text("+\(Int(d.volumeMl / 10)) cl")
|
||||
.font(.caption2)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(.clear, for: .widget)
|
||||
}
|
||||
|
||||
/// Cadran : affichage seul. Un tap ouvre l'app — voir l'avertissement en
|
||||
/// tête de fichier sur l'interactivité hors Smart Stack.
|
||||
private var circulaire: some View {
|
||||
Gauge(value: entry.fraction ?? 0) {
|
||||
Image(systemName: "drop.fill")
|
||||
} currentValueLabel: {
|
||||
Text(enLitres(entry.waterMl)).font(.system(size: 12))
|
||||
}
|
||||
.gaugeStyle(.accessoryCircular)
|
||||
.containerBackground(.clear, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
@available(watchOS 10.0, *)
|
||||
struct CoachWatchHydrationWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "CoachWatchHydration",
|
||||
provider: CoachWatchHydrationProvider()) { entry in
|
||||
CoachWatchHydrationView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Hydratation")
|
||||
.description("Où j'en suis, et noter un verre sans ouvrir l'app.")
|
||||
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nutrition
|
||||
//
|
||||
// ⚠️ **Affichage seulement, et c'est délibéré.** La saisie des repas au poignet
|
||||
// a été écartée le 25/09 : pas de caméra, pas de scanner de code-barres, pas de
|
||||
// saisie de texte confortable — un bouton n'aurait fait que renvoyer vers
|
||||
// l'iPhone. Mais VOIR où l'on en est n'a aucune de ces limites, et c'est ce que
|
||||
// Sylvain a demandé ensuite.
|
||||
//
|
||||
// ⛔ Rien n'est calculé ici. Les totaux viennent du journal et les cibles de
|
||||
// `nutrition_goals.py`, tous deux poussés par l'app. Le seul calcul est un
|
||||
// reste (cible − consommé), la même soustraction que la page /meals affiche.
|
||||
|
||||
struct CoachWatchNutritionEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let kcal: Double?
|
||||
let kcalGoal: Double?
|
||||
let protein: Double?
|
||||
let proteinGoal: Double?
|
||||
let fat: Double?
|
||||
let fatGoal: Double?
|
||||
let carbs: Double?
|
||||
let carbsGoal: Double?
|
||||
|
||||
/// Reste d'énergie. `nil` sans cible — une jauge sans objectif n'a pas de
|
||||
/// sens, et un « reste » inventé encore moins.
|
||||
var kcalRestantes: Double? {
|
||||
guard let g = kcalGoal, let c = kcal else { return nil }
|
||||
return max(0, g - c)
|
||||
}
|
||||
|
||||
var fraction: Double? {
|
||||
guard let g = kcalGoal, g > 0, let c = kcal else { return nil }
|
||||
return min(1, max(0, c / g))
|
||||
}
|
||||
}
|
||||
|
||||
struct CoachWatchNutritionProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> CoachWatchNutritionEntry {
|
||||
CoachWatchNutritionEntry(date: Date(), kcal: 1186, kcalGoal: 1908,
|
||||
protein: 74, proteinGoal: 118,
|
||||
fat: 41, fatGoal: 59,
|
||||
carbs: 158, carbsGoal: 225)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context,
|
||||
completion: @escaping (CoachWatchNutritionEntry) -> Void) {
|
||||
completion(current())
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context,
|
||||
completion: @escaping (Timeline<CoachWatchNutritionEntry>) -> Void) {
|
||||
let now = Date()
|
||||
let minuit = CoachWidgetSnapshot.nextMidnight(after: now)
|
||||
completion(Timeline(entries: [current(at: now), current(at: minuit)],
|
||||
policy: .after(minuit.addingTimeInterval(60))))
|
||||
}
|
||||
|
||||
/// `asOf(_:)` remet les totaux du jour à zéro quand le snapshot date
|
||||
/// d'hier — macros comprises depuis le 25/09. Sans lui, le réveil de
|
||||
/// minuit réafficherait les grammes de la veille.
|
||||
private func current(at day: Date = Date()) -> CoachWatchNutritionEntry {
|
||||
let s = CoachWidgetStore.load()?.asOf(day)
|
||||
return CoachWatchNutritionEntry(
|
||||
date: day,
|
||||
kcal: s?.kcalToday, kcalGoal: s?.kcalGoal,
|
||||
protein: s?.proteinToday, proteinGoal: s?.proteinGoal,
|
||||
fat: s?.fatToday, fatGoal: s?.fatGoal,
|
||||
carbs: s?.carbsToday, carbsGoal: s?.carbsGoal)
|
||||
}
|
||||
}
|
||||
|
||||
@available(watchOS 10.0, *)
|
||||
struct CoachWatchNutritionView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: CoachWatchNutritionEntry
|
||||
|
||||
/// « 74/118 g » ; « 74 g » sans cible. Jamais de cible inventée.
|
||||
private func macro(_ nom: String, _ valeur: Double?, _ cible: Double?) -> String {
|
||||
guard let v = valeur else { return "\(nom) —" }
|
||||
if let c = cible { return "\(nom) \(Int(v))/\(Int(c))" }
|
||||
return "\(nom) \(Int(v))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryRectangular:
|
||||
rectangulaire
|
||||
case .accessoryInline:
|
||||
Text(entry.kcalRestantes.map { "\(Int($0)) kcal restantes" }
|
||||
?? "\(Int(entry.kcal ?? 0)) kcal")
|
||||
default:
|
||||
circulaire
|
||||
}
|
||||
}
|
||||
|
||||
private var rectangulaire: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "fork.knife")
|
||||
if let reste = entry.kcalRestantes {
|
||||
Text("\(Int(reste))").font(.headline)
|
||||
Text("kcal restantes").font(.caption2).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("\(Int(entry.kcal ?? 0)) kcal").font(.headline)
|
||||
}
|
||||
}
|
||||
if let f = entry.fraction {
|
||||
ProgressView(value: f).progressViewStyle(.linear)
|
||||
}
|
||||
// P / L / G sur une ligne : trois valeurs tiennent, pas davantage.
|
||||
Text([macro("P", entry.protein, entry.proteinGoal),
|
||||
macro("L", entry.fat, entry.fatGoal),
|
||||
macro("G", entry.carbs, entry.carbsGoal)].joined(separator: " "))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.containerBackground(.clear, for: .widget)
|
||||
}
|
||||
|
||||
private var circulaire: some View {
|
||||
Gauge(value: entry.fraction ?? 0) {
|
||||
Image(systemName: "fork.knife")
|
||||
} currentValueLabel: {
|
||||
Text("\(Int(entry.kcalRestantes ?? entry.kcal ?? 0))")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.gaugeStyle(.accessoryCircular)
|
||||
.containerBackground(.clear, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
@available(watchOS 10.0, *)
|
||||
struct CoachWatchNutritionWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "CoachWatchNutrition",
|
||||
provider: CoachWatchNutritionProvider()) { entry in
|
||||
CoachWatchNutritionView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Nutrition")
|
||||
.description("Calories restantes et macros du jour.")
|
||||
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -9,12 +9,12 @@
|
||||
"version": "0.1.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.3.1",
|
||||
"@capacitor/android": "^8.5.2",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/core": "^8.3.1",
|
||||
"@capacitor/core": "^8.5.2",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@capacitor/haptics": "^8.0.2",
|
||||
"@capacitor/ios": "^8.3.1",
|
||||
"@capacitor/ios": "^8.5.2",
|
||||
"@capacitor/local-notifications": "^8.0.2",
|
||||
"@capacitor/push-notifications": "^8.0.3",
|
||||
"@capacitor/splash-screen": "^8.0.1",
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@capacitor/cli": "^8.3.1"
|
||||
"@capacitor/cli": "^8.5.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -51,12 +51,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.1.tgz",
|
||||
"integrity": "sha512-hjskIG8YcBEh3X4yaTXvE9gcqpdcxunTgFruSKnuPxtMxAUzEK4Oq25x0Z1g3cz+MQPc+lRG09R7Ovc+ydKsNw==",
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz",
|
||||
"integrity": "sha512-rtiaz78egCcS+vs29wkWCL3C+RhazKMSygXcjutwWP8NmO6r5UBxOvGf3Wjz5+ahobyQ723tn9gCePwRwcNDZQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^8.3.0"
|
||||
"@capacitor/core": "^8.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/app": {
|
||||
@@ -452,10 +452,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@capacitor/cli": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.1.tgz",
|
||||
"integrity": "sha512-1sPGW4THTDfR6YjXwZ0jM7oAfAtciPOHN00qs/3sNAQx1kKrrEYSfDPwCm1/xlAgi0OeL69SiRfw314Ans+1sw==",
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz",
|
||||
"integrity": "sha512-O2kpA5ZPJUAmliDCcmpMf8xnJwMezxlFPUrlyOAjbL3U9MFNY3l4ZpPI/UXwvyPmR8ETcVF9pFr+VdWq61rqTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ionic/cli-framework-output": "^2.2.8",
|
||||
"@ionic/utils-subprocess": "^3.0.1",
|
||||
@@ -473,6 +474,7 @@
|
||||
"semver": "^7.6.3",
|
||||
"tar": "^7.5.3",
|
||||
"tslib": "^2.8.1",
|
||||
"xcode": "^3.0.1",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -484,9 +486,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/core": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.1.tgz",
|
||||
"integrity": "sha512-UF8ItlHguU1Z6GXfPTeT2gakf+ctNI8pAS1kwSBQlsJMlfD4OPoto/SmKnOxKCQvnF4WRcdWeg6C0zREUNaAQg==",
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.2.tgz",
|
||||
"integrity": "sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -511,11 +514,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/ios": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.1.tgz",
|
||||
"integrity": "sha512-BEhLyYYHWJLib4mpaPMaaylbC8meqgxbNYwQJH2svsSLW7yo/hFie+Zoo66a44XnqcMd2tvmAuzimWunXZi/xA==",
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.2.tgz",
|
||||
"integrity": "sha512-eOT2S67AfTFG7KN0T3oxwAajrJz4rSye+ODfvChiwrk0F/+kWufFM1qU6cDjGJUOfwdAoeuEpZyWHSwZlu4GFg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^8.3.0"
|
||||
"@capacitor/core": "^8.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/local-notifications": {
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
"author": "Sylvain Bettinelli",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.3.1",
|
||||
"@capacitor/android": "^8.5.2",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/core": "^8.3.1",
|
||||
"@capacitor/core": "^8.5.2",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@capacitor/haptics": "^8.0.2",
|
||||
"@capacitor/ios": "^8.3.1",
|
||||
"@capacitor/ios": "^8.5.2",
|
||||
"@capacitor/local-notifications": "^8.0.2",
|
||||
"@capacitor/push-notifications": "^8.0.3",
|
||||
"@capacitor/splash-screen": "^8.0.1",
|
||||
@@ -30,6 +30,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@capacitor/cli": "^8.3.1"
|
||||
"@capacitor/cli": "^8.5.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,6 @@ struct HeartRateZones: Codable, Equatable, Sendable {
|
||||
var z5: [Int]?
|
||||
var fcmaxUsed: Int?
|
||||
var clinicalTargetBpm: Int?
|
||||
var zonesSince: String?
|
||||
var zonesFrom: String?
|
||||
}
|
||||
|
||||
1
tests-linux/Sources/CoachModel/PolarPftpStep.swift
Symbolic link
1
tests-linux/Sources/CoachModel/PolarPftpStep.swift
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../ios/App/App/PolarPftpStep.swift
|
||||
@@ -41,9 +41,16 @@ final class DayRolloverTests: XCTestCase {
|
||||
s.waterMlToday = 1660
|
||||
s.coffeeCountToday = 3
|
||||
s.kcalToday = 1186
|
||||
s.proteinToday = 74
|
||||
s.fatToday = 41
|
||||
s.carbsToday = 158
|
||||
s.waterGoalMl = 2750
|
||||
s.kcalGoal = 1950
|
||||
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112)
|
||||
s.proteinGoal = 118
|
||||
s.fatGoal = 59
|
||||
s.carbsGoal = 225
|
||||
s.zones = HeartRateZones(z2: [109, 118], fcmaxUsed: 152, clinicalTargetBpm: 112,
|
||||
zonesSince: "2026-08-12", zonesFrom: "frozen")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -68,6 +75,28 @@ final class DayRolloverTests: XCTestCase {
|
||||
XCTAssertEqual(vu.kcalToday, 0)
|
||||
}
|
||||
|
||||
func testMacrosResetLikeEnergy() {
|
||||
// ⚠️ Ajoutées le 25/09/2026 pour le suivi au poignet. Les oublier dans
|
||||
// `asOf` afficherait les grammes d'hier sous la date du jour — le
|
||||
// défaut même que cette fonction existe pour empêcher, et qui avait
|
||||
// survécu un temps sur les totaux de boisson.
|
||||
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||||
XCTAssertEqual(vu.proteinToday, 0)
|
||||
XCTAssertEqual(vu.fatToday, 0)
|
||||
XCTAssertEqual(vu.carbsToday, 0)
|
||||
}
|
||||
|
||||
func testMacroGoalsSurviveTheNight() {
|
||||
// Une CIBLE n'est pas une mesure : elle ne se périme pas à minuit.
|
||||
// Sans elle, la jauge disparaîtrait chaque nuit jusqu'à la prochaine
|
||||
// ouverture de l'app.
|
||||
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||||
XCTAssertEqual(vu.proteinGoal, 118)
|
||||
XCTAssertEqual(vu.fatGoal, 59)
|
||||
XCTAssertEqual(vu.carbsGoal, 225)
|
||||
XCTAssertEqual(vu.kcalGoal, 1950)
|
||||
}
|
||||
|
||||
func testYesterdaysDayScopedFieldsAreDropped() {
|
||||
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
||||
XCTAssertNil(vu.routine, "la routine se recoche chaque jour")
|
||||
@@ -94,6 +123,42 @@ final class DayRolloverTests: XCTestCase {
|
||||
"les zones sont une version figée, valable des semaines")
|
||||
}
|
||||
|
||||
/// La date de version des zones doit survivre au transport et au passage de
|
||||
/// minuit — sans elle, la montre affiche des bornes sans âge, et après une
|
||||
/// révision rien ne distingue les nouvelles des anciennes.
|
||||
/// Cf. `docs/DIAGNOSTIC-NATIF-2026-09-24.md` § 1.2.
|
||||
func testZonesCarryTheirVersionDate() {
|
||||
let vu = snapshot(at: "2026-08-10 12:00").asOf(date("2026-08-18 09:00"), calendar: zurich)
|
||||
XCTAssertEqual(vu.zones?.zonesSince, "2026-08-12",
|
||||
"la date de la version figée se perdait au transport")
|
||||
XCTAssertEqual(vu.zones?.zonesFrom, "frozen",
|
||||
"« figée » et « provisoire » ne doivent pas se confondre au poignet")
|
||||
}
|
||||
|
||||
/// Le snapshot voyage en JSON jusqu'à la montre : ce qui n'est pas encodé
|
||||
/// n'arrive pas. Le test précédent vérifie la recopie en mémoire, celui-ci
|
||||
/// le passage par le fil.
|
||||
func testVersionDateSurvivesJsonRoundTrip() throws {
|
||||
let original = snapshot(at: "2026-08-10 12:00")
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: data)
|
||||
XCTAssertEqual(relu.zones?.zonesSince, "2026-08-12")
|
||||
XCTAssertEqual(relu.zones?.zonesFrom, "frozen")
|
||||
}
|
||||
|
||||
/// ⚠️ Un snapshot écrit AVANT l'ajout de ces champs doit rester décodable :
|
||||
/// la montre en garde un en cache, et un décodage qui lève la laisserait
|
||||
/// sans zones du tout — pire que des zones sans date.
|
||||
func testOlderSnapshotWithoutVersionDateStillDecodes() throws {
|
||||
// `updatedAt` est un `Date` : l'encodeur par défaut l'écrit en secondes
|
||||
// depuis la date de référence, pas en ISO — comme les autres tests de
|
||||
// compatibilité ascendante de ce banc (cf. QuickDrinkTests).
|
||||
let json = #"{"updatedAt": 1755000000, "zones": {"z2": [109, 118], "fcmaxUsed": 152}}"#
|
||||
let relu = try JSONDecoder().decode(CoachWidgetSnapshot.self, from: Data(json.utf8))
|
||||
XCTAssertEqual(relu.zones?.fcmaxUsed, 152)
|
||||
XCTAssertNil(relu.zones?.zonesSince, "absente, pas inventée")
|
||||
}
|
||||
|
||||
// MARK: - Au-delà du lendemain : ne rien inventer
|
||||
|
||||
func testAfterTwoDaysTheSessionIsUnknown() {
|
||||
|
||||
339
tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift
Normal file
339
tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift
Normal file
@@ -0,0 +1,339 @@
|
||||
// Les garde-fous qui empêchent de bloquer une Polar Vantage V3.
|
||||
//
|
||||
// POURQUOI CES TESTS EXISTENT VRAIMENT
|
||||
//
|
||||
// Le 2026-08-17, une requête PFTP malformée — 174 octets écrits vers un chemin
|
||||
// terminé par « / », donc « du contenu dans un dossier » — a bloqué la montre :
|
||||
// logo Polar puis écran noir, récupérée par un appui long sur OK. Le firmware ne
|
||||
// renvoie pas d'erreur applicative sur ce genre de requête, il s'effondre. Le
|
||||
// Bluetooth utilise le même protocole que l'USB : changer de transport n'a rien
|
||||
// enlevé au risque.
|
||||
//
|
||||
// Ces règles ne peuvent donc pas être vérifiées « à la relecture ». Elles sont
|
||||
// la seule chose qui se dresse entre un bug de construction de chemin et une
|
||||
// montre à plusieurs centaines de francs, et elles sont exécutées ici parce que
|
||||
// le reste du plugin (Capacitor, PolarBleSdk) ne compile que sur un Mac.
|
||||
//
|
||||
// Les mêmes règles existent côté serveur dans
|
||||
// `coach_sportif/tools/polar/polar_ftp.py::pftp_put()`. Si l'une des deux
|
||||
// change, l'autre doit suivre.
|
||||
|
||||
import XCTest
|
||||
@testable import CoachModel
|
||||
|
||||
final class PolarPftpStepTests: XCTestCase {
|
||||
|
||||
// MARK: - Les deux formes interdites
|
||||
|
||||
func testDossierAvecContenuEstRefuse() throws {
|
||||
// La requête exacte qui a planté la montre le 2026-08-17.
|
||||
let etape = PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB/",
|
||||
data: Data(repeating: 0x42, count: 174))
|
||||
XCTAssertThrowsError(try etape.validate()) { erreur in
|
||||
XCTAssertTrue("\(erreur)".contains("2026-08-17"),
|
||||
"le message doit rappeler l'incident, pas seulement refuser")
|
||||
}
|
||||
}
|
||||
|
||||
func testFichierVideEstRefuse() {
|
||||
// L'inverse : sans slash final la montre attend un fichier, et un
|
||||
// fichier vide n'a aucun sens — c'est un mkdir mal écrit.
|
||||
let etape = PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB", data: Data())
|
||||
XCTAssertThrowsError(try etape.validate())
|
||||
}
|
||||
|
||||
func testCheminHorsDeUZeroEstRefuse() {
|
||||
// Rien d'autre que /U/0/ n'a jamais été écrit sur cette montre : tout
|
||||
// le reste est une exploration en écriture, donc un risque de blocage.
|
||||
let etape = PftpStep(path: "/SYS/quelquechose.BPB", data: Data([1, 2, 3]))
|
||||
XCTAssertThrowsError(try etape.validate())
|
||||
}
|
||||
|
||||
// MARK: - Les deux formes valides
|
||||
|
||||
func testDossierSansContenuEstAccepte() throws {
|
||||
try PftpStep(path: "/U/0/20260831/TST/", data: Data()).validate()
|
||||
try PftpStep(path: "/U/0/20260831/TST/180000/", data: Data()).validate()
|
||||
}
|
||||
|
||||
func testFichierAvecContenuEstAccepte() throws {
|
||||
try PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB",
|
||||
data: Data(repeating: 0x42, count: 240)).validate()
|
||||
}
|
||||
|
||||
// MARK: - L'en-tête PbPFtpOperation
|
||||
|
||||
func testEnTeteReproduitLaSerialisationPython() {
|
||||
// `encode_operation(PUT, path)` de polar_ftp.py :
|
||||
// 0x08 (champ 1, varint) + 0x01 (PUT)
|
||||
// 0x12 (champ 2, délimité) + longueur + chemin
|
||||
let etape = PftpStep(path: "/U/0/", data: Data())
|
||||
let attendu = Data([0x08, 0x01, 0x12, 0x05]) + Data("/U/0/".utf8)
|
||||
XCTAssertEqual(etape.header(), attendu)
|
||||
}
|
||||
|
||||
func testEnTeteAvecCheminReel() {
|
||||
let chemin = "/U/0/20260831/TST/180000/TST.BPB"
|
||||
let entete = PftpStep(path: chemin, data: Data([0x00])).header()
|
||||
XCTAssertEqual(Array(entete.prefix(3)), [0x08, 0x01, 0x12])
|
||||
XCTAssertEqual(entete[3], UInt8(chemin.utf8.count))
|
||||
XCTAssertEqual(entete.suffix(chemin.utf8.count), Data(chemin.utf8))
|
||||
}
|
||||
|
||||
func testEnTeteNePorteAucunCadrageSerie() {
|
||||
// Le préfixe [0x05, taille, taille] appartient au transport RFC76 de la
|
||||
// version USB. En Bluetooth, le SDK cadre lui-même : le reporter ici
|
||||
// produirait une requête malformée — c'est-à-dire ce qui plante la
|
||||
// montre. Le premier octet doit être le tag protobuf, jamais 0x05.
|
||||
let entete = PftpStep(path: "/U/0/test.BPB", data: Data([1])).header()
|
||||
XCTAssertEqual(entete.first, 0x08)
|
||||
}
|
||||
|
||||
// MARK: - Varint
|
||||
|
||||
func testVarintSurUnOctetEnDessousDe128() {
|
||||
XCTAssertEqual(PftpStep.varint(0), [0x00])
|
||||
XCTAssertEqual(PftpStep.varint(5), [0x05])
|
||||
XCTAssertEqual(PftpStep.varint(127), [0x7F])
|
||||
}
|
||||
|
||||
func testVarintPasseADeuxOctetsA128() {
|
||||
// Un chemin de plus de 127 caractères existe : `/U/0/<date>/TST/<heure>/`
|
||||
// plus un nom de fichier reste court, mais l'encodage doit être juste
|
||||
// pour que la montre lise le bon nombre d'octets.
|
||||
XCTAssertEqual(PftpStep.varint(128), [0x80, 0x01])
|
||||
XCTAssertEqual(PftpStep.varint(300), [0xAC, 0x02])
|
||||
}
|
||||
|
||||
func testEnTeteAvecCheminLongEncodeLaLongueurSurDeuxOctets() {
|
||||
let chemin = "/U/0/" + String(repeating: "a", count: 200)
|
||||
let entete = PftpStep(path: chemin, data: Data([1])).header()
|
||||
XCTAssertEqual(Array(entete[3...4]), PftpStep.varint(chemin.utf8.count))
|
||||
}
|
||||
|
||||
// MARK: - Description
|
||||
|
||||
func testLaDescriptionDistingueMkdirEtPut() {
|
||||
XCTAssertTrue(PftpStep(path: "/U/0/x/", data: Data()).describe.hasPrefix("mkdir"))
|
||||
XCTAssertTrue(PftpStep(path: "/U/0/x.BPB", data: Data([1])).describe.hasPrefix("put"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Les trois causes d'échec ne doivent pas se confondre
|
||||
//
|
||||
// Au premier essai Bluetooth du 2026-08-31, un message unique disait « aucune
|
||||
// montre exposant PsFTP » — sans distinguer « rien vu du tout » (Bluetooth
|
||||
// éteint, permission refusée) de « la montre est là mais ne répond pas » (canal
|
||||
// occupé par Polar Flow). Ce sont des causes opposées et des gestes différents.
|
||||
|
||||
final class PolarPftpErrorTests: XCTestCase {
|
||||
|
||||
func testAucunAppareilVuPointeVersLIPhone() {
|
||||
let e = PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0)
|
||||
XCTAssertTrue(e.description.contains("autorisation Bluetooth"))
|
||||
XCTAssertFalse(e.description.contains("Polar Flow"),
|
||||
"sans aucun appareil vu, accuser Flow envoie sur une fausse piste")
|
||||
}
|
||||
|
||||
func testUneMontreMuettePointeVersFlow() {
|
||||
let e = PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1)
|
||||
XCTAssertTrue(e.description.contains("Polar Flow"))
|
||||
}
|
||||
|
||||
/// ⚠️ Le message conseillait « fermer complètement l'app Polar Flow ».
|
||||
/// Mesuré faux le 31/08/2026 : la montre affichait « connexion impossible »
|
||||
/// pendant que les réglages iOS la disaient toujours connectée à Flow. iOS
|
||||
/// maintient le lien d'un accessoire appairé, app fermée ou non.
|
||||
func testLeMessageNeConseillePlusDeFermerLApp() {
|
||||
let e = PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1)
|
||||
XCTAssertTrue(e.description.contains("fermer l'app n'y change rien"),
|
||||
"le geste inutile doit être explicitement écarté")
|
||||
XCTAssertTrue(e.description.contains("Redémarrer la montre"),
|
||||
"le geste qui libère réellement le canal doit être donné")
|
||||
XCTAssertTrue(e.description.contains("câble"),
|
||||
"le chemin fiable doit être rappelé")
|
||||
}
|
||||
|
||||
/// L'« inconnue restante » a été levée le 31/08 : la montre expose bien
|
||||
/// PsFTP quand elle est connectée. Le message ne doit plus la présenter
|
||||
/// comme une question ouverte.
|
||||
func testDesAppareilsSansPsFtpNePresententPlusUneInconnue() {
|
||||
let e = PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0)
|
||||
XCTAssertTrue(e.description.contains("n'expose PsFTP"))
|
||||
XCTAssertTrue(e.description.contains("4 appareil"))
|
||||
XCTAssertFalse(e.description.contains("inconnue"),
|
||||
"la question a été tranchée, ne pas la rouvrir dans l'UI")
|
||||
}
|
||||
|
||||
func testLesTroisMessagesSontDistincts() {
|
||||
let messages = Set([
|
||||
PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0).description,
|
||||
PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1).description,
|
||||
PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0).description,
|
||||
])
|
||||
XCTAssertEqual(messages.count, 3)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - « La radio ne marche pas » ≠ « la montre est introuvable »
|
||||
//
|
||||
// Le 2026-08-31, le scan a tourné 30 s et rendu « aucun appareil » — alors que
|
||||
// la vraie cause était en amont : iOS n'avait jamais demandé l'autorisation
|
||||
// Bluetooth, et la ligne n'apparaissait même pas dans les réglages de l'app.
|
||||
// Un message parlant de la montre envoyait chercher au mauvais endroit.
|
||||
|
||||
extension PolarPftpErrorTests {
|
||||
|
||||
func testRadioInutilisableNeParlePasDeLaMontre() {
|
||||
let e = PolarPftpError.bluetoothUnusable(
|
||||
"coach n'a pas l'autorisation d'utiliser le Bluetooth.")
|
||||
XCTAssertFalse(e.description.contains("montre"))
|
||||
XCTAssertTrue(e.description.contains("autorisation"))
|
||||
}
|
||||
|
||||
func testRadioInutilisableEstDistinctDeMontreIntrouvable() {
|
||||
let radio = PolarPftpError.bluetoothUnusable("Bluetooth désactivé").description
|
||||
let montre = PolarPftpError.watchNotFound(30, vues: 0,
|
||||
sansPsFtp: 0, muettes: 0).description
|
||||
XCTAssertNotEqual(radio, montre)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Radio muette ou SDK muet : ce n'est pas le même diagnostic
|
||||
//
|
||||
// Le 2026-08-31, le SDK ne remontait aucune session et rien ne disait si
|
||||
// CoreBluetooth lui-même voyait quelque chose. Sans cette mesure, impossible de
|
||||
// savoir s'il fallait chercher du côté de la montre ou de notre code.
|
||||
|
||||
extension PolarPftpErrorTests {
|
||||
|
||||
func testAucunAppareilEnScanDirectAccuseLEnvironnement() {
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 0)
|
||||
XCTAssertTrue(e.description.contains("hors\u{00A0}de portée")
|
||||
|| e.description.contains("hors de portée"))
|
||||
XCTAssertFalse(e.description.contains("intégration"))
|
||||
}
|
||||
|
||||
func testDesAppareilsEnScanDirectAccusentLeSDK() {
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 12)
|
||||
XCTAssertTrue(e.description.contains("intégration du SDK"))
|
||||
XCTAssertTrue(e.description.contains("12"))
|
||||
XCTAssertTrue(e.description.contains("La radio va bien"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - « On n'a jamais cherché » n'est pas « on n'a rien trouvé »
|
||||
//
|
||||
// `search()` du SDK commence par `monitorBleState().filter { $0 == .poweredOn }`.
|
||||
// Si cet état n'arrive pas, le publisher ne dit RIEN : ni valeur, ni fin, ni
|
||||
// erreur. Le timeout conclut alors « aucun appareil » alors que le scan n'a
|
||||
// jamais démarré. Mesuré le 2026-08-31 : 43 appareils en scan direct, 0 par le
|
||||
// SDK.
|
||||
|
||||
extension PolarPftpErrorTests {
|
||||
|
||||
func testPublisherMuetNAccusePasLaMontre() {
|
||||
// ⚠️ Assoupli le 2026-08-31 : le message affirmait « le scan n'a pas
|
||||
// démarré », ce qui était une conclusion de trop — le publisher se tait
|
||||
// aussi quand le scan tourne sans rien découvrir. Ce test vérifie
|
||||
// désormais qu'on n'accuse ni la montre, ni une cause unique.
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||
publisherAParle: false)
|
||||
XCTAssertTrue(e.description.contains("aucun appareil"))
|
||||
XCTAssertFalse(e.description.contains("Ce n'est pas la montre"),
|
||||
"ne rien affirmer sur la montre : on n'en sait rien ici")
|
||||
}
|
||||
|
||||
func testUneErreurDuSdkEstRapporteeTelleQuelle() {
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||
publisherAParle: true,
|
||||
erreurSdk: "bleNotReady")
|
||||
XCTAssertTrue(e.description.contains("bleNotReady"))
|
||||
}
|
||||
|
||||
func testPublisherActifSansSessionAccuseLIntegration() {
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||
publisherAParle: true)
|
||||
XCTAssertTrue(e.description.contains("intégration du SDK"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Un envoi qui n'aboutit pas doit le dire
|
||||
//
|
||||
// Les `async` du SDK Polar n'ont aucune limite de temps. Le 2026-08-31, un envoi
|
||||
// est resté figé sans message : `waitPsFtpReady` attendait une montre qui ne
|
||||
// finissait pas la négociation. Une attente sans fin est pire qu'une erreur —
|
||||
// elle ne dit rien et n'apprend rien.
|
||||
|
||||
extension PolarPftpErrorTests {
|
||||
|
||||
func testLEcheanceDitQuoiFaire() {
|
||||
let e = PolarPftpError.echeanceDepassee(12)
|
||||
XCTAssertTrue(e.description.contains("12"))
|
||||
XCTAssertTrue(e.description.contains("écran"))
|
||||
XCTAssertTrue(e.description.contains("Polar Flow"))
|
||||
}
|
||||
|
||||
func testLEcheanceNeSeConfondPasAvecUnScanVide() {
|
||||
let echeance = PolarPftpError.echeanceDepassee(12).description
|
||||
let vide = PolarPftpError.watchNotFound(30, vues: 0,
|
||||
sansPsFtp: 0, muettes: 0).description
|
||||
XCTAssertNotEqual(echeance, vide)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ne pas conclure plus que ce qu'on a mesuré
|
||||
//
|
||||
// Le publisher de `search()` reste muet dans DEUX cas : le scan n'a pas démarré,
|
||||
// ou il tourne sans rien découvrir (`scanSubject` n'émet que sur découverte).
|
||||
// Un message affirmant le premier a fait chercher au mauvais endroit pendant
|
||||
// trois itérations du 2026-08-31.
|
||||
|
||||
extension PolarPftpErrorTests {
|
||||
|
||||
func testLeSilenceDuSdkNAffirmePasQueLeScanEstArrete() {
|
||||
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||
publisherAParle: false)
|
||||
XCTAssertTrue(e.description.contains("soit"),
|
||||
"le message doit énoncer les deux possibilités")
|
||||
XCTAssertFalse(e.description.contains("n'a jamais signalé"))
|
||||
}
|
||||
|
||||
func testAucunPolarListeCeQuiAEteVu() {
|
||||
let e = PolarPftpError.aucunPolarParmi(
|
||||
vues: 43, exemples: ["Apple Watch de Sylvain", "(sans nom)"])
|
||||
XCTAssertTrue(e.description.contains("43"))
|
||||
XCTAssertTrue(e.description.contains("Apple Watch de Sylvain"))
|
||||
XCTAssertTrue(e.description.contains("Polar Flow"),
|
||||
"l'hypothèse du lien exclusif doit être énoncée")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codes d'erreur PFTP (01/09/2026)
|
||||
|
||||
/// « errorcode 104 » rapporté par Sylvain sur un second envoi vers la même
|
||||
/// date. Ce n'était pas un refus de la montre : la session BLE s'était ouverte,
|
||||
/// et c'est notre séquence de `mkdir` qui n'était pas idempotente.
|
||||
final class PftpCodeTests: XCTestCase {
|
||||
|
||||
func test104EstBienDirectoryExists() {
|
||||
XCTAssertEqual(PftpCode.directoryExists, 104)
|
||||
}
|
||||
|
||||
func testUnMkdirSurDossierExistantEstSatisfait() {
|
||||
XCTAssertTrue(PftpCode.mkdirEstSatisfait(par: 104))
|
||||
}
|
||||
|
||||
func testLesAutresCodesRestentDesEchecs() {
|
||||
// 103 NO_SUCH_FILE_OR_DIRECTORY : le parent manque, l'ordre des mkdir
|
||||
// est en cause — surtout pas à avaler.
|
||||
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 103))
|
||||
// 105 FILE_EXISTS : l'objectif est déjà écrit. L'appelant DOIT le
|
||||
// savoir — le remplacer n'est visible qu'après redémarrage de la montre.
|
||||
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 105))
|
||||
// 106 OPERATION_NOT_PERMITTED, 108 TIMEOUT : de vrais refus.
|
||||
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 106))
|
||||
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 108))
|
||||
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 0))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user