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:
Sylvain Bettinelli
2026-05-18 10:56:20 +00:00
parent 934dc82aa6
commit 33ed74ca63
2 changed files with 215 additions and 0 deletions

View 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"
}
}
}

View File

@@ -15,6 +15,9 @@ class MainViewController: CAPBridgeViewController {
// Plugin natif route GPS HealthKit récupère la trace des workouts // Plugin natif route GPS HealthKit récupère la trace des workouts
// Apple Watch (HKWorkoutRouteQuery, cf. CoachHealthRoute.swift). // Apple Watch (HKWorkoutRouteQuery, cf. CoachHealthRoute.swift).
bridge?.registerPluginInstance(CoachHealthRoutePlugin()) 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() { override func viewDidLoad() {