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>
532 lines
19 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|