Files
Reflect/Shared/Services/HealthService.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

532 lines
19 KiB
Swift

//
// 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<HKObjectType> {
[
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 {
print("HealthService: HealthKit not available on this device")
return false
}
do {
try await healthStore.requestAuthorization(toShare: [], read: readTypes)
isAuthorized = true
isEnabled = true
AnalyticsManager.shared.track(.healthKitAuthorized)
return true
} catch {
print("HealthService: Authorization failed: \(error.localizedDescription)")
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
)
}
}