- 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>
185 lines
5.3 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|
|
|