Files
coach-ios/ios/App/App/CoachWorkoutObserver.swift
Sylvain Bettinelli 3d939b8c3c Le build de ce soir tient sur une page, et l'import ne peut plus surprendre
Sylvain build ce soir en rentrant : docs/watch-plan-origin-runbook-mac.md porte
les gestes — les deux fichiers touchés sont déjà dans la target App, donc aucun
Target Membership ni framework à ajouter, et WorkoutKit était déjà lié par
CoachWorkoutKit.swift. Le runbook dit aussi quoi vérifier sans courir (le
message d'envoi doit NOMMER la séance programmée, ou dire l'autorisation
manquante) et comment lire l'origine de la séance après coup.

`import WorkoutKit` passe sous `#if canImport`, comme dans CoachWorkoutKit, et
reportPlanOrigin avec lui : ce code n'ayant jamais vu de compilateur, autant que
l'absence du framework le rende inerte plutôt qu'incompilable. Les trois erreurs
les plus probables sont listées avec leur cause dans le runbook, pour ne pas
avoir à rouvrir la doc DocC devant Xcode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 07:16:50 +00:00

506 lines
23 KiB
Swift

// 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
// Pour l'extension `HKWorkout.workoutPlan` (iOS 17+), qui rend la composition
// du plan dont une séance est issue — ou nil si elle a été lancée à la main.
// Même garde que `CoachWorkoutKit.swift` : sur un SDK sans WorkoutKit, le
// fichier doit continuer à compiler, quitte à ne rien remonter.
#if canImport(WorkoutKit)
import WorkoutKit
#endif
@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),
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "testNotification", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "resetAnchor", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "requestAllHealthPermissions", returnType: CAPPluginReturnPromise),
]
// Clés UserDefaults pour debug visible côté JS via getStatus()
private let kFireCount = "CoachWorkoutObserver.fireCount"
private let kLastFireAt = "CoachWorkoutObserver.lastFireAt"
private let kLastNotifAt = "CoachWorkoutObserver.lastNotifAt"
private let kLastError = "CoachWorkoutObserver.lastError"
private let kBgDeliveryEnabled = "CoachWorkoutObserver.bgDeliveryEnabled"
private let kObserverActive = "CoachWorkoutObserver.observerActive"
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"
// Clé UserDefaults pour persister le HKQueryAnchor entre runs.
// HKAnchoredObjectQuery est l'API Apple-recommandée pour ne récupérer
// QUE les nouveaux samples ajoutés depuis le dernier check, plutôt
// qu'un predicate temporel qui peut louper des workouts.
private let kAnchor = "CoachWorkoutObserver.anchorData"
@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
}
UserDefaults.standard.set(false, forKey: kObserverActive)
let workoutType = HKObjectType.workoutType()
healthStore.disableBackgroundDelivery(for: workoutType) { _, _ in }
call.resolve(["observing": false])
}
// Debug — expose les compteurs persistés pour visualiser ce que fait
// l'observer sans Console.app. Appelé depuis page /settings ou /debug.
@objc func getStatus(_ call: CAPPluginCall) {
let d = UserDefaults.standard
call.resolve([
"observerActive": d.bool(forKey: kObserverActive),
"bgDeliveryEnabled": d.bool(forKey: kBgDeliveryEnabled),
"fireCount": d.integer(forKey: kFireCount),
"lastFireAt": d.string(forKey: kLastFireAt) ?? "",
"lastNotifAt": d.string(forKey: kLastNotifAt) ?? "",
"lastWorkoutUUID": d.string(forKey: lastNotifiedKey) ?? "",
"lastError": d.string(forKey: kLastError) ?? "",
"healthDataAvailable": HKHealthStore.isHealthDataAvailable(),
])
}
// Bypass complet de @capgo/capacitor-health pour requestAuthorization.
// Le plugin tiers semble pendre indéfiniment sur iOS 26 (Promise jamais
// résolue, confirmé empiriquement 2026-05-18 avec timeout 8s même
// avec 1 seul type 'steps'). On appelle directement HKHealthStore
// depuis notre plugin custom qui fonctionne (CoachWorkoutObserver
// a fireCount=10 = preuve que HealthKit native marche pour cette app).
@objc func requestAllHealthPermissions(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else {
call.reject("HealthKit not available on this device")
return
}
// Types lus par coach.hypnotruck.ch — alignés sur READ_SCOPES JS
var readTypes = Set<HKObjectType>()
let quantityIds: [HKQuantityTypeIdentifier] = [
.stepCount,
.heartRate,
.restingHeartRate,
.heartRateVariabilitySDNN,
.bodyMass,
.height,
.bodyFatPercentage,
.oxygenSaturation,
.distanceWalkingRunning,
.distanceCycling,
.activeEnergyBurned,
.basalEnergyBurned,
.appleExerciseTime,
.flightsClimbed,
]
for id in quantityIds {
if let t = HKObjectType.quantityType(forIdentifier: id) {
readTypes.insert(t)
}
}
let categoryIds: [HKCategoryTypeIdentifier] = [
.sleepAnalysis,
]
for id in categoryIds {
if let t = HKObjectType.categoryType(forIdentifier: id) {
readTypes.insert(t)
}
}
readTypes.insert(HKObjectType.workoutType())
let totalTypes = readTypes.count
NSLog("[CoachWorkoutObserver] requestAllHealthPermissions: requesting %d types", totalTypes)
healthStore.requestAuthorization(toShare: nil, read: readTypes) { success, error in
DispatchQueue.main.async {
if let error = error {
NSLog("[CoachWorkoutObserver] requestAuthorization error: %@", error.localizedDescription)
call.reject("requestAuthorization failed: \(error.localizedDescription)")
} else {
NSLog("[CoachWorkoutObserver] requestAuthorization OK success=%@", success ? "true" : "false")
call.resolve([
"success": success,
"typesRequested": totalTypes,
])
}
}
}
}
// Debug — reset l'anchor stocké. Au prochain observer fire, on
// récupèrera TOUS les workouts (initial sync). Utile pour tester
// le pipeline avec un workout pré-existant.
@objc func resetAnchor(_ call: CAPPluginCall) {
UserDefaults.standard.removeObject(forKey: kAnchor)
UserDefaults.standard.removeObject(forKey: lastNotifiedKey)
call.resolve(["reset": true])
}
// Debug — force une notif locale immédiate pour valider que le pipeline
// notif Apple fonctionne, indépendamment de l'observer HealthKit.
@objc func testNotification(_ call: CAPPluginCall) {
let content = UNMutableNotificationContent()
content.title = "🧪 Test CoachWorkoutObserver"
content.body = "Si tu vois ça, le pipeline notif natif fonctionne."
content.sound = .default
if #available(iOS 15.0, *) {
content.interruptionLevel = .timeSensitive
}
let req = UNNotificationRequest(
identifier: "coach_wo_test_\(Int(Date().timeIntervalSince1970))",
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(req) { error in
DispatchQueue.main.async {
if let error = error {
call.reject("Test notif failed: \(error.localizedDescription)")
} else {
call.resolve(["scheduled": true])
}
}
}
}
// 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) { [weak self] ok, error in
if let error = error {
UserDefaults.standard.set("bg delivery: \(error.localizedDescription)", forKey: self?.kLastError ?? "")
cont.resume(throwing: error)
} else {
UserDefaults.standard.set(ok, forKey: self?.kBgDeliveryEnabled ?? "")
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
guard let self = self else { completion(); return }
if let error = error {
UserDefaults.standard.set("observer: \(error.localizedDescription)", forKey: self.kLastError)
NSLog("[CoachWorkoutObserver] query error: %@", error.localizedDescription)
completion()
return
}
// Persiste compteur + timestamp pour debug visible via getStatus
let d = UserDefaults.standard
d.set(d.integer(forKey: self.kFireCount) + 1, forKey: self.kFireCount)
d.set(ISO8601DateFormatter().string(from: Date()), forKey: self.kLastFireAt)
NSLog("[CoachWorkoutObserver] observer fired")
self.fetchLatestWorkout {
completion() // signaler iOS qu'on a fini de traiter (mandatory)
}
}
healthStore.execute(query)
observerQuery = query
UserDefaults.standard.set(true, forKey: kObserverActive)
NSLog("[CoachWorkoutObserver] observer query started")
}
// MARK: - Fetch + notif
private func fetchLatestWorkout(completion: @escaping () -> Void) {
let workoutType = HKObjectType.workoutType()
let savedAnchor = loadAnchor()
let query = HKAnchoredObjectQuery(
type: workoutType,
predicate: nil,
anchor: savedAnchor,
limit: HKObjectQueryNoLimit
) { [weak self] _, samples, _, newAnchor, error in
defer { completion() }
guard let self = self else { return }
if let error = error {
UserDefaults.standard.set("anchored: \(error.localizedDescription)", forKey: self.kLastError)
NSLog("[CoachWorkoutObserver] anchored query error: %@", error.localizedDescription)
return
}
// Persiste le nouvel anchor pour le prochain call (clé du
// mécanisme : seuls les samples ajoutés APRÈS cet anchor
// seront retournés au prochain query).
if let newAnchor = newAnchor {
self.saveAnchor(newAnchor)
}
let workouts = (samples as? [HKWorkout]) ?? []
// Au 1er fire après installation (anchor était nil), on a pu
// récupérer un GROS historique de workouts iCloud. On
// enregistre juste l'anchor sans notifier pour éviter de
// spammer l'user avec des séances de l'année dernière.
if savedAnchor == nil {
NSLog("[CoachWorkoutObserver] initial sync — anchor saved, skipped %d historical workouts", workouts.count)
return
}
guard !workouts.isEmpty else {
NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts")
return
}
// Notifier le plus récent (par endDate) — généralement il y
// en a 1 mais Apple peut grouper plusieurs samples.
let latest = workouts.max(by: { $0.endDate < $1.endDate })!
let uuid = latest.uuid.uuidString
UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey)
NSLog("[CoachWorkoutObserver] %d new workout(s), notifying latest %@", workouts.count, uuid)
self.scheduleNotification(for: latest)
}
healthStore.execute(query)
}
// MARK: - Anchor persistence (NSKeyedArchiver pour HKQueryAnchor)
private func loadAnchor() -> HKQueryAnchor? {
guard let data = UserDefaults.standard.data(forKey: kAnchor) else { return nil }
do {
return try NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
} catch {
NSLog("[CoachWorkoutObserver] anchor unarchive failed: %@", error.localizedDescription)
return nil
}
}
private func saveAnchor(_ anchor: HKQueryAnchor) {
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
UserDefaults.standard.set(data, forKey: kAnchor)
} catch {
NSLog("[CoachWorkoutObserver] anchor archive failed: %@", error.localizedDescription)
}
}
/// Remonte au serveur la fin d'une séance de renfo, qui décide seul si elle
/// correspond à une routine poussée dans l'app Exercice.
///
/// Volontairement bête : on n'envoie que des faits bruts (type, durée). Les
/// règles de correspondance — fenêtre de 12 h depuis l'envoi explicite,
/// tolérance de durée — vivent dans `/api/routine/auto-complete`, donc
/// ajustables sans rebuild iOS.
private func reportToRoutineIfStrength(_ workout: HKWorkout) {
let activity = workout.workoutActivityType
guard activity == .functionalStrengthTraining
|| activity == .traditionalStrengthTraining else { return }
guard let url = URL(string: "https://coach.hypnotruck.ch/api/routine/auto-complete") else { return }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
req.httpBody = try? JSONSerialization.data(withJSONObject: [
"activity": "functionalStrengthTraining",
"duration_min": workout.duration / 60,
])
req.timeoutInterval = 15
URLSession.shared.dataTask(with: req) { data, _, error in
if let error = error {
NSLog("[CoachWorkoutObserver] auto-complete routine: %@", error.localizedDescription)
return
}
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
if (json["matched"] as? Bool) == true {
let n = (json["done"] as? [String])?.count ?? 0
NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)",
(json["moment"] as? String) ?? "?", n)
} else {
NSLog("[CoachWorkoutObserver] pas une routine (%@)", (json["reason"] as? String) ?? "?")
}
}.resume()
}
/// Dit au serveur si la séance vient d'une **séance programmée** ou d'un
/// démarrage à la main dans l'app Exercice.
///
/// C'est la seule chose qui distingue « la montre s'est tue alors qu'elle
/// devait parler » de « la séance n'a jamais été celle qu'on avait
/// poussée » : `tools/watch_alert_check.py` compte les alertes attendues à
/// partir du plan ENVOYÉ, mais rien, jusqu'ici, ne disait ce qui avait été
/// LANCÉ (cf. `coach_sportif/docs/GUIDE-MONTRE.md` §5bis, « angle mort »).
/// Le nom HealthKit ne le dit pas : il vaut « Course extérieure » dans les
/// deux cas.
///
/// Même parti pris que `reportToRoutineIfStrength` : on n'envoie que des
/// faits bruts, l'interprétation vit côté serveur donc sans rebuild iOS.
private func reportPlanOrigin(_ workout: HKWorkout) {
#if canImport(WorkoutKit)
guard #available(iOS 17.0, *) else { return }
Task {
var fromPlan = false
var planName = ""
do {
if let plan = try await workout.workoutPlan {
fromPlan = true
if case let .custom(custom) = plan.workout {
planName = custom.displayName ?? ""
}
}
} catch {
// Une lecture qui échoue n'est PAS un « lancé à la main » :
// sans réponse, on n'envoie rien plutôt qu'un faux négatif.
NSLog("[CoachWorkoutObserver] workoutPlan illisible: %@", error.localizedDescription)
return
}
guard let url = URL(string: "https://coach.hypnotruck.ch/api/workout/plan-origin") else { return }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
req.httpBody = try? JSONSerialization.data(withJSONObject: [
"uuid": workout.uuid.uuidString,
"start": ISO8601DateFormatter().string(from: workout.startDate),
"duration_min": workout.duration / 60,
"activity": Int(workout.workoutActivityType.rawValue),
"from_plan": fromPlan,
"plan_name": planName,
])
req.timeoutInterval = 15
URLSession.shared.dataTask(with: req) { _, _, error in
if let error = error {
NSLog("[CoachWorkoutObserver] plan-origin: %@", error.localizedDescription)
} else {
NSLog("[CoachWorkoutObserver] plan-origin envoyé (from_plan=%@)", fromPlan ? "true" : "false")
}
}.resume()
}
#endif
}
private func scheduleNotification(for workout: HKWorkout) {
reportToRoutineIfStrength(workout)
reportPlanOrigin(workout)
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
// .timeSensitive : force iOS à afficher la notif même en Focus modéré
// et en foreground app. Sans ça la notif peut être suppress
// silencieusement.
if #available(iOS 15.0, *) {
content.interruptionLevel = .timeSensitive
}
let request = UNNotificationRequest(
identifier: "coach_workout_done_\(workout.uuid.uuidString)",
content: content,
trigger: nil // déclenchement immédiat
)
UNUserNotificationCenter.current().add(request) { [weak self] error in
guard let self = self else { return }
if let error = error {
UserDefaults.standard.set("notif: \(error.localizedDescription)", forKey: self.kLastError)
NSLog("[CoachWorkoutObserver] notif scheduling failed: %@", error.localizedDescription)
} else {
UserDefaults.standard.set(ISO8601DateFormatter().string(from: Date()), forKey: self.kLastNotifAt)
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"
}
}
}