Fix HealthKit authorization and sync issues
- Consolidate permissions into single dialog (1 write + 5 read types) - Add retry logic to wait for iOS authorization status update - Show real-time sync status feedback in settings - Reduce batch size from 100 to 50 for smoother progress updates Fixes: toggle appearing to do nothing, only one permission showing, past moods not syncing to State of Mind 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<HKSampleType> = [stateOfMindType]
|
||||
let typesToRead: Set<HKObjectType> = [stateOfMindType]
|
||||
|
||||
// Read permissions for insights + State of Mind
|
||||
let typesToRead: Set<HKObjectType> = [
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user