Files
Reflect/Shared/HealthKitManager.swift
Trey t e5656f47fd Rename iFeels to Feels across entire codebase
- Bundle IDs: com.tt.ifeel* → com.tt.feels*
- App Groups: group.com.tt.ifeel* → group.com.tt.feels*
- iCloud containers: iCloud.com.tt.ifeel* → iCloud.com.tt.feels*
- IAP product IDs: com.tt.ifeel.IAP.* → com.tt.feels.IAP.*
- URLs: ifeels.app → feels.app
- Logger subsystems and dispatch queues
- Product names and display names

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:57:44 -06:00

366 lines
12 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.feels", 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
@Published var syncStatus: String = ""
// State of Mind sample type
private var stateOfMindType: HKSampleType? {
HKSampleType.stateOfMindType()
}
// Health data types for insights (read-only)
// Core activity metrics
private let stepCountType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
private let exerciseTimeType = HKQuantityType.quantityType(forIdentifier: .appleExerciseTime)!
private let activeEnergyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
private let distanceType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!
// Heart & stress indicators
private let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
private let restingHeartRateType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate)!
private let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!
// Sleep & recovery
private let sleepAnalysisType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)!
// Mindfulness
private let mindfulSessionType = HKCategoryType.categoryType(forIdentifier: .mindfulSession)!
// Workouts
private let workoutType = HKWorkoutType.workoutType()
// MARK: - Authorization
var isHealthKitAvailable: Bool {
HKHealthStore.isHealthDataAvailable()
}
/// 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
}
guard let stateOfMindType = stateOfMindType else {
throw HealthKitError.typeNotAvailable
}
// Write permission for State of Mind
let typesToShare: Set<HKSampleType> = [stateOfMindType]
// Read permissions for mood-health correlation insights
// These help Apple's AI provide personalized health insights
let typesToRead: Set<HKObjectType> = [
// State of Mind (read back our own data)
stateOfMindType,
// Activity - correlates with mood and energy levels
stepCountType,
exerciseTimeType,
activeEnergyType,
distanceType,
workoutType,
// Heart metrics - stress and recovery indicators
heartRateType,
restingHeartRateType,
hrvType,
// Sleep - strong correlation with mood
sleepAnalysisType,
// Mindfulness - meditation impact on mood
mindfulSessionType
]
logger.info("Requesting HealthKit permissions: share=1, read=\(typesToRead.count)")
try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
// 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 {
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")
syncStatus = "HealthKit not available"
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(
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")
syncStatus = "No moods to sync"
isSyncing = false
return
}
syncStatus = "Syncing \(totalToSync) moods..."
// 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 = 50
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)
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,
"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"
}
}
}