feat(1.5-p1): 워치 '행동 기록' 컴플리케이션 — 기간 옵트인·미니 행동 화면 딥링크
- WatchActionPeriod(하루/이번 주/이번 달/지난 7일/지난 30일) + WatchActionInfo에 optional 기간 필드 3종(구버전 캐시 호환 — isFavorite 패턴) - LocalPrefs.watchActions(uuid→기간 맵, 기기 로컬) + 전체 초기화 청소 목록 반영 - makeSnapshot: 옵트인 행동만 기간값 계산(rollingRange 재사용, StatSpan 무변경) - 신규 ActionValueComplication(4패밀리·측정 중 노랑+타이머·유령 타이머 안전장치· 빈 상태 안내·recommendations verbatim·widgetURL 딥링크) - 워치 미니 행동 화면(WatchActionDetailView) + NavigationPath 딥링크 착지 (뒤로가기 한 번에 메인 복귀), 행동 편집기 애플워치 섹션(토글+기간) - 검증 인자: -watchActions <N>·-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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
This commit is contained in:
parent
2e181dd1e2
commit
92afa69f6d
@ -61,7 +61,7 @@ xcodebuild -project Haru_Danim.xcodeproj -scheme Haru_Danim-Store \
|
||||
-destination 'platform=iOS Simulator,id=<SIM_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 <N>` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` `-recordAddFor "이름"`(기록 직접 입력 시트) `-mainOthersCollapsed`('나머지 행동' 섹션 접힘 강제 — 표시 전용 오버라이드, 실 설정 무변경) |
|
||||
| 측정 알림 | `-settings.longSessionAlertHours <N>`(설정 주입) `-longSessionAlertTestSeconds <N>`(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) |
|
||||
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals <N>`(생성순 목표 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>`(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-watchActions <N>`(1.5 — 배치순 행동 N개를 '행동 기록' 컴플리케이션에 올림. 표시 기간은 하루→이번 주→이번 달→지난 7일→지난 30일 순환, 스냅숏 즉시 푸시) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 49건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom·주기 몫 완료+목표 하루 평균+과거 기준 소급 차단] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) `-seedPeriodFulfilled YES`(주기 몫을 이미 채운 주간·월간 다짐 목표 1개 시드 — '이번 주/달 달성' 표기 검증용, 기본 시드와 분리·이름 비현지화. 기록은 주/달 첫날 09:00이라 실행일이 첫날이면 오늘 기록이 되지만 완료 표기는 동일) |
|
||||
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-historyDaysAgo <N>`(N일 전 날짜로 시작 — 지난주 등 과거 주 표시, 마케팅 촬영용) `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statRolling`('오늘 기준' 롤링 토글 켠 상태로 시작 — 1.4) `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor <session|count>`(최신 기록 수정 시트) |
|
||||
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo <help-main 등>`: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays <N>`이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays <N>`이면 날짜 지정[오늘-N일까지], `-dataResetScope diary`면 '일기만' 선택으로 시작) `-dataResetTest`(초기화 자가 검증 27건[범위 삭제 경계·보존·cascade·전체 초기화·일기만] → Documents/data-reset-test.txt — 인메모리·설정 고정, 실데이터 무영향) `-symbolAuditDump`(아이콘 카탈로그 중 현재 OS에 없는 심볼 → Documents/symbol-audit.txt — 하위 OS QA, 1.4) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock|marketing`(marketing = 디버그 라벨 없는 위젯 실렌더 모아 보기 — 마케팅 스크린샷 05-widgets/06-widgets 촬영용, Marketing/README 참고) `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
|
||||
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo <help-main 등>`: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays <N>`이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays <N>`이면 날짜 지정[오늘-N일까지], `-dataResetScope diary`면 '일기만' 선택으로 시작) `-dataResetTest`(초기화 자가 검증 27건[범위 삭제 경계·보존·cascade·전체 초기화·일기만] → Documents/data-reset-test.txt — 인메모리·설정 고정, 실데이터 무영향) `-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 <N>` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diaryShowDefaults`(기본 표시 설정 시트) `-diaryDefaultsSeed`(첫 목표·첫 행동 기본 숨김+캘린더 기본 '모든 캘린더' 주입 — 기본값 적용 검증용) `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryLockScreen`(잠금 게이트 화면 강제 — 인증 없이 표시) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) `-diaryShapeEdit`(활성 노트 페이지 첫 도형 선택+'도형 수정' 시트 — `-diarySeed`·`-diaryOpenToday`·`-diaryPage 1`과 함께. 시드 요소들이 스타일 필드[굵기·둥글기·채움·자유 비율·회전·글자 크기·정렬·테두리 색]를 전부 렌더) `-diaryTextEdit`(첫 텍스트 상자를 제자리 입력 상태로 — 같은 조합) `-diaryStrokeTest`(3초 뒤 프로그램 획 추가로 디바운스 저장 파이프라인 검증 → Documents/diary-stroke-test.txt[strokes·delivered] — `-diaryOpenToday`와 함께, 1.4) `-diaryExportDeleteTest`(내보내기 완료 후 '삭제' 버튼과 같은 가드·경로로 삭제 실행 — `-diaryShowExport -diaryExportRun`과 함께, 재실행 후 달력 마커 소멸로 판정, 1.4) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |
|
||||
| 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo <stale\|quest>` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) |
|
||||
| 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo <stale\|quest\|actionValue>` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) `-watchOpenActionDetail`(1.5 — 스냅숏 수신 후 첫 옵트인 행동의 미니 행동 화면을 딥링크와 같은 경로로 push, 착지·뒤로가기 검증) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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`와 동일 계열 — 워치는 이 아이폰과 페어링이므로 기기 로컬이 자연스러움).
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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/<uuid>
|
||||
static func deepLinkActionID(_ url: URL) -> UUID? {
|
||||
guard url.scheme == "harudanim" else { return nil }
|
||||
return [url.lastPathComponent, url.host ?? ""]
|
||||
.compactMap(UUID.init(uuidString:))
|
||||
.first
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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<ActionValueEntry> {
|
||||
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
|
||||
}
|
||||
|
||||
/// 워치 페이스 갤러리에 노출할 추천 구성 — 옵트인 행동 전부 (상한 금지, §11 규칙).
|
||||
/// 기간은 아이폰 설정이 결정하므로 행동당 1개만 나열한다.
|
||||
/// 주의: description에 문자열 보간 금지 — Text(verbatim:)만 (GoalRate 쪽 주석 참고)
|
||||
func recommendations() -> [AppIntentRecommendation<ActionValueConfigIntent>] {
|
||||
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])
|
||||
}
|
||||
}
|
||||
@ -14,5 +14,6 @@ struct Haru_DanimWatchWidgetsBundle: WidgetBundle {
|
||||
StatusComplication()
|
||||
GoalRateComplication()
|
||||
QuestRateComplication()
|
||||
ActionValueComplication()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -511,6 +511,23 @@ enum DebugSeed {
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
|
||||
/// `-watchActions <N>` 런치 인자: 배치순 행동 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<Action>(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>` 런치 인자: 생성순으로 목표 N개를 모음 탭에 표시 (다중 목표 카드 검증용)
|
||||
static func pinGoalsIfRequested(context: ModelContext) {
|
||||
let n = UserDefaults.standard.integer(forKey: "pinGoals")
|
||||
|
||||
@ -50,10 +50,13 @@ final class WatchSyncManager: NSObject {
|
||||
let actions = LocalPrefs.orderedActions(
|
||||
(try? context.fetch(FetchDescriptor<Action>(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<Date> {
|
||||
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
|
||||
|
||||
@ -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<PersistentIdentifier> = []
|
||||
@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()
|
||||
|
||||
@ -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회 이관
|
||||
|
||||
/// 각 키가 아직 없을 때만, 지금까지 모델에 저장돼 있던 값으로 채운다 (앱 시작 시 호출).
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user