Save 100 samples at a time instead of individually. Removes per-entry delays and uses healthStore.save([batch]) for much faster throughput. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
272 lines
8.3 KiB
Swift
272 lines
8.3 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
|
|
}
|
|
|
|
// Create all samples upfront
|
|
let samples: [HKStateOfMind] = validEntries.map { entry in
|
|
HKStateOfMind(
|
|
date: entry.forDate,
|
|
kind: .dailyMood,
|
|
valence: moodToValence(entry.mood),
|
|
labels: labelsForMood(entry.mood),
|
|
associations: [.currentEvents]
|
|
)
|
|
}
|
|
|
|
// Save in batches for better performance
|
|
let batchSize = 100
|
|
var successCount = 0
|
|
var failCount = 0
|
|
|
|
for batchStart in stride(from: 0, to: samples.count, by: batchSize) {
|
|
let batchEnd = min(batchStart + batchSize, samples.count)
|
|
let batch = Array(samples[batchStart..<batchEnd])
|
|
|
|
do {
|
|
try await healthStore.save(batch)
|
|
successCount += batch.count
|
|
} catch {
|
|
failCount += batch.count
|
|
logger.error("Failed to sync batch starting at \(batchStart): \(error.localizedDescription)")
|
|
}
|
|
|
|
syncedCount = batchEnd
|
|
syncProgress = Double(syncedCount) / Double(totalToSync)
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
}
|
|
|