diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index fa5d51e..a277cf3 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -354,7 +354,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f | 측정 알림 | `-settings.longSessionAlertHours `(설정 주입) `-longSessionAlertTestSeconds `(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) | | 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals `(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-watchActions `(1.5 — 배치순 행동 N개를 '행동 기록' 컴플리케이션에 올림. 표시 기간은 하루→이번 주→이번 달→지난 7일→지난 30일 순환, 스냅숏 즉시 푸시) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 49건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom·주기 몫 완료+목표 하루 평균+과거 기준 소급 차단] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) `-seedPeriodFulfilled YES`(주기 몫을 이미 채운 주간·월간 다짐 목표 1개 시드 — '이번 주/달 달성' 표기 검증용, 기본 시드와 분리·이름 비현지화. 기록은 주/달 첫날 09:00이라 실행일이 첫날이면 오늘 기록이 되지만 완료 표기는 동일) | | 기록/통계 | `-historyMode timetable` `-historyWeekly` `-historyDaysAgo `(N일 전 날짜로 시작 — 지난주 등 과거 주 표시, 마케팅 촬영용) `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statRolling`('오늘 기준' 롤링 토글 켠 상태로 시작 — 1.4) `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor `(최신 기록 수정 시트) | -| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo `: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays `이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays `이면 날짜 지정[오늘-N일까지], `-dataResetScope diary`면 '일기만' 선택으로 시작) `-dataResetTest`(초기화 자가 검증 27건[범위 삭제 경계·보존·cascade·전체 초기화·일기만] → Documents/data-reset-test.txt — 인메모리·설정 고정, 실데이터 무영향) `-symbolAuditDump`(아이콘 카탈로그 중 현재 OS에 없는 심볼 → Documents/symbol-audit.txt — 하위 OS QA, 1.4) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-actionShowEditor`(1.5 — 첫 행동의 편집 시트를 열고 애플워치 섹션까지 자동 스크롤, `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock|marketing`(marketing = 디버그 라벨 없는 위젯 실렌더 모아 보기 — 마케팅 스크린샷 05-widgets/06-widgets 촬영용, Marketing/README 참고) `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) | +| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo `: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays `이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays `이면 날짜 지정[오늘-N일까지], `-dataResetScope diary`면 '일기만' 선택으로 시작) `-dataResetTest`(초기화 자가 검증 27건[범위 삭제 경계·보존·cascade·전체 초기화·일기만] → Documents/data-reset-test.txt — 인메모리·설정 고정, 실데이터 무영향) `-seedHealthCache`(1.5 — 건강 스토어를 합성 값으로 대체[HK 비의존]·캐시 기록, 건강 타일·다짐 렌더 검증용) `-healthShowGuide`(1.5 — 건강 안내 시트 표시) `-healthShowMetricPicker`(1.5 — 건강 지표 선택 시트 표시) `-symbolAuditDump`(아이콘 카탈로그 중 현재 OS에 없는 심볼 → Documents/symbol-audit.txt — 하위 OS QA, 1.4) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-actionShowEditor`(1.5 — 첫 행동의 편집 시트를 열고 애플워치 섹션까지 자동 스크롤, `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock|marketing`(marketing = 디버그 라벨 없는 위젯 실렌더 모아 보기 — 마케팅 스크린샷 05-widgets/06-widgets 촬영용, Marketing/README 참고) `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) | | 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) | | 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryDrawMode`(맥 분기에서 '그리기' 토글 켠 채 시작) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage ` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diaryShowDefaults`(기본 표시 설정 시트) `-diaryDefaultsSeed`(첫 목표·첫 행동 기본 숨김+캘린더 기본 '모든 캘린더' 주입 — 기본값 적용 검증용) `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryLockScreen`(잠금 게이트 화면 강제 — 인증 없이 표시) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) `-diaryShapeEdit`(활성 노트 페이지 첫 도형 선택+'도형 수정' 시트 — `-diarySeed`·`-diaryOpenToday`·`-diaryPage 1`과 함께. 시드 요소들이 스타일 필드[굵기·둥글기·채움·자유 비율·회전·글자 크기·정렬·테두리 색]를 전부 렌더) `-diaryTextEdit`(첫 텍스트 상자를 제자리 입력 상태로 — 같은 조합) `-diaryStrokeTest`(3초 뒤 프로그램 획 추가로 디바운스 저장 파이프라인 검증 → Documents/diary-stroke-test.txt[strokes·delivered] — `-diaryOpenToday`와 함께, 1.4) `-diaryExportDeleteTest`(내보내기 완료 후 '삭제' 버튼과 같은 가드·경로로 삭제 실행 — `-diaryShowExport -diaryExportRun`과 함께, 재실행 후 달력 마커 소멸로 판정, 1.4) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` | | 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo ` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) `-watchOpenActionDetail`(1.5 — 스냅숏 수신 후 첫 옵트인 행동의 미니 행동 화면을 딥링크와 같은 경로로 push, 착지·뒤로가기 검증) | diff --git a/myApp/HaruDanim/Docs/plan-1.5.md b/myApp/HaruDanim/Docs/plan-1.5.md index 6daa015..c8bcfaf 100644 --- a/myApp/HaruDanim/Docs/plan-1.5.md +++ b/myApp/HaruDanim/Docs/plan-1.5.md @@ -58,7 +58,7 @@ - ⚠️ **심사 지침 5.1.3 — HealthKit 데이터를 iCloud(CloudKit)에 저장 금지**: HK **값**은 절대 SwiftData/CloudKit에 넣지 않는다(App Group 캐시는 기기 로컬이라 무방). 다짐의 **정의**(지표·목표량·방향·수면 구간)는 일반 데이터이므로 동기화 OK. → "정의는 동기화, 값은 기기별 자기 HealthKit"이 동기화 모델의 전부. - 읽기 권한은 거부돼도 감지 불가(빈 값이 옴 — 애플 프라이버시 설계) → "표시할 데이터 없음" 상태로 흡수, 권한 재요청 유도 문구는 안내 수준만. -### 3.3 타일(표시 전용, 무료) +### 3.3 타일(표시 전용, 무료) ✅구현 완료(2026-08-22 — 타일·순서 설정·지표 선택·안내 시트·CTA·HealthCache·엔타이틀먼트, 26.5+18.5 시각 QA·회귀 106건 그린. ⚠️QA 도구 주의: simctl spawn defaults의 앱그룹 도메인 쓰기가 18.5 심에선 앱그룹 컨테이너에 닿지 않음 — 26.5는 닿음, 검증 시 참고) - 형태: 가로 스크롤 컴팩트 타일 줄 + 접기(즐겨찾기/나머지 섹션과 같은 접힘 문법, 접힘 상태 기기 로컬). - **모음 탭 배치 = 설정으로 제어(사용자 요구 2026-08-20)**: 설정 탭 모음 탭 영역(목표 카드 선택 아래)에 ①**건강 데이터 표시 on/off** ②표시 시 **즐겨찾기 / 나머지 행동 / 건강 데이터 3섹션의 표시 순서 지정** UI 신설(기기 로컬 저장 — 보기 설정 계층). 목표 현황 카드·'현재 진행 중' 영역은 기존대로 상단 고정, 순서 지정 대상은 위 3섹션만(다른 해석 원하면 착수 전 확인). - 우상단 버튼 → **지표 선택 시트**(표시할 지표 체크+순서, 기기 로컬 — 표시 지표는 다짐 가능 8종보다 넓게 제공 가능). diff --git a/myApp/HaruDanim/IOS/Core/DataReset.swift b/myApp/HaruDanim/IOS/Core/DataReset.swift index 4048264..06dd9c0 100644 --- a/myApp/HaruDanim/IOS/Core/DataReset.swift +++ b/myApp/HaruDanim/IOS/Core/DataReset.swift @@ -125,8 +125,12 @@ enum DataReset { LocalPrefsKeys.collapsedGoals, LocalPrefsKeys.notePromptActions, LocalPrefsKeys.watchGoals, LocalPrefsKeys.mainOthersCollapsed, LocalPrefsKeys.watchActions, + // 1.5 건강 보기 설정 — 값 자체는 건강 앱 소유라 캐시·표시 설정만 정리 + LocalPrefsKeys.healthTilesEnabled, LocalPrefsKeys.mainSectionOrder, + LocalPrefsKeys.healthCollapsed, LocalPrefsKeys.healthMetrics, ] for key in keys { LocalPrefs.defaults.removeObject(forKey: key) } + HealthCache.clearAll() } return counts } diff --git a/myApp/HaruDanim/IOS/Core/HealthStore.swift b/myApp/HaruDanim/IOS/Core/HealthStore.swift new file mode 100644 index 0000000..439fc77 --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/HealthStore.swift @@ -0,0 +1,285 @@ +// +// 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 { + [ + 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, + 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) + } + + /// 구간 합집합 길이 (겹침 병합) + 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: 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) + } + } +} diff --git a/myApp/HaruDanim/IOS/Haru_Danim.entitlements b/myApp/HaruDanim/IOS/Haru_Danim.entitlements index cb7afe6..b591461 100644 --- a/myApp/HaruDanim/IOS/Haru_Danim.entitlements +++ b/myApp/HaruDanim/IOS/Haru_Danim.entitlements @@ -12,6 +12,8 @@ CloudKit + com.apple.developer.healthkit + com.apple.developer.siri com.apple.developer.ubiquity-kvstore-identifier diff --git a/myApp/HaruDanim/IOS/Info.plist b/myApp/HaruDanim/IOS/Info.plist index bb4c447..a6eb36a 100644 --- a/myApp/HaruDanim/IOS/Info.plist +++ b/myApp/HaruDanim/IOS/Info.plist @@ -18,6 +18,10 @@ Face ID 기기에서 인증 시도가 거부된다. Touch ID·암호는 별도 키 불필요 --> NSFaceIDUsageDescription 일기 잠금을 해제하는 데 사용돼요. + + NSHealthShareUsageDescription + 걸음·운동·수면 같은 건강 데이터를 앱에서 보여주고 다짐 달성 계산에 쓰기 위해 읽어요. 데이터는 이 기기 밖으로 나가지 않아요.