Deux épisodes de « la montre ne dit plus rien » ont buté sur la même absence : rien, côté app, ne distinguait « iOS a accepté l'appel » de « la séance est au poignet ». Détail du dossier et chronologie dans coach_sportif, docs/GUIDE-MONTRE.md §5quater et §5quinquies. CoachWorkoutKit : - requestAuthorization() n'avale plus son échec. Il était consigné dans un NSLog et l'envoi continuait : une réinstallation de l'app remet l'autorisation à notDetermined — celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre du silence — et l'écran affichait « Envoyé » alors que rien ne partait. - authorizationState est lu et rendu au JS, par isAvailable() comme par la nouvelle méthode scheduledWorkouts(). isAvailable() ne répondait jusqu'ici qu'« iOS 17+ » : une autorisation révoquée rendait la même réponse. - schedule() est suivi d'une relecture de WorkoutScheduler.scheduledWorkouts, et sendInterval rend ce qu'elle liste (nom, date, blocs, itérations). Une liste vide n'est PAS traitée comme un échec : le scheduler publie de façon asynchrone et Apple ne garantit aucun délai — inventer une erreur à chaque envoi coûterait plus cher que le silence qu'on cherche. CoachWorkoutObserver : à chaque fin de séance, HKWorkout.workoutPlan dit si elle vient d'une séance programmée. Le fait part vers /api/workout/plan-origin, sur le modèle de reportToRoutineIfStrength — faits bruts ici, interprétation côté serveur, donc ajustable sans rebuild. Une lecture en échec n'envoie rien plutôt qu'un faux « lancée à la main » : côté serveur, l'absence s'affiche « inconnu ». ⚠️ Rien de tout cela ne se compile ici : WorkoutKit et HealthKit sont absents de Swift pour Linux, et tests-linux ne couvre que ce qui n'importe que Foundation. À builder sur le Mac mini. Les signatures ont été vérifiées une par une dans la doc DocC — authorizationState (get async, 4 cas), scheduledWorkouts, ScheduledWorkoutPlan.date en DateComponents, WorkoutPlan.workout, CustomWorkout.displayName optionnel, HKWorkout.workoutPlan (get async throws). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
500 lines
22 KiB
Swift
500 lines
22 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.
|
|
import WorkoutKit
|
|
|
|
@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) {
|
|
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()
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
}
|