feat(ios): plugin natif CoachHealthRoute (HKWorkoutRouteQuery)
@capgo/capacitor-health n'expose pas les routes GPS des workouts. Ce
plugin Swift custom appelle HKSampleQuery + HKWorkoutRouteQuery pour
récupérer la liste des CLLocation associées à un workout (par UUID).
API JS :
CoachHealthRoute.getRoute({workoutUUID})
→ {available: bool, route: [{latitude, longitude, altitude, timestamp, speed}], count}
Format payload aligné sur HAE pour réutiliser le parser backend
existant (tools/health_parser.py).
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||||
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
|
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
|
||||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
|
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
|
||||||
|
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; };
|
||||||
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; };
|
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; };
|
||||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
|
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
|
||||||
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
|
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
|
||||||
|
F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = "<group>"; };
|
||||||
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
|
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
|
||||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
@@ -74,6 +76,7 @@
|
|||||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||||
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */,
|
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */,
|
||||||
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */,
|
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */,
|
||||||
|
F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */,
|
||||||
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */,
|
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */,
|
||||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||||
@@ -169,6 +172,7 @@
|
|||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */,
|
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */,
|
||||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */,
|
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */,
|
||||||
|
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */,
|
||||||
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
|
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
156
ios/App/App/CoachHealthRoute.swift
Normal file
156
ios/App/App/CoachHealthRoute.swift
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// 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 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),
|
||||||
|
]
|
||||||
|
|
||||||
|
private let store = HKHealthStore()
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
let types: Set<HKObjectType> = [
|
||||||
|
HKObjectType.workoutType(),
|
||||||
|
HKSeriesType.workoutRoute(),
|
||||||
|
]
|
||||||
|
store.requestAuthorization(toShare: nil, read: types) { ok, err in
|
||||||
|
if let err = err {
|
||||||
|
call.resolve(["granted": false, "reason": err.localizedDescription])
|
||||||
|
} else {
|
||||||
|
call.resolve(["granted": ok])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user