Files
coach-ios/ios/App/App/CoachLiveBridge.swift
Sylvain Bettinelli a61ba25590 Test de VMA guide au poignet
Pendant un test, le telephone est dans une poche : la montre est le seul ecran
consultable en courant, et c'est elle qui porte deja vitesse, distance et FC via
WorkoutManager.

Deux protocoles. Paliers d'une minute de 8 km/h par pas de 0,5, avec haptique au
changement et l'ecart a l'allure cible affiche — la vitesse brute demanderait un
calcul mental a chaque foulee. Ou six minutes, la distance parcourue donnant
directement la VMA.

⚠️ AUCUN calcul cote montre : elle transmet des mesures (paliers tenus,
distance, FC max), le serveur calcule. Deux implementations d'une meme formule
divergent toujours. Le POST passe par CoachRoutineBridge, qui a deja
l'abonnement NotificationCenter, la base d'API et le cookie — un fichier de plus
devrait etre cable a la main dans le pbxproj.

⚠️ Les paliers avancent sur `elapsedSec` de HKLiveWorkoutBuilder, PAS sur un
compteur accumule au fil du Timer de la vue : ce Timer ne tourne pas quand
l'ecran s'eteint, c'est-a-dire des que le poignet retombe — soit pendant
l'essentiel du test. Meme famille de piege que la Live Activity fantome.

L'envoi reprend la livraison differee de la routine : un test se fait dehors,
l'iPhone peut etre hors de portee, et refaire un test maximal parce qu'un
message s'est perdu n'est pas acceptable. Si l'envoi echoue, les chiffres
restent affiches pour une saisie manuelle.

VmaTestView.swift est un fichier NOUVEAU : cable dans le pbxproj (4 entrees,
sauvegarde project.pbxproj.bak-vma). Verification : `grep -c "VmaTestView.swift
in Sources"` doit rendre 2 (declaration + phase de compilation = 1 cible).

⚠️ NON COMPILE — a builder au prochain passage sur le Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:47:03 +00:00

170 lines
7.0 KiB
Swift

// CoachLiveBridge.swift
// Plugin Capacitor (côté iPhone) : reçoit les samples de séance poussés par
// l'app watchOS CoachWatch via WatchConnectivity et les relaie au JS via
// l'event "liveSample" (consommé par la page web /live).
//
// Enregistrement : MainViewController.capacitorDidLoad() ->
// bridge?.registerPluginInstance(CoachLiveBridgePlugin())
//
// Usage JS :
// const CoachLiveBridge = registerPlugin('CoachLiveBridge');
// CoachLiveBridge.addListener('liveSample', (s) => { ... });
// // s.heartRate, s.activeEnergyKcal, s.distanceMeters, s.elapsedSec, s.ts
import Foundation
import Capacitor
import WatchConnectivity
import SwiftUI
import UIKit
import os
private let liveLog = Logger(subsystem: "ch.hypnotruck.coach", category: "live")
@objc(CoachLiveBridgePlugin)
public class CoachLiveBridgePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CoachLiveBridgePlugin"
public let jsName = "CoachLiveBridge"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "openNativeLive", returnType: CAPPluginReturnPromise)
]
override public func load() {
super.load()
if #available(iOS 16.2, *) {
DispatchQueue.main.async { LiveActivityManager.shared.adoptExisting() }
}
guard WCSession.isSupported() else {
CAPLog.print("⚡️ CoachLiveBridge: WCSession non supporté sur cet appareil")
return
}
let session = WCSession.default
session.delegate = self
session.activate()
}
@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
])
}
private func emit(_ sample: [String: Any]) {
let hr = (sample["heartRate"] as? Double) ?? -1
liveLog.info("liveSample reçu de la Watch hr=\(hr, privacy: .public)")
CAPLog.print("⚡️ CoachLiveBridge: liveSample reçu de la Watch -> \(sample)")
DispatchQueue.main.async { [weak self] in
self?.notifyListeners("liveSample", data: sample)
}
// Bus interne pour la vue native CoachLiveView (Liquid Glass iOS 26).
LiveStore.shared.ingest(sample)
}
/// Présente la vue SwiftUI native (vrai Liquid Glass iOS 26) en plein écran
/// par-dessus la WebView. Appelé depuis le JS de /live au chargement.
/// Sur iOS < 26, no-op (la WebView affiche la version web glassmorphism).
@objc func openNativeLive(_ call: CAPPluginCall) {
guard #available(iOS 26.0, *) else {
call.resolve(["presented": false, "reason": "iOS < 26"])
return
}
// Token passé par le JS pour que CoachLiveView puisse fetch
// /api/cardiac-zones (auth backend = token query param accepté).
let token = call.getString("token") ?? ""
DispatchQueue.main.async { [weak self] in
guard let vc = self?.bridge?.viewController else {
call.resolve(["presented": false, "reason": "no host vc"])
return
}
if vc.presentedViewController != nil {
call.resolve(["presented": false, "reason": "already presented"])
return
}
let host = UIHostingController(rootView: CoachLiveView(authToken: token))
host.modalPresentationStyle = .fullScreen
host.view.backgroundColor = .black
vc.present(host, animated: true) {
call.resolve(["presented": true])
}
}
}
}
extension CoachLiveBridgePlugin: WCSessionDelegate {
public func session(_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?) {}
public func sessionDidBecomeInactive(_ session: WCSession) {}
public func sessionDidDeactivate(_ session: WCSession) {
// Réactiver pour les sessions futures (ex. changement d'Apple Watch).
WCSession.default.activate()
}
// 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 {
if message["routineDone"] != nil {
NotificationCenter.default.post(
name: .coachRoutineFromWatch, object: nil, userInfo: message
)
return true
}
// Résultat d'un test de VMA fait au poignet. Même aiguillage que la
// routine : ce plugin est l'unique WCSession.delegate côté iPhone.
if message["vmaTest"] as? Bool == true {
NotificationCenter.default.post(
name: .coachVmaFromWatch, object: nil, userInfo: message
)
return true
}
// Pause / reprise : pilote le libellé du bouton de la Live Activity.
if let paused = message["paused"] as? Bool {
liveLog.info("état pause reçu de la Watch : \(paused, privacy: .public)")
DispatchQueue.main.async { [weak self] in
self?.notifyListeners("livePaused", data: message)
}
LiveStore.shared.setPaused(paused)
return true
}
// Fin de séance annoncée par la montre. Sans ce signal, l'arrêt du flux
// est indistinguable d'une perte de portée : la Live Activity restait
// figée sur la dernière FC.
if message["liveEnded"] as? Bool == true {
let reason = (message["reason"] as? String) ?? "unknown"
liveLog.info("fin de séance reçue de la Watch (reason=\(reason, privacy: .public))")
DispatchQueue.main.async { [weak self] in
self?.notifyListeners("liveEnded", data: message)
}
LiveStore.shared.endLive()
return true
}
return false
}
}