mycode/myApp/HaruDanim/IOS/Core/HealthStore.swift
songyc macbook 78bad15852 feat(1.5-p3): 건강 다짐 — 8지표+운동 종목 20종, 값 공급 이음새로 §4.2 수학 무변경
- Quest 필드 3종(healthMetricRaw·수면 구간, CloudKit 기본값 규칙)+행동/꼬리표 우선 정규화
- HealthQuestTarget/HealthWorkoutKind(종목 큐레이션)·HealthCache 공유 계층 이전(위젯 읽기)
- QuestProgress 값 공급 단일 지점 3곳(value·spanValue·perDayValues)만 분기 — 기존 49건 무변경 그린
- 편집기 대상 3유형째(지표·종목·수면 구간 휠·단위별 목표 입력·프리미엄 게이트·하루 전용)
- §8ⓒⓕ: 데이터 없는 기기 모수 제외+'데이터 없음' 표기(목표 행·모음 카드·위젯 ②③④)
  +자동/수동 판정 보류, 위젯 값 라벨 지표 단위화, HealthStore 백필(400일)·운동 조회·부트스트랩
- 검증: progressSelfTest 61건(H 시리즈 12 신규)·20·27·10 ALL PASS(26.5+18.5),
  Debug/Store/워치 빌드, 시각 QA(목표 행·편집기·타일). 신규 인자 3종 §14 기록

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-22 06:07:25 +09:00

