Add HealthKit backfill to sync all existing moods
When user enables HealthKit integration, automatically syncs all previously recorded mood entries to Apple Health. Features: - Progress tracking with visual indicator in Settings - Batched saves with throttling to avoid overwhelming HealthKit - Success/failure logging with EventLogger 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,15 +7,21 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import HealthKit
|
import HealthKit
|
||||||
|
import os.log
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class HealthKitManager: ObservableObject {
|
class HealthKitManager: ObservableObject {
|
||||||
static let shared = HealthKitManager()
|
static let shared = HealthKitManager()
|
||||||
|
|
||||||
private let healthStore = HKHealthStore()
|
private let healthStore = HKHealthStore()
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.ifeel", category: "HealthKit")
|
||||||
|
|
||||||
@Published var isAuthorized = false
|
@Published var isAuthorized = false
|
||||||
@Published var authorizationError: Error?
|
@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
|
// State of Mind sample type
|
||||||
private var stateOfMindType: HKSampleType? {
|
private var stateOfMindType: HKSampleType? {
|
||||||
@@ -80,6 +86,76 @@ class HealthKitManager: ObservableObject {
|
|||||||
try await healthStore.save(stateOfMind)
|
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
|
// MARK: - Read Mood from HealthKit
|
||||||
|
|
||||||
func fetchMoods(from startDate: Date, to endDate: Date) async throws -> [HKStateOfMind] {
|
func fetchMoods(from startDate: Date, to endDate: Date) async throws -> [HKStateOfMind] {
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ struct SettingsContentView: View {
|
|||||||
// Request write permissions for State of Mind sync
|
// Request write permissions for State of Mind sync
|
||||||
do {
|
do {
|
||||||
try await HealthKitManager.shared.requestAuthorization()
|
try await HealthKitManager.shared.requestAuthorization()
|
||||||
|
// Sync all existing moods to HealthKit
|
||||||
|
await HealthKitManager.shared.syncAllMoods()
|
||||||
} catch {
|
} catch {
|
||||||
print("HealthKit write authorization failed: \(error)")
|
print("HealthKit write authorization failed: \(error)")
|
||||||
}
|
}
|
||||||
@@ -259,6 +261,19 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
|
// Show sync progress
|
||||||
|
if HealthKitManager.shared.isSyncing {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ProgressView(value: HealthKitManager.shared.syncProgress)
|
||||||
|
.tint(.red)
|
||||||
|
Text("Syncing moods to Health... \(HealthKitManager.shared.syncedCount)/\(HealthKitManager.shared.totalToSync)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -708,6 +723,8 @@ struct SettingsView: View {
|
|||||||
// Request write permissions for State of Mind sync
|
// Request write permissions for State of Mind sync
|
||||||
do {
|
do {
|
||||||
try await HealthKitManager.shared.requestAuthorization()
|
try await HealthKitManager.shared.requestAuthorization()
|
||||||
|
// Sync all existing moods to HealthKit
|
||||||
|
await HealthKitManager.shared.syncAllMoods()
|
||||||
} catch {
|
} catch {
|
||||||
print("HealthKit write authorization failed: \(error)")
|
print("HealthKit write authorization failed: \(error)")
|
||||||
}
|
}
|
||||||
@@ -729,6 +746,19 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
|
// Show sync progress
|
||||||
|
if HealthKitManager.shared.isSyncing {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ProgressView(value: HealthKitManager.shared.syncProgress)
|
||||||
|
.tint(.red)
|
||||||
|
Text("Syncing moods to Health... \(HealthKitManager.shared.syncedCount)/\(HealthKitManager.shared.totalToSync)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
|
|||||||
Reference in New Issue
Block a user