feat(workoutkit): pousse les zones cardiaques en plage BPM explicite (HeartRateRangeAlert)
Le plugin accepte hr_bpm_min/hr_bpm_max par step et pousse un HeartRateRangeAlert réel -> ce sont les zones Karvonen HRR (sous bêta-bloquant) qui s'appliquent dans l'app Exercice de la Watch, pas les zones génériques %FCmax d'Apple. hr_zone reste en fallback. API vérifiée dans le SDK WorkoutKit (WorkoutAlertMetric.countPerMinute). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,10 @@
|
|||||||
// activity: 'running' | 'cycling' | 'walking' | 'hiking',
|
// activity: 'running' | 'cycling' | 'walking' | 'hiking',
|
||||||
// displayName: 'Coach séance 6×3min Z4',
|
// displayName: 'Coach séance 6×3min Z4',
|
||||||
// warmupMin: 10,
|
// warmupMin: 10,
|
||||||
// work: { duration_min: 3, hr_zone: 4 },
|
// // Préférer hr_bpm_min/max (nos zones cardiaques Karvonen) à hr_zone
|
||||||
// rest: { duration_min: 1.5, hr_zone: 2 },
|
// // (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,
|
// repeats: 6,
|
||||||
// cooldownMin: 10,
|
// cooldownMin: 10,
|
||||||
// })
|
// })
|
||||||
@@ -60,36 +62,49 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let cooldownMin = call.getDouble("cooldownMin") ?? 10
|
let cooldownMin = call.getDouble("cooldownMin") ?? 10
|
||||||
let repeats = max(1, min(50, call.getInt("repeats") ?? 6))
|
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"
|
// 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
|
// label (optional) = displayName affiché sur l'Apple Watch pour ce step
|
||||||
// (ex. "Course", "Marche", "Vélo"). Si nil → Watch affiche le label
|
// (ex. "Course", "Marche", "Vélo"). Si nil → Watch affiche le label
|
||||||
// natif Apple "Travail" / "Récupération".
|
// natif Apple "Travail" / "Récupération".
|
||||||
// Backward compat : si pas blockSteps, lit work + rest comme avant.
|
// 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<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 {
|
if let raw = call.getArray("blockSteps") as? [[String: Any]], !raw.isEmpty {
|
||||||
for (i, s) in raw.enumerated() {
|
for (i, s) in raw.enumerated() {
|
||||||
let durMin = (s["duration_min"] as? Double) ?? 0
|
let durMin = (s["duration_min"] as? Double) ?? 0
|
||||||
if durMin <= 0 { continue }
|
if durMin <= 0 { continue }
|
||||||
let hrZone = s["hr_zone"] as? Int
|
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 typeStr = (s["type"] as? String) ?? (i % 2 == 0 ? "work" : "recovery")
|
||||||
let label = s["label"] as? String
|
let label = s["label"] as? String
|
||||||
stepsSpec.append((durMin, hrZone, typeStr == "work", label))
|
stepsSpec.append((durMin, hrZone, bpm, typeStr == "work", label))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Backward compat — work + rest (rest skipped si 0)
|
// Backward compat — work + rest (rest skipped si 0)
|
||||||
let workDict = call.getObject("work") ?? [:]
|
let workDict = call.getObject("work") ?? [:]
|
||||||
let workMin = workDict["duration_min"] as? Double ?? 3
|
let workMin = workDict["duration_min"] as? Double ?? 3
|
||||||
let workHrZone = workDict["hr_zone"] as? Int
|
let workHrZone = workDict["hr_zone"] as? Int
|
||||||
|
let workBpm = bpmRange(workDict["hr_bpm_min"], workDict["hr_bpm_max"])
|
||||||
let workLabel = workDict["label"] as? String
|
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 restDict = call.getObject("rest") ?? [:]
|
||||||
let restMin = restDict["duration_min"] as? Double ?? 1.5
|
let restMin = restDict["duration_min"] as? Double ?? 1.5
|
||||||
let restHrZone = restDict["hr_zone"] as? Int
|
let restHrZone = restDict["hr_zone"] as? Int
|
||||||
|
let restBpm = bpmRange(restDict["hr_bpm_min"], restDict["hr_bpm_max"])
|
||||||
let restLabel = restDict["label"] as? String
|
let restLabel = restDict["label"] as? String
|
||||||
if restMin > 0 {
|
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,
|
activity: HKWorkoutActivityType,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
warmupMin: Double,
|
warmupMin: Double,
|
||||||
stepsSpec: [(min: Double, hrZone: Int?, isWork: Bool, label: String?)],
|
stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange<Double>?, isWork: Bool, label: String?)],
|
||||||
repeats: Int,
|
repeats: Int,
|
||||||
cooldownMin: Double
|
cooldownMin: Double
|
||||||
) async throws {
|
) async throws {
|
||||||
@@ -202,7 +217,15 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
// par "Course"/"Marche"/"Vélo" etc. selon le sport.
|
// par "Course"/"Marche"/"Vélo" etc. selon le sport.
|
||||||
let intervalSteps: [IntervalStep] = stepsSpec.map { spec in
|
let intervalSteps: [IntervalStep] = stepsSpec.map { spec in
|
||||||
var ws = WorkoutStep(goal: .time(spec.min * 60, .seconds))
|
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)
|
ws.alert = HeartRateZoneAlert(zone: zone)
|
||||||
}
|
}
|
||||||
// displayName n'est dispo qu'à partir d'iOS 18. Sur iOS 17, on
|
// displayName n'est dispo qu'à partir d'iOS 18. Sur iOS 17, on
|
||||||
|
|||||||
Reference in New Issue
Block a user