Statut chantier, commits, et 3 deltas vs pseudocode (ConnectivityManager non @MainActor, WorkoutManager @MainActor + délégués nonisolated, registerPluginInstance Capacitor 8). Reste : test device + TestFlight + cross-check JS /live. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
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 :
File > New > Target- Choisir watchOS > App (pas "App with Companion" car notre iPhone est Capacitor)
- Product Name :
CoachWatch - Interface : SwiftUI
- Language : Swift
- Bundle ID :
ch.hypnotruck.coach.watchkitapp - Embed in Application : App (l'app iPhone existante)
- Finish
Étape 1.2 — Entitlements watchOS
Dans Signing & Capabilities de la target CoachWatch :
+ Capability→ HealthKit- Cocher : Workout Processing
+ Capability→ Background Modes- Cocher : Workout processing
- 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 @Publishedpour les samples (heartRate, activeEnergyKcal, distanceMeters, elapsedSec)func startWorkout(activityType: HKWorkoutActivityType)qui demande auth HK, crée la session + builder, configure le delegateHKLiveWorkoutBuilderDelegate:workoutBuilder(_:didCollectDataOf:)qui pull les samples + push via Connectivityfunc endWorkout()qui clôture proprement
APIs minimales requises :
HKHealthStoreHKWorkoutSession(healthStore:configuration:)HKLiveWorkoutBuilder(...)HKQuantityTypeIdentifier.heartRate,.activeEnergyBurned,.distanceWalkingRunning(ou.distanceCycling)HKWorkoutConfigurationavecactivityType+locationType(outdoor/indoor)
ConnectivityManager.swift (WCSession)
Pattern :
- Singleton avec
WCSession.defaultactivé func sendSample(...)qui trysendMessagesi reachable, sinon buffer en RAM + flush viatransferUserInfo- 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 :
TabViewavec.tabViewStyle(.page)pour swipe entre vues.digitalCrownrotation 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 :
- Active
WCSessioncôté iPhone au boot - Implémente
WCSessionDelegate.session(_:didReceiveMessage:)etdidReceiveUserInfo: - 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) : targetCoachWatchcréée par scriptxcodeproj(pas le wizard — gem CocoaPodsxcodeprojinstallée en user-level), bundlech.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).
- fix
- Phase 3 (commit
b2e9836) : pluginCoachLiveBridgePlugin(jsNameCoachLiveBridge) enregistré dansMainViewController.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)
ConnectivityManagern'est pas@MainActor(le plan le disait) : les callbacksWCSessionDelegatearrivent hors-main → délégué normal + hop main.WorkoutManagerest@MainActor(donc Sendable) avec délégués HealthKitnonisolatedqui hoppent viaTask { @MainActor }— sinon warnings/erreurs concurrency Swift 6 (capture de self non-Sendable).- Enregistrement plugin Capacitor 8 =
bridge?.registerPluginInstance(...)danscapacitorDidLoad()(PAS la macroCAP_PLUGIN), CAPBridgedPlugin.
Reste à faire
- Test device (FC réelle, WatchConnectivity → /live) : nécessite l'Apple Watch physique + signing (impossible en simulateur, pas de FC réelle).
- Cross-check : la page
/livedéployée doit utiliserregisterPlugin('CoachLiveBridge')et lireheartRate / activeEnergyKcal / distanceMeters / elapsedSec / ts. - TestFlight interne.