Deux épisodes de « la montre ne dit plus rien » ont buté sur la même absence : rien, côté app, ne distinguait « iOS a accepté l'appel » de « la séance est au poignet ». Détail du dossier et chronologie dans coach_sportif, docs/GUIDE-MONTRE.md §5quater et §5quinquies. CoachWorkoutKit : - requestAuthorization() n'avale plus son échec. Il était consigné dans un NSLog et l'envoi continuait : une réinstallation de l'app remet l'autorisation à notDetermined — celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre du silence — et l'écran affichait « Envoyé » alors que rien ne partait. - authorizationState est lu et rendu au JS, par isAvailable() comme par la nouvelle méthode scheduledWorkouts(). isAvailable() ne répondait jusqu'ici qu'« iOS 17+ » : une autorisation révoquée rendait la même réponse. - schedule() est suivi d'une relecture de WorkoutScheduler.scheduledWorkouts, et sendInterval rend ce qu'elle liste (nom, date, blocs, itérations). Une liste vide n'est PAS traitée comme un échec : le scheduler publie de façon asynchrone et Apple ne garantit aucun délai — inventer une erreur à chaque envoi coûterait plus cher que le silence qu'on cherche. CoachWorkoutObserver : à chaque fin de séance, HKWorkout.workoutPlan dit si elle vient d'une séance programmée. Le fait part vers /api/workout/plan-origin, sur le modèle de reportToRoutineIfStrength — faits bruts ici, interprétation côté serveur, donc ajustable sans rebuild. Une lecture en échec n'envoie rien plutôt qu'un faux « lancée à la main » : côté serveur, l'absence s'affiche « inconnu ». ⚠️ Rien de tout cela ne se compile ici : WorkoutKit et HealthKit sont absents de Swift pour Linux, et tests-linux ne couvre que ce qui n'importe que Foundation. À builder sur le Mac mini. Les signatures ont été vérifiées une par une dans la doc DocC — authorizationState (get async, 4 cas), scheduledWorkouts, ScheduledWorkoutPlan.date en DateComponents, WorkoutPlan.workout, CustomWorkout.displayName optionnel, HKWorkout.workoutPlan (get async throws). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
412 lines
19 KiB
Swift
412 lines
19 KiB
Swift
// 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<Double>?, isWork: Bool, label: String?)] = []
|
||
func bpmRange(_ lo: Any?, _ hi: Any?) -> ClosedRange<Double>? {
|
||
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<Double>?, 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."
|
||
}
|
||
}
|
||
}
|