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
This commit is contained in:
songyc macbook 2026-08-22 05:38:50 +09:00
parent 92afa69f6d
commit e04cdfdcb9
11 changed files with 845 additions and 8 deletions

View File

@ -354,7 +354,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
| 측정 알림 | `-settings.longSessionAlertHours <N>`(설정 주입) `-longSessionAlertTestSeconds <N>`(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals <N>`(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-watchActions <N>`(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>`(N일 전 날짜로 시작 — 지난주 등 과거 주 표시, 마케팅 촬영용) `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statRolling`('오늘 기준' 롤링 토글 켠 상태로 시작 — 1.4) `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor <session|count>`(최신 기록 수정 시트) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo <help-main 등>`: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays <N>`이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays <N>`이면 날짜 지정[오늘-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 <help-main 등>`: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays <N>`이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays <N>`이면 날짜 지정[오늘-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 <N>` `-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 <stale\|quest\|actionValue>` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) `-watchOpenActionDetail`(1.5 — 스냅숏 수신 후 첫 옵트인 행동의 미니 행동 화면을 딥링크와 같은 경로로 push, 착지·뒤로가기 검증) |

View File

@ -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종보다 넓게 제공 가능).

View File

@ -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
}

View File

@ -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<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)
}
}
}

View File

@ -12,6 +12,8 @@
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>

View File

@ -18,6 +18,10 @@
Face ID 기기에서 인증 시도가 거부된다. Touch ID·암호는 별도 키 불필요 -->
<key>NSFaceIDUsageDescription</key>
<string>일기 잠금을 해제하는 데 사용돼요.</string>
<!-- 건강 데이터 읽기 사유 (1.5 — 모음 탭 타일·건강 다짐). 읽기 전용이며
값은 기기 밖으로 전송·저장되지 않는다 (심사 5.1.3, PRIVACY.md 건강 문단 참고) -->
<key>NSHealthShareUsageDescription</key>
<string>걸음·운동·수면 같은 건강 데이터를 앱에서 보여주고 다짐 달성 계산에 쓰기 위해 읽어요. 데이터는 이 기기 밖으로 나가지 않아요.</string>
<!-- CFBundleName(맥 메뉴 막대 이름): 생성 Info.plist가 PRODUCT_NAME("Haru_Danim")으로
항상 덮어써 이 파일에 넣어도 무효(검증됨). 실제 표시는 언어별 InfoPlist.strings
(IOS/InfoPlist.xcstrings의 CFBundleName — ko 하루 다님 / en·ja Haru Danim)가 결정하고,

View File

