// CoachLiveActivityAttributes.swift // Modèle partagé App ↔ Widget Extension (membre des 2 targets). // Définit ce que la Live Activity / Dynamic Island affiche, et les commandes // que ses boutons envoient à la montre. // // ⚠️ Les intents et CoachWatchCommand vivent ICI plutôt que dans un fichier // dédié pour une raison pratique : ils doivent appartenir aux DEUX targets // (l'extension compile les boutons, l'app exécute leur `perform()`), et ce // fichier est le seul déjà câblé ainsi. Un nouveau fichier imposerait une // manipulation de Target Membership dans Xcode — source d'erreurs répétées // sur ce projet (cf. docs/SESSION-XCODE.md). import ActivityKit import AppIntents import Foundation import WatchConnectivity import os @available(iOS 16.2, *) struct CoachLiveActivityAttributes: ActivityAttributes { /// Données dynamiques (mises à jour à chaque sample reçu de la Watch). public struct ContentState: Codable, Hashable { var heartRate: Double var activeEnergyKcal: Double var distanceMeters: Double var elapsedSec: TimeInterval var isLive: Bool // true = direct ; false = perdu/en pause /// Séance mise en pause sur la montre. Pilote le libellé du bouton /// (Pause ↔ Reprendre). La valeur par défaut ne sert qu'aux sites de /// construction : le `init(from:)` synthétisé n'en tient pas compte. var isPaused: Bool = false } /// Données fixes (set au start, non modifiables). var activityLabel: String // "Course", "Vélo", "Marche", "Renfo", ou "Séance" var activitySymbol: String // SF Symbol (figure.run, bicycle, figure.walk, dumbbell) var startedAt: Date } // MARK: - Commandes vers la montre /// Envoie une commande de pilotage de séance (pause / reprise / fin) à l'app /// watchOS via WatchConnectivity. /// /// ⚠️ Limite Apple connue : `isReachable` retombe à false quand l'app watchOS /// tourne sa séance en arrière-plan profond (poignet baissé). `sendMessage` /// échoue alors. On retombe sur `transferUserInfo`, dont la livraison est /// garantie mais **différée** : la commande s'applique au réveil de l'app /// montre, pas à la seconde près. Rien dans les API publiques ne permet mieux. enum CoachWatchCommand { static let pause = "pause" static let resume = "resume" static let end = "end" private static let log = Logger(subsystem: "ch.hypnotruck.coach", category: "watch-command") static func send(_ command: String) { guard WCSession.isSupported() else { return } let session = WCSession.default let payload: [String: Any] = [ "workoutCommand": command, "ts": Date().timeIntervalSince1970, ] // L'intent peut réveiller l'app alors que le plugin n'a pas encore // activé la session : dans ce cas, seul transferUserInfo est possible // (il accepte d'être appelé avant activation, la file est persistée). guard session.activationState == .activated else { session.transferUserInfo(payload) log.info("commande \(command, privacy: .public) mise en file (session non activée)") return } if session.isReachable { session.sendMessage(payload, replyHandler: nil) { _ in session.transferUserInfo(payload) } } else { session.transferUserInfo(payload) } log.info("commande \(command, privacy: .public) envoyée (reachable=\(session.isReachable, privacy: .public))") } } // MARK: - Intents des boutons de la Live Activity /// Bouton Pause / Reprendre. `LiveActivityIntent` est indispensable : sans /// cette conformité, `perform()` n'est jamais appelé depuis une Live Activity. @available(iOS 17.0, *) struct CoachTogglePauseIntent: LiveActivityIntent { static var title: LocalizedStringResource = "Mettre la séance en pause" /// Hors Raccourcis / Spotlight : ce n'est pas une action autonome. static var isDiscoverable: Bool = false @Parameter(title: "Reprendre") var resuming: Bool init() {} init(resuming: Bool) { self.resuming = resuming } func perform() async throws -> some IntentResult { CoachWatchCommand.send(resuming ? CoachWatchCommand.resume : CoachWatchCommand.pause) return .result() } } /// Bouton Terminer. @available(iOS 17.0, *) struct CoachEndWorkoutIntent: LiveActivityIntent { static var title: LocalizedStringResource = "Terminer la séance" static var isDiscoverable: Bool = false init() {} func perform() async throws -> some IntentResult { CoachWatchCommand.send(CoachWatchCommand.end) return .result() } }