Files
coach-ios/ios/App/App/CoachWorkoutObserver.swift
Sylvain Bettinelli 31467851d9 Séances perdues en silence, et succès journalisés sur des requêtes refusées
Trois défauts du même chemin, tous vérifiés ligne à ligne avant correction.

1. Toutes les séances d'un lot sauf une étaient perdues DÉFINITIVEMENT.
L'ancre est sauvegardée ligne 298, puis seul `latest` était traité ligne 319 —
or scheduleNotification était le seul appelant de reportToRoutineIfStrength et
reportPlanOrigin. Renfo le matin, vélo le soir, iPhone resté à la maison : au
premier fire la requête ancrée rend les deux, le vélo est notifié, le renfo
n'est jamais POSTé, et l'ancre ayant avancé il ne reviendra jamais. Le log
disait « 2 new workout(s), notifying latest » : exact, et parfaitement muet sur
ce qu'il venait de jeter. Les rapports serveur bouclent désormais sur tout le
lot ; la notification reste unique, un « 🎉 » par séance serait du bruit.

2. Le statut HTTP n'était jamais lu. Les deux POST faisaient
`{ data, _, error in }` et ne regardaient que `error`. Un 401 sur token rotaté,
un 404 ou un 500 n'en produisent pas : reportPlanOrigin journalisait donc
« plan-origin envoyé » sur une requête refusée, et auto-complete sortait en
silence au guard de parsing. Un helper `envoyer(_:libelle:traiter:)` vérifie
maintenant (200..<300) et journalise le code — le pattern existait déjà dans
CoachRoutineBridge.postDay, à deux fichiers de là.

3. Une séance de musculation classique était étiquetée fonctionnelle.
L'activité était écrite en dur alors que le garde accepte aussi
traditionalStrengthTraining. Si la règle de correspondance côté serveur
discrimine sur ce champ, l'appariement était faussé. L'UUID et l'heure de début
sont ajoutés au passage : sans eux le serveur ne peut pas dédupliquer et doit
évaluer sa fenêtre de 12 h depuis l'heure de réception — une séance remontée
avec six heures de retard était datée de son arrivée.

⚠️ PAS corrigé, et assumé : le `defer { completion() }` dit à iOS que la
livraison est traitée alors que les requêtes sont encore en vol, sans
beginBackgroundTask. Le faire proprement demande un import UIKit, une bascule
sur le fil principal et un essai sur appareil — à traiter à part plutôt qu'en
marge. Le statut HTTP étant désormais lu, un échec laissera au moins une trace.

Relevé § 2.1, § 2.2 et § 3.2 de docs/AUDIT-2026-09-22.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:20:43 +00:00

565 lines
26 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
// ⚠️ Les rapports serveur partent pour TOUTES les séances du lot.
// Ils n'étaient envoyés que pour `latest`, alors que l'ancre venait
// d'être avancée quelques lignes plus haut : les autres séances du
// lot n'étaient donc jamais rapportées, et ne reviendraient jamais.
//
// Le cas se produit dès que l'iPhone a manqué un réveil : renfo le
// matin, vélo le soir, téléphone resté à la maison — au premier
// fire, la requête ancrée rend les deux, le vélo est notifié, et la
// routine du matin n'est jamais cochée. Le log disait « 2 new
// workout(s), notifying latest » : exact, et parfaitement muet sur
// ce qu'il venait de jeter.
for seance in workouts {
self.reportToRoutineIfStrength(seance)
self.reportPlanOrigin(seance)
}
// Une seule notification en revanche : un « 🎉 » par séance d'un
// même lot serait du bruit. On garde la plus récente.
let latest = workouts.max(by: { $0.endDate < $1.endDate })!
let uuid = latest.uuid.uuidString
UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey)
NSLog("[CoachWorkoutObserver] %d nouvelle(s) séance(s) rapportée(s), notification pour %@",
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)
}
}
/// Envoie une requête et **lit le statut HTTP**.
///
/// ⚠️ Les deux POST de ce fichier jetaient la réponse (`{ data, _, error in }`)
/// et ne regardaient que `error`. Or un 401 sur token rotaté, un 404 ou un
/// 500 ne produisent PAS d'`error` : `reportPlanOrigin` journalisait donc
/// « plan-origin envoyé » sur une requête refusée, et `auto-complete`
/// sortait en silence au `guard` de parsing. Le bon pattern existait déjà
/// deux fichiers plus loin — `CoachRoutineBridge.postDay` vérifie
/// `(200..<300)` et journalise le code.
///
/// `traiter` ne reçoit le corps que sur un statut de succès.
private func envoyer(_ req: URLRequest, libelle: String,
traiter: ((Data) -> Void)? = nil) {
URLSession.shared.dataTask(with: req) { data, reponse, error in
if let error = error {
NSLog("[CoachWorkoutObserver] %@ : %@", libelle, error.localizedDescription)
return
}
guard let http = reponse as? HTTPURLResponse else {
NSLog("[CoachWorkoutObserver] %@ : réponse sans statut HTTP", libelle)
return
}
guard (200..<300).contains(http.statusCode) else {
NSLog("[CoachWorkoutObserver] %@ : HTTP %d — non pris en compte",
libelle, http.statusCode)
return
}
traiter?(data ?? Data())
}.resume()
}
/// 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")
// ⚠️ L'activité était écrite en dur à « functionalStrengthTraining »
// alors que le garde ci-dessus accepte AUSSI traditionalStrengthTraining :
// une séance de musculation classique arrivait au serveur étiquetée
// fonctionnelle. Si la règle de correspondance discrimine sur ce champ
// — et le commentaire ci-dessus dit que les règles vivent là-bas —,
// l'appariement était faussé.
//
// L'UUID et l'heure de début manquaient aussi : sans eux le serveur ne
// peut pas dédupliquer, et il doit évaluer sa fenêtre de 12 h depuis
// l'heure de RÉCEPTION. Une séance remontée avec six heures de retard
// (iPhone resté à la maison) était donc datée de son arrivée.
req.httpBody = try? JSONSerialization.data(withJSONObject: [
"activity": activity == .traditionalStrengthTraining
? "traditionalStrengthTraining" : "functionalStrengthTraining",
"duration_min": workout.duration / 60,
"uuid": workout.uuid.uuidString,
"start": ISO8601DateFormatter().string(from: workout.startDate),
])
req.timeoutInterval = 15
envoyer(req, libelle: "auto-complete routine") { data in
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
NSLog("[CoachWorkoutObserver] auto-complete : réponse illisible")
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) ?? "?")
}
}
}
/// 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
// Le log de succès ne part plus que sur un 2xx : il s'écrivait
// auparavant sur un 401 ou un 500, puisque seul `error` était lu.
self.envoyer(req, libelle: "plan-origin") { _ in
NSLog("[CoachWorkoutObserver] plan-origin accepté (from_plan=%@)",
fromPlan ? "true" : "false")
}
}
#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"
}
}
}