@ -0,0 +1,354 @@
//
// HealthSection.swift
// Haru_Danim
//
// (1.5 Docs/plan-1.5.md §3.3, )
// - + (/ )
// - (+)· ( 2026-08-22)
// - ( CTA )
// - MainView (actionGrid·) (§10 R4)
//
import SwiftUI
struct HealthSectionView: View {
@AppStorage(LocalPrefsKeys.healthCollapsed, store: AppGroup.defaults)
private var collapsed = false
@AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults)
private var metricsRaw = ""
@State private var store = HealthDataStore.shared
@State private var showingMetricPicker = false
@State private var showingGuide = false
private var metrics: [HealthMetric] {
HealthMetric.selectedList(raw: metricsRaw)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
header
if !collapsed {
content
}
}
.sheet(isPresented: $showingMetricPicker) {
HealthMetricPickerSheet()
}
.sheet(isPresented: $showingGuide) {
HealthGuideSheet()
}
.task { await store.refreshToday() }
#if DEBUG
// : -healthShowGuide YES , -healthShowMetricPicker YES
.onAppear {
if UserDefaults.standard.bool(forKey: "healthShowGuide") { showingGuide = true }
if UserDefaults.standard.bool(forKey: "healthShowMetricPicker") { showingMetricPicker = true }
}
#endif
}
private var header: some View {
HStack(spacing: 6) {
Button {
withAnimation(.smooth(duration: 0.25)) { collapsed.toggle() }
} label: {
HStack(spacing: 6) {
Image(systemName: "heart.fill")
.font(.caption)
.foregroundStyle(.pink)
Text("건강 데이터")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
Image(systemName: "chevron.down")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(collapsed ? -90 : 0))
}
.padding(.leading, 2)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(collapsed ? Text("건강 데이터 펼치기") : Text("건강 데이터 접기"))
Spacer(minLength: 0)
if !collapsed {
Button {
showingGuide = true
} label: {
Image(systemName: "info.circle")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("건강 데이터 안내"))
Button {
showingMetricPicker = true
} label: {
Image(systemName: "slider.horizontal.3")
.font(.subheadline)
.foregroundStyle(AppTheme.green)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("표시할 지표 선택"))
}
}
}
@ViewBuilder
private var content: some View {
if !store.hasRequestedAuth {
connectTile
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(metrics) { metric in
tile(metric)
}
}
.padding(.vertical, 1)
}
}
}
/// ( )
private var connectTile: some View {
Button {
Task { await store.requestAuthorization() }
} label: {
HStack(spacing: 10) {
Image(systemName: "heart.text.square.fill")
.font(.title3)
.foregroundStyle(.pink)
VStack(alignment: .leading, spacing: 2) {
Text("애플 건강 연결하기")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text("걸음·운동·수면 같은 오늘 데이터를 여기서 볼 수 있어요")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(12)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.contentShape(.rect)
}
.buttonStyle(.plain)
}
private func tile(_ metric: HealthMetric) -> some View {
VStack(alignment: .leading, spacing: 4) {
Image(safeSymbol: metric.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.pink)
Text(metric.valueLabel(store.todayValues[metric] ?? 0))
.font(.system(.subheadline, design: .rounded).weight(.bold).monospacedDigit())
.foregroundStyle(.primary)
.lineLimit(1)
Text(metric.name)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(10)
.frame(minWidth: 86, alignment: .leading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(verbatim: "\(metric.name) \(metric.valueLabel(store.todayValues[metric] ?? 0))"))
}
}
// MARK: - ( + )
struct HealthMetricPickerSheet: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults)
private var metricsRaw = ""
/// : ( ) +
@State private var selected: [HealthMetric] = []
var body: some View {
NavigationStack {
List {
Section {
ForEach(selected) { metric in
row(metric, isOn: true)
}
.onMove { source, destination in
selected.move(fromOffsets: source, toOffset: destination)
}
} header: {
Text("표시할 지표 (드래그로 순서 변경)")
} footer: {
if selected.isEmpty {
Text("최소 1개는 선택해야 저장돼요.")
}
}
Section("표시 안 함") {
ForEach(HealthMetric.allCases.filter { !selected.contains($0) }) { metric in
row(metric, isOn: false)
}
}
}
.environment(\.editMode, .constant(.active))
.navigationTitle("건강 지표")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") {
if !selected.isEmpty {
metricsRaw = selected.map(\.rawValue).joined(separator: ",")
}
dismiss()
}
.disabled(selected.isEmpty)
}
}
.onAppear {
selected = HealthMetric.selectedList(raw: metricsRaw)
}
}
.presentationDetents([.medium, .large])
}
private func row(_ metric: HealthMetric, isOn: Bool) -> some View {
Button {
withAnimation(.smooth(duration: 0.2)) {
if isOn {
selected.removeAll { $0 == metric }
} else {
selected.append(metric)
}
}
} label: {
HStack(spacing: 10) {
Image(safeSymbol: metric.symbolName)
.font(.subheadline)
.foregroundStyle(.pink)
.frame(width: 22)
Text(metric.name)
.foregroundStyle(.primary)
Spacer()
Image(systemName: isOn ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isOn ? AppTheme.green : .secondary)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
// MARK: - 3 ( plan §3.3)
struct MainSectionOrderView: View {
@AppStorage(LocalPrefsKeys.mainSectionOrder, store: AppGroup.defaults)
private var orderRaw = ""
@State private var order: [MainSectionKind] = []
var body: some View {
List {
Section {
ForEach(order) { kind in
HStack(spacing: 10) {
Image(systemName: symbol(kind))
.font(.subheadline)
.foregroundStyle(kind == .health ? .pink : AppTheme.green)
.frame(width: 22)
Text(kind.label)
}
}
.onMove { source, destination in
order.move(fromOffsets: source, toOffset: destination)
MainSectionKind.saveOrder(order)
orderRaw = order.map(\.rawValue).joined(separator: ",")
}
} footer: {
Text("드래그로 순서를 바꾸면 모음 탭에 바로 적용돼요. 목표 진행 현황 카드와 '현재 진행 중' 영역은 항상 위에 고정돼요.")
}
}
.environment(\.editMode, .constant(.active))
.navigationTitle("섹션 표시 순서")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
order = MainSectionKind.orderedList(raw: orderRaw)
}
}
private func symbol(_ kind: MainSectionKind) -> String {
switch kind {
case .favorites: return "star.fill"
case .others: return "square.grid.2x2"
case .health: return "heart.fill"
}
}
}
// MARK: - ( 2026-08-22 )
struct HealthGuideSheet: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
item(
symbol: "lock.shield.fill",
title: String(localized: "데이터는 이 기기 안에만 있어요"),
body: String(localized: "여기 보이는 값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요. 하루 다님은 이 값을 서버로 보내거나 iCloud에 저장하지 않아요.")
)
item(
symbol: "clock.fill",
title: String(localized: "하루의 기준"),
body: String(localized: "걸음 수·운동 시간 같은 값은 설정의 '하루 시작 시간'을 기준으로 한 오늘 하루의 합이에요. 수면은 조금 달라요 — 잠은 보통 전날 밤에 시작되니까, 전날 밤부터 오늘 아침까지의 수면 구간을 통째로 '오늘 잔 것'으로 세요.")
)
item(
symbol: "arrow.triangle.2.circlepath",
title: String(localized: "값이 실시간이 아닐 수 있어요"),
body: String(localized: "애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보여요. 값이 안 맞아 보이면 잠시 뒤 다시 확인하거나 건강 앱을 한 번 열어 주세요.")
)
item(
symbol: "ipad.and.iphone",
title: String(localized: "기기마다 값이 다를 수 있어요"),
body: String(localized: "건강 데이터는 애플이 기기별로 관리해요. 아이패드는 시스템 설정에서 건강 iCloud 동기화를 켠 경우에만 아이폰과 같은 값이 보이고, 맥에서는 건강 데이터를 지원하지 않아요.")
)
item(
symbol: "hand.raised.fill",
title: String(localized: "값이 계속 비어 있나요?"),
body: String(localized: "읽기 권한이 꺼져 있으면 값이 0으로 보여요. 아이폰의 설정 → 개인정보 보호 및 보안 → 건강 → 하루 다님에서 권한을 확인할 수 있어요.")
)
}
.padding()
}
.navigationTitle("건강 데이터 안내")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("닫기") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
private func item(symbol: String, title: String, body text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: symbol)
.font(.title3)
.foregroundStyle(AppTheme.green)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(text)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}

