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 syncProgress: Double = 0
|
||||||
@Published var syncedCount: Int = 0
|
@Published var syncedCount: Int = 0
|
||||||
@Published var totalToSync: Int = 0
|
@Published var totalToSync: Int = 0
|
||||||
|
@Published var syncStatus: String = ""
|
||||||
|
|
||||||
// State of Mind sample type
|
// State of Mind sample type
|
||||||
private var stateOfMindType: HKSampleType? {
|
private var stateOfMindType: HKSampleType? {
|
||||||
HKSampleType.stateOfMindType()
|
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
|
// MARK: - Authorization
|
||||||
|
|
||||||
var isHealthKitAvailable: Bool {
|
var isHealthKitAvailable: Bool {
|
||||||
HKHealthStore.isHealthDataAvailable()
|
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 {
|
guard isHealthKitAvailable else {
|
||||||
throw HealthKitError.notAvailable
|
throw HealthKitError.notAvailable
|
||||||
}
|
}
|
||||||
@@ -43,14 +52,35 @@ class HealthKitManager: ObservableObject {
|
|||||||
throw HealthKitError.typeNotAvailable
|
throw HealthKitError.typeNotAvailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write permission for State of Mind
|
||||||
let typesToShare: Set<HKSampleType> = [stateOfMindType]
|
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)
|
try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
|
||||||
|
|
||||||
// Check authorization status
|
// Wait briefly for the authorization status to update after user interaction
|
||||||
let status = healthStore.authorizationStatus(for: stateOfMindType)
|
// The authorization sheet may have just dismissed
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 second
|
||||||
|
|
||||||
|
let status = checkAuthorizationStatus()
|
||||||
isAuthorized = status == .sharingAuthorized
|
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 {
|
func checkAuthorizationStatus() -> HKAuthorizationStatus {
|
||||||
@@ -93,17 +123,41 @@ class HealthKitManager: ObservableObject {
|
|||||||
func syncAllMoods() async {
|
func syncAllMoods() async {
|
||||||
guard isHealthKitAvailable else {
|
guard isHealthKitAvailable else {
|
||||||
logger.warning("HealthKit not available, skipping sync")
|
logger.warning("HealthKit not available, skipping sync")
|
||||||
|
syncStatus = "HealthKit not available"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard checkAuthorizationStatus() == .sharingAuthorized else {
|
// Update UI immediately
|
||||||
logger.warning("HealthKit not authorized for sharing, skipping sync")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSyncing = true
|
isSyncing = true
|
||||||
syncProgress = 0
|
syncProgress = 0
|
||||||
syncedCount = 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
|
// Fetch all mood entries
|
||||||
let allEntries = DataController.shared.getData(
|
let allEntries = DataController.shared.getData(
|
||||||
@@ -122,10 +176,13 @@ class HealthKitManager: ObservableObject {
|
|||||||
|
|
||||||
guard totalToSync > 0 else {
|
guard totalToSync > 0 else {
|
||||||
logger.info("No mood entries to sync")
|
logger.info("No mood entries to sync")
|
||||||
|
syncStatus = "No moods to sync"
|
||||||
isSyncing = false
|
isSyncing = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncStatus = "Syncing \(totalToSync) moods..."
|
||||||
|
|
||||||
// Create all samples upfront
|
// Create all samples upfront
|
||||||
let samples: [HKStateOfMind] = validEntries.map { entry in
|
let samples: [HKStateOfMind] = validEntries.map { entry in
|
||||||
HKStateOfMind(
|
HKStateOfMind(
|
||||||
@@ -138,7 +195,7 @@ class HealthKitManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save in batches for better performance
|
// Save in batches for better performance
|
||||||
let batchSize = 100
|
let batchSize = 50
|
||||||
var successCount = 0
|
var successCount = 0
|
||||||
var failCount = 0
|
var failCount = 0
|
||||||
|
|
||||||
@@ -156,9 +213,15 @@ class HealthKitManager: ObservableObject {
|
|||||||
|
|
||||||
syncedCount = batchEnd
|
syncedCount = batchEnd
|
||||||
syncProgress = Double(syncedCount) / Double(totalToSync)
|
syncProgress = Double(syncedCount) / Double(totalToSync)
|
||||||
|
syncStatus = "Synced \(syncedCount) of \(totalToSync)..."
|
||||||
}
|
}
|
||||||
|
|
||||||
isSyncing = false
|
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")
|
logger.info("HealthKit sync complete: \(successCount) succeeded, \(failCount) failed")
|
||||||
EventLogger.log(event: "healthkit_sync_complete", withData: [
|
EventLogger.log(event: "healthkit_sync_complete", withData: [
|
||||||
"total": totalToSync,
|
"total": totalToSync,
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ struct SettingsContentView: View {
|
|||||||
|
|
||||||
// MARK: - Health Kit Toggle
|
// MARK: - Health Kit Toggle
|
||||||
|
|
||||||
|
@ObservedObject private var healthKitManager = HealthKitManager.shared
|
||||||
|
|
||||||
private var healthKitToggle: some View {
|
private var healthKitToggle: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -231,17 +233,20 @@ struct SettingsContentView: View {
|
|||||||
set: { newValue in
|
set: { newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
Task {
|
Task {
|
||||||
// Request read permissions for health insights
|
// Request all permissions in a single dialog
|
||||||
let readSuccess = await healthService.requestAuthorization()
|
|
||||||
// Request write permissions for State of Mind sync
|
|
||||||
do {
|
do {
|
||||||
try await HealthKitManager.shared.requestAuthorization()
|
let authorized = try await HealthKitManager.shared.requestAllPermissions()
|
||||||
|
healthService.isEnabled = true
|
||||||
|
healthService.isAuthorized = true
|
||||||
|
|
||||||
|
if authorized {
|
||||||
// Sync all existing moods to HealthKit
|
// Sync all existing moods to HealthKit
|
||||||
await HealthKitManager.shared.syncAllMoods()
|
await HealthKitManager.shared.syncAllMoods()
|
||||||
} catch {
|
} else {
|
||||||
print("HealthKit write authorization failed: \(error)")
|
EventLogger.log(event: "healthkit_state_of_mind_not_authorized")
|
||||||
}
|
}
|
||||||
if !readSuccess {
|
} catch {
|
||||||
|
print("HealthKit authorization failed: \(error)")
|
||||||
EventLogger.log(event: "healthkit_enable_failed")
|
EventLogger.log(event: "healthkit_enable_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,14 +265,16 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
// Show sync progress
|
// Show sync progress or status
|
||||||
if HealthKitManager.shared.isSyncing {
|
if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
ProgressView(value: HealthKitManager.shared.syncProgress)
|
if healthKitManager.isSyncing {
|
||||||
|
ProgressView(value: healthKitManager.syncProgress)
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
Text("Syncing moods to Health... \(HealthKitManager.shared.syncedCount)/\(HealthKitManager.shared.totalToSync)")
|
}
|
||||||
|
Text(healthKitManager.syncStatus)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(healthKitManager.syncStatus.contains("✓") ? .green : .secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
@@ -683,6 +690,8 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// MARK: - Health Kit Toggle
|
// MARK: - Health Kit Toggle
|
||||||
|
|
||||||
|
@ObservedObject private var healthKitManager = HealthKitManager.shared
|
||||||
|
|
||||||
private var healthKitToggle: some View {
|
private var healthKitToggle: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -708,17 +717,20 @@ struct SettingsView: View {
|
|||||||
set: { newValue in
|
set: { newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
Task {
|
Task {
|
||||||
// Request read permissions for health insights
|
// Request all permissions in a single dialog
|
||||||
let readSuccess = await healthService.requestAuthorization()
|
|
||||||
// Request write permissions for State of Mind sync
|
|
||||||
do {
|
do {
|
||||||
try await HealthKitManager.shared.requestAuthorization()
|
let authorized = try await HealthKitManager.shared.requestAllPermissions()
|
||||||
|
healthService.isEnabled = true
|
||||||
|
healthService.isAuthorized = true
|
||||||
|
|
||||||
|
if authorized {
|
||||||
// Sync all existing moods to HealthKit
|
// Sync all existing moods to HealthKit
|
||||||
await HealthKitManager.shared.syncAllMoods()
|
await HealthKitManager.shared.syncAllMoods()
|
||||||
} catch {
|
} else {
|
||||||
print("HealthKit write authorization failed: \(error)")
|
EventLogger.log(event: "healthkit_state_of_mind_not_authorized")
|
||||||
}
|
}
|
||||||
if !readSuccess {
|
} catch {
|
||||||
|
print("HealthKit authorization failed: \(error)")
|
||||||
EventLogger.log(event: "healthkit_enable_failed")
|
EventLogger.log(event: "healthkit_enable_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -737,14 +749,16 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
// Show sync progress
|
// Show sync progress or status
|
||||||
if HealthKitManager.shared.isSyncing {
|
if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
ProgressView(value: HealthKitManager.shared.syncProgress)
|
if healthKitManager.isSyncing {
|
||||||
|
ProgressView(value: healthKitManager.syncProgress)
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
Text("Syncing moods to Health... \(HealthKitManager.shared.syncedCount)/\(HealthKitManager.shared.totalToSync)")
|
}
|
||||||
|
Text(healthKitManager.syncStatus)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(healthKitManager.syncStatus.contains("✓") ? .green : .secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
|
|||||||
Reference in New Issue
Block a user