Files
coach-ios/ios/App/App/CoachWorkoutKit.swift
Sylvain Bettinelli 9903424235 Le plugin affirmait l'envoi sans jamais relire ce que la montre avait reçu
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>
2026-09-03 06:24:08 +00:00

412 lines
19 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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."
}
}
}