import Combine import Foundation import HealthKit import WatchKit public enum WatchCollectionMode: Equatable, Sendable { case technicalMeasurement case guidedMindAndBody(saveToHealth: Bool) } public enum WatchMeasurementState: Equatable, Sendable { case idle case authorizing case waitingForFirstSample(secondsRemaining: Int) case collecting(progress: Double) case finishing case completed(WatchMeasurementSummaryV1) case failed(String) } @MainActor public final class WatchHealthMeasurementService: NSObject, ObservableObject { @Published public private(set) var state: WatchMeasurementState = .idle @Published public private(set) var currentBPM: Double? @Published public private(set) var samples: [WatchHeartRateSampleV1] = [] @Published public private(set) var lastWorkoutUUID: UUID? public var onLiveHeartRate: ((WatchHeartRateSampleV1) -> Void)? public var onCompleted: ((WatchMeasurementSummaryV1) -> Void)? private let healthStore: HKHealthStore private let arbiter: WatchResourceArbiter private var workoutSession: HKWorkoutSession? private var builder: HKLiveWorkoutBuilder? private var collectionMode: WatchCollectionMode = .technicalMeasurement private var startedAt: Date? private var validCollectionStartedAt: Date? private var timeoutTask: Task? private var finishTask: Task? private var collectionDurationSeconds: TimeInterval = 60 public init( healthStore: HKHealthStore = HKHealthStore(), arbiter: WatchResourceArbiter = .shared ) { self.healthStore = healthStore self.arbiter = arbiter super.init() } public func requestAuthorization() async throws { guard HKHealthStore.isHealthDataAvailable(), let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate) else { throw NSError(domain: "HaptikaWatch.Health", code: 1, userInfo: [NSLocalizedDescriptionKey: "Santé n’est pas disponible sur cette Watch."]) } state = .authorizing try await healthStore.requestAuthorization( toShare: [HKObjectType.workoutType()], read: [heartRate] ) state = .idle } public func start( mode: WatchCollectionMode = .technicalMeasurement, collectionDurationSeconds: TimeInterval = 60 ) async throws { guard case .idle = state else { return } try await arbiter.acquire(.heartRateMeasurement) do { let configuration = HKWorkoutConfiguration() configuration.activityType = .mindAndBody configuration.locationType = .unknown let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration) let builder = session.associatedWorkoutBuilder() builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration) session.delegate = self builder.delegate = self workoutSession = session self.builder = builder collectionMode = mode self.collectionDurationSeconds = max(60, collectionDurationSeconds) samples = [] currentBPM = nil lastWorkoutUUID = nil let now = Date() startedAt = now validCollectionStartedAt = nil state = .waitingForFirstSample(secondsRemaining: 30) session.startActivity(with: now) try await builder.beginCollection(at: now) scheduleFirstSampleTimeout() } catch { await arbiter.forceRelease() state = .failed(error.localizedDescription) throw error } } public func cancel() { timeoutTask?.cancel() finishTask?.cancel() workoutSession?.end() let builder = builder Task { @MainActor [weak self] in if let builder { try? await builder.endCollection(at: Date()) builder.discardWorkout() } await self?.arbiter.forceRelease() self?.resetResources() self?.state = .idle } } public func finish(interrupted: Bool = false) { guard builder != nil else { return } state = .finishing timeoutTask?.cancel() finishTask?.cancel() workoutSession?.end() let endDate = Date() let startDate = startedAt ?? endDate let validation = WatchMeasurementValidator.validate(samples: samples, interrupted: interrupted) let summary = WatchMeasurementSummaryV1( startedAt: startDate, endedAt: endDate, device: WatchDeviceIdentityV1( id: WKInterfaceDevice.current().identifierForVendor?.uuidString ?? "unknown-watch", name: WKInterfaceDevice.current().name, model: WKInterfaceDevice.current().model, systemVersion: WKInterfaceDevice.current().systemVersion ), currentBPM: validation.currentBPM, averageBPM: validation.averageBPM, minimumBPM: validation.minimumBPM, maximumBPM: validation.maximumBPM, samples: validation.validSamples, coverageSeconds: validation.coverageSeconds, quality: validation.quality, isValid: validation.isValid, invalidReasons: validation.invalidReasons ) let mode = collectionMode let builder = self.builder finishTask = Task { @MainActor [weak self] in guard let self, let builder else { return } do { try await builder.endCollection(at: endDate) switch mode { case .technicalMeasurement: builder.discardWorkout() case let .guidedMindAndBody(saveToHealth): if saveToHealth { self.lastWorkoutUUID = try await builder.finishWorkout()?.uuid } else { builder.discardWorkout() } } await self.arbiter.forceRelease() self.resetResources(keepSamples: true) self.state = .completed(summary) self.onCompleted?(summary) } catch { builder.discardWorkout() await self.arbiter.forceRelease() self.resetResources(keepSamples: true) self.state = .failed(error.localizedDescription) } } } public func resetAfterResult() { guard workoutSession == nil else { return } state = .idle samples = [] currentBPM = nil lastWorkoutUUID = nil } private func scheduleFirstSampleTimeout() { timeoutTask?.cancel() timeoutTask = Task { @MainActor [weak self] in for remaining in stride(from: 30, through: 1, by: -1) { guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return } self.state = .waitingForFirstSample(secondsRemaining: remaining) try? await Task.sleep(for: .seconds(1)) } guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return } self.finish() } } private func scheduleCollectionFinish(from firstSampleDate: Date) { timeoutTask?.cancel() finishTask?.cancel() finishTask = Task { @MainActor [weak self] in while let self, !Task.isCancelled { let elapsed = Date().timeIntervalSince(firstSampleDate) self.state = .collecting(progress: min(1, elapsed / self.collectionDurationSeconds)) if elapsed >= self.collectionDurationSeconds { self.finish() return } try? await Task.sleep(for: .milliseconds(250)) } } } private func acceptHeartRate(_ bpm: Double, at date: Date) { guard (WatchMeasurementValidator.minimumBPM ... WatchMeasurementValidator.maximumBPM).contains(bpm) else { return } let sample = WatchHeartRateSampleV1(date: date, bpm: bpm) samples.append(sample) currentBPM = bpm onLiveHeartRate?(sample) if validCollectionStartedAt == nil { validCollectionStartedAt = date scheduleCollectionFinish(from: date) } } private func resetResources(keepSamples: Bool = false) { workoutSession = nil builder = nil startedAt = nil validCollectionStartedAt = nil timeoutTask = nil finishTask = nil if !keepSamples { samples = [] } } } extension WatchHealthMeasurementService: HKWorkoutSessionDelegate { nonisolated public func workoutSession( _ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date ) {} nonisolated public func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { Task { @MainActor [weak self] in self?.finish(interrupted: true) } } } extension WatchHealthMeasurementService: HKLiveWorkoutBuilderDelegate { nonisolated public func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {} nonisolated public func workoutBuilder( _ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set ) { guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate), collectedTypes.contains(heartRateType), let statistics = workoutBuilder.statistics(for: heartRateType), let quantity = statistics.mostRecentQuantity() else { return } let unit = HKUnit.count().unitDivided(by: .minute()) let bpm = quantity.doubleValue(for: unit) Task { @MainActor [weak self] in self?.acceptHeartRate(bpm, at: Date()) } } }