diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 614eaef..058bd51 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.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 */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 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 = ""; }; C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = ""; }; D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = ""; }; + F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = ""; }; E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -74,6 +76,7 @@ 504EC3071FED79650016851F /* AppDelegate.swift */, C0AC4A07811751FB78AA0002 /* CoachAuth.swift */, D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */, + F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */, E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, @@ -169,6 +172,7 @@ 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */, D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */, + F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */, E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/App/App/CoachHealthRoute.swift b/ios/App/App/CoachHealthRoute.swift new file mode 100644 index 0000000..ee59697 --- /dev/null +++ b/ios/App/App/CoachHealthRoute.swift @@ -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.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]) + } + } +}