Premium Features: - Journal notes and photo attachments for mood entries - Data export (CSV and PDF reports) - Privacy lock with Face ID/Touch ID - Apple Health integration for mood correlation - 4 new personality packs (Motivational Coach, Zen Master, Best Friend, Data Analyst) Settings Tab Reorganization: - Combined Customize and Settings into single tab with segmented control - Added upgrade banner with trial countdown above segment - "Why Upgrade?" sheet showing all premium benefits - Subscribe button opens improved StoreKit 2 subscription view UI Improvements: - Enhanced subscription store with feature highlights - Entry detail view for viewing/editing notes and photos - Removed duplicate subscription banners from tab content 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
379 lines
14 KiB
Swift
379 lines
14 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
|
|
|
|
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<HKObjectType> {
|
|
[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
|
|
}
|
|
}
|