feat(1.5-p14): 통계·기록 탭 건강 데이터 — 추이·합계 카드와 그날 값 칩, 빌드 10

사용자 요구('이거까지 하고 배포'): 통계·기록에서도 건강 데이터를 하루다님 스타일로.
전부 표시 전용 — HealthCache 인메모리 읽기만, HK 신규 조회·스키마·집계 수학 무변경.

- 통계 탭 맨 아래: 하루=지표 값 칩 카드 / 주간·월간(롤링 포함)=
  '건강 지표 (일별)' 추이 카드(지표 선택 캡슐 메뉴·요일/일 축·단위 라벨, 분홍 단일 선)
  + '건강 지표 합계·평균' 표(분모=기존 '이미 시작된 날/주' 규칙 상속, 0 지표 생략)
- 기록 탭 목록 맨 아래 '건강 데이터' 섹션 — 그날 칩(과거 날짜도 캐시로)
- 표시 규칙: 지표=타일 선택 상속, 마스터 스위치=설정 '건강 데이터 표시'(문구 확장),
  미연결·맥=숨김, 내보내기 미포함(비교 카드 전례)
- 값 공급 단일 지점 HealthDisplayValues 신설(수면=유효 표시 구간 키, 일기 카드도 위임),
  칩 UI HealthValueChipGrid 공용 — 빌드 9 최적화 그대로 유지
- 도움말·팝업·설정 푸터·whats-new 3언어 갱신(새 키 5·수정 3, 전 카탈로그 0/0)

검증: 자가 검증 62/20/27/10 ALL PASS(수치 불변), 시각 QA(하루·주간[합계 53,966보 시드
검산 일치]·월간 롤링[30일 분모]·기록 목록 과거 날짜·ja·18.5[경과일 분모 4일 검산 일치]),
Debug·Store·18.5 빌드 그린. 종목별 운동 통계는 1.6 후보 보류. CURRENT_PROJECT_VERSION 10.

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-27 19:28:20 +09:00
parent ebe3551e07
commit 0d78789a05
12 changed files with 428 additions and 25 deletions

File diff suppressed because one or more lines are too long

View File

