diff --git a/ios/App/App/CoachLiveBridge.swift b/ios/App/App/CoachLiveBridge.swift index b7d4e20..178366e 100644 --- a/ios/App/App/CoachLiveBridge.swift +++ b/ios/App/App/CoachLiveBridge.swift @@ -31,6 +31,9 @@ public class CoachLiveBridgePlugin: CAPPlugin, CAPBridgedPlugin { 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 @@ -126,10 +129,24 @@ extension CoachLiveBridgePlugin: WCSessionDelegate { /// traiter comme des samples (ce qui polluerait LiveStore). /// Retourne `true` si le message a été routé ailleurs. private func routeIfNotLiveSample(_ message: [String: Any]) -> Bool { - guard message["routineDone"] != nil else { return false } - NotificationCenter.default.post( - name: .coachRoutineFromWatch, object: nil, userInfo: message - ) - return true + if message["routineDone"] != nil { + NotificationCenter.default.post( + name: .coachRoutineFromWatch, object: nil, userInfo: message + ) + 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 } } diff --git a/ios/App/App/LiveActivityManager.swift b/ios/App/App/LiveActivityManager.swift index 74a4157..a3e2140 100644 --- a/ios/App/App/LiveActivityManager.swift +++ b/ios/App/App/LiveActivityManager.swift @@ -20,6 +20,24 @@ final class LiveActivityManager { private init() {} + /// Reprend la main sur une activité laissée par une exécution précédente. + /// + /// Si l'app iPhone est tuée pendant une séance, `current` repart à nil au + /// lancement suivant : `end()` devenait un no-op et l'activité restait + /// affichée indéfiniment. À appeler une fois au démarrage. + /// Au-delà de 6 h, aucune séance n'est plausible : on termine. + func adoptExisting() { + for activity in Activity.activities { + if Date().timeIntervalSince(activity.attributes.startedAt) > 6 * 3600 { + Task { await activity.end(nil, dismissalPolicy: .immediate) } + Self.log.info("Live Activity orpheline (>6h) terminée") + } else if current == nil { + current = activity + Self.log.info("Live Activity existante adoptée") + } + } + } + private var isAuthorized: Bool { ActivityAuthorizationInfo().areActivitiesEnabled } @@ -67,11 +85,15 @@ final class LiveActivityManager { Task { await activity.update(content) } } - /// Termine l'activité. dismissalPolicy .default = bannière reste ~4h. + /// Termine l'activité et la retire de l'écran immédiatement. + /// ⚠️ `.default` laisse la bannière ~4 h après la fin : la séance est + /// terminée mais le cœur reste affiché avec la dernière FC reçue, ce qui se + /// lit comme une séance encore en cours. `.immediate` est le seul + /// comportement correct ici. func end() { guard let activity = current else { return } current = nil - Task { await activity.end(nil, dismissalPolicy: .default) } + Task { await activity.end(nil, dismissalPolicy: .immediate) } Self.log.info("Live Activity ended") } } diff --git a/ios/App/App/LiveStore.swift b/ios/App/App/LiveStore.swift index 8117e9a..c93fc2e 100644 --- a/ios/App/App/LiveStore.swift +++ b/ios/App/App/LiveStore.swift @@ -95,6 +95,23 @@ final class LiveStore: ObservableObject { } } + /// Fin de séance annoncée par la montre (`liveEnded`). Termine la Live + /// Activity tout de suite, sans attendre le watchdog — celui-ci repose sur + /// un Timer du main runloop, qui ne tourne pas quand l'app iPhone est + /// suspendue en arrière-plan (le cas normal pendant une séance). + /// nonisolated : appelé depuis un callback WCSession, hors main. + nonisolated func endLive() { + Task { @MainActor [weak self] in + guard let self else { return } + self.status = .waiting + self.lostSince = nil + self.lastSampleAt = nil + if #available(iOS 16.2, *) { + LiveActivityManager.shared.end() + } + } + } + @available(iOS 16.2, *) private func currentState(isLive: Bool) -> CoachLiveActivityAttributes.ContentState { CoachLiveActivityAttributes.ContentState( diff --git a/ios/App/CoachWatch/ConnectivityManager.swift b/ios/App/CoachWatch/ConnectivityManager.swift index 5eb1977..588da58 100644 --- a/ios/App/CoachWatch/ConnectivityManager.swift +++ b/ios/App/CoachWatch/ConnectivityManager.swift @@ -48,6 +48,35 @@ final class ConnectivityManager: NSObject, ObservableObject { } } + /// 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 »). diff --git a/ios/App/CoachWatch/WorkoutManager.swift b/ios/App/CoachWatch/WorkoutManager.swift index 67af0bb..a9a89e9 100644 --- a/ios/App/CoachWatch/WorkoutManager.swift +++ b/ios/App/CoachWatch/WorkoutManager.swift @@ -97,6 +97,9 @@ final class WorkoutManager: NSObject, ObservableObject { self.simTask = nil isRunning = false statusMessage = nil + // Le mode test ne passe pas par HKWorkoutSession : c'est ici le + // seul endroit où l'iPhone peut apprendre que le flux s'arrête. + ConnectivityManager.shared.sendEnded(reason: "sim") } else { session?.end() } @@ -210,6 +213,9 @@ extension WorkoutManager: HKWorkoutSessionDelegate { self.isRunning = false self.session = nil self.builder = nil + // Fin explicite vers l'iPhone : sans elle, sa Live Activity reste + // affichée avec la dernière FC reçue jusqu'à expiration d'ActivityKit. + ConnectivityManager.shared.sendEnded(reason: "session-ended") } } @@ -223,6 +229,7 @@ extension WorkoutManager: HKWorkoutSessionDelegate { self.isRunning = false self.session = nil self.builder = nil + ConnectivityManager.shared.sendEnded(reason: "session-failed") } } }