522 lines
24 KiB
Markdown
522 lines
24 KiB
Markdown
# 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. `+ 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 %}
|
|
<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 Max` → **BUILD 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).
|