diff --git a/Shared/HealthKitManager.swift b/Shared/HealthKitManager.swift index 7a5c787..67df702 100644 --- a/Shared/HealthKitManager.swift +++ b/Shared/HealthKitManager.swift @@ -22,19 +22,28 @@ class HealthKitManager: ObservableObject { @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) + 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)! + // MARK: - Authorization var isHealthKitAvailable: Bool { HKHealthStore.isHealthDataAvailable() } - func requestAuthorization() async throws { + /// 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 } @@ -43,14 +52,35 @@ class HealthKitManager: ObservableObject { throw HealthKitError.typeNotAvailable } + // Write permission for State of Mind let typesToShare: Set = [stateOfMindType] - let typesToRead: Set = [stateOfMindType] + + // Read permissions for insights + State of Mind + let typesToRead: Set = [ + stateOfMindType, + stepCountType, + exerciseTimeType, + heartRateType, + sleepAnalysisType + ] + + logger.info("Requesting HealthKit permissions: share=1, read=5") try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) - // Check authorization status - let status = healthStore.authorizationStatus(for: stateOfMindType) + // 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 { @@ -93,17 +123,41 @@ class HealthKitManager: ObservableObject { func syncAllMoods() async { guard isHealthKitAvailable else { logger.warning("HealthKit not available, skipping sync") + syncStatus = "HealthKit not available" return } - guard checkAuthorizationStatus() == .sharingAuthorized else { - logger.warning("HealthKit not authorized for sharing, skipping sync") - 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( @@ -122,10 +176,13 @@ class HealthKitManager: ObservableObject { 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( @@ -138,7 +195,7 @@ class HealthKitManager: ObservableObject { } // Save in batches for better performance - let batchSize = 100 + let batchSize = 50 var successCount = 0 var failCount = 0 @@ -156,9 +213,15 @@ class HealthKitManager: ObservableObject { syncedCount = batchEnd syncProgress = Double(syncedCount) / Double(totalToSync) + syncStatus = "Synced \(syncedCount) of \(totalToSync)..." } isSyncing = false + if failCount == 0 { + syncStatus = "✓ Synced \(successCount) moods to Health" + } else { + syncStatus = "Synced \(successCount), failed \(failCount)" + } logger.info("HealthKit sync complete: \(successCount) succeeded, \(failCount) failed") EventLogger.log(event: "healthkit_sync_complete", withData: [ "total": totalToSync, diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index aefbc1b..2536d90 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -206,6 +206,8 @@ struct SettingsContentView: View { // MARK: - Health Kit Toggle + @ObservedObject private var healthKitManager = HealthKitManager.shared + private var healthKitToggle: some View { VStack(spacing: 0) { HStack(spacing: 12) { @@ -231,17 +233,20 @@ struct SettingsContentView: View { set: { newValue in if newValue { Task { - // Request read permissions for health insights - let readSuccess = await healthService.requestAuthorization() - // Request write permissions for State of Mind sync + // Request all permissions in a single dialog do { - try await HealthKitManager.shared.requestAuthorization() - // Sync all existing moods to HealthKit - await HealthKitManager.shared.syncAllMoods() + let authorized = try await HealthKitManager.shared.requestAllPermissions() + healthService.isEnabled = true + healthService.isAuthorized = true + + if authorized { + // Sync all existing moods to HealthKit + await HealthKitManager.shared.syncAllMoods() + } else { + EventLogger.log(event: "healthkit_state_of_mind_not_authorized") + } } catch { - print("HealthKit write authorization failed: \(error)") - } - if !readSuccess { + print("HealthKit authorization failed: \(error)") EventLogger.log(event: "healthkit_enable_failed") } } @@ -260,14 +265,16 @@ struct SettingsContentView: View { } .padding() - // Show sync progress - if HealthKitManager.shared.isSyncing { + // Show sync progress or status + if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty { VStack(spacing: 4) { - ProgressView(value: HealthKitManager.shared.syncProgress) - .tint(.red) - Text("Syncing moods to Health... \(HealthKitManager.shared.syncedCount)/\(HealthKitManager.shared.totalToSync)") + if healthKitManager.isSyncing { + ProgressView(value: healthKitManager.syncProgress) + .tint(.red) + } + Text(healthKitManager.syncStatus) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(healthKitManager.syncStatus.contains("✓") ? .green : .secondary) } .padding(.horizontal) .padding(.bottom, 12) @@ -683,6 +690,8 @@ struct SettingsView: View { // MARK: - Health Kit Toggle + @ObservedObject private var healthKitManager = HealthKitManager.shared + private var healthKitToggle: some View { VStack(spacing: 0) { HStack(spacing: 12) { @@ -708,17 +717,20 @@ struct SettingsView: View { set: { newValue in if newValue { Task { - // Request read permissions for health insights - let readSuccess = await healthService.requestAuthorization() - // Request write permissions for State of Mind sync + // Request all permissions in a single dialog do { - try await HealthKitManager.shared.requestAuthorization() - // Sync all existing moods to HealthKit - await HealthKitManager.shared.syncAllMoods() + let authorized = try await HealthKitManager.shared.requestAllPermissions() + healthService.isEnabled = true + healthService.isAuthorized = true + + if authorized { + // Sync all existing moods to HealthKit + await HealthKitManager.shared.syncAllMoods() + } else { + EventLogger.log(event: "healthkit_state_of_mind_not_authorized") + } } catch { - print("HealthKit write authorization failed: \(error)") - } - if !readSuccess { + print("HealthKit authorization failed: \(error)") EventLogger.log(event: "healthkit_enable_failed") } } @@ -737,14 +749,16 @@ struct SettingsView: View { } .padding() - // Show sync progress - if HealthKitManager.shared.isSyncing { + // Show sync progress or status + if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty { VStack(spacing: 4) { - ProgressView(value: HealthKitManager.shared.syncProgress) - .tint(.red) - Text("Syncing moods to Health... \(HealthKitManager.shared.syncedCount)/\(HealthKitManager.shared.totalToSync)") + if healthKitManager.isSyncing { + ProgressView(value: healthKitManager.syncProgress) + .tint(.red) + } + Text(healthKitManager.syncStatus) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(healthKitManager.syncStatus.contains("✓") ? .green : .secondary) } .padding(.horizontal) .padding(.bottom, 12)