@ -206,3 +206,15 @@
- **새 구성**: [뒤로가기(시스템)] / **아이콘 타일=실행 버튼**(64pt, 우하단 배지 (횟수)·▶(대기)·■(측정 중), 측정 중=노란 테두리+노란 배지 — 앱 전체 '측정 중=노랑' 문법) / 이름 / 기간 라벨+큰 값(측정 중 노란 타이머) / **상태별 한 줄 힌트**("아이콘을 누르면 시작/종료/1회 추가"). 별도 '측정 중' 줄은 사중 중복이라 제거. 실행 경로는 기존과 동일한 store.run(햅틱 포함) — 트리거 뷰만 바뀜, a11y 라벨은 기존 버튼 문구 재사용.
- **작은 화면 대응**: 화면 높이<220pt(40·41mm) 컴팩트 메트릭(아이콘 54·간격 6) 40mm에서도 힌트까지 스크롤 없이 화면. 힌트는 lineLimit 1+minimumScaleFactor 0.75(ja "アイコンをタップで計測終了" 40mm에서 줄로 꺾여 폴드 밑으로 가던 실측 수정 일기 건강 전례 문법).
- **검증**: 신규 인자 `-watchSeedSnapshot`(페어링 없는 합성 스냅숏 — 3상태)+`-watchOpenActionIndex`**49mm(울트라)·46mm(26.2)·40mm SE(11.5) × 횟수형/측정 중/대기 + ja** 스크린샷 전부 잘림 0(임시 심은 QA 후 삭제). 도움말 문구 갱신+전 카탈로그 missing/stale 0, 워치·iOS Debug·Store 빌드 그린. 스키마·빌드 번호 무변경(9 유지 — ASC 업로드 전).
### §12 후속 6 — 통계·기록 탭 건강 데이터 (2026-08-27, 빌드 10)
- **발단(사용자 소킹 요구)**: "통계·기록에서 건강 데이터를 하루다님 스타일로 정리해 보고 싶다 — 기존 동작·논리·스타일 유지, 건강만 추가" → 아리나 제안 형태 승인.
- **구현(전부 표시 전용 — HealthCache 인메모리 읽기만, HK 신규 조회·스키마·집계 수학 무변경)**:
- 통계 탭 맨 아래: 하루='건강 데이터' 칩 카드(선택 날짜 값·값 없으면 대시), 주간/월간(롤링 포함)='건강 지표 (일별)' 추이 카드(지표 선택 캡슐 메뉴, stats.healthMetric 기기 로컬 저장, 주간=요일 축/월간=일 숫자 축, Y축 단위 시간(h)·보·kcal·km·L, 분홍 단일 선)+'건강 지표 합계·평균' 표(합계·하루 평균, 달력 월간만 주 평균 — 분모는 기존 elapsedDay/WeekCount 규칙 그대로, 값 0 지표 생략, 전무 시 빈 문구 카드 1장).
- 기록 탭 목록 보기 맨 아래 '건강 데이터' 섹션(그날 칩 — 시간·횟수 섹션 뒤라 기존 배치 불변).
- 표시 규칙: 지표=모음 탭 타일 선택 상속(HealthMetric.selectedList), **마스터 스위치=설정 '건강 데이터 표시'**(healthTilesEnabled 의미 확장 — 푸터 문구 갱신), 미연결·맥=조용히 숨김, 내보내기 리포트 미포함(비교 카드 전례).
- 값 공급 단일 지점 `HealthDisplayValues` 신설(수면=SleepWindowPrefs 유효 구간 키) — DiaryHealthCard도 위임으로 통일, 칩 UI는 `HealthValueChipGrid` 공용(그리드+refreshToday 신선도).
- **문구**: 도움말 건강 첫 주제·신기능 팝업 타일 항목·설정 푸터에 통계·기록 카드 반영, whats-new-1.5 3언어 bullet 추가. 새 키 5(추이 카드 제목·표 제목·빈 문구·'보'·메뉴 a11y)+수정 3, 전 카탈로그 missing/stale 0.
- **한계(의도)**: 종목별 운동 통계 제외(하루 합계 캐시가 다짐 종목만 — 백필 확장 필요, 1.6 후보), 400일 보존 밖 과거=빈 값, 신선도=타일과 동일(캐시).
- **검증**: 자가 검증 62/20/27/10 ALL PASS(⑱ 최적화·수치 불변 재확인), 시각 QA — 하루 칩(8,432보 시드 일치)·주간(합계 53,966보=시드 7일 합산 검산 일치, 요일 축)·월간 롤링(30일 분모 2,051보)·기록 목록 과거 날짜(7,589보=시드×0.9)·ja(ヘルス指標(日別)·指標別合計・平均)·18.5(경과일 분모 4일 실측 7,799보=31,199÷4 검산 일치), Debug·Store·18.5 빌드 그린. 빌드 10.

View File

@ -601,7 +601,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -643,7 +643,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -686,7 +686,7 @@
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -722,7 +722,7 @@
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -758,7 +758,7 @@
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
@ -794,7 +794,7 @@
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
@ -827,7 +827,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Widgets/Info.plist;
@ -853,7 +853,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Widgets/Info.plist;

View File

