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>
498 lines
22 KiB
Swift
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)
|
|
}
|
|
}
|