feat(healthkit): lit le score d'effort saisi sur l'Apple Watch
watchOS demande 'Quel a été votre effort ?' après chaque séance et l'user y
répond déjà. La réponse est stockée dans HealthKit en workoutEffortScore
(iOS 18+, vérifié dans la doc Apple) : on la récupère au lieu de la redemander
dans l'app.
- getEffortScores({days}) : parcourt les séances de la fenêtre et, pour chacune,
interroge les samples d'effort via le prédicat officiel
predicateForWorkoutEffortSamplesRelated(workout:activity:). Pas de
rapprochement approximatif par horodatage.
- unité HKUnit.appleEffortScore(), score arrondi sur 1-10
- requestAuthorization demande désormais la permission de lecture du type :
sans elle, la requête renvoie une liste vide SANS erreur — HealthKit ne
distingue pas 'refusé' de 'aucune donnée'
- guard iOS 18 : sur plus ancien, renvoie [] avec une raison explicite
Le pendant serveur est dans coach_sportif (commit 0e79e74) :
POST /api/feedback/device/import, qui n'écrase jamais une saisie manuelle.
NON COMPILÉ — à inclure au prochain build Xcode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<HKSampleType> = [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")
|
||||
|
||||
Reference in New Issue
Block a user