@ -2587,6 +2587,38 @@
}
}
},
"건강 지표 (일별)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Health metrics (daily)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ヘルス指標(日別)"
}
}
}
},
"건강 지표 합계·평균": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Totals & averages by metric"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "指標別合計・平均"
}
}
}
},
"건강한 생활 습관": {
"localizations": {
"en": {
@ -2706,18 +2738,18 @@
}
}
},
"걸음·운동·수면 같은 애플 건강 앱의 오늘 데이터를 모음 탭 타일로 볼 수 있어요. 값은 기기 밖으로 나가지 않아요.": {
"걸음·운동·수면 같은 애플 건강 앱의 오늘 데이터를 모음 탭 타일로 볼 수 있어요. 통계·기록 탭에서도 함께 보여요. 값은 기기 밖으로 나가지 않아요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "See today's Apple Health data — steps, exercise, sleep and more — as tiles on the Home tab. Values never leave your device."
"value": "See today's Apple Health data — steps, exercise, sleep and more — as tiles on the Home tab, and on the Stats and History tabs too. Values never leave your device."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "歩数・運動・睡眠など、Appleヘルスケアの今日のデータをホームタブのタイルで見られます。値が端末の外に出ることはありません。"
"value": "歩数・運動・睡眠など、Appleヘルスケアの今日のデータをホームタブのタイルで見られます。統計・記録タブにも一緒に表示されます。値が端末の外に出ることはありません。"
}
}
}
@ -6604,18 +6636,18 @@
}
}
},
"모음 탭의 '건강 데이터' 줄에서 '애플 건강 연결하기'를 누르면 걸음 수·운동 시간·수면 같은 오늘 데이터를 타일로 볼 수 있어요. 어떤 지표를 어떤 순서로 보여줄지는 줄 오른쪽의 지표 버튼에서 고르고, 설정 → '건강 데이터'에서 줄 자체를 끄거나 즐겨찾기·나머지 행동과의 순서를 바꿀 수 있어요.\n값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요 — 하루 다님이 서버로 보내거나 iCloud에 저장하는 일은 없어요. 걸음·운동 같은 값은 설정의 '하루 시작 시간' 기준으로 오늘 하루를 더한 것이고, 수면만은 정해 둔 수면 구간(기본 저녁 9시~다음 날 아침 9시)에서 잔 시간을 통째로 '그날 잔 것'으로 세요. 애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보이니 값이 안 맞아 보이면 잠시 뒤 다시 확인해 주세요. 값이 계속 0이라면 아이폰의 설정 → 개인정보 보호 및 보안 → 건강에서 하루 다님의 읽기 권한을 확인해 주세요.": {
"모음 탭의 '건강 데이터' 줄에서 '애플 건강 연결하기'를 누르면 걸음 수·운동 시간·수면 같은 오늘 데이터를 타일로 볼 수 있어요. 어떤 지표를 어떤 순서로 보여줄지는 줄 오른쪽의 지표 버튼에서 고르고, 설정 → '건강 데이터'에서 줄 자체를 끄거나 즐겨찾기·나머지 행동과의 순서를 바꿀 수 있어요. 골라 둔 지표는 통계 탭 아래의 건강 카드(하루 값·지표별 추이·합계와 하루 평균)와 기록 탭 목록의 그날 건강 카드에도 그대로 쓰여요 — '건강 데이터 표시'를 끄면 이 카드들도 함께 사라져요.\n값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요 — 하루 다님이 서버로 보내거나 iCloud에 저장하는 일은 없어요. 걸음·운동 같은 값은 설정의 '하루 시작 시간' 기준으로 오늘 하루를 더한 것이고, 수면만은 정해 둔 수면 구간(기본 저녁 9시~다음 날 아침 9시)에서 잔 시간을 통째로 '그날 잔 것'으로 세요. 애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보이니 값이 안 맞아 보이면 잠시 뒤 다시 확인해 주세요. 값이 계속 0이라면 아이폰의 설정 → 개인정보 보호 및 보안 → 건강에서 하루 다님의 읽기 권한을 확인해 주세요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tap 'Connect Apple Health' in the Health data row on the Home tab to see today's data — steps, exercise time, sleep — as tiles. Choose which metrics appear, and in what order, with the metrics button on the right of the row; in Settings → Health data you can turn the row off or reorder it against Favorites and the other actions.\nThe values are read from the Apple Health app — Haru Danim never sends them to a server or stores them in iCloud. Steps, exercise and the like are summed for today based on the 'Day start time' setting, while sleep counts everything you slept within your sleep window (9 PM to 9 AM the next day by default) as that day's sleep. Data gathered on Apple Watch shows up after the watch syncs with your iPhone, so if a value looks off, check again in a moment. If a value stays at 0, check Haru Danim's read permission in iPhone Settings → Privacy & Security → Health."
"value": "Tap 'Connect Apple Health' in the Health data row on the Home tab to see today's data — steps, exercise time, sleep — as tiles. Choose which metrics appear, and in what order, with the metrics button on the right of the row; in Settings → Health data you can turn the row off or reorder it against Favorites and the other actions. The metrics you pick also drive the health cards at the bottom of the Stats tab (the day's values, per-metric trends, totals and daily averages) and the day's health card in the History tab's list — turning off 'Show health data' hides these cards too.\nThe values are read from the Apple Health app — Haru Danim never sends them to a server or stores them in iCloud. Steps, exercise and the like are summed for today based on the 'Day start time' setting, while sleep counts everything you slept within your sleep window (9 PM to 9 AM the next day by default) as that day's sleep. Data gathered on Apple Watch shows up after the watch syncs with your iPhone, so if a value looks off, check again in a moment. If a value stays at 0, check Haru Danim's read permission in iPhone Settings → Privacy & Security → Health."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ホームタブの「ヘルスデータ」行で「Appleヘルスケアと連携」をタップすると、歩数・運動時間・睡眠など今日のデータをタイルで見られます。どの指標をどの順番で表示するかは行の右にある指標ボタンで選び、設定 →「ヘルスデータ」で行そのものをオフにしたり、お気に入り・その他のアクションとの順番を変えたりできます。\n値はAppleヘルスケアAppが持つデータを読み込んだものです — Haru Danimがサーバーへ送信したりiCloudに保存したりすることはありません。歩数・運動などの値は設定の「1日の開始時刻」を基準に今日1日を合計したもので、睡眠だけは決めておいた睡眠時間帯初期設定は夜9時〜翌朝9時に眠った時間をまるごと「その日の睡眠」として数えます。Apple WatchにたまったデータはウォッチとiPhoneが同期した後に表示されるので、値が合わないように見えたら少し待ってから確認してください。値がずっと0の場合は、iPhoneの設定 → プライバシーとセキュリティ → ヘルスケアでHaru Danimの読み取り許可を確認してください。"
"value": "ホームタブの「ヘルスデータ」行で「Appleヘルスケアと連携」をタップすると、歩数・運動時間・睡眠など今日のデータをタイルで見られます。どの指標をどの順番で表示するかは行の右にある指標ボタンで選び、設定 →「ヘルスデータ」で行そのものをオフにしたり、お気に入り・その他のアクションとの順番を変えたりできます。選んでおいた指標は、統計タブ下部のヘルスカードその日の値・指標別の推移・合計と1日平均と記録タブ一覧のその日のヘルスカードにもそのまま使われます —「ヘルスデータを表示」をオフにすると、これらのカードも一緒に消えます。\n値はAppleヘルスケアAppが持つデータを読み込んだものです — Haru Danimがサーバーへ送信したりiCloudに保存したりすることはありません。歩数・運動などの値は設定の「1日の開始時刻」を基準に今日1日を合計したもので、睡眠だけは決めておいた睡眠時間帯初期設定は夜9時〜翌朝9時に眠った時間をまるごと「その日の睡眠」として数えます。Apple WatchにたまったデータはウォッチとiPhoneが同期した後に表示されるので、値が合わないように見えたら少し待ってから確認してください。値がずっと0の場合は、iPhoneの設定 → プライバシーとセキュリティ → ヘルスケアでHaru Danimの読み取り許可を確認してください。"
}
}
}
@ -7908,6 +7940,22 @@
}
}
},
"보": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "steps"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "歩"
}
}
}
},
"보기": {
"localizations": {
"en": {
@ -11300,6 +11348,22 @@
}
}
},
"이 기간의 건강 데이터가 없어요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No health data for this period"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "この期間のヘルスデータがありません"
}
}
}
},
"이 기록에 대한 메모 (선택)": {
"localizations": {
"en": {
@ -14522,6 +14586,22 @@
}
}
},
"추이를 볼 지표 선택": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Choose a metric to chart"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "推移を見る指標を選ぶ"
}
}
}
},
"추적 방식": {
"localizations": {
"en": {
@ -14858,18 +14938,18 @@
}
}
},
"켜면 애플 건강 앱의 오늘 데이터(걸음·운동·수면 등)를 모음 탭에서 타일로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요. 수면 표시 구간은 어느 시간대의 잠을 '그날 잔 것'으로 볼지 정해요 — 밤낮이 바뀌는 날이 있어도 맞출 수 있어요.": {
"켜면 애플 건강 앱의 데이터(걸음·운동·수면 등)를 모음 탭 타일과 통계·기록 탭의 건강 카드로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요. 수면 표시 구간은 어느 시간대의 잠을 '그날 잔 것'으로 볼지 정해요 — 밤낮이 바뀌는 날이 있어도 맞출 수 있어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Turn it on to see today's data from the Apple Health app (steps, exercise, sleep and more) as tiles on the Home tab. Use Section order to arrange Favorites, the other actions and Health data. The sleep display window decides which hours of sleep count as 'that day's sleep' — so it fits even when your days and nights swap."
"value": "Turn it on to see data from the Apple Health app (steps, exercise, sleep and more) as tiles on the Home tab and as health cards on the Stats and History tabs. Use Section order to arrange Favorites, the other actions and Health data. The sleep display window decides which hours of sleep count as 'that day's sleep' — so it fits even when your days and nights swap."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "オンにすると、AppleヘルスケアAppの今日のデータ(歩数・運動・睡眠など)をホームタブのタイルで見られます。セクションの表示順でお気に入り・その他のアクション・ヘルスデータの配置を決められます。睡眠の表示時間帯は、どの時間帯の眠りを「その日の睡眠」と見なすかを決めます — 昼夜が入れ替わる日があっても合わせられます。"
"value": "オンにすると、AppleヘルスケアAppのデータ歩数・運動・睡眠などをホームタブのタイルと、統計・記録タブのヘルスカードで見られます。セクションの表示順でお気に入り・その他のアクション・ヘルスデータの配置を決められます。睡眠の表示時間帯は、どの時間帯の眠りを「その日の睡眠」と見なすかを決めます — 昼夜が入れ替わる日があっても合わせられます。"
}
}
}

