// // HealthService.swift // Reflect // // Manages Apple Health integration for mood correlation insights. // import Foundation import HealthKit import SwiftUI @MainActor class HealthService: ObservableObject { static let shared = HealthService() // MARK: - Published State @Published var isAuthorized: Bool = false @Published var isAvailable: Bool = false // MARK: - App Storage @AppStorage(UserDefaultsStore.Keys.healthKitEnabled.rawValue, store: GroupUserDefaults.groupDefaults) var isEnabled: Bool = false // MARK: - HealthKit Store private let healthStore = HKHealthStore() // MARK: - Data Types // Core activity metrics private let stepCountType = HKQuantityType(.stepCount) private let exerciseTimeType = HKQuantityType(.appleExerciseTime) private let activeEnergyType = HKQuantityType(.activeEnergyBurned) private let distanceType = HKQuantityType(.distanceWalkingRunning) // Heart & stress indicators private let heartRateType = HKQuantityType(.heartRate) private let restingHeartRateType = HKQuantityType(.restingHeartRate) private let hrvType = HKQuantityType(.heartRateVariabilitySDNN) // Sleep & recovery private let sleepAnalysisType = HKCategoryType(.sleepAnalysis) // Mindfulness private let mindfulSessionType = HKCategoryType(.mindfulSession) // State of Mind private let stateOfMindType = HKSampleType.stateOfMindType() // Workouts private let workoutType = HKWorkoutType.workoutType() private var readTypes: Set { [ stepCountType, exerciseTimeType, activeEnergyType, distanceType, heartRateType, restingHeartRateType, hrvType, sleepAnalysisType, mindfulSessionType, stateOfMindType, workoutType ] } // MARK: - Initialization init() { isAvailable = HKHealthStore.isHealthDataAvailable() } // MARK: - Authorization func requestAuthorization() async -> Bool { guard isAvailable else { #if DEBUG print("HealthService: HealthKit not available on this device") #endif return false } do { try await healthStore.requestAuthorization(toShare: [], read: readTypes) isAuthorized = true isEnabled = true AnalyticsManager.shared.track(.healthKitAuthorized) return true } catch { #if DEBUG print("HealthService: Authorization failed: \(error.localizedDescription)") #endif AnalyticsManager.shared.track(.healthKitAuthFailed(error: error.localizedDescription)) return false } } // MARK: - Fetch Health Data for Date struct DailyHealthData { let date: Date // Activity metrics let steps: Int? let exerciseMinutes: Int? let activeCalories: Int? let distanceKm: Double? // Heart metrics let averageHeartRate: Double? let restingHeartRate: Double? let hrv: Double? // Heart Rate Variability in ms // Recovery metrics let sleepHours: Double? let mindfulMinutes: Int? var hasData: Bool { steps != nil || exerciseMinutes != nil || averageHeartRate != nil || sleepHours != nil || hrv != nil || mindfulMinutes != nil } } func fetchHealthData(for date: Date) async -> DailyHealthData { let calendar = Calendar.current let startOfDay = calendar.startOfDay(for: date) guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else { return DailyHealthData(date: date, steps: nil, exerciseMinutes: nil, activeCalories: nil, distanceKm: nil, averageHeartRate: nil, restingHeartRate: nil, hrv: nil, sleepHours: nil, mindfulMinutes: nil) } // Fetch all metrics concurrently async let steps = fetchSteps(start: startOfDay, end: endOfDay) async let exercise = fetchExerciseMinutes(start: startOfDay, end: endOfDay) async let activeCalories = fetchActiveCalories(start: startOfDay, end: endOfDay) async let distance = fetchDistance(start: startOfDay, end: endOfDay) async let heartRate = fetchAverageHeartRate(start: startOfDay, end: endOfDay) async let restingHR = fetchRestingHeartRate(start: startOfDay, end: endOfDay) async let hrv = fetchHRV(start: startOfDay, end: endOfDay) async let sleep = fetchSleepHours(for: date) async let mindful = fetchMindfulMinutes(start: startOfDay, end: endOfDay) return await DailyHealthData( date: date, steps: steps, exerciseMinutes: exercise, activeCalories: activeCalories, distanceKm: distance, averageHeartRate: heartRate, restingHeartRate: restingHR, hrv: hrv, sleepHours: sleep, mindfulMinutes: mindful ) } // MARK: - Steps private func fetchSteps(start: Date, end: Date) async -> Int? { guard isAuthorized else { return nil } let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) return await withCheckedContinuation { continuation in let query = HKStatisticsQuery( quantityType: stepCountType, quantitySamplePredicate: predicate, options: .cumulativeSum ) { _, result, error in guard error == nil, let sum = result?.sumQuantity() else { continuation.resume(returning: nil) return } let steps = Int(sum.doubleValue(for: .count())) continuation.resume(returning: steps) } healthStore.execute(query) } } // MARK: - Exercise Minutes private func fetchExerciseMinutes(start: Date, end: Date) async -> Int? { guard isAuthorized else { return nil } let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) return await withCheckedContinuation { continuation in let query = HKStatisticsQuery( quantityType: exerciseTimeType, quantitySamplePredicate: predicate, options: .cumulativeSum ) { _, result, error in guard error == nil, let sum = result?.sumQuantity() else { continuation.resume(returning: nil) return } let minutes = Int(sum.doubleValue(for: .minute())) continuation.resume(returning: minutes) } healthStore.execute(query) } } // MARK: - Heart Rate private func fetchAverageHeartRate(start: Date, end: Date) async -> Double? { guard isAuthorized else { return nil } let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) return await withCheckedContinuation { continuation in let query = HKStatisticsQuery( quantityType: heartRateType, quantitySamplePredicate: predicate, options: .discreteAverage ) { _, result, error in guard error == nil, let avg = result?.averageQuantity() else { continuation.resume(returning: nil) return } let bpm = avg.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) continuation.resume(returning: bpm) } healthStore.execute(query) } } // MARK: - Sleep private func fetchSleepHours(for date: Date) async -> Double? { guard isAuthorized else { return nil } // Sleep data is typically recorded for the night before // So for mood on date X, we look at sleep from evening of X-1 to morning of X let calendar = Calendar.current guard let endOfSleep = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: date), let startOfSleep = calendar.date(byAdding: .hour, value: -18, to: endOfSleep) else { return nil } let predicate = HKQuery.predicateForSamples(withStart: startOfSleep, end: endOfSleep, options: .strictStartDate) return await withCheckedContinuation { continuation in let query = HKSampleQuery( sampleType: sleepAnalysisType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil ) { _, samples, error in guard error == nil, let sleepSamples = samples as? [HKCategorySample] else { continuation.resume(returning: nil) return } // Sum up asleep time (not in bed time) var totalSleepSeconds: TimeInterval = 0 for sample in sleepSamples { // Filter for actual sleep states (not in bed) if sample.value == HKCategoryValueSleepAnalysis.asleepCore.rawValue || sample.value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue || sample.value == HKCategoryValueSleepAnalysis.asleepREM.rawValue || sample.value == HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue { totalSleepSeconds += sample.endDate.timeIntervalSince(sample.startDate) } } if totalSleepSeconds > 0 { let hours = totalSleepSeconds / 3600 continuation.resume(returning: hours) } else { continuation.resume(returning: nil) } } healthStore.execute(query) } } // MARK: - Active Calories private func fetchActiveCalories(start: Date, end: Date) async -> Int? { guard isAuthorized else { return nil } let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) return await withCheckedContinuation { continuation in let query = HKStatisticsQuery( quantityType: activeEnergyType, quantitySamplePredicate: predicate, options: .cumulativeSum ) { _, result, error in guard error == nil, let sum = result?.sumQuantity() else { continuation.resume(returning: nil) return } let calories = Int(sum.doubleValue(for: .kilocalorie())) continuation.resume(returning: calories) } healthStore.execute(query) } } // MARK: - Distance private func fetchDistance(start: Date, end: Date) async -> Double? { guard isAuthorized else { return nil } let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) return await withCheckedContinuation { continuation in let query = HKStatisticsQuery( quantityType: distanceType, quantitySamplePredicate: predicate, options: .cumulativeSum ) { _, result, error in guard error == nil, let sum = result?.sumQuantity() else { continuation.resume(returning: nil) return } let km = sum.doubleValue(for: .meterUnit(with: .kilo)) continuation.resume(returning: km) } healthStore.execute(query) } } // MARK: - Resting Heart Rate private func fetchRestingHeartRate(start: Date, end: Date) async -> Double? { guard isAuthorized else { return nil } let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) return await withCheckedContinuation { continuation in let query = HKStatisticsQuery( quantityType: restingHeartRateType, quantitySamplePredicate: predicate, options: .discreteAverage ) { _, result, error in guard error == nil, let avg = result?.averageQuantity() else { continuation.resume(returning: nil) return } let bpm = avg.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) continuation.resume(returning: bpm) } healthStore.execute(query) } } // MARK: - Heart Rate Variability (HRV) private func fetchHRV(start: Date, end: Date) async -> Double? { guard isAuthorized else { return nil } let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) return await withCheckedContinuation { continuation in let query = HKStatisticsQuery( quantityType: hrvType, quantitySamplePredicate: predicate, options: .discreteAverage ) { _, result, error in guard error == nil, let avg = result?.averageQuantity() else { continuation.resume(returning: nil) return } // HRV is measured in milliseconds let ms = avg.doubleValue(for: .secondUnit(with: .milli)) continuation.resume(returning: ms) } healthStore.execute(query) } } // MARK: - Mindful Minutes private func fetchMindfulMinutes(start: Date, end: Date) async -> Int? { guard isAuthorized else { return nil } let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) return await withCheckedContinuation { continuation in let query = HKSampleQuery( sampleType: mindfulSessionType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil ) { _, samples, error in guard error == nil, let mindfulSamples = samples as? [HKCategorySample] else { continuation.resume(returning: nil) return } // Sum up all mindful session durations var totalSeconds: TimeInterval = 0 for sample in mindfulSamples { totalSeconds += sample.endDate.timeIntervalSince(sample.startDate) } if totalSeconds > 0 { let minutes = Int(totalSeconds / 60) continuation.resume(returning: minutes) } else { continuation.resume(returning: nil) } } healthStore.execute(query) } } // MARK: - Batch Fetch for Insights func fetchHealthData(for entries: [MoodEntryModel]) async -> [Date: DailyHealthData] { guard isEnabled && isAuthorized else { return [:] } var results: [Date: DailyHealthData] = [:] let calendar = Calendar.current // Get unique dates let dates = Set(entries.map { calendar.startOfDay(for: $0.forDate) }) // Fetch health data for each date await withTaskGroup(of: (Date, DailyHealthData).self) { group in for date in dates { group.addTask { let data = await self.fetchHealthData(for: date) return (date, data) } } for await (date, data) in group { results[date] = data } } return results } // MARK: - Health Averages for AI Analysis /// Aggregated health metrics for a period - passed to AI for correlation analysis struct HealthAverages { // Activity let avgSteps: Int? let avgExerciseMinutes: Int? let avgActiveCalories: Int? let avgDistanceKm: Double? // Heart health let avgHeartRate: Double? let avgRestingHeartRate: Double? let avgHRV: Double? // milliseconds // Recovery let avgSleepHours: Double? let avgMindfulMinutes: Int? // Sample sizes (for AI context) let daysWithHealthData: Int let totalMoodEntries: Int var hasData: Bool { daysWithHealthData > 0 } } /// Compute aggregate health averages for a set of mood entries /// The AI will analyze these alongside mood data to find correlations func computeHealthAverages(entries: [MoodEntryModel], healthData: [Date: DailyHealthData]) -> HealthAverages { let calendar = Calendar.current var steps: [Int] = [] var exercise: [Int] = [] var calories: [Int] = [] var distance: [Double] = [] var heartRate: [Double] = [] var restingHR: [Double] = [] var hrv: [Double] = [] var sleep: [Double] = [] var mindful: [Int] = [] for entry in entries { let date = calendar.startOfDay(for: entry.forDate) guard let health = healthData[date] else { continue } if let s = health.steps { steps.append(s) } if let e = health.exerciseMinutes { exercise.append(e) } if let c = health.activeCalories { calories.append(c) } if let d = health.distanceKm { distance.append(d) } if let h = health.averageHeartRate { heartRate.append(h) } if let r = health.restingHeartRate { restingHR.append(r) } if let v = health.hrv { hrv.append(v) } if let sl = health.sleepHours { sleep.append(sl) } if let m = health.mindfulMinutes { mindful.append(m) } } let daysWithData = Set(healthData.keys).count return HealthAverages( avgSteps: steps.isEmpty ? nil : steps.reduce(0, +) / steps.count, avgExerciseMinutes: exercise.isEmpty ? nil : exercise.reduce(0, +) / exercise.count, avgActiveCalories: calories.isEmpty ? nil : calories.reduce(0, +) / calories.count, avgDistanceKm: distance.isEmpty ? nil : distance.reduce(0, +) / Double(distance.count), avgHeartRate: heartRate.isEmpty ? nil : heartRate.reduce(0, +) / Double(heartRate.count), avgRestingHeartRate: restingHR.isEmpty ? nil : restingHR.reduce(0, +) / Double(restingHR.count), avgHRV: hrv.isEmpty ? nil : hrv.reduce(0, +) / Double(hrv.count), avgSleepHours: sleep.isEmpty ? nil : sleep.reduce(0, +) / Double(sleep.count), avgMindfulMinutes: mindful.isEmpty ? nil : mindful.reduce(0, +) / mindful.count, daysWithHealthData: daysWithData, totalMoodEntries: entries.count ) } }