- Update App Group IDs from group.com.tt.feels to group.com.88oakapps.feels - Update iCloud container IDs from iCloud.com.tt.feels to iCloud.com.88oakapps.feels - Sync code constants with entitlements across all targets (iOS, Watch, Widget) - Update documentation in CLAUDE.md and PROJECT_OVERVIEW.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
420 lines
14 KiB
Swift
420 lines
14 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: - 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: - 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"
|
|
}
|
|
}
|
|
}
|
|
|