Files
coach-ios/ios/App/App/CoachHealthRoute.swift
Sylvain Bettinelli 1c0e0320a9 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>
2026-08-08 06:49:28 +00:00

498 lines
22 KiB
Swift

// CoachHealthRoute.swift
// Plugin Capacitor natif iOS : récupère la route GPS d'un workout HealthKit
// (HKWorkoutRoute / CLLocation). Comble le manque de @capgo/capacitor-health
// qui n'expose pas les routes.
//
// Usage côté JS :
// const r = await window.Capacitor.Plugins.CoachHealthRoute.getRoute({
// workoutUUID: '...HKWorkout UUID string...'
// });
// // r.route = [{lat, lon, ts, alt?, speed?}, ...] ordonné chronologiquement
// // r.available = bool (false si pas de route associée, pas d'autorisation, etc.)
import Foundation
import UIKit
import Capacitor
import HealthKit
import CoreLocation
@objc(CoachHealthRoutePlugin)
public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CoachHealthRoutePlugin"
public let jsName = "CoachHealthRoute"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "requestAuthorization", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise),
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()
public override func load() {
NSLog("[CoachHealthRoute] plugin loaded (iOS \(UIDevice.current.systemVersion))")
super.load()
}
@objc func isAvailable(_ call: CAPPluginCall) {
call.resolve(["available": HKHealthStore.isHealthDataAvailable()])
}
@objc func requestAuthorization(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else {
call.resolve(["granted": false, "reason": "HealthKit unavailable"])
return
}
var types: Set<HKObjectType> = [
HKObjectType.workoutType(),
HKSeriesType.workoutRoute(),
]
// Caractéristiques (DOB, sexe, groupe sanguin) read-only depuis HK
if let dob = HKObjectType.characteristicType(forIdentifier: .dateOfBirth) { types.insert(dob) }
if let sex = HKObjectType.characteristicType(forIdentifier: .biologicalSex) { types.insert(sex) }
if let blood = HKObjectType.characteristicType(forIdentifier: .bloodType) { types.insert(blood) }
// Score d'effort saisi sur la Watch en fin de séance (iOS 18+).
// Sans cette permission, getEffortScores renvoie une liste vide sans
// erreur HealthKit ne distingue pas « refusé » de « aucune donnée ».
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()]
store.requestAuthorization(toShare: shareTypes, read: types) { ok, err in
if let err = err {
call.resolve(["granted": false, "reason": err.localizedDescription])
} else {
call.resolve(["granted": ok])
}
}
}
@objc func getCharacteristics(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else {
call.resolve(["available": false, "reason": "HealthKit unavailable"])
return
}
var result: [String: Any] = ["available": true]
// Date de naissance
if let dob = try? store.dateOfBirthComponents() {
if let date = Calendar.current.date(from: dob) {
let fmt = ISO8601DateFormatter()
fmt.formatOptions = [.withFullDate]
result["dateOfBirth"] = fmt.string(from: date)
}
}
// Sexe biologique
if let bs = try? store.biologicalSex() {
switch bs.biologicalSex {
case .male: result["biologicalSex"] = "male"
case .female: result["biologicalSex"] = "female"
case .other: result["biologicalSex"] = "other"
default: break // notSet
}
}
// Groupe sanguin
if let bt = try? store.bloodType() {
let map: [HKBloodType: String] = [
.aPositive: "A+", .aNegative: "A-",
.bPositive: "B+", .bNegative: "B-",
.abPositive: "AB+", .abNegative: "AB-",
.oPositive: "O+", .oNegative: "O-",
]
if let s = map[bt.bloodType] { result["bloodType"] = s }
}
call.resolve(result)
}
@objc func getRoute(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else {
call.resolve(["available": false, "reason": "HealthKit unavailable", "route": []])
return
}
guard let uuidStr = call.getString("workoutUUID"), let uuid = UUID(uuidString: uuidStr) else {
call.reject("workoutUUID requis (UUID string)")
return
}
// 1. Retrouver le HKWorkout via son UUID.
let pred = HKQuery.predicateForObject(with: uuid)
let wq = HKSampleQuery(
sampleType: HKObjectType.workoutType(),
predicate: pred,
limit: 1,
sortDescriptors: nil
) { _, results, err in
if let err = err {
call.reject("Workout fetch failed: \(err.localizedDescription)")
return
}
guard let workout = results?.first as? HKWorkout else {
call.resolve(["available": false, "reason": "workout not found", "route": []])
return
}
self.fetchRoute(for: workout, call: call)
}
store.execute(wq)
}
private func fetchRoute(for workout: HKWorkout, call: CAPPluginCall) {
// 2. Récupère les HKWorkoutRoute samples associés à ce workout (souvent 1).
let routePred = HKQuery.predicateForObjects(from: workout)
let routeQ = HKSampleQuery(
sampleType: HKSeriesType.workoutRoute(),
predicate: routePred,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samples, err in
if let err = err {
call.reject("Route fetch failed: \(err.localizedDescription)")
return
}
guard let routes = samples as? [HKWorkoutRoute], !routes.isEmpty else {
call.resolve(["available": false, "reason": "no route", "route": []])
return
}
// 3. Pour chaque HKWorkoutRoute, streamer les CLLocation samples.
self.collectLocations(routes: routes, call: call)
}
store.execute(routeQ)
}
private func collectLocations(routes: [HKWorkoutRoute], call: CAPPluginCall) {
var all: [CLLocation] = []
let group = DispatchGroup()
var pluginErr: Error?
for route in routes {
group.enter()
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
if let err = err {
pluginErr = err
group.leave()
return
}
if let locs = locations {
all.append(contentsOf: locs)
}
if done {
group.leave()
}
}
store.execute(q)
}
group.notify(queue: .main) {
if let err = pluginErr {
call.reject("Locations stream failed: \(err.localizedDescription)")
return
}
// Tri chronologique + sérialisation
let sorted = all.sorted { $0.timestamp < $1.timestamp }
// Format HAE-compatible : latitude/longitude/altitude.
// Le parser backend lit ces noms (tools/health_parser.py).
let isoFmt = ISO8601DateFormatter()
let payload: [[String: Any]] = sorted.map { loc in
var dict: [String: Any] = [
"latitude": loc.coordinate.latitude,
"longitude": loc.coordinate.longitude,
"timestamp": isoFmt.string(from: loc.timestamp),
]
if loc.verticalAccuracy >= 0 {
dict["altitude"] = loc.altitude
}
if loc.speed >= 0 {
dict["speed"] = loc.speed // m/s
}
return dict
}
call.resolve(["available": true, "route": payload, "count": payload.count])
}
}
/// Supprime un workout d'Apple Santé via son UUID. Requiert l'autorisation
/// share pour HKWorkoutType (demandée dans requestAuthorization). Renvoie
/// {deleted: true} si trouvé+supprimé, {deleted: false, reason: ...} si
/// introuvable, ou reject() si erreur HealthKit.
/// Scores d'effort saisis sur l'Apple Watch en fin de séance.
///
/// watchOS demande « Quel a été votre effort ? » après chaque entraînement ;
/// la réponse est stockée dans HealthKit en `workoutEffortScore` (iOS 18+).
/// On la récupère au lieu de reposer la question dans l'app.
///
/// La liaison score séance passe par le prédicat officiel
/// `predicateForWorkoutEffortSamplesRelated(workout:activity:)` : pas de
/// rapprochement approximatif par horodatage.
///
/// `getEffortScores({ days: 30 })` { scores: [{ workout_id, rpe, date }] }
@objc func getEffortScores(_ call: CAPPluginCall) {
guard #available(iOS 18.0, *) else {
call.resolve(["scores": [], "reason": "iOS 18 requis pour workoutEffortScore"])
return
}
guard HKHealthStore.isHealthDataAvailable() else {
call.resolve(["scores": [], "reason": "HealthKit indisponible"])
return
}
let days = max(1, min(365, call.getInt("days") ?? 30))
let start = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date()
let workoutPredicate = HKQuery.predicateForSamples(withStart: start, end: nil)
let workoutQuery = HKSampleQuery(
sampleType: .workoutType(),
predicate: workoutPredicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)]
) { [weak self] _, samples, error in
guard let self = self else { return }
if let error = error {
call.reject("Lecture des séances impossible : \(error.localizedDescription)")
return
}
let workouts = (samples as? [HKWorkout]) ?? []
guard !workouts.isEmpty else {
call.resolve(["scores": []])
return
}
let effortType = HKQuantityType(.workoutEffortScore)
let group = DispatchGroup()
let lock = NSLock()
var out: [[String: Any]] = []
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd"
fmt.timeZone = TimeZone.current
for workout in workouts {
group.enter()
let related = HKQuery.predicateForWorkoutEffortSamplesRelated(
workout: workout, activity: nil
)
let q = HKSampleQuery(
sampleType: effortType,
predicate: related,
limit: 1,
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)]
) { _, effortSamples, _ in
defer { group.leave() }
guard let q = (effortSamples as? [HKQuantitySample])?.first else { return }
let score = q.quantity.doubleValue(for: HKUnit.appleEffortScore())
guard score > 0 else { return }
lock.lock()
out.append([
"workout_id": workout.uuid.uuidString,
"rpe": Int(score.rounded()),
"date": fmt.string(from: workout.startDate),
])
lock.unlock()
}
self.store.execute(q)
}
group.notify(queue: .main) {
NSLog("[CoachHealthRoute] %d score(s) d'effort trouvé(s) sur %d séance(s)",
out.count, workouts.count)
call.resolve(["scores": out])
}
}
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")
return
}
guard let uuidStr = call.getString("workoutUUID"),
let uuid = UUID(uuidString: uuidStr) else {
call.reject("workoutUUID requis (UUID string)")
return
}
// 1. Retrouver le HKWorkout via son UUID (même pattern que getRoute).
let pred = HKQuery.predicateForObject(with: uuid)
let wq = HKSampleQuery(
sampleType: HKObjectType.workoutType(),
predicate: pred,
limit: 1,
sortDescriptors: nil
) { _, results, err in
if let err = err {
call.reject("Workout fetch failed: \(err.localizedDescription)")
return
}
guard let workout = results?.first as? HKWorkout else {
call.resolve(["deleted": false, "reason": "workout not found"])
return
}
// 2. Supprime le workout d'Apple Santé (requiert share auth pour workoutType).
self.store.delete(workout) { success, err in
if let err = err {
call.reject("Delete failed: \(err.localizedDescription)")
return
}
call.resolve(["deleted": success])
}
}
store.execute(wq)
}
}