feat(workout-observer): notif locale 'Séance terminée' via HKObserverQuery
Backlog #3-C — Détecte la fin d'une séance Apple Watch (ajout d'un nouveau HKWorkout au HealthStore) et push une notif locale enrichie '🎉 Séance {sport} terminée — {min}min · {kcal}kcal · {km}km'. Nouveau plugin natif CoachWorkoutObserver.swift : - requestAuthorization (lecture HKWorkout) — idempotent, ne re-prompt pas si déjà autorisé via @capgo/capacitor-health. - enableBackgroundDelivery(.immediate) sur HKWorkoutType — iOS réveille l'app dès qu'un nouveau workout est ajouté. - HKObserverQuery callback → fetchLatestWorkout (filtre 1h) → schedule UNNotificationRequest immédiat. - Dédup via UserDefaults (lastNotifiedUUID) pour éviter doublons quand iOS rappelle l'observer plusieurs fois pour le même workout. - Workaround Swift 15.0 deployment target : withCheckedThrowingContinuation pour wrapper les APIs callback-only (requestAuthorization / enableBackgroundDelivery existent en async/await mais iOS 15.4+ only). Signatures Apple vérifiées via JSON DocC : - requestAuthorization(toShare:read:completion:) ✓ - enableBackgroundDelivery(for:frequency:withCompletion:) ✓ - HKObserverQuery(sampleType:predicate:updateHandler:) ✓ - HKSampleSortIdentifierEndDate (global String) ✓ Bump version requis côté Mac avant rebuild TestFlight. ⚠️ Capability Xcode requise lors du rebuild Mac : Signing & Capabilities → HealthKit → cocher 'Background Delivery'. Sans ça, iOS ne réveille pas l'app quand un workout se termine.
This commit is contained in:
212
ios/App/App/CoachWorkoutObserver.swift
Normal file
212
ios/App/App/CoachWorkoutObserver.swift
Normal file
@@ -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<Void, Error>) 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<Void, Error>) 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user