View File

@ -355,6 +355,70 @@ struct HealthGuideSheet: View {
}
}
// MARK: - (1.5(10) ·· )
/// HealthCache "" (SleepWindowPrefs) .
/// (todayValues)· .
enum HealthDisplayValues {
static func value(_ metric: HealthMetric, dayKey: Date) -> Double? {
let key = metric == .sleep
? "sleep@\(SleepWindowPrefs.spec(forDayKey: dayKey))"
: metric.rawValue
return HealthCache.value(key, dayKey: dayKey)
}
}
// MARK: - (1.5(10) '' · )
/// ( ).
/// ( ).
/// · refreshToday 1 .
struct HealthValueChipGrid: View {
let dayKey: Date
let metrics: [HealthMetric]
var body: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8) {
ForEach(metrics) { metric in
chip(metric)
}
}
.task { await HealthDataStore.shared.refreshToday() }
}
private func chip(_ metric: HealthMetric) -> some View {
let value = HealthDisplayValues.value(metric, dayKey: dayKey)
return VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Image(safeSymbol: metric.symbolName)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.pink)
Text(metric.name)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let value {
// (ja "728" ) ( )
Text(metric.valueLabel(value))
.font(.system(.subheadline, design: .rounded).weight(.bold).monospacedDigit())
.lineLimit(1)
.minimumScaleFactor(0.7)
} else {
Text(verbatim: "")
.font(.system(.subheadline, design: .rounded).weight(.bold))
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(verbatim: "\(metric.name) \(value.map(metric.valueLabel) ?? String(localized: "기록 없음"))"))
}
}
// MARK: - (1.5 · , HealthIntervals )
/// · · .
@ -447,10 +511,8 @@ struct DiaryHealthCard: View {
}
private func value(_ metric: HealthMetric) -> Double? {
let key = metric == .sleep
? "sleep@\(SleepWindowPrefs.spec(forDayKey: dayKey))"
: metric.rawValue
return HealthCache.value(key, dayKey: dayKey)
// · (1.5(10)) =
HealthDisplayValues.value(metric, dayKey: dayKey)
}
var body: some View {

View File

@ -302,7 +302,7 @@ struct HelpView: View {
HelpTopic(
symbol: "heart.fill",
title: "모음 탭에서 건강 데이터 보기",
body: "모음 탭의 '건강 데이터' 줄에서 '애플 건강 연결하기'를 누르면 걸음 수·운동 시간·수면 같은 오늘 데이터를 타일로 볼 수 있어요. 어떤 지표를 어떤 순서로 보여줄지는 줄 오른쪽의 지표 버튼에서 고르고, 설정 → '건강 데이터'에서 줄 자체를 끄거나 즐겨찾기·나머지 행동과의 순서를 바꿀 수 있어요.\n값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요 — 하루 다님이 서버로 보내거나 iCloud에 저장하는 일은 없어요. 걸음·운동 같은 값은 설정의 '하루 시작 시간' 기준으로 오늘 하루를 더한 것이고, 수면만은 정해 둔 수면 구간(기본 저녁 9시~다음 날 아침 9시)에서 잔 시간을 통째로 '그날 잔 것'으로 세요. 애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보이니 값이 안 맞아 보이면 잠시 뒤 다시 확인해 주세요. 값이 계속 0이라면 아이폰의 설정 → 개인정보 보호 및 보안 → 건강에서 하루 다님의 읽기 권한을 확인해 주세요."
body: "모음 탭의 '건강 데이터' 줄에서 '애플 건강 연결하기'를 누르면 걸음 수·운동 시간·수면 같은 오늘 데이터를 타일로 볼 수 있어요. 어떤 지표를 어떤 순서로 보여줄지는 줄 오른쪽의 지표 버튼에서 고르고, 설정 → '건강 데이터'에서 줄 자체를 끄거나 즐겨찾기·나머지 행동과의 순서를 바꿀 수 있어요. 골라 둔 지표는 통계 탭 아래의 건강 카드(하루 값·지표별 추이·합계와 하루 평균)와 기록 탭 목록의 그날 건강 카드에도 그대로 쓰여요 — '건강 데이터 표시'를 끄면 이 카드들도 함께 사라져요.\n값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요 — 하루 다님이 서버로 보내거나 iCloud에 저장하는 일은 없어요. 걸음·운동 같은 값은 설정의 '하루 시작 시간' 기준으로 오늘 하루를 더한 것이고, 수면만은 정해 둔 수면 구간(기본 저녁 9시~다음 날 아침 9시)에서 잔 시간을 통째로 '그날 잔 것'으로 세요. 애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보이니 값이 안 맞아 보이면 잠시 뒤 다시 확인해 주세요. 값이 계속 0이라면 아이폰의 설정 → 개인정보 보호 및 보안 → 건강에서 하루 다님의 읽기 권한을 확인해 주세요."
),
HelpTopic(
symbol: "bed.double.fill",

View File

@ -367,6 +367,11 @@ private struct HistoryRecordsView: View {
@Query private var sessions: [TimeSession]
@Query private var entries: [CountEntry]
// 1.5(10): ' ' · .
// ' '( ) ( )
@AppStorage(LocalPrefsKeys.healthTilesEnabled, store: AppGroup.defaults) private var healthTilesEnabled = true
@AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults) private var healthMetricsRaw = ""
private let math = DayMath()
init(dayKey: Date, mode: HistoryMode, timetableWeekly: Binding<Bool>,
@ -549,6 +554,17 @@ private struct HistoryRecordsView: View {
.buttonStyle(.plain)
}
}
// 1.5(10): · .
// (HealthCache ) ·· ,
// ( )
if HealthDataStore.isAvailable && healthTilesEnabled && HealthDataStore.shared.hasRequestedAuth {
Section("건강 데이터") {
HealthValueChipGrid(
dayKey: dayKey,
metrics: HealthMetric.selectedList(raw: healthMetricsRaw)
)
}
}
}
.scrollContentBackground(.hidden)
}

View File

@ -228,7 +228,7 @@ struct SettingsView: View {
} header: {
Text("건강 데이터")
} footer: {
Text("켜면 애플 건강 앱의 오늘 데이터(걸음·운동·수면 등)를 모음 탭에서 타일로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요. 수면 표시 구간은 어느 시간대의 잠을 '그날 잔 것'으로 볼지 정해요 — 밤낮이 바뀌는 날이 있어도 맞출 수 있어요.")
Text("켜면 애플 건강 앱의 데이터(걸음·운동·수면 등)를 모음 탭 타일과 통계·기록 탭의 건강 카드로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요. 수면 표시 구간은 어느 시간대의 잠을 '그날 잔 것'으로 볼지 정해요 — 밤낮이 바뀌는 날이 있어도 맞출 수 있어요.")
}
.id("healthSection")
}

