Files
Reflect/Shared/HealthKitManager.swift
Trey t 373d481613 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>
2025-12-20 00:55:25 -06:00

261 lines
7.9 KiB
Swift

//
// 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<HKSampleType> = [stateOfMindType]
let typesToRead: Set<HKObjectType> = [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"
}
}
}