`didCollectDataOf` se déclenche dès qu'UN type est collecté. Quand le capteur
optique décroche — bracelet desserré, froid, arythmie, et la FA thermoablée est
précisément le terrain à risque — les calories continuent d'arriver et `hr`
vaut nil. La ligne `if let hr { heartRate = hr }` gardait alors la dernière
valeur connue, et `sendSample` la renvoyait à l'iPhone avec un `ts` NEUF.
Conséquence en trois étages : au poignet, une FC morte s'affichait stable avec
sa zone colorée, indéfiniment ; côté iPhone, `LiveStore` posait
`lastSampleAt = Date()` et `status = .live`, donc son watchdog — qui ne regarde
que l'arrivée de samples — ne pouvait JAMAIS la déclarer perdue ; et aucun log
ne le signalait. De quoi décider de tenir une pente sur un chiffre qui
n'existait plus.
La FC porte désormais la date de sa dernière mesure réelle. `freshHeartRate`
rend 0 au-delà de `heartRateStaleAfter`, et c'est lui qui est affiché et émis.
0 est la convention « inconnu » déjà partagée par toute la chaîne :
`HeartRateMetric` cesse d'afficher une zone, `heartRateText` rend « — » côté
iPhone. Rien à changer dans LiveStore, qui reste à `.live` à juste titre — la
connexion est vivante, c'est la mesure qui manque, et les deux méritent d'être
distingués.
Le seuil de 10 s est un choix d'ingénierie, pas une donnée physiologique :
Apple ne publie aucune cadence garantie d'échantillonnage en séance. Il est
posé au-dessus du `lostThreshold` de 8 s de LiveStore pour que la FC ne se
périme jamais AVANT que la connexion soit déclarée perdue — deux signaux
contradictoires seraient illisibles au poignet. Commenté comme tel, avec la
consigne de l'augmenter si « — » clignote plutôt que de retirer la péremption.
Trouvé au passage : `WorkoutManager` est un singleton et ne remettait RIEN à
zéro entre deux séances. La FC, les calories et la distance de la séance
précédente restaient affichées jusqu'au premier échantillon de la suivante.
`resetMetrics()` est appelé au démarrage, séance réelle comme simulation.
Limite assumée et commentée : la péremption est évaluée dans `ingest`, donc
elle suppose qu'un échantillon d'un type quelconque continue d'arriver — le cas
visé. 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 un cas déjà visible.
⚠️ Vérifié ici par `swiftc -parse` (syntaxe) et les 88 tests Linux, qui ne
couvrent PAS ce fichier : WatchKit et HealthKit n'existent pas sur Linux. Reste
à compiler et à essayer sur la montre — notamment que « — » n'apparaisse pas en
usage normal.
Relevé § 1.1 de docs/AUDIT-2026-09-22.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
465 lines
22 KiB
Swift
465 lines
22 KiB
Swift
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()
|
||
try? await builder.endCollection(at: date)
|
||
// ⚠️ `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.
|
||
let workout = try? await builder.finishWorkout()
|
||
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()
|
||
}
|
||
}
|
||
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)")
|
||
Task { @MainActor in
|
||
self.statusMessage = "Erreur séance : \(message)"
|
||
self.isRunning = false
|
||
self.session = nil
|
||
self.builder = nil
|
||
ConnectivityManager.shared.sendEnded(reason: "session-failed")
|
||
}
|
||
}
|
||
}
|