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: "getRoute", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
|
||||||
]
|
]
|
||||||
|
|
||||||
private lazy var store = HKHealthStore()
|
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 dob = HKObjectType.characteristicType(forIdentifier: .dateOfBirth) { types.insert(dob) }
|
||||||
if let sex = HKObjectType.characteristicType(forIdentifier: .biologicalSex) { types.insert(sex) }
|
if let sex = HKObjectType.characteristicType(forIdentifier: .biologicalSex) { types.insert(sex) }
|
||||||
if let blood = HKObjectType.characteristicType(forIdentifier: .bloodType) { types.insert(blood) }
|
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
|
// Share workoutType requis pour deleteWorkout (HKHealthStore.delete
|
||||||
// exige share auth). Read-only pour les autres types.
|
// exige share auth). Read-only pour les autres types.
|
||||||
let shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
|
let shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
|
||||||
@@ -210,6 +217,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
/// share pour HKWorkoutType (demandée dans requestAuthorization). Renvoie
|
/// share pour HKWorkoutType (demandée dans requestAuthorization). Renvoie
|
||||||
/// {deleted: true} si trouvé+supprimé, {deleted: false, reason: ...} si
|
/// {deleted: true} si trouvé+supprimé, {deleted: false, reason: ...} si
|
||||||
/// introuvable, ou reject() si erreur HealthKit.
|
/// 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) {
|
@objc func deleteWorkout(_ call: CAPPluginCall) {
|
||||||
guard HKHealthStore.isHealthDataAvailable() else {
|
guard HKHealthStore.isHealthDataAvailable() else {
|
||||||
call.reject("HealthKit unavailable")
|
call.reject("HealthKit unavailable")
|
||||||
|
|||||||
Reference in New Issue
Block a user