From caeac37f386159d2d634a3490a722adfba39184f Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Mon, 3 Aug 2026 14:22:33 +0000 Subject: [PATCH] feat(watch): routine quotidienne cochable sur l'Apple Watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- COWORK.md | 21 +++ docs/routine-watch-runbook-mac.md | 126 +++++++++++++++ ios/App/App.xcodeproj/project.pbxproj | 12 ++ ios/App/App/CoachLiveBridge.swift | 15 ++ ios/App/App/CoachRoutineBridge.swift | 160 +++++++++++++++++++ ios/App/App/MainViewController.swift | 4 + ios/App/CoachWatch/ConnectivityManager.swift | 37 +++++ ios/App/CoachWatch/ContentView.swift | 12 ++ ios/App/CoachWatch/RoutineStore.swift | 134 ++++++++++++++++ ios/App/CoachWatch/RoutineView.swift | 118 ++++++++++++++ 10 files changed, 639 insertions(+) create mode 100644 docs/routine-watch-runbook-mac.md create mode 100644 ios/App/App/CoachRoutineBridge.swift create mode 100644 ios/App/CoachWatch/RoutineStore.swift create mode 100644 ios/App/CoachWatch/RoutineView.swift diff --git a/COWORK.md b/COWORK.md index 6499ec7..f114aa9 100644 --- a/COWORK.md +++ b/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. diff --git a/docs/routine-watch-runbook-mac.md b/docs/routine-watch-runbook-mac.md new file mode 100644 index 0000000..4384d99 --- /dev/null +++ b/docs/routine-watch-runbook-mac.md @@ -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. diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 018adb2..636eee9 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; + AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = ""; }; + 969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = ""; }; A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = ""; }; A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = ""; }; + 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.swift; sourceTree = ""; }; B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = ""; }; D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = ""; }; @@ -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; diff --git a/ios/App/App/CoachLiveBridge.swift b/ios/App/App/CoachLiveBridge.swift index d894bae..b7d4e20 100644 --- a/ios/App/App/CoachLiveBridge.swift +++ b/ios/App/App/CoachLiveBridge.swift @@ -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 + } } diff --git a/ios/App/App/CoachRoutineBridge.swift b/ios/App/App/CoachRoutineBridge.swift new file mode 100644 index 0000000..5f0e586 --- /dev/null +++ b/ios/App/App/CoachRoutineBridge.swift @@ -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()) + } +} diff --git a/ios/App/App/MainViewController.swift b/ios/App/App/MainViewController.swift index 0bc0009..933ed2b 100644 --- a/ios/App/App/MainViewController.swift +++ b/ios/App/App/MainViewController.swift @@ -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() { diff --git a/ios/App/CoachWatch/ConnectivityManager.swift b/ios/App/CoachWatch/ConnectivityManager.swift index 4edf377..5eb1977 100644 --- a/ios/App/CoachWatch/ConnectivityManager.swift +++ b/ios/App/CoachWatch/ConnectivityManager.swift @@ -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]) { diff --git a/ios/App/CoachWatch/ContentView.swift b/ios/App/CoachWatch/ContentView.swift index fa46a19..68e53b9 100644 --- a/ios/App/CoachWatch/ContentView.swift +++ b/ios/App/CoachWatch/ContentView.swift @@ -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() + } } } diff --git a/ios/App/CoachWatch/RoutineStore.swift b/ios/App/CoachWatch/RoutineStore.swift new file mode 100644 index 0000000..8d75280 --- /dev/null +++ b/ios/App/CoachWatch/RoutineStore.swift @@ -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 = [] + @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()) + } +} diff --git a/ios/App/CoachWatch/RoutineView.swift b/ios/App/CoachWatch/RoutineView.swift new file mode 100644 index 0000000..676044b --- /dev/null +++ b/ios/App/CoachWatch/RoutineView.swift @@ -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) + } +}