Files
coach-ios/ios/App/CoachWatch/WorkoutManager.swift
Sylvain Bettinelli 3b5b61268c Les quatre constats restants, corrigés — dont deux sur le chemin qu'il fallait éviter
Sur demande explicite de Sylvain (« pour le reste vas-y »), après le § 1.2 buildé
et vérifié le matin. Les cinq constats de l'audit sont désormais traités.

⚠️ § 2.3 et § 2.4 touchent `WorkoutManager.swift` — la fin de séance du live
workout, que la consigne du 22/09 demandait de ne pas approcher. Fait en ne
changeant AUCUNE logique : les `try?` deviennent des `do/catch` qui journalisent,
et le chemin d'échec gagne la clôture qui lui manquait. La récupération par
`recentlySavedWorkout()` est intacte.

§ 1.3 — `CoachSleep` teste `authorizationStatus` : `.notDetermined` ne se
confond plus avec une nuit sans sommeil, et le serveur cessera d'enregistrer des
nuits vides sur lesquelles `sleep_score.py` calculait une régularité. Un refus
EXPLICITE reste indiscernable d'une absence de données — HealthKit ne le révèle
pas en lecture, par conception. L'en-tête du fichier dit maintenant ce que la
promesse couvre au lieu de promettre plus.

§ 2.3 — le `else` final distinguait deux situations opposées qui y tombaient
ensemble : montre verrouillée (la séance EST écrite) et `endCollection` qui a
levé (rien n'est écrit). Seule la seconde est une perte, et elle seule renseigne
`statusMessage` — annoncer une perte sur une montre verrouillée serait une
fausse alerte.

§ 2.4 — `stopTracking()` d'abord : c'est le seul geste qui réussit à coup sûr et
le seul dont l'omission se paie en batterie pendant des heures. Puis une
tentative de sauvegarde en best-effort, avec `recentlySavedWorkout()` en second
recours. `builder = nil` n'intervient plus qu'après les tentatives — il
détruisait jusqu'ici la seule référence, rendant la perte définitive dans
l'instruction qui l'ignorait.

§ 2.6 — `NSLock` sur le tampon GPS, comme aux quatre autres endroits du même
fichier. ⚠️ Invérifiable sans son déclencheur : un workout à plusieurs
HKWorkoutRoute. Sa correction ne se constatera pas plus que le défaut ne se
manifestait.

⛔ NON COMPILÉS. Le test qui comptera pour § 2.3 et § 2.4 n'est pas un log :
c'est une séance réelle menée jusqu'au bout, puis retrouvée dans Santé et dans
coach. Tant qu'elle n'a pas eu lieu, ce sont des intentions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 08:27:17 +00:00

531 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.
import Foundation
import HealthKit
import os
private let workoutLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "workout")
/// Pilote la séance via HKLiveWorkoutBuilder et pousse les metrics vers l'iPhone.
/// `@MainActor` (donc Sendable) : l'état observable vit sur le main ; les callbacks
/// HealthKit, livrés hors-main, sont `nonisolated` et hoppent vers le main.
@MainActor
final class WorkoutManager: NSObject, ObservableObject {
static let shared = WorkoutManager()
@Published private(set) var heartRate = 0.0
@Published private(set) var activeEnergyKcal = 0.0
@Published private(set) var distanceMeters = 0.0
@Published private(set) var speedKmh = 0.0
@Published private(set) var elapsedSec = 0.0
@Published private(set) var isRunning = false
@Published private(set) var isPaused = false
@Published private(set) var statusMessage: String?
private let healthStore = HKHealthStore()
private var session: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
private var lastSentAt = Date.distantPast
// Throttle léger anti-saturation BLE pour les metrics à haute fréquence
// (calories/distance). La FC, elle, est poussée immédiatement à chaque
// nouvel échantillon — comme l'app Exercice native d'Apple.
private let minSendInterval: TimeInterval = 0.5
private var simTask: Task<Void, Never>?
/// Date du dernier échantillon de FC **réellement mesuré**. Posée nulle
/// part ailleurs que là où HealthKit nous donne une valeur.
private var heartRateAt: Date?
/// Au-delà de ce délai sans nouvel échantillon, on cesse d'afficher et
/// d'émettre la dernière FC connue.
///
/// ⚠️ Cette valeur est un **choix d'ingénierie, pas une donnée
/// physiologique** : Apple ne publie pas de cadence garantie pour
/// l'échantillonnage de la FC en séance. Elle est posée au-dessus du
/// `lostThreshold` de 8 s de `LiveStore` côté iPhone, pour que la FC ne se
/// périme jamais *avant* que la connexion elle-même soit déclarée perdue —
/// deux signaux contradictoires seraient illisibles au poignet.
///
/// Si « — » clignote en usage normal, c'est cette valeur qu'il faut
/// augmenter, pas la péremption qu'il faut retirer.
///
/// ⚠️ Limite assumée : la péremption est évaluée dans `ingest`, donc elle
/// suppose qu'un échantillon — d'un type quelconque — continue d'arriver.
/// C'est le cas du scénario visé (le capteur FC décroche pendant que
/// calories et distance continuent). Si plus RIEN n'arrive, l'écran entier
/// se fige, chronomètre compris, ce qui se voit ; un timer dédié coûterait
/// de la batterie pour couvrir un cas déjà visible autrement.
private let heartRateStaleAfter: TimeInterval = 10
/// La FC connue si elle est encore fraîche, 0 sinon.
///
/// 0 est la convention « inconnu » partagée par toute la chaîne :
/// `HeartRateMetric` n'affiche alors plus de zone, et `heartRateText` côté
/// iPhone rend « — ».
private var freshHeartRate: Double {
guard let at = heartRateAt,
Date().timeIntervalSince(at) <= heartRateStaleAfter else { return 0 }
return heartRate
}
private override init() { super.init() }
/// Remet les mesures à « inconnu » avant une nouvelle séance.
///
/// `WorkoutManager` est un singleton : sans ça, les valeurs de la séance
/// précédente restaient affichées jusqu'au premier échantillon de la
/// nouvelle — une FC d'hier présentée comme celle de maintenant.
private func resetMetrics() {
heartRate = 0
heartRateAt = nil
activeEnergyKcal = 0
distanceMeters = 0
speedKmh = 0
elapsedSec = 0
lastSentAt = .distantPast
}
func startWorkout(activityType: HKWorkoutActivityType,
locationType: HKWorkoutSessionLocationType) async {
guard !isRunning, session == nil else { return }
guard HKHealthStore.isHealthDataAvailable() else {
statusMessage = "HealthKit indisponible sur cet appareil"
return
}
resetMetrics()
let distanceType = Self.distanceType(for: activityType)
let hr = HKQuantityType(.heartRate)
let energy = HKQuantityType(.activeEnergyBurned)
// Vitesse instantanée mesurée par la montre. HKLiveWorkoutDataSource la
// collecte d'office (course extérieure sur Series 6+, vélo sur
// watchOS 11+), mais la lecture doit être autorisée. Sur un appareil qui
// ne la produit pas, les stats restent vides : `ingest` retombe alors
// sur la vitesse moyenne dérivée de la distance.
let speedType = Self.speedType(for: activityType)
// La trace exige d'être demandée en lecture ET en écriture — Apple :
// « you must request permission to read and share both HKWorkout and
// HKWorkoutRoute samples ». Sans elle, `finishRoute` échoue et la
// sortie n'a pas de parcours.
let route = HKSeriesType.workoutRoute()
let outdoor = (locationType == .outdoor)
var read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
var share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
if outdoor {
read.insert(route)
share.insert(route)
}
do {
try await healthStore.requestAuthorization(toShare: share, read: read)
let woStatus = healthStore.authorizationStatus(for: HKQuantityType.workoutType())
workoutLog.info("requestAuthorization OK; workoutType share status=\(woStatus.rawValue)")
if woStatus == .sharingDenied {
statusMessage = "Accès Santé refusé. Active Coach dans Réglages → Santé → Accès aux données, puis relance."
return
}
} catch {
workoutLog.error("requestAuthorization threw: \(error.localizedDescription)")
statusMessage = "Autorisation Santé refusée : \(error.localizedDescription)"
return
}
// Demandée ici, donc au premier plan : watchOS n'affiche pas la feuille
// d'autorisation depuis l'arrière-plan. Volontairement « quand
// l'app est active » et jamais « toujours » — sur watchOS ce dernier
// mène à un parcours d'autorisation qui boucle.
if outdoor { LocationTracker.shared.requestAuthorization() }
let config = HKWorkoutConfiguration()
config.activityType = activityType
config.locationType = locationType
do {
let session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
workoutConfiguration: config)
session.delegate = self
builder.delegate = self
self.session = session
self.builder = builder
let start = Date()
session.startActivity(with: start)
workoutLog.info("startActivity called, beginning collection…")
try await builder.beginCollection(at: start)
isRunning = true
statusMessage = nil
workoutLog.info("beginCollection OK, isRunning=true")
// GPS uniquement en extérieur : en salle il ne ferait que vider la
// batterie pour une trace immobile.
if outdoor {
// Apple déconseille d'instancier un HKWorkoutRouteBuilder
// directement : celui du builder de séance associe la trace.
let routeBuilder = builder.seriesBuilder(for: HKSeriesType.workoutRoute())
as? HKWorkoutRouteBuilder
if routeBuilder == nil {
workoutLog.error("seriesBuilder n'a pas rendu un HKWorkoutRouteBuilder")
}
LocationTracker.shared.start(activity: activityType, routeBuilder: routeBuilder)
}
} catch {
self.session = nil
self.builder = nil
let ns = error as NSError
workoutLog.error("start failed domain=\(ns.domain) code=\(ns.code): \(error.localizedDescription)")
statusMessage = "Démarrage séance impossible : \(error.localizedDescription)"
}
}
/// Pause / reprise. Déclenchées depuis la montre OU depuis les boutons de la
/// Live Activity de l'iPhone (via `ConnectivityManager.handleCommand`).
/// Avec une vraie session, `isPaused` n'est pas positionné ici : il suit le
/// changement d'état HealthKit, seule source fiable (la montre peut aussi
/// mettre en pause d'elle-même).
func pause() {
guard simTask == nil else { setSimPaused(true); return }
session?.pause()
}
func resume() {
guard simTask == nil else { setSimPaused(false); return }
session?.resume()
}
/// Le mode test ne passe pas par HKWorkoutSession : il n'émet aucun
/// changement d'état, l'iPhone doit donc être prévenu ici.
private func setSimPaused(_ paused: Bool) {
isPaused = paused
ConnectivityManager.shared.sendPaused(paused)
}
func end() {
if let simTask {
simTask.cancel()
self.simTask = nil
isRunning = false
statusMessage = nil
// Le mode test ne passe pas par HKWorkoutSession : c'est ici le
// seul endroit où l'iPhone peut apprendre que le flux s'arrête.
ConnectivityManager.shared.sendEnded(reason: "sim")
} else {
session?.end()
}
}
/// Mode test : FC simulée, sans HealthKit, pour valider le flux watch -> iPhone -> /live.
func startSimulation() {
guard !isRunning else { return }
resetMetrics()
isRunning = true
statusMessage = "Mode test — FC simulée"
let startedAt = Date()
simTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let elapsed = Date().timeIntervalSince(startedAt)
let hr = 110 + 40 * abs(sin(elapsed / 30))
let kcal = elapsed / 6
let dist = elapsed * 2.6
self.heartRate = hr
self.heartRateAt = Date() // sinon la FC simulée se périme
self.activeEnergyKcal = kcal
self.distanceMeters = dist
self.elapsedSec = elapsed
self.speedKmh = 2.6 * 3.6 // la simulation avance à 2,6 m/s
ConnectivityManager.shared.sendSample([
"heartRate": hr,
"activeEnergyKcal": kcal,
"distanceMeters": dist,
"speedKmh": self.speedKmh,
"elapsedSec": elapsed,
"ts": Date().timeIntervalSince1970,
"sim": true
])
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
}
/// Appelé sur le main depuis le callback de collecte (hors-main).
private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?,
elapsed: TimeInterval) {
// ⚠️ `didCollectDataOf` se déclenche dès qu'UN type est collecté. Quand
// le capteur optique décroche — bracelet desserré, froid, arythmie —
// les calories continuent d'arriver et `hr` vaut nil. Sans la date
// ci-dessous, la dernière FC connue restait affichée, et repartait vers
// l'iPhone avec un horodatage NEUF : le watchdog de `LiveStore`, qui
// ne regarde que l'arrivée de samples, ne pouvait alors jamais la
// déclarer perdue. Une FC morte s'affichait stable, avec sa zone
// colorée, indéfiniment — de quoi valider un effort sur un chiffre qui
// n'existe plus.
if let hr {
heartRate = hr
heartRateAt = Date()
} else if freshHeartRate == 0 {
// Périmée : on retombe sur la convention « inconnu » plutôt que de
// laisser la vue afficher une valeur que plus rien ne soutient.
heartRate = 0
heartRateAt = nil
}
if let kcal { activeEnergyKcal = kcal }
if let dist { distanceMeters = dist }
elapsedSec = elapsed
// Vitesse instantanée de la montre si elle la mesure, sinon moyenne
// depuis le départ (distance / temps). Les deux en km/h.
if let speedMs {
speedKmh = speedMs * 3.6
} else if elapsed > 5, distanceMeters > 0 {
speedKmh = (distanceMeters / elapsed) * 3.6
}
let now = Date()
// Nouvelle FC -> envoi immédiat (temps réel, comme Apple). Sinon (mise à
// jour calories/distance seule), on applique le throttle anti-saturation.
let heartRateUpdated = hr != nil
guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval else { return }
lastSentAt = now
ConnectivityManager.shared.sendSample([
// `freshHeartRate`, pas `heartRate` : ne jamais envoyer une valeur
// périmée sous un horodatage neuf. L'iPhone ne peut pas deviner
// l'âge d'un chiffre qu'on lui présente comme actuel.
"heartRate": freshHeartRate,
"activeEnergyKcal": activeEnergyKcal,
"distanceMeters": distanceMeters,
"speedKmh": speedKmh,
"elapsedSec": elapsed,
"ts": now.timeIntervalSince1970
])
}
private static func distanceType(for activity: HKWorkoutActivityType) -> HKQuantityType {
activity == .cycling
? HKQuantityType(.distanceCycling)
: HKQuantityType(.distanceWalkingRunning)
}
/// La marche n'a pas de type de vitesse dédié : on lui donne celui de la
/// course, qui restera vide — la vitesse moyenne dérivée prend le relais.
private static func speedType(for activity: HKWorkoutActivityType) -> HKQuantityType {
activity == .cycling
? HKQuantityType(.cyclingSpeed)
: HKQuantityType(.runningSpeed)
}
}
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>) {
let hrType = HKQuantityType(.heartRate)
let energyType = HKQuantityType(.activeEnergyBurned)
let walkRun = HKQuantityType(.distanceWalkingRunning)
let cycling = HKQuantityType(.distanceCycling)
let runSpeed = HKQuantityType(.runningSpeed)
let cycleSpeed = HKQuantityType(.cyclingSpeed)
var hr: Double?
var kcal: Double?
var dist: Double?
var speedMs: Double?
for type in collectedTypes {
guard let qType = type as? HKQuantityType,
let stats = workoutBuilder.statistics(for: qType) else { continue }
if qType == hrType {
let bpm = HKUnit.count().unitDivided(by: .minute())
hr = stats.mostRecentQuantity()?.doubleValue(for: bpm)
} else if qType == energyType {
kcal = stats.sumQuantity()?.doubleValue(for: .kilocalorie())
} else if qType == walkRun || qType == cycling {
dist = stats.sumQuantity()?.doubleValue(for: .meter())
} else if qType == runSpeed || qType == cycleSpeed {
// mostRecent : c'est bien la vitesse à l'instant t qu'on veut
// afficher, pas la moyenne de la séance.
let ms = HKUnit.meter().unitDivided(by: .second())
speedMs = stats.mostRecentQuantity()?.doubleValue(for: ms)
}
}
let elapsed = workoutBuilder.elapsedTime
Task { @MainActor in
self.ingest(hr: hr, kcal: kcal, dist: dist, speedMs: speedMs, elapsed: elapsed)
}
}
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
}
extension WorkoutManager: HKWorkoutSessionDelegate {
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date) {
workoutLog.info("session didChangeTo state=\(toState.rawValue) from=\(fromState.rawValue)")
// Pause / reprise : c'est la session HealthKit qui fait foi, pas
// l'appel à pause() — la montre peut mettre en pause d'elle-même
// (auto-pause) sans passer par nous.
if toState == .paused || toState == .running {
let paused = (toState == .paused)
Task { @MainActor in
self.isPaused = paused
// Le flux GPS reste ouvert pendant la pause : watchOS interdit
// de redémarrer la localisation depuis l'arrière-plan, donc on
// ignore les points au lieu de couper le manager.
LocationTracker.shared.setPaused(paused)
ConnectivityManager.shared.sendPaused(paused)
}
}
guard toState == .ended else { return }
let builder = workoutSession.associatedWorkoutBuilder()
Task { @MainActor in
// Ordre imposé par Apple : couper le GPS, clore la collecte,
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
// l'associant au workout obtenu.
LocationTracker.shared.stopTracking()
/* ⚠️ Les deux `try?` d'avant rendaient indiscernables deux
situations OPPOSÉES, toutes deux menant à `workout == nil` :
· montre verrouillée à l'arrêt → la séance EST écrite ;
· `endCollection` a levé → il n'y a rien à retrouver.
Elles tombaient dans le même `else`, et seul le sort de la TRACE
était journalisé — pas celui de la séance. Après deux heures de
sortie, l'écran de fin restait le même.
§ 2.3 du diagnostic du 24/09/2026. La logique de récupération
ci-dessous n'a pas changé : seul l'échec devient visible. */
var collecteClose = true
do {
try await builder.endCollection(at: date)
} catch {
collecteClose = false
workoutLog.error("endCollection a echoue : \(error.localizedDescription, privacy: .public)")
}
// ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est
// verrouillée : ce n'est pas un échec, la séance EST écrite dans
// HealthKit — seul l'objet manque. Comme `finishRoute(with:)`
// exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
// on va rechercher celle qui vient d'être sauvegardée.
var workout: HKWorkout?
do {
workout = try await builder.finishWorkout()
} catch {
workoutLog.error("finishWorkout a echoue : \(error.localizedDescription, privacy: .public)")
}
if let workout {
await LocationTracker.shared.finishRoute(with: workout)
} else if let recovered = await self.recentlySavedWorkout() {
workoutLog.info("workout recupere apres un finishWorkout() nil")
await LocationTracker.shared.finishRoute(with: recovered)
} else {
LocationTracker.shared.discardRoute()
if collecteClose {
// La collecte s'est close proprement mais rien n'est
// retrouvable : anormal, et jamais journalisé jusqu'ici.
workoutLog.error("seance introuvable apres un finishWorkout() nil : rien a associer a la trace")
} else {
workoutLog.error("SEANCE PERDUE : endCollection a leve, rien n'a ete ecrit dans HealthKit")
/* Le dire à l'écran. C'est le cœur du constat : l'utilisateur
voyait l'écran de fin habituel après une séance perdue.
Uniquement dans ce cas — une montre verrouillée n'est pas
une perte, et l'annoncer serait une fausse alerte. */
self.statusMessage = "Séance non enregistrée — rien n'a pu être écrit dans Santé"
}
}
}
Task { @MainActor in
self.isRunning = false
self.isPaused = false
self.session = nil
self.builder = nil
// Fin explicite vers l'iPhone : sans elle, sa Live Activity reste
// affichée avec la dernière FC reçue jusqu'à expiration d'ActivityKit.
ConnectivityManager.shared.sendEnded(reason: "session-ended")
}
}
/// La séance que HealthKit vient d'enregistrer, quand `finishWorkout()`
/// n'a rien rendu.
///
/// ⚠️ La fenêtre porte sur le **chevauchement**, pas sur la date de début :
/// sans `.strictStartDate`, un workout est retenu dès qu'il croise
/// l'intervalle. Filtrer sur son `startDate` raterait toute séance de plus
/// de quelques minutes — c'est le piège qui avait rendu muettes les
/// notifications de fin de séance côté iPhone.
///
/// Restreinte à ce que **cette app** a écrit (`HKSource.default()`) : sans
/// ça, une séance enregistrée en parallèle par l'app Exercice d'Apple
/// pourrait récupérer notre trace.
private func recentlySavedWorkout() async -> HKWorkout? {
let recent = HKQuery.predicateForSamples(
withStart: Date(timeIntervalSinceNow: -Self.recoveryWindow), end: nil)
// Type écrit en toutes lettres : `predicateForObjects(from:)` a cinq
// surcharges (HKSource, Set<HKSource>, HKWorkout, Set<HKSourceRevision>,
// Set<HKDevice>) et un `.default()` abrégé s'y résout mal.
let mine = HKQuery.predicateForObjects(from: HKSource.default())
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [recent, mine])
let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
return await withCheckedContinuation { continuation in
let query = HKSampleQuery(sampleType: .workoutType(),
predicate: predicate,
limit: 1,
sortDescriptors: [newestFirst]) { _, samples, error in
if let error {
workoutLog.error("recuperation du workout impossible : \(error.localizedDescription)")
}
continuation.resume(returning: samples?.first as? HKWorkout)
}
healthStore.execute(query)
}
}
/// Fenêtre de recherche du workout de repli. Large assez pour couvrir une
/// sauvegarde lente, courte assez pour ne pas ramasser la séance d'avant.
private static let recoveryWindow: TimeInterval = 5 * 60
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
didFailWithError error: Error) {
let message = error.localizedDescription
let ns = error as NSError
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
let builder = workoutSession.associatedWorkoutBuilder()
Task { @MainActor in
/* ⚠️ Ce chemin ne clôturait RIEN. Comparé à `.ended`, il manquait
`stopTracking()`, `endCollection`, `finishWorkout` et
`finishRoute`/`discardRoute` — et `self.builder = nil` détruisait
la seule référence au builder : la perte devenait définitive dans
l'instruction même qui l'ignorait. Le GPS, lui, continuait de
tourner après la fin apparente de la séance, sans que rien ne
l'indique. § 2.4 du diagnostic du 24/09/2026.
Le GPS d'abord : c'est le seul geste qui réussit à coup sûr, et
le seul dont l'omission se paie en batterie pendant des heures.
La sauvegarde ensuite, en best-effort — une session qui a échoué
refusera souvent de clore, mais ne pas essayer garantit la perte. */
LocationTracker.shared.stopTracking()
var sauvee: HKWorkout?
do {
try await builder.endCollection(at: Date())
sauvee = try await builder.finishWorkout()
} catch {
workoutLog.error("cloture apres echec impossible : \(error.localizedDescription, privacy: .public)")
}
if let sauvee {
workoutLog.info("seance sauvee malgre l'echec de session")
await LocationTracker.shared.finishRoute(with: sauvee)
self.statusMessage = "Séance interrompue, mais enregistrée"
} else if let recovered = await self.recentlySavedWorkout() {
workoutLog.info("seance retrouvee dans HealthKit apres l'echec de session")
await LocationTracker.shared.finishRoute(with: recovered)
self.statusMessage = "Séance interrompue, mais enregistrée"
} else {
LocationTracker.shared.discardRoute()
workoutLog.error("SEANCE PERDUE apres didFailWithError : rien n'a pu etre sauve")
self.statusMessage = "Séance perdue : \(message)"
}
self.isRunning = false
self.isPaused = false
self.session = nil
self.builder = nil
ConnectivityManager.shared.sendEnded(reason: "session-failed")
}
}
}