diff --git a/ios/App/App/CoachHealthRoute.swift b/ios/App/App/CoachHealthRoute.swift index d1f60e2..fd929cb 100644 --- a/ios/App/App/CoachHealthRoute.swift +++ b/ios/App/App/CoachHealthRoute.swift @@ -26,6 +26,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise), ] private lazy var store = HKHealthStore() @@ -52,6 +53,12 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { 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)) + } // Share workoutType requis pour deleteWorkout (HKHealthStore.delete // exige share auth). Read-only pour les autres types. let shareTypes: Set = [HKObjectType.workoutType()] @@ -210,6 +217,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { /// 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) + } + @objc func deleteWorkout(_ call: CAPPluginCall) { guard HKHealthStore.isHealthDataAvailable() else { call.reject("HealthKit unavailable")