mycode/myApp/HaruDanim/IOS/Core/HealthStore.swift
songyc macbook 8b1bc44f67 fix+feat(1.5-p7): 소킹 실측 2건 수정 + 추가 2건 — 빌드 5
- fix: 더보기 탭 일기 달력 날짜 탭 무반응 — morePath [AppTab]→NavigationPath(Date 푸시 가능), 회귀 인자 -morePushDiaryToday(착지 실측)
- fix: 수면 블록이 수면 단계별로 칸칸이 갈라짐 — 10분 이하 깸 병합(sleepDisplayMergeGap, 표시 전용·값 무영향)
- feat: 기록 탭 타임테이블(하루·주간)에 수면·운동 블록 — 단일 공급 지점·숨김 토글 공유, 내보내기 injectingHealthBlocks(forDayKeys:)+trim 확장
- feat: 타임테이블 표시 색 설정(설정→건강 데이터 — 기본 인디고·주황, 전 타임테이블·내보내기 공통), -healthColorPreview
- QA: 주간 6일 블록 렌더·커스텀 색 픽셀 검증(#FF2D55→(255,221,228))·색 설정 화면·Date 푸시 착지, l10n 0/0, Debug/Store 그린

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

626 lines
31 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)
// · ·.
// ( + ),
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..<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)
HealthIntervals.merge(await dayIntervals(forDayKey: key, math: math))
if offset == 0 {
var tiles: [HealthMetric: Double] = [:]
for metric in HealthMetric.allCases { tiles[metric] = values[metric.rawValue] ?? 0 }
todayValues = tiles
}
}
}
/// ( )· .
/// · (SleepWindowPrefs +)
private func questHealthKeys() -> (sleepWindows: Set<String>, workoutKinds: Set<HealthWorkoutKind>) {
var windows: Set<String> = [Self.defaultSleepSpec]
windows.formUnion(SleepWindowPrefs.allSpecs)
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)
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<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)
}
/// ( , ) 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<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: ","))|\(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..<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
}
// "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..<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)
intervalMerged[HealthIntervals.workoutKey(kind), default: [:]][segment.dayKey, default: []]
.append([segment.range.lowerBound.timeIntervalSince1970,
segment.range.upperBound.timeIntervalSince1970])
}
}
for kind in keys.workoutKinds {
merged["workout.\(kind.rawValue)"] = byKindDays[kind] ?? [:]
}
HealthCache.merge(merged)
HealthIntervals.merge(intervalMerged)
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)
}
}
}