mycode/myApp/HaruDanim/IOS/Core/HealthStore.swift
songyc macbook e04cdfdcb9 feat(1.5-p2): 건강 데이터 타일 — HealthCache 기반·모음 탭 3섹션 순서·안내/지표 시트
- 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
2026-08-22 05:38:50 +09:00

286 lines
12 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 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)
}
}
}