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>
231 lines
9.3 KiB
Swift
231 lines
9.3 KiB
Swift
import Foundation
|
|
import WatchConnectivity
|
|
import WidgetKit
|
|
import os
|
|
|
|
private let connLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "connectivity")
|
|
|
|
/// Envoie les samples de séance vers l'iPhone.
|
|
/// - `sendMessage` quand l'iPhone est joignable (temps réel)
|
|
/// - sinon buffer + `transferUserInfo` (livraison différée garantie)
|
|
final class ConnectivityManager: NSObject, ObservableObject {
|
|
static let shared = ConnectivityManager()
|
|
|
|
@Published private(set) var isReachable = false
|
|
|
|
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.connectivity")
|
|
private var buffer: [[String: Any]] = []
|
|
|
|
private override init() {
|
|
super.init()
|
|
guard WCSession.isSupported() else { return }
|
|
let session = WCSession.default
|
|
session.delegate = self
|
|
session.activate()
|
|
}
|
|
|
|
func sendSample(_ payload: [String: Any]) {
|
|
queue.async {
|
|
let session = WCSession.default
|
|
let hr = (payload["heartRate"] as? Double) ?? -1
|
|
connLog.info("sendSample activated=\(session.activationState == .activated, privacy: .public) reachable=\(session.isReachable, privacy: .public) hr=\(hr, privacy: .public)")
|
|
guard session.activationState == .activated else {
|
|
self.buffer.append(payload)
|
|
return
|
|
}
|
|
if session.isReachable {
|
|
session.sendMessage(payload, replyHandler: nil) { [weak self] _ in
|
|
// échec d'envoi temps réel -> bascule en livraison différée
|
|
self?.queue.async {
|
|
self?.buffer.append(payload)
|
|
self?.flushLocked()
|
|
}
|
|
}
|
|
} else {
|
|
self.buffer.append(payload)
|
|
self.flushLocked()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Signale la FIN de séance à l'iPhone.
|
|
///
|
|
/// Sans ce message, l'iPhone ne peut pas distinguer « séance terminée » de
|
|
/// « montre hors de portée » : le flux de samples s'arrête dans les deux
|
|
/// cas. Sa Live Activity restait alors figée sur la dernière FC reçue.
|
|
/// Les samples encore en attente partent AVANT la fin (flushLocked) pour
|
|
/// que l'ordre d'arrivée reste cohérent.
|
|
func sendEnded(reason: String = "user") {
|
|
queue.async {
|
|
let session = WCSession.default
|
|
guard session.activationState == .activated else { return }
|
|
self.flushLocked()
|
|
let payload: [String: Any] = [
|
|
"liveEnded": true,
|
|
"reason": reason,
|
|
"ts": Date().timeIntervalSince1970,
|
|
]
|
|
if session.isReachable {
|
|
session.sendMessage(payload, replyHandler: nil) { _ in
|
|
// Échec temps réel : livraison différée garantie.
|
|
session.transferUserInfo(payload)
|
|
}
|
|
} else {
|
|
session.transferUserInfo(payload)
|
|
}
|
|
connLog.info("fin de séance signalée à l'iPhone (reason=\(reason, privacy: .public))")
|
|
}
|
|
}
|
|
|
|
/// Envoie l'état COMPLET de la routine du jour vers l'iPhone, qui le POSTe
|
|
/// au serveur. Retourne `false` si rien n'a pu partir (l'appelant affiche
|
|
/// alors un bouton « Renvoyer »).
|
|
///
|
|
/// `sendMessage` quand l'iPhone est joignable, `transferUserInfo` sinon :
|
|
/// la coche faite pendant une séance, iPhone resté à la maison, partira au
|
|
/// prochain rapprochement.
|
|
@discardableResult
|
|
func sendRoutine(date: String, done: [String]) -> Bool {
|
|
let session = WCSession.default
|
|
guard session.activationState == .activated else { return false }
|
|
let payload: [String: Any] = ["routineDone": done, "date": date]
|
|
|
|
if session.isReachable {
|
|
session.sendMessage(payload, replyHandler: nil) { _ in
|
|
// Échec temps réel : bascule en livraison différée garantie.
|
|
session.transferUserInfo(payload)
|
|
}
|
|
} else {
|
|
session.transferUserInfo(payload)
|
|
}
|
|
connLog.info("routine envoyée à l'iPhone (\(done.count, privacy: .public) blocs)")
|
|
return true
|
|
}
|
|
|
|
/// Envoie le résultat brut d'un test de VMA (paliers tenus ou distance).
|
|
///
|
|
/// Livraison différée garantie comme la routine : un test se fait dehors,
|
|
/// l'iPhone peut être hors de portée, et refaire un test maximal parce que
|
|
/// le message s'est perdu n'est pas acceptable. Le calcul de la VMA se fait
|
|
/// côté serveur — la montre ne transmet que des mesures.
|
|
@discardableResult
|
|
func sendVmaResult(_ payload: [String: Any]) -> Bool {
|
|
let session = WCSession.default
|
|
guard session.activationState == .activated else { return false }
|
|
if session.isReachable {
|
|
session.sendMessage(payload, replyHandler: nil) { _ in
|
|
session.transferUserInfo(payload)
|
|
}
|
|
} else {
|
|
session.transferUserInfo(payload)
|
|
}
|
|
connLog.info("résultat de test VMA envoyé à l'iPhone")
|
|
return true
|
|
}
|
|
|
|
/// Signale à l'iPhone que la séance est en pause / a repris, pour que le
|
|
/// bouton de la Live Activity affiche le bon état.
|
|
func sendPaused(_ paused: Bool) {
|
|
queue.async {
|
|
let session = WCSession.default
|
|
guard session.activationState == .activated else { return }
|
|
let payload: [String: Any] = ["paused": paused, "ts": Date().timeIntervalSince1970]
|
|
if session.isReachable {
|
|
session.sendMessage(payload, replyHandler: nil) { _ in
|
|
session.transferUserInfo(payload)
|
|
}
|
|
} else {
|
|
session.transferUserInfo(payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Commande de pilotage envoyée par la Live Activity de l'iPhone.
|
|
/// Arrive soit par `sendMessage` (immédiat), soit par `transferUserInfo`
|
|
/// (différé) quand la montre n'était pas joignable.
|
|
private func handleCommand(_ dict: [String: Any]) {
|
|
guard let cmd = dict["workoutCommand"] as? String else { return }
|
|
connLog.info("commande reçue de l'iPhone : \(cmd, privacy: .public)")
|
|
Task { @MainActor in
|
|
switch cmd {
|
|
case "pause": WorkoutManager.shared.pause()
|
|
case "resume": WorkoutManager.shared.resume()
|
|
case "end": WorkoutManager.shared.end()
|
|
default: connLog.error("commande inconnue : \(cmd, privacy: .public)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// À appeler sur `queue` uniquement.
|
|
private func flushLocked() {
|
|
guard !buffer.isEmpty else { return }
|
|
let session = WCSession.default
|
|
guard session.activationState == .activated else { return }
|
|
session.transferUserInfo(["samples": buffer])
|
|
buffer.removeAll()
|
|
}
|
|
|
|
private func updateReachable(_ value: Bool) {
|
|
DispatchQueue.main.async { [weak self] in self?.isReachable = value }
|
|
}
|
|
}
|
|
|
|
extension ConnectivityManager: WCSessionDelegate {
|
|
func session(_ session: WCSession,
|
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
|
error: Error?) {
|
|
updateReachable(session.isReachable)
|
|
if activationState == .activated {
|
|
queue.async { [weak self] in self?.flushLocked() }
|
|
}
|
|
}
|
|
|
|
func sessionReachabilityDidChange(_ session: WCSession) {
|
|
let reachable = session.isReachable
|
|
updateReachable(reachable)
|
|
if reachable {
|
|
queue.async { [weak self] in self?.flushLocked() }
|
|
}
|
|
}
|
|
|
|
// ─── Réception du snapshot widget (iPhone → Watch) ───
|
|
// L'iPhone (CoachWidgetBridge) pousse les données du jour ; on les persiste
|
|
// dans l'App Group de CETTE montre et on recharge les complications.
|
|
|
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
|
storeWidgetSnapshot(applicationContext)
|
|
storeRoutineSnapshot(applicationContext)
|
|
}
|
|
|
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
|
storeWidgetSnapshot(userInfo)
|
|
storeRoutineSnapshot(userInfo)
|
|
handleCommand(userInfo)
|
|
}
|
|
|
|
/// Temps réel : commandes de la Live Activity quand la montre est joignable.
|
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
|
handleCommand(message)
|
|
}
|
|
|
|
/// Routine quotidienne poussée par l'iPhone (`CoachRoutineBridge`).
|
|
/// Les blocs voyagent avec l'état : la montre n'a aucune liste codée en dur,
|
|
/// modifier la routine côté web ne demande donc pas de rebuild watchOS.
|
|
private func storeRoutineSnapshot(_ dict: [String: Any]) {
|
|
guard let snapshot = dict["routineSnapshot"] as? [String: Any] else { return }
|
|
Task { @MainActor in
|
|
RoutineStore.shared.apply(snapshot: snapshot)
|
|
}
|
|
}
|
|
|
|
private func storeWidgetSnapshot(_ dict: [String: Any]) {
|
|
guard let data = dict["widgetSnapshot"] as? Data,
|
|
let snapshot = try? JSONDecoder().decode(CoachWidgetSnapshot.self, from: data) else { return }
|
|
CoachWidgetStore.save(snapshot)
|
|
connLog.info("widgetSnapshot reçu de l'iPhone -> complications rechargées")
|
|
DispatchQueue.main.async {
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
}
|
|
}
|