// CoachWorkoutKit.swift // Plugin Capacitor natif iOS 17+ pour pousser des workouts structurés (warmup + // intervals + cooldown) vers l'app Exercice de l'Apple Watch via WorkoutKit. // // Usage côté JS (dans la WebView) : // await window.Capacitor.Plugins.CoachWorkoutKit.isAvailable() // → { available: bool, reason?: string } // await window.Capacitor.Plugins.CoachWorkoutKit.sendInterval({ // activity: 'running' | 'cycling' | 'walking' | 'hiking', // displayName: 'Coach séance 6×3min Z4', // warmupMin: 10, // // Préférer hr_bpm_min/max (nos zones cardiaques Karvonen) à hr_zone // // (zones génériques %FCmax d'Apple) — voir sendInterval. // work: { duration_min: 3, hr_bpm_min: 132, hr_bpm_max: 145 }, // rest: { duration_min: 1.5, hr_bpm_min: 95, hr_bpm_max: 110 }, // repeats: 6, // cooldownMin: 10, // }) // → { sent: true } si la sheet a été présentée et user a tapé Ajouter // → reject avec error string sinon // // Sur iOS 17.4+ : utilise WorkoutScheduler.shared.preview(plan) qui ouvre // la sheet système iOS 'Ajouter aux workouts'. // Sur iOS 17.0-17.3 (rare) : fallback schedule() qui ajoute direct. import Foundation import Capacitor #if canImport(WorkoutKit) import WorkoutKit import HealthKit #endif @objc(CoachWorkoutKitPlugin) public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin { // CAPBridgedPlugin : déclaration explicite du contrat plugin pour // Capacitor 8. Sans ça, registerPluginInstance() expose pas les // méthodes au JS bridge (Capacitor.Plugins.CoachWorkoutKit reste undefined). public let identifier = "CoachWorkoutKitPlugin" public let jsName = "CoachWorkoutKit" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "sendInterval", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "scheduledWorkouts", returnType: CAPPluginReturnPromise), ] @objc func isAvailable(_ call: CAPPluginCall) { guard #available(iOS 17.0, *) else { call.resolve(["available": false, "reason": "iOS 17+ required for WorkoutKit"]) return } // `available` disait « iOS 17+ », rien de plus : une autorisation // révoquée ou jamais accordée rendait exactement la même réponse. Task { let state = await WorkoutScheduler.shared.authorizationState call.resolve([ "available": true, "min_ios": "17.0", "supported": WorkoutScheduler.isSupported, "authorization": Self.describe(state), "authorized": state == .authorized, ]) } } /// Ce que la montre a réellement en attente, interrogeable sans rien envoyer. @objc func scheduledWorkouts(_ call: CAPPluginCall) { guard #available(iOS 17.0, *) else { call.reject("WorkoutKit requires iOS 17.0 or later") return } Task { let state = await WorkoutScheduler.shared.authorizationState let scheduled = await Self.scheduledSummaries() call.resolve([ "authorization": Self.describe(state), "authorized": state == .authorized, "count": scheduled.count, "scheduled": scheduled, ]) } } @objc func sendInterval(_ call: CAPPluginCall) { guard #available(iOS 17.0, *) else { call.reject("WorkoutKit requires iOS 17.0 or later") return } let activityStr = call.getString("activity") ?? "running" let displayName = call.getString("displayName") ?? "Coach séance" let warmupMin = call.getDouble("warmupMin") ?? 10 let cooldownMin = call.getDouble("cooldownMin") ?? 10 let repeats = max(1, min(50, call.getInt("repeats") ?? 6)) // Nouveau format : blockSteps = [{ type, duration_min, hr_zone, hr_bpm_min, hr_bpm_max, label }, ...] // type = "work" | "recovery" // hr_bpm_min / hr_bpm_max (optional) = plage cible en BPM EXPLICITE. // Quand fournie, on pousse un HeartRateRangeAlert -> ce sont NOS zones // cardiaques (Karvonen HRR sous bêta-bloquant) qui s'appliquent dans // l'app Exercice, et non les zones génériques %FCmax d'Apple. // hr_zone (optional) = fallback zone Apple 1-5 si pas de plage BPM. // label (optional) = displayName affiché sur l'Apple Watch pour ce step // (ex. "Course", "Marche", "Vélo"). Si nil → Watch affiche le label // natif Apple "Travail" / "Récupération". // Backward compat : si pas blockSteps, lit work + rest comme avant. var stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange?, isWork: Bool, label: String?)] = [] func bpmRange(_ lo: Any?, _ hi: Any?) -> ClosedRange? { guard let lo = lo as? Double, let hi = hi as? Double, lo > 0, hi > 0, hi >= lo else { return nil } return lo...hi } if let raw = call.getArray("blockSteps") as? [[String: Any]], !raw.isEmpty { for (i, s) in raw.enumerated() { let durMin = (s["duration_min"] as? Double) ?? 0 if durMin <= 0 { continue } let hrZone = s["hr_zone"] as? Int let bpm = bpmRange(s["hr_bpm_min"], s["hr_bpm_max"]) let typeStr = (s["type"] as? String) ?? (i % 2 == 0 ? "work" : "recovery") let label = s["label"] as? String stepsSpec.append((durMin, hrZone, bpm, typeStr == "work", label)) } } else { // Backward compat — work + rest (rest skipped si 0) let workDict = call.getObject("work") ?? [:] let workMin = workDict["duration_min"] as? Double ?? 3 let workHrZone = workDict["hr_zone"] as? Int let workBpm = bpmRange(workDict["hr_bpm_min"], workDict["hr_bpm_max"]) let workLabel = workDict["label"] as? String stepsSpec.append((workMin, workHrZone, workBpm, true, workLabel)) let restDict = call.getObject("rest") ?? [:] let restMin = restDict["duration_min"] as? Double ?? 1.5 let restHrZone = restDict["hr_zone"] as? Int let restBpm = bpmRange(restDict["hr_bpm_min"], restDict["hr_bpm_max"]) let restLabel = restDict["label"] as? String if restMin > 0 { stepsSpec.append((restMin, restHrZone, restBpm, false, restLabel)) } } if stepsSpec.isEmpty { call.reject("Au moins 1 step (durée > 0) requis dans le bloc") return } let requestedActivity = workoutActivity(from: activityStr) // Whitelist défensive — CustomWorkout.init fait des asserts internes // (precondition failure non catchable, crash dur) pour les activities // non supportées. La doc Apple confirme : .running, .cycling toujours, // .swimming/.rowing/.skating depuis watchOS 11 (WWDC24). Empirique // (testé 2026-05-18 build 15) : .functionalStrengthTraining marche. // Pour tout le reste (yoga, pilates, hiit, crossTraining, core, etc.) : // fallback .functionalStrengthTraining — sémantiquement plus juste // pour du non-cardio que .running. let activity = isSafeForCustomWorkout(requestedActivity) ? requestedActivity : .functionalStrengthTraining if activity != requestedActivity { NSLog("[CoachWorkoutKit] Activity rawValue=%lu not whitelisted for CustomWorkout, falling back to .functionalStrengthTraining", requestedActivity.rawValue) } Task { do { let scheduled = try await sendCustomWorkout( activity: activity, displayName: displayName, warmupMin: warmupMin, stepsSpec: stepsSpec, repeats: repeats, cooldownMin: cooldownMin ) call.resolve([ "sent": true, "activityUsed": Int(activity.rawValue), "activityFallback": activity != requestedActivity, // Ce que le scheduler liste APRÈS l'envoi : c'est la preuve, // le reste n'est qu'une intention. "scheduled": scheduled, ]) } catch { call.reject(error.localizedDescription) } } } // Whitelist des HKWorkoutActivityType compatibles avec CustomWorkout. // Mettre à jour dès qu'une nouvelle activity est testée empiriquement // sans crash sur watchOS / iOS courants. private func isSafeForCustomWorkout(_ activity: HKWorkoutActivityType) -> Bool { switch activity { // Confirmé doc Apple WWDC23+24 case .running, .cycling, .swimming, .walking, .hiking, .rowing, .skatingSports, .paddleSports: return true // Confirmé empiriquement 2026-05-18 (build 15) case .functionalStrengthTraining, .traditionalStrengthTraining: return true default: return false } } private func workoutActivity(from str: String) -> HKWorkoutActivityType { switch str.lowercased() { // Cardio outdoor case "cycling": return .cycling case "walking": return .walking case "hiking": return .hiking case "running": return .running case "swimming": return .swimming // Strength / renfo case "strength", "functional_strength": return .functionalStrengthTraining case "traditional_strength": return .traditionalStrengthTraining case "core", "core_training": return .coreTraining // HIIT / cross training case "hiit", "high_intensity": return .highIntensityIntervalTraining case "cross_training", "crosstraining": return .crossTraining // Mobilité case "yoga": return .yoga case "pilates": return .pilates case "flexibility", "stretching", "mobility": return .flexibility case "mixed", "mixed_cardio": return .mixedCardio default: return .running } } @available(iOS 17.0, *) private func sendCustomWorkout( activity: HKWorkoutActivityType, displayName: String, warmupMin: Double, stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange?, isWork: Bool, label: String?)], repeats: Int, cooldownMin: Double ) async throws -> [[String: Any]] { // 1. Warmup — un WorkoutStep optionnel. Si warmupMin == 0, on passe // nil au CustomWorkout (la signature accepte WorkoutStep?). Passer // un WorkoutStep(goal: .time(0, .seconds)) déclenche un assertion // failure dans CustomWorkout.init (precondition Apple non // catchable). Cas d'usage : séances 100% décrites en blockSteps // (renforcement avec items détaillés warmup+main+aux+cooldown). let warmupStep: WorkoutStep? = warmupMin > 0 ? WorkoutStep(goal: .time(warmupMin * 60, .seconds)) : nil // 2. Construit la liste d'IntervalSteps depuis stepsSpec. // Chaque entrée = (durée min, zone HR optionnelle, isWork bool, label optionnel). // Si label fourni, override l'affichage natif Apple ("Travail"/"Récupération") // par "Course"/"Marche"/"Vélo" etc. selon le sport. let intervalSteps: [IntervalStep] = stepsSpec.map { spec in var ws = WorkoutStep(goal: .time(spec.min * 60, .seconds)) // Priorité à la plage BPM explicite (nos zones cardiaques Karvonen) ; // sinon fallback sur la zone Apple générique 1-5. if let bpm = spec.bpm { let unit = WorkoutAlertMetric.countPerMinute ws.alert = HeartRateRangeAlert( target: Measurement(value: bpm.lowerBound, unit: unit) ... Measurement(value: bpm.upperBound, unit: unit) ) } else if let zone = spec.hrZone { ws.alert = HeartRateZoneAlert(zone: zone) } // displayName n'est dispo qu'à partir d'iOS 18. Sur iOS 17, on // garde l'affichage natif Apple "Travail" / "Récupération". if #available(iOS 18.0, *) { if let label = spec.label, !label.isEmpty { ws.displayName = label } } return IntervalStep(spec.isWork ? .work : .recovery, step: ws) } // 3. Block répété N fois — la séquence COMPLÈTE de tous les steps // se répète repeats fois. let block = IntervalBlock(steps: intervalSteps, iterations: repeats) // 4. Cooldown — idem warmup : nil si cooldownMin == 0 pour éviter // l'assertion failure sur WorkoutStep de durée 0. let cooldownStep: WorkoutStep? = cooldownMin > 0 ? WorkoutStep(goal: .time(cooldownMin * 60, .seconds)) : nil // 6. CustomWorkout — location selon l'activité (indoor pour // strength/yoga/core, outdoor pour cardio extérieur). let location: HKWorkoutSessionLocationType switch activity { case .functionalStrengthTraining, .traditionalStrengthTraining, .coreTraining, .yoga, .pilates, .flexibility, .highIntensityIntervalTraining, .crossTraining: location = .indoor default: location = .outdoor } let custom = CustomWorkout( activity: activity, location: location, displayName: displayName, warmup: warmupStep, blocks: [block], cooldown: cooldownStep ) // 7. Plan let plan = WorkoutPlan(.custom(custom)) // 8. Demande d'autorisation explicite (idempotent — déclenche dialog // iOS la 1re fois, return immédiat si déjà accordé). // // ⚠️ L'échec ne se ravale plus. Il l'était jusqu'au 03/09/2026 : un // NSLog, puis on continuait comme si de rien n'était. Une // réinstallation de l'app remet l'autorisation à `notDetermined` // (celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre du // silence de la montre — cf. GUIDE-MONTRE.md §5quater), et rien à // l'écran n'aurait dit que plus aucune séance n'arrivait au poignet. do { try await WorkoutScheduler.shared.requestAuthorization() NSLog("[CoachWorkoutKit] requestAuthorization OK") } catch { NSLog("[CoachWorkoutKit] requestAuthorization failed: \(error)") throw CoachWorkoutKitError.authorization(error.localizedDescription) } let authState = await WorkoutScheduler.shared.authorizationState guard authState == .authorized else { NSLog("[CoachWorkoutKit] authorizationState = %@", String(describing: authState)) throw CoachWorkoutKitError.notAuthorized(Self.describe(authState)) } // 8bis. Efface les workouts déjà programmés par l'app avant d'en // ajouter un nouveau. Sans ça, WorkoutScheduler EMPILE chaque envoi // (un ancien workout reste visible dans Exercice → Programmées, et // on finit par buter sur maxAllowedScheduledWorkoutCount). Modèle // "1 envoi = la séance courante remplace la précédente". // removeAllWorkouts() ne touche que NOS workouts programmés. await WorkoutScheduler.shared.removeAllWorkouts() NSLog("[CoachWorkoutKit] removeAllWorkouts (anciennes séances effacées)") // 9. Schedule le workout. Schedule pour 1 minute dans le futur (vs // "now") pour s'assurer que la date n'est pas dans le passé au // moment où le système traite l'event (sinon il peut être ignoré). let scheduleDate = Date().addingTimeInterval(60) // +1 min let comps = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute, .timeZone], from: scheduleDate ) try await WorkoutScheduler.shared.schedule(plan, at: comps) NSLog("[CoachWorkoutKit] schedule OK at \(scheduleDate)") // 10. Relecture : ce qui compte n'est pas que `schedule()` soit rentré // sans erreur, c'est que la séance soit RÉELLEMENT dans la liste du // scheduler. C'est la seule chose qui réponde, sans aller regarder // la montre, à « est-ce que ma séance est programmée ? ». // ⚠️ Une liste vide n'est PAS traitée comme un échec : le scheduler // est asynchrone et rien chez Apple ne garantit qu'il ait publié la // séance à l'instant où on le relit. Inventer une erreur à chaque // envoi coûterait plus cher que le silence qu'on cherche. On rend // ce qu'on voit, l'appelant le dit. return await Self.scheduledSummaries() } /// Les séances programmées PAR CETTE APP, telles que le scheduler les rend. @available(iOS 17.0, *) private static func scheduledSummaries() async -> [[String: Any]] { let plans = await WorkoutScheduler.shared.scheduledWorkouts return plans.map { sw in var out: [String: Any] = [ "date": ISO8601DateFormatter().string( from: Calendar.current.date(from: sw.date) ?? Date() ), "complete": sw.complete, ] if case let .custom(custom) = sw.plan.workout { // `displayName` est optionnel côté Apple : ne jamais poser un // Optional dans le dictionnaire rendu au JS, il n'est pas // sérialisable par le bridge Capacitor. out["displayName"] = custom.displayName ?? "" out["blocks"] = custom.blocks.count out["steps"] = custom.blocks.reduce(0) { $0 + $1.steps.count } out["iterations"] = custom.blocks.map { $0.iterations } } return out } } @available(iOS 17.0, *) private static func describe(_ state: WorkoutScheduler.AuthorizationState) -> String { switch state { case .authorized: return "authorized" case .denied: return "denied" case .notDetermined: return "notDetermined" case .restricted: return "restricted" @unknown default: return "unknown" } } } /// Erreurs qui doivent remonter jusqu'à l'écran, pas jusqu'au seul NSLog. enum CoachWorkoutKitError: LocalizedError { case authorization(String) case notAuthorized(String) var errorDescription: String? { switch self { case .authorization(let detail): return "Autorisation « Séances programmées » refusée par iOS : \(detail)" case .notAuthorized(let state): return "Autorisation « Séances programmées » absente (\(state)) — " + "Réglages iPhone → coach → Séances, puis renvoyer la séance." } } }