// // HealthService.swift // Feels // // 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 private let stepCountType = HKQuantityType.quantityType(forIdentifier: .stepCount)! private let exerciseTimeType = HKQuantityType.quantityType(forIdentifier: .appleExerciseTime)! private let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)! private let sleepAnalysisType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)! private var readTypes: Set { [stepCountType, exerciseTimeType, heartRateType, sleepAnalysisType] } // MARK: - Initialization init() { isAvailable = HKHealthStore.isHealthDataAvailable() } // MARK: - Authorization func requestAuthorization() async -> Bool { guard isAvailable else { print("HealthService: HealthKit not available on this device") return false } do { try await healthStore.requestAuthorization(toShare: [], read: readTypes) isAuthorized = true isEnabled = true EventLogger.log(event: "healthkit_authorized") return true } catch { print("HealthService: Authorization failed: \(error.localizedDescription)") EventLogger.log(event: "healthkit_auth_failed", withData: ["error": error.localizedDescription]) return false } } // MARK: - Fetch Health Data for Date struct DailyHealthData { let date: Date let steps: Int? let exerciseMinutes: Int? let averageHeartRate: Double? let sleepHours: Double? var hasData: Bool { steps != nil || exerciseMinutes != nil || averageHeartRate != nil || sleepHours != nil } } func fetchHealthData(for date: Date) async -> DailyHealthData { let calendar = Calendar.current let startOfDay = calendar.startOfDay(for: date) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! async let steps = fetchSteps(start: startOfDay, end: endOfDay) async let exercise = fetchExerciseMinutes(start: startOfDay, end: endOfDay) async let heartRate = fetchAverageHeartRate(start: startOfDay, end: endOfDay) async let sleep = fetchSleepHours(for: date) return await DailyHealthData( date: date, steps: steps, exerciseMinutes: exercise, averageHeartRate: heartRate, sleepHours: sleep ) } // 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 let endOfSleep = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: date)! let startOfSleep = calendar.date(byAdding: .hour, value: -18, to: endOfSleep)! 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: - 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: - Correlation Analysis struct HealthMoodCorrelation { let metric: String let correlation: String // "positive", "negative", or "none" let insight: String let averageWithHighMetric: Double let averageWithLowMetric: Double } func analyzeCorrelations(entries: [MoodEntryModel], healthData: [Date: DailyHealthData]) -> [HealthMoodCorrelation] { var correlations: [HealthMoodCorrelation] = [] let calendar = Calendar.current // Prepare data pairs var stepsAndMoods: [(steps: Int, mood: Int)] = [] var exerciseAndMoods: [(minutes: Int, mood: Int)] = [] var sleepAndMoods: [(hours: Double, mood: Int)] = [] var heartRateAndMoods: [(bpm: Double, mood: Int)] = [] for entry in entries { let date = calendar.startOfDay(for: entry.forDate) guard let health = healthData[date] else { continue } let moodValue = entry.moodValue + 1 // Use 1-5 scale if let steps = health.steps { stepsAndMoods.append((steps, moodValue)) } if let exercise = health.exerciseMinutes { exerciseAndMoods.append((exercise, moodValue)) } if let sleep = health.sleepHours { sleepAndMoods.append((sleep, moodValue)) } if let hr = health.averageHeartRate { heartRateAndMoods.append((hr, moodValue)) } } // Analyze steps correlation if stepsAndMoods.count >= 5 { let threshold = 8000 let highSteps = stepsAndMoods.filter { $0.steps >= threshold } let lowSteps = stepsAndMoods.filter { $0.steps < threshold } if !highSteps.isEmpty && !lowSteps.isEmpty { let avgHigh = Double(highSteps.map { $0.mood }.reduce(0, +)) / Double(highSteps.count) let avgLow = Double(lowSteps.map { $0.mood }.reduce(0, +)) / Double(lowSteps.count) let diff = avgHigh - avgLow if abs(diff) >= 0.3 { correlations.append(HealthMoodCorrelation( metric: "Steps", correlation: diff > 0 ? "positive" : "negative", insight: diff > 0 ? "Your mood averages \(String(format: "%.1f", diff)) points higher on days with 8k+ steps" : "Interestingly, your mood is slightly lower on high-step days", averageWithHighMetric: avgHigh, averageWithLowMetric: avgLow )) } } } // Analyze sleep correlation if sleepAndMoods.count >= 5 { let threshold = 7.0 let goodSleep = sleepAndMoods.filter { $0.hours >= threshold } let poorSleep = sleepAndMoods.filter { $0.hours < threshold } if !goodSleep.isEmpty && !poorSleep.isEmpty { let avgGood = Double(goodSleep.map { $0.mood }.reduce(0, +)) / Double(goodSleep.count) let avgPoor = Double(poorSleep.map { $0.mood }.reduce(0, +)) / Double(poorSleep.count) let diff = avgGood - avgPoor if abs(diff) >= 0.3 { correlations.append(HealthMoodCorrelation( metric: "Sleep", correlation: diff > 0 ? "positive" : "negative", insight: diff > 0 ? "7+ hours of sleep correlates with \(String(format: "%.1f", diff)) point higher mood" : "Sleep duration doesn't seem to strongly affect your mood", averageWithHighMetric: avgGood, averageWithLowMetric: avgPoor )) } } } // Analyze exercise correlation if exerciseAndMoods.count >= 5 { let threshold = 30 let active = exerciseAndMoods.filter { $0.minutes >= threshold } let inactive = exerciseAndMoods.filter { $0.minutes < threshold } if !active.isEmpty && !inactive.isEmpty { let avgActive = Double(active.map { $0.mood }.reduce(0, +)) / Double(active.count) let avgInactive = Double(inactive.map { $0.mood }.reduce(0, +)) / Double(inactive.count) let diff = avgActive - avgInactive if abs(diff) >= 0.3 { correlations.append(HealthMoodCorrelation( metric: "Exercise", correlation: diff > 0 ? "positive" : "negative", insight: diff > 0 ? "30+ minutes of exercise correlates with \(String(format: "%.1f", diff)) point mood boost" : "Exercise doesn't show a strong mood correlation for you", averageWithHighMetric: avgActive, averageWithLowMetric: avgInactive )) } } } return correlations } }