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.**
|
||||
- ⚠️ É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
|
||||
- ✅ **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.
|
||||
|
||||
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 */; };
|
||||
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.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 */; };
|
||||
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, ); }; };
|
||||
@@ -33,6 +34,8 @@
|
||||
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
|
||||
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.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 */; };
|
||||
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -214,6 +220,7 @@
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
|
||||
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */,
|
||||
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
|
||||
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
|
||||
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */,
|
||||
@@ -229,6 +236,8 @@
|
||||
82122ACF7E5A608AD1D55D24 /* Info.plist */,
|
||||
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */,
|
||||
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */,
|
||||
AA5C5902EFB7F60563678E46 /* RoutineView.swift */,
|
||||
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
|
||||
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
||||
3342ACCE085010828CF63565 /* Assets.xcassets */,
|
||||
);
|
||||
@@ -410,6 +419,7 @@
|
||||
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
|
||||
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
|
||||
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
|
||||
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */,
|
||||
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */,
|
||||
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
|
||||
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */,
|
||||
@@ -424,6 +434,8 @@
|
||||
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */,
|
||||
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */,
|
||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
||||
95D0478A499056FE922211EA /* RoutineView.swift in Sources */,
|
||||
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
|
||||
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -106,15 +106,30 @@ extension CoachLiveBridgePlugin: WCSessionDelegate {
|
||||
|
||||
// Temps réel : un sample envoyé via sendMessage.
|
||||
public func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
if routeIfNotLiveSample(message) { return }
|
||||
emit(message)
|
||||
}
|
||||
|
||||
// Livraison différée : lot de samples envoyé via transferUserInfo.
|
||||
public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
if routeIfNotLiveSample(userInfo) { return }
|
||||
if let samples = userInfo["samples"] as? [[String: Any]] {
|
||||
for sample in samples { emit(sample) }
|
||||
} else {
|
||||
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
|
||||
// et rafraîchit les widgets d'écran d'accueil (cf. CoachWidgetBridge.swift).
|
||||
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() {
|
||||
|
||||
@@ -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.
|
||||
private func flushLocked() {
|
||||
guard !buffer.isEmpty else { return }
|
||||
@@ -86,10 +111,22 @@ extension ConnectivityManager: WCSessionDelegate {
|
||||
|
||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
storeWidgetSnapshot(applicationContext)
|
||||
storeRoutineSnapshot(applicationContext)
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
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]) {
|
||||
|
||||
@@ -24,6 +24,7 @@ struct ContentView: View {
|
||||
|
||||
struct ActivityPickerView: View {
|
||||
@EnvironmentObject private var workout: WorkoutManager
|
||||
@State private var showRoutine = false
|
||||
|
||||
private struct Activity: Identifiable {
|
||||
let id = UUID()
|
||||
@@ -54,6 +55,14 @@ struct ActivityPickerView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
showRoutine = true
|
||||
} label: {
|
||||
Label("Routine du jour", systemImage: "figure.cooldown")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.tint(.cyan)
|
||||
|
||||
Button {
|
||||
workout.startSimulation()
|
||||
} label: {
|
||||
@@ -71,6 +80,9 @@ struct ActivityPickerView: View {
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.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