Le snapshot poussé par l'iPhone (séance prévue, forme, hydratation, énergie, routine, séance de demain) arrive sur la montre depuis WatchConnectivity et vit dans son App Group. Les complications le lisent ; l'app, elle, ouvrait sur le lanceur d'activité et n'en affichait pas une ligne. Aucun nouveau pont : `ConnectivityManager` publie le snapshot qu'il persistait déjà, l'accueil en montre un résumé, et une feuille « Aujourd'hui » donne le détail. Même `asOf(_:)` que les complications, pour qu'un snapshot d'hier se vide au lieu de mentir. Les vues vivent dans ContentView.swift, déjà membre de la cible CoachWatch : pas de Target Membership à régler dans Xcode. ⚠️ Non compilé — SwiftUI et WatchKit n'existent pas sur Linux. Seuls `swiftc -parse` et les tests Linux existants sont passés. À builder sur le Mac. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
11 KiB
Swift
257 lines
11 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
|
||
|
||
/// Zones cardiaques en vigueur, poussées par l'iPhone avec le snapshot des
|
||
/// complications. Sert à traduire la FC en zone pendant la séance.
|
||
///
|
||
/// ⚠️ Publiée depuis `ConnectivityManager` plutôt que lue à chaque rendu :
|
||
/// `CoachWidgetStore.load()` décode du JSON, l'appeler dans un `body`
|
||
/// rafraîchi à chaque battement coûterait pour rien.
|
||
@Published private(set) var zones: HeartRateZones?
|
||
|
||
/// Dernier snapshot du jour poussé par l'iPhone : séance prévue, score de
|
||
/// forme, hydratation, énergie, routine.
|
||
///
|
||
/// Ces données arrivaient déjà sur la montre — les complications les lisent
|
||
/// depuis le cadran — mais rien ne les publiait, donc l'app elle-même n'en
|
||
/// montrait rien. Publié pour la même raison que `zones` : décoder le JSON
|
||
/// de l'App Group à chaque rendu coûterait pour rien.
|
||
@Published private(set) var snapshot: CoachWidgetSnapshot?
|
||
|
||
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()
|
||
// La montre a pu redémarrer depuis le dernier envoi de l'iPhone : on
|
||
// repart du dernier snapshot connu plutôt que d'attendre le suivant.
|
||
let stored = CoachWidgetStore.load()
|
||
zones = stored?.zones
|
||
snapshot = stored
|
||
}
|
||
|
||
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 {
|
||
// Des zones absentes d'un snapshot partiel ne doivent pas effacer
|
||
// celles en place : la séance en cours perdrait son repère.
|
||
if let received = snapshot.zones { self.zones = received }
|
||
self.snapshot = snapshot
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
}
|
||
}
|
||
}
|