- HealthMetric 8종(표준 단위·표기) + HealthStore(HK 조회: 누적 통계·스탠드·마음챙김· 수면 구간[합집합 병합, 깨어난 날 귀속 — plan §3.4 규칙]) + HealthCache(App Group, 지표×dayKey, 400일 보존 — 5.1.3 준수: CloudKit 비저장) - 모음 탭 건강 섹션(타일 줄+접기·연결 CTA[명시적 탭 권한]·안내 시트[사용자 요구 가이드]· 지표 선택 시트[체크+드래그 순서]) — actionGrid·레이아웃 금지구역 무접촉, 편집 모드 숨김 - 3섹션(즐겨찾기·나머지·건강) 순서 렌더러 + 설정 탭 표시 토글·순서 화면(기기 로컬) - HealthKit 엔타이틀먼트(iOS 앱만)·NSHealthShareUsageDescription, 전체 초기화에 건강 키·캐시 청소 추가 - 검증: Debug/Store 빌드, 자가 검증 106건 ALL PASS(26.5+18.5), 시각 QA 7장 (타일·순서·설정·안내·지표·CTA·18.5 빈 상태). 신규 인자 3종 §14 기록 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
286 lines
12 KiB
Swift
286 lines
12 KiB
Swift
//
|
||
// 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 UIKit
|
||
|
||
// MARK: - 지표×논리적 하루 값 캐시 (App Group)
|
||
|
||
nonisolated enum HealthCache {
|
||
static let storageKey = "health.cache.v1"
|
||
/// 연속 계산 상한(§4.2 400일)에 맞춘 지표당 보존 일수
|
||
static let retentionDays = 400
|
||
|
||
/// dayKey(달력일 자정 Date) → "yyyyMMdd" 토큰
|
||
static func dayToken(_ dayKey: Date) -> String {
|
||
let parts = Calendar.current.dateComponents([.year, .month, .day], from: dayKey)
|
||
let y = parts.year ?? 0, m = parts.month ?? 0, d = parts.day ?? 0
|
||
let mm = m < 10 ? "0\(m)" : "\(m)"
|
||
let dd = d < 10 ? "0\(d)" : "\(d)"
|
||
return "\(y)\(mm)\(dd)"
|
||
}
|
||
|
||
private static func load() -> [String: [String: Double]] {
|
||
(AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: Double]]) ?? [:]
|
||
}
|
||
|
||
static func value(_ metricRaw: String, dayKey: Date) -> Double? {
|
||
load()[metricRaw]?[dayToken(dayKey)]
|
||
}
|
||
|
||
/// 한 날짜의 여러 지표 값을 일괄 기록 (+지표당 보존 일수 초과분 정리)
|
||
static func setValues(_ values: [String: Double], dayKey: Date) {
|
||
guard !values.isEmpty else { return }
|
||
var all = load()
|
||
let token = dayToken(dayKey)
|
||
for (metricRaw, value) in values {
|
||
var days = all[metricRaw] ?? [:]
|
||
days[token] = value
|
||
if days.count > retentionDays {
|
||
let sorted = days.keys.sorted(by: >)
|
||
for old in sorted.dropFirst(retentionDays) { days.removeValue(forKey: old) }
|
||
}
|
||
all[metricRaw] = days
|
||
}
|
||
AppGroup.defaults.set(all, forKey: storageKey)
|
||
}
|
||
|
||
/// 전체 초기화(데이터 정리)용 — 값은 어차피 건강 앱 소유라 캐시만 비운다
|
||
static func clearAll() {
|
||
AppGroup.defaults.removeObject(forKey: storageKey)
|
||
}
|
||
}
|
||
|
||
// 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 await HealthDataStore.shared.refreshToday() }
|
||
}
|
||
}
|
||
|
||
/// 읽기 권한 대상 전체 — 타일 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 refreshToday()
|
||
}
|
||
|
||
/// 오늘(논리적 하루)의 전 지표 값을 조회해 화면 상태와 캐시를 갱신
|
||
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: 1.2,
|
||
]
|
||
todayValues = seeded
|
||
let math = DayMath()
|
||
HealthCache.setValues(
|
||
Dictionary(uniqueKeysWithValues: seeded.map { ($0.key.rawValue, $0.value) }),
|
||
dayKey: math.dayKey(for: .now)
|
||
)
|
||
return
|
||
}
|
||
#endif
|
||
guard Self.isAvailable, hasRequestedAuth else { return }
|
||
let math = DayMath()
|
||
let now = Date.now
|
||
var result: [HealthMetric: Double] = [:]
|
||
for metric in HealthMetric.allCases {
|
||
result[metric] = await value(of: metric, forDayContaining: now, math: math)
|
||
}
|
||
todayValues = result
|
||
HealthCache.setValues(
|
||
Dictionary(uniqueKeysWithValues: result.map { ($0.key.rawValue, $0.value) }),
|
||
dayKey: math.dayKey(for: now)
|
||
)
|
||
}
|
||
|
||
// 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:
|
||
return await sumQuantity(.dietaryWater, unit: .liter(), 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: 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)
|
||
}
|
||
}
|
||
}
|