# Chantier watchOS — Live Workout sur iPhone **Statut** : Phases 1-3 RÉALISÉES (Mac, 2026-05-25, voir "Avancement réel" en bas). Reste : test device + TestFlight. **Owner exécution** : Claude Code lancé sur le Mac (cd `~/coach-ios && claude`). **Origine** : memory `project_coach_watchos_live.md` + session Linux 2026-05-24. ## Objectif fonctionnel Pendant que Sylvain fait une séance de sport sur son Apple Watch, l'iPhone (et la web app `/live`) affiche en temps réel : FC bpm, calories, distance parcourue, durée écoulée, allure (si applicable). Comportement de référence : app **Apple Workout / Vélo native** qui le fait déjà côté iPhone via APIs privées. On reproduit avec APIs publiques. ## Architecture cible (5 couches) ``` ┌─────────────────────────────────────────────┐ │ Apple Watch │ │ ┌─────────────────────────────────────┐ │ │ │ CoachWatch (watchOS app native) │ │ │ │ ┌─────────────────────────────┐ │ │ │ │ │ WorkoutManager (HKLiveWB) │←──┼───┼── L1 │ │ └─────────────────────────────┘ │ │ │ │ ↓ heart rate / │ │ │ │ ↓ calories / │ │ │ │ ↓ distance │ │ │ │ ┌─────────────────────────────┐ │ │ │ │ │ ConnectivityManager (WCSession) ←──┼── L2 │ │ └─────────────────────────────┘ │ │ │ └─────────────────────────────────────┘ │ └──────────────────────┬──────────────────────┘ │ WatchConnectivity │ sendMessage (1-3 sec) ↓ ┌─────────────────────────────────────────────┐ │ iPhone │ │ ┌─────────────────────────────────────┐ │ │ │ App.xcodeproj (Capacitor) │ │ │ │ ┌─────────────────────────────┐ │ │ │ │ │ CoachLiveBridge.swift │←──┼───┼── L3 │ │ │ (Capacitor Plugin natif) │ │ │ │ │ └─────────────────────────────┘ │ │ │ │ ↓ event JS │ │ │ │ ┌─────────────────────────────┐ │ │ │ │ │ WKWebView (loads /live) │←──┼───┼── L4 │ │ └─────────────────────────────┘ │ │ │ └─────────────────────────────────────┘ │ └──────────────────────┬──────────────────────┘ │ HTTP (au démarrage seulement) ↓ ┌─────────────────────────────────────────────┐ │ Backend coach.hypnotruck.ch │ │ ┌─────────────────────────────────────┐ │ │ │ /live route (FastAPI + template) │←──── L5 │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ ``` ## Décisions d'archi (à respecter) ### Décision 1 — App watchOS native, pas SwiftUI Capacitor Le live workout nécessite `HKLiveWorkoutBuilder` qui n'est dispo que sur watchOS natif (Xcode target Watch App). Capacitor ne tourne PAS sur watchOS. ### Décision 2 — `sendMessage` plutôt que `transferUserInfo` `sendMessage(_:replyHandler:errorHandler:)` est temps réel mais nécessite les 2 devices reachable. Si la Watch perd la connexion à l'iPhone (BLE down), on passe en fallback `transferUserInfo` (queued, livraison différée). Stratégie : tenter `sendMessage` toutes les 1-3 sec. Si `WCSession.isReachable == false`, buffer 30 sec puis dump via `transferUserInfo` au retour de connexion. ### Décision 3 — Pas de Live Activity iOS pour le MVP Live Activity (Dynamic Island + Lock Screen) = ActivityKit framework, plus de complexité, plus de surface de bug, validation Apple Store plus stricte. Reporté à la V2 si la V1 cartonne en TestFlight. V1 = juste page web `/live` qui affiche les données. iPhone doit être déverrouillé et l'app au premier plan pour voir le live. Comportement acceptable car le user est en train de faire du sport — il a son iPhone à côté de lui (vélo, treadmill). ### Décision 4 — Démarrage du workout depuis l'app Watch (pas l'iPhone) `HKLiveWorkoutBuilder` doit être démarré côté watchOS. Donc l'user **lance la séance depuis l'app CoachWatch sur sa montre**, pas depuis l'app native Workout. Trade-off accepté : moins ergonomique (l'user pourrait préférer Workout native), mais c'est la seule voie publique. Workaround possible plus tard : **WatchOS App Intent / Complication** pour "Lancer séance Coach" depuis le cadran (1 tap). ### Décision 5 — Bundle ID watchOS `ch.hypnotruck.coach.watchkitapp` (extension du bundle iOS `ch.hypnotruck.coach`). Signing automatique via Apple Developer Account déjà actif. ## Phase 1 — Création de la cible watchOS dans Xcode **Owner** : Sylvain (manuel UI Xcode) ou Claude Code Mac via édition `project.pbxproj` direct. ### Étape 1.1 — Add target Watch App Dans Xcode : 1. `File > New > Target` 2. Choisir **watchOS > App** (pas "App with Companion" car notre iPhone est Capacitor) 3. Product Name : `CoachWatch` 4. Interface : **SwiftUI** 5. Language : Swift 6. Bundle ID : `ch.hypnotruck.coach.watchkitapp` 7. Embed in Application : **App** (l'app iPhone existante) 8. Finish ### Étape 1.2 — Entitlements watchOS Dans Signing & Capabilities de la target CoachWatch : 1. `+ Capability` → **HealthKit** - Cocher : Workout Processing 2. `+ Capability` → **Background Modes** - Cocher : Workout processing 3. Vérifier le provisioning profile (auto-managed devrait suffire avec Apple Dev Account) ### Étape 1.3 — Info.plist watchOS Ajouter 2 clés (FR pour App Store FR) : - `NSHealthShareUsageDescription` : "Coach Hypnotruck lit ta fréquence cardiaque et tes calories pendant la séance pour les afficher en direct sur ton iPhone." - `NSHealthUpdateUsageDescription` : "Coach Hypnotruck enregistre ta séance dans l'app Santé." ## Phase 2 — Code Swift watchOS **4 fichiers Swift à créer** dans la target CoachWatch : ### `CoachWatchApp.swift` (entry point SwiftUI) ```swift import SwiftUI import WatchKit @main struct CoachWatchApp: App { @WKApplicationDelegateAdaptor private var appDelegate: AppDelegate var body: some Scene { WindowGroup { ContentView() .environmentObject(WorkoutManager.shared) .environmentObject(ConnectivityManager.shared) } } } class AppDelegate: NSObject, WKApplicationDelegate { func applicationDidFinishLaunching() { // Activate WCSession dès le boot pour ne pas rater les early messages _ = ConnectivityManager.shared } } ``` ### `WorkoutManager.swift` (HKLiveWorkoutBuilder) Pattern : - Singleton `@MainActor` - `@Published` pour les samples (heartRate, activeEnergyKcal, distanceMeters, elapsedSec) - `func startWorkout(activityType: HKWorkoutActivityType)` qui demande auth HK, crée la session + builder, configure le delegate - `HKLiveWorkoutBuilderDelegate` : `workoutBuilder(_:didCollectDataOf:)` qui pull les samples + push via Connectivity - `func endWorkout()` qui clôture proprement APIs minimales requises : - `HKHealthStore` - `HKWorkoutSession(healthStore:configuration:)` - `HKLiveWorkoutBuilder(...)` - `HKQuantityTypeIdentifier.heartRate`, `.activeEnergyBurned`, `.distanceWalkingRunning` (ou `.distanceCycling`) - `HKWorkoutConfiguration` avec `activityType` + `locationType` (outdoor/indoor) ### `ConnectivityManager.swift` (WCSession) Pattern : - Singleton avec `WCSession.default` activé - `func sendSample(...)` qui try `sendMessage` si reachable, sinon buffer en RAM + flush via `transferUserInfo` - Throttle : un sample max toutes les 1-3 sec pour ne pas saturer BLE ```swift import WatchConnectivity @MainActor final class ConnectivityManager: NSObject, ObservableObject, WCSessionDelegate { static let shared = ConnectivityManager() private var buffer: [[String: Any]] = [] override init() { super.init() guard WCSession.isSupported() else { return } WCSession.default.delegate = self WCSession.default.activate() } func sendSample(_ payload: [String: Any]) { let session = WCSession.default if session.isReachable { session.sendMessage(payload, replyHandler: nil) { [weak self] _ in // Fallback queue self?.buffer.append(payload) } } else { buffer.append(payload) if buffer.count >= 10 || buffer.count == 1 { flushBuffer() } } } private func flushBuffer() { guard !buffer.isEmpty else { return } WCSession.default.transferUserInfo(["samples": buffer]) buffer.removeAll() } // WCSessionDelegate stubs func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} func sessionReachabilityDidChange(_ session: WCSession) { if session.isReachable { flushBuffer() } } } ``` ### `ContentView.swift` (UI SwiftUI watchOS) 3 vues : - **Picker activité** : Course / Vélo / Marche / Renfo (les 4 sports principaux du plan) - **Live workout view** : grosses metrics centrées (FC, calories, durée), bouton Pause/Resume + End - **Summary view** : après End, montre les totaux + bouton "Retour" qui ferme l'app Patterns SwiftUI iOS HIG conformes : - `TabView` avec `.tabViewStyle(.page)` pour swipe entre vues - `.digitalCrown` rotation pour ajuster nothing (skip) - Haptic feedback sur start/pause/end (`WKInterfaceDevice.current().play(.start)` etc.) - Always-on display via `.scenePhase` (preserve simplified UI quand `.inactive`) ## Phase 3 — Plugin Capacitor iOS (côté iPhone) ### `CoachLiveBridge.swift` À placer dans `ios/App/App/CoachLiveBridge.swift`. C'est un plugin Capacitor natif qui : 1. Active `WCSession` côté iPhone au boot 2. Implémente `WCSessionDelegate.session(_:didReceiveMessage:)` et `didReceiveUserInfo:` 3. Pousse les samples au JS via `notifyListeners("liveSample", payload)` ```swift import Foundation import Capacitor import WatchConnectivity @objc(CoachLiveBridge) public class CoachLiveBridge: CAPPlugin, WCSessionDelegate { override public func load() { super.load() guard WCSession.isSupported() else { return } WCSession.default.delegate = self WCSession.default.activate() } public func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} public func sessionDidBecomeInactive(_ session: WCSession) {} public func sessionDidDeactivate(_ session: WCSession) { // Re-activer pour les sessions futures (pattern recommandé) WCSession.default.activate() } public func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { DispatchQueue.main.async { [weak self] in self?.notifyListeners("liveSample", data: message) } } public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { if let samples = userInfo["samples"] as? [[String: Any]] { DispatchQueue.main.async { [weak self] in for s in samples { self?.notifyListeners("liveSample", data: s) } } } } } ``` ### Enregistrement du plugin Dans `Podfile` (déjà géré par Capacitor build) et dans le JS web : ```javascript // Côté web (à ajouter dans /live template) import { registerPlugin } from '@capacitor/core'; const CoachLiveBridge = registerPlugin('CoachLiveBridge'); CoachLiveBridge.addListener('liveSample', (sample) => { // sample.heartRate, sample.activeEnergyKcal, sample.distanceMeters, sample.elapsedSec updateLiveUI(sample); }); ``` ## Phase 4 — Route web `/live` côté backend À ajouter dans `coach_sportif/web/routers/misc.py` ou nouveau router : ```python @router.get("/live", response_class=HTMLResponse) def live_workout(request: Request, token: str | None = Query(default=None)): """Page affichage live workout : reçoit les samples push de la Watch via CoachLiveBridge (plugin Capacitor) et affiche les metrics en temps réel. Cette page n'est utile QUE dans le wrapper Capacitor iOS — en navigateur pur, aucun WCSession donc 0 samples reçus.""" check_auth(request, token) return templates.TemplateResponse( request, "live_workout.html", {"token": token or "", "v": assets_version()}, ) ``` Template `coach_sportif/web/templates/live_workout.html` (squelette) : ```html {% extends "_layout.html" %} {% block title %}Live workout — Coach{% endblock %} {% block content %}

