diff --git a/ios/App/App/CoachWorkoutObserver.swift b/ios/App/App/CoachWorkoutObserver.swift new file mode 100644 index 0000000..54b13de --- /dev/null +++ b/ios/App/App/CoachWorkoutObserver.swift @@ -0,0 +1,212 @@ +// CoachWorkoutObserver.swift +// Observer HealthKit en background : détecte les nouveaux HKWorkout +// ajoutés au store (= fin de séance Apple Watch ou autre source) et +// push une notif locale enrichie sur l'iPhone. +// +// Pipeline : +// 1. requestAuthorization (lecture HKWorkout) +// 2. enableBackgroundDelivery(.immediate) sur HKWorkoutType +// 3. HKObserverQuery → callback à chaque nouveau workout (réveille +// l'app en background même si fermée) +// 4. fetchLatestWorkout → query le dernier workout sur la dernière +// heure pour récupérer durée/kcal/distance +// 5. scheduleNotification → UNNotificationRequest immédiat +// +// Usage JS : +// await window.Capacitor.Plugins.CoachWorkoutObserver.startObserving() +// → { observing: true } +// await window.Capacitor.Plugins.CoachWorkoutObserver.stopObserving() +// → { observing: false } +// +// Capability Xcode requise : Signing & Capabilities → HealthKit → +// cocher "Background Delivery". Sans ça, iOS ne réveille pas l'app. + +import Foundation +import Capacitor +import HealthKit +import UserNotifications + +@objc(CoachWorkoutObserverPlugin) +public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "CoachWorkoutObserverPlugin" + public let jsName = "CoachWorkoutObserver" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "startObserving", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "stopObserving", returnType: CAPPluginReturnPromise), + ] + + private let healthStore = HKHealthStore() + private var observerQuery: HKObserverQuery? + + // Clé UserDefaults pour persister l'UUID du dernier workout notifié. + // Évite les doublons si iOS appelle l'observer plusieurs fois pour + // le même workout (ce qui arrive). + private let lastNotifiedKey = "CoachWorkoutObserver.lastNotifiedUUID" + + @objc func startObserving(_ call: CAPPluginCall) { + guard HKHealthStore.isHealthDataAvailable() else { + call.reject("HealthKit not available on this device") + return + } + Task { + do { + try await requestPermissionsIfNeeded() + try await enableBackgroundDelivery() + startObserverQuery() + call.resolve(["observing": true]) + } catch { + call.reject("Observer setup failed: \(error.localizedDescription)") + } + } + } + + @objc func stopObserving(_ call: CAPPluginCall) { + if let q = observerQuery { + healthStore.stop(q) + observerQuery = nil + } + let workoutType = HKObjectType.workoutType() + healthStore.disableBackgroundDelivery(for: workoutType) { _, _ in } + call.resolve(["observing": false]) + } + + // MARK: - HealthKit setup + + private func requestPermissionsIfNeeded() async throws { + let workoutType = HKObjectType.workoutType() + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + healthStore.requestAuthorization(toShare: nil, read: [workoutType]) { _, error in + if let error = error { + cont.resume(throwing: error) + } else { + cont.resume() + } + } + } + } + + private func enableBackgroundDelivery() async throws { + let workoutType = HKObjectType.workoutType() + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + healthStore.enableBackgroundDelivery(for: workoutType, frequency: .immediate) { ok, error in + if let error = error { + cont.resume(throwing: error) + } else { + NSLog("[CoachWorkoutObserver] background delivery enabled=%@", ok ? "true" : "false") + cont.resume() + } + } + } + } + + private func startObserverQuery() { + // Stop l'ancienne au cas où startObserving est rappelé (idempotent) + if let q = observerQuery { + healthStore.stop(q) + } + let workoutType = HKObjectType.workoutType() + let query = HKObserverQuery(sampleType: workoutType, predicate: nil) { [weak self] _, completion, error in + if let error = error { + NSLog("[CoachWorkoutObserver] query error: %@", error.localizedDescription) + completion() + return + } + NSLog("[CoachWorkoutObserver] observer fired") + self?.fetchLatestWorkout { + completion() // signaler iOS qu'on a fini de traiter (mandatory) + } + } + healthStore.execute(query) + observerQuery = query + NSLog("[CoachWorkoutObserver] observer query started") + } + + // MARK: - Fetch + notif + + private func fetchLatestWorkout(completion: @escaping () -> Void) { + let workoutType = HKObjectType.workoutType() + // Filtre 1h pour limiter le scope (un workout très ancien remonté + // par un sync HealthKit tardif ne devrait pas spammer une notif). + let pred = HKQuery.predicateForSamples(withStart: Date(timeIntervalSinceNow: -3600), end: nil) + let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + let query = HKSampleQuery( + sampleType: workoutType, + predicate: pred, + limit: 1, + sortDescriptors: [sort] + ) { [weak self] _, samples, error in + defer { completion() } + guard let self = self else { return } + if let error = error { + NSLog("[CoachWorkoutObserver] sample query error: %@", error.localizedDescription) + return + } + guard let workout = samples?.first as? HKWorkout else { + NSLog("[CoachWorkoutObserver] no workout in last hour") + return + } + let uuid = workout.uuid.uuidString + let lastUuid = UserDefaults.standard.string(forKey: self.lastNotifiedKey) ?? "" + if uuid == lastUuid { + NSLog("[CoachWorkoutObserver] workout %@ already notified, skip", uuid) + return + } + UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey) + self.scheduleNotification(for: workout) + } + healthStore.execute(query) + } + + private func scheduleNotification(for workout: HKWorkout) { + let durationMin = Int(workout.duration / 60) + var bodyParts: [String] = ["\(durationMin) min"] + if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 { + bodyParts.append("\(Int(kcal)) kcal") + } + if let dist = workout.totalDistance?.doubleValue(for: .meter()), dist > 100 { + let km = dist / 1000 + bodyParts.append(String(format: "%.1f km", km)) + } + let typeName = workoutActivityName(workout.workoutActivityType) + let body = bodyParts.joined(separator: " · ") + + let content = UNMutableNotificationContent() + content.title = "🎉 Séance \(typeName) terminée" + content.body = "Bravo ! \(body)" + content.sound = .default + + let request = UNNotificationRequest( + identifier: "coach_workout_done_\(workout.uuid.uuidString)", + content: content, + trigger: nil // déclenchement immédiat + ) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + NSLog("[CoachWorkoutObserver] notif scheduling failed: %@", error.localizedDescription) + } else { + NSLog("[CoachWorkoutObserver] notif scheduled for workout %@", workout.uuid.uuidString) + } + } + } + + private func workoutActivityName(_ activity: HKWorkoutActivityType) -> String { + switch activity { + case .running: return "course" + case .cycling: return "vélo" + case .walking: return "marche" + case .hiking: return "rando" + case .swimming: return "natation" + case .functionalStrengthTraining, .traditionalStrengthTraining: return "renfo" + case .coreTraining: return "gainage" + case .yoga: return "yoga" + case .pilates: return "pilates" + case .flexibility: return "mobilité" + case .highIntensityIntervalTraining: return "HIIT" + case .crossTraining: return "cross-training" + case .rowing: return "aviron" + case .skatingSports: return "patinage" + case .paddleSports: return "paddle" + default: return "sport" + } + } +} diff --git a/ios/App/App/MainViewController.swift b/ios/App/App/MainViewController.swift index 257642b..aca0510 100644 --- a/ios/App/App/MainViewController.swift +++ b/ios/App/App/MainViewController.swift @@ -15,6 +15,9 @@ class MainViewController: CAPBridgeViewController { // Plugin natif route GPS HealthKit — récupère la trace des workouts // Apple Watch (HKWorkoutRouteQuery, cf. CoachHealthRoute.swift). bridge?.registerPluginInstance(CoachHealthRoutePlugin()) + // Plugin natif observer HealthKit — détecte fin de séance Watch et + // push notif locale "🎉 Séance terminée" (cf. CoachWorkoutObserver.swift). + bridge?.registerPluginInstance(CoachWorkoutObserverPlugin()) } override func viewDidLoad() {