Files
coach-ios/docs/watchos-live-workout-plan.md

24 KiB

Chantier watchOS — Live Workout sur iPhone

Statut : CHANTIER CLOS — test E2E sur hardware réel PASSÉ (2026-05-31, voir bas de page). Vraie FC Watch Ultra 3 → /live confirmée. Reste seulement : upload TestFlight interne (compte App Store Connect Sylvain). 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. + CapabilityHealthKit
    • Cocher : Workout Processing
  2. + CapabilityBackground 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)

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
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)
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 :

// 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 :

@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) :

{% extends "_layout.html" %}
{% block title %}Live workout — Coach{% endblock %}
{% block content %}
<header class="ap-page-header --hr">
  <svg class="ap-page-header__icon"><use href="#icon-hr"/></svg>
  <h1 class="ap-page-header__title">Live workout</h1>
</header>
<section class="ap-card --hr live-workout-card">
  <div class="live-stat">
    <span class="live-stat__label">FC</span>
    <span class="live-stat__value" id="live-hr"></span>
    <span class="live-stat__unit">bpm</span>
  </div>
  <div class="live-stat">
    <span class="live-stat__label">Calories</span>
    <span class="live-stat__value" id="live-kcal"></span>
    <span class="live-stat__unit">kcal</span>
  </div>
  <div class="live-stat">
    <span class="live-stat__label">Distance</span>
    <span class="live-stat__value" id="live-dist"></span>
    <span class="live-stat__unit">km</span>
  </div>
  <div class="live-stat">
    <span class="live-stat__label">Durée</span>
    <span class="live-stat__value" id="live-elapsed"></span>
  </div>
  <div class="live-status" id="live-status">En attente de la Watch…</div>
</section>
<script type="module">
import { registerPlugin } from '/static/vendor/capacitor-core.js';
const CoachLiveBridge = registerPlugin('CoachLiveBridge');
let lastTs = 0;
CoachLiveBridge.addListener('liveSample', (s) => {
  document.getElementById('live-hr').textContent = Math.round(s.heartRate || 0);
  document.getElementById('live-kcal').textContent = Math.round(s.activeEnergyKcal || 0);
  document.getElementById('live-dist').textContent = ((s.distanceMeters || 0) / 1000).toFixed(2);
  document.getElementById('live-elapsed').textContent = fmtMS(s.elapsedSec || 0);
  document.getElementById('live-status').textContent = 'En direct';
  lastTs = Date.now();
});
function fmtMS(sec) {
  const h = Math.floor(sec / 3600);
  const m = Math.floor((sec % 3600) / 60);
  const s = Math.floor(sec % 60);
  return h ? `${h}h${String(m).padStart(2,'0')}` : `${m}:${String(s).padStart(2,'0')}`;
}
setInterval(() => {
  if (Date.now() - lastTs > 8000) {
    document.getElementById('live-status').textContent = 'Connexion perdue avec la Watch';
  }
}, 1000);
</script>
{% 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.

Re-vérif build (Mac, 2026-05-31)

xcodebuild -scheme App -destination iPhone 17 Pro MaxBUILD SUCCEEDED. L'app embarque bien Watch/CoachWatch.app + PlugIns/CoachLiveActivity.appex (embedded binary validation OK). Xcode 26.5. Aucune régression depuis a41a1b1 (cardiac-aware zones natives dans CoachLiveView). Chantier natif = déployable.

Test E2E hardware réel PASSÉ (2026-05-31)

Après re-pairing de la Watch (bug Santé résolu côté Sylvain), déploiement sur devices physiques depuis le Mac :

  • Signing réel validé (generic/platform=iOS → BUILD SUCCEEDED, profil iOS Team Provisioning Profile: ch.hypnotruck.coach, team TZ2PVTRKNY).
  • xcrun devicectl device install app sur iPhone 17 Pro Max (udid 00008150-…) → App.app + embed Watch/CoachWatch.app + PlugIns/CoachLiveActivity.appex. CoachWatch poussée sur la Watch Ultra 3 par l'iPhone companion.
  • Séance lancée sur CoachWatch → vraie FC affichée sur /live (iPhone) : confirmé par Sylvain (« ça marche »). Toute la chaîne native + WatchConnectivity
    • plugin Capacitor + backend /live fonctionne sur vrai matériel.

Le chantier watchOS Live Workout est donc fonctionnellement TERMINÉ. Seul reste l'upload TestFlight interne (compte App Store Connect Sylvain).

Reste à faire — 2 blocages EXTERNES (pas le code natif)

  1. Backend /live FAIT + DÉPLOYÉ (2026-05-25) : la route/auth/template étaient déjà OK (Phase 4) ; il manquait juste le lien nav → onglet « Live workout » → /live ajouté dans NAV_ITEMS et déployé en prod (coach_sportif commit ecf334a, service coach-web actif). « token invalide » venait de l'accès à /live non authentifié (pas de lien → redirect /login). Détail dans HANDOFF-LIVE-BACKEND.md.
  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).