// // WorkoutHealthWriter.swift // Workouts // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import Foundation import HealthKit import SwiftData /// Writes an *estimated* `HKWorkout` from the phone when a workout completes without /// a watch session having recorded it — so phone-only workouts still feed the Move /// ring. Mirrors what Apple's own "Add Workout" does: energy ≈ MET × bodyweight × /// duration, with bodyweight read from Health. /// /// Dedupe is the whole game: a workout must be written to Health by the watch OR the /// phone, never both. The signals are `WorkoutMetrics.source`/`healthKitWorkoutUUID` /// (already present → skip) plus a debounce that a late watch update cancels. @MainActor final class WorkoutHealthWriter { private let container: ModelContainer private let healthStore = HKHealthStore() private weak var syncEngine: SyncEngine? /// Scheduled estimate tasks keyed by workout id — for debounce + cancellation. private var pending: [String: Task] = [:] /// Grace window before writing, so a watch session that finishes a beat later can /// cancel us (its metrics arrive over WatchConnectivity and call `watchMetricsArrived`). private let debounce: Duration = .seconds(10) /// Default bodyweight when Health has none, in kg (~154 lb). private let fallbackBodyMassKg = 70.0 init(container: ModelContainer, syncEngine: SyncEngine) { self.container = container self.syncEngine = syncEngine } private var context: ModelContext { container.mainContext } private var isScreenshotRun: Bool { #if DEBUG return ScreenshotSeed.isActive #else return false #endif } /// Request the scopes the estimate needs: share workouts + active energy, read /// body measurements (to size the estimate) and heart rate (to show watch data). func authorizeIfNeeded() { guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return } healthStore.requestAuthorization( toShare: WorkoutHealthAuthorization.phoneShare, read: WorkoutHealthAuthorization.phoneRead ) { _, _ in } } /// A workout was persisted as completed. If nothing has written it to Health yet, /// schedule an estimated write after the debounce. func workoutCompleted(_ doc: WorkoutDocument) { guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return } guard doc.status == WorkoutStatus.completed.rawValue, doc.end != nil else { return } // Already recorded (watch session, or a prior estimate) → nothing to do. guard doc.metrics?.healthKitWorkoutUUID == nil, doc.metrics?.source != .watch else { return } guard pending[doc.id] == nil else { return } let id = doc.id pending[id] = Task { [weak self] in try? await Task.sleep(for: self?.debounce ?? .seconds(10)) guard let self, !Task.isCancelled else { return } self.pending[id] = nil await self.writeEstimate(workoutID: id) } } /// The watch recorded this workout — cancel any pending phone estimate so we don't /// double-write. func watchMetricsArrived(workoutID id: String) { pending[id]?.cancel() pending[id] = nil } /// Best-effort removal of a workout's Health record, on the user's request when /// deleting the workout in the app. HealthKit only allows deleting samples this /// app authored, so phone estimates delete cleanly while watch-recorded workouts /// (authored by the watch app's separate HealthKit source) may be refused — that /// refusal is swallowed; the record then stays in Health until removed there. func deleteFromHealth(uuidString: String) { guard HKHealthStore.isHealthDataAvailable(), let uuid = UUID(uuidString: uuidString) else { return } let healthStore = self.healthStore let query = HKSampleQuery( sampleType: .workoutType(), predicate: HKQuery.predicateForObject(with: uuid), limit: 1, sortDescriptors: nil ) { _, samples, _ in guard let workout = samples?.first else { return } healthStore.delete([workout]) { _, _ in } } healthStore.execute(query) } /// Launch safety sweep: catch workouts that completed but never got a Health record /// (e.g., the app was killed during the debounce). Bounded to the last day so we /// don't retroactively flood Health with historical workouts; idempotent via the /// per-workout UUID guard. func sweepRecentlyCompleted() { guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return } let cutoff = Date().addingTimeInterval(-24 * 60 * 60) let completedRaw = WorkoutStatus.completed.rawValue let all = (try? context.fetch(FetchDescriptor())) ?? [] for w in all where w.statusRaw == completedRaw && w.metricHealthKitWorkoutUUID == nil { guard let end = w.end, end >= cutoff else { continue } workoutCompleted(WorkoutDocument(from: w)) } } // MARK: - Estimate write private func writeEstimate(workoutID id: String) async { guard healthStore.authorizationStatus(for: .workoutType()) == .sharingAuthorized else { return } // Re-read the freshest doc — watch metrics may have landed during the debounce. guard let workout = CacheMapper.fetchWorkout(id: id, in: context), workout.status == .completed, let end = workout.end else { return } if let m = workout.metrics, m.healthKitWorkoutUUID != nil || m.source == .watch { return } let doc = WorkoutDocument(from: workout) let activity = activityType(for: doc) let bodyKg = await bodyMassKg() let hours = max(0, end.timeIntervalSince(doc.start)) / 3600 let kcal = activity.met * bodyKg * hours let config = HKWorkoutConfiguration() config.activityType = activity.hkActivityType config.locationType = .indoor let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: config, device: .local()) do { try await builder.beginCollection(at: doc.start) if kcal > 0 { let sample = HKQuantitySample( type: HKQuantityType(.activeEnergyBurned), quantity: HKQuantity(unit: .kilocalorie(), doubleValue: kcal), start: doc.start, end: end ) try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in builder.add([sample]) { _, error in if let error { cont.resume(throwing: error) } else { cont.resume() } } } } try await builder.endCollection(at: end) let saved = try await builder.finishWorkout() // Persist the estimate's metrics so it's never written twice. var updated = doc updated.metrics = WorkoutMetrics( activeEnergyKcal: kcal > 0 ? kcal : nil, avgHeartRate: nil, maxHeartRate: nil, minHeartRate: nil, totalVolume: WorkoutVolume.total(doc.logs), hrZoneSeconds: nil, healthKitWorkoutUUID: saved?.uuid.uuidString, source: .phoneEstimate, recordedAt: end ) updated.updatedAt = Date() await syncEngine?.save(workout: updated) } catch { // Leave it unmarked; the next launch sweep retries within the window. } } /// The split's configured activity type, following the seed clone-on-edit /// redirect (the estimate can run seconds to hours after the workout, so the /// split may have forked in between). Generic strength when the split is gone. private func activityType(for doc: WorkoutDocument) -> WorkoutActivityType { if let splitID = doc.splitID { let resolved = syncEngine?.currentSplitID(for: splitID) ?? splitID if let split = CacheMapper.fetchSplit(id: resolved, in: context) { return split.activityTypeEnum } } return .traditionalStrength } /// Most recent body mass from Health, in kg, falling back to a default when none. private func bodyMassKg() async -> Double { let fallback = fallbackBodyMassKg return await withCheckedContinuation { continuation in let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) let query = HKSampleQuery( sampleType: HKQuantityType(.bodyMass), predicate: nil, limit: 1, sortDescriptors: [sort] ) { _, samples, _ in let kg = (samples?.first as? HKQuantitySample)? .quantity.doubleValue(for: .gramUnit(with: .kilo)) continuation.resume(returning: kg ?? fallback) } healthStore.execute(query) } } }