Live workout

FC bpm
Calories kcal
Distance km
Durée
En attente de la Watch…
{% endblock %} ``` ## Phase 5 — Tests ### Tests unitaires possibles - `WorkoutManager` : impossible sans device (HKHealthStore mock = expérimental) - `ConnectivityManager` : peut tester le buffer logique sans réseau - Plugin iOS : pareil — buffer + notifyListeners testable en unit ### Tests device (obligatoires) - iPhone + Apple Watch jumelée déverrouillées - Lancer app CoachWatch sur la montre - Choisir activité (Course par ex) - Vérifier que FC commence à s'afficher dans 5-10 sec - Ouvrir Coach Hypnotruck iPhone → `/live` → vérifier que les chiffres défilent - Test fallback : couper le Bluetooth pendant 30 sec, voir que le buffer flush au retour ### Test simulator (limité) - Simulator watchOS supporte HKLiveWorkoutBuilder en mock mais valeurs aléatoires - WatchConnectivity entre simulators iPhone + Watch supportée depuis Xcode 14 ## Estimation effort (révisée après recherche) | Phase | Effort | Owner | |---|---|---| | 1 — Création target Xcode + entitlements | 1-2h | Sylvain (UI Xcode) ou Claude Mac via pbxproj | | 2 — Code Swift watchOS (4 fichiers) | 4-6h | Claude Mac (compile, lit erreurs) | | 3 — Plugin Capacitor iOS | 2-3h | Claude Mac | | 4 — Route web `/live` + template | 1h | Faisable depuis n'importe où (Linux ou Mac) | | 5 — Tests device + fixes | 4-8h | Sylvain + Claude Mac | | **Total réaliste** | **12-20h sur 3-5 jours** | | ## Risques connus + mitigations | Risque | Mitigation | |---|---| | HKLiveWorkoutBuilder permissions refusées par user | Doc claire dans onboarding + bouton "Réessayer" qui force re-prompt | | WCSession.isReachable false en permanence (BLE flaky) | Fallback `transferUserInfo` queued (livraison différée mais garantie) | | Sample rate trop élevé → batterie Watch crame | Throttle à 1-3 sec, agréger côté Watch avant push | | User lance Workout natif Apple au lieu de CoachWatch | Doc + onboarding pédagogique. V2 : App Intent / Complication | | Apple rejette en TestFlight pour signing/entitlements | Tester en internal TestFlight d'abord, pas production directe | | `notifyListeners` Capacitor ne reçoit pas en background | App doit rester au premier plan iPhone (acceptable pendant séance) | ## Pas dans le scope V1 - Live Activity iOS (Dynamic Island) — V2 si V1 cartonne - App Intent / Complication "Lancer séance" — V2 - Sync vers Polar / Garmin pendant la séance — backlog séparé - Multi-user / Apple Watch partagée — irrelevant pour Sylvain - Workouts piscine (HKWorkoutSwimmingLocationType) — exclu, Sylvain ne nage pas ## Références à valider sur Mac (les liens Apple ne se rendent pas via WebFetch Linux) - HKLiveWorkoutBuilder : `developer.apple.com/documentation/healthkit/hkliveworkoutbuilder` - WCSession : `developer.apple.com/documentation/watchconnectivity/wcsession` - HKWorkoutSession : `developer.apple.com/documentation/healthkit/hkworkoutsession` - Capacitor Custom Plugin iOS : `capacitorjs.com/docs/plugins/creating-plugins/ios-guide` Le Claude Mac doit lire ces 4 pages dans sa session pour valider les signatures avant de les utiliser. ## Avancement réel (Mac, 2026-05-25) Env réel : **Xcode 26.5 / SDK watchOS 26.5** (pas watchOS 11). Signatures vérifiées directement dans les `.swiftinterface` / headers du SDK installé (la doc Apple ne se rend pas via WebFetch). ### Fait (Phases 1-3) — sur `main` - **Phase 1** (commit `5d60a40`) : target `CoachWatch` créée **par script `xcodeproj`** (pas le wizard — gem CocoaPods `xcodeproj` installée en user-level), bundle `ch.hypnotruck.coach.watchkitapp`, embed "Embed Watch Content" + dépendance App→CoachWatch, schemes partagés App + CoachWatch. Entitlement HealthKit + Info.plist FR + `WKBackgroundModes=workout-processing`. - **Phase 2** : `ConnectivityManager` (`5864cfc`), `WorkoutManager` (`c6f5a7f` + fix `167c1e4`), UI + entry point (`5185298`). - **Phase 3** (commit `b2e9836`) : plugin `CoachLiveBridgePlugin` (jsName `CoachLiveBridge`) enregistré dans `MainViewController.capacitorDidLoad()`. - **Fix** (`1cd96de`) : `WKCompanionAppBundleIdentifier=ch.hypnotruck.coach` (sans ça l'app watch refuse de s'installer — détecté au smoke test simctl). Validé : build watchsimulator + build App (embed) iphonesimulator, **0 warning** ; app watch installée + lancée sur sim Apple Watch Series 11 (watchOS 26.5), UI picker rendue. ### Deltas vs le pseudocode du plan (corrigés) 1. `ConnectivityManager` **n'est pas** `@MainActor` (le plan le disait) : les callbacks `WCSessionDelegate` arrivent hors-main → délégué normal + hop main. 2. `WorkoutManager` **est** `@MainActor` (donc Sendable) avec délégués HealthKit `nonisolated` qui hoppent via `Task { @MainActor }` — sinon warnings/erreurs concurrency Swift 6 (capture de self non-Sendable). 3. Enregistrement plugin Capacitor 8 = `bridge?.registerPluginInstance(...)` dans `capacitorDidLoad()` (PAS la macro `CAP_PLUGIN`), CAPBridgedPlugin. ### Pont watch -> iPhone PROUVÉ E2E (simulateur, 2026-05-25) Validé sur paire simulée iPhone+Watch (mode Test FC simulée + hook env `AUTOSIM`) : chaque sample envoyé par la Watch (`sendSample … hr=130.69→148.32`) est reçu à l'identique par le plugin iPhone (`liveSample reçu … hr=130.69→148.32`), latence ~300 ms. Vue live watch rendue (FC/calories/distance). Toute la chaîne native fonctionne ; il ne reste que l'affichage web et la vraie FC. ### Reste à faire — 2 blocages EXTERNES (pas le code natif) 1. **Backend `/live`** (« token invalide ») : route à passer en auth cookie de session + lien d'accès depuis l'app + JS template. Détail + contrat exact dans **`HANDOFF-LIVE-BACKEND.md`** (repo `coach_sportif`, session Linux). 2. **Vraie FC sur la Watch physique de Sylvain** : bug système Santé de SA montre (NanoSettings crashe, Santé spinne) — désappairer/réappairer la Watch. 3. TestFlight interne (signing / App Store Connect).