// // 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 { [ 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) // 실구간도 시드 — 일기 타임테이블 수면·운동 블록 렌더 검증·촬영용. // 수면은 자정을 걸친 모양(새벽 몫 + 밤 몫), 달리기는 아침 구간 let dayStart = math.dayRange(forKey: key).lowerBound func at(_ minutes: Double) -> Double { dayStart.addingTimeInterval(minutes * 60).timeIntervalSince1970 } HealthIntervals.merge([ HealthIntervals.sleepKey: [key: [[at(0), at(7 * 60 + 28)], [at(23 * 60 + 20), at(24 * 60)]]], HealthIntervals.workoutKey(.running): [key: [[at(7 * 60 + 42), at(8 * 60 + 13)]]], ]) } 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.. (sleepWindows: Set, workoutKinds: Set) { var windows: Set = [Self.defaultSleepSpec] windows.formUnion(SleepWindowPrefs.allSpecs) var kinds: Set = [] let quests = (try? DataStore.shared.mainContext.fetch(FetchDescriptor())) ?? [] 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, workoutKinds: Set) 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) result["sleep@\(spec)"] = await sleepDuration(window: window) } // 평문 "sleep" 키(타일·일기 카드) = 그날의 유효 표시 구간 값 (1.5 추가분 — // 설정에서 구간을 바꾸면 교대·야간 근무의 낮잠도 그날 값으로 잡힌다) let effectiveSpec = SleepWindowPrefs.spec(forDayKey: key) result[HealthMetric.sleep.rawValue] = result["sleep@\(effectiveSpec)"] ?? result["sleep@\(Self.defaultSleepSpec)"] ?? 0 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, endMinutes: Int = 9 * 60, length: TimeInterval = 12 * 3600) -> Range { 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)..) async -> Double { let samples = await categorySamples(.sleepAnalysis, in: window) let asleepValues: Set = 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) } /// 구간 합집합 (겹침 병합, 시작 오름차순) — mergedDuration의 구간 보존판. /// 아이폰+워치가 같은 잠을 겹쳐 기록해도 타임테이블에 블록이 이중으로 그려지지 않게 한다. /// tolerance: 이 간격 이하로 떨어진 이웃 구간도 이어 붙인다 (표시 전용 — 값 합산에는 쓰지 않음) static func mergedIntervals(_ intervals: [(Date, Date)], tolerance: TimeInterval = 0) -> [(Date, Date)] { let sorted = intervals.sorted { $0.0 < $1.0 } var result: [(Date, Date)] = [] for (start, end) in sorted { if let last = result.last, start.timeIntervalSince(last.1) <= tolerance { result[result.count - 1].1 = max(last.1, end) } else { result.append((start, end)) } } return result } /// 그 논리적 하루와 겹치는 수면·운동 실구간 (HealthIntervals 갱신값 — refreshRecent 전용). /// 이전에 있던 종류가 이번 조회에 없으면 빈 배열로 명시해 지운다 (건강 앱에서의 삭제 반영) private func dayIntervals(forDayKey dayKey: Date, math: DayMath) async -> [String: [Date: [[Double]]]] { let range = math.dayRange(forKey: dayKey) var perKey: [String: [[Double]]] = [:] for existing in HealthIntervals.workoutKeys(dayKey: dayKey) { perKey[existing] = [] } let samples = await categorySamples(.sleepAnalysis, in: range) let asleepValues = Set(HKCategoryValueSleepAnalysis.allAsleepValues.map(\.rawValue)) let sleepIntervals = Self.mergedIntervals(samples.compactMap { sample in guard asleepValues.contains(sample.value) else { return nil } let start = max(sample.startDate, range.lowerBound) let end = min(sample.endDate, range.upperBound) return end > start ? (start, end) : nil }) perKey[HealthIntervals.sleepKey] = sleepIntervals.map { [$0.0.timeIntervalSince1970, $0.1.timeIntervalSince1970] } for workout in await workoutSamples(in: range) { let start = max(workout.startDate, range.lowerBound) let end = min(workout.endDate, range.upperBound) guard start < end else { continue } perKey[HealthIntervals.workoutKey(Self.workoutKind(for: workout.workoutActivityType)), default: []] .append([start.timeIntervalSince1970, end.timeIntervalSince1970]) } return perKey.mapValues { [dayKey: $0] } } /// 구간 합집합 길이 (겹침 병합) 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) -> 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) 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) 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: ","))|\(SleepWindowPrefs.mappingSignature)" 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.. 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 } // 평문 "sleep" 키 = 각 날짜의 유효 표시 구간 값 (SleepWindowPrefs — allValues와 동일 규칙) var plainSleep: [Date: Double] = [:] var plainKey = startKey while plainKey <= todayKey { let spec = SleepWindowPrefs.spec(forDayKey: plainKey) if let value = merged["sleep@\(spec)"]?[plainKey] { plainSleep[plainKey] = value } guard let next = cal.date(byAdding: .day, value: 1, to: plainKey) else { break } plainKey = next } merged[HealthMetric.sleep.rawValue] = plainSleep // 수면 실구간 — 일기 타임테이블 표시용 (논리적 하루로 분할 후 병합, HealthIntervals. // 다짐의 '깨어난 날 통귀속'과 달리 사실 그대로 이틀에 나뉘는 의도된 규칙) var sleepIntervalDays: [Date: [(Date, Date)]] = [:] for (start, end) in asleepIntervals { let clippedStart = max(start, lower) let clippedEnd = min(end, upper) guard clippedStart < clippedEnd else { continue } for segment in math.splitByDay(start: clippedStart, end: clippedEnd) where segment.range.lowerBound < segment.range.upperBound { sleepIntervalDays[segment.dayKey, default: []] .append((segment.range.lowerBound, segment.range.upperBound)) } } var intervalMerged: [String: [Date: [[Double]]]] = [:] intervalMerged[HealthIntervals.sleepKey] = sleepIntervalDays.mapValues { list in Self.mergedIntervals(list).map { [$0.0.timeIntervalSince1970, $0.1.timeIntervalSince1970] } } // ⑤ 운동 — 범위 1회 조회 후 종목×하루 버킷팅 (하루 합계는 다짐 종목만 캐시, // 실구간은 전 종목 — 일기 타임테이블은 다짐 여부와 무관하게 그날 운동을 그린다) let workouts = await workoutSamples(in: lower.. [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) 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) 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) } } }