// // HealthKitManager.swift // Feels // // 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.tt.ifeel", 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 // State of Mind sample type private var stateOfMindType: HKSampleType? { HKSampleType.stateOfMindType() } // MARK: - Authorization var isHealthKitAvailable: Bool { HKHealthStore.isHealthDataAvailable() } func requestAuthorization() async throws { guard isHealthKitAvailable else { throw HealthKitError.notAvailable } guard let stateOfMindType = stateOfMindType else { throw HealthKitError.typeNotAvailable } let typesToShare: Set = [stateOfMindType] let typesToRead: Set = [stateOfMindType] try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) // Check authorization status let status = healthStore.authorizationStatus(for: stateOfMindType) isAuthorized = status == .sharingAuthorized } 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 Feels 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") return } guard checkAuthorizationStatus() == .sharingAuthorized else { logger.warning("HealthKit not authorized for sharing, skipping sync") return } isSyncing = true syncProgress = 0 syncedCount = 0 // 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") isSyncing = false return } var successCount = 0 var failCount = 0 for (index, entry) in validEntries.enumerated() { do { try await saveMood(entry.mood, for: entry.forDate, note: entry.notes) successCount += 1 } catch { failCount += 1 logger.error("Failed to sync mood for \(entry.forDate): \(error.localizedDescription)") } syncedCount = index + 1 syncProgress = Double(syncedCount) / Double(totalToSync) // Add a small delay to avoid overwhelming HealthKit if index % 10 == 0 && index > 0 { try? await Task.sleep(nanoseconds: 50_000_000) // 50ms } } isSyncing = false logger.info("HealthKit sync complete: \(successCount) succeeded, \(failCount) failed") EventLogger.log(event: "healthkit_sync_complete", withData: [ "total": totalToSync, "success": successCount, "failed": failCount ]) } // MARK: - Read Mood from HealthKit func fetchMoods(from startDate: Date, to endDate: Date) async throws -> [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 Feels 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 Feels 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" } } }