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:
songyc macbook 2026-08-22 05:18:42 +09:00
parent 2e181dd1e2
commit 92afa69f6d
14 changed files with 650 additions and 8 deletions

View File

@ -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, 착지·뒤로가기 검증) |
---

View File

@ -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`와 동일 계열 — 워치는 이 아이폰과 페어링이므로 기기 로컬이 자연스러움).

View File

@ -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))

View File

@ -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

View File

@ -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"
}
}

View File

@ -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])
}
}

View File

@ -14,5 +14,6 @@ struct Haru_DanimWatchWidgetsBundle: WidgetBundle {
StatusComplication()
GoalRateComplication()
QuestRateComplication()
ActionValueComplication()
}
}

View File

@ -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()

View File

@ -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) }
}

View File

@ -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")

View File

@ -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

View File

@ -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()

View File

@ -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
/// , ( ).

View File

@ -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 {