Les pas d'une séance sont lus en dépliant les séries condensées par HealthKit
Apple, « Accessing condensed workout samples » : HealthKit fusionne les échantillons d'une séance en séries (count > 1), supprime les originaux, « while still preserving data resolution », et le détail se relit avec HKQuantitySeriesSampleQuery. Depuis watchOS 27 (21/09/2026), les pas d'une course arrivent en 4 à 7 tranches de 5 à 12 min ; le plugin Capgo lit quantity.doubleValue sans regarder count. getStepSamples(startDate, endDate) lit les pas de la fenêtre, déplie chaque série et rend aussi les comptes (raw / series / expanded) : c'est la preuve que le détail existe encore — ou qu'il n'existe plus. Lecture seule. Syntaxe vérifiée (swiftc -parse), build Xcode à faire. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getStepSamples", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
private lazy var store = HKHealthStore()
|
||||
@@ -396,6 +397,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
store.execute(wq)
|
||||
}
|
||||
|
||||
/// Pas d'une fenêtre de temps, séries condensées **dépliées**.
|
||||
///
|
||||
/// Apple, *Accessing condensed workout samples* : « HealthKit enumerates the
|
||||
/// data associated with a workout and adds that data to one or more
|
||||
/// quantity series samples. After saving all the data to series objects,
|
||||
/// HealthKit deletes the originals » — « while still preserving data
|
||||
/// resolution ». Un échantillon condensé a `count > 1`, et son détail se
|
||||
/// lit avec `HKQuantitySeriesSampleQuery`. Depuis watchOS 27 (constaté le
|
||||
/// 21/09/2026), les pas d'une course arrivent en 4 à 7 tranches de 5 à
|
||||
/// 12 min au lieu de ~850 échantillons de 2,5 s ; le plugin Capgo lit
|
||||
/// `quantity.doubleValue` sans regarder `count`. Cette méthode dit si le
|
||||
/// détail existe encore, et le rend s'il existe. Lecture seule.
|
||||
@objc func getStepSamples(_ call: CAPPluginCall) {
|
||||
guard HKHealthStore.isHealthDataAvailable(),
|
||||
let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) else {
|
||||
call.resolve(["samples": [], "reason": "HealthKit unavailable"])
|
||||
return
|
||||
}
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let isoPlain = ISO8601DateFormatter()
|
||||
func date(_ s: String?) -> Date? {
|
||||
guard let s = s else { return nil }
|
||||
return iso.date(from: s) ?? isoPlain.date(from: s)
|
||||
}
|
||||
guard let start = date(call.getString("startDate")),
|
||||
let end = date(call.getString("endDate")), end > start else {
|
||||
call.reject("startDate et endDate requis (ISO 8601), fin après début")
|
||||
return
|
||||
}
|
||||
|
||||
let window = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
|
||||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||||
let query = HKSampleQuery(sampleType: stepType, predicate: window,
|
||||
limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { [weak self] _, results, error in
|
||||
guard let self = self else { return }
|
||||
if let error = error {
|
||||
call.reject("Step fetch failed: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
let samples = (results as? [HKQuantitySample]) ?? []
|
||||
let group = DispatchGroup()
|
||||
let lock = NSLock()
|
||||
var rows: [[String: Any]] = []
|
||||
var nSeries = 0
|
||||
var nExpanded = 0
|
||||
|
||||
for s in samples {
|
||||
let src = s.sourceRevision.source
|
||||
let base: [String: Any] = ["source": src.name, "sourceId": src.bundleIdentifier]
|
||||
if s.count <= 1 {
|
||||
var row = base
|
||||
row["startDate"] = iso.string(from: s.startDate)
|
||||
row["endDate"] = iso.string(from: s.endDate)
|
||||
row["qty"] = s.quantity.doubleValue(for: .count())
|
||||
lock.lock(); rows.append(row); lock.unlock()
|
||||
continue
|
||||
}
|
||||
nSeries += 1
|
||||
group.enter()
|
||||
// Une série : ses quantités individuelles, chacune avec son
|
||||
// intervalle. `done` marque le dernier appel du handler.
|
||||
let inSeries = HKQuery.predicateForObject(with: s.uuid)
|
||||
let detail = HKQuantitySeriesSampleQuery(quantityType: stepType,
|
||||
predicate: inSeries) { _, quantity, interval, _, done, err in
|
||||
if let quantity = quantity, let interval = interval {
|
||||
var row = base
|
||||
row["startDate"] = iso.string(from: interval.start)
|
||||
row["endDate"] = iso.string(from: interval.end)
|
||||
row["qty"] = quantity.doubleValue(for: .count())
|
||||
row["fromSeries"] = true
|
||||
lock.lock(); rows.append(row); nExpanded += 1; lock.unlock()
|
||||
}
|
||||
if done || err != nil { group.leave() }
|
||||
}
|
||||
self.store.execute(detail)
|
||||
}
|
||||
|
||||
group.notify(queue: .global()) {
|
||||
rows.sort { ($0["startDate"] as? String ?? "") < ($1["startDate"] as? String ?? "") }
|
||||
NSLog("[CoachHealthRoute] pas : %d échantillons, %d séries, %d dépliés", samples.count, nSeries, nExpanded)
|
||||
call.resolve(["samples": rows, "raw": samples.count, "series": nSeries, "expanded": nExpanded])
|
||||
}
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
Reference in New Issue
Block a user