Files
Reflect/Shared/Services/HealthService.swift
Trey t e0330dbc8d Replace EventLogger with typed AnalyticsManager using PostHog
Complete analytics overhaul: delete EventLogger.swift, create Analytics.swift
with typed event enum (~45 events), screen tracking, super properties
(theme, icon pack, voting layout, etc.), session replay with kill switch,
autocapture, and network telemetry. Replace all 99 call sites across 38 files
with compiler-enforced typed events in object_action naming convention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:12:33 -06:00

528 lines
19 KiB
Swift

//
// 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
// Core activity metrics
private let stepCountType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
private let exerciseTimeType = HKQuantityType.quantityType(forIdentifier: .appleExerciseTime)!
private let activeEnergyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
private let distanceType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!
// Heart & stress indicators
private let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
private let restingHeartRateType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate)!
private let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!
// Sleep & recovery
private let sleepAnalysisType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)!
// Mindfulness
private let mindfulSessionType = HKCategoryType.categoryType(forIdentifier: .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)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
// 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
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: - 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
)
}
}