Compare commits
19 Commits
e5c8967ea9
...
feat/watch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aa7265147 | ||
|
|
d41e8a457b | ||
|
|
0d34908f4f | ||
|
|
98b1cd0c89 | ||
|
|
3b5b61268c | ||
|
|
a794323314 | ||
|
|
7e7be4a1d1 | ||
|
|
8e0c2738ce | ||
|
|
6031be8c1c | ||
|
|
8973a26e47 | ||
|
|
b15867273c | ||
|
|
31467851d9 | ||
|
|
2c9af6c684 | ||
|
|
a5cbb70210 | ||
|
|
adb8177f2f | ||
|
|
157250b3eb | ||
|
|
bb1261dfd8 | ||
|
|
316a35f33b | ||
|
|
446b611afd |
@@ -126,5 +126,6 @@ l'emplacement de la toolchain.
|
|||||||
## ⚠️ Limites connues sync
|
## ⚠️ 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.
|
- **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).
|
- **Polar AccessLink** : pas d'historique pré-enregistrement (limitation API).
|
||||||
- **Push training-targets vers Polar** : nécessiterait migration AccessLink v4 (backlog).
|
- **Push training-targets vers Polar** : nécessiterait migration AccessLink v4 (backlog).
|
||||||
|
|||||||
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.
|
||||||
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.
|
||||||
@@ -18,8 +18,6 @@
|
|||||||
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
|
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
|
||||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||||
3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; };
|
|
||||||
C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; };
|
|
||||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; };
|
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; };
|
||||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||||
@@ -31,9 +29,13 @@
|
|||||||
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; };
|
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; };
|
||||||
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
|
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
|
||||||
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
|
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; };
|
||||||
|
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */; };
|
||||||
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; };
|
6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; };
|
||||||
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
|
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
|
||||||
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
|
79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; };
|
||||||
|
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 */; };
|
880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; };
|
||||||
880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; };
|
880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; };
|
||||||
880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
@@ -50,7 +52,7 @@
|
|||||||
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
|
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
|
||||||
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
|
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
|
||||||
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
|
97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; };
|
||||||
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 */; };
|
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
|
||||||
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
|
A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; };
|
||||||
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
|
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
|
||||||
@@ -61,6 +63,8 @@
|
|||||||
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||||
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; };
|
||||||
C0AC4E1Z0005HRZONES00001 /* 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 */; };
|
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
||||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
|
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
|
||||||
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
|
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
|
||||||
@@ -68,7 +72,6 @@
|
|||||||
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; };
|
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; };
|
||||||
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; };
|
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; };
|
||||||
F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0004 /* CoachSleep.swift */; };
|
F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0004 /* CoachSleep.swift */; };
|
||||||
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -107,15 +110,15 @@
|
|||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */ = {
|
C4916F81E61FABAAD62EE4F9 /* Embed Foundation Extensions */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
dstPath = "";
|
dstPath = "";
|
||||||
dstSubfolderSpec = 13;
|
dstSubfolderSpec = 13;
|
||||||
files = (
|
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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = {
|
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = {
|
||||||
@@ -152,6 +155,8 @@
|
|||||||
60061E60061E60061E060002 /* CoachGoogleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachGoogleAuth.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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; };
|
880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
@@ -165,8 +170,6 @@
|
|||||||
88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; };
|
88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = "<group>"; };
|
||||||
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
|
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
|
||||||
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteFilter.swift; sourceTree = "<group>"; };
|
|
||||||
C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationTracker.swift; sourceTree = "<group>"; };
|
|
||||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
|
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
|
||||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
|
96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = "<group>"; };
|
||||||
@@ -178,9 +181,12 @@
|
|||||||
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
|
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
|
||||||
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
|
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
|
||||||
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
|
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
|
||||||
|
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>"; };
|
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
||||||
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
|
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
|
||||||
C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; };
|
||||||
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
|
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
|
||||||
@@ -191,7 +197,6 @@
|
|||||||
F1ABCDEF0123456789AB0004 /* CoachSleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachSleep.swift; sourceTree = "<group>"; };
|
F1ABCDEF0123456789AB0004 /* CoachSleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachSleep.swift; sourceTree = "<group>"; };
|
||||||
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = "<group>"; };
|
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = "<group>"; };
|
||||||
FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -205,18 +210,7 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
880A76D4302B601000F582D5 /* CoachWatchWidgets */ = {
|
880A76D4302B601000F582D5 /* CoachWatchWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (880A76E2302B601100F582D5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = CoachWatchWidgets; sourceTree = "<group>"; };
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
exceptions = (
|
|
||||||
880A76E2302B601100F582D5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
|
|
||||||
);
|
|
||||||
explicitFileTypes = {
|
|
||||||
};
|
|
||||||
explicitFolders = (
|
|
||||||
);
|
|
||||||
path = CoachWatchWidgets;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -225,6 +219,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
||||||
|
8807F51D3045721100A4D4E8 /* PolarBleSdk in Frameworks */,
|
||||||
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */,
|
88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -307,6 +302,8 @@
|
|||||||
504EC3061FED79650016851F /* App */ = {
|
504EC3061FED79650016851F /* App */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
8807F5183045719400A4D4E8 /* CoachPolarBLE.swift */,
|
||||||
|
8807F5193045719400A4D4E8 /* PolarPftpStep.swift */,
|
||||||
88ABE0023024C45A002636FD /* CoachQuickLog.swift */,
|
88ABE0023024C45A002636FD /* CoachQuickLog.swift */,
|
||||||
88ABE0033024C45A002636FD /* CoachQuickSync.swift */,
|
88ABE0033024C45A002636FD /* CoachQuickSync.swift */,
|
||||||
88A837392FAA741C00DDD144 /* App.entitlements */,
|
88A837392FAA741C00DDD144 /* App.entitlements */,
|
||||||
@@ -408,7 +405,7 @@
|
|||||||
504EC3011FED79650016851F /* Frameworks */,
|
504EC3011FED79650016851F /* Frameworks */,
|
||||||
504EC3021FED79650016851F /* Resources */,
|
504EC3021FED79650016851F /* Resources */,
|
||||||
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */,
|
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */,
|
||||||
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */,
|
C4916F81E61FABAAD62EE4F9 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -420,6 +417,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
||||||
88924CCD2FD2A733002F3A80 /* GoogleSignIn */,
|
88924CCD2FD2A733002F3A80 /* GoogleSignIn */,
|
||||||
|
8807F51C3045721100A4D4E8 /* PolarBleSdk */,
|
||||||
);
|
);
|
||||||
productName = App;
|
productName = App;
|
||||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||||
@@ -474,7 +472,7 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastSwiftUpdateCheck = 2660;
|
LastSwiftUpdateCheck = 2660;
|
||||||
LastUpgradeCheck = 2650;
|
LastUpgradeCheck = 2700;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
CreatedOnToolsVersion = 9.2;
|
CreatedOnToolsVersion = 9.2;
|
||||||
@@ -498,6 +496,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
||||||
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */,
|
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */,
|
||||||
|
8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -584,6 +583,8 @@
|
|||||||
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */,
|
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */,
|
||||||
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */,
|
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */,
|
||||||
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
|
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
|
||||||
|
8807F51A3045719400A4D4E8 /* PolarPftpStep.swift in Sources */,
|
||||||
|
8807F51B3045719400A4D4E8 /* CoachPolarBLE.swift in Sources */,
|
||||||
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
|
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
|
||||||
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
|
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
|
||||||
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */,
|
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */,
|
||||||
@@ -673,10 +674,11 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = CoachWatch/Info.plist;
|
INFOPLIST_FILE = CoachWatch/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -691,7 +693,7 @@
|
|||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -700,6 +702,7 @@
|
|||||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -763,6 +766,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -824,10 +828,10 @@
|
|||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -849,10 +853,10 @@
|
|||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -873,10 +877,11 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = CoachWatch/Info.plist;
|
INFOPLIST_FILE = CoachWatch/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -892,7 +897,7 @@
|
|||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 11.0;
|
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -906,12 +911,13 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
|
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
|
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -947,12 +953,13 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
|
INFOPLIST_FILE = CoachWatchWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
|
INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -983,7 +990,7 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
DEVELOPMENT_TEAM = TZ2PVTRKNY;
|
DEVELOPMENT_TEAM = TZ2PVTRKNY;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -1012,7 +1019,7 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
DEVELOPMENT_TEAM = TZ2PVTRKNY;
|
DEVELOPMENT_TEAM = TZ2PVTRKNY;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -1093,6 +1100,14 @@
|
|||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference 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" */ = {
|
88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/google/GoogleSignIn-iOS";
|
repositoryURL = "https://github.com/google/GoogleSignIn-iOS";
|
||||||
@@ -1109,6 +1124,11 @@
|
|||||||
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
||||||
productName = "CapApp-SPM";
|
productName = "CapApp-SPM";
|
||||||
};
|
};
|
||||||
|
8807F51C3045721100A4D4E8 /* PolarBleSdk */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 8807F51730456ED400A4D4E8 /* XCRemoteSwiftPackageReference "polar-ble-sdk" */;
|
||||||
|
productName = PolarBleSdk;
|
||||||
|
};
|
||||||
88924CCD2FD2A733002F3A80 /* GoogleSignIn */ = {
|
88924CCD2FD2A733002F3A80 /* GoogleSignIn */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */;
|
package = 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "a07fb3cab82bafe1a29362b82b6680a574f65c3772bc11737e36d9b15949d1dd",
|
"originHash" : "71d869ed5304dbc1f922c9e16fb695db4fb02f0cacefa12d4a10572958e936b8",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "app-check",
|
"identity" : "app-check",
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
|
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "f1a8fadf1437c23b825c818fb6509c9dbbae2f61",
|
"revision" : "0b6882e9a3288342aacf36348e5a94e4f1dd7b13",
|
||||||
"version" : "8.3.1"
|
"version" : "8.5.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -73,6 +73,15 @@
|
|||||||
"version" : "2.1.1"
|
"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",
|
"identity" : "promises",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -81,6 +90,24 @@
|
|||||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
||||||
"version" : "2.4.0"
|
"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
|
"version" : 3
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2650"
|
LastUpgradeVersion = "2700"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2650"
|
LastUpgradeVersion = "2700"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
@@ -55,7 +55,6 @@
|
|||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
|
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
|
||||||
BuildableName = "CoachWatch.app"
|
BuildableName = "CoachWatch.app"
|
||||||
BlueprintName = "CoachWatch"
|
|
||||||
ReferencedContainer = "container:App.xcodeproj">
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
@@ -72,7 +71,6 @@
|
|||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
|
BlueprintIdentifier = "D5BF41648D334DC24770B9D2"
|
||||||
BuildableName = "CoachWatch.app"
|
BuildableName = "CoachWatch.app"
|
||||||
BlueprintName = "CoachWatch"
|
|
||||||
ReferencedContainer = "container:App.xcodeproj">
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
|||||||
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: "deleteWorkout", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getStepSamples", returnType: CAPPluginReturnPromise),
|
||||||
]
|
]
|
||||||
|
|
||||||
private lazy var store = HKHealthStore()
|
private lazy var store = HKHealthStore()
|
||||||
@@ -183,17 +184,36 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
var all: [CLLocation] = []
|
var all: [CLLocation] = []
|
||||||
let group = DispatchGroup()
|
let group = DispatchGroup()
|
||||||
var pluginErr: Error?
|
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 {
|
for route in routes {
|
||||||
group.enter()
|
group.enter()
|
||||||
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
|
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
|
||||||
if let err = err {
|
if let err = err {
|
||||||
|
lock.lock()
|
||||||
pluginErr = err
|
pluginErr = err
|
||||||
|
lock.unlock()
|
||||||
group.leave()
|
group.leave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let locs = locations {
|
if let locs = locations {
|
||||||
|
lock.lock()
|
||||||
all.append(contentsOf: locs)
|
all.append(contentsOf: locs)
|
||||||
|
lock.unlock()
|
||||||
}
|
}
|
||||||
if done {
|
if done {
|
||||||
group.leave()
|
group.leave()
|
||||||
@@ -396,6 +416,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
store.execute(wq)
|
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
|
/// Assemble métadonnées + statistiques d'une séance. Les champs absents ne
|
||||||
/// sont pas émis : le backend distingue « pas mesuré » de « zéro ».
|
/// sont pas émis : le backend distingue « pas mesuré » de « zéro ».
|
||||||
private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) {
|
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
|
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.
|
// Statistiques agrégées sur la fenêtre exacte de la séance.
|
||||||
let samplePredicate = HKQuery.predicateForObjects(from: workout)
|
let samplePredicate = HKQuery.predicateForObjects(from: workout)
|
||||||
var requests: [(key: String, id: HKQuantityTypeIdentifier, unit: HKUnit, option: HKStatisticsOptions, scale: Double)] = []
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,10 @@
|
|||||||
// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z',
|
// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z',
|
||||||
// });
|
// });
|
||||||
// // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...]
|
// // 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 Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -68,6 +71,28 @@ public class CoachSleepPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"])
|
call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"])
|
||||||
return
|
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")),
|
guard let start = Self.parseISO(call.getString("startDate")),
|
||||||
let end = Self.parseISO(call.getString("endDate")) else {
|
let end = Self.parseISO(call.getString("endDate")) else {
|
||||||
call.reject("startDate et endDate requis (ISO 8601)")
|
call.reject("startDate et endDate requis (ISO 8601)")
|
||||||
|
|||||||
@@ -13,10 +13,18 @@
|
|||||||
// forme: { score: 72, label:"Prêt" }
|
// forme: { score: 72, label:"Prêt" }
|
||||||
// });
|
// });
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import Capacitor
|
import Capacitor
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import WatchConnectivity
|
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)
|
@objc(CoachWidgetBridgePlugin)
|
||||||
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
public let identifier = "CoachWidgetBridgePlugin"
|
public let identifier = "CoachWidgetBridgePlugin"
|
||||||
@@ -164,8 +172,15 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
||||||
pushToWatch(snapshot)
|
// Le statut d'envoi remonte au JS : jusqu'ici, la page n'avait aucun
|
||||||
call.resolve(["ok": true])
|
// 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
|
/// 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.
|
/// de SA montre, alimenté ici via WatchConnectivity.
|
||||||
/// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente
|
/// 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.
|
/// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée.
|
||||||
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) {
|
/// Rend la raison de l'échec, ou `nil` si le snapshot est parti.
|
||||||
guard WCSession.isSupported() else { return }
|
///
|
||||||
|
/// ⚠️ 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
|
let session = WCSession.default
|
||||||
guard session.activationState == .activated,
|
guard session.activationState == .activated else {
|
||||||
let data = try? JSONEncoder().encode(snapshot) else { return }
|
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
|
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent
|
||||||
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
|
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
|
||||||
try? session.updateApplicationContext(["widgetSnapshot": data])
|
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.
|
/// Relit le snapshot RÉELLEMENT stocké dans l'App Group.
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ struct CoachWidgetSnapshot: Codable {
|
|||||||
var kcalToday: Double? = nil
|
var kcalToday: Double? = nil
|
||||||
var kcalGoal: 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.
|
/// 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.
|
/// `nil` ⇒ repli aligné sur les préréglages par défaut du serveur.
|
||||||
var quickDrinks: [QuickDrink]? = nil
|
var quickDrinks: [QuickDrink]? = nil
|
||||||
@@ -237,6 +252,13 @@ extension CoachWidgetSnapshot {
|
|||||||
s.waterMlToday = 0
|
s.waterMlToday = 0
|
||||||
s.coffeeCountToday = 0
|
s.coffeeCountToday = 0
|
||||||
s.kcalToday = 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
|
s.routine = nil
|
||||||
// L'activité de la veille n'est pas celle du jour, et le score de forme
|
// 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
|
// du jour n'est calculé qu'après la nuit : afficher celui d'hier comme
|
||||||
|
|||||||
@@ -310,12 +310,29 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts")
|
NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Notifier le plus récent (par endDate) — généralement il y
|
// ⚠️ Les rapports serveur partent pour TOUTES les séances du lot.
|
||||||
// en a 1 mais Apple peut grouper plusieurs samples.
|
// 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 latest = workouts.max(by: { $0.endDate < $1.endDate })!
|
||||||
let uuid = latest.uuid.uuidString
|
let uuid = latest.uuid.uuidString
|
||||||
UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey)
|
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)
|
self.scheduleNotification(for: latest)
|
||||||
}
|
}
|
||||||
healthStore.execute(query)
|
healthStore.execute(query)
|
||||||
@@ -342,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
|
/// 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.
|
/// correspond à une routine poussée dans l'app Exercice.
|
||||||
///
|
///
|
||||||
@@ -359,19 +407,31 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
req.httpMethod = "POST"
|
req.httpMethod = "POST"
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
|
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: [
|
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||||
"activity": "functionalStrengthTraining",
|
"activity": activity == .traditionalStrengthTraining
|
||||||
|
? "traditionalStrengthTraining" : "functionalStrengthTraining",
|
||||||
"duration_min": workout.duration / 60,
|
"duration_min": workout.duration / 60,
|
||||||
|
"uuid": workout.uuid.uuidString,
|
||||||
|
"start": ISO8601DateFormatter().string(from: workout.startDate),
|
||||||
])
|
])
|
||||||
req.timeoutInterval = 15
|
req.timeoutInterval = 15
|
||||||
|
|
||||||
URLSession.shared.dataTask(with: req) { data, _, error in
|
envoyer(req, libelle: "auto-complete routine") { data in
|
||||||
if let error = error {
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
NSLog("[CoachWorkoutObserver] auto-complete routine: %@", error.localizedDescription)
|
NSLog("[CoachWorkoutObserver] auto-complete : réponse illisible")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data,
|
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
||||||
if (json["matched"] as? Bool) == true {
|
if (json["matched"] as? Bool) == true {
|
||||||
let n = (json["done"] as? [String])?.count ?? 0
|
let n = (json["done"] as? [String])?.count ?? 0
|
||||||
NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)",
|
NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)",
|
||||||
@@ -379,7 +439,7 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
} else {
|
} else {
|
||||||
NSLog("[CoachWorkoutObserver] pas une routine (%@)", (json["reason"] as? String) ?? "?")
|
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
|
/// Dit au serveur si la séance vient d'une **séance programmée** ou d'un
|
||||||
@@ -428,13 +488,12 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
"plan_name": planName,
|
"plan_name": planName,
|
||||||
])
|
])
|
||||||
req.timeoutInterval = 15
|
req.timeoutInterval = 15
|
||||||
URLSession.shared.dataTask(with: req) { _, _, error in
|
// Le log de succès ne part plus que sur un 2xx : il s'écrivait
|
||||||
if let error = error {
|
// auparavant sur un 401 ou un 500, puisque seul `error` était lu.
|
||||||
NSLog("[CoachWorkoutObserver] plan-origin: %@", error.localizedDescription)
|
self.envoyer(req, libelle: "plan-origin") { _ in
|
||||||
} else {
|
NSLog("[CoachWorkoutObserver] plan-origin accepté (from_plan=%@)",
|
||||||
NSLog("[CoachWorkoutObserver] plan-origin envoyé (from_plan=%@)", fromPlan ? "true" : "false")
|
fromPlan ? "true" : "false")
|
||||||
}
|
}
|
||||||
}.resume()
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ struct HeartRateZones: Codable, Equatable, Sendable {
|
|||||||
/// vers Z2-Z3. C'est le repère clinique, celui qui compte ici.
|
/// vers Z2-Z3. C'est le repère clinique, celui qui compte ici.
|
||||||
var clinicalTargetBpm: Int?
|
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 ?
|
/// L'effort est-il dans la zone d'entraînement utile ?
|
||||||
func isUseful(bpm: Int) -> Bool {
|
func isUseful(bpm: Int) -> Bool {
|
||||||
guard let target = clinicalTargetBpm else { return false }
|
guard let target = clinicalTargetBpm else { return false }
|
||||||
|
|||||||
@@ -33,21 +33,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
options connectionOptions: UIScene.ConnectionOptions) {
|
options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
guard let windowScene = scene as? UIWindowScene else { return }
|
guard let windowScene = scene as? UIWindowScene else { return }
|
||||||
|
|
||||||
// Le manifeste déclare `UISceneStoryboardFile = Main` : UIKit a donc
|
// ⚠️ Ne PAS conditionner cette création à `window == nil`.
|
||||||
// déjà chargé Main.storyboard et instancié `MainViewController`, qui
|
|
||||||
// porte l'enregistrement des plugins natifs et la config WKWebView.
|
|
||||||
//
|
//
|
||||||
// Le template de Capacitor pose ici un `CAPBridgeViewController()` nu.
|
// C'était ma première version, par prudence : le manifeste déclarant
|
||||||
// L'appliquer tel quel remplacerait notre sous-classe et ferait perdre
|
// `UISceneStoryboardFile = Main`, je supposais qu'UIKit avait déjà
|
||||||
// cette configuration **sans erreur visible**. On ne crée donc la
|
// monté la fenêtre et qu'il ne fallait pas l'écraser. Résultat sur
|
||||||
// fenêtre que si le storyboard ne l'a pas fait, et avec notre
|
// appareil : **écran noir permanent, sans la moindre erreur**. UIKit
|
||||||
// contrôleur.
|
// fournit bien une fenêtre, mais ne la rend pas visible à notre place ;
|
||||||
if window == nil {
|
// 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)
|
let fenetre = UIWindow(windowScene: windowScene)
|
||||||
fenetre.rootViewController = MainViewController()
|
fenetre.rootViewController = MainViewController()
|
||||||
window = fenetre
|
window = fenetre
|
||||||
fenetre.makeKeyAndVisible()
|
fenetre.makeKeyAndVisible()
|
||||||
}
|
|
||||||
|
|
||||||
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import PackageDescription
|
|||||||
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "CapApp-SPM",
|
name: "CapApp-SPM",
|
||||||
platforms: [.iOS(.v15)],
|
platforms: [.iOS(.v"$)],
|
||||||
products: [
|
products: [
|
||||||
.library(
|
.library(
|
||||||
name: "CapApp-SPM",
|
name: "CapApp-SPM",
|
||||||
|
|||||||
@@ -17,41 +17,14 @@ import SwiftUI
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
// MARK: - Intents
|
// MARK: - Intents
|
||||||
|
//
|
||||||
/// Ajoute une boisson sans quitter l'écran d'accueil.
|
// ⚠️ `LogDrinkIntent` et `CoachQuickError` ont DÉMÉNAGÉ dans
|
||||||
@available(iOS 17.0, watchOS 10.0, *)
|
// `ios/App/App/CoachIntents.swift` le 25/09/2026, pour être partagés entre
|
||||||
struct LogDrinkIntent: AppIntent {
|
// trois cibles : ce widget, l'app (d'où Siri les découvre) et le widget Watch.
|
||||||
static var title: LocalizedStringResource = "Noter une boisson"
|
// Les laisser ici les rendait invisibles aux deux autres — Siri n'avait aucun
|
||||||
/// `false` : tout l'intérêt est de NE PAS ouvrir l'app.
|
// intent à écouter, et la montre ne savait pas noter un verre.
|
||||||
static var openAppWhenRun: Bool = false
|
// ⛔ 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.
|
||||||
@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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Timeline
|
// MARK: - Timeline
|
||||||
|
|
||||||
|
|||||||
@@ -142,9 +142,21 @@ final class LocationTracker: NSObject, ObservableObject {
|
|||||||
guard isTracking else { return }
|
guard isTracking else { return }
|
||||||
isPaused = paused
|
isPaused = paused
|
||||||
if paused {
|
if paused {
|
||||||
// Le point qui suivra la reprise serait à des centaines de mètres :
|
// ⚠️ Ici se jouait une perte silencieuse. `pendingRoute` ne contient
|
||||||
// on repart d'une origine neuve pour ne pas tracer la pause.
|
// que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
|
||||||
pendingRoute.removeAll()
|
// `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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -381,13 +381,33 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
|
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
|
||||||
// l'associant au workout obtenu.
|
// l'associant au workout obtenu.
|
||||||
LocationTracker.shared.stopTracking()
|
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
|
// ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est
|
||||||
// verrouillée : ce n'est pas un échec, la séance EST écrite dans
|
// verrouillée : ce n'est pas un échec, la séance EST écrite dans
|
||||||
// HealthKit — seul l'objet manque. Comme `finishRoute(with:)`
|
// HealthKit — seul l'objet manque. Comme `finishRoute(with:)`
|
||||||
// exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
|
// exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
|
||||||
// on va rechercher celle qui vient d'être sauvegardée.
|
// on va rechercher celle qui vient d'être sauvegardée.
|
||||||
let workout = try? await builder.finishWorkout()
|
var workout: HKWorkout?
|
||||||
|
do {
|
||||||
|
workout = try await builder.finishWorkout()
|
||||||
|
} catch {
|
||||||
|
workoutLog.error("finishWorkout a echoue : \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
if let workout {
|
if let workout {
|
||||||
await LocationTracker.shared.finishRoute(with: workout)
|
await LocationTracker.shared.finishRoute(with: workout)
|
||||||
} else if let recovered = await self.recentlySavedWorkout() {
|
} else if let recovered = await self.recentlySavedWorkout() {
|
||||||
@@ -395,6 +415,18 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
await LocationTracker.shared.finishRoute(with: recovered)
|
await LocationTracker.shared.finishRoute(with: recovered)
|
||||||
} else {
|
} else {
|
||||||
LocationTracker.shared.discardRoute()
|
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
|
Task { @MainActor in
|
||||||
@@ -453,9 +485,43 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
let message = error.localizedDescription
|
let message = error.localizedDescription
|
||||||
let ns = error as NSError
|
let ns = error as NSError
|
||||||
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
|
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
|
||||||
|
let builder = workoutSession.associatedWorkoutBuilder()
|
||||||
Task { @MainActor in
|
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.isRunning = false
|
||||||
|
self.isPaused = false
|
||||||
self.session = nil
|
self.session = nil
|
||||||
self.builder = nil
|
self.builder = nil
|
||||||
ConnectivityManager.shared.sendEnded(reason: "session-failed")
|
ConnectivityManager.shared.sendEnded(reason: "session-failed")
|
||||||
|
|||||||
@@ -300,5 +300,11 @@ struct CoachWatchWidgetsBundle: WidgetBundle {
|
|||||||
CoachWatchFormeWidget()
|
CoachWatchFormeWidget()
|
||||||
CoachWatchSessionWidget()
|
CoachWatchSessionWidget()
|
||||||
CoachWatchRoutineWidget()
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,4 +18,6 @@ struct HeartRateZones: Codable, Equatable, Sendable {
|
|||||||
var z5: [Int]?
|
var z5: [Int]?
|
||||||
var fcmaxUsed: Int?
|
var fcmaxUsed: Int?
|
||||||
var clinicalTargetBpm: Int?
|
var clinicalTargetBpm: Int?
|
||||||
|
var zonesSince: String?
|
||||||
|
var zonesFrom: String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,16 @@ final class DayRolloverTests: XCTestCase {
|
|||||||
s.waterMlToday = 1660
|
s.waterMlToday = 1660
|
||||||
s.coffeeCountToday = 3
|
s.coffeeCountToday = 3
|
||||||
s.kcalToday = 1186
|
s.kcalToday = 1186
|
||||||
|
s.proteinToday = 74
|
||||||
|
s.fatToday = 41
|
||||||
|
s.carbsToday = 158
|
||||||
s.waterGoalMl = 2750
|
s.waterGoalMl = 2750
|
||||||
s.kcalGoal = 1950
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +75,28 @@ final class DayRolloverTests: XCTestCase {
|
|||||||
XCTAssertEqual(vu.kcalToday, 0)
|
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() {
|
func testYesterdaysDayScopedFieldsAreDropped() {
|
||||||
let vu = snapshot(at: "2026-08-17 22:00").asOf(date("2026-08-18 00:01"), calendar: zurich)
|
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")
|
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")
|
"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
|
// MARK: - Au-delà du lendemain : ne rien inventer
|
||||||
|
|
||||||
func testAfterTwoDaysTheSessionIsUnknown() {
|
func testAfterTwoDaysTheSessionIsUnknown() {
|
||||||
|
|||||||
Reference in New Issue
Block a user