mycode/myApp/HaruDanim/IOS/Views/HealthSection.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

355 lines
14 KiB
Swift

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