// // HealthKitManager.swift // Reflect // // HealthKit State of Mind API integration for syncing mood data with Apple Health // import Foundation import HealthKit import os.log @MainActor class HealthKitManager: ObservableObject { static let shared = HealthKitManager() private let healthStore = HKHealthStore() private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect", category: "HealthKit") @Published var isAuthorized = false @Published var authorizationError: Error? @Published var isSyncing = false @Published var syncProgress: Double = 0 @Published var syncedCount: Int = 0 @Published var totalToSync: Int = 0 @Published var syncStatus: String = "" // State of Mind sample type private var stateOfMindType: HKSampleType? { HKSampleType.stateOfMindType() } // Health data types for insights (read-only) // 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)! // Workouts private let workoutType = HKWorkoutType.workoutType() // MARK: - Authorization var isHealthKitAvailable: Bool { HKHealthStore.isHealthDataAvailable() } /// Request all HealthKit permissions in a single dialog /// Returns true if State of Mind write access was granted func requestAllPermissions() async throws -> Bool { guard isHealthKitAvailable else { throw HealthKitError.notAvailable } guard let stateOfMindType = stateOfMindType else { throw HealthKitError.typeNotAvailable } // Write permission for State of Mind let typesToShare: Set = [stateOfMindType] // Read permissions for mood-health correlation insights // These help Apple's AI provide personalized health insights let typesToRead: Set = [ // State of Mind (read back our own data) stateOfMindType, // Activity - correlates with mood and energy levels stepCountType, exerciseTimeType, activeEnergyType, distanceType, workoutType, // Heart metrics - stress and recovery indicators heartRateType, restingHeartRateType, hrvType, // Sleep - strong correlation with mood sleepAnalysisType, // Mindfulness - meditation impact on mood mindfulSessionType ] logger.info("Requesting HealthKit permissions: share=1, read=\(typesToRead.count)") try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) // Wait briefly for the authorization status to update after user interaction // The authorization sheet may have just dismissed try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 second let status = checkAuthorizationStatus() isAuthorized = status == .sharingAuthorized logger.info("HealthKit authorization result: \(status.rawValue), isAuthorized=\(self.isAuthorized)") return isAuthorized } func requestAuthorization() async throws { _ = try await requestAllPermissions() } func checkAuthorizationStatus() -> HKAuthorizationStatus { guard let stateOfMindType = stateOfMindType else { return .notDetermined } return healthStore.authorizationStatus(for: stateOfMindType) } // MARK: - Save Mood to HealthKit func saveMood(_ mood: Mood, for date: Date, note: String? = nil) async throws { guard isHealthKitAvailable else { throw HealthKitError.notAvailable } guard checkAuthorizationStatus() == .sharingAuthorized else { throw HealthKitError.notAuthorized } // Convert Reflect mood to HealthKit valence (-1 to 1 scale) let valence = moodToValence(mood) // Create State of Mind sample let stateOfMind = HKStateOfMind( date: date, kind: .dailyMood, valence: valence, labels: labelsForMood(mood), associations: [.currentEvents] ) try await healthStore.save(stateOfMind) } // MARK: - Sync All Existing Moods to HealthKit /// Syncs all existing mood entries from the app to HealthKit /// Call this after HealthKit authorization is granted to backfill historical data func syncAllMoods() async { guard isHealthKitAvailable else { logger.warning("HealthKit not available, skipping sync") syncStatus = "HealthKit not available" return } // Update UI immediately isSyncing = true syncProgress = 0 syncedCount = 0 syncStatus = "Preparing to sync..." // Check authorization with retry - user may have just granted permission var authorized = checkAuthorizationStatus() == .sharingAuthorized if !authorized { logger.info("Waiting for authorization status to update...") syncStatus = "Checking permissions..." // Retry a few times with delay - iOS may take a moment to update status for attempt in 1...5 { try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 second authorized = checkAuthorizationStatus() == .sharingAuthorized if authorized { logger.info("Authorization confirmed on attempt \(attempt)") break } } } guard authorized else { logger.warning("HealthKit not authorized for sharing after retries, skipping sync") syncStatus = "Permission not granted for State of Mind" isSyncing = false return } syncStatus = "Loading mood entries..." // Fetch all mood entries let allEntries = DataController.shared.getData( startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: [1, 2, 3, 4, 5, 6, 7] ) // Filter out missing and placeholder entries let validEntries = allEntries.filter { entry in entry.mood != .missing && entry.mood != .placeholder } totalToSync = validEntries.count logger.info("Starting HealthKit sync for \(validEntries.count) mood entries") guard totalToSync > 0 else { logger.info("No mood entries to sync") syncStatus = "No moods to sync" isSyncing = false return } syncStatus = "Syncing \(totalToSync) moods..." // Create all samples upfront let samples: [HKStateOfMind] = validEntries.map { entry in HKStateOfMind( date: entry.forDate, kind: .dailyMood, valence: moodToValence(entry.mood), labels: labelsForMood(entry.mood), associations: [.currentEvents] ) } // Save in batches for better performance let batchSize = 50 var successCount = 0 var failCount = 0 for batchStart in stride(from: 0, to: samples.count, by: batchSize) { let batchEnd = min(batchStart + batchSize, samples.count) let batch = Array(samples[batchStart.. Int { guard isHealthKitAvailable else { throw HealthKitError.notAvailable } guard let stateOfMindType = stateOfMindType else { throw HealthKitError.typeNotAvailable } guard checkAuthorizationStatus() == .sharingAuthorized else { throw HealthKitError.notAuthorized } logger.info("Starting deletion of all State of Mind samples from this app") // Fetch all State of Mind samples (HealthKit will only return ones we can delete - our own) let samples = try await fetchMoods( from: Date(timeIntervalSince1970: 0), to: Date().addingTimeInterval(86400) // Include today + 1 day buffer ) guard !samples.isEmpty else { logger.info("No State of Mind samples found to delete") return 0 } logger.info("Found \(samples.count) State of Mind samples to delete") // Delete in batches let batchSize = 50 var deletedCount = 0 for batchStart in stride(from: 0, to: samples.count, by: batchSize) { let batchEnd = min(batchStart + batchSize, samples.count) let batch = Array(samples[batchStart.. [HKStateOfMind] { guard isHealthKitAvailable else { throw HealthKitError.notAvailable } guard let stateOfMindType = stateOfMindType else { throw HealthKitError.typeNotAvailable } let predicate = HKQuery.predicateForSamples( withStart: startDate, end: endDate, options: .strictStartDate ) let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false) return try await withCheckedThrowingContinuation { continuation in let query = HKSampleQuery( sampleType: stateOfMindType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor] ) { _, samples, error in if let error = error { continuation.resume(throwing: error) return } let stateOfMindSamples = samples?.compactMap { $0 as? HKStateOfMind } ?? [] continuation.resume(returning: stateOfMindSamples) } healthStore.execute(query) } } // MARK: - Conversion Helpers /// Convert Reflect Mood to HealthKit valence (-1 to 1) private func moodToValence(_ mood: Mood) -> Double { switch mood { case .horrible: return -1.0 case .bad: return -0.5 case .average: return 0.0 case .good: return 0.5 case .great: return 1.0 case .missing, .placeholder: return 0.0 } } /// Convert HealthKit valence to Reflect Mood func valenceToMood(_ valence: Double) -> Mood { switch valence { case ..<(-0.75): return .horrible case -0.75..<(-0.25): return .bad case -0.25..<0.25: return .average case 0.25..<0.75: return .good default: return .great } } /// Get HealthKit labels for a mood private func labelsForMood(_ mood: Mood) -> [HKStateOfMind.Label] { switch mood { case .horrible: return [.sad, .stressed, .anxious] case .bad: return [.sad, .stressed] case .average: return [.calm, .indifferent] case .good: return [.happy, .calm, .content] case .great: return [.happy, .excited, .joyful] case .missing, .placeholder: return [] } } } // MARK: - Errors enum HealthKitError: LocalizedError { case notAvailable case notAuthorized case typeNotAvailable var errorDescription: String? { switch self { case .notAvailable: return "HealthKit is not available on this device" case .notAuthorized: return "HealthKit access not authorized" case .typeNotAvailable: return "State of Mind type not available" } } }