View File

@ -22,6 +22,18 @@ struct MainView: View {
@AppStorage(LocalPrefsKeys.pinnedGoals, store: AppGroup.defaults) private var pinnedGoalsRaw = ""
/// ' ' ()
@AppStorage(LocalPrefsKeys.mainOthersCollapsed, store: AppGroup.defaults) private var othersCollapsed = false
// 1.5: + 3 ( LocalPrefs·plan §3.3)
@AppStorage(LocalPrefsKeys.healthTilesEnabled, store: AppGroup.defaults) private var healthTilesEnabled = true
@AppStorage(LocalPrefsKeys.mainSectionOrder, store: AppGroup.defaults) private var mainSectionOrderRaw = ""
private var orderedMainSections: [MainSectionKind] {
MainSectionKind.orderedList(raw: mainSectionOrderRaw)
}
/// + ( )
private var showsHealthSection: Bool {
healthTilesEnabled && HealthDataStore.isAvailable
}
private var orderedActions: [Action] {
LocalPrefs.orderedActions(actions, raw: actionOrderRaw)
@ -85,17 +97,37 @@ struct MainView: View {
}
if actions.isEmpty {
emptyState
// ( 1.5)
if showsHealthSection {
HealthSectionView()
}
} else if isEditing {
// = , =·
// = , =·.
// () (plan §10 R4)
editingFavoritesSection
editingOthersSection
} else if favoriteActions.isEmpty {
} else {
// 1.5: ·· 3 (§6.1
// · , )
ForEach(orderedMainSections) { kind in
switch kind {
case .favorites:
if favoriteActions.isEmpty {
// ( )
grid
} else {
favoritesSection
if !otherActions.isEmpty {
}
case .others:
if !favoriteActions.isEmpty && !otherActions.isEmpty {
othersSection
}
case .health:
if showsHealthSection {
HealthSectionView()
}
}
}
}
}
.padding()

View File

@ -30,6 +30,13 @@ struct SettingsView: View {
@AppStorage(SettingsKeys.goalCardStyle) private var goalCardStyle = GoalCardStyle.perQuest.rawValue
// (CloudKit , LocalPrefs )
@AppStorage(LocalPrefsKeys.pinnedGoals, store: AppGroup.defaults) private var pinnedGoalsRaw = ""
// 1.5: + 3 ( )
@AppStorage(LocalPrefsKeys.healthTilesEnabled, store: AppGroup.defaults) private var healthTilesEnabled = true
@AppStorage(LocalPrefsKeys.mainSectionOrder, store: AppGroup.defaults) private var mainSectionOrderRaw = ""
private var sectionOrderSummary: String {
MainSectionKind.orderedList(raw: mainSectionOrderRaw).map(\.label).joined(separator: " · ")
}
private var visibleTabs: [AppTab] {
AppTab.visibleTabs(from: visibleTabsRaw)
@ -167,6 +174,38 @@ struct SettingsView: View {
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 카드를 옆으로 쓸어 넘겨 한 장씩 볼 수 있고, 표시 방식(다짐별로 각각 / 전체 다짐 합산)은 모든 카드에 똑같이 적용돼요.")
}
.id("goalSection")
// 1.5: + 3 ( [] )
if HealthDataStore.isAvailable {
Section {
Toggle(isOn: $healthTilesEnabled) {
Label {
Text("건강 데이터 표시")
} icon: {
Image(systemName: "heart.fill")
.foregroundStyle(.pink)
}
}
if healthTilesEnabled {
NavigationLink {
MainSectionOrderView()
} label: {
HStack {
Text("섹션 표시 순서")
Spacer()
Text(sectionOrderSummary)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
} header: {
Text("모음 탭 건강 데이터")
} footer: {
Text("켜면 애플 건강 앱의 오늘 데이터(걸음·운동·수면 등)를 모음 탭에서 타일로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요.")
}
.id("healthSection")
}
Section {
Picker("주 시작 요일", selection: $weekStartWeekday) {
ForEach(1...7, id: \.self) { weekday in

View File

@ -0,0 +1,81 @@
//
// HealthMetric.swift
// Haru_Danim
//
// (1.5 Docs/plan-1.5.md §3)
// - · (HealthKit )
// - : (··)=, =, =kcal,
// =m, =, =L. HealthKit ··
//
import Foundation
nonisolated enum HealthMetric: String, CaseIterable, Identifiable, Codable {
case steps, exerciseMinutes, sleep, activeEnergy, distance, standHours, mindfulMinutes, water
var id: String { rawValue }
var name: String {
switch self {
case .steps: return String(localized: "걸음 수")
case .exerciseMinutes: return String(localized: "운동 시간")
case .sleep: return String(localized: "수면")
case .activeEnergy: return String(localized: "활동 에너지")
case .distance: return String(localized: "이동 거리")
case .standHours: return String(localized: "일어서기")
case .mindfulMinutes: return String(localized: "마음챙김")
case .water: return String(localized: "물 섭취")
}
}
var symbolName: String {
switch self {
case .steps: return "figure.walk"
case .exerciseMinutes: return "figure.run"
case .sleep: return "bed.double.fill"
case .activeEnergy: return "flame.fill"
case .distance: return "map.fill"
case .standHours: return "figure.stand"
case .mindfulMinutes: return "brain.head.profile"
case .water: return "drop.fill"
}
}
/// ( ) ·
var isDuration: Bool {
switch self {
case .exerciseMinutes, .sleep, .mindfulMinutes: return true
default: return false
}
}
/// (·· )
func valueLabel(_ value: Double) -> String {
switch self {
case .steps:
return String(localized: "\(Int(value).formatted())")
case .exerciseMinutes, .sleep, .mindfulMinutes:
return Format.durationShort(value)
case .activeEnergy:
return String(localized: "\(Int(value).formatted())kcal")
case .distance:
let km = value / 1000
let text = km.formatted(.number.precision(.fractionLength(km >= 10 ? 0 : 1)))
return String(localized: "\(text)km")
case .standHours:
return String(localized: "\(Int(value))시간")
case .water:
let text = value.formatted(.number.precision(.fractionLength(0...2)))
return String(localized: "\(text)L")
}
}
/// (·· )
static let defaultTileMetrics: [HealthMetric] = [.steps, .exerciseMinutes, .sleep]
/// ( )
static func selectedList(raw: String) -> [HealthMetric] {
let parsed = raw.split(separator: ",").compactMap { HealthMetric(rawValue: String($0)) }
return parsed.isEmpty ? defaultTileMetrics : parsed
}
}

View File

@ -37,6 +37,42 @@ nonisolated enum LocalPrefsKeys {
/// [Action.uuid : WatchActionPeriod raw] . ().
/// (watchGoals )
static let watchActions = "local.watchActions"
/// Bool, (1.5. true )
static let healthTilesEnabled = "local.healthTilesEnabled"
/// 3(··) MainSectionKind raw (1.5)
static let mainSectionOrder = "local.mainSectionOrder"
/// Bool (1.5)
static let healthCollapsed = "local.healthCollapsed"
/// HealthMetric raw ( , 1.5).
/// : ··
static let healthMetrics = "local.healthMetrics"
}
/// 3 (1.5 · , )
nonisolated enum MainSectionKind: String, CaseIterable, Identifiable {
case favorites, others, health
var id: String { rawValue }
var label: String {
switch self {
case .favorites: return String(localized: "즐겨찾기 행동")
case .others: return String(localized: "나머지 행동")
case .health: return String(localized: "건강 데이터")
}
}
/// ( )
static func orderedList(raw: String) -> [MainSectionKind] {
var result = raw.split(separator: ",").compactMap { MainSectionKind(rawValue: String($0)) }
for kind in MainSectionKind.allCases where !result.contains(kind) {
result.append(kind)
}
return result
}
static func saveOrder(_ kinds: [MainSectionKind]) {
LocalPrefs.defaults.set(kinds.map(\.rawValue).joined(separator: ","), forKey: LocalPrefsKeys.mainSectionOrder)
}
}
nonisolated enum LocalPrefs {