diff --git a/ios/App/App/CoachWorkoutKit.swift b/ios/App/App/CoachWorkoutKit.swift index 5410fc7..1aa5915 100644 --- a/ios/App/App/CoachWorkoutKit.swift +++ b/ios/App/App/CoachWorkoutKit.swift @@ -9,8 +9,10 @@ // activity: 'running' | 'cycling' | 'walking' | 'hiking', // displayName: 'Coach séance 6×3min Z4', // warmupMin: 10, -// work: { duration_min: 3, hr_zone: 4 }, -// rest: { duration_min: 1.5, hr_zone: 2 }, +// // 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, // }) @@ -60,36 +62,49 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin { let cooldownMin = call.getDouble("cooldownMin") ?? 10 let repeats = max(1, min(50, call.getInt("repeats") ?? 6)) - // Nouveau format : blockSteps = [{ type, duration_min, hr_zone, label }, ...] + // 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?, isWork: Bool, label: String?)] = [] + 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, typeStr == "work", label)) + 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, true, workLabel)) + 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, false, restLabel)) + stepsSpec.append((restMin, restHrZone, restBpm, false, restLabel)) } } @@ -181,7 +196,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin { activity: HKWorkoutActivityType, displayName: String, warmupMin: Double, - stepsSpec: [(min: Double, hrZone: Int?, isWork: Bool, label: String?)], + stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange?, isWork: Bool, label: String?)], repeats: Int, cooldownMin: Double ) async throws { @@ -202,7 +217,15 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin { // 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)) - if let zone = spec.hrZone { + // 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