Le plugin santé expose les métriques que seule la montre mesure
getWorkoutDetails lit, pour une liste de séances : les métadonnées du HKWorkout (dénivelé de l'altimètre barométrique, température, humidité, indoor, METs) et la dynamique de course de watchOS 9+ (puissance, longueur de foulée, oscillation verticale, temps de contact au sol, vitesse max). @capgo/capacitor-health n'expose aucun de ces types, et le dénivelé barométrique est plus juste que le cumul d'altitude GPS que le serveur calculait faute de mieux. Sur une montre antérieure à la Series 6, les champs de dynamique sont simplement absents de la réponse. Le web appelle déjà la méthode derrière un test d'existence : elle ne prendra effet qu'après ce build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
private lazy var store = HKHealthStore()
|
||||
@@ -59,6 +60,14 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
if #available(iOS 18.0, *) {
|
||||
types.insert(HKQuantityType(.workoutEffortScore))
|
||||
}
|
||||
// Dynamique de course écrite par l'Apple Watch (Series 6+ / SE 2 / Ultra,
|
||||
// watchOS 9+) : @capgo/capacitor-health n'expose aucun de ces types.
|
||||
// Sur une montre plus ancienne, la permission est accordée mais aucune
|
||||
// donnée n'existe — getWorkoutDetails omet simplement les champs.
|
||||
for id in Self.runningMetricIdentifiers {
|
||||
types.insert(HKQuantityType(id))
|
||||
}
|
||||
if let steps = HKObjectType.quantityType(forIdentifier: .stepCount) { types.insert(steps) }
|
||||
// Share workoutType requis pour deleteWorkout (HKHealthStore.delete
|
||||
// exige share auth). Read-only pour les autres types.
|
||||
let shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
|
||||
@@ -304,6 +313,150 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
store.execute(workoutQuery)
|
||||
}
|
||||
|
||||
// ─── Détail de séance : métadonnées Apple + dynamique de course ──────────
|
||||
//
|
||||
// Ce que @capgo/capacitor-health ne donne pas et qui n'existe nulle part
|
||||
// ailleurs dans le pipeline :
|
||||
// · métadonnées du HKWorkout — dénivelé mesuré par l'altimètre
|
||||
// barométrique de la Watch (bien plus juste que le cumul GPS),
|
||||
// météo à la séance, indoor/outdoor, METs moyens ;
|
||||
// · dynamique de course (watchOS 9+, Watch Series 6+ / SE 2 / Ultra) —
|
||||
// puissance, longueur de foulée, oscillation verticale, temps de
|
||||
// contact au sol, vitesse instantanée moyenne/max.
|
||||
//
|
||||
// Usage JS :
|
||||
// const r = await CoachHealthRoute.getWorkoutDetails({ workoutUUIDs: [...] });
|
||||
// // r.details = [{ workout_id, elevation_ascended_m?, temperature_c?, … }]
|
||||
|
||||
/// Types de dynamique de course, disponibles depuis iOS 16 / watchOS 9.
|
||||
private static var runningMetricIdentifiers: [HKQuantityTypeIdentifier] {
|
||||
guard #available(iOS 16.0, *) else { return [] }
|
||||
return [
|
||||
.runningSpeed, .runningPower, .runningStrideLength,
|
||||
.runningVerticalOscillation, .runningGroundContactTime,
|
||||
]
|
||||
}
|
||||
|
||||
@objc func getWorkoutDetails(_ call: CAPPluginCall) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
call.resolve(["details": [], "reason": "HealthKit unavailable"])
|
||||
return
|
||||
}
|
||||
let uuids = (call.getArray("workoutUUIDs") as? [String] ?? [])
|
||||
.compactMap { UUID(uuidString: $0) }
|
||||
guard !uuids.isEmpty else {
|
||||
call.reject("workoutUUIDs requis (liste d'UUID strings)")
|
||||
return
|
||||
}
|
||||
|
||||
let pred = HKQuery.predicateForObjects(with: Set(uuids))
|
||||
let wq = HKSampleQuery(
|
||||
sampleType: HKObjectType.workoutType(),
|
||||
predicate: pred,
|
||||
limit: uuids.count,
|
||||
sortDescriptors: nil
|
||||
) { [weak self] _, results, err in
|
||||
guard let self = self else { return }
|
||||
if let err = err {
|
||||
call.reject("Workout fetch failed: \(err.localizedDescription)")
|
||||
return
|
||||
}
|
||||
let workouts = (results as? [HKWorkout]) ?? []
|
||||
guard !workouts.isEmpty else {
|
||||
call.resolve(["details": []])
|
||||
return
|
||||
}
|
||||
|
||||
let group = DispatchGroup()
|
||||
let lock = NSLock()
|
||||
var out: [[String: Any]] = []
|
||||
|
||||
for workout in workouts {
|
||||
group.enter()
|
||||
self.details(for: workout) { detail in
|
||||
lock.lock()
|
||||
out.append(detail)
|
||||
lock.unlock()
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
group.notify(queue: .main) {
|
||||
NSLog("[CoachHealthRoute] détails récupérés pour %d séance(s)", out.count)
|
||||
call.resolve(["details": out])
|
||||
}
|
||||
}
|
||||
store.execute(wq)
|
||||
}
|
||||
|
||||
/// Assemble métadonnées + statistiques d'une séance. Les champs absents ne
|
||||
/// sont pas émis : le backend distingue « pas mesuré » de « zéro ».
|
||||
private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) {
|
||||
var out: [String: Any] = ["workout_id": workout.uuid.uuidString]
|
||||
let meta = workout.metadata ?? [:]
|
||||
|
||||
// Dénivelé : altimètre barométrique de la Watch, pas le GPS.
|
||||
if let ascended = meta[HKMetadataKeyElevationAscended] as? HKQuantity {
|
||||
out["elevation_ascended_m"] = ascended.doubleValue(for: .meter()).rounded()
|
||||
}
|
||||
if let descended = meta["HKElevationDescended"] as? HKQuantity {
|
||||
out["elevation_descended_m"] = descended.doubleValue(for: .meter()).rounded()
|
||||
}
|
||||
if let temp = meta[HKMetadataKeyWeatherTemperature] as? HKQuantity {
|
||||
out["temperature_c"] = (temp.doubleValue(for: .degreeCelsius()) * 10).rounded() / 10
|
||||
}
|
||||
if let hum = meta[HKMetadataKeyWeatherHumidity] as? HKQuantity {
|
||||
out["humidity_pct"] = hum.doubleValue(for: .percent()).rounded()
|
||||
}
|
||||
if let indoor = meta[HKMetadataKeyIndoorWorkout] as? Bool {
|
||||
out["is_indoor"] = indoor
|
||||
}
|
||||
if let mets = meta[HKMetadataKeyAverageMETs] as? HKQuantity {
|
||||
let unit = HKUnit.kilocalorie().unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: .hour()))
|
||||
out["average_mets"] = (mets.doubleValue(for: unit) * 10).rounded() / 10
|
||||
}
|
||||
|
||||
// Statistiques agrégées sur la fenêtre exacte de la séance.
|
||||
let samplePredicate = HKQuery.predicateForObjects(from: workout)
|
||||
var requests: [(key: String, id: HKQuantityTypeIdentifier, unit: HKUnit, option: HKStatisticsOptions, scale: Double)] = []
|
||||
if #available(iOS 16.0, *) {
|
||||
let perMinute = HKUnit.count().unitDivided(by: .minute())
|
||||
_ = perMinute // cadence dérivée côté serveur depuis stepCount
|
||||
requests += [
|
||||
("running_speed_avg_ms", .runningSpeed, .meter().unitDivided(by: .second()), .discreteAverage, 100),
|
||||
("running_speed_max_ms", .runningSpeed, .meter().unitDivided(by: .second()), .discreteMax, 100),
|
||||
("running_power_avg_w", .runningPower, .watt(), .discreteAverage, 1),
|
||||
("running_power_max_w", .runningPower, .watt(), .discreteMax, 1),
|
||||
("stride_length_m", .runningStrideLength, .meter(), .discreteAverage, 100),
|
||||
("vertical_oscillation_cm", .runningVerticalOscillation, .meterUnit(with: .centi), .discreteAverage, 10),
|
||||
("ground_contact_ms", .runningGroundContactTime, .secondUnit(with: .milli), .discreteAverage, 1),
|
||||
]
|
||||
}
|
||||
requests.append(("steps", .stepCount, .count(), .cumulativeSum, 1))
|
||||
|
||||
let group = DispatchGroup()
|
||||
let lock = NSLock()
|
||||
for req in requests {
|
||||
guard let type = HKObjectType.quantityType(forIdentifier: req.id) else { continue }
|
||||
group.enter()
|
||||
let q = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: samplePredicate,
|
||||
options: req.option) { _, stats, _ in
|
||||
defer { group.leave() }
|
||||
let quantity: HKQuantity?
|
||||
switch req.option {
|
||||
case .discreteAverage: quantity = stats?.averageQuantity()
|
||||
case .discreteMax: quantity = stats?.maximumQuantity()
|
||||
default: quantity = stats?.sumQuantity()
|
||||
}
|
||||
guard let value = quantity?.doubleValue(for: req.unit), value > 0 else { return }
|
||||
lock.lock()
|
||||
out[req.key] = (value * req.scale).rounded() / req.scale
|
||||
lock.unlock()
|
||||
}
|
||||
self.store.execute(q)
|
||||
}
|
||||
group.notify(queue: .global()) { completion(out) }
|
||||
}
|
||||
|
||||
@objc func deleteWorkout(_ call: CAPPluginCall) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
call.reject("HealthKit unavailable")
|
||||
|
||||
Reference in New Issue
Block a user