From 1c0e0320a9220b5ed3cf6f2d2910d28e944ffdf1 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Sat, 8 Aug 2026 06:49:28 +0000 Subject: [PATCH] =?UTF-8?q?Le=20plugin=20sant=C3=A9=20expose=20les=20m?= =?UTF-8?q?=C3=A9triques=20que=20seule=20la=20montre=20mesure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/App/App/CoachHealthRoute.swift | 153 +++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/ios/App/App/CoachHealthRoute.swift b/ios/App/App/CoachHealthRoute.swift index fd929cb..a6a3041 100644 --- a/ios/App/App/CoachHealthRoute.swift +++ b/ios/App/App/CoachHealthRoute.swift @@ -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 = [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")