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>
This commit is contained in:
184
Shared/HealthKitManager.swift
Normal file
184
Shared/HealthKitManager.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
//
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user