Files
Reflect/Shared/HealthKitManager.swift
Trey t 440b04159e Add Apple platform features and UX improvements
- Add HealthKit State of Mind sync for mood entries
- Add Live Activity with streak display and rating time window
- Add App Shortcuts/Siri integration for voice mood logging
- Add TipKit hints for feature discovery
- Add centralized MoodLogger for consistent side effects
- Add reminder time setting in Settings with time picker
- Fix duplicate notifications when changing reminder time
- Fix Live Activity streak showing 0 when not yet rated today
- Fix slow tap response in entry detail mood selection
- Update widget timeline to refresh at rating time
- Sync widgets when reminder time changes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 17:21:55 -06:00

185 lines
5.3 KiB
Swift

//
// HealthKitManager.swift
// Feels
//
// HealthKit State of Mind API integration for syncing mood data with Apple Health
//
import Foundation
import HealthKit
@MainActor
class HealthKitManager: ObservableObject {
static let shared = HealthKitManager()
private let healthStore = HKHealthStore()
@Published var isAuthorized = false
@Published var authorizationError: Error?
// 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: - 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"
}
}
}