diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index db07f82..c1d6808 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -19,7 +19,7 @@ | 번들 ID | `com.yechan.HaruDanim` / App Group `group.com.yechan.HaruDanim` / CloudKit `iCloud.com.yechan.HaruDanim` | | 수익 모델 | 부분 유료화. 무료 한도(행동 10·목표 2·목표당 다짐 3) + 프리미엄 결제 시 해제 | | 결제 상품 | `com.yechan.HaruDanim.premium.monthly`(월 4,400원) / `.yearly`(년 44,000원) / `.lifetime`(77,000원) — **ASC 등록·판매 중(실제 가격, 2026-07-28)**. 로컬 테스트 구성(HaruDanim.storekit)도 동일 가격. 가격은 코드에 없고 `product.displayPrice`로 동적 표기, 년 구독 절약률 배지도 동적 계산 | -| 현재 상태 (2026-08-22) | **버전 1.5 (빌드 3) TestFlight 배포 완료 — 사용자 실기기 소킹 중(2026-08-22 시작, 약 1주 — 문제 없으면 ASC 심사 제출)**. 업로드 검증 실측 2건: 빌드 1=ITMS-90683(HealthKit 엔타이틀먼트 보유 시 읽기 전용이어도 `NSHealthUpdateUsageDescription` 필수 — 쓰기 사유 3언어 추가, 쓰기 요청이 없어 표시될 일 없음), 빌드 2=ITMS-90626(**앱 인텐트 메타데이터에 'iPhone' 단어 금지** — 신규 컴플리케이션 인텐트·위젯 설명에서 '하루 다님 앱'으로 교체. 화면 뷰 문구의 iPhone은 스캔 대상 아님[1.4 전례]) → **빌드 3**으로 재업로드. 계획·결정 이력은 `Docs/plan-1.5.md`(전 섹션 ✅구현 완료). 1.5 내용: ①**워치 '행동 기록' 컴플리케이션**(§11 — 행동 편집 옵트인+표시 기간 5종[하루/이번 주/이번 달/지난 7일/지난 30일, 롤링은 rollingRange 재사용], 측정 중 노란 타이머, 탭=워치 앱 미니 행동 화면 push[뒤로가기=메인], recommendations 상한 없음) ②**모음 탭 건강 데이터 타일**(무료, §6.1 — 걸음·운동·수면 등 8지표 선택·순서, 접기, 연결 CTA[명시적 탭 권한], 안내 시트, 즐겨찾기·나머지·건강 3섹션 순서 설정[§6.8], 맥은 섹션 숨김) ③**건강 다짐**(프리미엄, §4.2·§6.4 — 8지표+운동 종목 20종[workout. 인코딩], 수면은 다짐별 수면 구간[기본 21:00~09:00]을 '구간 끝이 속한 논리적 하루'에 통귀속[겹침 분할과 다른 의도된 예외], 하루 단위 전용·마감 시각 미지원, QuestProgress 값 공급 단일 지점 3곳만 분기[수학 무변경 — 기존 49건 그린이 증거], HealthCache[App Group, 400일, ⚠️5.1.3: 값은 CloudKit 저장 금지 — 정의만 동기화]·백필·부트스트랩, 데이터 없는 기기는 모수 제외+'데이터 없음' 표기+판정 보류, 구버전 이중 상태는 행동/꼬리표 우선 정규화) ④**신기능 소개 팝업**(WhatsNewView — 버전당 1회, 신규 설치 시드로 미표시, 온보딩 우선, 全기기 업데이트 안내) ⑤**도움말 전면 자연화+신규 주제 3개**(사용자 예약 작업 — 오타·번역투·UI 명칭 정정, en/ja 전면 재번역·용어 통일[Action/アクション 등]) ⑥HealthKit 엔타이틀먼트(iOS 앱만)+NSHealthShareUsageDescription 3언어+PRIVACY.md 건강 문단. 검증: `-progressSelfTest` **61건**(건강 H 시리즈 12 신규)·20·27·10 ALL PASS(26.5+18.5 심), Debug/Store/워치(26.2·11.5) 빌드, 시각 QA 20여 장(워치 컴플리케이션 실데이터·미니 화면·타일·다짐 행·편집기·팝업·ja 도움말), 실버전 업그레이드 팝업 경로·벌크 1만 건+건강 결합 확인. 스토어 자료: whats-new-1.5·promotional-text-1.5·설명 3언어 갱신, **스크린샷 전면 신규 — 아이폰 10·아이패드 10·워치 1 × ko/en/ja 원본 57장+합성 63장 재생성 완료**(09-records→09-health 교체, Marketing/README 절차·주의 갱신). **CloudKit 프로덕션 스키마 배포 완료(2026-08-22, 사용자 콘솔 실행)** — Quest 신규 필드 3종(healthMetricRaw·sleepWindow*)은 개발 스키마에 없어서(디버그 내보내기 이력 없음이 원인) 시뮬 519869F7에 사용자 iCloud 로그인 후 디버그 빌드를 `-premium YES -cloudSync YES -seedHealthQuest YES`로 실행 → Sandbox 환경 전체 내보내기 성공(98레코드, error nil)으로 필드 생성 → 콘솔 배포(이 시뮬은 iCloud 로그인 유지 — 향후 스키마 작업 재사용 가능). **실측 발견: 1.3의 DiaryPageItem 스타일 필드가 프로덕션 미배포 상태였음**(마지막 배포=1.0 출시 시점 — 1.3 출시[8/6] 이후 도형·텍스트 상자 신규 스타일 필드 동기화가 조용히 실패했을 가능성, 이번 배포로 해소·밀린 내보내기는 자동 재시도. 소킹 중 아이패드↔맥 요소 동기화 교차 확인 권장). **남은 출시 절차(사용자)**: ①TestFlight 실기기 소킹(진행 중 — 실제 건강 데이터·워치 컴플리케이션·기기 간 건강 다짐 정의 동기화 중점) ②ASC 심사 제출(스크린샷 framed 세트 교체·whats-new/프로모션/설명 반영). **1.6 후보 보류 기록: `Docs/plan-1.6.md`**(타임테이블 건강 데이터 표시 — 2026-08-22 사용자 "일단 보류", 구현 금지·재논의 시 착수). 운동 종목 목록(20종)은 §3.4 — 사용자 실사용 종목 빠졌으면 추가 요청. **버전 1.4 (빌드 3) App Store 출시 완료(2026-08-13, 사용자 확인)** — 2026-08-13 심사 제출 후 같은 날 출시 확인. TestFlight 실기기 테스트(문제 없음 확인) 후 제출했음. iOS 18 실기기는 미확보로 미테스트(시뮬 검증 106건 ALL PASS로 갈음 — 해당 사용자 유입 시 피드백 대응 예정). 1.4 내용(사용자 A안 승인, 2026-08-11 세션에서 전부 구현·검증): ①**iOS 18.0 하한 인하**(위 플랫폼 행 — 글라스 버블 머티리얼 폴백·사용자 심볼 안전 렌더 `Image(safeSymbol:)`[iOS 55곳+워치 7곳, 18에 없는 카탈로그 심볼 3개 물음표 폴백]·`-symbolAuditDump`. ⚠️하한 18 Release(-O wholemodule)에서 swift-frontend가 제네릭 중첩 Coordinator의 deinit 최적화 중 크래시하는 컴파일러 버그 실측 — DiaryZoomCoordinator를 비제네릭 톱레벨+AnyView 소거로 우회, 되돌리지 말 것) ②**펜슬 저장 디바운스**(§6.7 — 획마다 전체 직렬화·블랍 재기록하던 버벅임 해소) ③**통계 '오늘 기준' 롤링**(§6.6·§10 ⑤ — 지난 7일/30일) ④**CSV 구간 내보내기**(§6.8, 코어 CSVExport 추출) ⑤**일기 내보내기 후 삭제**(§6.7, 렌더 코어 DiaryExportRenderer 추출) ⑥**데이터 정리·초기화**(§6.8 — 전체/처음~날짜까지+백업 3종[CSV·일기 PDF·정리 리포트 ArchiveReport]+**'일기만' 대상 축**[1.4(3), 2026-08-12 — 기록·구조·양식 보존, 백업은 일기 PDF만, `DataReset.deleteDiaries`·`-dataResetScope diary`·자가 검증 27건으로 확장]) ⑧**라디얼 모드 FAB 가림 수정**(1.4(3) — 목록 마지막 행이 FAB에 가려지던 실측 문제: 각 탭 루트에 하단 safeAreaInset 78pt, ⚠️NavigationStack '안쪽'에 붙일 것[바깥은 TabView가 safe area 재설정해 무시됨, 실측]) ⑦도움말 재구성(1.4(2) 검수 후속, 2026-08-12 — 뭉친 주제 3곳 분리: 기록·통계 그룹에 '지난 7일·30일로 보기'·'데이터 정리·초기화' 독립 주제 신설[CSV 주제는 백업·복원만, 비교 주제는 1.3 원문 복귀], 일기 그룹 '펜슬 노트 페이지'에서 '사진·도형·텍스트 꾸미기' 분리. 이미지는 그룹당 대표 1주제 원칙 유지 — 18장 전수 실사로 참조·언어 매칭 무결 확인)·l10n(전 카탈로그 missing/stale 0)·**워치 실행 햅틱 .click→.success 상향**(1.4(2) — 실사용 '너무 약함' 보고)·MARKETING_VERSION 1.4/**빌드 3**(TestFlight 재업로드용 — 같은 버전은 빌드 번호만 올려 업로드하면 1.4(2)로 기존 테스터에 배포됨)·`Marketing/AppStore/whats-new-1.4.txt`. 검증: 자가 검증 `-progressSelfTest` 49·`-csvImportTest` 20·`-intentSmokeTest` 10·`-dataResetTest` 27 ALL PASS(26 심+18.5 심), Debug/Store/워치 빌드 성공, 18.5·26 시각 QA(버블 폴백·일기 줌·롤링 통계·초기화 화면·리포트 PDF 3페이지 실물). **스토어 제출 시**: 하한 인하는 ASC에서 바꿀 것 없음(바이너리에서 자동 파생), 스크린샷 재촬영 불필요(통계 토글·설정 행 추가는 사소 — 교체 여부는 사용자 판단), 심사 특이사항 없음. 스토어 텍스트는 1.4(3)에서 한국어 문장 흐름 자연화(사용자 피드백 — 설명 ko 자연화+3언어에 1.4 기능 2줄 추가, whats-new 전면 재작성, 프로모션 1.4 전용 `promotional-text-1.4.txt`). **📌 다음 버전(1.5) 확정 계획 — 상세는 `Docs/plan-1.5.md`(2026-08-20 사용자 결정 완료)**: ①워치 행동 컴플리케이션(행동 편집에서 옵트인+기간 선택[하루/이번 주/이번 달/지난 7일/지난 30일], 탭=미니 행동 화면 착지·뒤로가기로 메인 복귀) ②건강 데이터 통합(모음 탭 타일 **무료**+건강 다짐 **프리미엄**, HealthCache 구조[값은 CloudKit 저장 금지 — 5.1.3], 수면은 사용자 지정 수면 구간→깨어난 날 귀속, 모음 탭 3섹션[즐겨찾기·나머지·건강] 순서 설정) ③도움말(HelpView) 전체 문구 한국어 기준 자연화(사용자 지시 2026-08-12 — ~해요체 유지·번역투 제거·en/ja 재검토·l10n 루틴) ④업데이트 신기능 소개 팝업(버전당 1회, 全기기 업데이트 안내 — 계획서 §11). 운동 세부 종목 다짐 포함(§3.4), 스크린샷 전면 신규(아이폰·아이패드 각 10장 — §6), 모든 사전 결정 완료(결정 이력 8건·위험 검토 R1~R16). 하위 OS 인하는 검토 후 기각(근거는 계획서 §4 — 재제안 금지). ⚠️**구현은 사용자의 "진행/시작" 지시가 있을 때만 착수** — 그 전까지 코드 무변경, 추가 요구는 계획서에 계속 반영. **버전 1.3 App Store 출시 2026-08-06** — 3일 실사용+심층 검증(2026-08-05: 자가 검증 3종 ALL PASS·벌크 1만 건·일기 내보내기 일치·위젯 스모크·만료 열람·도움말 — 회귀 0) 통과 후 제출. 심층 검증에서 기록한 사소 잔여 엣지 9건은 후속 후보로만 보류(전부 극단 한정·자가 복구형, 필기 blob 동시 편집 한계는 §6.7). 1.3 내용: 아래 소기능 이력의 일기 개선 묶음(양식 펜 색 고정 + 도형·텍스트 상자 편집 개편 + 검토 후속 수정), `Marketing/AppStore/whats-new-1.3.txt`. 잔여 엣지 9건 상세: 최소 크기 커밋 스냅백·일기 상세 이탈 시 빈 텍스트 상자 잔존·페이지 가장자리 손잡이 클리핑·인라인 편집 중 페이지 스와이프 상태 잔존·회전 상자 커서 어색함 등(1.0부터의 구조 한계인 필기 blob 동시 편집 덮어쓰기·열린 캔버스 원격 미갱신은 §6.7, 같은 페이지 동시 필기 회피 권장). 1.2 내용: 주기 몫 완료 상태 표기(§4.2)+도움말 '진행률 읽는 법' 갱신(상세는 아래 소기능 이력). **버전 1.1 출시 2026-07-30.** 1.1 내용: ①**맥 일기 개방**(§6.7 — 사이드바 노출 + '그리기' 토글로 마우스 필기, 텍스트 우선 도구줄. 아이패드 경로 불변. 맥 실기 확인 완료) ②**CSV 가져오기**(§6.8 — 프리미엄, 추가 전용·미리 보기·멱등. 검증 `-csvImportTest` 20건 ALL PASS) ③**일기 기본 표시 설정**(§6.7 — 캘린더·목표·행동 전 날짜 기본값 + 날짜별 재정의, 스키마 무변경 마커 방식) ④**'짧은 기록 무시' iCloud KVS 동기화**(§6.8 — 전 기기 공통) ⑤스크린샷 전면 개편(아이폰 10·아이패드 10·워치 합성 1 × ko/en/ja — ASC 업로드 완료) ⑥스토어 메타데이터 변경(앱 이름·부제목·키워드·프로모션 — 검색·색인 강화, `Marketing/AppStore/app-name-subtitle.txt`·`keywords.txt`·`promotional-text.txt`. 스토어 표기만 변경, 설치 이름 불변) ⑦MARKETING_VERSION 1.1. **버전 1.0 출시 2026-07-28.** 이후 작업은 사용자 실사용 중 발견한 버그 수정·사용자가 원하는 소기능 추가만 한다(선제적 기능 제안·대규모 리팩터링 금지). 출시 후 소기능 이력: **일기 도형·텍스트 상자 UX 2차 개편**(2026-08-03, 사용자 요청 — §6.7. 종류별 모양 손잡이[사각형 꼭짓점 4개·원 상하좌우 4개·선/화살표 양 끝점]와 정사각형·정원 스냅 피드백, 텍스트 상자 제자리 입력[탭→바로 키보드]·테두리 색 독립, '글 수정'→'스타일' 시트로 재편, borderColorHex 필드 추가. 1.3으로 버전 상향)·**일기 도형·텍스트 상자 편집 강화**(2026-08-03, 사용자 요청 — §6.7. "정해진 모양·크기 그대로만 나온다"는 보고: 크기 손잡이(가로세로 자유·맥 마우스 대응)·회전 제스처·'도형 수정' 시트[색·선 굵기·채우기·모서리 둥글기·회전]·텍스트 시트 확장[글자 크기·굵게·정렬·테두리], DiaryPageItem에 기본값 필드 8개 추가)·**양식 페이지 펜 색 고정**(2026-08-03, 사용자 요청 — §6.7. 다크/라이트 잉크 반전으로 맥·아이패드에서 필기가 안 보이던 실사용 보고 해소, 내보내기 라이트 트레잇 고정 포함)·**주기 몫 완료 상태 표기**(2026-08-01, 사용자 요청 — §4.2. 도움말 '진행률 읽는 법' 문구도 갱신. 같은 날 MARKETING_VERSION 1.2(빌드 1)로 상향 + `Marketing/AppStore/whats-new-1.2.txt` 작성·TestFlight 업로드, **심사 통과 → App Store 출시 완료(2026-08-02, 사용자 확인)**). 업데이트 제출: 버전 올려 빌드 업로드 → 심사 제출(IAP는 첫 심사에서 승인됐으므로 첨부 불필요). 출시 전 이력 — RC는 커밋 `f05e611`(버전 1.0 (1)), 이후 실사용 보고 기반 수정 라운드(전부 커밋·푸시·실기기 확인 완료): l10n 누락 일괄·ja 앱 이름 'Haru Danim' 통일·워치 컴플리케이션 추천 캡 제거(§11)·⑥ 위젯 종료 버튼(§10)·진행률 환산 캡+수행일 집계+수행일 아님 표기(§4.2)·파일명/CSV 헤더 현지화(§7)·워치 컴플리케이션 목표 필터(§6.4·§11, 사용자 승인 소기능)·기록 시각 입력 상시 휠 다이얼+분 절사(§6.5)·일기 달력 기분 사진 백레이어(§6.7)·다짐 없는 목표 '다짐 없음' 표기(§4.3)·워치 측정 중 행 탭 종료(§11)·도움말 예시 스크린샷 18장(§6.8)·설정 하루 시작 시간 접힘 행(§6.5)·**모음 탭 즐겨찾기 모아 보기**(§6.1 — 행동 많아질 때 혼잡도 대응, 사용자 승인 소기능. v1 별도 카드 가로 줄[대안 A~F 중 A]이 "따로 노는 느낌" 실사용 피드백으로 v2 그리드 섹션 통합+나머지 접기로 교체, 같은 날 후속 피드백으로 편집 모드 섹션 인지·섹션 간 드래그 지정/해제·"지정 순서대로 뒤에" 규칙 추가, 2026-07-22)·**en 복수형 정리**(2026-07-22 — 수량 1일 때 "1 times/quests" 류 오류를 카탈로그 plural variation으로 14건 전환[iOS 10·위젯 3·워치 1], 모음 탭 섹션 제목 en "Favorite"→"Favorites"로 워치와 통일. ko/ja 무영향). **App Store 등록 자료는 `Marketing/`에 완비**(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것. 2026-07-22 즐겨찾기 섹션(v2) 반영해 아이폰·아이패드 01-main과 help-main ko/en/ja 재촬영·합성판 재생성. **2026-07-29 스크린샷 세트 확장** — 아이폰 8장(+위젯·버블 메뉴·iCloud 동기화 합성·다크 모드)/아이패드 7장(+주간 타임테이블·위젯·동기화 합성) × ko/en/ja, 촬영·합성 절차는 Marketing/README '추가 스크린샷' 절. **1.1 제출 때 ASC 업로드 완료**). **사업자 등록·통신판매업 신고 완료(2026-07-22)** — 서류 절차 끝, ASC 등록·유료 계약 진행 가능(순서는 Marketing/README 체크리스트). 사용자는 계속 실사용 테스트 중이며 버그 보고 시: 최소 변경 + 시뮬레이터 재검증 + l10n 루틴(§2.3) + Debug/Store(워치 파일이면 워치까지) 빌드 + 커밋·푸시 | +| 현재 상태 (2026-08-22) | **버전 1.5 (빌드 4) — 소킹 첫날 사용자 요구 5건 추가 구현 완료(2026-08-22 저녁, 아래 '1.5 추가분'), TestFlight 재업로드 대기**. ⚠️**미해결 실측 이슈 1건(사용자 재현)**: 일기에 도형·텍스트 상자(DiaryPageItem)가 있으면 **그 기기의 CloudKit 동기화 전체가 정지**(내보내기 대기열이 직렬이라 거부된 아이템 뒤로 모든 내보내기·가져오기가 막힘 — 아이템 삭제 시 즉시 회복도 재현됨). 추정 원인: 오늘 프로덕션 배포가 1.0 시절 개발 스키마 기준이라 **1.3의 DiaryPageItem 스타일 필드(text·fontSize·textBold·textAlignRaw·textBorder·borderColorHex·heightRatio·lineWidth·filled·cornerRadius)가 여전히 프로덕션에 없음** — 해결 절차: iCloud 로그인된 시뮬 519869F7에서 디버그 빌드+`-cloudSync YES`+일기 아이템 시드 내보내기로 개발 스키마에 필드 생성 → 사용자가 콘솔에서 재배포 → 아이패드 자동 회복(밀린 내보내기 재시도). 업로드 검증 실측 2건: 빌드 1=ITMS-90683(HealthKit 엔타이틀먼트 보유 시 읽기 전용이어도 `NSHealthUpdateUsageDescription` 필수 — 쓰기 사유 3언어 추가, 쓰기 요청이 없어 표시될 일 없음), 빌드 2=ITMS-90626(**앱 인텐트 메타데이터에 'iPhone' 단어 금지** — 신규 컴플리케이션 인텐트·위젯 설명에서 '하루 다님 앱'으로 교체. 화면 뷰 문구의 iPhone은 스캔 대상 아님[1.4 전례]) → **빌드 3**으로 재업로드. **1.5 추가분(빌드 4, 2026-08-22 — 소킹 중 사용자 요구, 스키마 무변경·전부 로컬 설정/표시 계층)**: ⑦**아이폰 일기 탭 개방**(§6.7 — AppTab.phoneCases 개방[radialOrder 필터 제거·버블 8개 자동 배치 확인], 노트 필기는 맥 '그리기' 토글 문법 재사용[DeviceLayout.isPhone 신설, drawingPolicy anyInput, 가로 스크롤 도구 줄, 배치↔그리기 상호 배타 — 아이패드 경로 무변경], 일기 잠금 설정 전 기기 노출) ⑧**일기 하루 정리 '건강 데이터' 카드**(§6.7 — DiarySection.health[케이스 마지막이라 기존 사용자 목록 끝에 추가], DiaryHealthCard: 지표 선택 시트 공용화[HealthMetricPickerSheet Binding화], 저장은 기기 로컬 diary.healthMetrics[비면 타일 선택 상속], 맥은 목록·화면 모두 숨김, 인쇄 요약에 값 줄 DiaryPrintHealthRow 동승) ⑨**일기 타임테이블 수면·운동 실구간 블록**(§6.7 — **HealthIntervals 신설**[App Group health.intervals.v1, 400일, 실구간을 논리적 하루로 분할 저장·병합, CloudKit 금지 동일], refreshRecent/백필이 유지, ExportSnapshot.injectingHealthBlocks 주입[캘린더 전례·최하층 렌더 healthBlockView 반투명+실선], 지표 고정색 수면 인디고·운동 주황, 필터 시트 '건강 데이터' 토글[RecordFilterSheet showsHealthSection — 전 날짜 공통 diary.timetableHealthHidden, 기본 켬]+**맥 안내 문구**, 일기 필터 버튼 상시 노출로 변경, 내보내기 렌더러 동일 주입) ⑩**수면 표시 구간 설정**(§6.8 — SleepWindowPrefs[LocalPrefs]: 전체 스펙+요일별 7종[켤 때만], 설정 → 건강 데이터 → '수면 표시 구간'[SleepWindowSettingsView — 시작=끝이면 24시간 창], 평문 'sleep' 캐시 키=그날 유효 구간 값으로 변경[타일·일기 카드 공유], 백필 서명에 요일 매핑 포함[배정만 바뀌어도 재백필], **새 수면 다짐 기본 구간을 여기서 시드**) ⑪일기 목표 카드의 건강 다짐 값 단위 실측 수정("8,432회/8,000보"→지표 단위 — DiaryGoalsCard.valueLabel healthTarget 분기). 검증: 61/20/27/10 ALL PASS(26.5), 3종 빌드 그린, 전 카탈로그 missing/stale 0, 시각 QA(아이폰 요약·건강 카드·타임테이블 블록·노트 도구 줄·버블 8개·수면 구간 화면·아이패드·맥 분기·18.5). 스토어 자료: whats-new-1.5에 일기 확장 절 추가·설명 3언어 일기 항목 갱신. 신규 인자 `-sleepWindowPreview`(§14), `-seedHealthCache`가 실구간(수면 자정 분할+달리기)도 시드. 계획·결정 이력은 `Docs/plan-1.5.md`(전 섹션 ✅구현 완료). 1.5 내용: ①**워치 '행동 기록' 컴플리케이션**(§11 — 행동 편집 옵트인+표시 기간 5종[하루/이번 주/이번 달/지난 7일/지난 30일, 롤링은 rollingRange 재사용], 측정 중 노란 타이머, 탭=워치 앱 미니 행동 화면 push[뒤로가기=메인], recommendations 상한 없음) ②**모음 탭 건강 데이터 타일**(무료, §6.1 — 걸음·운동·수면 등 8지표 선택·순서, 접기, 연결 CTA[명시적 탭 권한], 안내 시트, 즐겨찾기·나머지·건강 3섹션 순서 설정[§6.8], 맥은 섹션 숨김) ③**건강 다짐**(프리미엄, §4.2·§6.4 — 8지표+운동 종목 20종[workout. 인코딩], 수면은 다짐별 수면 구간[기본 21:00~09:00]을 '구간 끝이 속한 논리적 하루'에 통귀속[겹침 분할과 다른 의도된 예외], 하루 단위 전용·마감 시각 미지원, QuestProgress 값 공급 단일 지점 3곳만 분기[수학 무변경 — 기존 49건 그린이 증거], HealthCache[App Group, 400일, ⚠️5.1.3: 값은 CloudKit 저장 금지 — 정의만 동기화]·백필·부트스트랩, 데이터 없는 기기는 모수 제외+'데이터 없음' 표기+판정 보류, 구버전 이중 상태는 행동/꼬리표 우선 정규화) ④**신기능 소개 팝업**(WhatsNewView — 버전당 1회, 신규 설치 시드로 미표시, 온보딩 우선, 全기기 업데이트 안내) ⑤**도움말 전면 자연화+신규 주제 3개**(사용자 예약 작업 — 오타·번역투·UI 명칭 정정, en/ja 전면 재번역·용어 통일[Action/アクション 등]) ⑥HealthKit 엔타이틀먼트(iOS 앱만)+NSHealthShareUsageDescription 3언어+PRIVACY.md 건강 문단. 검증: `-progressSelfTest` **61건**(건강 H 시리즈 12 신규)·20·27·10 ALL PASS(26.5+18.5 심), Debug/Store/워치(26.2·11.5) 빌드, 시각 QA 20여 장(워치 컴플리케이션 실데이터·미니 화면·타일·다짐 행·편집기·팝업·ja 도움말), 실버전 업그레이드 팝업 경로·벌크 1만 건+건강 결합 확인. 스토어 자료: whats-new-1.5·promotional-text-1.5·설명 3언어 갱신, **스크린샷 전면 신규 — 아이폰 10·아이패드 10·워치 1 × ko/en/ja 원본 57장+합성 63장 재생성 완료**(09-records→09-health 교체, Marketing/README 절차·주의 갱신). **CloudKit 프로덕션 스키마 배포 완료(2026-08-22, 사용자 콘솔 실행)** — Quest 신규 필드 3종(healthMetricRaw·sleepWindow*)은 개발 스키마에 없어서(디버그 내보내기 이력 없음이 원인) 시뮬 519869F7에 사용자 iCloud 로그인 후 디버그 빌드를 `-premium YES -cloudSync YES -seedHealthQuest YES`로 실행 → Sandbox 환경 전체 내보내기 성공(98레코드, error nil)으로 필드 생성 → 콘솔 배포(이 시뮬은 iCloud 로그인 유지 — 향후 스키마 작업 재사용 가능). **실측 발견: 1.3의 DiaryPageItem 스타일 필드가 프로덕션 미배포 상태였음**(마지막 배포=1.0 출시 시점 — 1.3 출시[8/6] 이후 도형·텍스트 상자 신규 스타일 필드 동기화가 조용히 실패했을 가능성, 이번 배포로 해소·밀린 내보내기는 자동 재시도. 소킹 중 아이패드↔맥 요소 동기화 교차 확인 권장). **남은 출시 절차(사용자)**: ①TestFlight 실기기 소킹(진행 중 — 실제 건강 데이터·워치 컴플리케이션·기기 간 건강 다짐 정의 동기화 중점) ②ASC 심사 제출(스크린샷 framed 세트 교체·whats-new/프로모션/설명 반영). **1.6 후보 보류 기록: `Docs/plan-1.6.md`**(타임테이블 건강 데이터 표시 — 2026-08-22 사용자 "일단 보류", 구현 금지·재논의 시 착수). 운동 종목 목록(20종)은 §3.4 — 사용자 실사용 종목 빠졌으면 추가 요청. **버전 1.4 (빌드 3) App Store 출시 완료(2026-08-13, 사용자 확인)** — 2026-08-13 심사 제출 후 같은 날 출시 확인. TestFlight 실기기 테스트(문제 없음 확인) 후 제출했음. iOS 18 실기기는 미확보로 미테스트(시뮬 검증 106건 ALL PASS로 갈음 — 해당 사용자 유입 시 피드백 대응 예정). 1.4 내용(사용자 A안 승인, 2026-08-11 세션에서 전부 구현·검증): ①**iOS 18.0 하한 인하**(위 플랫폼 행 — 글라스 버블 머티리얼 폴백·사용자 심볼 안전 렌더 `Image(safeSymbol:)`[iOS 55곳+워치 7곳, 18에 없는 카탈로그 심볼 3개 물음표 폴백]·`-symbolAuditDump`. ⚠️하한 18 Release(-O wholemodule)에서 swift-frontend가 제네릭 중첩 Coordinator의 deinit 최적화 중 크래시하는 컴파일러 버그 실측 — DiaryZoomCoordinator를 비제네릭 톱레벨+AnyView 소거로 우회, 되돌리지 말 것) ②**펜슬 저장 디바운스**(§6.7 — 획마다 전체 직렬화·블랍 재기록하던 버벅임 해소) ③**통계 '오늘 기준' 롤링**(§6.6·§10 ⑤ — 지난 7일/30일) ④**CSV 구간 내보내기**(§6.8, 코어 CSVExport 추출) ⑤**일기 내보내기 후 삭제**(§6.7, 렌더 코어 DiaryExportRenderer 추출) ⑥**데이터 정리·초기화**(§6.8 — 전체/처음~날짜까지+백업 3종[CSV·일기 PDF·정리 리포트 ArchiveReport]+**'일기만' 대상 축**[1.4(3), 2026-08-12 — 기록·구조·양식 보존, 백업은 일기 PDF만, `DataReset.deleteDiaries`·`-dataResetScope diary`·자가 검증 27건으로 확장]) ⑧**라디얼 모드 FAB 가림 수정**(1.4(3) — 목록 마지막 행이 FAB에 가려지던 실측 문제: 각 탭 루트에 하단 safeAreaInset 78pt, ⚠️NavigationStack '안쪽'에 붙일 것[바깥은 TabView가 safe area 재설정해 무시됨, 실측]) ⑦도움말 재구성(1.4(2) 검수 후속, 2026-08-12 — 뭉친 주제 3곳 분리: 기록·통계 그룹에 '지난 7일·30일로 보기'·'데이터 정리·초기화' 독립 주제 신설[CSV 주제는 백업·복원만, 비교 주제는 1.3 원문 복귀], 일기 그룹 '펜슬 노트 페이지'에서 '사진·도형·텍스트 꾸미기' 분리. 이미지는 그룹당 대표 1주제 원칙 유지 — 18장 전수 실사로 참조·언어 매칭 무결 확인)·l10n(전 카탈로그 missing/stale 0)·**워치 실행 햅틱 .click→.success 상향**(1.4(2) — 실사용 '너무 약함' 보고)·MARKETING_VERSION 1.4/**빌드 3**(TestFlight 재업로드용 — 같은 버전은 빌드 번호만 올려 업로드하면 1.4(2)로 기존 테스터에 배포됨)·`Marketing/AppStore/whats-new-1.4.txt`. 검증: 자가 검증 `-progressSelfTest` 49·`-csvImportTest` 20·`-intentSmokeTest` 10·`-dataResetTest` 27 ALL PASS(26 심+18.5 심), Debug/Store/워치 빌드 성공, 18.5·26 시각 QA(버블 폴백·일기 줌·롤링 통계·초기화 화면·리포트 PDF 3페이지 실물). **스토어 제출 시**: 하한 인하는 ASC에서 바꿀 것 없음(바이너리에서 자동 파생), 스크린샷 재촬영 불필요(통계 토글·설정 행 추가는 사소 — 교체 여부는 사용자 판단), 심사 특이사항 없음. 스토어 텍스트는 1.4(3)에서 한국어 문장 흐름 자연화(사용자 피드백 — 설명 ko 자연화+3언어에 1.4 기능 2줄 추가, whats-new 전면 재작성, 프로모션 1.4 전용 `promotional-text-1.4.txt`). **📌 다음 버전(1.5) 확정 계획 — 상세는 `Docs/plan-1.5.md`(2026-08-20 사용자 결정 완료)**: ①워치 행동 컴플리케이션(행동 편집에서 옵트인+기간 선택[하루/이번 주/이번 달/지난 7일/지난 30일], 탭=미니 행동 화면 착지·뒤로가기로 메인 복귀) ②건강 데이터 통합(모음 탭 타일 **무료**+건강 다짐 **프리미엄**, HealthCache 구조[값은 CloudKit 저장 금지 — 5.1.3], 수면은 사용자 지정 수면 구간→깨어난 날 귀속, 모음 탭 3섹션[즐겨찾기·나머지·건강] 순서 설정) ③도움말(HelpView) 전체 문구 한국어 기준 자연화(사용자 지시 2026-08-12 — ~해요체 유지·번역투 제거·en/ja 재검토·l10n 루틴) ④업데이트 신기능 소개 팝업(버전당 1회, 全기기 업데이트 안내 — 계획서 §11). 운동 세부 종목 다짐 포함(§3.4), 스크린샷 전면 신규(아이폰·아이패드 각 10장 — §6), 모든 사전 결정 완료(결정 이력 8건·위험 검토 R1~R16). 하위 OS 인하는 검토 후 기각(근거는 계획서 §4 — 재제안 금지). ⚠️**구현은 사용자의 "진행/시작" 지시가 있을 때만 착수** — 그 전까지 코드 무변경, 추가 요구는 계획서에 계속 반영. **버전 1.3 App Store 출시 2026-08-06** — 3일 실사용+심층 검증(2026-08-05: 자가 검증 3종 ALL PASS·벌크 1만 건·일기 내보내기 일치·위젯 스모크·만료 열람·도움말 — 회귀 0) 통과 후 제출. 심층 검증에서 기록한 사소 잔여 엣지 9건은 후속 후보로만 보류(전부 극단 한정·자가 복구형, 필기 blob 동시 편집 한계는 §6.7). 1.3 내용: 아래 소기능 이력의 일기 개선 묶음(양식 펜 색 고정 + 도형·텍스트 상자 편집 개편 + 검토 후속 수정), `Marketing/AppStore/whats-new-1.3.txt`. 잔여 엣지 9건 상세: 최소 크기 커밋 스냅백·일기 상세 이탈 시 빈 텍스트 상자 잔존·페이지 가장자리 손잡이 클리핑·인라인 편집 중 페이지 스와이프 상태 잔존·회전 상자 커서 어색함 등(1.0부터의 구조 한계인 필기 blob 동시 편집 덮어쓰기·열린 캔버스 원격 미갱신은 §6.7, 같은 페이지 동시 필기 회피 권장). 1.2 내용: 주기 몫 완료 상태 표기(§4.2)+도움말 '진행률 읽는 법' 갱신(상세는 아래 소기능 이력). **버전 1.1 출시 2026-07-30.** 1.1 내용: ①**맥 일기 개방**(§6.7 — 사이드바 노출 + '그리기' 토글로 마우스 필기, 텍스트 우선 도구줄. 아이패드 경로 불변. 맥 실기 확인 완료) ②**CSV 가져오기**(§6.8 — 프리미엄, 추가 전용·미리 보기·멱등. 검증 `-csvImportTest` 20건 ALL PASS) ③**일기 기본 표시 설정**(§6.7 — 캘린더·목표·행동 전 날짜 기본값 + 날짜별 재정의, 스키마 무변경 마커 방식) ④**'짧은 기록 무시' iCloud KVS 동기화**(§6.8 — 전 기기 공통) ⑤스크린샷 전면 개편(아이폰 10·아이패드 10·워치 합성 1 × ko/en/ja — ASC 업로드 완료) ⑥스토어 메타데이터 변경(앱 이름·부제목·키워드·프로모션 — 검색·색인 강화, `Marketing/AppStore/app-name-subtitle.txt`·`keywords.txt`·`promotional-text.txt`. 스토어 표기만 변경, 설치 이름 불변) ⑦MARKETING_VERSION 1.1. **버전 1.0 출시 2026-07-28.** 이후 작업은 사용자 실사용 중 발견한 버그 수정·사용자가 원하는 소기능 추가만 한다(선제적 기능 제안·대규모 리팩터링 금지). 출시 후 소기능 이력: **일기 도형·텍스트 상자 UX 2차 개편**(2026-08-03, 사용자 요청 — §6.7. 종류별 모양 손잡이[사각형 꼭짓점 4개·원 상하좌우 4개·선/화살표 양 끝점]와 정사각형·정원 스냅 피드백, 텍스트 상자 제자리 입력[탭→바로 키보드]·테두리 색 독립, '글 수정'→'스타일' 시트로 재편, borderColorHex 필드 추가. 1.3으로 버전 상향)·**일기 도형·텍스트 상자 편집 강화**(2026-08-03, 사용자 요청 — §6.7. "정해진 모양·크기 그대로만 나온다"는 보고: 크기 손잡이(가로세로 자유·맥 마우스 대응)·회전 제스처·'도형 수정' 시트[색·선 굵기·채우기·모서리 둥글기·회전]·텍스트 시트 확장[글자 크기·굵게·정렬·테두리], DiaryPageItem에 기본값 필드 8개 추가)·**양식 페이지 펜 색 고정**(2026-08-03, 사용자 요청 — §6.7. 다크/라이트 잉크 반전으로 맥·아이패드에서 필기가 안 보이던 실사용 보고 해소, 내보내기 라이트 트레잇 고정 포함)·**주기 몫 완료 상태 표기**(2026-08-01, 사용자 요청 — §4.2. 도움말 '진행률 읽는 법' 문구도 갱신. 같은 날 MARKETING_VERSION 1.2(빌드 1)로 상향 + `Marketing/AppStore/whats-new-1.2.txt` 작성·TestFlight 업로드, **심사 통과 → App Store 출시 완료(2026-08-02, 사용자 확인)**). 업데이트 제출: 버전 올려 빌드 업로드 → 심사 제출(IAP는 첫 심사에서 승인됐으므로 첨부 불필요). 출시 전 이력 — RC는 커밋 `f05e611`(버전 1.0 (1)), 이후 실사용 보고 기반 수정 라운드(전부 커밋·푸시·실기기 확인 완료): l10n 누락 일괄·ja 앱 이름 'Haru Danim' 통일·워치 컴플리케이션 추천 캡 제거(§11)·⑥ 위젯 종료 버튼(§10)·진행률 환산 캡+수행일 집계+수행일 아님 표기(§4.2)·파일명/CSV 헤더 현지화(§7)·워치 컴플리케이션 목표 필터(§6.4·§11, 사용자 승인 소기능)·기록 시각 입력 상시 휠 다이얼+분 절사(§6.5)·일기 달력 기분 사진 백레이어(§6.7)·다짐 없는 목표 '다짐 없음' 표기(§4.3)·워치 측정 중 행 탭 종료(§11)·도움말 예시 스크린샷 18장(§6.8)·설정 하루 시작 시간 접힘 행(§6.5)·**모음 탭 즐겨찾기 모아 보기**(§6.1 — 행동 많아질 때 혼잡도 대응, 사용자 승인 소기능. v1 별도 카드 가로 줄[대안 A~F 중 A]이 "따로 노는 느낌" 실사용 피드백으로 v2 그리드 섹션 통합+나머지 접기로 교체, 같은 날 후속 피드백으로 편집 모드 섹션 인지·섹션 간 드래그 지정/해제·"지정 순서대로 뒤에" 규칙 추가, 2026-07-22)·**en 복수형 정리**(2026-07-22 — 수량 1일 때 "1 times/quests" 류 오류를 카탈로그 plural variation으로 14건 전환[iOS 10·위젯 3·워치 1], 모음 탭 섹션 제목 en "Favorite"→"Favorites"로 워치와 통일. ko/ja 무영향). **App Store 등록 자료는 `Marketing/`에 완비**(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것. 2026-07-22 즐겨찾기 섹션(v2) 반영해 아이폰·아이패드 01-main과 help-main ko/en/ja 재촬영·합성판 재생성. **2026-07-29 스크린샷 세트 확장** — 아이폰 8장(+위젯·버블 메뉴·iCloud 동기화 합성·다크 모드)/아이패드 7장(+주간 타임테이블·위젯·동기화 합성) × ko/en/ja, 촬영·합성 절차는 Marketing/README '추가 스크린샷' 절. **1.1 제출 때 ASC 업로드 완료**). **사업자 등록·통신판매업 신고 완료(2026-07-22)** — 서류 절차 끝, ASC 등록·유료 계약 진행 가능(순서는 Marketing/README 체크리스트). 사용자는 계속 실사용 테스트 중이며 버그 보고 시: 최소 변경 + 시뮬레이터 재검증 + l10n 루틴(§2.3) + Debug/Store(워치 파일이면 워치까지) 빌드 + 커밋·푸시 | | 출시·운영 메모 (2026-07-28) | **ASC 등록·심사·출시 전부 완료** — 제출 1건에 앱 1.0+구독 그룹+상품 3종 동반(신형 ASC는 각 항목의 "심사에 추가"로 하나의 제출 초안에 담는 방식), '앱 개인정보 보호'="데이터 수집 안 함", 유료 앱 계약·대한민국 세금 양식 활성, 소규모 사업자 프로그램 가입. 개발자 본인 프리미엄은 **평생 이용권 특가 코드(무료 Offer Code) 교환**으로 스토어 버전에 적용됨(IAP용 프로모션 코드는 2026-03 폐지 — 이후 무료 지급은 특가 코드만 가능, 생성 후 교환 가능까지 최대 1시간). 상시 참고: `ITSAppUsesNonExemptEncryption=NO`, PrivacyInfo.xcprivacy 4개 번들(수집·트래킹 없음), 개인정보처리방침=`github.com/notalentprogrammer/HaruDanim` PRIVACY.md·이용약관=Apple 표준 EULA(결제 화면·앱 설명 모두 링크). **iCloud 프로덕션 동기화 확인 완료(2026-07-28)** — 아이폰·아이패드·맥 3기기 반나절 실사용에서 기존 데이터 포함 정상 왕복(개발→프로덕션 CloudKit 환경 전환에 따른 재업로드 우려 해소. 향후 동기화 문제 보고 시 이 전환 이력 참고). **정산 계좌 변경 완료** — 1.0 출시 시점에 이미 처리(2026-08-06 사용자 확인). 남은 운영 예정 작업 없음. 절차 상세 기록은 Marketing/README.md 체크리스트 | --- @@ -224,9 +224,9 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f - **'오늘 기준' 롤링 보기(1.4)**: 주간/월간 세그먼트 아래 '지난 7일/30일 단위로 보기' 토글(기기 로컬 `stats.rollingWindow`) — 달력 주기 대신 앵커 하루로 끝나는 최근 7일/30일 창(`DayMath.rollingRange`+`statsRange` 헬퍼, 화살표는 7/30일 점프, 비교 카드는 '이전 7일/30일과 비교'). **⚠️ StatSpan enum은 다짐 진행률과 공유되므로 케이스 추가 금지** — 롤링은 통계 표면(탭·⑤ 위젯·통계 내보내기)에서만 rolling 플래그로 처리. 롤링 월간은 "N주차"가 성립하지 않아 주차별 카드·'주 평균' 열 제외(확장된 주간 보기 문법). 내보내기 슬러그 last7/last30. 검증 `-statRolling` - 평균 분모는 "이미 시작된" 날/주만 (미래로 희석 방지). 필터·내보내기는 기록 탭과 동일 패턴 -### 6.7 일기 탭 (아이패드·맥, 프리미엄 — `DiaryView`, `DiaryNotePage`, `DiaryCalendarEvents`, `DiaryExportSheet`) +### 6.7 일기 탭 (아이폰·아이패드·맥[1.5(4)부터 전 기기], 프리미엄 — `DiaryView`, `DiaryNotePage`, `DiaryCalendarEvents`, `DiaryExportSheet`) -- **기기별 입력 정책(1.1)**: 아이패드 = 펜슬 메인(필기 상시, 손가락은 이동/줌 — 기존 그대로). **맥 = 텍스트 메인** — 노트 도구줄이 [텍스트·사진·도형·줄노트·줄간격·그리기·배치] 순서의 **가로 스크롤 줄**로 바뀌고(xLarge 보정+좁은 창 대응), 필기는 **'그리기' 토글**(`drawMode`)을 켠 동안만 캔버스가 마우스·트랙패드 입력을 받는다(`drawingPolicy`가 맥에서 anyInput). 그리기↔배치는 맥에서 상호 배타. drawMode는 맥이 아니면 항상 true라 **아이패드 경로는 코드가 동일** — 회귀 원천 차단. 아이폰 노출은 검토 후 보류(2026-07-29 사용자 결정). 검증 인자 `-diaryDrawMode`(§14) +- **기기별 입력 정책(1.1, 1.5(4) 아이폰 추가)**: 아이패드 = 펜슬 메인(필기 상시, 손가락은 이동/줌 — 기존 그대로). **맥 = 텍스트 메인·아이폰 = 손가락**(1.5(4) 사용자 결정 b안) — 노트 도구줄이 [텍스트·사진·도형·줄노트·줄간격·그리기·배치] 순서의 **가로 스크롤 줄**로 바뀌고(맥 xLarge 보정·아이폰 좁은 폭 대응), 필기는 **'그리기' 토글**(`drawMode`)을 켠 동안만 캔버스가 마우스·트랙패드(맥)/손가락(아이폰) 입력을 받는다(`drawingPolicy`가 맥·아이폰에서 anyInput — `DeviceLayout.isPhone` 신설). 그리기↔배치는 맥·아이폰에서 상호 배타. drawMode는 맥·아이폰이 아니면 항상 true라 **아이패드 경로는 코드가 동일** — 회귀 원천 차단. 아이폰 탭 노출은 `AppTab.phoneCases` 개방(기본 노출 탭엔 미포함 — 더보기·버블 끝에 추가, 버블 8개 배치는 개수 기반 자동). **일기 하루 정리에 '건강 데이터' 카드(§DiarySection.health — 카드 지표 선택은 기기 로컬 diary.healthMetrics, 비면 타일 선택 상속, 맥 숨김)와 타임테이블 수면·운동 실구간 블록(HealthIntervals 캐시 — 수면 인디고·운동 주황 반투명 최하층, 필터 시트 '건강 데이터' 토글[전 날짜 공통]·맥은 안내 문구, 내보내기 동일 주입)도 1.5(4) 추가.** 검증 인자 `-diaryDrawMode`(§14) - 루트: 월 달력(작성일에 기분 이모지/연필 마커, **기분 사진이 있으면 셀 배경에 썸네일 백레이어** — ImageIO ≤220px 축소를 NSCache에 캐시(`DiaryMoodThumbs`), 검은 스크림 0.16+흰 날짜·마커 그림자로 가독성 유지, 사진 없으면 기존 그대로), 날짜 탭 → 일기 상세. 툴바에서 기간/복수 날짜 내보내기(PDF=페이지별 1장, 이미지=세로 스티치 PNG, 2배율 — 렌더 코어는 `DiaryExportRenderer`로 추출되어 '데이터 정리·초기화' 백업과 공유, 1.4). **내보내기 후 삭제(1.4)**: 내보내기 완료 화면(공유·다시 만들기 아래)의 '내보낸 날짜의 일기 삭제…' — 확인 다이얼로그, cascade로 필기·사진·할 일까지 정리, 양식 라이브러리는 보존, 열람 전용에선 숨김. ⚠️시트가 떠 있는 동안 뒤에 새 Section을 조건부 삽입하면 그려지지 않아 기존 섹션 분기 안에 배치(재발 주의). 검증 `-diaryExportDeleteTest` - **펜슬 저장 디바운스(1.4)**: 예전에는 획을 뗄 때마다 전체 드로잉을 직렬화(dataRepresentation)해 externalStorage 블랍을 통째로 재기록 — 획이 쌓일수록 비용이 선형 증가해 연속 필기가 버벅였다(양식 페이지의 큰 래스터와 겹치면 더 체감, 실사용 보고). 이제 획 종료 시엔 최신 PKDrawing(값 타입)만 보관하고 **1.2초 조용하면 백그라운드에서 직렬화해 1회 저장**(세대 가드로 비행 중 옛 결과가 새 결과를 덮는 역전 방지). 유실 방지 플러시: 비활성 전환(페이지 이탈·배치 모드·그리기 끔)·dismantleUIView·willResignActive — 유실 창 최대 1.2초(강제 종료 한정). CloudKit 트래픽도 획당 1회→묶음 1회. 검증 `-diaryStrokeTest`(프로그램 획→배달 확인→재실행 복원) - 상세는 진입만 해도 엔트리를 생성(loadEntry)하므로, 달력 onAppear가 **완전히 빈 엔트리만 정리**(기분·할일·페이지 없음 + calendarEventIDs·hiddenActionIDs 비어있음 + 페이지 0개 — 캘린더/필터 선택이나 빈 노트 페이지가 있으면 보존) @@ -355,7 +355,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f | 측정 알림 | `-settings.longSessionAlertHours `(설정 주입) `-longSessionAlertTestSeconds `(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) | | 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐. `-questShowEditorHealth`를 함께 주면 건강 다짐 우선 — 1.5) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals `(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-watchActions `(1.5 — 배치순 행동 N개를 '행동 기록' 컴플리케이션에 올림. 표시 기간은 하루→이번 주→이번 달→지난 7일→지난 30일 순환, 스냅숏 즉시 푸시) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 49건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom·주기 몫 완료+목표 하루 평균+과거 기준 소급 차단] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) `-seedPeriodFulfilled YES`(주기 몫을 이미 채운 주간·월간 다짐 목표 1개 시드 — '이번 주/달 달성' 표기 검증용, 기본 시드와 분리·이름 비현지화. 기록은 주/달 첫날 09:00이라 실행일이 첫날이면 오늘 기록이 되지만 완료 표기는 동일) | | 기록/통계 | `-historyMode timetable` `-historyWeekly` `-historyDaysAgo `(N일 전 날짜로 시작 — 지난주 등 과거 주 표시, 마케팅 촬영용) `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statRolling`('오늘 기준' 롤링 토글 켠 상태로 시작 — 1.4) `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor `(최신 기록 수정 시트) | -| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo `: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays `이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-showWhatsNew`(1.5 — 신기능 소개 팝업 강제 표시) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays `이면 날짜 지정[오늘-N일까지], `-dataResetScope diary`면 '일기만' 선택으로 시작) `-dataResetTest`(초기화 자가 검증 27건[범위 삭제 경계·보존·cascade·전체 초기화·일기만] → Documents/data-reset-test.txt — 인메모리·설정 고정, 실데이터 무영향) `-seedHealthCache`(1.5 — 건강 스토어를 합성 값으로 대체[HK 비의존]·과거 7일+운동 종목·수면 구간 키까지 캐시 기록, 건강 타일·다짐 렌더 검증용) `-seedHealthQuest`(1.5 — 건강 다짐 목표 1개 시드: 걸음 8000·수면 8h·달리기 30분·물 2L, 멱등) `-healthQuestDump`(1.5 — 건강 다짐들의 캐시 키·오늘 값·하루 진행률 → Documents/health-quest-dump.txt) `-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과 함께) | +| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo `: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays `이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-showWhatsNew`(1.5 — 신기능 소개 팝업 강제 표시) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays `이면 날짜 지정[오늘-N일까지], `-dataResetScope diary`면 '일기만' 선택으로 시작) `-dataResetTest`(초기화 자가 검증 27건[범위 삭제 경계·보존·cascade·전체 초기화·일기만] → Documents/data-reset-test.txt — 인메모리·설정 고정, 실데이터 무영향) `-seedHealthCache`(1.5 — 건강 스토어를 합성 값으로 대체[HK 비의존]·과거 7일+운동 종목·수면 구간 키까지 캐시 기록, 건강 타일·다짐 렌더 검증용) `-seedHealthQuest`(1.5 — 건강 다짐 목표 1개 시드: 걸음 8000·수면 8h·달리기 30분·물 2L, 멱등) `-healthQuestDump`(1.5 — 건강 다짐들의 캐시 키·오늘 값·하루 진행률 → Documents/health-quest-dump.txt) `-healthShowGuide`(1.5 — 건강 안내 시트 표시) `-healthShowMetricPicker`(1.5 — 건강 지표 선택 시트 표시) `-sleepWindowPreview`(1.5(4) — 수면 표시 구간 설정 화면 직접 표시) `-symbolAuditDump`(아이콘 카탈로그 중 현재 OS에 없는 심볼 → Documents/symbol-audit.txt — 하위 OS QA, 1.4) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-actionShowEditor`(1.5 — 첫 행동의 편집 시트를 열고 애플워치 섹션까지 자동 스크롤, `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock|marketing`(marketing = 디버그 라벨 없는 위젯 실렌더 모아 보기 — 마케팅 스크린샷 05-widgets/06-widgets 촬영용, Marketing/README 참고) `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) | | 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) | | 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryDrawMode`(맥 분기에서 '그리기' 토글 켠 채 시작) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage ` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diaryShowDefaults`(기본 표시 설정 시트) `-diaryDefaultsSeed`(첫 목표·첫 행동 기본 숨김+캘린더 기본 '모든 캘린더' 주입 — 기본값 적용 검증용) `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryLockScreen`(잠금 게이트 화면 강제 — 인증 없이 표시) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) `-diaryShapeEdit`(활성 노트 페이지 첫 도형 선택+'도형 수정' 시트 — `-diarySeed`·`-diaryOpenToday`·`-diaryPage 1`과 함께. 시드 요소들이 스타일 필드[굵기·둥글기·채움·자유 비율·회전·글자 크기·정렬·테두리 색]를 전부 렌더) `-diaryTextEdit`(첫 텍스트 상자를 제자리 입력 상태로 — 같은 조합) `-diaryStrokeTest`(3초 뒤 프로그램 획 추가로 디바운스 저장 파이프라인 검증 → Documents/diary-stroke-test.txt[strokes·delivered] — `-diaryOpenToday`와 함께, 1.4) `-diaryExportDeleteTest`(내보내기 완료 후 '삭제' 버튼과 같은 가드·경로로 삭제 실행 — `-diaryShowExport -diaryExportRun`과 함께, 재실행 후 달력 마커 소멸로 판정, 1.4) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` | | 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo ` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) `-watchOpenActionDetail`(1.5 — 스냅숏 수신 후 첫 옵트인 행동의 미니 행동 화면을 딥링크와 같은 경로로 push, 착지·뒤로가기 검증) | diff --git a/myApp/HaruDanim/Docs/plan-1.5.md b/myApp/HaruDanim/Docs/plan-1.5.md index 7275727..3b8fa13 100644 --- a/myApp/HaruDanim/Docs/plan-1.5.md +++ b/myApp/HaruDanim/Docs/plan-1.5.md @@ -157,3 +157,16 @@ - 업데이트 후 첫 실행 시 스플래시 뒤 **버전당 1회** 표시되는 신기능 소개 시트(통상의 "이번 업데이트" 팝업 관습): 1.5 신기능 카드(행동 컴플리케이션·건강 타일·건강 다짐·도움말 개편) + **"여러 기기를 쓰신다면 모든 기기에서 업데이트해 주세요" 안내 1줄**(혼합 버전 완화 — §10 R16 연계). - 표시 규칙: standard defaults에 마지막 표시 버전 기록(예: `whatsnew.lastShownVersion`) — 저장된 버전과 현재 버전이 다르고 소개 콘텐츠가 있는 버전일 때만 표시, 닫는 즉시 기록(재표시 없음). **신규 설치는 미표시**(첫 실행 안내는 온보딩 담당 — 최초 실행 시 현재 버전을 시드). 온보딩과 동시 표시 금지(온보딩이 우선). - 구조는 OnboardingView 스타일 재사용, 향후 버전에서 콘텐츠만 교체 가능한 구조로. l10n 3언어, 검증 인자(예: `-showWhatsNew`) 신설. + +## 12. 1.5 추가분 — 소킹 중 사용자 요구 5건 (2026-08-22 저녁, 빌드 4) ✅구현 완료 + +사용자 결정: 1.6이 아니라 **1.5에서 해결**. 세부 UX는 아리나 위임(전제: 기존 기능·사용성 무변화, 버그·충돌 없음). 확정 4결정: ①아이폰 노트 필기= b안(맥 '그리기' 토글 문법, 손가락) ②타임테이블 건강 블록 기본 켬 ③건강 카드 지표 선택= 전 날짜 공통(기기 로컬) ④새 수면 다짐 기본 구간= 타일 설정 시드. + +1. **맥 일기 필터창 건강 안내** — RecordFilterSheet 건강 섹션이 맥(HealthDataStore 불가)에선 토글 대신 안내 문구. +2. **아이폰 일기 탭** — AppTab.phoneCases 개방(+radialOrder 필터 제거, 버블 8개 자동 배치). 노트 도구줄= 맥과 같은 가로 스크롤+'그리기' 토글(DeviceLayout.isPhone 신설, drawingPolicy anyInput, 배치↔그리기 상호 배타). 일기 잠금 설정 전 기기 노출, 아이패드 경로 무변경. +3. **일기 '건강 데이터' 카드** — DiarySection.health(케이스 마지막 — 기존 사용자 목록 끝 추가), DiaryHealthCard(HealthCache 하루 값, 수면은 SleepWindowPrefs 유효 구간 키), 지표 선택 diary.healthMetrics(비면 타일 상속), 맥 숨김, 인쇄 요약 DiaryPrintHealthRow 동승. +4. **일기 타임테이블 수면·운동 실구간** — **HealthIntervals 캐시 신설**(App Group health.intervals.v1, 400일, 실구간을 논리적 하루로 분할·병합 저장 — 다짐의 '깨어난 날 통귀속'과 다른 사실 그대로 표시, CloudKit 금지 동일). refreshRecent(3일)+백필이 유지, 화면·내보내기는 injectingHealthBlocks 주입(캘린더 전례, 최하층 반투명+실선 스타일), 색= 수면 인디고·운동 주황(지표 고정색), 필터 시트 '건강 데이터' 토글(diary.timetableHealthHidden — 전 날짜 공통, 기본 켬), 일기 필터 버튼 상시 노출화. +5. **수면 표시 구간 설정** — SleepWindowPrefs(전체 스펙+요일별, 시작=끝이면 24시간 창), 설정 → 건강 데이터 → '수면 표시 구간'. 평문 "sleep" 캐시 키= 그날 유효 구간 값(타일·카드 공유), 백필 서명에 요일 매핑 포함, QuestEditor 새 수면 다짐 기본값 시드. 발단: 사용자 교대·야간 근무 실측(주말 낮 수면이 기본 창 밖 → 타일 0). +6. 부수 실측 수정: 일기 목표 카드 건강 다짐 값 단위("8,432회/8,000보" → 지표 단위). + +검증: 자가 검증 61/20/27/10 ALL PASS, 3종 빌드 그린, 전 카탈로그 missing/stale 0, 시각 QA 10여 장(아이폰 요약·카드·블록·노트·필터·버블8·수면 구간, 아이패드, 맥 분기[카드 숨김], 18.5). 마케팅: 아이폰 06-bubble 재촬영(버블 8개)·10-week→**10-diary** 교체·아이패드 03-diary-summary 재촬영(건강 카드·블록), whats-new-1.5/설명 3언어 갱신. 빌드 4. diff --git a/myApp/HaruDanim/Docs/plan-1.6.md b/myApp/HaruDanim/Docs/plan-1.6.md index 15d5072..7c89388 100644 --- a/myApp/HaruDanim/Docs/plan-1.6.md +++ b/myApp/HaruDanim/Docs/plan-1.6.md @@ -2,8 +2,10 @@ > **상태: 보류** — 사용자 결정(2026-08-22): "다음 업데이트 사항으로 두자. 생각이 바뀔 수도, 더 다양한 방법이 떠오를 수도 있으니 일단 보류." > ⚠️ **구현 금지** — 사용자가 재논의·진행을 지시할 때만 착수. 그 전까지 이 문서는 아이디어 기록 전용. +> +> 📌 **같은 날 저녁 대부분 1.5로 조기 흡수됨**(사용자 재결정 — plan-1.5.md §12): 후보 1의 **일기 표면**(하루 정리 타임테이블 수면·운동 블록)과 후보 2(수면 표시 구간)는 **1.5(4)에 구현 완료**. 1.6에 남은 잔여분: **기록 탭 타임테이블에도 건강 블록 표시**(HealthIntervals 캐시·주입 문법·색은 이미 있어 표시 계층만 — 난이도 하), 물 점 표시(미구현 — 1.5(4)는 수면·운동만). -## 후보 1 — 타임테이블에 건강 데이터 표시 +## 후보 1 — 타임테이블에 건강 데이터 표시 (일기 표면은 1.5(4)에 구현 — 잔여: 기록 탭) - **발단**: 사용자 제안 2026-08-22 (1.5 TestFlight 소킹 시작 직전). "일기나 기록의 타임테이블에서도 건강 데이터가 보여졌으면. 꼭 필요한 건 아니고 있으면 좋을 것 같은 정도." - **범위 시안(v1)**: 수면·운동(·마음챙김 — 구간 샘플 보유) = **구간 블록**, 물 = **시각+양 점**(횟수 기록과 같은 문법). 걸음·kcal·거리·스탠드는 연속/파생 지표라 타임테이블에 부적합 → **제외로 못 박기**. diff --git a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj index 8c198d3..a51e92d 100644 --- a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj +++ b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj @@ -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 = 3; + CURRENT_PROJECT_VERSION = 4; 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 = 3; + CURRENT_PROJECT_VERSION = 4; 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 = 3; + CURRENT_PROJECT_VERSION = 4; 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 = 3; + CURRENT_PROJECT_VERSION = 4; 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 = 3; + CURRENT_PROJECT_VERSION = 4; 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 = 3; + CURRENT_PROJECT_VERSION = 4; 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 = 3; + CURRENT_PROJECT_VERSION = 4; 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 = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 5GNS4Z2SYT; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Widgets/Info.plist; diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index e60373e..9bea4d0 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -37,6 +37,9 @@ struct ContentView: View { NavigationStack { DataImportView() } } else if UserDefaults.standard.bool(forKey: "dataResetPreview") { NavigationStack { DataResetView() } + } else if UserDefaults.standard.bool(forKey: "sleepWindowPreview") { + // 검증용: -sleepWindowPreview YES → 수면 표시 구간 설정 화면 직접 표시 (1.5 추가분) + NavigationStack { SleepWindowSettingsView() } } else { RootNavigationView() } @@ -266,9 +269,11 @@ enum AppTab: String, CaseIterable, Identifiable { static let moreTabValue = "more" - /// iPhone에 노출되는 탭 (일기는 iPad 전용 — 탭바/더보기/라디얼 메뉴에서 제외) + /// iPhone에 노출되는 탭. 일기는 1.0~1.5(3)에서 iPad 전용이었으나 1.5 추가분에서 + /// 아이폰에도 개방 — 노트 필기는 맥과 같은 '그리기' 토글로 손가락 입력을 받는다 (§6.7). + /// 기본 노출 탭에는 들어가지 않으므로 기존 사용자에게는 더보기·버블 끝에 조용히 추가된다 static var phoneCases: [AppTab] { - allCases.filter { $0 != .diary } + allCases } /// 사이드바(iPad·Mac)에 노출되는 탭. @@ -326,7 +331,7 @@ enum AppTab: String, CaseIterable, Identifiable { for tab in AppTab.phoneCases where !seen.contains(tab) { result.append(tab) } - return result.filter { $0 != .diary } + return result } @ViewBuilder diff --git a/myApp/HaruDanim/IOS/Core/HealthStore.swift b/myApp/HaruDanim/IOS/Core/HealthStore.swift index 85b8b63..7228bdf 100644 --- a/myApp/HaruDanim/IOS/Core/HealthStore.swift +++ b/myApp/HaruDanim/IOS/Core/HealthStore.swift @@ -117,6 +117,16 @@ final class HealthDataStore { values = values.mapValues { ($0 * 0.9).rounded() } } HealthCache.setValues(values, dayKey: key) + // 실구간도 시드 — 일기 타임테이블 수면·운동 블록 렌더 검증·촬영용. + // 수면은 자정을 걸친 모양(새벽 몫 + 밤 몫), 달리기는 아침 구간 + let dayStart = math.dayRange(forKey: key).lowerBound + func at(_ minutes: Double) -> Double { + dayStart.addingTimeInterval(minutes * 60).timeIntervalSince1970 + } + HealthIntervals.merge([ + HealthIntervals.sleepKey: [key: [[at(0), at(7 * 60 + 28)], [at(23 * 60 + 20), at(24 * 60)]]], + HealthIntervals.workoutKey(.running): [key: [[at(7 * 60 + 42), at(8 * 60 + 13)]]], + ]) } return } @@ -144,6 +154,7 @@ final class HealthDataStore { sleepWindows: keys.sleepWindows, workoutKinds: keys.workoutKinds ) HealthCache.setValues(values, dayKey: key) + HealthIntervals.merge(await dayIntervals(forDayKey: key, math: math)) if offset == 0 { var tiles: [HealthMetric: Double] = [:] for metric in HealthMetric.allCases { tiles[metric] = values[metric.rawValue] ?? 0 } @@ -152,9 +163,11 @@ final class HealthDataStore { } } - /// 건강 다짐이 실제로 쓰는 캐시 키 집합 — 수면 구간 변형(기본 구간 포함)·운동 종목 + /// 건강 다짐이 실제로 쓰는 캐시 키 집합 — 수면 구간 변형(기본 구간 포함)·운동 종목. + /// 타일·일기 카드용 수면 표시 구간 설정(SleepWindowPrefs — 전체+요일별)도 함께 계산 대상이다 private func questHealthKeys() -> (sleepWindows: Set, workoutKinds: Set) { var windows: Set = [Self.defaultSleepSpec] + windows.formUnion(SleepWindowPrefs.allSpecs) var kinds: Set = [] let quests = (try? DataStore.shared.mainContext.fetch(FetchDescriptor())) ?? [] for quest in quests { @@ -194,10 +207,13 @@ final class HealthDataStore { for spec in sleepWindows { guard let parsed = Self.windowSpec(spec) else { continue } let window = Self.sleepWindow(forDayRange: range, endMinutes: parsed.endMinutes, length: parsed.length) - let value = await sleepDuration(window: window) - result["sleep@\(spec)"] = value - if spec == Self.defaultSleepSpec { result[HealthMetric.sleep.rawValue] = value } + result["sleep@\(spec)"] = await sleepDuration(window: window) } + // 평문 "sleep" 키(타일·일기 카드) = 그날의 유효 표시 구간 값 (1.5 추가분 — + // 설정에서 구간을 바꾸면 교대·야간 근무의 낮잠도 그날 값으로 잡힌다) + let effectiveSpec = SleepWindowPrefs.spec(forDayKey: key) + result[HealthMetric.sleep.rawValue] = + result["sleep@\(effectiveSpec)"] ?? result["sleep@\(Self.defaultSleepSpec)"] ?? 0 if !workoutKinds.isEmpty { let byKind = await workoutDurations(in: range) for kind in workoutKinds { @@ -269,6 +285,50 @@ final class HealthDataStore { return Self.mergedDuration(intervals) } + /// 구간 합집합 (겹침 병합, 시작 오름차순) — mergedDuration의 구간 보존판. + /// 아이폰+워치가 같은 잠을 겹쳐 기록해도 타임테이블에 블록이 이중으로 그려지지 않게 한다 + static func mergedIntervals(_ intervals: [(Date, Date)]) -> [(Date, Date)] { + let sorted = intervals.sorted { $0.0 < $1.0 } + var result: [(Date, Date)] = [] + for (start, end) in sorted { + if let last = result.last, start <= last.1 { + result[result.count - 1].1 = max(last.1, end) + } else { + result.append((start, end)) + } + } + return result + } + + /// 그 논리적 하루와 겹치는 수면·운동 실구간 (HealthIntervals 갱신값 — refreshRecent 전용). + /// 이전에 있던 종류가 이번 조회에 없으면 빈 배열로 명시해 지운다 (건강 앱에서의 삭제 반영) + private func dayIntervals(forDayKey dayKey: Date, math: DayMath) async -> [String: [Date: [[Double]]]] { + let range = math.dayRange(forKey: dayKey) + var perKey: [String: [[Double]]] = [:] + for existing in HealthIntervals.workoutKeys(dayKey: dayKey) { perKey[existing] = [] } + + let samples = await categorySamples(.sleepAnalysis, in: range) + let asleepValues = Set(HKCategoryValueSleepAnalysis.allAsleepValues.map(\.rawValue)) + let sleepIntervals = Self.mergedIntervals(samples.compactMap { sample in + guard asleepValues.contains(sample.value) else { return nil } + let start = max(sample.startDate, range.lowerBound) + let end = min(sample.endDate, range.upperBound) + return end > start ? (start, end) : nil + }) + perKey[HealthIntervals.sleepKey] = sleepIntervals.map { + [$0.0.timeIntervalSince1970, $0.1.timeIntervalSince1970] + } + + for workout in await workoutSamples(in: range) { + let start = max(workout.startDate, range.lowerBound) + let end = min(workout.endDate, range.upperBound) + guard start < end else { continue } + perKey[HealthIntervals.workoutKey(Self.workoutKind(for: workout.workoutActivityType)), default: []] + .append([start.timeIntervalSince1970, end.timeIntervalSince1970]) + } + return perKey.mapValues { [dayKey: $0] } + } + /// 구간 합집합 길이 (겹침 병합) static func mergedDuration(_ intervals: [(Date, Date)]) -> TimeInterval { let sorted = intervals.sorted { $0.0 < $1.0 } @@ -373,7 +433,7 @@ final class HealthDataStore { let cal = math.calendar let todayKey = math.dayKey(for: now) let keys = questHealthKeys() - let signature = "\(HealthCache.dayToken(todayKey))|\(keys.sleepWindows.sorted().joined(separator: ","))|\(keys.workoutKinds.map(\.rawValue).sorted().joined(separator: ","))" + let signature = "\(HealthCache.dayToken(todayKey))|\(keys.sleepWindows.sorted().joined(separator: ","))|\(keys.workoutKinds.map(\.rawValue).sorted().joined(separator: ","))|\(SleepWindowPrefs.mappingSignature)" guard AppGroup.defaults.string(forKey: "health.backfillSignature") != signature else { return } guard let startKey = cal.date(byAdding: .day, value: -(HealthCache.retentionDays - 1), to: todayKey) @@ -446,30 +506,60 @@ final class HealthDataStore { key = next } merged["sleep@\(spec)"] = days - if spec == Self.defaultSleepSpec { merged[HealthMetric.sleep.rawValue] = days } + } + // 평문 "sleep" 키 = 각 날짜의 유효 표시 구간 값 (SleepWindowPrefs — allValues와 동일 규칙) + var plainSleep: [Date: Double] = [:] + var plainKey = startKey + while plainKey <= todayKey { + let spec = SleepWindowPrefs.spec(forDayKey: plainKey) + if let value = merged["sleep@\(spec)"]?[plainKey] { plainSleep[plainKey] = value } + guard let next = cal.date(byAdding: .day, value: 1, to: plainKey) else { break } + plainKey = next + } + merged[HealthMetric.sleep.rawValue] = plainSleep + + // 수면 실구간 — 일기 타임테이블 표시용 (논리적 하루로 분할 후 병합, HealthIntervals. + // 다짐의 '깨어난 날 통귀속'과 달리 사실 그대로 이틀에 나뉘는 의도된 규칙) + var sleepIntervalDays: [Date: [(Date, Date)]] = [:] + for (start, end) in asleepIntervals { + let clippedStart = max(start, lower) + let clippedEnd = min(end, upper) + guard clippedStart < clippedEnd else { continue } + for segment in math.splitByDay(start: clippedStart, end: clippedEnd) + where segment.range.lowerBound < segment.range.upperBound { + sleepIntervalDays[segment.dayKey, default: []] + .append((segment.range.lowerBound, segment.range.upperBound)) + } + } + var intervalMerged: [String: [Date: [[Double]]]] = [:] + intervalMerged[HealthIntervals.sleepKey] = sleepIntervalDays.mapValues { list in + Self.mergedIntervals(list).map { [$0.0.timeIntervalSince1970, $0.1.timeIntervalSince1970] } } - // ⑤ 운동 종목 — 범위 1회 조회 후 종목×하루 버킷팅 - if !keys.workoutKinds.isEmpty { - let workouts = await workoutSamples(in: lower.. { DiarySectionConfig.hidden(from: hiddenRaw) } var body: some View { @@ -1110,6 +1118,8 @@ private struct DiarySummaryPage: View { @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" @AppStorage(DiarySectionConfig.orderKey) private var sectionOrderRaw = "" @AppStorage(DiarySectionConfig.hiddenKey) private var hiddenSectionsRaw = "" + /// 타임테이블 건강 블록 숨김(수면/운동) — 필터 시트 토글이 이 값을 쓰므로 변경 즉시 재렌더 (1.5) + @AppStorage(DiaryHealthTimetable.hiddenKey) private var healthHiddenRaw = "" /// 이 날짜에 선택된 캘린더 일정 → 타임테이블 블록 (선택 변경 시 .task로 재조회) @State private var calendarBlocks: [ExportTimetableData.Block] = [] @@ -1131,10 +1141,13 @@ private struct DiarySummaryPage: View { private var math: DayMath { DayMath() } - /// 표시할 섹션 (구성 설정의 순서, 숨김 제외) + /// 표시할 섹션 (구성 설정의 순서, 숨김 제외). + /// 건강 카드는 건강 데이터가 없는 기기(맥)에선 제외 — 빈 카드 자리를 남기지 않는다 private var visibleSections: [DiarySection] { let hidden = DiarySectionConfig.hidden(from: hiddenSectionsRaw) - return DiarySectionConfig.order(from: sectionOrderRaw).filter { !hidden.contains($0) } + return DiarySectionConfig.order(from: sectionOrderRaw).filter { + !hidden.contains($0) && ($0 != .health || HealthDataStore.isAvailable) + } } private var orderedActions: [Action] { @@ -1288,6 +1301,7 @@ private struct DiarySummaryPage: View { tags: allTags, actions: dayRecordedActions, excludedActionIDs: $filterExcluded, + showsHealthSection: true, onResetToDefault: { // 날짜별 재정의 삭제 → 이후 기기 기본값(기본 표시 설정)을 따른다. // filterExcluded는 건드리지 않아 onChange가 다시 재정의를 쓰지 않는다 (재열람 시 재초기화) @@ -1371,6 +1385,8 @@ private struct DiarySummaryPage: View { recordSections(innerWidth: innerWidth) case .bars: barSections(innerWidth: innerWidth) + case .health: + DiaryHealthCard(dayKey: entry.dayKey) } } @@ -1395,15 +1411,23 @@ private struct DiarySummaryPage: View { /// - 캘린더 버튼: 그날 넣을 캘린더 일정을 고르는 팝업 @ViewBuilder private func timetableSections(innerWidth: CGFloat) -> some View { - let injected = timetableSnapshot.injectingCalendarEvents(calendarBlocks) + // 건강 블록(수면·운동 실구간)은 캘린더 블록과 같은 주입 문법 — healthHiddenRaw를 + // 명시적으로 넘겨 필터 시트 토글이 즉시 화면에 반영되게 한다 (1.5 추가분) + let injected = timetableSnapshot + .injectingCalendarEvents(calendarBlocks) + .injectingHealthBlocks(DiaryHealthTimetable.blocks( + dayKey: entry.dayKey, math: math, + hidden: Set(healthHiddenRaw.split(separator: ",").map(String.init)) + )) let hiddenCount = timetableExcludedIDs.count ForEach(Array(injected.sections.enumerated()), id: \.offset) { _, section in if case .timetable = section { ZStack(alignment: .topTrailing) { ExportSectionView(section: section, innerWidth: innerWidth) - // 열람 전용에서는 날짜별 선택(필터·캘린더)을 편집할 수 없다 — 저장된 표시만 유지 + // 열람 전용에서는 날짜별 선택(필터·캘린더)을 편집할 수 없다 — 저장된 표시만 유지. + // 필터 버튼은 항상 노출 (1.5 — 기록 없는 날도 건강 토글·맥 안내가 이 시트에 있다) HStack(spacing: 6) { - if !readOnly, !dayRecordedActions.isEmpty || hiddenCount > 0 { + if !readOnly { Button { filterExcluded = timetableExcludedIDs showingActionFilter = true @@ -1727,6 +1751,11 @@ private struct DiaryGoalsCard: View { } private func valueLabel(_ value: Double, quest: Quest) -> String { + // 건강 다짐은 지표 고유 단위(보·kcal·km·L 등)로 — targetValueLabel과 짝. + // ("8,432회 / 8,000보"로 단위가 어긋나던 실측 수정, 1.5(4)) + if let health = quest.healthTarget { + return health.valueLabel(value) + } switch quest.measure { case .time: return Format.durationShort(value) case .count: return String(localized: "\(Int(value))회") diff --git a/myApp/HaruDanim/IOS/Views/ExportImageView.swift b/myApp/HaruDanim/IOS/Views/ExportImageView.swift index 7cd3219..c67929c 100644 --- a/myApp/HaruDanim/IOS/Views/ExportImageView.swift +++ b/myApp/HaruDanim/IOS/Views/ExportImageView.swift @@ -49,6 +49,20 @@ struct ExportSnapshot { return copy } + /// 하루짜리 타임테이블 섹션에 건강(수면·운동) 실구간 블록을 끼워 넣는다 (1.5 추가분 — + /// 일기 화면·내보내기 공용. 캘린더 주입과 같은 문법, 렌더는 별도 스타일의 최하층) + func injectingHealthBlocks(_ blocks: [ExportTimetableData.Block]) -> ExportSnapshot { + guard !blocks.isEmpty else { return self } + var copy = self + copy.sections = sections.map { section in + guard case .timetable(let title, let subtitle, var data) = section, + data.days.count == 1 else { return section } + data.days[0].healthBlocks = blocks + return .timetable(title: title, subtitle: subtitle, data) + } + return copy + } + /// 타임테이블 섹션만 다른 스냅숏의 것으로 교체. /// 일기의 날짜별 타임테이블 필터용 — 기록·통계 섹션은 전체(자신)를 유지한다. func replacingTimetableSections(with other: ExportSnapshot) -> ExportSnapshot { @@ -140,6 +154,8 @@ struct ExportTimetableData { var markers: [Marker] /// 캘린더(EventKit) 일정 — 행동 블록 아래에 외곽선 스타일로 깔린다 (일기 전용) var eventBlocks: [Block] = [] + /// 건강(수면·운동) 실구간 — 반투명 채움 스타일로 맨 아래층에 깔린다 (일기 전용, 1.5) + var healthBlocks: [Block] = [] } /// 하루 시작 시각(시). 축 라벨은 (startHour + 오프셋) % 24 @@ -1175,6 +1191,9 @@ private struct ExportTimetableView: View { Spacer(minLength: 0) } } + ForEach(day.healthBlocks) { block in + healthBlockView(block, columnWidth: columnWidth) + } ForEach(day.eventBlocks) { block in eventBlockView(block, columnWidth: columnWidth) } @@ -1239,6 +1258,36 @@ private struct ExportTimetableView: View { .offset(x: 2, y: y) } + /// 건강(수면·운동) 블록: 세 번째 시각 문법 — 반투명 채움 + 가는 실선 테두리. + /// 행동 블록(불투명 채움)·캘린더 블록(점선 테두리)과 구분되고, 잠처럼 긴 구간이 + /// 배경을 덮어도 위층 블록이 잘 보이도록 농도를 가장 옅게 유지한다 (1.5 추가분) + private func healthBlockView(_ block: ExportTimetableData.Block, columnWidth: CGFloat) -> some View { + let y = CGFloat(block.startFrac - Double(data.hourLo)) * hourHeight + let height = max(CGFloat(block.endFrac - block.startFrac) * hourHeight, 5) + return RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(block.color.opacity(0.16)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .strokeBorder(block.color.opacity(0.55), lineWidth: 1) + ) + .overlay(alignment: .topLeading) { + if !weekly && height >= 18 { + Label(block.name, systemImage: block.symbol) + .font(.caption2.weight(.semibold)) + .foregroundStyle(block.color) + .lineLimit(1) + .padding(4) + } else if height >= 12 { + Image(safeSymbol: block.symbol) + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(block.color) + .padding(2) + } + } + .frame(width: max(columnWidth - 4, 8), height: height) + .offset(x: 2, y: y) + } + private func markerView(_ marker: ExportTimetableData.Marker, index: Int, columnWidth: CGFloat) -> some View { let size: CGFloat = weekly ? 8 : 14 let x = 4 + CGFloat(index % 5) * (size + 3) diff --git a/myApp/HaruDanim/IOS/Views/HealthSection.swift b/myApp/HaruDanim/IOS/Views/HealthSection.swift index 20d60a0..2421356 100644 --- a/myApp/HaruDanim/IOS/Views/HealthSection.swift +++ b/myApp/HaruDanim/IOS/Views/HealthSection.swift @@ -33,7 +33,7 @@ struct HealthSectionView: View { } } .sheet(isPresented: $showingMetricPicker) { - HealthMetricPickerSheet() + HealthMetricPickerSheet(metricsRaw: $metricsRaw) } .sheet(isPresented: $showingGuide) { HealthGuideSheet() @@ -161,12 +161,14 @@ struct HealthSectionView: View { } } -// MARK: - 지표 선택 시트 (체크 + 드래그 순서) +// MARK: - 지표 선택 시트 (체크 + 드래그 순서 — 모음 탭 타일·일기 건강 카드 공용) struct HealthMetricPickerSheet: View { @Environment(\.dismiss) private var dismiss - @AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults) - private var metricsRaw = "" + /// 저장 대상 raw 문자열 (모음 탭=AppGroup healthMetrics, 일기 카드=standard diary.healthMetrics) + @Binding var metricsRaw: String + /// metricsRaw가 비어 있을 때의 초기 선택 폴백 (일기 카드가 타일 선택을 물려받는 용도) + var fallbackRaw: String = "" /// 편집 목록: 선택된 지표(순서 유지) + 나머지 지표 @State private var selected: [HealthMetric] = [] @@ -209,7 +211,7 @@ struct HealthMetricPickerSheet: View { } } .onAppear { - selected = HealthMetric.selectedList(raw: metricsRaw) + selected = HealthMetric.selectedList(raw: metricsRaw.isEmpty ? fallbackRaw : metricsRaw) } } .presentationDetents([.medium, .large]) @@ -352,3 +354,290 @@ struct HealthGuideSheet: View { } } } + +// MARK: - 일기 타임테이블 건강 블록 (1.5 추가분 — 수면·운동 실구간, HealthIntervals 소비) + +/// 일기 하루 정리·내보내기의 타임테이블에 얹는 수면·운동 블록의 단일 공급 지점. +/// 색은 꼬리표 팔레트·'측정 중' 노랑과 겹치지 않는 지표 고정색 — 수면 인디고, 운동 주황. +enum DiaryHealthTimetable { + /// 숨김 저장 키 (standard defaults — 기기 로컬, 모든 날짜 공통. "sleep"/"workout" 쉼표 연결) + static let hiddenKey = "diary.timetableHealthHidden" + static let sleepColor = Color.indigo + static let workoutColor = Color.orange + + static var hidden: Set { + Set((UserDefaults.standard.string(forKey: hiddenKey) ?? "") + .split(separator: ",").map(String.init)) + } + + static func setHidden(_ set: Set) { + UserDefaults.standard.set(set.sorted().joined(separator: ","), forKey: hiddenKey) + } + + /// 그날 타임테이블에 얹을 건강 블록 — 화면(DiarySummaryPage)과 내보내기(DiaryExportRenderer)가 + /// 같은 호출로 항상 같은 결과를 얻는다. 데이터 없는 기기(맥·미동기화 아이패드)는 자연히 빈 배열. + /// hidden은 화면이 @AppStorage 값을 넘겨 토글 즉시 재렌더되게 하는 용도 (기본=저장값) + static func blocks(dayKey: Date, math: DayMath, + hidden: Set? = nil) -> [ExportTimetableData.Block] { + guard HealthDataStore.isAvailable else { return [] } + let hiddenSet = hidden ?? Self.hidden + let dayStart = math.dayRange(forKey: dayKey).lowerBound + func frac(_ date: Date) -> Double { date.timeIntervalSince(dayStart) / 3600 } + var blocks: [ExportTimetableData.Block] = [] + if !hiddenSet.contains("sleep") { + for interval in HealthIntervals.intervals(HealthIntervals.sleepKey, dayKey: dayKey) { + blocks.append(ExportTimetableData.Block( + startFrac: frac(interval.start), endFrac: frac(interval.end), + color: sleepColor, symbol: HealthMetric.sleep.symbolName, + name: HealthMetric.sleep.name + )) + } + } + if !hiddenSet.contains("workout") { + for key in HealthIntervals.workoutKeys(dayKey: dayKey).sorted() { + guard let kind = HealthWorkoutKind(rawValue: String(key.dropFirst("workout.".count))) + else { continue } + for interval in HealthIntervals.intervals(key, dayKey: dayKey) { + blocks.append(ExportTimetableData.Block( + startFrac: frac(interval.start), endFrac: frac(interval.end), + color: workoutColor, symbol: kind.symbolName, name: kind.name + )) + } + } + } + return blocks + } +} + +// MARK: - 일기 '건강 데이터' 카드 (1.5 추가분 — 하루 정리 섹션, 아이폰·아이패드 전용) + +/// 그 날짜의 건강 지표 값 카드. 값은 HealthCache(기기 로컬)라 기기마다 다를 수 있고(§안내 시트), +/// 수면은 설정의 수면 표시 구간(SleepWindowPrefs — 요일별 포함)을 따른다. +struct DiaryHealthCard: View { + let dayKey: Date + + @Environment(\.diaryReadOnly) private var readOnly + /// 카드 표시 지표 — 기기 로컬·전 날짜 공통. 비어 있으면 모음 탭 타일 선택을 물려받는다 + @AppStorage("diary.healthMetrics") private var diaryMetricsRaw = "" + @AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults) private var tileMetricsRaw = "" + @State private var store = HealthDataStore.shared + @State private var showingPicker = false + + private var metrics: [HealthMetric] { + HealthMetric.selectedList(raw: diaryMetricsRaw.isEmpty ? tileMetricsRaw : diaryMetricsRaw) + } + + private func value(_ metric: HealthMetric) -> Double? { + let key = metric == .sleep + ? "sleep@\(SleepWindowPrefs.spec(forDayKey: dayKey))" + : metric.rawValue + return HealthCache.value(key, dayKey: dayKey) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Image(systemName: "heart.fill") + .font(.caption) + .foregroundStyle(.pink) + Text("건강 데이터") + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 0) + if !readOnly, store.hasRequestedAuth { + Button { + showingPicker = true + } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.green) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(AppTheme.green.opacity(0.12), in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("표시할 지표 선택")) + } + } + if store.hasRequestedAuth { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8) { + ForEach(metrics) { metric in + chip(metric) + } + } + } else { + Text("모음 탭에서 애플 건강을 연결하면 이 날의 걸음·운동·수면 데이터가 여기 보여요.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .sheet(isPresented: $showingPicker) { + HealthMetricPickerSheet(metricsRaw: $diaryMetricsRaw, fallbackRaw: tileMetricsRaw) + } + .task { await store.refreshToday() } + } + + private func chip(_ metric: HealthMetric) -> some View { + 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 = value(metric) { + // ja "7時間28分"처럼 긴 값이 칩 폭에서 말줄임되지 않게 살짝 축소 허용 (1.5(4) 실측) + 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(metric).map(metric.valueLabel) ?? String(localized: "기록 없음"))")) + } +} + +// MARK: - 수면 표시 구간 설정 (1.5 추가분 — 설정 탭, SleepWindowPrefs) + +/// 모음 탭 타일·일기 건강 카드의 수면 창 설정. 교대·야간 근무처럼 낮에 자는 패턴은 +/// 전체 구간을 넓히거나(시작=끝이면 24시간) 요일별 구간으로 맞춘다. 다짐의 수면 구간은 +/// 다짐별 저장이라 그대로 두되, 새 수면 다짐의 기본값은 여기 전체 구간을 따른다. +struct SleepWindowSettingsView: View { + @AppStorage(LocalPrefsKeys.sleepWindowGlobal, store: AppGroup.defaults) + private var globalRaw = "" + @AppStorage(LocalPrefsKeys.sleepWindowWeekdayEnabled, store: AppGroup.defaults) + private var weekdayEnabled = false + /// 요일별 스펙 사본 — 변경 즉시 defaults에 반영 + @State private var weekdaySpecs: [String: String] = SleepWindowPrefs.weekdaySpecs + + /// 표시 순서: 월~일 (한국 관습 — 주 시작 요일 설정과 무관한 목록 순서일 뿐) + private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1] + + var body: some View { + List { + Section { + SleepWindowEditorRows(spec: Binding( + get: { globalRaw.isEmpty ? SleepWindowPrefs.defaultSpec : globalRaw }, + set: { globalRaw = $0 } + )) + } header: { + Text("전체 구간") + } footer: { + Text("이 구간 안에서 잔 시간이 '구간이 끝나는 날'의 수면으로 집계돼요. 기본은 저녁 9시부터 다음 날 아침 9시까지예요. 시작과 끝을 같은 시각으로 두면 하루 전체(24시간)를 봐요.") + } + Section { + Toggle(isOn: $weekdayEnabled.animation()) { + Text("요일마다 다르게") + } + .tint(AppTheme.green) + if weekdayEnabled { + ForEach(weekdayOrder, id: \.self) { weekday in + weekdayGroup(weekday) + } + } + } footer: { + Text("야간 근무 등으로 특정 요일엔 낮에 잔다면, 그 요일만 구간을 따로 정할 수 있어요. 정하지 않은 요일은 전체 구간을 따라요.\n이 설정은 모음 탭 타일과 일기의 건강 카드에 적용돼요. 다짐의 수면 구간은 다짐 편집에서 따로 정해요 — 새로 만드는 수면 다짐은 여기의 전체 구간으로 시작해요.") + } + } + .scrollContentBackground(.hidden) + .background(AppTheme.background) + .navigationTitle("수면 표시 구간") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private func weekdayGroup(_ weekday: Int) -> some View { + let binding = Binding( + get: { + let raw = weekdaySpecs["\(weekday)"] ?? "" + return raw.isEmpty ? (globalRaw.isEmpty ? SleepWindowPrefs.defaultSpec : globalRaw) : raw + }, + set: { newValue in + weekdaySpecs["\(weekday)"] = newValue + AppGroup.defaults.set(weekdaySpecs, forKey: LocalPrefsKeys.sleepWindowByWeekday) + } + ) + DisclosureGroup { + SleepWindowEditorRows(spec: binding) + } label: { + HStack { + Text("\(Format.weekdayShort(weekday))요일") + Spacer() + Text(Self.summary(binding.wrappedValue)) + .font(.caption) + .foregroundStyle(weekdaySpecs["\(weekday)"].map { $0.isEmpty } ?? true ? .secondary : AppTheme.green) + } + } + } + + /// "1260-540" → "21:00 ~ 다음 날 09:00" 요약 + static func summary(_ spec: String) -> String { + let parts = spec.split(separator: "-").compactMap { Int(String($0)) } + guard parts.count == 2 else { return "" } + func hhmm(_ minutes: Int) -> String { + String(format: "%02d:%02d", minutes / 60, minutes % 60) + } + if parts[0] == parts[1] { + return String(localized: "\(hhmm(parts[1]))에 끝나는 24시간") + } + return parts[0] > parts[1] + ? String(localized: "\(hhmm(parts[0])) ~ 다음 날 \(hhmm(parts[1]))") + : String(localized: "\(hhmm(parts[0])) ~ \(hhmm(parts[1]))") + } +} + +/// 수면 구간 스펙("시작분-끝분") 하나를 편집하는 시작·끝 휠 두 줄 +private struct SleepWindowEditorRows: View { + @Binding var spec: String + + private func minutes(_ index: Int) -> Int { + let parts = spec.split(separator: "-").compactMap { Int(String($0)) } + guard parts.count == 2 else { return index == 0 ? 1260 : 540 } + return parts[index] + } + + private func dateBinding(_ index: Int) -> Binding { + Binding( + get: { + Calendar.current.date( + byAdding: .minute, value: minutes(index), + to: Calendar.current.startOfDay(for: .now) + ) ?? .now + }, + set: { newValue in + let comps = Calendar.current.dateComponents([.hour, .minute], from: newValue) + let value = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) + let start = index == 0 ? value : minutes(0) + let end = index == 1 ? value : minutes(1) + spec = "\(start)-\(end)" + } + ) + } + + var body: some View { + CollapsibleTimeWheel( + label: String(localized: "구간 시작"), + selection: dateBinding(0), + components: .hourAndMinute + ) + CollapsibleTimeWheel( + label: String(localized: "구간 끝"), + selection: dateBinding(1), + components: .hourAndMinute + ) + } +} diff --git a/myApp/HaruDanim/IOS/Views/HelpView.swift b/myApp/HaruDanim/IOS/Views/HelpView.swift index 287cbb0..6024a27 100644 --- a/myApp/HaruDanim/IOS/Views/HelpView.swift +++ b/myApp/HaruDanim/IOS/Views/HelpView.swift @@ -58,9 +58,9 @@ struct HelpView: View { result.append(health) } result.append(sync) - if DeviceLayout.isPad { - result.append(diary) - } else { + // 일기는 1.5 추가분부터 전 기기 (아이폰 포함 — §6.7) + result.append(diary) + if !DeviceLayout.isPad { result.append(iphoneUI) result.append(watch) } @@ -302,7 +302,17 @@ struct HelpView: View { HelpTopic( symbol: "heart.fill", title: "모음 탭에서 건강 데이터 보기", - body: "모음 탭의 '건강 데이터' 줄에서 '애플 건강 연결하기'를 누르면 걸음 수·운동 시간·수면 같은 오늘 데이터를 타일로 볼 수 있어요. 어떤 지표를 어떤 순서로 보여줄지는 줄 오른쪽의 지표 버튼에서 고르고, 설정 → '모음 탭 건강 데이터'에서 줄 자체를 끄거나 즐겨찾기·나머지 행동과의 순서를 바꿀 수 있어요.\n값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요 — 하루 다님이 서버로 보내거나 iCloud에 저장하는 일은 없어요. 걸음·운동 같은 값은 설정의 '하루 시작 시간' 기준으로 오늘 하루를 더한 것이고, 수면만은 전날 밤부터 오늘 아침까지의 수면 구간을 통째로 '오늘 잔 것'으로 세요. 애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보이니 값이 안 맞아 보이면 잠시 뒤 다시 확인해 주세요. 값이 계속 0이라면 아이폰의 설정 → 개인정보 보호 및 보안 → 건강에서 하루 다님의 읽기 권한을 확인해 주세요." + body: "모음 탭의 '건강 데이터' 줄에서 '애플 건강 연결하기'를 누르면 걸음 수·운동 시간·수면 같은 오늘 데이터를 타일로 볼 수 있어요. 어떤 지표를 어떤 순서로 보여줄지는 줄 오른쪽의 지표 버튼에서 고르고, 설정 → '건강 데이터'에서 줄 자체를 끄거나 즐겨찾기·나머지 행동과의 순서를 바꿀 수 있어요.\n값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요 — 하루 다님이 서버로 보내거나 iCloud에 저장하는 일은 없어요. 걸음·운동 같은 값은 설정의 '하루 시작 시간' 기준으로 오늘 하루를 더한 것이고, 수면만은 정해 둔 수면 구간(기본 저녁 9시~다음 날 아침 9시)에서 잔 시간을 통째로 '그날 잔 것'으로 세요. 애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보이니 값이 안 맞아 보이면 잠시 뒤 다시 확인해 주세요. 값이 계속 0이라면 아이폰의 설정 → 개인정보 보호 및 보안 → 건강에서 하루 다님의 읽기 권한을 확인해 주세요." + ), + HelpTopic( + symbol: "bed.double.fill", + title: "수면 표시 구간 맞추기", + body: "타일의 수면이 실제와 다르게 0으로 보인다면, 그 잠이 수면 구간 밖에 있었을 가능성이 커요 — 기본 구간(저녁 9시~다음 날 아침 9시)은 아침 9시 이후에 잔 잠을 세지 않거든요. 설정 → 건강 데이터 → '수면 표시 구간'에서 구간을 바꿔 보세요. 시작과 끝을 같은 시각으로 두면 그 시각에 끝나는 하루 전체(24시간)를 보고, '요일마다 다르게'를 켜면 야간 근무처럼 특정 요일만 낮에 자는 패턴도 요일별 구간으로 맞출 수 있어요.\n이 설정은 모음 탭 타일과 일기의 건강 카드에 적용돼요. 다짐의 수면 구간은 다짐 편집에서 따로 정하고, 새로 만드는 수면 다짐은 여기의 전체 구간으로 시작해요." + ), + HelpTopic( + symbol: "heart.text.square.fill", + title: "일기에서 건강 데이터 보기", + body: "일기의 하루 정리 첫 페이지에 '건강 데이터' 카드가 있어요 — 그날의 걸음·운동·수면 같은 값을 일기와 함께 봐요. 카드 오른쪽 위 버튼으로 보여줄 지표를 고르면 모든 날짜에 함께 적용돼요. 타임테이블에는 수면·운동이 실제 시간 그대로 옅은 블록으로 깔려요(수면 남보라·운동 주황) — 타임테이블 필터의 '건강 데이터' 토글로 켜고 끌 수 있어요.\n건강 값은 기기 안에만 있어서 카드의 값도 기기마다 다를 수 있어요 — 아이패드는 시스템의 건강 iCloud 동기화를 켠 경우에만 아이폰과 같은 값이 보이고, 맥에서는 카드와 블록이 보이지 않아요." ), HelpTopic( symbol: "figure.run", @@ -329,25 +339,25 @@ struct HelpView: View { ]) } - // MARK: iPad 전용 — 일기 + // MARK: 공통 — 일기 (1.5 추가분부터 아이폰 포함 전 기기) private var diary: HelpGroup { - HelpGroup(title: "일기 (아이패드·맥 · 프리미엄)", topics: [ + HelpGroup(title: "일기 (프리미엄)", topics: [ HelpTopic( symbol: "book.closed.fill", title: "하루 정리 첫 페이지", - body: "일기 탭 달력에서 날짜를 누르면 그날의 일기가 열려요. 첫 페이지에는 오늘 기분·요약·목표 진행 현황·할 일·24시간 타임테이블·기록이 모여 하루가 한눈에 정리돼요. 오른쪽 위 ⋯ 메뉴의 '첫 화면 구성'에서 섹션을 보일지 말지와 순서를 바꿀 수 있어요. 기분 사진을 남긴 날은 달력의 그 칸에 사진이 배경으로 깔려요. 일기는 아이패드와 맥에서 쓸 수 있고, iCloud 동기화를 켜면 두 기기에서 같은 일기가 이어져요.", + body: "일기 탭 달력에서 날짜를 누르면 그날의 일기가 열려요. 첫 페이지에는 오늘 기분·요약·목표 진행 현황·할 일·24시간 타임테이블·기록·건강 데이터가 모여 하루가 한눈에 정리돼요. 오른쪽 위 ⋯ 메뉴의 '첫 화면 구성'에서 섹션을 보일지 말지와 순서를 바꿀 수 있어요. 기분 사진을 남긴 날은 달력의 그 칸에 사진이 배경으로 깔려요. 일기는 아이폰·아이패드·맥에서 쓸 수 있고, iCloud 동기화를 켜면 어느 기기에서든 같은 일기가 이어져요.", imageName: "help-diary" ), HelpTopic( symbol: "calendar.badge.plus", title: "타임테이블에 일정·필터", - body: "첫 페이지 타임테이블 카드의 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 보여줄 수 있어요 — 일정은 점선 테두리라 행동과 바로 구분돼요. 필터 버튼으로는 그날 기록된 행동 중 타임테이블에 올릴 것만 고르고, '이 날의 목표' 카드의 필터로는 보고 싶은 목표만 남겨요. 이 선택들은 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 담겨요.\n매일 같은 선택을 되풀이하기 싫다면 달력 화면 오른쪽 위의 '기본 표시 설정'을 써 보세요. 캘린더 일정을 기본으로 넣을지(모든 캘린더/선택한 캘린더만), 어떤 목표·행동을 기본으로 숨길지 한 번만 정하면 모든 날짜에 적용돼요. 날짜별 필터로 다르게 고른 날은 그 선택이 우선하고, '기본값으로 되돌리기'로 언제든 돌아갈 수 있어요." + body: "첫 페이지 타임테이블 카드의 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 보여줄 수 있어요 — 일정은 점선 테두리라 행동과 바로 구분돼요. 필터 버튼으로는 그날 기록된 행동 중 타임테이블에 올릴 것만 고르고, '이 날의 목표' 카드의 필터로는 보고 싶은 목표만 남겨요. 이 선택들은 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 담겨요.\n애플 건강의 수면·운동 시간도 실제 잔 시간·운동한 시간 그대로 옅은 블록으로 깔려요 — 수면은 남보라, 운동은 주황이에요. 필터의 '건강 데이터' 토글로 켜고 끌 수 있고(모든 날짜 공통), 건강 데이터가 없는 맥에서는 보이지 않아요.\n매일 같은 선택을 되풀이하기 싫다면 달력 화면 오른쪽 위의 '기본 표시 설정'을 써 보세요. 캘린더 일정을 기본으로 넣을지(모든 캘린더/선택한 캘린더만), 어떤 목표·행동을 기본으로 숨길지 한 번만 정하면 모든 날짜에 적용돼요. 날짜별 필터로 다르게 고른 날은 그 선택이 우선하고, '기본값으로 되돌리기'로 언제든 돌아갈 수 있어요." ), HelpTopic( symbol: "applepencil", title: "펜슬 노트 페이지", - body: "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 마음껏 쓰고 그리세요 — 손가락은 페이지 넘기기와 확대·축소를 맡아요. 두 손가락으로 벌려 확대하면 작은 글씨도 또박또박 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 차례를 바꿔요. 맥에서는 키보드 입력이 기본이고, 도구 줄의 '그리기'를 켠 동안에만 마우스·트랙패드로 그려져요 — 평소의 클릭과 스크롤은 이동과 버튼에 쓰여요." + body: "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 마음껏 쓰고 그리세요 — 손가락은 페이지 넘기기와 확대·축소를 맡아요. 두 손가락으로 벌려 확대하면 작은 글씨도 또박또박 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 차례를 바꿔요.\n아이폰에서는 도구 줄의 '그리기'를 켠 동안 손가락으로 그려지고, 끄면 손가락이 다시 페이지 넘기기·확대에 쓰여요. 맥에서는 키보드 입력이 기본이고, 마찬가지로 '그리기'를 켠 동안에만 마우스·트랙패드로 그려져요." ), HelpTopic( symbol: "square.on.circle", @@ -440,7 +450,7 @@ struct HelpView: View { HelpTopic( symbol: "crown.fill", title: "무료와 프리미엄의 차이", - body: "무료로는 행동 10개·목표 2개·목표당 다짐 3개까지 만들 수 있어요. 프리미엄에서는 개수 제한이 풀리고 위젯, 애플워치 앱, 시리 단축어, 기기 간 동기화, 아이패드·맥 일기, CSV 가져오기, 건강 다짐을 쓸 수 있어요. 월·년 구독이나 한 번만 결제하는 평생 이용권 중에 고르면 돼요. 구독이 끝나도 기록한 데이터는 그대로 남고, 이미 만든 건강 다짐도 계속 보이고, 쓴 일기도 계속 열람할 수 있어요." + body: "무료로는 행동 10개·목표 2개·목표당 다짐 3개까지 만들 수 있어요. 프리미엄에서는 개수 제한이 풀리고 위젯, 애플워치 앱, 시리 단축어, 기기 간 동기화, 일기(아이폰·아이패드·맥), CSV 가져오기, 건강 다짐을 쓸 수 있어요. 월·년 구독이나 한 번만 결제하는 평생 이용권 중에 고르면 돼요. 구독이 끝나도 기록한 데이터는 그대로 남고, 이미 만든 건강 다짐도 계속 보이고, 쓴 일기도 계속 열람할 수 있어요." ), HelpTopic( symbol: "mic.fill", diff --git a/myApp/HaruDanim/IOS/Views/QuestEditorView.swift b/myApp/HaruDanim/IOS/Views/QuestEditorView.swift index b0b195a..2cce2a0 100644 --- a/myApp/HaruDanim/IOS/Views/QuestEditorView.swift +++ b/myApp/HaruDanim/IOS/Views/QuestEditorView.swift @@ -46,13 +46,23 @@ struct QuestEditorView: View { /// 지표 선택 raw — HealthMetric raw 또는 "workout"(종목별 운동) @State private var healthChoiceRaw: String = HealthMetric.steps.rawValue @State private var healthWorkoutKind: HealthWorkoutKind = .running - /// 수면 구간 (수면 지표 전용 — 구간 끝이 속한 논리적 하루에 통귀속) - @State private var sleepStart: Date = Calendar.current.date( - bySettingHour: 21, minute: 0, second: 0, of: .now - ) ?? .now - @State private var sleepEnd: Date = Calendar.current.date( - bySettingHour: 9, minute: 0, second: 0, of: .now - ) ?? .now + /// 수면 구간 (수면 지표 전용 — 구간 끝이 속한 논리적 하루에 통귀속). + /// 새 다짐의 기본값은 설정의 '수면 표시 구간' 전체 값을 따른다 (1.5 추가분 — + /// 기존 다짐은 load()가 저장값으로 덮어써 무영향) + @State private var sleepStart: Date = { + let minutes = SleepWindowPrefs.globalSpec.split(separator: "-").compactMap { Int(String($0)) } + return Calendar.current.date( + byAdding: .minute, value: minutes.first ?? 1260, + to: Calendar.current.startOfDay(for: .now) + ) ?? .now + }() + @State private var sleepEnd: Date = { + let minutes = SleepWindowPrefs.globalSpec.split(separator: "-").compactMap { Int(String($0)) } + return Calendar.current.date( + byAdding: .minute, value: minutes.count == 2 ? minutes[1] : 540, + to: Calendar.current.startOfDay(for: .now) + ) ?? .now + }() /// 수량형 건강 목표 입력값 (지표별 기본 단위 — 걸음 보 / 에너지 kcal / 스탠드 시간) @State private var healthIntTarget = 8000 /// 거리 목표 (km — 저장은 m) diff --git a/myApp/HaruDanim/IOS/Views/RecordFilterSheet.swift b/myApp/HaruDanim/IOS/Views/RecordFilterSheet.swift index 1087322..ac74ef6 100644 --- a/myApp/HaruDanim/IOS/Views/RecordFilterSheet.swift +++ b/myApp/HaruDanim/IOS/Views/RecordFilterSheet.swift @@ -17,10 +17,27 @@ struct RecordFilterSheet: View { let tags: [Tag] let actions: [Action] @Binding var excludedActionIDs: Set + /// 일기 타임테이블 필터 전용(1.5): 수면·운동 블록 표시 토글 섹션. + /// 맥(건강 미지원)에서는 토글 대신 안내 문구가 나온다. 기록·통계 탭은 false — 기존 화면 무영향 + var showsHealthSection: Bool = false /// 일기 타임테이블 필터 전용: 날짜별 재정의를 지우고 기본 표시 설정으로 되돌린다. /// nil이면(기록·통계 탭) 해당 섹션이 아예 그려지지 않는다 — 기존 화면 무영향 var onResetToDefault: (() -> Void)? = nil + /// 건강 블록 숨김("sleep"/"workout" 쉼표 연결) — 기기 로컬, 모든 날짜 공통 + @AppStorage(DiaryHealthTimetable.hiddenKey) private var healthHiddenRaw = "" + + private func healthVisibleBinding(_ key: String) -> Binding { + Binding( + get: { !healthHiddenRaw.split(separator: ",").map(String.init).contains(key) }, + set: { visible in + var hidden = Set(healthHiddenRaw.split(separator: ",").map(String.init)) + if visible { hidden.remove(key) } else { hidden.insert(key) } + healthHiddenRaw = hidden.sorted().joined(separator: ",") + } + ) + } + private var allActionIDs: Set { Set(actions.map(\.persistentModelID)) } @@ -60,6 +77,45 @@ struct RecordFilterSheet: View { } } } + if showsHealthSection { + Section { + if HealthDataStore.isAvailable { + Toggle(isOn: healthVisibleBinding("sleep")) { + Label { + Text("수면") + } icon: { + Image(systemName: HealthMetric.sleep.symbolName) + .foregroundStyle(DiaryHealthTimetable.sleepColor) + } + } + .tint(AppTheme.green) + Toggle(isOn: healthVisibleBinding("workout")) { + Label { + Text("운동") + } icon: { + Image(systemName: "figure.run") + .foregroundStyle(DiaryHealthTimetable.workoutColor) + } + } + .tint(AppTheme.green) + } else { + Label { + Text("맥에서는 애플 건강 데이터를 지원하지 않아 수면·운동을 타임테이블에 표시할 수 없어요. 아이폰·아이패드에서 확인해 주세요.") + .font(.footnote) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "heart.slash") + .foregroundStyle(.secondary) + } + } + } header: { + Text("건강 데이터") + } footer: { + if HealthDataStore.isAvailable { + Text("애플 건강의 수면·운동 시간을 실제 구간 그대로 타임테이블에 보여줘요. 이 설정은 모든 날짜에 함께 적용돼요.") + } + } + } if let onResetToDefault { Section { Button("기본값으로 되돌리기") { diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index e2580c3..07d19c9 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -199,10 +199,23 @@ struct SettingsView: View { } } } + // 1.5 추가분: 수면 표시 구간 (타일·일기 건강 카드 공용 — 교대·야간 근무 대응) + NavigationLink { + SleepWindowSettingsView() + } label: { + HStack { + Text("수면 표시 구간") + Spacer() + Text(SleepWindowSettingsView.summary(SleepWindowPrefs.globalSpec)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } } header: { - Text("모음 탭 건강 데이터") + Text("건강 데이터") } footer: { - Text("켜면 애플 건강 앱의 오늘 데이터(걸음·운동·수면 등)를 모음 탭에서 타일로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요.") + Text("켜면 애플 건강 앱의 오늘 데이터(걸음·운동·수면 등)를 모음 탭에서 타일로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요. 수면 표시 구간은 어느 시간대의 잠을 '그날 잔 것'으로 볼지 정해요 — 밤낮이 바뀌는 날이 있어도 맞출 수 있어요.") } .id("healthSection") } @@ -261,22 +274,20 @@ struct SettingsView: View { } footer: { Text("여러 행동을 동시에 추적 중일 때 다이나믹 아일랜드·잠금화면과 워치 컴플리케이션에 대표로 표시할 행동이에요.") } - // 일기는 아이패드·맥에서 사용(§6.7) — 잠금 설정도 그 기기에서만 의미가 있다 - if DeviceLayout.isPad { - Section { - Toggle(isOn: diaryLockBinding) { - Label { - Text("일기 잠금") - } icon: { - Image(systemName: "lock.fill") - .foregroundStyle(AppTheme.green) - } + // 일기는 1.5 추가분부터 전 기기 사용(§6.7) — 잠금 설정도 전 기기에 노출 + Section { + Toggle(isOn: diaryLockBinding) { + Label { + Text("일기 잠금") + } icon: { + Image(systemName: "lock.fill") + .foregroundStyle(AppTheme.green) } - } header: { - Text("일기") - } footer: { - Text("켜면 일기 탭을 열 때 Face ID·Touch ID 또는 기기 암호로 잠금을 해제해야 해요. 기기가 지원하는 방식이 자동으로 쓰이고, 앱을 나갔다 오면 다시 잠겨요. 이 설정은 이 기기에만 적용돼요.") } + } header: { + Text("일기") + } footer: { + Text("켜면 일기 탭을 열 때 Face ID·Touch ID 또는 기기 암호로 잠금을 해제해야 해요. 기기가 지원하는 방식이 자동으로 쓰이고, 앱을 나갔다 오면 다시 잠겨요. 이 설정은 이 기기에만 적용돼요.") } Section("프리미엄") { NavigationLink { @@ -521,7 +532,7 @@ struct PremiumView: View { featureRow("switch.2", "제어 센터에서 실행", "제어 센터·잠금화면 버튼·액션 버튼에서 행동을 바로 실행해요.") featureRow("icloud.fill", "기기 간 동기화", "iCloud로 아이폰·아이패드·맥의 데이터가 동기화돼요.") featureRow("applewatch", "애플워치 앱", "워치에서 추적하고 컴플리케이션으로 확인해요.") - featureRow("book.closed.fill", "아이패드·맥 일기", "하루 정리와 애플 펜슬 다이어리 — 맥에서는 마우스·키보드로 써요.") + featureRow("book.closed.fill", "일기 (아이폰·아이패드·맥)", "하루 정리와 다이어리 — 아이패드는 애플 펜슬, 아이폰은 손가락 그리기, 맥은 마우스·키보드로 써요.") featureRow("square.and.arrow.down", "CSV 가져오기", "내보냈던 CSV에서 행동과 기록을 복원해요.") featureRow("mic.fill", "시리 단축어", "\"시리야, 하루 다님에서 측정 시작\" — 말과 단축어 자동화로 기록해요.") } header: { diff --git a/myApp/HaruDanim/IOS/Views/SidebarRootView.swift b/myApp/HaruDanim/IOS/Views/SidebarRootView.swift index 2519ba4..304cdd0 100644 --- a/myApp/HaruDanim/IOS/Views/SidebarRootView.swift +++ b/myApp/HaruDanim/IOS/Views/SidebarRootView.swift @@ -15,6 +15,8 @@ import UIKit /// 크기 클래스 대신 기기 종류로 판정해 iPhone(가로 모드 포함)은 항상 기존 UI를 유지한다. enum DeviceLayout { static let isPad = UIDevice.current.userInterfaceIdiom == .pad + /// 아이폰 여부 (1.5 추가분 — 일기 노트의 '그리기' 토글·손가락 필기 분기용) + static let isPhone = UIDevice.current.userInterfaceIdiom == .phone /// 맥에서 실행 중인 아이패드 앱(Designed for iPad) 여부. /// 아이패드 포인트가 맥 화면에 축소 매핑돼 전반적으로 작게 보이므로, /// 맥에서만 글자 크기·모음 탭 카드/버튼 폭을 키운다 (아이폰·아이패드 무영향) diff --git a/myApp/HaruDanim/IOS/Views/WhatsNewView.swift b/myApp/HaruDanim/IOS/Views/WhatsNewView.swift index 0213f8c..759dd89 100644 --- a/myApp/HaruDanim/IOS/Views/WhatsNewView.swift +++ b/myApp/HaruDanim/IOS/Views/WhatsNewView.swift @@ -76,6 +76,18 @@ struct WhatsNewView: View { title: String(localized: "건강 데이터로 다짐 만들기"), body: String(localized: "'매일 8,000보', '수면 8시간', '달리기 30분'처럼 건강 데이터를 목표로 삼는 다짐을 만들 수 있어요. 운동은 종목별로도 돼요. (프리미엄)") ) + feature( + symbol: "book.closed.fill", + tint: AppTheme.green, + title: String(localized: "아이폰에서도 일기 쓰기"), + body: String(localized: "일기 탭이 아이폰에도 생겼어요. 하루 정리를 확인하고, 노트에서 '그리기'를 켜면 손가락으로 쓰고 그릴 수 있어요. (프리미엄)") + ) + feature( + symbol: "heart.text.square.fill", + tint: .pink, + title: String(localized: "일기에서 건강 데이터 보기"), + body: String(localized: "하루 정리에 그날의 건강 값을 담는 카드가 생기고, 타임테이블에는 수면·운동이 실제 시간 그대로 깔려요. 낮에 자는 날이 있다면 설정의 '수면 표시 구간'을 요일별로 맞춰 보세요.") + ) feature( symbol: "questionmark.circle.fill", tint: AppTheme.yellow, diff --git a/myApp/HaruDanim/Marketing/AppStore/description-en.txt b/myApp/HaruDanim/Marketing/AppStore/description-en.txt index d7b6206..f533c4d 100644 --- a/myApp/HaruDanim/Marketing/AppStore/description-en.txt +++ b/myApp/HaruDanim/Marketing/AppStore/description-en.txt @@ -29,7 +29,7 @@ A habit and time tracker: log time and counts with a single tap, and shape your • Apple Watch app and complications — track from your wrist, with an Action Log complication showing totals on your watch face • Siri Shortcuts — "Hey Siri, start tracking Reading in Haru Danim" • iCloud sync across iPhone, iPad and Mac -• Diary on iPad and Mac — a daily summary page plus free-form Apple Pencil notes (mouse and keyboard on Mac), with your own PDF page templates +• Diary on iPhone, iPad and Mac — a daily summary page plus free-form notes (Apple Pencil on iPad, finger drawing on iPhone, mouse and keyboard on Mac), with your own PDF page templates. The summary now includes a health-data card and a timetable with your sleep and workouts laid in • CSV import — bring actions and records back from your exported CSVs • Health quests — goals from Apple Health data like steps, sleep and workout types ("8,000 steps a day," "8 hours of sleep") diff --git a/myApp/HaruDanim/Marketing/AppStore/description-ja.txt b/myApp/HaruDanim/Marketing/AppStore/description-ja.txt index af09007..e21e205 100644 --- a/myApp/HaruDanim/Marketing/AppStore/description-ja.txt +++ b/myApp/HaruDanim/Marketing/AppStore/description-ja.txt @@ -29,7 +29,7 @@ Haru Danim — あなたの一日に立ち寄る • Apple Watchアプリとコンプリケーション — 手首で記録し、「アクションの記録」でフェイスに累計まで • Siriショートカット — 「Hey Siri、Haru Danimで読書の計測を開始」 • iCloudでiPhone・iPad・Macのデータを同期 -• iPad・Macの日記 — 一日のまとめページとApple Pencilの自由ノート(Macではマウス・キーボード)、自分のPDF用紙(テンプレート) +• 日記(iPhone・iPad・Mac)— 一日のまとめページと自由ノート(iPadはApple Pencil、iPhoneは指で描く、Macはマウス・キーボード)、自分のPDF用紙(テンプレート)。まとめにはその日のヘルス値カードと、睡眠・運動が敷かれたタイムテーブルも • CSV読み込み — 書き出したCSVからアクションと記録を復元 • ヘルスクエスト — 歩数・睡眠・運動種目などAppleヘルスケアのデータを目標に(「毎日8,000歩」「睡眠8時間」) diff --git a/myApp/HaruDanim/Marketing/AppStore/description-ko.txt b/myApp/HaruDanim/Marketing/AppStore/description-ko.txt index 951b0e5..1afdaf5 100644 --- a/myApp/HaruDanim/Marketing/AppStore/description-ko.txt +++ b/myApp/HaruDanim/Marketing/AppStore/description-ko.txt @@ -29,7 +29,7 @@ • 애플워치 앱과 컴플리케이션 — 손목에서 기록하고, '행동 기록' 컴플리케이션으로 페이스에서 누적까지 • 시리 단축어 — "시리야, 하루 다님에서 독서 측정 시작" • iCloud로 아이폰·아이패드·맥 데이터 동기화 -• 아이패드·맥 일기 — 하루 정리 요약과 애플 펜슬 자유 노트(맥은 마우스·키보드), 나만의 PDF 양식(속지) +• 일기 (아이폰·아이패드·맥) — 하루 정리 요약과 자유 노트(아이패드는 애플 펜슬, 아이폰은 손가락 그리기, 맥은 마우스·키보드), 나만의 PDF 양식(속지). 하루 정리에는 그날의 건강 값 카드와 수면·운동이 깔린 타임테이블까지 • CSV 가져오기 — 내보냈던 CSV에서 행동과 기록을 다시 불러와 복원 • 건강 다짐 — 걸음·수면·운동 종목 같은 애플 건강 데이터를 목표로 ('매일 8,000보', '수면 8시간') diff --git a/myApp/HaruDanim/Marketing/AppStore/whats-new-1.5.txt b/myApp/HaruDanim/Marketing/AppStore/whats-new-1.5.txt index f37e138..5a1fc45 100644 --- a/myApp/HaruDanim/Marketing/AppStore/whats-new-1.5.txt +++ b/myApp/HaruDanim/Marketing/AppStore/whats-new-1.5.txt @@ -1,12 +1,17 @@ # 버전 1.5 '새로운 기능' (로케일별) [ko] -이번 업데이트는 애플 건강, 그리고 애플워치와 더 가까워졌어요. +이번 업데이트는 애플 건강, 그리고 애플워치와 더 가까워졌어요. 일기는 이제 아이폰에서도 열려요. ● 애플 건강과 연결 • 모음 탭에서 걸음·운동·수면 같은 오늘의 건강 데이터를 타일로 볼 수 있어요 — 값은 기기 밖으로 나가지 않아요 • 건강 데이터로 다짐을 만들 수 있어요(프리미엄) — '매일 8,000보', '수면 8시간', '달리기 30분'처럼요. 운동은 종목별로도 돼요 -• 수면은 '수면 구간'을 정해 두면 자정을 넘겨 자도 하루가 쪼개지지 않아요 +• 수면은 '수면 구간'을 정해 두면 자정을 넘겨 자도 하루가 쪼개지지 않아요. 낮에 자는 날이 있다면 '수면 표시 구간'을 요일별로 맞출 수도 있어요 + +● 일기가 더 넓어졌어요 (프리미엄) +• 일기 탭이 아이폰에도 생겼어요 — 하루 정리를 확인하고, '그리기'를 켜면 손가락으로 노트에 쓸 수 있어요 +• 하루 정리에 그날의 건강 값을 담는 '건강 데이터' 카드가 생겼어요 +• 타임테이블에 수면·운동이 실제 시간 그대로 깔려요 — 하루가 정말 한눈에 정리돼요 ● 애플워치 '행동 기록' 컴플리케이션 • 행동의 누적 기록(하루·이번 주·이번 달·지난 7일·지난 30일)을 워치 페이스에서 바로 봐요 @@ -17,12 +22,17 @@ • 여러 기기를 쓰신다면 모든 기기에서 함께 업데이트해 주세요 [en] -This update brings Haru Danim closer to Apple Health — and your Apple Watch. +This update brings Haru Danim closer to Apple Health — and your Apple Watch. And the Diary now opens on iPhone, too. ● Apple Health, connected • 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, too -• Set a sleep window and sleeping past midnight won't split your night +• 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 + +● 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 +• The daily summary gains a Health Data card with that day's values +• Sleep and workouts now appear on the timetable at their actual times — your whole day, truly at a glance ● Action Log complication for Apple Watch • See an action's running total (day, this week, this month, last 7/30 days) right on your watch face @@ -33,12 +43,17 @@ This update brings Haru Danim closer to Apple Health — and your Apple Watch. • Using several devices? Please update Haru Danim on all of them [ja] -今回のアップデートで、Appleヘルスケア、そしてApple Watchともっと近くなりました。 +今回のアップデートで、Appleヘルスケア、そしてApple Watchともっと近くなりました。日記はiPhoneでも開けるようになりました。 ● Appleヘルスケアと連携 • ホームタブで歩数・運動・睡眠など今日のヘルスデータをタイルで見られます — 値が端末の外に出ることはありません • ヘルスデータでクエストを作れます(プレミアム)—「毎日8,000歩」「睡眠8時間」「ランニング30分」など。運動は種目別でもOK -• 「睡眠時間帯」を決めておけば、日付をまたいで眠っても1日が分割されません +• 「睡眠時間帯」を決めておけば、日付をまたいで眠っても1日が分割されません。昼間に眠る日があるなら、表示の時間帯を曜日ごとに調整できます + +● 日記がもっと広く(プレミアム) +• 日記タブがiPhoneにも登場 — 1日のまとめを確認し、「描く」をオンにすれば指でノートに書けます +• 1日のまとめに、その日のヘルス値をまとめる「ヘルスデータ」カードが加わりました +• タイムテーブルに睡眠・運動が実際の時間どおりに表示されます — 1日が本当にひと目でわかります ● Apple Watch「アクションの記録」コンプリケーション • アクションの累計(1日・今週・今月・過去7日・過去30日)をウォッチフェイスで直接確認 diff --git a/myApp/HaruDanim/Marketing/README.md b/myApp/HaruDanim/Marketing/README.md index e48a0f6..c0c3087 100644 --- a/myApp/HaruDanim/Marketing/README.md +++ b/myApp/HaruDanim/Marketing/README.md @@ -75,7 +75,7 @@ FormatStyle에 반영되지 않는다. 로케일마다 첫 시드 실행에 `-ma # 06-bubble : -navStyle radial -radialExpanded YES # 08-dark : 기본 실행 후 `simctl ui appearance dark` → 촬영 → light 복귀 # 09-records : -startTab history -historyDaysAgo 1 (어제 목록 — 메모 노랑 표시) -# 10-week : (벌크 시드 후) -startTab history -historyMode timetable -historyWeekly YES -historyDaysAgo 7 +# 10-diary : 1.5(4)부터 — 시드에 -diarySeed 추가 후 -startTab diary -diaryOpenToday YES -seedHealthCache YES -diary.sectionOrder "mood,health,timetable" (구 10-week[주간 타임테이블]는 폐기) # 아이패드: 01/10-goals/09-dark 동일 요령. 일기는 첫 실행에 # -startTab diary -diarySeed YES -diarySeedPhoto YES -diarySeedTemplate YES 로 시드 후: # 02: -startTab diary 03: +-diaryOpenToday 04: +-diaryPage 1 08: +-diaryApplyTemplate @@ -103,7 +103,9 @@ MW=0A2CB37C-A755-4784-9D14-C7AB8FD72C3E # 'MarketingWatch' — 비페어링 Se > ✅ **2026-07-28: 아래 절차 전부 완료 — 버전 1.0 App Store 출시됨.** 이 체크리스트는 절차 기록으로 유지. > 📸 **2026-08-22: 1.5용 스크린샷 전면 재촬영 — 기존 세트 폐기, 아이폰 10·아이패드 10·워치 1 × ko/en/ja (원본 57장+합성 63장).** > 구성 변경: 아이폰 09-records → **09-health**(건강 다짐 목표 탭 — `-seedHealthQuest`+`-seedHealthCache`, 데모 시드 없이 단독. ⚠️행동이 없어 온보딩이 뜨므로 촬영 전 `defaults write com.yechan.HaruDanim onboarding.done -bool YES` 필요). 01-main은 건강 타일이 보이게 `-mainSectionOrder "favorites,health,others"` 표시 오버라이드 사용. -> 시뮬레이터 리셋(2026-08-22)으로 마케팅 심 재생성: iPhone 17 Pro Max(26.5) `0255B56E-A9B8-44DE-9C0B-8570BB0C6315`, iPad Pro 13"(26.5) `9A8DE7A7-D7B1-4C95-92FE-0AA4B5D3034E`, MarketingWatch(26.2, 비페어링) `071FD7ED-9047-498D-B6BA-73A898FDE03A`. 시간대는 Pacific/Auckland 부팅 주입(오전 9시대). +> 시뮬레이터 리셋(2026-08-22)으로 마케팅 심 재생성: iPhone 17 Pro Max(26.5) `0255B56E-A9B8-44DE-9C0B-8570BB0C6315`, iPad Pro 13"(26.5) `9A8DE7A7-D7B1-4C95-92FE-0AA4B5D3034E`, MarketingWatch(26.2, 비페어링) `071FD7ED-9047-498D-B6BA-73A898FDE03A`. 시간대는 Pacific/Auckland 부팅 주입(오전 9시대 — **KST 저녁 촬영이면 America/Argentina/Buenos_Aires(UTC-3)가 오전 9시대**, 1.5(4) 실측). +> +> 📸 **2026-08-22 저녁, 1.5(4) 추가분 3장 갱신**: ①아이폰 `06-bubble` 재촬영(일기 개방으로 버블 8개) ②아이폰 `10-week` → **`10-diary` 교체**(아이폰 일기 — 인자: 시드 `-seedDemo -diarySeed -seedHealthCache` 후 `-startTab diary -diaryOpenToday -seedHealthCache -diary.sectionOrder "mood,health,timetable"`) ③아이패드 `03-diary-summary` 재촬영(건강 카드+타임테이블 수면·운동 블록 — 같은 요령, sectionOrder `"mood,health,timetable,hero,goals"`). 합성 문구는 frame_screenshots.swift에 반영(10-diary 신설·03/02 서브라인 갱신). > ⚠️촬영 실측 주의 3가지: ①`simctl io screenshot`은 **기존 파일을 덮어쓰지 않음** — 재촬영 전 rm 필수 ②워치 절차의 그룹 컨테이너 경로는 **부팅 중에만 조회 가능**하고 아이폰 앱 재설치마다 페어링 워치 쪽 경로가 바뀜 — 로케일마다 재조회(셧다운 후 조회하면 빈 값→엉뚱한 cp) ③아이폰 언인스톨→워치 앱 삭제 전파가 늦어 직후 설치가 지워질 수 있음 — 언인스톨 후 8초 대기. > > 📸 **2026-07-29: 1.1용 스크린샷 전면 개편 — 아이폰 10장·아이패드 10장·워치 합성 1장 × ko/en/ja (전부 재촬영·재합성).** diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/en/02-diary-calendar.png b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/en/02-diary-calendar.png index 7fa0d32..1d95bc3 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/en/02-diary-calendar.png and b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/en/02-diary-calendar.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/en/03-diary-summary.png b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/en/03-diary-summary.png index 9253791..a5e22d6 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/en/03-diary-summary.png and b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/en/03-diary-summary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ja/02-diary-calendar.png b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ja/02-diary-calendar.png index 75b23db..532e275 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ja/02-diary-calendar.png and b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ja/02-diary-calendar.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ja/03-diary-summary.png b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ja/03-diary-summary.png index 19aba57..7bbd6c6 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ja/03-diary-summary.png and b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ja/03-diary-summary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ko/02-diary-calendar.png b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ko/02-diary-calendar.png index 6a51315..d6b00d4 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ko/02-diary-calendar.png and b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ko/02-diary-calendar.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ko/03-diary-summary.png b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ko/03-diary-summary.png index 380c8ff..2b6eb3d 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ko/03-diary-summary.png and b/myApp/HaruDanim/Marketing/Screenshots/framed-ipad-13/ko/03-diary-summary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/en/06-bubble.png b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/en/06-bubble.png index 03ccf3e..0e8d874 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/en/06-bubble.png and b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/en/06-bubble.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/en/10-diary.png b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/en/10-diary.png new file mode 100644 index 0000000..e77e05c Binary files /dev/null and b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/en/10-diary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/en/10-week.png b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/en/10-week.png deleted file mode 100644 index 432412a..0000000 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/en/10-week.png and /dev/null differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ja/06-bubble.png b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ja/06-bubble.png index e0d24c8..02ca5ce 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ja/06-bubble.png and b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ja/06-bubble.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ja/10-diary.png b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ja/10-diary.png new file mode 100644 index 0000000..76f507a Binary files /dev/null and b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ja/10-diary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ja/10-week.png b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ja/10-week.png deleted file mode 100644 index 0de790d..0000000 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ja/10-week.png and /dev/null differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ko/06-bubble.png b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ko/06-bubble.png index 9a7bbcf..cb4ad0e 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ko/06-bubble.png and b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ko/06-bubble.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ko/10-diary.png b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ko/10-diary.png new file mode 100644 index 0000000..a264d23 Binary files /dev/null and b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ko/10-diary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ko/10-week.png b/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ko/10-week.png deleted file mode 100644 index 0fc4490..0000000 Binary files a/myApp/HaruDanim/Marketing/Screenshots/framed-iphone-6.9/ko/10-week.png and /dev/null differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/ipad-13/en/03-diary-summary.png b/myApp/HaruDanim/Marketing/Screenshots/ipad-13/en/03-diary-summary.png index 0d2aff8..4590bc3 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/ipad-13/en/03-diary-summary.png and b/myApp/HaruDanim/Marketing/Screenshots/ipad-13/en/03-diary-summary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/ipad-13/ja/03-diary-summary.png b/myApp/HaruDanim/Marketing/Screenshots/ipad-13/ja/03-diary-summary.png index 372dd0e..2b8e051 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/ipad-13/ja/03-diary-summary.png and b/myApp/HaruDanim/Marketing/Screenshots/ipad-13/ja/03-diary-summary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/ipad-13/ko/03-diary-summary.png b/myApp/HaruDanim/Marketing/Screenshots/ipad-13/ko/03-diary-summary.png index 84d9c2c..fb1bdc1 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/ipad-13/ko/03-diary-summary.png and b/myApp/HaruDanim/Marketing/Screenshots/ipad-13/ko/03-diary-summary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/06-bubble.png b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/06-bubble.png index 6dbde0e..3a92e93 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/06-bubble.png and b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/06-bubble.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/10-diary.png b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/10-diary.png new file mode 100644 index 0000000..646087d Binary files /dev/null and b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/10-diary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/10-week.png b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/10-week.png deleted file mode 100644 index c60f93f..0000000 Binary files a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/10-week.png and /dev/null differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/06-bubble.png b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/06-bubble.png index 74f1534..f3822c9 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/06-bubble.png and b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/06-bubble.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/10-diary.png b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/10-diary.png new file mode 100644 index 0000000..4487f91 Binary files /dev/null and b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/10-diary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/10-week.png b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/10-week.png deleted file mode 100644 index 48bac13..0000000 Binary files a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/10-week.png and /dev/null differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/06-bubble.png b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/06-bubble.png index 5d305fe..b2ce563 100644 Binary files a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/06-bubble.png and b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/06-bubble.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/10-diary.png b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/10-diary.png new file mode 100644 index 0000000..48244eb Binary files /dev/null and b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/10-diary.png differ diff --git a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/10-week.png b/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/10-week.png deleted file mode 100644 index d6068dd..0000000 Binary files a/myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/10-week.png and /dev/null differ diff --git a/myApp/HaruDanim/Marketing/tools/frame_screenshots.swift b/myApp/HaruDanim/Marketing/tools/frame_screenshots.swift index 9641ea7..6f6e619 100644 --- a/myApp/HaruDanim/Marketing/tools/frame_screenshots.swift +++ b/myApp/HaruDanim/Marketing/tools/frame_screenshots.swift @@ -53,9 +53,10 @@ let iphoneCopies: [String: Copy] = [ "08-dark": Copy( headline: ["ko": "밤에는 다크 모드로 편안하게", "en": "Easy on the eyes in Dark Mode", "ja": "夜はダークモードで心地よく"], subline: ["ko": "라이트·다크·시스템 설정 따름 중 선택", "en": "Light, dark, or match the system", "ja": "ライト・ダーク・システム連動から選択"]), - "10-week": Copy( - headline: ["ko": "일주일의 흐름이 한눈에", "en": "Your week at a glance", "ja": "一週間の流れをひと目で"], - subline: ["ko": "24시간 축 위에 그려지는 주간 타임테이블", "en": "Seven days drawn on a 24-hour timetable", "ja": "24時間軸に描かれる週間タイムテーブル"]), + // 1.5(4): 10-week → 10-diary 교체 — 아이폰 일기 개방이 헤드라인 기능이라 주간 타임테이블 슬롯을 양보 + "10-diary": Copy( + headline: ["ko": "일기가 이제 아이폰에도", "en": "The Diary, now on iPhone", "ja": "日記がiPhoneにも"], + subline: ["ko": "하루 정리와 건강 데이터, 손가락으로 쓰는 노트", "en": "Daily summary, health data and finger-drawn notes", "ja": "1日のまとめとヘルスデータ、指で書くノート"]), ] let ipadCopies: [String: Copy] = [ @@ -64,10 +65,10 @@ let ipadCopies: [String: Copy] = [ subline: ["ko": "사이드바와 넓은 그리드로 한 화면에", "en": "Sidebar navigation and a wide grid", "ja": "サイドバーと広いグリッドで一画面に"]), "02-diary-calendar": Copy( headline: ["ko": "하루를 남기는 일기", "en": "A diary for every day", "ja": "毎日を残す日記"], - subline: ["ko": "기분 이모지와 사진으로 채워지는 달력 — 맥에서도", "en": "A calendar of moods and photos — on Mac too", "ja": "気分の絵文字と写真で埋まるカレンダー — Macでも"]), + subline: ["ko": "기분 이모지와 사진으로 채워지는 달력 — 아이폰·맥에서도", "en": "A calendar of moods and photos — on iPhone and Mac too", "ja": "気分の絵文字と写真で埋まるカレンダー — iPhone・Macでも"]), "03-diary-summary": Copy( headline: ["ko": "하루 정리를 한 페이지에", "en": "Your whole day on one page", "ja": "一日のまとめを1ページに"], - subline: ["ko": "기록·목표·할 일·타임테이블까지", "en": "Records, goals, to-dos and a timetable", "ja": "記録・目標・やること・タイムテーブル"]), + subline: ["ko": "기록·목표·건강 데이터·타임테이블까지", "en": "Records, goals, health data and a timetable", "ja": "記録・目標・ヘルスデータ・タイムテーブル"]), "04-diary-note": Copy( headline: ["ko": "애플 펜슬로 자유롭게", "en": "Free-form with Apple Pencil", "ja": "Apple Pencilで自由に"], subline: ["ko": "필기·사진·도형·텍스트 — 맥에서는 마우스와 키보드로", "en": "Ink, photos, shapes and text — mouse and keyboard on Mac", "ja": "手書き・写真・図形・テキスト — Macではマウスとキーボードで"]), diff --git a/myApp/HaruDanim/Shared/HealthMetric.swift b/myApp/HaruDanim/Shared/HealthMetric.swift index 3076e2d..41489f2 100644 --- a/myApp/HaruDanim/Shared/HealthMetric.swift +++ b/myApp/HaruDanim/Shared/HealthMetric.swift @@ -265,6 +265,69 @@ nonisolated enum HealthCache { } /// 전체 초기화(데이터 정리)용 — 값은 어차피 건강 앱 소유라 캐시만 비운다 + static func clearAll() { + AppGroup.defaults.removeObject(forKey: storageKey) + HealthIntervals.clearAll() + } +} + +// MARK: - 시간 구간 캐시 (1.5 추가분 — 일기 타임테이블의 수면·운동 블록) + +/// 하루 '합계'만 담는 HealthCache와 달리, 타임테이블 표시용 실제 시간 구간을 담는다. +/// 키: "sleep" 또는 "workout.", 값: dayToken → [[시작 epoch, 끝 epoch], ...]. +/// 구간은 그 논리적 하루와 겹치는 부분으로 클리핑해 저장 — 자정을 걸친 잠은 이틀에 나뉜다 +/// (다짐의 '깨어난 날 통귀속'과 다른, 사실 그대로의 표시 규칙 — 의도된 차이). +/// ⚠️ HealthCache와 동일하게 기기 로컬 defaults 전용 — CloudKit 저장 금지 (5.1.3) +nonisolated enum HealthIntervals { + static let storageKey = "health.intervals.v1" + static let retentionDays = HealthCache.retentionDays + + static let sleepKey = "sleep" + static func workoutKey(_ kind: HealthWorkoutKind) -> String { "workout.\(kind.rawValue)" } + + private static func load() -> [String: [String: [[Double]]]] { + (AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: [[Double]]]]) ?? [:] + } + + /// 한 종류·한 날짜의 구간 목록 (시작 오름차순) + static func intervals(_ key: String, dayKey: Date) -> [(start: Date, end: Date)] { + let stored = load()[key]?[HealthCache.dayToken(dayKey)] ?? [] + return stored.compactMap { pair in + guard pair.count == 2, pair[1] > pair[0] else { return nil } + return (Date(timeIntervalSince1970: pair[0]), Date(timeIntervalSince1970: pair[1])) + }.sorted { $0.start < $1.start } + } + + /// 그날 저장된 운동 종류 키 목록 (표시 순서는 호출부가 정함) + static func workoutKeys(dayKey: Date) -> [String] { + let token = HealthCache.dayToken(dayKey) + return load().keys.filter { $0.hasPrefix("workout.") && !(load()[$0]?[token] ?? []).isEmpty } + } + + /// 여러 날짜 × 여러 종류 일괄 기록 (백필·최근 갱신 공용). + /// values: [종류 키: [dayKey: [[시작 epoch, 끝 epoch]]]] — 빈 배열은 "그날 없음" 기록으로 유효 + static func merge(_ values: [String: [Date: [[Double]]]]) { + guard !values.isEmpty else { return } + var all = load() + for (key, days) in values { + var stored = all[key] ?? [:] + for (dayKey, list) in days { + let token = HealthCache.dayToken(dayKey) + if list.isEmpty { + stored.removeValue(forKey: token) + } else { + stored[token] = list + } + } + if stored.count > retentionDays { + let sorted = stored.keys.sorted(by: >) + for old in sorted.dropFirst(retentionDays) { stored.removeValue(forKey: old) } + } + all[key] = stored + } + AppGroup.defaults.set(all, forKey: storageKey) + } + static func clearAll() { AppGroup.defaults.removeObject(forKey: storageKey) } diff --git a/myApp/HaruDanim/Shared/LocalPrefs.swift b/myApp/HaruDanim/Shared/LocalPrefs.swift index 776522b..c0d36f7 100644 --- a/myApp/HaruDanim/Shared/LocalPrefs.swift +++ b/myApp/HaruDanim/Shared/LocalPrefs.swift @@ -46,6 +46,61 @@ nonisolated enum LocalPrefsKeys { /// 모음 탭 건강 타일에 표시할 지표 — HealthMetric raw 쉼표 연결(순서 포함, 1.5). /// 기본: 걸음·운동·수면 static let healthMetrics = "local.healthMetrics" + /// 수면 표시 구간(타일·일기 건강 카드 공용) — "시작분-끝분" 스펙, 빈 값=기본(1260-540) (1.5) + static let sleepWindowGlobal = "local.sleepWindowGlobal" + /// 요일별 수면 구간 사용 여부 — Bool (1.5) + static let sleepWindowWeekdayEnabled = "local.sleepWindowWeekdayEnabled" + /// 요일별 수면 구간 — ["1"(일)~"7"(토): 스펙] 딕셔너리. 값 없는 요일은 전체 구간 (1.5) + static let sleepWindowByWeekday = "local.sleepWindowByWeekday" +} + +// MARK: - 수면 표시 구간 설정 (1.5 추가분 — 설정 탭, 교대·야간 근무 수면 패턴 대응) + +/// 모음 탭 타일과 일기 건강 카드가 쓰는 '수면 구간'(어느 시각에 끝나는 창의 잠을 그날 것으로 +/// 볼지)의 설정 계층. 다짐의 수면 구간은 다짐별 저장이라 별개 — 새 다짐의 기본값만 여기서 시드. +/// 값은 기기 로컬(App Group defaults) — 건강 값과 같은 계층이라 CloudKit 비동기화가 자연스럽다. +nonisolated enum SleepWindowPrefs { + static let defaultSpec = "1260-540" + + /// 전체(요일 미지정) 구간 스펙 + static var globalSpec: String { + let raw = AppGroup.defaults.string(forKey: LocalPrefsKeys.sleepWindowGlobal) ?? "" + return raw.isEmpty ? defaultSpec : raw + } + + static var weekdayEnabled: Bool { + AppGroup.defaults.bool(forKey: LocalPrefsKeys.sleepWindowWeekdayEnabled) + } + + static var weekdaySpecs: [String: String] { + (AppGroup.defaults.dictionary(forKey: LocalPrefsKeys.sleepWindowByWeekday) as? [String: String]) ?? [:] + } + + /// 특정 요일(1=일 ~ 7=토)의 유효 스펙 + static func spec(forWeekday weekday: Int) -> String { + guard weekdayEnabled, let raw = weekdaySpecs["\(weekday)"], !raw.isEmpty else { return globalSpec } + return raw + } + + /// 특정 논리적 하루 키의 유효 스펙 (키는 달력일 자정 — 요일은 달력일 그대로) + static func spec(forDayKey dayKey: Date) -> String { + spec(forWeekday: Calendar.current.component(.weekday, from: dayKey)) + } + + /// 설정된 모든 스펙 (캐시 계산 대상 — 전체 + 켜져 있으면 요일별 전부) + static var allSpecs: Set { + var specs: Set = [globalSpec] + if weekdayEnabled { + for raw in weekdaySpecs.values where !raw.isEmpty { specs.insert(raw) } + } + return specs + } + + /// 요일→스펙 매핑 서명 — 백필 서명에 포함해, 스펙 집합이 같아도 요일 배정이 + /// 바뀌면(예: 주말만 다른 구간으로 변경) 과거 날짜의 "sleep" 평문 키가 재계산되게 한다 + static var mappingSignature: String { + (1...7).map { spec(forWeekday: $0) }.joined(separator: "/") + } } /// 모음 탭의 순서 지정 가능한 3섹션 (1.5 — 목표 카드·진행 중 영역은 상단 고정, 대상 아님)