From 92afa69f6dd36c62563eceea70dd19a317b5eb09 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sat, 22 Aug 2026 05:18:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(1.5-p1):=20=EC=9B=8C=EC=B9=98=20'=ED=96=89?= =?UTF-8?q?=EB=8F=99=20=EA=B8=B0=EB=A1=9D'=20=EC=BB=B4=ED=94=8C=EB=A6=AC?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=85=98=20=E2=80=94=20=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=EC=98=B5=ED=8A=B8=EC=9D=B8=C2=B7=EB=AF=B8=EB=8B=88=20=ED=96=89?= =?UTF-8?q?=EB=8F=99=20=ED=99=94=EB=A9=B4=20=EB=94=A5=EB=A7=81=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WatchActionPeriod(하루/이번 주/이번 달/지난 7일/지난 30일) + WatchActionInfo에 optional 기간 필드 3종(구버전 캐시 호환 — isFavorite 패턴) - LocalPrefs.watchActions(uuid→기간 맵, 기기 로컬) + 전체 초기화 청소 목록 반영 - makeSnapshot: 옵트인 행동만 기간값 계산(rollingRange 재사용, StatSpan 무변경) - 신규 ActionValueComplication(4패밀리·측정 중 노랑+타이머·유령 타이머 안전장치· 빈 상태 안내·recommendations verbatim·widgetURL 딥링크) - 워치 미니 행동 화면(WatchActionDetailView) + NavigationPath 딥링크 착지 (뒤로가기 한 번에 메인 복귀), 행동 편집기 애플워치 섹션(토글+기간) - 검증 인자: -watchActions ·-actionShowEditor·-watchOpenActionDetail - 검증: Debug/Store/워치(26.2·11.5) 빌드, 자가 검증 49·20·27·10 ALL PASS(26.5+18.5 심), 시각 QA 5장. 신규 문구 l10n은 계획 §10 R6대로 도움말 단계에서 일괄 - CLAUDE.md: 시뮬레이터 세트 리셋 반영(신규 UUID)·검증 인자 추가 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6 --- myApp/HaruDanim/CLAUDE.md | 8 +- myApp/HaruDanim/Docs/plan-1.5.md | 2 +- .../ComplicationPreviewScreen.swift | 23 ++ .../ContentView.swift | 38 ++- .../WatchActionDetailView.swift | 121 +++++++ .../ActionValueComplication.swift | 296 ++++++++++++++++++ .../Haru_DanimWatchWidgetsBundle.swift | 1 + myApp/HaruDanim/IOS/ContentView.swift | 1 + myApp/HaruDanim/IOS/Core/DataReset.swift | 1 + myApp/HaruDanim/IOS/Core/DebugSeed.swift | 17 + .../HaruDanim/IOS/Core/WatchSyncManager.swift | 31 +- myApp/HaruDanim/IOS/Views/ActionViews.swift | 67 +++- myApp/HaruDanim/Shared/LocalPrefs.swift | 28 ++ .../HaruDanim/WatchShared/WatchPayload.swift | 24 ++ 14 files changed, 650 insertions(+), 8 deletions(-) create mode 100644 myApp/HaruDanim/Haru_DanimWatch Watch App/WatchActionDetailView.swift create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgets/ActionValueComplication.swift diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index d8e9a81..fa5d51e 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -61,7 +61,7 @@ xcodebuild -project Haru_Danim.xcodeproj -scheme Haru_Danim-Store \ -destination 'platform=iOS Simulator,id=' build ``` -- 자주 쓰는 시뮬레이터: iPhone `B1346141-3991-46BF-A12B-A66184FAEBC2`, iPad Pro 11(M5) `CC81FCE1-20D6-460F-9E90-BE949E7388FC`, **iOS 18.5 하위 OS QA용 iPhone 16 Pro `2B7756BE-E27A-4F7F-8692-232BC8435BE4`(1.4 하한 검증 — 하한 관련 수정 시 여기서도 확인)**, 워치 watchOS 11.5(최소 OS 근접 검증용) `686B7E3C-C685-409E-B624-B409E5F7D06D` / watchOS 26.2 `3B7C0B47-EBD0-41C9-8BC3-166AA35F80D4`. 마케팅 스크린샷용(Marketing/README): iPhone 17 Pro Max(6.9") `DB9D0281-123D-425D-9547-10CFF7946733`, iPad Pro 13"(M5) `39E5A4BE-9082-47AF-B6E4-1B9E8D271681`, 비페어링 워치 'MarketingWatch' `0A2CB37C-A755-4784-9D14-C7AB8FD72C3E`, 페어링 워치(iPhone B134와 페어) `8B57A70E-C0E1-43F2-BB4C-FEB05E82D741` +- 자주 쓰는 시뮬레이터(**2026-08-22 Xcode 업데이트로 시뮬레이터 세트 전체 리셋 — 이전 문서의 구 UUID는 전부 무효**): iPhone 17 Pro(iOS 26.5) `519869F7-1020-4EF2-904F-F077795F4C2F`(워치 26.2 심과 페어링됨), iPad Pro 11(M5, 26.5) `ECF4FAC4-A5AF-4F16-92C2-2CC430568E75`, **iOS 18.5 하위 OS QA용 iPhone 16 Pro `C23050D5-9F7B-404D-ACC8-E263FDA0A395`(하한 검증 — 하한 관련 수정 시 여기서도 확인)**, 워치 watchOS 11.5(최소 OS 근접 검증용, S10 46mm) `9BB1E881-F0C0-4040-AE14-6D4E34E1BE1D` / watchOS 26.2(S11 46mm, 아이폰 519869F7와 페어) `568C1EBB-51ED-4CE6-A6BC-CC2833E370A7`. 마케팅 스크린샷용 심(6.9" 아이폰·13" 아이패드·워치)은 리셋으로 소멸 — 1.5 스크린샷 단계에서 재생성 예정(절차는 Marketing/README) - ⚠️ 같은 프로젝트에 xcodebuild 2개를 **동시에** 돌리면 빌드 DB 경합으로 위장 실패(BUILD FAILED인데 에러 없음) — 항상 직렬로. `simctl install` 직후의 `launch`는 런치 인자가 무시될 수 있음 — terminate 후 `sleep 2` 두고 재실행 - 워치 UI 검증: 스킴 `Haru_DanimWatch Watch App` + `platform=watchOS Simulator` 대상으로 빌드·설치 후 `-complicationPreview` 인자로 실행(§14 워치) — 페어링 폰 없이 컴플리케이션 3종 미리보기가 렌더된다 - **SourceKit 인라인 진단은 항상 오탐 노이즈**("No such module 'UIKit'" 등) — 판정은 xcodebuild 결과만 신뢰 @@ -352,12 +352,12 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f |---|---| | 모음 | `-startEditing` `-expandGoalCard` `-pinGoals ` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` `-recordAddFor "이름"`(기록 직접 입력 시트) `-mainOthersCollapsed`('나머지 행동' 섹션 접힘 강제 — 표시 전용 오버라이드, 실 설정 무변경) | | 측정 알림 | `-settings.longSessionAlertHours `(설정 주입) `-longSessionAlertTestSeconds `(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) | -| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals `(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 49건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom·주기 몫 완료+목표 하루 평균+과거 기준 소급 차단] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) `-seedPeriodFulfilled YES`(주기 몫을 이미 채운 주간·월간 다짐 목표 1개 시드 — '이번 주/달 달성' 표기 검증용, 기본 시드와 분리·이름 비현지화. 기록은 주/달 첫날 09:00이라 실행일이 첫날이면 오늘 기록이 되지만 완료 표기는 동일) | +| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals `(생성순 목표 N개를 '애플워치에서 보기'로 설정, 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 — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays `이면 날짜 지정[오늘-N일까지], `-dataResetScope diary`면 '일기만' 선택으로 시작) `-dataResetTest`(초기화 자가 검증 27건[범위 삭제 경계·보존·cascade·전체 초기화·일기만] → Documents/data-reset-test.txt — 인메모리·설정 고정, 실데이터 무영향) `-symbolAuditDump`(아이콘 카탈로그 중 현재 OS에 없는 심볼 → Documents/symbol-audit.txt — 하위 OS QA, 1.4) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-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 — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays `이면 날짜 지정[오늘-N일까지], `-dataResetScope diary`면 '일기만' 선택으로 시작) `-dataResetTest`(초기화 자가 검증 27건[범위 삭제 경계·보존·cascade·전체 초기화·일기만] → Documents/data-reset-test.txt — 인메모리·설정 고정, 실데이터 무영향) `-symbolAuditDump`(아이콘 카탈로그 중 현재 OS에 없는 심볼 → Documents/symbol-audit.txt — 하위 OS QA, 1.4) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-actionShowEditor`(1.5 — 첫 행동의 편집 시트를 열고 애플워치 섹션까지 자동 스크롤, `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock|marketing`(marketing = 디버그 라벨 없는 위젯 실렌더 모아 보기 — 마케팅 스크린샷 05-widgets/06-widgets 촬영용, Marketing/README 참고) `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) | | 버블 | `-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용) | +| 워치 | `-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 6b38ebf..6daa015 100644 --- a/myApp/HaruDanim/Docs/plan-1.5.md +++ b/myApp/HaruDanim/Docs/plan-1.5.md @@ -26,7 +26,7 @@ 7. 후속 승인·추가(2026-08-22): **§8 세 건 전부 승인**(③ 표시 전용 상속 / 데이터 없는 기기 모수 제외+판정 보류 / 건강 다짐 v1 마감 시각 미지원). 추가 요구: **건강 데이터의 특성(하루 경계·수면 귀속 규칙·값 신선도·기기별 편차)을 도움말에 상세히 설명**하고, **건강 데이터를 보는 화면(모음 탭 타일 영역)에도 해당 안내 가이드 문구를 표기**할 것. 1.5 도움말 작업은 자연화(예약분)+신규 주제+이 안내를 한 묶음으로 진행. **안정성 최우선 지시**: 1.4 기능은 오류 없이 그대로 유지된 채 1.5가 얹혀야 함 — §10 위험 검토가 그 근거 문서 8. 2차 답변(2026-08-22): ①지표 8종 확정 + **운동 세부 종목 다짐 추가**(사용자 핵심 니즈 — §3.4) ②프리미엄 만료 처리=기존 원칙(만든 건 유지 — "돈 내고 쓴 기간에 한 건 인정") ③스크린샷=기존 전부 폐기, 아이폰·아이패드 각 10장 신규(스타일 동일·구성 일신 — §6) ④whats-new에 全기기 업데이트 완화 문구 ⑤**업데이트 신기능 소개 팝업 신설**(§11) ⑥CloudKit 스키마 배포=사용자 기억 없음 → 아리나가 구현 중 확인·준비하고 콘솔 작업 필요 시 시점·절차를 사용자에게 안내(§3.5). 이 반영은 "기록 용도 문서 수정만 허용(코드·프로젝트 파일 금지)" 상태에서 수행 -## 2. 기능 1 — 워치 행동 컴플리케이션 +## 2. 기능 1 — 워치 행동 컴플리케이션 ✅구현 완료(2026-08-22 — 빌드 4종·자가 검증 106건×2심·시각 QA[워치 26.2 실데이터/11.5 빈 상태·미니 화면 뒤로가기·편집기 섹션] 통과) ### 스펙 - **옵트인+기간 설정(아이폰)**: 행동 편집 화면에 '애플워치 컴플리케이션' 토글, 켜면 표시 기간 선택: **하루 / 이번 주 / 이번 달 / 지난 7일 / 지난 30일**. 저장은 기기 로컬 `LocalPrefs` 신설 키(uuid→기간 맵, `local.watchGoals`와 동일 계열 — 워치는 이 아이폰과 페어링이므로 기기 로컬이 자연스러움). diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift index 30d0976..1b9743a 100644 --- a/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift @@ -15,6 +15,7 @@ struct ComplicationPreviewScreen: View { @State private var status: StatusEntry? @State private var goalRate: GoalRateEntry? @State private var questRate: QuestRateEntry? + @State private var actionValue: ActionValueEntry? var body: some View { ScrollViewReader { proxy in @@ -49,12 +50,27 @@ struct ComplicationPreviewScreen: View { } .id("quest") } + if let actionValue { + label("행동 기록 (1.5)") + HStack(spacing: 8) { + circularBox { ActionValueComplicationView(previewFamily: .accessoryCircular, entry: actionValue) } + rectBox { ActionValueComplicationView(previewFamily: .accessoryRectangular, entry: actionValue) } + } + // 측정 중 상태 (고정 샘플 — 노란 시그널 + 타이머) + label("행동 기록 · 측정 중") + HStack(spacing: 8) { + circularBox { ActionValueComplicationView(previewFamily: .accessoryCircular, entry: runningActionSample) } + rectBox { ActionValueComplicationView(previewFamily: .accessoryRectangular, entry: runningActionSample) } + } + .id("actionValue") + } } } .onAppear { status = StatusProvider.makeEntry() goalRate = GoalRateProvider.makeEntry(GoalRateConfigIntent()) questRate = QuestRateProvider.makeEntry(QuestRateConfigIntent()) + actionValue = ActionValueProvider.makeEntry(ActionValueConfigIntent()) // -complicationScroll YES → 다짐 섹션까지 자동 스크롤 if UserDefaults.standard.bool(forKey: "complicationScroll") { DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { @@ -76,6 +92,13 @@ struct ComplicationPreviewScreen: View { runningStartAt: .now.addingTimeInterval(-3 * 3600), extraCount: 0, stale: true) } + private var runningActionSample: ActionValueEntry { + ActionValueEntry(date: .now, locked: false, empty: false, actionID: nil, + name: "독서", symbolName: "book.fill", colorHex: "#2F6B4F", + isCount: false, value: 47 * 60, periodLabel: "이번 주", + isRunning: true, tickingBase: .now.addingTimeInterval(-47 * 60)) + } + private func label(_ text: String) -> some View { Text(text) .font(.system(size: 11, weight: .semibold)) diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift index 67c0fcf..40307af 100644 --- a/myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift @@ -10,9 +10,15 @@ import SwiftUI struct ContentView: View { @State private var store = WatchStore.shared + /// 딥링크(컴플리케이션 탭)로 미니 행동 화면을 push하기 위한 경로. + /// 첫 화면 위에 쌓이므로 뒤로가기 한 번이면 메인으로 복귀한다 (1.5 사용자 요구) + @State private var path = NavigationPath() + #if DEBUG + @State private var didAutoOpenDetail = false + #endif var body: some View { - NavigationStack { + NavigationStack(path: $path) { Group { #if DEBUG if UserDefaults.standard.bool(forKey: "complicationPreview") { @@ -25,8 +31,38 @@ struct ContentView: View { #endif } .navigationTitle("하루 다님") + .navigationDestination(for: UUID.self) { id in + WatchActionDetailView(actionID: id) + } + } + .onOpenURL { url in + guard let id = Self.deepLinkActionID(url) else { return } + // 다른 화면에 들어가 있었어도 [메인 → 미니 화면] 상태로 착지 + path = NavigationPath() + path.append(id) } .onAppear { store.activate() } + #if DEBUG + // -watchOpenActionDetail YES: 스냅숏 수신 후 첫 옵트인(없으면 첫) 행동의 미니 화면을 + // 딥링크와 같은 경로로 push — 시뮬레이터에서 컴플리케이션 탭 착지를 검증하기 위한 훅 + .onChange(of: store.snapshot?.generatedAt) { + guard !didAutoOpenDetail, + UserDefaults.standard.bool(forKey: "watchOpenActionDetail"), + let snapshot = store.snapshot else { return } + let target = snapshot.actions.first { $0.complicationPeriodRaw != nil } ?? snapshot.actions.first + guard let target else { return } + didAutoOpenDetail = true + path.append(target.id) + } + #endif + } + + /// 컴플리케이션 widgetURL 딥링크에서 행동 id 추출 — harudanim://watch-action/ + static func deepLinkActionID(_ url: URL) -> UUID? { + guard url.scheme == "harudanim" else { return nil } + return [url.lastPathComponent, url.host ?? ""] + .compactMap(UUID.init(uuidString:)) + .first } @ViewBuilder diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchActionDetailView.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchActionDetailView.swift new file mode 100644 index 0000000..f157e3a --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchActionDetailView.swift @@ -0,0 +1,121 @@ +// +// WatchActionDetailView.swift +// Haru_DanimWatch Watch App +// +// 미니 행동 화면 (1.5 — Docs/plan-1.5.md §2) +// '행동 기록' 컴플리케이션 탭의 딥링크 착지 지점. +// - 워치 앱 첫 화면 위에 push되어 뒤로가기 한 번이면 메인으로 복귀 (사용자 요구) +// - 자동 실행 없음: 큼직한 버튼을 눌러야 실행 (손목 오탭 방지 — 명시적 탭 문법) +// + +import SwiftUI + +struct WatchActionDetailView: View { + @State private var store = WatchStore.shared + let actionID: UUID + + /// 최신 스냅숏 기준의 행동 (실행 직후 응답 스냅숏으로 자동 갱신) + private var action: WatchActionInfo? { + store.snapshot?.actions.first { $0.id == actionID } + } + + var body: some View { + Group { + if let action { + detail(action) + } else { + notFound + } + } + .navigationBarTitleDisplayMode(.inline) + } + + private func detail(_ action: WatchActionInfo) -> some View { + ScrollView { + VStack(spacing: 10) { + Image(safeSymbol: action.symbolName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background( + Color(watchHex: action.colorHex), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + Text(action.name) + .font(.headline) + .multilineTextAlignment(.center) + .lineLimit(2) + valueBlock(action) + Button { + store.run(action) + } label: { + Label(buttonTitle(action), systemImage: buttonSymbol(action)) + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(action.isRunning && !action.isCount ? .yellow : Color(watchHex: action.colorHex)) + if store.lastSendFailed { + Text("iPhone에 연결할 수 없어요. 근처에 있는지 확인해 주세요.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 4) + } + } + + /// 설정 기간(컴플리케이션 옵트인)이 있으면 그 기간, 없으면 오늘 기준 누적 + @ViewBuilder + private func valueBlock(_ action: WatchActionInfo) -> some View { + let period = action.complicationPeriodRaw.flatMap(WatchActionPeriod.init(rawValue:)) + VStack(spacing: 1) { + Text(period?.label ?? String(localized: "오늘")) + .font(.caption2) + .foregroundStyle(.secondary) + if action.isCount { + Text("\(Int(period != nil ? (action.periodValue ?? 0) : action.todayValue))회") + .font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit()) + } else if action.isRunning, + let base = (period != nil ? action.periodTickingBase : action.tickingBase) + ?? action.tickingBase { + Text(base, style: .timer) + .font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit()) + .foregroundStyle(.yellow) + } else { + Text(WatchFormat.duration(period != nil ? (action.periodValue ?? 0) : action.todayValue)) + .font(.system(.title3, design: .rounded).weight(.bold).monospacedDigit()) + } + if action.isRunning, !action.isCount { + Label("측정 중", systemImage: "record.circle") + .font(.caption2) + .foregroundStyle(.yellow) + } + } + } + + private var notFound: some View { + VStack(spacing: 6) { + Image(systemName: "questionmark.circle") + .font(.title3) + .foregroundStyle(.secondary) + Text("행동을 찾을 수 없어요") + .font(.headline) + Text("iPhone에서 삭제되었거나 아직 연결되지 않았어요") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + + private func buttonTitle(_ action: WatchActionInfo) -> String { + if action.isCount { return String(localized: "1회 추가") } + return action.isRunning ? String(localized: "측정 종료") : String(localized: "측정 시작") + } + + private func buttonSymbol(_ action: WatchActionInfo) -> String { + if action.isCount { return "plus" } + return action.isRunning ? "stop.fill" : "play.fill" + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/ActionValueComplication.swift b/myApp/HaruDanim/Haru_DanimWatchWidgets/ActionValueComplication.swift new file mode 100644 index 0000000..7c209cc --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/ActionValueComplication.swift @@ -0,0 +1,296 @@ +// +// ActionValueComplication.swift +// Haru_DanimWatchWidgetsExtension +// +// 행동 기록 컴플리케이션 (1.5 — CLAUDE.md §9.2, Docs/plan-1.5.md §2) +// - 아이폰 행동 편집에서 옵트인한 행동의 "설정 기간 누적값"(하루/이번 주/이번 달/지난 7일/지난 30일)을 표시 +// - 측정 중이면 노란 시그널 + 실시간 타이머 (기간 누적 기준) +// - 탭하면 widgetURL 딥링크로 워치 앱의 미니 행동 화면에 착지 (자동 실행 없음 — 오탭 방지) +// - 기간은 컴플리케이션 파라미터가 아니라 아이폰 설정이 결정한다 (스냅숏의 complicationPeriodRaw) +// + +import AppIntents +import SwiftUI +import WidgetKit + +// MARK: - 행동 엔티티 (캐시된 스냅숏의 옵트인 행동) + +struct WatchActionEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "행동") + static let defaultQuery = WatchActionEntityQuery() + + let id: UUID + let title: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(title)") + } +} + +struct WatchActionEntityQuery: EntityQuery { + private func all() -> [WatchActionEntity] { + (ComplicationStore.snapshot()?.actions ?? []) + .filter { $0.complicationPeriodRaw != nil } + .map { WatchActionEntity(id: $0.id, title: $0.name) } + } + + func entities(for identifiers: [UUID]) async throws -> [WatchActionEntity] { + all().filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [WatchActionEntity] { + all() + } +} + +// MARK: - 구성 인텐트 + +struct ActionValueConfigIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "행동 기록" + static let description = IntentDescription("표시할 행동을 선택하세요. 표시 기간은 iPhone 행동 편집에서 지정해요.") + + @Parameter(title: "행동") + var action: WatchActionEntity? +} + +// MARK: - 타임라인 + +struct ActionValueEntry: TimelineEntry { + let date: Date + let locked: Bool + /// 표시할 옵트인 행동이 하나도 없음 (빈 상태 안내) + let empty: Bool + let actionID: UUID? + let name: String + let symbolName: String + let colorHex: String + let isCount: Bool + /// 설정 기간 누적값 (시간형 초 / 횟수형 회) + let value: Double + let periodLabel: String + let isRunning: Bool + /// 측정 중일 때 기간 누적 실시간 타이머 기준 (스냅숏이 오래됐으면 nil — 유령 타이머 방지) + let tickingBase: Date? +} + +struct ActionValueProvider: AppIntentTimelineProvider { + static func makeEntry(_ configuration: ActionValueConfigIntent) -> ActionValueEntry { + func blank(locked: Bool, empty: Bool) -> ActionValueEntry { + ActionValueEntry(date: .now, locked: locked, empty: empty, actionID: nil, + name: "", symbolName: "figure.walk", colorHex: "#2F6B4F", + isCount: true, value: 0, periodLabel: "", isRunning: false, tickingBase: nil) + } + guard let snapshot = ComplicationStore.snapshot() else { return blank(locked: false, empty: true) } + guard snapshot.isPremium else { return blank(locked: true, empty: false) } + let candidates = snapshot.actions.filter { $0.complicationPeriodRaw != nil } + guard let action = candidates.first(where: { $0.id == configuration.action?.id }) ?? candidates.first + else { return blank(locked: false, empty: true) } + + // 유령 타이머 안전장치: 종료 푸시가 유실된 오래된 스냅숏의 '측정 중'은 + // 무한 타이머 대신 정적 값으로 강등 (현재 현황 컴플리케이션과 같은 한도) + let stale = Date.now.timeIntervalSince(snapshot.generatedAt) > StatusProvider.runningTrustInterval + let running = action.isRunning && !stale + let period = action.complicationPeriodRaw.flatMap(WatchActionPeriod.init(rawValue:)) ?? .day + return ActionValueEntry( + date: .now, + locked: false, + empty: false, + actionID: action.id, + name: action.name, + symbolName: action.symbolName, + colorHex: action.colorHex, + isCount: action.isCount, + value: action.periodValue ?? action.todayValue, + periodLabel: period.label, + isRunning: running, + tickingBase: running && !action.isCount + ? (action.periodTickingBase ?? action.tickingBase) : nil + ) + } + + func placeholder(in context: Context) -> ActionValueEntry { + ActionValueEntry(date: .now, locked: false, empty: false, actionID: nil, + name: String(localized: "독서"), symbolName: "book.fill", colorHex: "#2F6B4F", + isCount: false, value: 47 * 60, periodLabel: String(localized: "하루"), + isRunning: false, tickingBase: nil) + } + + func snapshot(for configuration: ActionValueConfigIntent, in context: Context) async -> ActionValueEntry { + Self.makeEntry(configuration) + } + + func timeline(for configuration: ActionValueConfigIntent, in context: Context) async -> Timeline { + Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60))) + } + + /// 워치 페이스 갤러리에 노출할 추천 구성 — 옵트인 행동 전부 (상한 금지, §11 규칙). + /// 기간은 아이폰 설정이 결정하므로 행동당 1개만 나열한다. + /// 주의: description에 문자열 보간 금지 — Text(verbatim:)만 (GoalRate 쪽 주석 참고) + func recommendations() -> [AppIntentRecommendation] { + let cached = (ComplicationStore.snapshot()?.actions ?? []) + .filter { $0.complicationPeriodRaw != nil } + // 스냅숏이 아직 없으면 자리 표시용 엔티티로 추천 (표시 시점엔 첫 옵트인 행동으로 대체됨) + if cached.isEmpty { + let intent = ActionValueConfigIntent() + intent.action = WatchActionEntity(id: UUID(), title: String(localized: "행동")) + return [AppIntentRecommendation(intent: intent, description: Text(verbatim: String(localized: "행동 기록")))] + } + return cached.map { action in + let period = action.complicationPeriodRaw.flatMap(WatchActionPeriod.init(rawValue:)) ?? .day + let intent = ActionValueConfigIntent() + intent.action = WatchActionEntity(id: action.id, title: action.name) + return AppIntentRecommendation( + intent: intent, + description: Text(verbatim: "\(action.name) · \(period.label)") + ) + } + } +} + +// MARK: - 값 포맷 (위젯 확장 전용 — 워치 앱의 WatchFormat과 별개 타깃이라 여기 둔다) + +nonisolated enum ActionValueFormat { + /// 원형용 압축 시간: 1시간 미만 "47분", 이상 "1:12" (숫자 표기라 언어 무관) + static func compactDuration(_ seconds: Double) -> String { + let total = Int(seconds.rounded()) + let h = total / 3600 + let m = (total % 3600) / 60 + if h > 0 { + let mm = m < 10 ? "0\(m)" : "\(m)" + return "\(h):\(mm)" + } + return String(localized: "\(m)분") + } + + /// 넉넉한 표기 (rectangular·inline): "1시간 12분" / "47분" / "0분" + static func duration(_ seconds: Double) -> String { + let total = Int(seconds.rounded()) + let h = total / 3600 + let m = (total % 3600) / 60 + if h > 0 { + return m > 0 + ? String(localized: "\(h)시간 \(m)분") + : String(localized: "\(h)시간") + } + return String(localized: "\(m)분") + } +} + +// MARK: - 뷰 + +struct ActionValueComplicationView: View { + @Environment(\.widgetFamily) private var envFamily + /// 워치 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용 + var previewFamily: WidgetFamily? = nil + private var family: WidgetFamily { previewFamily ?? envFamily } + let entry: ActionValueEntry + + private var staticValueText: String { + entry.isCount + ? String(localized: "\(Int(entry.value))회") + : ActionValueFormat.duration(entry.value) + } + + var body: some View { + Group { + if entry.locked { + ComplicationLockedView() + } else if entry.empty { + // 옵트인(기본 전부 꺼짐) — rectangular에서는 해결 방법을 함께 안내 (목표 쪽과 동일 문법) + ComplicationEmptyView(message: family == .accessoryRectangular + ? String(localized: "표시할 행동이 없어요 — iPhone 행동 편집에서 '애플워치 컴플리케이션'을 켜면 나타나요") + : String(localized: "행동 없음")) + } else { + content + } + } + .containerBackground(.clear, for: .widget) + // 탭 = 워치 앱의 미니 행동 화면으로 착지 (자동 실행 없음) + .widgetURL(entry.actionID.flatMap { URL(string: "harudanim://watch-action/\($0.uuidString)") }) + } + + @ViewBuilder + private var content: some View { + switch family { + case .accessoryInline: + if entry.isRunning, let base = entry.tickingBase { + Text("\(entry.name) \(Text(base, style: .timer))") + } else { + Text("\(entry.name) \(staticValueText)") + } + case .accessoryRectangular: + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Image(safeSymbol: entry.symbolName) + .font(.system(size: 11)) + .foregroundStyle(entry.isRunning ? .yellow : .primary) + Text(entry.name) + .font(.headline) + .lineLimit(1) + if entry.isRunning { + Image(systemName: "record.circle") + .font(.system(size: 9)) + .foregroundStyle(.yellow) + } + } + if entry.isRunning, let base = entry.tickingBase { + Text(base, style: .timer) + .font(.system(.body, design: .rounded).monospacedDigit()) + } else { + Text(staticValueText) + .font(.system(.body, design: .rounded).weight(.semibold).monospacedDigit()) + } + Text(entry.periodLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } + case .accessoryCorner: + Image(safeSymbol: entry.symbolName) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(entry.isRunning ? .yellow : .primary) + .widgetLabel { + if entry.isRunning, let base = entry.tickingBase { + Text(base, style: .timer) + } else { + Text(verbatim: "\(entry.name) \(entry.isCount ? staticValueText : ActionValueFormat.compactDuration(entry.value))") + } + } + default: // accessoryCircular + VStack(spacing: 1) { + Image(safeSymbol: entry.symbolName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(entry.isRunning ? .yellow : .primary) + if entry.isRunning, let base = entry.tickingBase { + Text(base, style: .timer) + .font(.system(size: 11).monospacedDigit()) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.7) + } else if entry.isCount { + Text("\(Int(entry.value))") + .font(.system(size: 15, weight: .bold).monospacedDigit()) + } else { + Text(ActionValueFormat.compactDuration(entry.value)) + .font(.system(size: 11, weight: .semibold).monospacedDigit()) + .minimumScaleFactor(0.7) + } + } + } + } +} + +// MARK: - 위젯 정의 + +struct ActionValueComplication: Widget { + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: "HaruWatchActionValue", + intent: ActionValueConfigIntent.self, + provider: ActionValueProvider() + ) { entry in + ActionValueComplicationView(entry: entry) + } + .configurationDisplayName("행동 기록") + .description("선택한 행동의 기간 누적 기록을 표시해요. 기간은 iPhone 행동 편집에서 지정해요. (프리미엄)") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner]) + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsBundle.swift b/myApp/HaruDanim/Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsBundle.swift index 570e8c4..ee7a35a 100644 --- a/myApp/HaruDanim/Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsBundle.swift +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsBundle.swift @@ -14,5 +14,6 @@ struct Haru_DanimWatchWidgetsBundle: WidgetBundle { StatusComplication() GoalRateComplication() QuestRateComplication() + ActionValueComplication() } } diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index 8a53c13..81bd70a 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -68,6 +68,7 @@ struct ContentView: View { DebugSeed.autoStartIfRequested(context: context) DebugSeed.pinGoalsIfRequested(context: context) DebugSeed.watchGoalsIfRequested(context: context) + DebugSeed.watchActionsIfRequested(context: context) DebugSeed.endGoalYesterdayIfRequested(context: context) DebugSeed.dumpStreaksIfRequested(context: context) DebugSeed.dumpMissingSymbolsIfRequested() diff --git a/myApp/HaruDanim/IOS/Core/DataReset.swift b/myApp/HaruDanim/IOS/Core/DataReset.swift index b8e363b..4048264 100644 --- a/myApp/HaruDanim/IOS/Core/DataReset.swift +++ b/myApp/HaruDanim/IOS/Core/DataReset.swift @@ -124,6 +124,7 @@ enum DataReset { LocalPrefsKeys.actionOrder, LocalPrefsKeys.pinnedGoals, LocalPrefsKeys.collapsedGoals, LocalPrefsKeys.notePromptActions, LocalPrefsKeys.watchGoals, LocalPrefsKeys.mainOthersCollapsed, + LocalPrefsKeys.watchActions, ] for key in keys { LocalPrefs.defaults.removeObject(forKey: key) } } diff --git a/myApp/HaruDanim/IOS/Core/DebugSeed.swift b/myApp/HaruDanim/IOS/Core/DebugSeed.swift index 4257386..8fbdda5 100644 --- a/myApp/HaruDanim/IOS/Core/DebugSeed.swift +++ b/myApp/HaruDanim/IOS/Core/DebugSeed.swift @@ -511,6 +511,23 @@ enum DebugSeed { WatchSyncManager.shared.pushSnapshot() } + /// `-watchActions ` 런치 인자: 배치순 행동 N개를 '행동 기록' 컴플리케이션에 올린다 (1.5) + /// (0이면 전체 해제 — 표시 기간은 하루→이번 주→이번 달→지난 7일→지난 30일 순환으로 + /// 다양하게 지정해 렌더 검증에 쓴다. 설정 후 스냅숏을 즉시 푸시) + static func watchActionsIfRequested(context: ModelContext) { + guard UserDefaults.standard.object(forKey: "watchActions") != nil else { return } + let n = UserDefaults.standard.integer(forKey: "watchActions") + let actions = LocalPrefs.orderedActions( + (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]))) ?? [] + ) + LocalPrefs.defaults.removeObject(forKey: LocalPrefsKeys.watchActions) + let periods = WatchActionPeriod.allCases + for (index, action) in actions.prefix(n).enumerated() { + LocalPrefs.setWatchActionPeriod(action.uuid, raw: periods[index % periods.count].rawValue) + } + WatchSyncManager.shared.pushSnapshot() + } + /// `-pinGoals ` 런치 인자: 생성순으로 목표 N개를 모음 탭에 표시 (다중 목표 카드 검증용) static func pinGoalsIfRequested(context: ModelContext) { let n = UserDefaults.standard.integer(forKey: "pinGoals") diff --git a/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift b/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift index 94c9cf9..8d536dd 100644 --- a/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift +++ b/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift @@ -50,10 +50,13 @@ final class WatchSyncManager: NSObject { let actions = LocalPrefs.orderedActions( (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]))) ?? [] ) + // '행동 기록' 컴플리케이션에 옵트인한 행동만 설정 기간의 누적값을 함께 계산한다 (1.5) + // — 옵트인이 없으면 기간 집계 비용 0 (기존 스냅숏과 동일) + let complicationPeriods = LocalPrefs.watchActionPeriods() snapshot.actions = actions.map { action in let todayValue = agg.todayValue(for: action, now: now) let isCount = action.trackingType == .count - return WatchActionInfo( + var info = WatchActionInfo( id: action.uuid, name: action.name, symbolName: action.symbolName, @@ -65,6 +68,19 @@ final class WatchSyncManager: NSObject { tagIDs: action.sortedTags.map(\.uuid), isFavorite: action.isFavorite ) + if let raw = complicationPeriods[action.uuid], + let period = WatchActionPeriod(rawValue: raw) { + let range = Self.periodRange(period, math: math, now: now) + let value = isCount + ? Double(agg.count(for: action, in: range)) + : agg.seconds(for: action, in: range, now: now) + info.complicationPeriodRaw = raw + info.periodValue = value + if info.isRunning && !isCount { + info.periodTickingBase = now.addingTimeInterval(-value) + } + } + return info } // 컴플리케이션(목표·다짐 달성률)에는 진행 중이면서 '애플워치에서 보기'를 켠 목표만 보낸다. @@ -128,6 +144,19 @@ final class WatchSyncManager: NSObject { return snapshot } + /// '행동 기록' 컴플리케이션 표시 기간의 집계 범위 (1.5). + /// 롤링(지난 7일/30일)은 통계 '오늘 기준' 보기(1.4)의 rollingRange 재사용 — + /// 수치는 통계 탭과 동일해야 하므로 반드시 DayMath 경유 + private static func periodRange(_ period: WatchActionPeriod, math: DayMath, now: Date) -> Range { + switch period { + case .day: return math.dayRange(containing: now) + case .week: return math.weekRange(containing: now) + case .month: return math.monthRange(containing: now) + case .last7: return math.rollingRange(endingAtKey: math.dayKey(for: now), days: 7) + case .last30: return math.rollingRange(endingAtKey: math.dayKey(for: now), days: 30) + } + } + /// 컴플리케이션 전용 전송의 최근 상태 (하루 전송 예산이 있어 절약해서 보냄) private var lastComplicationStateKey = "" private var lastComplicationPushAt = Date.distantPast diff --git a/myApp/HaruDanim/IOS/Views/ActionViews.swift b/myApp/HaruDanim/IOS/Views/ActionViews.swift index 6c6a1e2..fd4be7b 100644 --- a/myApp/HaruDanim/IOS/Views/ActionViews.swift +++ b/myApp/HaruDanim/IOS/Views/ActionViews.swift @@ -27,6 +27,14 @@ struct ActionListView: View { #endif return false }() + @State private var showingFirstEditor: Bool = { + #if DEBUG + // 검증용: -actionShowEditor YES → 첫 행동의 편집 시트 바로 표시 + // (1.5 애플워치 컴플리케이션 섹션 렌더 확인용) + if UserDefaults.standard.bool(forKey: "actionShowEditor") { return true } + #endif + return false + }() // 행동 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고) @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" @@ -131,6 +139,11 @@ struct ActionListView: View { .sheet(isPresented: $showingFavoriteOrder) { FavoriteOrderSheet() } + .sheet(isPresented: $showingFirstEditor) { + if let first = actions.first { + ActionEditorView(action: first) + } + } .alert("무료 사용 한도", isPresented: $showLimitAlert) { Button("프리미엄 알아보기") { showingPremiumSheet = true } Button("확인", role: .cancel) {} @@ -446,12 +459,16 @@ struct ActionEditorView: View { @State private var symbolName = "star.fill" @State private var trackingType: TrackingType = .time @State private var promptsForNote = false + /// 애플워치 '행동 기록' 컴플리케이션 노출 여부 + 표시 기간 — 기기 로컬 설정(LocalPrefs, 1.5) + @State private var showsComplication = false + @State private var complicationPeriod: WatchActionPeriod = .day @State private var selectedTags: Set = [] @State private var showingSymbolPicker = false @State private var showingNewTag = false var body: some View { NavigationStack { + ScrollViewReader { editorScrollProxy in Form { Section("이름") { TextField("행동 이름 (예: 독서)", text: $name) @@ -537,7 +554,43 @@ struct ActionEditorView: View { ? String(localized: "켜면 시간 측정을 종료할 때 메모 창이 떠요. 건너뛸 수 있어요.") : String(localized: "켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요.")) } + // 워치는 iPhone과 페어링되고 이 설정은 기기 로컬이라 iPad에서는 의미가 없다 + // (목표 편집의 '애플워치에서 보기'와 동일 문법 — 1.5) + if !DeviceLayout.isPad { + Section { + Toggle(isOn: $showsComplication.animation()) { + Label { + Text("애플워치 컴플리케이션") + } icon: { + Image(systemName: "applewatch") + .foregroundStyle(AppTheme.green) + } + } + if showsComplication { + Picker("표시 기간", selection: $complicationPeriod) { + ForEach(WatchActionPeriod.allCases, id: \.self) { period in + Text(period.label).tag(period) + } + } + } + } header: { + Text("애플워치") + } footer: { + Text("켜면 워치 페이스의 '행동 기록' 컴플리케이션에 이 행동을 올릴 수 있어요. 선택한 기간의 누적 기록이 표시되고, 컴플리케이션을 누르면 워치 앱에서 바로 실행할 수 있어요. 이 설정은 워치와 연결된 이 아이폰에만 저장돼요.") + } + .id("watchSection") + } } + #if DEBUG + // -actionShowEditor YES 검증에서 애플워치 섹션까지 자동 스크롤 + .onAppear { + if UserDefaults.standard.bool(forKey: "actionShowEditor") { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation { editorScrollProxy.scrollTo("watchSection", anchor: .bottom) } + } + } + } + #endif .navigationTitle(action == nil ? String(localized: "행동 추가") : String(localized: "행동 수정")) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -556,6 +609,7 @@ struct ActionEditorView: View { TagEditorView(tag: nil) } .onAppear(perform: load) + } } } @@ -565,6 +619,11 @@ struct ActionEditorView: View { symbolName = action.symbolName trackingType = action.trackingType promptsForNote = LocalPrefs.promptsForNote(action.uuid) + if let raw = LocalPrefs.watchActionPeriodRaw(action.uuid), + let period = WatchActionPeriod(rawValue: raw) { + showsComplication = true + complicationPeriod = period + } selectedTags = Set(action.tags.map(\.persistentModelID)) } @@ -585,6 +644,9 @@ struct ActionEditorView: View { action.trackingType = trackingType action.tags = tags LocalPrefs.setPromptsForNote(action.uuid, enabled: promptsForNote) + LocalPrefs.setWatchActionPeriod( + action.uuid, raw: showsComplication ? complicationPeriod.rawValue : nil + ) } else { // sortOrder는 로컬 배치 순서가 없는 기기(첫 실행·다른 기기)에서의 폴백 정렬용으로만 기록 let maxOrder = allActions.map(\.sortOrder).max() ?? -1 @@ -596,9 +658,12 @@ struct ActionEditorView: View { ) newAction.tags = tags context.insert(newAction) - // 이 기기의 로컬 배치 맨 끝에 추가 + 메모 창 설정 저장 + // 이 기기의 로컬 배치 맨 끝에 추가 + 메모 창·워치 컴플리케이션 설정 저장 LocalPrefs.appendActionToOrder(newAction.uuid) LocalPrefs.setPromptsForNote(newAction.uuid, enabled: promptsForNote) + LocalPrefs.setWatchActionPeriod( + newAction.uuid, raw: showsComplication ? complicationPeriod.rawValue : nil + ) } DataChange.commit(context: context) dismiss() diff --git a/myApp/HaruDanim/Shared/LocalPrefs.swift b/myApp/HaruDanim/Shared/LocalPrefs.swift index 085a9c8..6e2a4fd 100644 --- a/myApp/HaruDanim/Shared/LocalPrefs.swift +++ b/myApp/HaruDanim/Shared/LocalPrefs.swift @@ -33,6 +33,10 @@ nonisolated enum LocalPrefsKeys { static let watchGoals = "local.watchGoals" /// 모음 탭 '나머지 행동' 섹션(즐겨찾기가 있을 때만 생김) 접힘 여부 — Bool static let mainOthersCollapsed = "local.mainOthersCollapsed" + /// 애플워치 '행동 기록' 컴플리케이션에 노출할 행동과 표시 기간 (1.5) — + /// [Action.uuid 문자열: WatchActionPeriod raw] 딕셔너리. 기본 비어 있음(옵트인). + /// 워치는 이 아이폰과 페어링되므로 기기 로컬이 자연스러움 (watchGoals와 동일 계열) + static let watchActions = "local.watchActions" } nonisolated enum LocalPrefs { @@ -164,6 +168,30 @@ nonisolated enum LocalPrefs { defaults.set(toggling(id, in: raw), forKey: LocalPrefsKeys.watchGoals) } + // MARK: 애플워치 '행동 기록' 컴플리케이션 (1.5 — 옵트인 + 표시 기간, 스냅숏 생성 시점에 뷰 밖에서도 읽음) + + /// 옵트인한 전체 행동의 [uuid: 기간 raw] 맵 (스냅숏 생성용) + static func watchActionPeriods() -> [UUID: String] { + let raw = (defaults.dictionary(forKey: LocalPrefsKeys.watchActions) as? [String: String]) ?? [:] + var result: [UUID: String] = [:] + for (key, value) in raw { + if let id = UUID(uuidString: key) { result[id] = value } + } + return result + } + + static func watchActionPeriodRaw(_ id: UUID) -> String? { + (defaults.dictionary(forKey: LocalPrefsKeys.watchActions) as? [String: String])?[id.uuidString] + } + + /// raw가 nil이면 옵트아웃(항목 제거). 값이 같으면 무동작 + static func setWatchActionPeriod(_ id: UUID, raw: String?) { + var dict = (defaults.dictionary(forKey: LocalPrefsKeys.watchActions) as? [String: String]) ?? [:] + guard dict[id.uuidString] != raw else { return } + dict[id.uuidString] = raw + defaults.set(dict, forKey: LocalPrefsKeys.watchActions) + } + // MARK: 구버전 @Model 속성 값 1회 이관 /// 각 키가 아직 없을 때만, 지금까지 모델에 저장돼 있던 값으로 채운다 (앱 시작 시 호출). diff --git a/myApp/HaruDanim/WatchShared/WatchPayload.swift b/myApp/HaruDanim/WatchShared/WatchPayload.swift index 2189b8f..98cdac7 100644 --- a/myApp/HaruDanim/WatchShared/WatchPayload.swift +++ b/myApp/HaruDanim/WatchShared/WatchPayload.swift @@ -84,6 +84,30 @@ nonisolated struct WatchActionInfo: Codable, Identifiable, Hashable { /// 앱에서 즐겨찾기로 등록한 행동 — 워치 첫 화면의 즐겨찾기 목록에 모인다. /// optional인 이유: 구버전 워치에 캐시된 스냅숏(키 없음)도 디코딩되게 하기 위함 var isFavorite: Bool? = nil + /// '행동 기록' 컴플리케이션 표시 기간 raw (1.5) — 아이폰 행동 편집에서 옵트인한 행동만 채워짐. + /// nil이면 컴플리케이션 비대상. optional인 이유: 구버전 캐시 호환 (isFavorite와 동일 패턴) + var complicationPeriodRaw: String? = nil + /// 옵트인 행동의 설정 기간 누적값 — 시간형은 초, 횟수형은 회 (스냅숏 시점 기준) + var periodValue: Double? = nil + /// 시간형이 측정 중일 때 기간 누적 실시간 타이머 기준 시각 (스냅숏 시점 - 기간 누적초) + var periodTickingBase: Date? = nil +} + +/// '행동 기록' 컴플리케이션의 표시 기간 (1.5 — 아이폰 행동 편집에서 지정, 기기 로컬 LocalPrefs). +/// ⚠️ 다짐 진행률의 StatSpan과 별개 타입 — StatSpan에는 케이스를 추가하지 않는다 (§6.6). +/// 롤링(last7/last30)은 통계 '오늘 기준' 보기(1.4)와 같은 개념: 오늘로 끝나는 최근 N일 창. +nonisolated enum WatchActionPeriod: String, Codable, CaseIterable { + case day, week, month, last7, last30 + + var label: String { + switch self { + case .day: return String(localized: "하루") + case .week: return String(localized: "이번 주") + case .month: return String(localized: "이번 달") + case .last7: return String(localized: "지난 7일") + case .last30: return String(localized: "지난 30일") + } + } } nonisolated struct WatchGoalInfo: Codable, Identifiable, Hashable {