Files
Reflect/Shared/HealthKitManager.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

448 lines
15 KiB
Swift

//
// HealthKitManager.swift
// Reflect
//
// 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.88oakapps.reflect", 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 Reflect 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")
AnalyticsManager.shared.track(.healthKitSyncCompleted(total: totalToSync, success: successCount, failed: failCount))
}
// MARK: - Delete All Moods from HealthKit
/// Deletes all State of Mind samples created by this app
/// Note: HealthKit only allows deleting samples that your app created
func deleteAllMoods() async throws -> Int {
guard isHealthKitAvailable else {
throw HealthKitError.notAvailable
}
guard let stateOfMindType = stateOfMindType else {
throw HealthKitError.typeNotAvailable
}
guard checkAuthorizationStatus() == .sharingAuthorized else {
throw HealthKitError.notAuthorized
}
logger.info("Starting deletion of all State of Mind samples from this app")
// Fetch all State of Mind samples (HealthKit will only return ones we can delete - our own)
let samples = try await fetchMoods(
from: Date(timeIntervalSince1970: 0),
to: Date().addingTimeInterval(86400) // Include today + 1 day buffer
)
guard !samples.isEmpty else {
logger.info("No State of Mind samples found to delete")
return 0
}
logger.info("Found \(samples.count) State of Mind samples to delete")
// Delete in batches
let batchSize = 50
var deletedCount = 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.delete(batch)
deletedCount += batch.count
logger.info("Deleted batch \(batchStart/batchSize + 1): \(batch.count) samples")
} catch {
logger.error("Failed to delete batch starting at \(batchStart): \(error.localizedDescription)")
throw error
}
}
logger.info("Successfully deleted \(deletedCount) State of Mind samples from HealthKit")
return deletedCount
}
// MARK: - Delete Mood for Date from HealthKit
/// Deletes State of Mind samples created by this app for a specific date
/// Note: HealthKit only allows deleting samples that your app created
func deleteMood(for date: Date) async throws {
guard isHealthKitAvailable else {
throw HealthKitError.notAvailable
}
guard let stateOfMindType = stateOfMindType else {
throw HealthKitError.typeNotAvailable
}
guard checkAuthorizationStatus() == .sharingAuthorized else {
throw HealthKitError.notAuthorized
}
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: date)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return }
let samples = try await fetchMoods(from: dayStart, to: dayEnd)
guard !samples.isEmpty else {
logger.info("No State of Mind samples found for \(date) to delete")
return
}
try await healthStore.delete(samples)
logger.info("Deleted \(samples.count) State of Mind samples for \(date)")
}
// 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 Reflect 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 Reflect 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"
}
}
}