534 lines
26 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// HealthStore.swift
// Haru_Danim
//
// (HealthKit) (1.5 Docs/plan-1.5.md §3.2)
// :
// - HealthKit . HealthCache(App Group) ,
// · · ( HK )
// - '' SwiftData·CloudKit ( 5.1.3
// HealthKit iCloud ). defaults
// - ( )
// " " CTA/
// - (DayMath) . ' '( 21~
// 09) ( plan §3.4)
//
import Foundation
import HealthKit
import Observation
import SwiftData
import UIKit
// (HealthCache·HealthSupport·HealthQuestValues Shared/HealthMetric.swift
// · . [HK ].)
// MARK: - ( )
@MainActor
@Observable
final class HealthDataStore {
static let shared = HealthDataStore()
private let store = HKHealthStore()
/// ( )
private(set) var todayValues: [HealthMetric: Double] = [:]
/// (Designed for iPad) HealthKit false
static var isAvailable: Bool {
#if DEBUG
if UserDefaults.standard.bool(forKey: "seedHealthCache") { return true }
#endif
return HKHealthStore.isHealthDataAvailable() && !DeviceLayout.isMac
}
/// ( CTA )
var hasRequestedAuth: Bool {
#if DEBUG
if UserDefaults.standard.bool(forKey: "seedHealthCache") { return true }
#endif
return AppGroup.defaults.bool(forKey: "health.authRequested")
}
private init() {
// ( )
NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main
) { _ in
Task { @MainActor in HealthDataStore.shared.bootstrap() }
}
}
/// · (§8 ) + +
func bootstrap() {
AppGroup.defaults.set(Self.isAvailable, forKey: HealthSupport.deviceAvailableKey)
guard Self.isAvailable, hasRequestedAuth else { return }
Task { @MainActor in
await self.refreshRecent()
await self.backfillIfNeeded()
}
}
/// 8 + ( , )
static var readTypes: Set<HKObjectType> {
[
HKQuantityType(.stepCount),
HKQuantityType(.appleExerciseTime),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.distanceWalkingRunning),
HKQuantityType(.dietaryWater),
HKCategoryType(.sleepAnalysis),
HKCategoryType(.appleStandHour),
HKCategoryType(.mindfulSession),
HKObjectType.workoutType(),
]
}
func requestAuthorization() async {
guard HKHealthStore.isHealthDataAvailable() else { return }
try? await store.requestAuthorization(toShare: [], read: Self.readTypes)
AppGroup.defaults.set(true, forKey: "health.authRequested")
await refreshRecent()
await backfillIfNeeded()
}
/// ( )
func refreshToday() async {
#if DEBUG
if UserDefaults.standard.bool(forKey: "seedHealthCache") {
let seeded: [HealthMetric: Double] = [
.steps: 8432, .exerciseMinutes: 32 * 60, .sleep: 7 * 3600 + 28 * 60,
.activeEnergy: 512, .distance: 5230, .standHours: 9,
.mindfulMinutes: 10 * 60, .water: 1200,
]
todayValues = seeded
let math = DayMath()
let todayKey = math.dayKey(for: .now)
// 7 · .
// ()· .
let cal = math.calendar
for offset in 0...7 {
guard let key = cal.date(byAdding: .day, value: -offset, to: todayKey) else { continue }
var values = Dictionary(uniqueKeysWithValues: seeded.map { ($0.key.rawValue, $0.value) })
values["workout.running"] = 31 * 60
values["sleep@1260-540"] = seeded[.sleep] ?? 0
if offset > 0 {
// ( )
values = values.mapValues { ($0 * 0.9).rounded() }
}
HealthCache.setValues(values, dayKey: key)
}
return
}
#endif
await refreshRecent(days: 1)
}
/// ( 3)
/// · , .
/// ( · ) .
func refreshRecent(days: Int = 3) async {
#if DEBUG
if UserDefaults.standard.bool(forKey: "seedHealthCache") { await refreshToday(); return }
#endif
guard Self.isAvailable, hasRequestedAuth else { return }
let math = DayMath()
let now = Date.now
let cal = math.calendar
let todayKey = math.dayKey(for: now)
let keys = questHealthKeys()
for offset in 0..<max(1, days) {
guard let key = cal.date(byAdding: .day, value: -offset, to: todayKey) else { continue }
let values = await allValues(
forDayKey: key, math: math,
sleepWindows: keys.sleepWindows, workoutKinds: keys.workoutKinds
)
HealthCache.setValues(values, dayKey: key)
if offset == 0 {
var tiles: [HealthMetric: Double] = [:]
for metric in HealthMetric.allCases { tiles[metric] = values[metric.rawValue] ?? 0 }
todayValues = tiles
}
}
}
/// ( )·
private func questHealthKeys() -> (sleepWindows: Set<String>, workoutKinds: Set<HealthWorkoutKind>) {
var windows: Set<String> = [Self.defaultSleepSpec]
var kinds: Set<HealthWorkoutKind> = []
let quests = (try? DataStore.shared.mainContext.fetch(FetchDescriptor<Quest>())) ?? []
for quest in quests {
switch quest.healthTarget {
case .metric(.sleep):
windows.insert("\(quest.sleepWindowStartMinutes)-\(quest.sleepWindowEndMinutes)")
case .workout(let kind):
kinds.insert(kind)
default:
break
}
}
return (windows, kinds)
}
/// ( 21:00~ 09:00) "sleep"
static let defaultSleepSpec = "1260-540"
/// "-" ( , ). = 24
static func windowSpec(_ spec: String) -> (endMinutes: Int, length: TimeInterval)? {
let parts = spec.split(separator: "-").compactMap { Int(String($0)) }
guard parts.count == 2, (0...1439).contains(parts[0]), (0...1439).contains(parts[1]) else { return nil }
var minutes = ((parts[1] - parts[0]) % 1440 + 1440) % 1440
if minutes == 0 { minutes = 1440 }
return (parts[1], TimeInterval(minutes * 60))
}
/// +
private func allValues(forDayKey key: Date, math: DayMath,
sleepWindows: Set<String>, workoutKinds: Set<HealthWorkoutKind>) async -> [String: Double] {
let range = math.dayRange(forKey: key)
let anchor = range.lowerBound
var result: [String: Double] = [:]
for metric in HealthMetric.allCases where metric != .sleep {
result[metric.rawValue] = await value(of: metric, forDayContaining: anchor, math: math)
}
for spec in sleepWindows {
guard let parsed = Self.windowSpec(spec) else { continue }
let window = Self.sleepWindow(forDayRange: range, endMinutes: parsed.endMinutes, length: parsed.length)
let value = await sleepDuration(window: window)
result["sleep@\(spec)"] = value
if spec == Self.defaultSleepSpec { result[HealthMetric.sleep.rawValue] = value }
}
if !workoutKinds.isEmpty {
let byKind = await workoutDurations(in: range)
for kind in workoutKinds {
result["workout.\(kind.rawValue)"] = byKind[kind] ?? 0
}
}
return result
}
// MARK: ( HealthMetric )
/// date
func value(of metric: HealthMetric, forDayContaining date: Date, math: DayMath) async -> Double {
let range = math.dayRange(containing: date)
switch metric {
case .steps:
return await sumQuantity(.stepCount, unit: .count(), in: range)
case .exerciseMinutes:
return await sumQuantity(.appleExerciseTime, unit: .minute(), in: range) * 60
case .activeEnergy:
return await sumQuantity(.activeEnergyBurned, unit: .kilocalorie(), in: range)
case .distance:
return await sumQuantity(.distanceWalkingRunning, unit: .meter(), in: range)
case .water:
// ml ( HealthMetric )
return await sumQuantity(.dietaryWater, unit: .literUnit(with: .milli), in: range)
case .standHours:
let samples = await categorySamples(.appleStandHour, in: range)
return Double(samples.count { $0.value == HKCategoryValueAppleStandHour.stood.rawValue })
case .mindfulMinutes:
let samples = await categorySamples(.mindfulSession, in: range)
return samples.reduce(0) { total, sample in
total + overlapDuration(sample, range: range)
}
case .sleep:
return await sleepDuration(window: Self.sleepWindow(forDayRange: range))
}
}
/// range " ( 9) "
/// 12 . (plan §10 R8)
static func sleepWindow(forDayRange range: Range<Date>,
endMinutes: Int = 9 * 60, length: TimeInterval = 12 * 3600) -> Range<Date> {
let calendar = Calendar.current
// range " " range
for dayOffset in 0...1 {
guard let base = calendar.date(byAdding: .day, value: dayOffset,
to: calendar.startOfDay(for: range.lowerBound)),
let end = calendar.date(byAdding: .minute, value: endMinutes, to: base) else { continue }
if range.contains(end) || end == range.lowerBound {
return end.addingTimeInterval(-length)..<end
}
}
// range
return range.lowerBound.addingTimeInterval(-length)..<range.lowerBound
}
/// '' (+ )
private func sleepDuration(window: Range<Date>) async -> Double {
let samples = await categorySamples(.sleepAnalysis, in: window)
let asleepValues: Set<Int> = Set(HKCategoryValueSleepAnalysis.allAsleepValues.map(\.rawValue))
let intervals: [(Date, Date)] = samples.compactMap { sample in
guard asleepValues.contains(sample.value) else { return nil }
let start = max(sample.startDate, window.lowerBound)
let end = min(sample.endDate, window.upperBound)
guard end > start else { return nil }
return (start, end)
}
return Self.mergedDuration(intervals)
}
/// ( )
static func mergedDuration(_ intervals: [(Date, Date)]) -> TimeInterval {
let sorted = intervals.sorted { $0.0 < $1.0 }
var total: TimeInterval = 0
var currentStart: Date?
var currentEnd: Date?
for (start, end) in sorted {
if let cs = currentStart, let ce = currentEnd {
if start <= ce {
currentEnd = max(ce, end)
} else {
total += ce.timeIntervalSince(cs)
currentStart = start
currentEnd = end
}
} else {
currentStart = start
currentEnd = end
}
}
if let cs = currentStart, let ce = currentEnd {
total += ce.timeIntervalSince(cs)
}
return total
}
private func overlapDuration(_ sample: HKSample, range: Range<Date>) -> TimeInterval {
let start = max(sample.startDate, range.lowerBound)
let end = min(sample.endDate, range.upperBound)
return max(0, end.timeIntervalSince(start))
}
// MARK: () (plan §3.4)
/// range ( )
private func workoutDurations(in range: Range<Date>) async -> [HealthWorkoutKind: Double] {
let workouts = await workoutSamples(in: range)
var result: [HealthWorkoutKind: Double] = [:]
for workout in workouts {
let start = max(workout.startDate, range.lowerBound)
let end = min(workout.endDate, range.upperBound)
guard end > start else { continue }
result[Self.workoutKind(for: workout.workoutActivityType), default: 0]
+= end.timeIntervalSince(start)
}
return result
}
private func workoutSamples(in range: Range<Date>) async -> [HKWorkout] {
await withCheckedContinuation { continuation in
let predicate = HKQuery.predicateForSamples(
withStart: range.lowerBound, end: range.upperBound, options: []
)
let query = HKSampleQuery(
sampleType: HKObjectType.workoutType(), predicate: predicate,
limit: HKObjectQueryNoLimit, sortDescriptors: nil
) { _, samples, _ in
continuation.resume(returning: (samples as? [HKWorkout]) ?? [])
}
store.execute(query)
}
}
/// HKWorkoutActivityType ( ' ')
static func workoutKind(for type: HKWorkoutActivityType) -> HealthWorkoutKind {
switch type {
case .running: return .running
case .walking: return .walking
case .cycling: return .cycling
case .swimming: return .swimming
case .traditionalStrengthTraining, .functionalStrengthTraining: return .strength
case .yoga: return .yoga
case .pilates: return .pilates
case .hiking: return .hiking
case .soccer: return .soccer
case .baseball: return .baseball
case .basketball: return .basketball
case .tennis: return .tennis
case .badminton: return .badminton
case .golf: return .golf
case .jumpRope: return .jumpRope
case .boxing, .kickboxing, .martialArts: return .boxing
case .cardioDance, .socialDance, .barre: return .dance
case .flexibility, .cooldown, .mindAndBody: return .stretching
case .highIntensityIntervalTraining, .crossTraining: return .hiit
default: return .other
}
}
// MARK: ( 1 · · , plan §10 R9)
/// (400) . ( ) 1
/// , · 1 .
/// ( · ) .
func backfillIfNeeded() async {
#if DEBUG
if UserDefaults.standard.bool(forKey: "seedHealthCache") { return }
#endif
guard Self.isAvailable, hasRequestedAuth else { return }
let math = DayMath()
let now = Date.now
let cal = math.calendar
let todayKey = math.dayKey(for: now)
let keys = questHealthKeys()
let signature = "\(HealthCache.dayToken(todayKey))|\(keys.sleepWindows.sorted().joined(separator: ","))|\(keys.workoutKinds.map(\.rawValue).sorted().joined(separator: ","))"
guard AppGroup.defaults.string(forKey: "health.backfillSignature") != signature else { return }
guard let startKey = cal.date(byAdding: .day, value: -(HealthCache.retentionDays - 1), to: todayKey)
else { return }
let lower = math.dayRange(forKey: startKey).lowerBound
let upper = math.dayRange(forKey: todayKey).upperBound
var merged: [String: [Date: Double]] = [:]
// 1 ( )
let quantitySpecs: [(HealthMetric, HKQuantityTypeIdentifier, HKUnit, Double)] = [
(.steps, .stepCount, .count(), 1),
(.exerciseMinutes, .appleExerciseTime, .minute(), 60),
(.activeEnergy, .activeEnergyBurned, .kilocalorie(), 1),
(.distance, .distanceWalkingRunning, .meter(), 1),
(.water, .dietaryWater, .literUnit(with: .milli), 1),
]
for (metric, id, unit, factor) in quantitySpecs {
let buckets = await dailySums(id, unit: unit, lower: lower, upper: upper, math: math)
merged[metric.rawValue] = buckets.mapValues { $0 * factor }
}
//
let stand = await categorySamples(.appleStandHour, in: lower..<upper)
var standDays: [Date: Double] = [:]
for sample in stand where sample.value == HKCategoryValueAppleStandHour.stood.rawValue {
standDays[math.dayKey(for: sample.startDate), default: 0] += 1
}
merged[HealthMetric.standHours.rawValue] = standDays
//
let mindful = await categorySamples(.mindfulSession, in: lower..<upper)
var mindfulDays: [Date: Double] = [:]
for sample in mindful {
let start = max(sample.startDate, lower)
let end = min(sample.endDate, upper)
guard start < end else { continue }
for segment in math.splitByDay(start: start, end: end)
where segment.range.lowerBound < segment.range.upperBound {
mindfulDays[segment.dayKey, default: 0]
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
}
}
merged[HealthMetric.mindfulMinutes.rawValue] = mindfulDays
// ( )
let sleepSamples = await categorySamples(
.sleepAnalysis, in: lower.addingTimeInterval(-24 * 3600)..<upper
)
let asleepValues = Set(HKCategoryValueSleepAnalysis.allAsleepValues.map(\.rawValue))
let asleepIntervals: [(Date, Date)] = sleepSamples.compactMap { sample in
asleepValues.contains(sample.value) ? (sample.startDate, sample.endDate) : nil
}
for spec in keys.sleepWindows {
guard let parsed = Self.windowSpec(spec) else { continue }
var days: [Date: Double] = [:]
var key = startKey
while key <= todayKey {
let window = Self.sleepWindow(
forDayRange: math.dayRange(forKey: key),
endMinutes: parsed.endMinutes, length: parsed.length
)
let clipped: [(Date, Date)] = asleepIntervals.compactMap { start, end in
let cs = max(start, window.lowerBound)
let ce = min(end, window.upperBound)
return ce > cs ? (cs, ce) : nil
}
let value = Self.mergedDuration(clipped)
if value > 0 { days[key] = value }
guard let next = cal.date(byAdding: .day, value: 1, to: key) else { break }
key = next
}
merged["sleep@\(spec)"] = days
if spec == Self.defaultSleepSpec { merged[HealthMetric.sleep.rawValue] = days }
}
// 1 ×
if !keys.workoutKinds.isEmpty {
let workouts = await workoutSamples(in: lower..<upper)
var byKindDays: [HealthWorkoutKind: [Date: Double]] = [:]
for workout in workouts {
let kind = Self.workoutKind(for: workout.workoutActivityType)
let start = max(workout.startDate, lower)
let end = min(workout.endDate, upper)
guard start < end else { continue }
for segment in math.splitByDay(start: start, end: end)
where segment.range.lowerBound < segment.range.upperBound {
byKindDays[kind, default: [:]][segment.dayKey, default: 0]
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
}
}
for kind in keys.workoutKinds {
merged["workout.\(kind.rawValue)"] = byKindDays[kind] ?? [:]
}
}
HealthCache.merge(merged)
AppGroup.defaults.set(signature, forKey: "health.backfillSignature")
}
/// ( )
private func dailySums(_ id: HKQuantityTypeIdentifier, unit: HKUnit,
lower: Date, upper: Date, math: DayMath) async -> [Date: Double] {
await withCheckedContinuation { continuation in
let predicate = HKQuery.predicateForSamples(withStart: lower, end: upper, options: .strictStartDate)
let query = HKStatisticsCollectionQuery(
quantityType: HKQuantityType(id),
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: lower,
intervalComponents: DateComponents(day: 1)
)
query.initialResultsHandler = { _, collection, _ in
var result: [Date: Double] = [:]
collection?.enumerateStatistics(from: lower, to: upper) { statistics, _ in
let value = statistics.sumQuantity()?.doubleValue(for: unit) ?? 0
if value > 0 { result[math.dayKey(for: statistics.startDate)] = value }
}
continuation.resume(returning: result)
}
store.execute(query)
}
}
// MARK: HealthKit
private func sumQuantity(_ id: HKQuantityTypeIdentifier, unit: HKUnit, in range: Range<Date>) async -> Double {
await withCheckedContinuation { continuation in
let predicate = HKQuery.predicateForSamples(
withStart: range.lowerBound, end: range.upperBound, options: .strictStartDate
)
let query = HKStatisticsQuery(
quantityType: HKQuantityType(id),
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, statistics, _ in
continuation.resume(returning: statistics?.sumQuantity()?.doubleValue(for: unit) ?? 0)
}
store.execute(query)
}
}
/// range ( = · )
private func categorySamples(_ id: HKCategoryTypeIdentifier, in range: Range<Date>) async -> [HKCategorySample] {
await withCheckedContinuation { continuation in
let predicate = HKQuery.predicateForSamples(
withStart: range.lowerBound, end: range.upperBound, options: []
)
let query = HKSampleQuery(
sampleType: HKCategoryType(id), predicate: predicate,
limit: HKObjectQueryNoLimit, sortDescriptors: nil
) { _, samples, _ in
continuation.resume(returning: (samples as? [HKCategorySample]) ?? [])
}
store.execute(query)
}
}
}