feat(watch): routine quotidienne cochable sur l'Apple Watch
Pendant natif du backend coach_sportif (commit 772385b, déjà en prod). - CoachRoutineBridge (target App) : pousse blocs + état du jour vers la Watch via updateApplicationContext, relaie les coches Watch vers /api/routine/day puis notifie la WebView (event routineUpdated) - CoachWatch/RoutineStore : état persisté en UserDefaults, reset au changement de jour. Les blocs viennent de l'iPhone — rien codé en dur côté watchOS, donc modifier la routine côté web ne demande aucun rebuild - CoachWatch/RoutineView : liste cochable, progression, haptique, bouton de renvoi si la synchro a échoué - ConnectivityManager (watch) : sendRoutine() en sendMessage avec repli transferUserInfo (coche faite iPhone hors de portée -> livraison différée) ⚠️ CoachLiveBridge touché : routeIfNotLiveSample() aiguille les messages routineDone vers NotificationCenter. WCSession.delegate est unique côté iPhone, impossible d'en ajouter un second. Le flux live workout n'est pas modifié mais doit être re-vérifié au build. Les 3 nouveaux fichiers sont référencés dans project.pbxproj (bonne target) : aucun "Add Files" à faire sur le Mac. Runbook : docs/routine-watch-runbook-mac.md NON COMPILÉ — nécessite une session Xcode sur le Mac mini. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
21
COWORK.md
21
COWORK.md
@@ -30,6 +30,27 @@ Les deux ne communiquent **que via git + ce fichier**. ⚠️ La mémoire de cha
|
|||||||
- Watch : `CoachWidgetBridge.pushToWatch` (WCSession) → `CoachWatch/ConnectivityManager` reçoit → App Group montre + reload. Complications `CoachWatchWidgets/CoachWatchComplications.swift` (Forme circular/corner, Séance rectangular/inline). **Nouvelle target widget watchOS à créer dans Xcode.**
|
- Watch : `CoachWidgetBridge.pushToWatch` (WCSession) → `CoachWatch/ConnectivityManager` reçoit → App Group montre + reload. Complications `CoachWatchWidgets/CoachWatchComplications.swift` (Forme circular/corner, Séance rectangular/inline). **Nouvelle target widget watchOS à créer dans Xcode.**
|
||||||
- ⚠️ Étapes Mac obligatoires (App Group portail + capabilities + Target Membership + target Watch + build) : **TOUT est détaillé dans `docs/widgets-runbook-mac.md`.**
|
- ⚠️ Étapes Mac obligatoires (App Group portail + capabilities + Target Membership + target Watch + build) : **TOUT est détaillé dans `docs/widgets-runbook-mac.md`.**
|
||||||
|
|
||||||
|
## 🆕 Routine quotidienne sur la Watch (2026-08-03, dev — non compilé)
|
||||||
|
|
||||||
|
Nouveau chantier, code complet côté dev, **build Mac requis**. Tout est détaillé
|
||||||
|
dans **`docs/routine-watch-runbook-mac.md`** (archi, étapes, débogage).
|
||||||
|
|
||||||
|
- **Nouveaux fichiers, DÉJÀ référencés dans `project.pbxproj`** (aucun « Add
|
||||||
|
Files » à faire) : `App/CoachRoutineBridge.swift` (target App),
|
||||||
|
`CoachWatch/RoutineStore.swift` + `CoachWatch/RoutineView.swift` (target
|
||||||
|
CoachWatch). Backup : `project.pbxproj.bak-routine`.
|
||||||
|
- **Modifs** : `MainViewController.capacitorDidLoad()` enregistre
|
||||||
|
`CoachRoutineBridgePlugin` ; `CoachWatch/ContentView` ouvre `RoutineView` en
|
||||||
|
sheet ; `ConnectivityManager` (watch) gagne `sendRoutine()` + réception du
|
||||||
|
`routineSnapshot`.
|
||||||
|
- ⚠️ **`CoachLiveBridge` touché** — un seul ajout : `routeIfNotLiveSample()`
|
||||||
|
aiguille les messages `routineDone` vers `NotificationCenter` au lieu de les
|
||||||
|
émettre comme samples. Le délégué `WCSession` est unique côté iPhone, on ne
|
||||||
|
pouvait pas en ajouter un second. **À re-vérifier au build** que le live
|
||||||
|
workout (validé E2E le 31/05) fonctionne toujours.
|
||||||
|
- Côté backend `coach_sportif` : **déjà en prod** (`/api/routine`,
|
||||||
|
`/api/routine/check`, `/api/routine/day`, `data/routine_log.json`).
|
||||||
|
|
||||||
## 👉 À reprendre
|
## 👉 À reprendre
|
||||||
- ✅ **Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
|
- ✅ **Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
|
||||||
- **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcworkspace` → Clean Build Folder → Archive → Upload.
|
- **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcworkspace` → Clean Build Folder → Archive → Upload.
|
||||||
|
|||||||
126
docs/routine-watch-runbook-mac.md
Normal file
126
docs/routine-watch-runbook-mac.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Runbook Mac — Routine quotidienne sur l'Apple Watch
|
||||||
|
|
||||||
|
**Écrit côté dev (Linux) le 2026-08-03. Rien n'est compilé.** Ce document dit
|
||||||
|
quoi faire sur le Mac pour activer la fonctionnalité, et comment la tester.
|
||||||
|
|
||||||
|
## Ce que ça fait
|
||||||
|
|
||||||
|
La routine anti-douleurs (6 blocs) apparaît sur la montre, cochable au doigt.
|
||||||
|
Une coche faite sur la Watch remonte à l'iPhone, qui la POSTe au serveur — donc
|
||||||
|
la page `/routine` et le widget de la home reflètent immédiatement l'état, et
|
||||||
|
inversement.
|
||||||
|
|
||||||
|
## Architecture (à connaître avant de débugger)
|
||||||
|
|
||||||
|
```
|
||||||
|
page /routine (web) Apple Watch (RoutineView)
|
||||||
|
│ coche │ coche
|
||||||
|
▼ ▼
|
||||||
|
POST /api/routine/check ConnectivityManager.sendRoutine
|
||||||
|
│ │ sendMessage / transferUserInfo
|
||||||
|
▼ ▼
|
||||||
|
data/routine_log.json ◄────────── CoachLiveBridge (délégué WCSession)
|
||||||
|
▲ POST /api/routine/day │ routeIfNotLiveSample()
|
||||||
|
│ ▼
|
||||||
|
└─────────────────────── CoachRoutineBridge (NotificationCenter)
|
||||||
|
│ notifyListeners
|
||||||
|
▼
|
||||||
|
routine.js re-render
|
||||||
|
```
|
||||||
|
|
||||||
|
Trois décisions structurantes, à ne pas défaire sans raison :
|
||||||
|
|
||||||
|
1. **L'état est serveur, plus en localStorage.** La Watch n'a aucun accès au
|
||||||
|
localStorage du WKWebView : il fallait un arbitre commun aux trois surfaces.
|
||||||
|
2. **On transmet l'état COMPLET du jour, jamais un delta.** Si un message
|
||||||
|
WatchConnectivity se perd, le suivant recale tout au lieu de laisser les deux
|
||||||
|
côtés diverger. C'est aussi pour ça que `updateApplicationContext` convient :
|
||||||
|
le système n'en garde que le dernier, ce qui est la sémantique voulue.
|
||||||
|
3. **`CoachRoutineBridge` n'est PAS `WCSession.delegate`.** `CoachLiveBridge`
|
||||||
|
l'est déjà et le délégué est unique. `CoachLiveBridge.routeIfNotLiveSample()`
|
||||||
|
aiguille les messages `routineDone` vers `NotificationCenter` — c'est le seul
|
||||||
|
endroit où j'ai touché au flux live workout (validé E2E sur device le 31/05),
|
||||||
|
et il retourne `true` sans rien émettre, donc `LiveStore` n'est pas pollué.
|
||||||
|
|
||||||
|
Les blocs eux-mêmes **voyagent avec l'état** : rien n'est codé en dur côté
|
||||||
|
watchOS. Modifier la routine côté web ne demande donc aucun rebuild.
|
||||||
|
|
||||||
|
## Étapes Mac
|
||||||
|
|
||||||
|
### 1. Récupérer le code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Xcode FERMÉ (règle COWORK)
|
||||||
|
cd ~/coach-ios && git pull
|
||||||
|
npm install && npx cap sync ios
|
||||||
|
open ios/App/App.xcworkspace
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Rien à ajouter dans Xcode
|
||||||
|
|
||||||
|
Les trois nouveaux fichiers sont **déjà référencés dans `project.pbxproj`**,
|
||||||
|
dans la bonne target :
|
||||||
|
|
||||||
|
| Fichier | Target |
|
||||||
|
|---|---|
|
||||||
|
| `App/CoachRoutineBridge.swift` | App |
|
||||||
|
| `CoachWatch/RoutineStore.swift` | CoachWatch |
|
||||||
|
| `CoachWatch/RoutineView.swift` | CoachWatch |
|
||||||
|
|
||||||
|
Vérification rapide si un doute :
|
||||||
|
```bash
|
||||||
|
grep -c "RoutineView.swift" ios/App/App.xcodeproj/project.pbxproj # doit valoir 4
|
||||||
|
```
|
||||||
|
(4 = PBXBuildFile + PBXFileReference + children du groupe + Sources phase.)
|
||||||
|
|
||||||
|
Backup du pbxproj avant modification : `project.pbxproj.bak-routine`.
|
||||||
|
|
||||||
|
### 3. Build
|
||||||
|
|
||||||
|
Clean Build Folder (`Cmd+Shift+K`) → Run sur l'iPhone, avec la Watch appairée.
|
||||||
|
|
||||||
|
⚠️ `CoachAuth.swift` doit exister (gitignored) : le plugin s'en sert pour
|
||||||
|
authentifier son POST vers `/api/routine/day`. Sans lui, ça ne compile pas.
|
||||||
|
|
||||||
|
### 4. Tester le cycle complet
|
||||||
|
|
||||||
|
1. Ouvrir l'app iPhone sur `/routine` — ça pousse les blocs vers la Watch.
|
||||||
|
2. Sur la Watch : app Coach → **Routine du jour** → la liste des 6 blocs doit
|
||||||
|
apparaître avec la progression.
|
||||||
|
3. Cocher un bloc sur la Watch → retour à l'iPhone, la page `/routine` doit se
|
||||||
|
mettre à jour **sans rechargement** (event `routineUpdated`).
|
||||||
|
4. Cocher un bloc sur l'iPhone → rouvrir la vue Watch, l'état doit suivre.
|
||||||
|
5. Vérifier côté serveur :
|
||||||
|
```bash
|
||||||
|
ssh ubuntu@83.228.246.229 'cat /home/ubuntu/coach_sportif/data/routine_log.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Si la Watch affiche « Ouvre l'app Coach sur l'iPhone »
|
||||||
|
|
||||||
|
C'est l'état vide normal au premier lancement : la montre n'a pas encore reçu de
|
||||||
|
blocs. Si ça persiste après avoir ouvert `/routine` sur l'iPhone :
|
||||||
|
|
||||||
|
- console Safari (Développement → iPhone → coach.hypnotruck.ch) :
|
||||||
|
```js
|
||||||
|
await Capacitor.Plugins.CoachRoutineBridge.getStatus()
|
||||||
|
```
|
||||||
|
→ `paired` et `watchAppInstalled` doivent être `true`.
|
||||||
|
- Xcode console, filtre `subsystem:ch.hypnotruck.coach category:routine`.
|
||||||
|
- `updateApplicationContext` échoue silencieusement si la session n'est pas
|
||||||
|
activée : vérifier que `CoachLiveBridge.load()` a bien tourné (c'est lui qui
|
||||||
|
active la session).
|
||||||
|
|
||||||
|
### 6. Si une coche Watch ne remonte pas
|
||||||
|
|
||||||
|
- Hors de portée de l'iPhone, c'est **normal et attendu** : `transferUserInfo`
|
||||||
|
garantit la livraison différée, la coche partira au prochain rapprochement.
|
||||||
|
La vue affiche un bouton « Renvoyer vers l'iPhone » si l'envoi a échoué net.
|
||||||
|
- Sinon : vérifier le log `POST /api/routine/day` côté Xcode (le plugin logue le
|
||||||
|
code HTTP en cas d'échec — un 401 signifie un `CoachAuth.kCoachWebToken`
|
||||||
|
périmé).
|
||||||
|
|
||||||
|
## Reste à faire (hors périmètre de ce chantier)
|
||||||
|
|
||||||
|
- Complication watchOS « Routine » : la target `CoachWatchWidgets` n'existe
|
||||||
|
toujours pas (cf. `docs/widgets-runbook-mac.md`).
|
||||||
|
- Notification de rappel sur la montre si la routine n'est pas faite à 20 h.
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
|
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
|
||||||
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
|
||||||
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
|
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
|
||||||
|
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
|
||||||
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A06CDFF5B4F7852B6329862 /* ContentView.swift */; };
|
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A06CDFF5B4F7852B6329862 /* ContentView.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, ); }; };
|
||||||
@@ -33,6 +34,8 @@
|
|||||||
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
|
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
|
||||||
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
|
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
|
||||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
|
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
|
||||||
|
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
|
||||||
|
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; };
|
||||||
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
|
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.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 */; };
|
||||||
@@ -110,8 +113,11 @@
|
|||||||
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; };
|
||||||
9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; };
|
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; };
|
||||||
|
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
|
||||||
|
969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = "<group>"; };
|
||||||
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
|
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
|
||||||
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
|
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
|
||||||
|
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.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>"; };
|
||||||
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>"; };
|
||||||
@@ -214,6 +220,7 @@
|
|||||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||||
50B271D01FEDC1A000F3C39B /* public */,
|
50B271D01FEDC1A000F3C39B /* public */,
|
||||||
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
|
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
|
||||||
|
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */,
|
||||||
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
|
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
|
||||||
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
|
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
|
||||||
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */,
|
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */,
|
||||||
@@ -229,6 +236,8 @@
|
|||||||
82122ACF7E5A608AD1D55D24 /* Info.plist */,
|
82122ACF7E5A608AD1D55D24 /* Info.plist */,
|
||||||
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */,
|
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */,
|
||||||
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */,
|
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */,
|
||||||
|
AA5C5902EFB7F60563678E46 /* RoutineView.swift */,
|
||||||
|
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
|
||||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
||||||
3342ACCE085010828CF63565 /* Assets.xcassets */,
|
3342ACCE085010828CF63565 /* Assets.xcassets */,
|
||||||
);
|
);
|
||||||
@@ -410,6 +419,7 @@
|
|||||||
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
|
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.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 */,
|
||||||
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */,
|
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */,
|
||||||
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
|
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
|
||||||
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */,
|
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */,
|
||||||
@@ -424,6 +434,8 @@
|
|||||||
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */,
|
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */,
|
||||||
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */,
|
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */,
|
||||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
||||||
|
95D0478A499056FE922211EA /* RoutineView.swift in Sources */,
|
||||||
|
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
||||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@@ -106,15 +106,30 @@ extension CoachLiveBridgePlugin: WCSessionDelegate {
|
|||||||
|
|
||||||
// Temps réel : un sample envoyé via sendMessage.
|
// Temps réel : un sample envoyé via sendMessage.
|
||||||
public func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
public func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
|
if routeIfNotLiveSample(message) { return }
|
||||||
emit(message)
|
emit(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Livraison différée : lot de samples envoyé via transferUserInfo.
|
// Livraison différée : lot de samples envoyé via transferUserInfo.
|
||||||
public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||||
|
if routeIfNotLiveSample(userInfo) { return }
|
||||||
if let samples = userInfo["samples"] as? [[String: Any]] {
|
if let samples = userInfo["samples"] as? [[String: Any]] {
|
||||||
for sample in samples { emit(sample) }
|
for sample in samples { emit(sample) }
|
||||||
} else {
|
} else {
|
||||||
emit(userInfo)
|
emit(userInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aiguillage : ce plugin est l'unique `WCSession.delegate` côté iPhone, il
|
||||||
|
/// reçoit donc AUSSI les messages qui ne concernent pas la séance live.
|
||||||
|
/// On les relaie par NotificationCenter au plugin concerné plutôt que de les
|
||||||
|
/// traiter comme des samples (ce qui polluerait LiveStore).
|
||||||
|
/// Retourne `true` si le message a été routé ailleurs.
|
||||||
|
private func routeIfNotLiveSample(_ message: [String: Any]) -> Bool {
|
||||||
|
guard message["routineDone"] != nil else { return false }
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .coachRoutineFromWatch, object: nil, userInfo: message
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
160
ios/App/App/CoachRoutineBridge.swift
Normal file
160
ios/App/App/CoachRoutineBridge.swift
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import Capacitor
|
||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let routineLog = Logger(subsystem: "ch.hypnotruck.coach", category: "routine")
|
||||||
|
|
||||||
|
/// Nom de notification interne : la Watch a modifié l'état de la routine.
|
||||||
|
/// `CoachLiveBridgePlugin` (unique `WCSession.delegate` côté iPhone) reçoit le
|
||||||
|
/// message et le relaie ici plutôt que de l'émettre comme sample de séance.
|
||||||
|
extension Notification.Name {
|
||||||
|
static let coachRoutineFromWatch = Notification.Name("coachRoutineFromWatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pont routine quotidienne iPhone ↔ Apple Watch.
|
||||||
|
///
|
||||||
|
/// Sens descendant (web → Watch) : le JS appelle `pushToWatch` à chaque coche ;
|
||||||
|
/// on pousse l'état complet du jour via `updateApplicationContext` (le système
|
||||||
|
/// ne garde que le dernier contexte, ce qui est exactement la sémantique
|
||||||
|
/// voulue : « voici l'état actuel », pas un journal d'événements).
|
||||||
|
///
|
||||||
|
/// Sens montant (Watch → serveur) : la Watch envoie son état complet, on le
|
||||||
|
/// POSTe sur `/api/routine/day` puis on notifie la WebView pour re-render.
|
||||||
|
/// L'état complet (et non un delta) rend la synchro idempotente : si un message
|
||||||
|
/// WatchConnectivity se perd, le suivant recale tout.
|
||||||
|
///
|
||||||
|
/// ⚠️ Ce plugin n'est PAS `WCSession.delegate` — `CoachLiveBridgePlugin` l'est
|
||||||
|
/// déjà et `WCSession.default.delegate` est unique. Même pattern que
|
||||||
|
/// `CoachWidgetBridge.pushToWatch`.
|
||||||
|
@objc(CoachRoutineBridgePlugin)
|
||||||
|
public class CoachRoutineBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
public let identifier = "CoachRoutineBridgePlugin"
|
||||||
|
public let jsName = "CoachRoutineBridge"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "pushToWatch", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let apiBase = "https://coach.hypnotruck.ch"
|
||||||
|
|
||||||
|
override public func load() {
|
||||||
|
super.load()
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleWatchUpdate(_:)),
|
||||||
|
name: .coachRoutineFromWatch,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JS → natif
|
||||||
|
|
||||||
|
/// `pushToWatch({ date: "2026-08-03", done: ["hanches", ...] })`
|
||||||
|
@objc func pushToWatch(_ call: CAPPluginCall) {
|
||||||
|
let date = call.getString("date") ?? Self.todayISO()
|
||||||
|
let done = call.getArray("done", String.self) ?? []
|
||||||
|
|
||||||
|
guard WCSession.isSupported() else {
|
||||||
|
call.resolve(["pushed": false, "reason": "WCSession non supporté"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let session = WCSession.default
|
||||||
|
guard session.activationState == .activated else {
|
||||||
|
call.resolve(["pushed": false, "reason": "session non activée"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard session.isPaired, session.isWatchAppInstalled else {
|
||||||
|
call.resolve(["pushed": false, "reason": "pas de Watch appairée avec l'app"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try session.updateApplicationContext([
|
||||||
|
"routineSnapshot": ["date": date, "done": done]
|
||||||
|
])
|
||||||
|
routineLog.info("routine poussée vers la Watch (\(done.count, privacy: .public) blocs)")
|
||||||
|
call.resolve(["pushed": true, "count": done.count])
|
||||||
|
} catch {
|
||||||
|
routineLog.error("updateApplicationContext a échoué : \(error.localizedDescription, privacy: .public)")
|
||||||
|
call.reject("Envoi vers la Watch impossible : \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func getStatus(_ call: CAPPluginCall) {
|
||||||
|
let session = WCSession.default
|
||||||
|
call.resolve([
|
||||||
|
"supported": WCSession.isSupported(),
|
||||||
|
"activated": session.activationState == .activated,
|
||||||
|
"paired": session.isPaired,
|
||||||
|
"watchAppInstalled": session.isWatchAppInstalled,
|
||||||
|
"reachable": session.isReachable
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Watch → serveur → WebView
|
||||||
|
|
||||||
|
@objc private func handleWatchUpdate(_ note: Notification) {
|
||||||
|
guard let payload = note.userInfo as? [String: Any],
|
||||||
|
let done = payload["routineDone"] as? [String] else { return }
|
||||||
|
let date = (payload["date"] as? String) ?? Self.todayISO()
|
||||||
|
|
||||||
|
routineLog.info("routine reçue de la Watch (\(done.count, privacy: .public) blocs) -> POST serveur")
|
||||||
|
postDay(date: date, done: done) { [weak self] serverDone in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// Le serveur fait foi : on renvoie SON état à la WebView, pas
|
||||||
|
// celui de la Watch (il a pu être filtré côté serveur).
|
||||||
|
self?.notifyListeners("routineUpdated", data: [
|
||||||
|
"date": date,
|
||||||
|
"done": serverDone ?? done
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST `/api/routine/day` avec le cookie d'auth déjà injecté par AppDelegate.
|
||||||
|
private func postDay(date: String, done: [String], completion: @escaping ([String]?) -> Void) {
|
||||||
|
guard let url = URL(string: "\(Self.apiBase)/api/routine/day") else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
|
||||||
|
req.httpBody = try? JSONSerialization.data(withJSONObject: ["date": date, "done": done])
|
||||||
|
req.timeoutInterval = 15
|
||||||
|
|
||||||
|
URLSession.shared.dataTask(with: req) { data, response, error in
|
||||||
|
if let error = error {
|
||||||
|
routineLog.error("POST /api/routine/day : \(error.localizedDescription, privacy: .public)")
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
||||||
|
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
routineLog.error("POST /api/routine/day a répondu \(code, privacy: .public)")
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = data,
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let serverDone = json["done"] as? [String] else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(serverDone)
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func todayISO() -> String {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateFormat = "yyyy-MM-dd"
|
||||||
|
fmt.timeZone = TimeZone.current
|
||||||
|
return fmt.string(from: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ class MainViewController: CAPBridgeViewController {
|
|||||||
// Pont widgets : la WebView pousse les données du jour vers l'App Group
|
// Pont widgets : la WebView pousse les données du jour vers l'App Group
|
||||||
// et rafraîchit les widgets d'écran d'accueil (cf. CoachWidgetBridge.swift).
|
// et rafraîchit les widgets d'écran d'accueil (cf. CoachWidgetBridge.swift).
|
||||||
bridge?.registerPluginInstance(CoachWidgetBridgePlugin())
|
bridge?.registerPluginInstance(CoachWidgetBridgePlugin())
|
||||||
|
// Pont routine quotidienne : pousse les blocs + l'état du jour vers
|
||||||
|
// l'app watchOS, et relaie les coches faites sur la montre vers
|
||||||
|
// /api/routine/day (cf. CoachRoutineBridge.swift).
|
||||||
|
bridge?.registerPluginInstance(CoachRoutineBridgePlugin())
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
|
|||||||
@@ -48,6 +48,31 @@ final class ConnectivityManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Envoie l'état COMPLET de la routine du jour vers l'iPhone, qui le POSTe
|
||||||
|
/// au serveur. Retourne `false` si rien n'a pu partir (l'appelant affiche
|
||||||
|
/// alors un bouton « Renvoyer »).
|
||||||
|
///
|
||||||
|
/// `sendMessage` quand l'iPhone est joignable, `transferUserInfo` sinon :
|
||||||
|
/// la coche faite pendant une séance, iPhone resté à la maison, partira au
|
||||||
|
/// prochain rapprochement.
|
||||||
|
@discardableResult
|
||||||
|
func sendRoutine(date: String, done: [String]) -> Bool {
|
||||||
|
let session = WCSession.default
|
||||||
|
guard session.activationState == .activated else { return false }
|
||||||
|
let payload: [String: Any] = ["routineDone": done, "date": date]
|
||||||
|
|
||||||
|
if session.isReachable {
|
||||||
|
session.sendMessage(payload, replyHandler: nil) { _ in
|
||||||
|
// Échec temps réel : bascule en livraison différée garantie.
|
||||||
|
session.transferUserInfo(payload)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.transferUserInfo(payload)
|
||||||
|
}
|
||||||
|
connLog.info("routine envoyée à l'iPhone (\(done.count, privacy: .public) blocs)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/// À appeler sur `queue` uniquement.
|
/// À appeler sur `queue` uniquement.
|
||||||
private func flushLocked() {
|
private func flushLocked() {
|
||||||
guard !buffer.isEmpty else { return }
|
guard !buffer.isEmpty else { return }
|
||||||
@@ -86,10 +111,22 @@ extension ConnectivityManager: WCSessionDelegate {
|
|||||||
|
|
||||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||||
storeWidgetSnapshot(applicationContext)
|
storeWidgetSnapshot(applicationContext)
|
||||||
|
storeRoutineSnapshot(applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||||
storeWidgetSnapshot(userInfo)
|
storeWidgetSnapshot(userInfo)
|
||||||
|
storeRoutineSnapshot(userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routine quotidienne poussée par l'iPhone (`CoachRoutineBridge`).
|
||||||
|
/// Les blocs voyagent avec l'état : la montre n'a aucune liste codée en dur,
|
||||||
|
/// modifier la routine côté web ne demande donc pas de rebuild watchOS.
|
||||||
|
private func storeRoutineSnapshot(_ dict: [String: Any]) {
|
||||||
|
guard let snapshot = dict["routineSnapshot"] as? [String: Any] else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
RoutineStore.shared.apply(snapshot: snapshot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func storeWidgetSnapshot(_ dict: [String: Any]) {
|
private func storeWidgetSnapshot(_ dict: [String: Any]) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
struct ActivityPickerView: View {
|
struct ActivityPickerView: View {
|
||||||
@EnvironmentObject private var workout: WorkoutManager
|
@EnvironmentObject private var workout: WorkoutManager
|
||||||
|
@State private var showRoutine = false
|
||||||
|
|
||||||
private struct Activity: Identifiable {
|
private struct Activity: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -54,6 +55,14 @@ struct ActivityPickerView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
showRoutine = true
|
||||||
|
} label: {
|
||||||
|
Label("Routine du jour", systemImage: "figure.cooldown")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.tint(.cyan)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
workout.startSimulation()
|
workout.startSimulation()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -71,6 +80,9 @@ struct ActivityPickerView: View {
|
|||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
}
|
}
|
||||||
.navigationTitle("Coach")
|
.navigationTitle("Coach")
|
||||||
|
.sheet(isPresented: $showRoutine) {
|
||||||
|
RoutineView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
ios/App/CoachWatch/RoutineStore.swift
Normal file
134
ios/App/CoachWatch/RoutineStore.swift
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import os
|
||||||
|
|
||||||
|
private let routineLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "routine")
|
||||||
|
|
||||||
|
/// Un bloc de la routine quotidienne, tel que poussé par l'iPhone.
|
||||||
|
struct RoutineBlock: Codable, Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let num: Int
|
||||||
|
let title: String
|
||||||
|
let durationMin: Int
|
||||||
|
let moment: String // "matin" | "après la course"
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, num, title
|
||||||
|
case durationMin = "duration_min"
|
||||||
|
case moment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État de la routine sur la montre.
|
||||||
|
///
|
||||||
|
/// Les blocs ne sont PAS codés en dur ici : ils viennent de l'iPhone (qui les
|
||||||
|
/// tient du serveur). Une seule source de vérité — modifier la routine côté web
|
||||||
|
/// n'oblige pas à rebuilder l'app Watch.
|
||||||
|
///
|
||||||
|
/// Persisté dans UserDefaults pour survivre au relancement de l'app Watch : sans
|
||||||
|
/// ça, la montre afficherait un écran vide tant que l'iPhone n'a pas repoussé
|
||||||
|
/// le contexte (ce qui peut prendre plusieurs minutes hors de portée).
|
||||||
|
@MainActor
|
||||||
|
final class RoutineStore: ObservableObject {
|
||||||
|
static let shared = RoutineStore()
|
||||||
|
|
||||||
|
@Published private(set) var blocks: [RoutineBlock] = []
|
||||||
|
@Published private(set) var done: Set<String> = []
|
||||||
|
@Published private(set) var date: String = ""
|
||||||
|
@Published private(set) var lastSyncFailed = false
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
private let kBlocks = "routine.blocks"
|
||||||
|
private let kDone = "routine.done"
|
||||||
|
private let kDate = "routine.date"
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEmpty: Bool { blocks.isEmpty }
|
||||||
|
|
||||||
|
var morningBlocks: [RoutineBlock] { blocks.filter { $0.moment == "matin" } }
|
||||||
|
var otherBlocks: [RoutineBlock] { blocks.filter { $0.moment != "matin" } }
|
||||||
|
|
||||||
|
// MARK: - Persistance locale
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
if let data = defaults.data(forKey: kBlocks),
|
||||||
|
let decoded = try? JSONDecoder().decode([RoutineBlock].self, from: data) {
|
||||||
|
blocks = decoded
|
||||||
|
}
|
||||||
|
done = Set(defaults.stringArray(forKey: kDone) ?? [])
|
||||||
|
date = defaults.string(forKey: kDate) ?? Self.todayISO()
|
||||||
|
resetIfStale()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
if let data = try? JSONEncoder().encode(blocks) {
|
||||||
|
defaults.set(data, forKey: kBlocks)
|
||||||
|
}
|
||||||
|
defaults.set(Array(done), forKey: kDone)
|
||||||
|
defaults.set(date, forKey: kDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changement de jour : les coches d'hier ne doivent pas traîner.
|
||||||
|
private func resetIfStale() {
|
||||||
|
let today = Self.todayISO()
|
||||||
|
if date != today {
|
||||||
|
date = today
|
||||||
|
done = []
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iPhone → Watch
|
||||||
|
|
||||||
|
func apply(snapshot: [String: Any]) {
|
||||||
|
if let blocksRaw = snapshot["blocks"] as? [[String: Any]],
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: blocksRaw),
|
||||||
|
let decoded = try? JSONDecoder().decode([RoutineBlock].self, from: data) {
|
||||||
|
blocks = decoded
|
||||||
|
}
|
||||||
|
if let snapDate = snapshot["date"] as? String {
|
||||||
|
date = snapDate
|
||||||
|
}
|
||||||
|
if let doneIds = snapshot["done"] as? [String] {
|
||||||
|
done = Set(doneIds)
|
||||||
|
}
|
||||||
|
lastSyncFailed = false
|
||||||
|
persist()
|
||||||
|
routineLog.info("routine reçue de l'iPhone : \(self.blocks.count, privacy: .public) blocs, \(self.done.count, privacy: .public) faits")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Watch → iPhone
|
||||||
|
|
||||||
|
func toggle(_ blockId: String) {
|
||||||
|
resetIfStale()
|
||||||
|
if done.contains(blockId) {
|
||||||
|
done.remove(blockId)
|
||||||
|
} else {
|
||||||
|
done.insert(blockId)
|
||||||
|
}
|
||||||
|
persist()
|
||||||
|
push()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renvoie l'état COMPLET (pas un delta) : si un message se perd, le suivant
|
||||||
|
/// recale tout au lieu de laisser les deux côtés diverger.
|
||||||
|
private func push() {
|
||||||
|
let sent = ConnectivityManager.shared.sendRoutine(date: date, done: Array(done))
|
||||||
|
lastSyncFailed = !sent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nouvelle tentative manuelle après un échec (bouton de la vue).
|
||||||
|
func retry() {
|
||||||
|
push()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func todayISO() -> String {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateFormat = "yyyy-MM-dd"
|
||||||
|
fmt.timeZone = TimeZone.current
|
||||||
|
return fmt.string(from: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
118
ios/App/CoachWatch/RoutineView.swift
Normal file
118
ios/App/CoachWatch/RoutineView.swift
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
/// Routine quotidienne sur la montre : liste des blocs, cochables au doigt.
|
||||||
|
/// Chaque coche part vers l'iPhone, qui la POSTe au serveur — l'état est donc
|
||||||
|
/// partagé avec la page /routine et le widget de la home.
|
||||||
|
struct RoutineView: View {
|
||||||
|
@StateObject private var store = RoutineStore.shared
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if store.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Routine")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "figure.cooldown")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Ouvre l'app Coach sur l'iPhone une fois pour recevoir la routine.")
|
||||||
|
.font(.footnote)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var list: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
progressHeader
|
||||||
|
|
||||||
|
if !store.morningBlocks.isEmpty {
|
||||||
|
sectionTitle("Le matin")
|
||||||
|
ForEach(store.morningBlocks) { block in
|
||||||
|
row(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !store.otherBlocks.isEmpty {
|
||||||
|
sectionTitle("Après la course")
|
||||||
|
ForEach(store.otherBlocks) { block in
|
||||||
|
row(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if store.lastSyncFailed {
|
||||||
|
Button {
|
||||||
|
store.retry()
|
||||||
|
} label: {
|
||||||
|
Label("Renvoyer vers l'iPhone", systemImage: "arrow.clockwise")
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
.tint(.orange)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressHeader: some View {
|
||||||
|
let total = store.blocks.count
|
||||||
|
let count = store.done.count
|
||||||
|
return VStack(spacing: 4) {
|
||||||
|
Text("\(count) / \(total) blocs")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(count == total && total > 0 ? .green : .primary)
|
||||||
|
ProgressView(value: total > 0 ? Double(count) / Double(total) : 0)
|
||||||
|
.tint(.cyan)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionTitle(_ text: String) -> some View {
|
||||||
|
Text(text.uppercased())
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ block: RoutineBlock) -> some View {
|
||||||
|
let isDone = store.done.contains(block.id)
|
||||||
|
return Button {
|
||||||
|
store.toggle(block.id)
|
||||||
|
WKInterfaceDevice.current().play(isDone ? .click : .success)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(isDone ? .green : .secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(block.title)
|
||||||
|
.font(.footnote)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.strikethrough(isDone, color: .secondary)
|
||||||
|
Text("\(block.durationMin) min")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.background(Color.gray.opacity(isDone ? 0.10 : 0.18), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
.opacity(isDone ? 0.65 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user