View File

@ -335,6 +335,13 @@ private struct StatsChartsView: View {
@Query private var sessions: [TimeSession]
@Query private var entries: [CountEntry]
// 1.5(10): · ( ).
// ' '( )
@AppStorage(LocalPrefsKeys.healthTilesEnabled, store: AppGroup.defaults) private var healthTilesEnabled = true
@AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults) private var healthMetricsRaw = ""
/// ( statsRollingWindow )
@AppStorage(SettingsKeys.statsHealthMetric) private var statsHealthMetricRaw = ""
private let math = DayMath()
init(span: StatSpan, anchorDayKey: Date, rolling: Bool,
@ -509,6 +516,8 @@ private struct StatsChartsView: View {
case .month:
monthCharts(data)
}
// (1.5(10) )
healthSection
}
.padding()
Color.clear
@ -1081,6 +1090,224 @@ private struct StatsChartsView: View {
}
}
// MARK: (1.5(10) HealthCache .
// ' ' )
/// + ' ' + .
/// ( CTA )
private var showsHealth: Bool {
HealthDataStore.isAvailable && healthTilesEnabled && HealthDataStore.shared.hasRequestedAuth
}
/// = ( )
private var healthMetricsList: [HealthMetric] {
HealthMetric.selectedList(raw: healthMetricsRaw)
}
/// ( )
private var chartHealthMetric: HealthMetric {
if let saved = HealthMetric(rawValue: statsHealthMetricRaw),
healthMetricsList.contains(saved) {
return saved
}
return healthMetricsList.first ?? .steps
}
@ViewBuilder
private var healthSection: some View {
if showsHealth {
if span == .day {
// : ( )
chartCard("건강 데이터") {
HealthValueChipGrid(dayKey: anchorDayKey, metrics: healthMetricsList)
}
} else {
let days = math.dayKeys(in: spanRange)
let hasAny = days.contains { day in
healthMetricsList.contains { HealthDisplayValues.value($0, dayKey: day) != nil }
}
if hasAny {
healthTrendCard(days: days)
healthSummaryCard(days: days)
} else {
// (400)
chartCard("건강 데이터") { emptyHealthText }
}
}
}
}
private struct HealthDayPoint: Identifiable {
let dayKey: Date
let value: Double
var id: Date { dayKey }
}
/// (h), · km·L,
private func healthChartValue(_ metric: HealthMetric, _ raw: Double) -> Double {
if metric.isDuration { return raw / 3600 }
switch metric {
case .distance, .water: return raw / 1000
default: return raw
}
}
/// Y (kcal·km·L )
@ViewBuilder
private func healthUnitLabel(_ metric: HealthMetric) -> some View {
if metric.isDuration || metric == .standHours {
Text("시간(h)")
} else {
switch metric {
case .steps: Text("")
case .activeEnergy: Text(verbatim: "kcal")
case .distance: Text(verbatim: "km")
case .water: Text(verbatim: "L")
default: Text(verbatim: "")
}
}
}
/// ,
private func healthTrendCard(days: [Date]) -> some View {
let metric = chartHealthMetric
let points = days.map { day in
HealthDayPoint(
dayKey: day,
value: healthChartValue(metric, HealthDisplayValues.value(metric, dayKey: day) ?? 0)
)
}
return VStack(alignment: .leading, spacing: 12) {
HStack {
Text("건강 지표 (일별)")
.font(.subheadline.weight(.semibold))
Spacer()
Menu {
ForEach(healthMetricsList) { candidate in
Button {
statsHealthMetricRaw = candidate.rawValue
} label: {
if candidate == metric {
Label(candidate.name, systemImage: "checkmark")
} else {
Text(candidate.name)
}
}
}
} label: {
HStack(spacing: 4) {
Image(safeSymbol: metric.symbolName)
.font(.system(size: 11, weight: .semibold))
Text(metric.name)
.font(.caption.weight(.semibold))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(.pink)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(Color.pink.opacity(0.12), in: Capsule())
}
.accessibilityLabel(Text("추이를 볼 지표 선택"))
}
Chart(points) { point in
LineMark(
x: .value("날짜", point.dayKey, unit: .day),
y: .value(metric.name, point.value)
)
.foregroundStyle(.pink)
.symbol(.circle)
.interpolationMethod(.monotone)
}
.chartXAxis {
if span == .week {
AxisMarks(values: .stride(by: .day)) { value in
AxisGridLine()
AxisValueLabel {
if let date = value.as(Date.self) {
Text(Format.weekdayShort(math.calendar.component(.weekday, from: date)))
}
}
}
} else {
AxisMarks { _ in
AxisGridLine()
AxisValueLabel(format: .dateTime.day())
}
}
}
.chartYAxisLabel { healthUnitLabel(metric) }
.frame(height: 200)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
/// · ( )
private func healthSummaryCard(days: [Date]) -> some View {
let rows: [(metric: HealthMetric, total: Double)] = healthMetricsList.compactMap { metric in
let total = days.reduce(0.0) { $0 + (HealthDisplayValues.value(metric, dayKey: $1) ?? 0) }
return total > 0 ? (metric, total) : nil
}
return chartCard("건강 지표 합계·평균") {
if rows.isEmpty {
emptyHealthText
} else {
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 12) {
GridRow {
Text("지표")
.fontWeight(.semibold)
Text("합계")
.gridColumnAlignment(.trailing)
Text("하루 평균")
.gridColumnAlignment(.trailing)
if span == .month && !rolling {
Text("주 평균")
.gridColumnAlignment(.trailing)
}
}
.font(.caption2)
.foregroundStyle(.secondary)
ForEach(rows, id: \.metric) { row in
GridRow {
HStack(spacing: 8) {
Image(safeSymbol: row.metric.symbolName)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 24, height: 24)
.background(Color.pink, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
Text(row.metric.name)
.font(.footnote)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
Text(row.metric.valueLabel(row.total))
.font(.footnote.weight(.semibold).monospacedDigit())
.foregroundStyle(.pink)
Text(row.metric.valueLabel(row.total / Double(elapsedDayCount)))
.font(.footnote.monospacedDigit())
.foregroundStyle(.secondary)
if span == .month && !rolling {
Text(row.metric.valueLabel(row.total / Double(elapsedWeekCount)))
.font(.footnote.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
}
}
}
}
private var emptyHealthText: some View {
Text("이 기간의 건강 데이터가 없어요")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}
// MARK:
private func chartCard(_ title: LocalizedStringKey, @ViewBuilder content: () -> some View) -> some View {

View File

@ -68,7 +68,7 @@ struct WhatsNewView: View {
symbol: "heart.fill",
tint: .pink,
title: String(localized: "모음 탭에서 건강 데이터 보기"),
body: String(localized: "걸음·운동·수면 같은 애플 건강 앱의 오늘 데이터를 모음 탭 타일로 볼 수 있어요. 값은 기기 밖으로 나가지 않아요.")
body: String(localized: "걸음·운동·수면 같은 애플 건강 앱의 오늘 데이터를 모음 탭 타일로 볼 수 있어요. 통계·기록 탭에서도 함께 보여요. 값은 기기 밖으로 나가지 않아요.")
)
feature(
symbol: "figure.run",

View File

@ -7,6 +7,7 @@
• 모음 탭에서 걸음·운동·수면 같은 오늘의 건강 데이터를 타일로 볼 수 있어요 — 값은 기기 밖으로 나가지 않아요
• 건강 데이터로 다짐을 만들 수 있어요(프리미엄) — '매일 8,000보', '수면 8시간', '달리기 30분'처럼요. 운동은 종목별로도 되고, 다짐이 보일 색도 고를 수 있어요
• 수면은 '수면 구간'을 정해 두면 자정을 넘겨 자도 하루가 쪼개지지 않아요. 낮에 자는 날이 있다면 '수면 표시 구간'을 요일별로 맞출 수도 있어요
• 통계 탭에서 지표별 추이와 합계·하루 평균을, 기록 탭에서 그날의 건강 값을 함께 볼 수 있어요
● 일기가 더 넓어졌어요 (프리미엄)
• 일기 탭이 아이폰에도 생겼어요 — 하루 정리를 확인하고, '그리기'를 켜면 손가락으로 노트에 쓸 수 있어요
@ -29,6 +30,7 @@ This update brings Haru Danim closer to Apple Health — and your Apple Watch. A
• See today's health data — steps, exercise, sleep — as tiles on the Home tab. Values never leave your device
• Build quests from health data (Premium): "8,000 steps a day," "8 hours of sleep," "30 minutes of running" — workouts by type, and pick the quest's color too
• Set a sleep window and sleeping past midnight won't split your night. If some days you sleep in the daytime, tune the display window per weekday
• See metric trends with totals and daily averages on the Stats tab, and the day's health values on the History tab
● A bigger Diary (Premium)
• The Diary tab is now on iPhone — review your day, and turn on Draw to write on note pages with your finger
@ -51,6 +53,7 @@ This update brings Haru Danim closer to Apple Health — and your Apple Watch. A
• ホームタブで歩数・運動・睡眠など今日のヘルスデータをタイルで見られます — 値が端末の外に出ることはありません
• ヘルスデータでクエストを作れますプレミアム—「毎日8,000歩」「睡眠8時間」「ランニング30分」など。運動は種目別でもOK、クエストの色も選べます
• 「睡眠時間帯」を決めておけば、日付をまたいで眠っても1日が分割されません。昼間に眠る日があるなら、表示の時間帯を曜日ごとに調整できます
• 統計タブで指標ごとの推移と合計・1日平均を、記録タブでその日のヘルス値を一緒に見られます
● 日記がもっと広く(プレミアム)
• 日記タブがiPhoneにも登場 — 1日のまとめを確認し、「描く」をオンにすれば指でートに書けます

View File

@ -38,6 +38,9 @@ enum SettingsKeys {
/// / ' 7/30' (1.4).
/// (standard defaults)
static let statsRollingWindow = "stats.rollingWindow"
/// raw (1.5(10)).
/// (standard defaults)
static let statsHealthMetric = "stats.healthMetric"
}
/// iPhone (iPad·Mac , )