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:
parent
92afa69f6d
commit
e04cdfdcb9
@ -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) |
|
| 측정 알림 | `-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이라 실행일이 첫날이면 오늘 기록이 되지만 완료 표기는 동일) |
|
| 목표 | `-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>`(최신 기록 수정 시트) |
|
| 기록/통계 | `-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 <탭>`과 함께) |
|
| 버블 | `-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"` |
|
| 일기 | `-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, 착지·뒤로가기 검증) |
|
| 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo <stale\|quest\|actionValue>` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) `-watchOpenActionDetail`(1.5 — 스냅숏 수신 후 첫 옵트인 행동의 미니 행동 화면을 딥링크와 같은 경로로 push, 착지·뒤로가기 검증) |
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
- ⚠️ **심사 지침 5.1.3 — HealthKit 데이터를 iCloud(CloudKit)에 저장 금지**: HK **값**은 절대 SwiftData/CloudKit에 넣지 않는다(App Group 캐시는 기기 로컬이라 무방). 다짐의 **정의**(지표·목표량·방향·수면 구간)는 일반 데이터이므로 동기화 OK. → "정의는 동기화, 값은 기기별 자기 HealthKit"이 동기화 모델의 전부.
|
- ⚠️ **심사 지침 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섹션만(다른 해석 원하면 착수 전 확인).
|
- **모음 탭 배치 = 설정으로 제어(사용자 요구 2026-08-20)**: 설정 탭 모음 탭 영역(목표 카드 선택 아래)에 ①**건강 데이터 표시 on/off** ②표시 시 **즐겨찾기 / 나머지 행동 / 건강 데이터 3섹션의 표시 순서 지정** UI 신설(기기 로컬 저장 — 보기 설정 계층). 목표 현황 카드·'현재 진행 중' 영역은 기존대로 상단 고정, 순서 지정 대상은 위 3섹션만(다른 해석 원하면 착수 전 확인).
|
||||||
- 우상단 버튼 → **지표 선택 시트**(표시할 지표 체크+순서, 기기 로컬 — 표시 지표는 다짐 가능 8종보다 넓게 제공 가능).
|
- 우상단 버튼 → **지표 선택 시트**(표시할 지표 체크+순서, 기기 로컬 — 표시 지표는 다짐 가능 8종보다 넓게 제공 가능).
|
||||||
|
|||||||
@ -125,8 +125,12 @@ enum DataReset {
|
|||||||
LocalPrefsKeys.collapsedGoals, LocalPrefsKeys.notePromptActions,
|
LocalPrefsKeys.collapsedGoals, LocalPrefsKeys.notePromptActions,
|
||||||
LocalPrefsKeys.watchGoals, LocalPrefsKeys.mainOthersCollapsed,
|
LocalPrefsKeys.watchGoals, LocalPrefsKeys.mainOthersCollapsed,
|
||||||
LocalPrefsKeys.watchActions,
|
LocalPrefsKeys.watchActions,
|
||||||
|
// 1.5 건강 보기 설정 — 값 자체는 건강 앱 소유라 캐시·표시 설정만 정리
|
||||||
|
LocalPrefsKeys.healthTilesEnabled, LocalPrefsKeys.mainSectionOrder,
|
||||||
|
LocalPrefsKeys.healthCollapsed, LocalPrefsKeys.healthMetrics,
|
||||||
]
|
]
|
||||||
for key in keys { LocalPrefs.defaults.removeObject(forKey: key) }
|
for key in keys { LocalPrefs.defaults.removeObject(forKey: key) }
|
||||||
|
HealthCache.clearAll()
|
||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
}
|
}
|
||||||
|
|||||||
285
myApp/HaruDanim/IOS/Core/HealthStore.swift
Normal file
285
myApp/HaruDanim/IOS/Core/HealthStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>CloudKit</string>
|
<string>CloudKit</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.developer.siri</key>
|
<key>com.apple.developer.siri</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
|||||||
@ -18,6 +18,10 @@
|
|||||||
Face ID 기기에서 인증 시도가 거부된다. Touch ID·암호는 별도 키 불필요 -->
|
Face ID 기기에서 인증 시도가 거부된다. Touch ID·암호는 별도 키 불필요 -->
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<string>일기 잠금을 해제하는 데 사용돼요.</string>
|
<string>일기 잠금을 해제하는 데 사용돼요.</string>
|
||||||
|
<!-- 건강 데이터 읽기 사유 (1.5 — 모음 탭 타일·건강 다짐). 읽기 전용이며
|
||||||
|
값은 기기 밖으로 전송·저장되지 않는다 (심사 5.1.3, PRIVACY.md 건강 문단 참고) -->
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>걸음·운동·수면 같은 건강 데이터를 앱에서 보여주고 다짐 달성 계산에 쓰기 위해 읽어요. 데이터는 이 기기 밖으로 나가지 않아요.</string>
|
||||||
<!-- CFBundleName(맥 메뉴 막대 이름): 생성 Info.plist가 PRODUCT_NAME("Haru_Danim")으로
|
<!-- CFBundleName(맥 메뉴 막대 이름): 생성 Info.plist가 PRODUCT_NAME("Haru_Danim")으로
|
||||||
항상 덮어써 이 파일에 넣어도 무효(검증됨). 실제 표시는 언어별 InfoPlist.strings
|
항상 덮어써 이 파일에 넣어도 무효(검증됨). 실제 표시는 언어별 InfoPlist.strings
|
||||||
(IOS/InfoPlist.xcstrings의 CFBundleName — ko 하루 다님 / en·ja Haru Danim)가 결정하고,
|
(IOS/InfoPlist.xcstrings의 CFBundleName — ko 하루 다님 / en·ja Haru Danim)가 결정하고,
|
||||||
|
|||||||
354
myApp/HaruDanim/IOS/Views/HealthSection.swift
Normal file
354
myApp/HaruDanim/IOS/Views/HealthSection.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,18 @@ struct MainView: View {
|
|||||||
@AppStorage(LocalPrefsKeys.pinnedGoals, store: AppGroup.defaults) private var pinnedGoalsRaw = ""
|
@AppStorage(LocalPrefsKeys.pinnedGoals, store: AppGroup.defaults) private var pinnedGoalsRaw = ""
|
||||||
/// '나머지 행동' 섹션 접힘(기기별) — 즐겨찾기가 있을 때만 섹션이 생기므로 그때만 의미 있음
|
/// '나머지 행동' 섹션 접힘(기기별) — 즐겨찾기가 있을 때만 섹션이 생기므로 그때만 의미 있음
|
||||||
@AppStorage(LocalPrefsKeys.mainOthersCollapsed, store: AppGroup.defaults) private var othersCollapsed = false
|
@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] {
|
private var orderedActions: [Action] {
|
||||||
LocalPrefs.orderedActions(actions, raw: actionOrderRaw)
|
LocalPrefs.orderedActions(actions, raw: actionOrderRaw)
|
||||||
@ -85,16 +97,36 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
if actions.isEmpty {
|
if actions.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
|
// 행동이 없어도 건강 섹션은 배치 (설정에서 켠 경우 — 1.5)
|
||||||
|
if showsHealthSection {
|
||||||
|
HealthSectionView()
|
||||||
|
}
|
||||||
} else if isEditing {
|
} else if isEditing {
|
||||||
// 편집도 같은 섹션 구조 — 섹션 안 드래그=순서 변경, 섹션 사이 드래그=지정·해제
|
// 편집도 같은 섹션 구조 — 섹션 안 드래그=순서 변경, 섹션 사이 드래그=지정·해제.
|
||||||
|
// 건강 섹션은 편집(배치) 모드에서 숨김 — 드롭 델리게이트와의 간섭 원천 차단 (plan §10 R4)
|
||||||
editingFavoritesSection
|
editingFavoritesSection
|
||||||
editingOthersSection
|
editingOthersSection
|
||||||
} else if favoriteActions.isEmpty {
|
|
||||||
grid
|
|
||||||
} else {
|
} else {
|
||||||
favoritesSection
|
// 1.5: 즐겨찾기·나머지·건강 3섹션을 설정 순서대로 배치 (§6.1 —
|
||||||
if !otherActions.isEmpty {
|
// 기존 섹션 빌더·그리드는 무변경, 순서만 이 루프가 결정)
|
||||||
othersSection
|
ForEach(orderedMainSections) { kind in
|
||||||
|
switch kind {
|
||||||
|
case .favorites:
|
||||||
|
if favoriteActions.isEmpty {
|
||||||
|
// 즐겨찾기가 없으면 통짜 그리드가 행동 자리를 담당 (기존 동작 그대로)
|
||||||
|
grid
|
||||||
|
} else {
|
||||||
|
favoritesSection
|
||||||
|
}
|
||||||
|
case .others:
|
||||||
|
if !favoriteActions.isEmpty && !otherActions.isEmpty {
|
||||||
|
othersSection
|
||||||
|
}
|
||||||
|
case .health:
|
||||||
|
if showsHealthSection {
|
||||||
|
HealthSectionView()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,13 @@ struct SettingsView: View {
|
|||||||
@AppStorage(SettingsKeys.goalCardStyle) private var goalCardStyle = GoalCardStyle.perQuest.rawValue
|
@AppStorage(SettingsKeys.goalCardStyle) private var goalCardStyle = GoalCardStyle.perQuest.rawValue
|
||||||
// 모음 탭 노출 목표는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고)
|
// 모음 탭 노출 목표는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고)
|
||||||
@AppStorage(LocalPrefsKeys.pinnedGoals, store: AppGroup.defaults) private var pinnedGoalsRaw = ""
|
@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] {
|
private var visibleTabs: [AppTab] {
|
||||||
AppTab.visibleTabs(from: visibleTabsRaw)
|
AppTab.visibleTabs(from: visibleTabsRaw)
|
||||||
@ -167,6 +174,38 @@ struct SettingsView: View {
|
|||||||
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 카드를 옆으로 쓸어 넘겨 한 장씩 볼 수 있고, 표시 방식(다짐별로 각각 / 전체 다짐 합산)은 모든 카드에 똑같이 적용돼요.")
|
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요. 2개 이상 선택하면 카드를 옆으로 쓸어 넘겨 한 장씩 볼 수 있고, 표시 방식(다짐별로 각각 / 전체 다짐 합산)은 모든 카드에 똑같이 적용돼요.")
|
||||||
}
|
}
|
||||||
.id("goalSection")
|
.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 {
|
Section {
|
||||||
Picker("주 시작 요일", selection: $weekStartWeekday) {
|
Picker("주 시작 요일", selection: $weekStartWeekday) {
|
||||||
ForEach(1...7, id: \.self) { weekday in
|
ForEach(1...7, id: \.self) { weekday in
|
||||||
|
|||||||
81
myApp/HaruDanim/Shared/HealthMetric.swift
Normal file
81
myApp/HaruDanim/Shared/HealthMetric.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,6 +37,42 @@ nonisolated enum LocalPrefsKeys {
|
|||||||
/// [Action.uuid 문자열: WatchActionPeriod raw] 딕셔너리. 기본 비어 있음(옵트인).
|
/// [Action.uuid 문자열: WatchActionPeriod raw] 딕셔너리. 기본 비어 있음(옵트인).
|
||||||
/// 워치는 이 아이폰과 페어링되므로 기기 로컬이 자연스러움 (watchGoals와 동일 계열)
|
/// 워치는 이 아이폰과 페어링되므로 기기 로컬이 자연스러움 (watchGoals와 동일 계열)
|
||||||
static let watchActions = "local.watchActions"
|
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 {
|
nonisolated enum LocalPrefs {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user