chore: rewrite CLAUDE.md from actual code, plus safe hardening fixes

CLAUDE.md v2.0 — 기획서(v1.0)를 폐기하고 실제 구현 기준의 현행 명세로 전면 재작성:
프로젝트 구조·빌드/검증 루틴·도메인 모델(CloudKit 규칙)·집계/판정 규칙·데이터 계층
(LocalPrefs/DataChange)·화면별 상세·내보내기·프리미엄/StoreKit·위젯 6종·워치·시리·
DEBUG 런치 인자·작업 컨벤션까지 새 세션이 이 문서만으로 파악 가능하게 정리.

동작 보존 범위의 안전 수정 3건:
- Color.hexString: 와이드 컬러(P3) 성분을 0...1로 클램프 — 음수/1 초과 값이
  %02X를 거치며 자릿수 깨진 hex가 태그·목표 색으로 저장되던 잠재 버그 방지
- 일기 타임테이블 필터: 선택 즉시 명시적 save — 캘린더 일정 선택 경로와 동일하게
  영속을 보장 (autosave 의존 제거)
- 통계 평균 "%.1f회"가 String(format:)이라 영어·일본어에서도 '회'로 노출되던 것을
  Format.countAverage로 지역화 (en "times" / ja "回", 카탈로그 동기화 포함)

Debug·Store(Release) 스킴 빌드 검증 완료.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-13 18:45:53 +09:00
parent 93dd3d1d9f
commit 417070bde7
7 changed files with 299 additions and 239 deletions

View File

@ -1,274 +1,306 @@
# 하루 다님 (HaruDanim) — 앱 스펙 문서
# 하루 다님 (HaruDanim) — 프로젝트 가이드
> **한 줄 소개**: 하루의 습관과 시간을 추적하는 iOS 앱. 사용자가 등록한 "행동"의 **소요 시간을 측정**하거나 **횟수를 기록**하고, 이를 바탕으로 **목표(Goal)와 다짐(Quest)** 을 세워 달성 여부를 관리한다.
> **한 줄 소개**: 하루의 습관과 시간을 추적하는 iOS 앱. "행동"의 **시간을 측정**하거나 **횟수를 기록**하고, 이를 바탕으로 **목표(Goal)와 다짐(Quest)** 의 달성률을 관리한다. 프리미엄(StoreKit 2)으로 위젯·워치·iCloud 동기화·iPad 일기를 제공한다.
>
> 문서 버전: v1.0 (2026-07-06) / 이 문서는 개발자·AI에게 전달하기 위한 기획 명세서입니다.
> 문서 버전: v2.0 (2026-07-13) — **실제 구현 코드를 기준으로 작성된 현행 명세**. 새 세션에서 이 문서만 읽어도 앱 전체를 파악할 수 있도록 유지한다. 기능이 바뀌면 이 문서도 함께 갱신할 것.
---
## 1. 개요
## 1. 개요
| 항목 | 내용 |
|---|---|
| 앱 이름 | 하루 다님 — 하루 습관 및 시간 추적 |
| 플랫폼 | iOS (추후 iPadOS 동기화, watchOS 앱 — 유료 기능) |
| 기본 언어 | 한국어 (설정에서 영어 / 일본어 전환 가능) |
| 핵심 콘셉트 | 시간 추적형 타임 트래커 + 습관 카운터/스트릭 관리를 하나로 합친 앱. 행동을 버튼 한 번으로 기록하고, 그 기록들이 모여 목표 달성률로 이어지는 구조 |
| 수익 모델 | 부분 유료화 (무료 사용 + 프리미엄 결제 시 제한 해제 및 확장 기능) |
| 결제 형태 | 월구독, 년구독, 일회성 결제이며 확정된 가격은 아니고 임시 가격으로는 월 구독은 월 5000원, 년 구독은 월 50000원, 일회성 결제는 80000원 이다. 금액은 추후 수정될 예정 |
| 앱 이름 | 하루 다님 (스플래시 멘트: "당신의 하루에 다녀가다") |
| 플랫폼 | iOS (iPhone) + iPadOS(사이드바·일기) + watchOS 앱/컴플리케이션 + 홈·잠금화면 위젯 |
| 기본 언어 | 한국어. 설정에서 영어/일본어 전환 (String Catalog `IOS/Localizable.xcstrings`) |
| 번들 ID | `com.yechan.HaruDanim` / App Group `group.com.yechan.HaruDanim` / CloudKit `iCloud.com.yechan.HaruDanim` |
| 수익 모델 | 부분 유료화. 무료 한도(행동 10·목표 2·목표당 다짐 3) + 프리미엄 결제 시 해제 |
| 결제 상품 | `com.yechan.HaruDanim.premium.monthly`(월 5,000원) / `.yearly`(년 50,000원) / `.lifetime`(80,000원) — 임시 가격, App Store Connect 등록은 아직 안 됨 |
| 남은 배포 작업 | App Store Connect 상품 3종 등록(구독 그룹 + 비소모성, 유료 앱 계약), 실기기 검증(펜슬 필기감·캘린더 권한), App Store 배포 |
---
## 2. 개발 환경 및 기술 스택
## 2. 빌드·검증 환경
### 2.1 개발 환경
- **장비/도구**: MacBook + Xcode
- **Apple Developer Program**: 현재 미가입 상태.
- **1단계**: 미가입 상태로 구현 가능한 기능을 모두 먼저 구현
- **2단계**: 구현 완료 후 가입하여 유료 계정이 필요한 기능(인앱결제, iCloud 동기화, 앱 배포 등) 추가 구현
- **프로젝트 구조**: Xcode 프로젝트 생성 완료. 최상위 폴더명 `HaruDanim`, 그 하위의 앱 타깃 폴더명은 `iOS`로 변경했으며 관련 설정(빌드 세팅, 경로 등)도 모두 반영된 상태
### 2.1 프로젝트 구조 (타깃별 폴더)
### 2.2 기술 스택
| 영역 | 기술 | 비고 |
```
Haru_Danim.xcodeproj # 프로젝트 (스킴: Haru_Danim, Haru_Danim-Store)
IOS/ # iOS 앱 타깃 (Haru_Danim)
Haru_DanimApp.swift # 앱 엔트리 (프리미엄 부트스트랩, 마이그레이션, 훅 주입)
ContentView.swift # 스플래시 + 탭/사이드바/버블 내비게이션 분기, AppRouter, AppTab
Core/ # Store(StoreKit)·LiveActivity·WatchSync·CloudSyncMonitor·AppRefresh·DebugSeed·SymbolCatalog
Views/ # 탭 화면들 + 내보내기 + 일기 + 도움말
Localizable.xcstrings # ko(원문)/en/ja 문자열 카탈로그
Shared/ # 앱+위젯(일부는 워치도) 공용: 모델, 계산, 설정, 프리미엄, 인텐트
Widgets/ # 홈·잠금화면 위젯 + Live Activity UI 타깃 (Haru_DanimWidgets)
WatchShared/WatchPayload.swift# iPhone↔워치 Codable 페이로드 (iOS+워치+워치위젯 공용)
Haru_DanimWatch Watch App/ # 워치 앱 타깃
Haru_DanimWatchWidgets/ # 워치 컴플리케이션 타깃
HaruDanim.storekit # 로컬 StoreKit 테스트 구성 (Store 스킴이 사용)
DesignAssets/ # 아이콘 원본 등
```
### 2.2 빌드 커맨드
```bash
# Debug (개발·검증 기본)
xcodebuild -project Haru_Danim.xcodeproj -scheme Haru_Danim \
-destination 'platform=iOS Simulator,id=<SIM_ID>' build
# 스토어 동일 구성 (Release + HaruDanim.storekit)
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`
- **SourceKit 인라인 진단은 항상 오탐 노이즈**("No such module 'UIKit'" 등) — 판정은 xcodebuild 결과만 신뢰
- DEBUG 런치 인자 검증은 **콜드 스타트 필수** (terminate 후 재실행). 목록은 §14
- 캘린더 권한 부여: `xcrun simctl privacy <sim> grant calendar com.yechan.HaruDanim`
### 2.3 다국어(l10n) 루틴
`String(localized:)` 문자열 추가 후:
```zsh
# 1) Debug 빌드 → 2) stringsdata 동기화
files=(${(f)"$(find ~/Library/Developer/Xcode/DerivedData/Haru_Danim-*/ -name '*.stringsdata' -path '*Debug-iphonesimulator*')"})
xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
# 3) python으로 en/ja 번역 채움 → 4) sync 재실행으로 포맷 정규화 → 5) missing 확인
```
용어 통일: 다짐=Quest/クエスト, 행동=Action, 꼬리표=Tag. `String(format:)`은 카탈로그로 추출되지 않으므로 사용자 노출 문구에 금지 (`Format.countAverage` 참고).
---
## 3. 도메인 모델 (SwiftData — `Shared/Models.swift`, `Shared/DiaryModels.swift`)
**CloudKit 호환 규칙 (필수)**: 모든 저장 프로퍼티는 기본값을 가짐. to-many 관계는 optional 저장(`~Storage`) + non-optional computed 접근자. 기존 필드명 유지를 위해 `@Relationship(originalName:)` 사용.
| 모델 | 핵심 필드 | 비고 |
|---|---|---|
| UI | SwiftUI | |
| 데이터 저장 | **SwiftData** | 프로젝트 생성 시 Storage는 None으로 선택했으나 실제 구현은 SwiftData 사용 |
| 시간 추적 (백그라운드/다이나믹 아일랜드/잠금화면) | ActivityKit — **Live Activities** | 앱을 나가도 추적 중인 시간 표시 |
| 위젯 | WidgetKit | 홈 화면 / 잠금 화면 위젯 (유료) |
| 워치 앱 | watchOS App + WidgetKit 기반 컴플리케이션 | 유료 |
| 통계 그래프 | Swift Charts | |
| 아이콘 | SF Symbols | 가능한 전체 심볼을 선택할 수 있도록 제공 |
| 결제 | StoreKit 2 | 개발자 프로그램 가입 후 |
| 기기 간 동기화 | CloudKit (SwiftData + CloudKit 연동) | 개발자 프로그램 가입 후, 유료 기능 |
| 다국어 | String Catalog (ko 기본, en / ja) | |
### 2.3 개발 단계 구분 (개발자 프로그램 가입 기준)
- **가입 전 구현**: 앱 본체 전체(행동/목표/다짐/꼬리표/기록/설정), SwiftData 저장, Live Activity·다이나믹 아일랜드, 위젯·워치 앱의 로직/UI (실기기 테스트는 무료 프로비저닝 범위 내)
- **가입 후 구현**: 인앱결제(StoreKit), iCloud(CloudKit) 기기 간 동기화, App Store 배포
| `Tag` | uuid, name, colorHex, sortOrder | 행동 분류. 고유 색. 다대다(Action) |
| `Action` | uuid, name, symbolName(SF Symbol), trackingTypeRaw(time/count), isFavorite | 버튼 색 = **가장 먼저 만든 꼬리표의 색**. sortOrder·promptsForNote는 레거시(§5 LocalPrefs로 이관, 폴백 정렬용만) |
| `TimeSession` | startAt, endAt(nil=측정 중), note | 삭제 규칙: Action cascade |
| `CountEntry` | timestamp, amount, note | |
| `Goal` | uuid, title, symbolName, colorHex, startDate, endDate?, statusRaw(inProgress/achieved/notAchieved), **achieveThresholdPercent(기본 100)**, sortOrder | isCollapsed·showsOnMain은 레거시 |
| `Quest` | uuid, goal, targetAction 또는 targetTag, measureRaw, periodRaw(daily/weekly/monthly/custom), scheduleModeRaw(everyDay/weekdays/monthDays/ordinalWeekday), weekdays[], monthDays[], ordinalWeek/Weekday, customStart/End, targetSeconds/targetCount, directionRaw(atLeast/atMost), sortOrder | 꼬리표 대상이면 measure는 생성 시 선택, `targetActions`는 해당 measure의 행동만 |
| `DiaryEntry` | dayKey(논리적 하루 키), moodEmoji, moodImageData(externalStorage), **calendarEventIDs[]**(그날 타임테이블에 넣을 EKEvent id), **hiddenActionIDs[]**(그날 타임테이블에서 숨길 Action.uuid 문자열) | 날짜당 1개. 날짜별 선택은 uuid 문자열로 저장(기기 간 동기화 안전) |
| `DiaryTodo` | text, isDone, sortOrder | 요약 페이지 체크리스트 |
| `DiaryPage` | index, lined, lineSpacing, drawingData(PKDrawing, externalStorage) | 768×1024 논리 좌표 |
| `DiaryPageItem` | kind(photo/rectangle/ellipse/arrow/line), imageData, colorHex, centerX/Y·widthRatio(비율 좌표), rotationDegrees | |
---
## 3. 핵심 용어 (도메인 개념)
## 4. 핵심 계산 규칙 (`Shared/DayMath.swift`, `Shared/QuestProgress.swift`)
### 3.1 행동 (Action)
- 추적·기록의 최소 단위. 습관(habit) 또는 할 일(task)을 등록한다.
- **추적 방식은 생성 시 둘 중 하나로 지정**:
1. **시간 추적형**: 시작/종료 토글로 소요 시간을 측정. 앱이 백그라운드로 가도 측정이 계속 유지되어야 함 (Live Activity로 표시)
2. **횟수 추적형**: 탭할 때마다 카운트 +1
- 구성 요소: 이름, 아이콘(SF Symbols), 꼬리표(복수 지정 가능), 추적 방식(시간/횟수)
- 행동 버튼의 색은 **지정된 꼬리표의 색**을 따른다.
### 4.1 논리적 하루 (DayMath)
- 설정 "하루 시작 시간"(`dayStartMinutes`)·"주 시작 요일"(`weekStartWeekday`, 기본 월요일)이 모든 집계의 기준
- `dayKey(for:)`: 시각 → 그 시각이 속한 논리적 하루의 달력일 자정. 경계 이전 시각은 전날 귀속
- 세션은 실제 시각 그대로 하나로 저장하고, **집계는 구간 겹침(overlap)으로 자동 분할** — 하루 경계를 걸친 세션이 날짜별로 나뉘어 계산됨. `Aggregator.seconds/count`가 이 규칙의 단일 구현이며 화면·위젯·내보내기 모두 이를 공유
### 3.2 목표 (Goal)
- 상위 개념의 큰 목표. 사용자가 직접 텍스트로 작성 (예: "토익 700점 이상 받기")
- 구성 요소: 목표 내용(텍스트), 아이콘, 색, **시작일(필수, 과거 날짜 가능)**, **종료일(선택)**
- 종료일 미지정 시 → **수동 완료** 처리 가능해야 함. 이때 달성 여부는 하위 다짐들의 전체 달성 여부에 따라 결정
- 종료일 지정 시 → 목표 탭에 **진행률** 표시
### 4.2 다짐 진행률 (QuestProgress)
- `ratio`(게이지용, 0...1): atLeast = min(value/target, 1) / **atMost = 한도 안이면 1, 넘으면 0**
- `displayRatio`(퍼센트 문구): atLeast는 100% 초과 가능
- **주간·월간 span에서 '이상 달성' 하루 단위 다짐은 하루별 기여를 그날 목표량으로 캡** — 어느 날의 초과 달성이 다른 날 미달을 가리지 않음 (하루 span·atMost·주/월/기간 다짐은 캡 없음, 원본 기록 무영향)
- `scaledTarget`: 주기 목표량을 span 길이에 환산 (daily→적용일 수×목표, weekly→/7 등)
- `isScheduled(on:)`: 하루 진행률 모수 판정 — 수행일 아닌 daily 다짐은 그날 모수에서 제외
### 3.3 다짐 (Quest)
- 목표의 **하위 단위**. 목표를 먼저 만들고, 그 목표 안에 다짐을 하나씩 추가하는 흐름
- 대상: 특정 **행동** 또는 특정 **꼬리표**를 선택
- **반복 주기** 옵션:
- 하루 단위 / 일주일 단위 / 한 달 단위 / 특정 기간 단위
- 요일 지정: "매주 월·수·금" 등 복수 요일
- 날짜 지정: "매달 15일, 20일, 25일" 등 복수 날짜
- 주차+요일 지정: "매달 셋째 주 화요일" 등
- **목표량과 방향**:
- 시간 추적형 행동 → 해당 기간 동안 특정 시간을 **초과 달성(이상)** 이 목표인지 / **초과하지 않음(이하)** 이 목표인지 선택
- 횟수 추적형 행동 → 특정 횟수 **이상**이 목표인지 / **넘지 않음**이 목표인지 선택
### 3.4 꼬리표 (Tag)
- 행동을 분류하는 태그. **고유의 색**을 가지며 생성 시 색을 지정
- 생성 위치: 꼬리표 탭, 또는 행동 추가 화면의 꼬리표 지정 단계에서 즉석 추가
- **하나의 행동은 여러 개의 꼬리표를 가질 수 있음** (다대다 관계)
### 4.3 목표 진행률·판정 (Goal extension)
- `combinedSpanRatio(span)`: 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급). 모음 탭 카드·위젯·워치·시리가 공유
- **달성 판정**: 종료일 경과 자동(`evaluateIfEnded`, 목표 탭 진입 시 실행) 또는 수동 종료(`manualFinish`). `questAchievementRatio`(달성 다짐=1, 미달성=현재 주기 ratio 클램프, 수행일 아님=1) × 100 ≥ `achieveThresholdPercent` 0.0001 이면 달성. **기준 100 = 레거시 allSatisfy와 동일**
- 다짐 없는 목표: 종료일 도래 시 "확인 필요"(달성했나요?) 상태 → 사용자가 직접 선택. 수동 종료도 동일
- 진행률 표시 연산(combinedSpanRatio 등)은 판정 기준과 무관 — 건드리지 말 것
---
## 4. 시간 추적 시스템 (핵심 동작 명세)
## 5. 데이터 계층
### 4.1 기본 동작
- 시간 추적형 행동 버튼을 탭 → 측정 시작 / 다시 탭 → 측정 종료 (토글)
- 앱을 나가도 측정은 계속되며, **다이나믹 아일랜드**와 **잠금화면 실시간 현황(Live Activity)** 에 추적 중인 시간이 표시됨
### 5.1 저장소 (`Shared/DataStore.swift`)
- 스토어 파일: App Group 컨테이너의 `HaruDanim.store` (위젯·인텐트가 같은 DB 사용). 구 샌드박스 `default.store`는 1회 이관
- **CloudKit 미러링은 메인 앱 프로세스만**. 위젯 확장(.appex)은 같은 파일을 로컬 전용으로 열음. 확장의 쓰기는 메인 앱이 원격 변경 알림으로 받아 내보냄
- iCloud 동기화 = 프리미엄 + `settings.cloudSync` 토글 (앱 재시작 시 적용). 실패 시 로컬 폴백
- 컨테이너 생성 실패 시: 짧은 재시도 3회 → 스토어를 `.backup-<ts>`로 보존 후 새로 시작 (fatalError로 즉사하지 않음)
- `ensureUniqueEntityIDs`: uuid 기본값 마이그레이션 중복 보정 (앱 시작 1회)
### 4.2 동시(멀티) 추적
- 한 행동을 측정 중인 상태에서, 종료하지 않고 다른 행동을 탭하면 **그 행동도 함께 추적 시작** (동시 추적 개수 제한 없음)
- 다이나믹 아일랜드/잠금화면에 표시할 대표 시간은 **설정에서 선택**:
- 가장 **먼저** 시작한 행동 표시 / 가장 **나중에** 시작한 행동 표시 *(기본값 제안: 가장 나중에 시작한 것)*
- 여러 개를 동시에 추적 중이면 대표 시간 옆에 **`+1`, `+2`** 형식으로 추가 추적 중인 개수를 표시
### 5.2 동기화되는 데이터 vs 기기 로컬 설정 (`Shared/LocalPrefs.swift`)
- **데이터**(행동·목표·기록·일기)는 SwiftData(+CloudKit), **"이 기기에서 어떻게 보여줄지"는 App Group UserDefaults**:
- `local.actionOrder`(모음 탭 배치 — 위젯·워치·인텐트 나열 순서도 이걸 따름), `local.pinnedGoals`(모음 탭 노출 목표), `local.collapsedGoals`, `local.notePromptActions`(기록 시 메모 창)
- 대응하는 @Model 레거시 필드는 스키마 안정성 위해 남겨두고 1회 이관(`adoptLegacyModelValuesIfNeeded`)
- 설정 키는 `Shared/Settings.swift` — 집계 기준·테마·대표시간 등은 App Group defaults(위젯 공유), navStyle·visibleTabs·radialTabOrder·diary.section* 은 standard(기기 로컬)
### 4.3 하루 경계(하루 시작 시간) 예외 처리
- 설정에서 "하루 시작 시간"을 지정 가능 (예: 오전 6시)
- **세션이 하루 경계를 걸치는 경우** (예: 하루 시작이 06:00일 때, 05:00 시작 → 07:00 종료):
- 저장: 세션 자체는 실제 시작~종료 시각 그대로 하나의 기록으로 저장
- **집계/통계**: 하루 시작 시간을 기준으로 자동 분할하여 각 날짜에 귀속 (위 예시에서는 05:00~06:00은 전날, 06:00~07:00은 당일 실적으로 계산)
- 기록 탭 표시/다짐 진행률 계산에도 동일한 분할 규칙 적용
### 5.3 변경 전파
- 앱 내 모든 mutation 끝에 **`DataChange.commit(context:)`**: save → LiveActivity sync → `WidgetCenter.reloadAllTimelines()` → 워치 스냅숏 push. 위젯/시리 경로는 `IntentStore.performWrite`가 같은 역할(한 컨텍스트로 조회·수정·저장 원자 처리 + 저장 1회 재시도)
- `CloudSyncMonitor`: NSPersistentStoreRemoteChange를 2초 디바운스로 받아 위젯·워치·LiveActivity 갱신 (화면은 건드리지 않음)
- `AppRefresh`(모음 탭 새로고침 버튼): 루트 뷰 `.id(token)` 리셋으로 모든 @Query 재조회 — 다른 기기 변경이 화면에 안 보일 때의 수동 해결책
---
## 5. 화면 구성
## 6. 내비게이션·화면 구성
### 5.1 로딩(스플래시) 화면
- 앱 실행 시 **앱 로고 + 앱 이름**이 표시되는 로딩 화면 필요 (로고는 아직 미제작 → 플레이스홀더로 진행 후 교체)
- **스플래시**: 앱 로고(AppLogo) + 이름 + 멘트, 1.2초 후 페이드아웃 (`ContentView`)
- **탭 정의** (`AppTab`): 모음(main) · 행동 · 꼬리표 · 목표 · 기록 · 통계 · 일기(iPad 전용) · 설정
- **iPhone 기본**: 하단 탭바에 **노출 탭 1~3개**(기본 모음·행동·기록) + "더보기" 탭(나머지 목록). 설정에서 구성
- **iPhone 실험 옵션**: "글래스 버블 메뉴"(`RadialNavigationView`) — 하단 중앙 FAB를 누르면 유리 구슬 탭 버튼들이 부채꼴로 펼쳐짐. 라벨은 글라스 알약 배경. 순서는 설정에서 편집(기기 로컬)
- **iPad·Mac**: 항상 `NavigationSplitView` 사이드바 (`SidebarRootView`, `DeviceLayout.isPad`로 판정)
- `AppRouter`: 탭 전환 + 탭 간 전달값(모음 탭 롱프레스 → 기록/통계 탭 행동 필터 예약, 더보기 내부 경로 진입 포함)
### 5.2 테마
- **라이트 / 다크 두 가지 모드**
- 컬러 팔레트: 두 모드 모두 **초록 + 노랑 + 배경색(라이트=흰색 계열 / 다크=검정 계열)** 구성
- 모드별로 초록·노랑의 컬러 값을 **다르게** 하여 각 배경에 어울리게 조정. 흔한 원색 계열 초록/노랑은 배제
- **제안 컬러 값** (구현 시 조정 가능):
### 6.1 모음 탭 (`MainView`)
- 상단: 노출 목표 진행 현황 카드(1개=단독, 2개+=스냅 페이징 가로 스크롤 + 페이지 점, iPad=그리드). 카드 스타일 설정: 다짐별 각각(perQuest, 접힘 시 3개+더보기) / 전체 합산(combined)
- "현재 진행 중" 영역: 측정 중 세션 나열 + 실시간 타이머 + 종료 버튼
- 행동 버튼 그리드: 꼬리표 색 그라데이션 배경 + 아이콘 + 이름 + 오늘 누적(시간형) 또는 오늘 횟수(횟수형, 숫자 전환 애니메이션). 측정 중이면 노란 테두리 + record 점. 탭 = 시작/종료 토글 또는 +1 (스프링 눌림 + 햅틱)
- 한 줄 개수: iPhone 2~6개 설정(5개 이상은 compact 셀), iPad는 adaptive
- 롱프레스 메뉴: 기록 확인(기록 탭 필터 이동) / 통계 보기 / 기록 직접 입력·수정 / 행동 설정 수정 / 삭제
- 배치 편집: 지글 애니메이션 + 드래그 재정렬 → `local.actionOrder` 저장 (CloudKit 모델에 쓰지 않음)
- "짧은 기록 무시"(minSessionSeconds): 설정보다 짧은 세션은 종료 시 삭제. 메모 창 옵션 켠 행동은 종료/+1 시 메모 시트(건너뛰기 가능)
- 툴바: 새로고침(좌) / 배치 편집(우)
| 역할 | 라이트 모드 | 다크 모드 |
### 6.2 행동 탭 (`ActionViews`)
- 즐겨찾기 섹션(스와이프로 토글) → 꼬리표별 그룹 → 꼬리표 없음. 탭하면 상세(정보·즐겨찾기·메모 창 토글·누적 통계·삭제)
- 추가/수정: 이름, 아이콘(SF Symbols 검색+카테고리 선택기 `SymbolPickerView`/`SymbolCatalog`), 꼬리표 복수 선택(즉석 추가 가능), 추적 방식, 메모 창 여부. 무료 한도 초과 시 프리미엄 안내 얼럿
- ⋯ 메뉴: 꼬리표 순서 변경 시트
### 6.3 꼬리표 탭 (`TagViews`): 목록(행동 수 표시)·드래그 정렬·수정(이름/색: ColorPicker+프리셋 12색)·스와이프 삭제(행동은 유지)
### 6.4 목표 탭 (`GoalViews`, `QuestEditorView`)
- 진행 중 목표만 기본 목록(섹션당 목표 행 + 다짐 행들, 다짐 접기/펼치기는 기기 로컬). 완료(달성/미달성) 목표는 "완료된 목표" 별도 화면
- 목표 행: 상태 뱃지(진행 중/달성/미달성/확인 필요) + 기간 진행률 바(종료일 있을 때). "확인 필요"는 탭해서 달성/미달성 직접 선택
- 목표 편집: 내용·아이콘·색·시작일(과거 가능)·종료일(선택)·**달성 판정 기준 슬라이더(10~100%, 5% 단위, 기본 100%)**
- 목표 상세: 다짐 목록(탭=수정, 스와이프 삭제, 순서 모드), 다짐 추가(무료 한도 3개), 종료일 없으면 수동 종료(기준<100% 안내 문구에 기준 표기), 삭제
- 다짐 편집 3단계: ①대상(행동 또는 꼬리표+측정 기준) ②주기(하루[매일/요일/날짜/몇째주 요일]·주·월·특정 기간) ③목표량(시간 휠 또는 횟수 스테퍼)+방향(이상 달성/이하 유지)
- 목록 진입 시 `evaluateIfEnded` 일괄 실행
### 6.5 기록 탭 (`HistoryView`, `RecordEditors`)
- 날짜 헤더(화살표 이동, 날짜 탭=달력 팝오버, "오늘" 버튼) + 목록/타임테이블 세그먼트
- 목록: 시간 기록(시작~종료·총 시간·메모)·횟수 기록(시각·+n·메모), 탭하면 수정 시트
- 타임테이블: 24시간 축(하루 시작 시간 기준), 하루/일주일 전환(주간 보기에서 화살표는 7일씩 점프). 시간=색 블록, 횟수=색 점. 블록/점 탭=수정
- 필터: `RecordFilterSheet`(공용) — 꼬리표 아래 행동이 그룹된 트리, **"제외한 행동 ID" 집합**으로 관리해 기본이 전체 선택. 활성 시 초록 칩 표시
- 내보내기: 현재 날짜/모드/필터 그대로 `ExportBuilder.history` 스냅숏 → 이미지 시트
### 6.6 통계 탭 (`StatsTabView`) — 기록과 분리된 독립 탭
- 하루: 꼬리표별 시간/횟수 가로 막대. 주간: 행동별 일별 꺾은선(행동 색+범례)+합계 막대+합계·평균 표. 월간: 주차별+일별 꺾은선+합계+표
- 평균 분모는 "이미 시작된" 날/주만 (미래로 희석 방지). 필터·내보내기는 기록 탭과 동일 패턴
### 6.7 일기 탭 (iPad 전용, 프리미엄 — `DiaryView`, `DiaryNotePage`, `DiaryCalendarEvents`, `DiaryExportSheet`)
- 루트: 월 달력(작성일에 기분 이모지/연필 마커), 날짜 탭 → 일기 상세. 툴바에서 기간/복수 날짜 내보내기(PDF=페이지별 1장, 이미지=세로 스티치 PNG, 2배율)
- 상세: 좌우 페이지 넘김. 1페이지=하루 정리 요약, 이후=펜슬 노트, 마지막=페이지 추가 자리
- **요약 페이지 섹션** (표시·순서는 "첫 화면 구성" 시트, 전 날짜 공통, standard defaults): 오늘 기분(대형 정사각 타일: 이모지/사진) · 요약 지표 · 이 날의 목표(그날 기준 다짐 현황, 주간/월간은 그날까지 누적) · 오늘 할 일 · 타임테이블 · 기록 상세 · 통계 막대. 화면 720pt 이상이면 타임테이블 좌측 고정 2컬럼
- 타임테이블 카드 우상단 버튼: **필터**(그날 기록된 행동만 나열된 팝업에서 표시 선택, 날짜별 저장 `hiddenActionIDs`, 숨긴 개수 노란 배지 — 타임테이블에만 적용, 요약 수치·기록 목록은 전체 유지) + **캘린더**(그날의 EKEvent 선택, 날짜별 저장, 외곽선 스타일 블록). 둘 다 내보내기에 동일 반영
- 노트 페이지: 768×1024 논리 좌표 고정 + scaleEffect. **실기기 필기는 펜슬 전용, 손가락은 이동/줌만**(시뮬레이터는 anyInput). 핀치줌 최대 4배, `SharpPencilCanvasView`가 contentsScale을 줌에 맞춰 유지(획 선명도). 줄 노트(간격 4단계)·사진·도형(사각/원/화살표/선) 배치 모드(드래그·핀치 크기 조절, 저장은 비율 좌표)
### 6.8 설정 탭 (`SettingsView`)
- 테마(라이트/다크 — 즉시 적용, App Group 저장으로 위젯 공유) / 언어(재시작 적용, AppleLanguages 오버라이드)
- iPhone만: 내비게이션 방식(탭바/버블) + 탭바 구성 or 버블 순서
- 모음 탭 목표 카드 선택(개수 무제한)+표시 방식 / 주 시작 요일 / 하루 시작 시간 / 짧은 기록 무시 / 대표 시간 기준(가장 먼저·나중에 시작 — 기본 latest)
- 프리미엄 화면(§8) / 도움말(`HelpView` — 기기별 주제 분기, DisclosureGroup) / 버전
---
## 7. 이미지·PDF 내보내기 시스템 (`ExportImageView.swift`)
- `ExportSnapshot`: SwiftData 비의존 값 타입 (title/periodLabel/filterNote/hero/sections). 섹션 종류: timetable / records / bars / lines / table
- `ExportBuilder.history(...)` / `.stats(...)`: 화면과 동일한 집계 규칙으로 스냅숏 생성. `trimHours: false`면 기록이 없어도 24시간 전체 그리드(일기용)
- `injectingCalendarEvents(_:)`: 하루 타임테이블에 캘린더 블록 주입. `replacingTimetableSections(with:)`: 일기 필터용 — 타임테이블 섹션만 교체하고 나머지는 유지
- `ExportPosterView`: 고정폭 430pt, ImageRenderer scale 3 → 1290px PNG. 라이트/다크는 `.environment(\.colorScheme)` 강제. `ExportSectionView`/`ExportHeroRow`는 일기 요약 화면과 공용 — **화면과 내보내기 결과가 항상 일치**
- 일기 인쇄용: `DiaryPrintSummaryView`(768pt) + `DiaryPrintNotePageView`(768×1024, 라이트 고정)
---
## 8. 프리미엄·결제 (`Shared/Premium.swift`, `IOS/Core/Store.swift`)
- 계층: `EntitlementProvider` 프로토콜 ← `StoreKitEntitlementProvider`(currentEntitlements + Transaction.updates 스트림, 앱 시작 시 `PremiumManager.bootstrap` 주입) / Mock(확장용)
- `PremiumManager`(@Observable, 앱 UI 단일 진입점, canAddAction/Goal/Quest) / `PremiumGate`(App Group 캐시 — 위젯·워치·인텐트가 동기 조회)
- `PremiumStore`: 상품 로드·구매·복원(`AppStore.sync`)·현재 플랜(평생 > 만료 먼 구독). 구매/복원 후 권한 재계산 + 위젯 리로드
- 결제 화면(`PremiumView`): 기능 소개 → 미구매 시 상품 3종(년 구독 절약률 배지, 평생 "한 번 결제") + 복원. 구매 후: 플랜 표시 + 구독 관리 시트 + **기기 간 동기화 토글** + 구독→평생 전환 안내
- DEBUG 전용: 테스트 토글(`setMockPremium` — 켜면 실권한 무시 모드) + "토글 강제 해제". 실구매·복원은 강제 모드를 자동 해제
- 프리미엄 기능: 개수 무제한, 홈·잠금 위젯, 워치 앱+컴플리케이션, 시리 단축어, iCloud 동기화, iPad 일기
---
## 9. Live Activity·다이나믹 아일랜드 (`LiveActivityManager`, `Widgets/HaruDanimWidgets.swift`)
- 측정 시작/종료/수정마다 `sync(context:)`: 진행 중 세션 조회 → 대표 1개(설정: earliest/latest) + `extraCount`(+N 표시) 상태로 Activity 시작/갱신/종료. 중복 Activity 정리
- 백그라운드(워치 명령)에서 시작 거부 시 `pendingStartRetry` → 포그라운드 복귀 때 재시도
- UI: 잠금화면 배너(아이콘 타일+이름+타이머) / 확장 아일랜드(leading 아이콘+이름, trailing 타이머, bottom "+N개 함께 추적 중") / compact(아이콘+타이머+N) / minimal(아이콘)
---
## 10. 홈·잠금화면 위젯 (`Widgets/` — 모두 프리미엄 게이트)
번들: TrackingLiveActivity + 5종 홈 위젯 + 잠금화면 1종. 모든 홈 위젯 공통 설정: **테마(앱 일치/라이트/다크)**. 홈 화면이 틴트/클리어(리퀴드 글라스) 렌더링 모드면 커스텀 배경·강제 스킴을 얹지 않고 시스템 바이브런트에 맡김(white-on-white 방어, `WidgetTheme.swift`).
| 위젯 (kind) | 내용 |
|---|---|
| ① 행동 실행 `HaruActionRunWidget` | `Button(intent: RunActionIntent)` — 시간형 토글/횟수형 +1. 누적값 표시 기간(오늘/주/월/숨김) 선택. 소형 1(풀블리드)/중형 4(2×2 행 레이아웃)/대형 4 또는 8. 측정 중 노란 테두리+타이머(`Text(style:.timer)`) |
| ② 목표 진행률 `HaruGoalBarsWidget` | 하루/주간/월간 가로 진행바 3줄. 중형: 목표2 또는 목표1+다짐. 대형: 목표4 / 목표+다짐×2 / 목표1+모든 다짐. 다짐 목록은 높이에 맞춰 적응 채움(+N) |
| ③ 다짐 진행률 `HaruQuestRingWidget` | 원형 링(행동 대상이면 눌러 실행). 기간 선택. 소형1/중형2/대형4. atMost는 "한도 지킴/초과" |
| ④ 다짐 현황 `HaruGoalQuestGridWidget` | 표시 전용 링 그리드 + 목표 헤더. 소형 2×2 / 중형 4×2 / 대형: 다짐16 / 목표4 / 목표2 |
| ⑤ 행동 통계 `HaruStatsChartWidget` | 꺾은선(행동 색). 기간: 일주일/한 달(일별, 소형 제외→주별 대체)/한 달(주별). 선 수 제한 소2/중4/대6 |
| 잠금화면 `HaruLockGoalWidget` | 목표 1개 달성률 — 원형 게이지/숫자만/다짐 점(달성=채움). circular/rectangular/inline |
- 슬롯 규칙: 미선택이면 기본 대상 채움, 선택했으면 순서 존중 + 모자란 슬롯은 점선 빈 칸. '선택 안 함' 센티널 `NoneEntityID`
- **타임라인 예산 전략**(`WidgetRefresh`): 주기 폴링 없음. 값 변경은 `DataChange.commit`/인텐트의 reloadAllTimelines가 담당. 측정 중일 때만 10분 간격 미래 엔트리 1시간치+.atEnd, 평상시엔 하루 경계(최대 4h) 안전망
- 확장 프로세스는 `IntentStore.refresh()`로 작업마다 컨테이너를 새로 열어 최신 데이터 보장 (실패 시 직전 컨테이너 유지 — 크래시 방지)
- `RunActionIntent`는 AppEntity 대신 **UUID 문자열 파라미터** — 버튼 탭 지연·유실 방지의 핵심
---
## 11. 애플워치 (프리미엄)
- **워치 앱**: 꼬리표 목록(측정 중 섹션 포함) → 행동 목록 → 탭으로 실행. iPhone과 실시간 연동
- **통신** (`WatchSyncManager`(iOS) ↔ `WatchStore`(워치), 페이로드 `WatchShared/WatchPayload.swift`):
- iPhone→워치: `updateApplicationContext`(기본) + `transferCurrentComplicationUserInfo`(측정 상태 변경 또는 30분 경과 시만 — 일일 예산 절약)
- 워치→iPhone: `sendMessage`(응답=최신 스냅숏), 실패/미활성 시 `transferUserInfo` 큐 폴백 (5분 지난 큐 명령은 무시)
- 스냅숏은 워치 App Group defaults에 캐시(오래된 스냅숏 역행 방지: generatedAt 비교) → 컴플리케이션이 읽음
- **컴플리케이션** 3종 (15분 .after 타임라인): 현재 현황(대표 행동+타이머+N) / 목표 달성률(기간 선택, 게이지) / 다짐 달성률(rectangular는 3기간 모두, **atMost는 게이지 대신 한도 안/초과 상태 표현**)
- ⚠️ `recommendations()`의 description에 포맷 텍스트 금지 — `Text(verbatim:)`만 (WidgetKit assertion으로 익스텐션 즉사)
---
## 12. 시리 단축어 (`Shared/HaruDanimIntents.swift`, 프리미엄 게이트)
인텐트: 측정 시작/종료(짧은 기록 무시 규칙 동일 적용) · 횟수 추가(1~999) · 행동 누적값 조회 · 목표 진행률 조회 · 다짐 진행률 조회(atMost는 한도 문구). `AppShortcutsProvider`에 대표 문구 등록. 엔티티(Action/Goal/Quest)는 EntityStringQuery + '선택 안 함' 항목.
---
## 13. 테마·컬러 (`Shared/Theme.swift`)
| 역할 | 라이트 | 다크 |
|---|---|---|
| Primary Green | `#2F6B4F` (딥 모스 그린) | `#7FBF9E` (세이지 민트) |
| Accent Yellow | `#D9A621` (머스터드 골드) | `#E8C558` (소프트 앰버) |
| Background | `#FAFAF6` (웜 화이트) | `#111512` (그린 틴트 블랙) |
| Primary Green | `#2F6B4F` 딥 모스 그린 | `#7FBF9E` 세이지 민트 |
| Accent Yellow | `#D9A621` 머스터드 골드 | `#E8C558` 소프트 앰버 |
| Background | `#FAFAF6` 웜 화이트 | `#111512` 그린 틴트 블랙 |
| Surface(카드) | white | `#1B211D` |
### 5.3 탭 구조 (총 6개, 순서 고정)
`메인``행동``꼬리표``목표``기록``설정`
- 노랑 = "측정 중" 시그널(테두리·record 점·정지 버튼)과 강조. 꼬리표 프리셋 12색
- `AppGroup.defaults`는 반드시 단일 인스턴스 사용 — @AppStorage(store:)가 인스턴스를 관찰하므로 매번 새로 만들면 변경이 전파 안 됨(테마 즉시 적용 버그의 원인이었음)
- 아이콘: light/dark/tinted 변형 등록(로고 v4: 시계 행성 위 걷는 사람)
---
## 6. 탭별 상세 명세
## 14. DEBUG 런치 인자 (검증용, 모두 DEBUG 빌드 전용)
### 6.1 메인 탭
- **행동 버튼 그리드**: 등록된 행동들이 스마트폰 홈 화면의 앱 아이콘처럼 나열됨
- 각 버튼: **라운드가 있는 직사각형** / 꼬리표 색 배경 + SF Symbols 아이콘 + 행동 이름
- 시간 추적형: 탭 → 측정 시작, 다시 탭 → 종료. **측정 중/아닐 때 시각적으로 구분** 필수
- 횟수 추적형: 탭 → 카운트 +1
- **진행 중 영역**: 측정 중인 행동은 메인 탭 **상단의 "현재 진행 중" 영역**에 표시. 옆의 종료 버튼으로 종료 가능 (아래 그리드의 버튼을 다시 탭해도 동일하게 종료)
- **길게 누르기(롱프레스) 메뉴**:
- 횟수 추적형: ① 횟수 직접 입력/수정 (증가 취소 포함. 횟수를 여러 개 추가할 경우 각 건마다 "지금 시각"으로 기록할지 특정 시각을 지정할지 선택 가능) ② 행동 설정 수정 ③ 행동 삭제
- 시간 추적형: ① 시작/종료 시각 수동 입력·수정 ② 행동 설정 수정 ③ 행동 삭제
- **배치 편집 모드**: 배치 수정 버튼 존재(위치는 구현 시 재량). 누르면 iPhone 홈 화면 편집처럼 **버튼들이 흔들리며(지글 애니메이션) 드래그로 순서 변경** 가능. "완료"를 누르면 배치가 저장·유지됨
공통: `-seedDemo YES`(데모 데이터: 태그3·행동6·기록 7일치·목표6·자정 걸친 세션), `-premium YES/NO`, `-startTab <main|action|tag|goal|history|stats|diary|settings>`, `-navStyle radial`, `-themeAutoToggle YES`
### 6.2 행동 탭
- 등록된 **모든 행동을 꼬리표별로 그룹화한 리스트**로 표시
- 리스트에서 행동을 탭해도 측정이 동작하지 않음 → 대신 **설정 내용 상세가 표시**되고, 수정 버튼으로 수정 / 삭제 가능
- **행동 추가**: 추가 버튼 → 이름, 아이콘, 꼬리표, 추적 방식(시간/횟수) 설정
- 아이콘 선택기는 **SF Symbols를 최대한 폭넓게 전부 사용**할 수 있도록 구성 (검색/카테고리 탐색 제공 권장)
### 6.3 꼬리표 탭
- 생성된 태그 목록 확인 / 새 태그 추가 / 기존 태그의 이름·색 수정
- 태그 생성 시 **색 지정** 필수
### 6.4 목표 탭
- **목표 생성**: 내용(텍스트) + 아이콘 + 색 + 시작일(필수, 과거 가능) + 종료일(선택)
- **목표 상태 표시** (각각 다른 시각 상태 필요):
1. 진행 중 (종료일 있으면 진행률 함께 표시)
2. 달성 완료
3. 미달성 종료
4. **확인 필요**: 다짐이 하나도 등록되지 않은 채 종료일이 도래한 목표 → "달성했나요?" 질문 상태로 전환, 사용자가 탭하여 달성/미달성 직접 선택 → 선택에 따라 2 또는 3 상태로 변경
- 종료일 없는 목표 → 수동 종료 버튼 제공. 종료 시 달성 여부는 하위 다짐 전체 달성 여부로 판정
- **다짐 추가 플로우**: 목표 상세에서 [다짐 추가] → ① 대상 선택(행동 목록 또는 꼬리표 목록에서 선택) → ② 주기 선택(하루/주/월/특정 기간 + 요일·날짜·몇째 주 요일 옵션) → ③ 목표량 입력(시간 또는 횟수) + 방향 선택(이상 달성 / 이하 유지)
- **표시 구조**: 목표 리스트 아래에 해당 목표의 다짐들이 하위 리스트로 표시되고, 각 다짐마다 **하루 진행률 / 주간 진행률 / 월간 진행률**이 표기됨
### 6.5 기록 탭
- **기본(일간) 뷰**: 진입 시 오늘 하루의 기록 표시
- 시간 추적형: 각 행동을 언제부터 언제까지, 총 몇 시간 했는지
- 횟수 추적형: 언제 몇 번을 했는지(시각 포함)
- 각 기록을 탭하면 **바로 수정 화면**이 떠서 세부 데이터 수정 가능
- 상단 **날짜 이동 버튼**으로 이전/이후 날짜의 기록도 동일한 형태로 조회
- **타임테이블 뷰** (전환 버튼 제공):
- 24시간 축의 타임테이블. **하루 단위 / 일주일 단위** 보기 전환 가능
- 시간 추적형 → 해당 시간 구간만큼 영역(블록)을 차지. 횟수 추적형 → 기록된 그 시점에 점/마커로 표시
- 표시는 행동의 **아이콘 + 꼬리표 색** 사용
- **통계 영역** (아래로 스크롤):
- 태그별 하루 시간 투자량 / 횟수 달성량
- 일주일별, 한 달별 집계
- 그래프(차트)로 빈도·비율을 한눈에 파악 가능하게 (Swift Charts)
### 6.6 설정 탭
- 테마: 라이트 / 다크 선택
- 언어: 한국어(기본) / 영어 / 일본어
- **주 시작 요일** 설정 *(기본값 제안: 월요일)*
- **하루 시작 시간** 설정 *(기본값 제안: 00:00)*
- Live Activity/다이나믹 아일랜드 대표 시간 기준: 가장 먼저 시작한 행동 / 가장 나중에 시작한 행동
- **프리미엄 기능** 메뉴:
- 미결제 상태 → 유료 기능 소개 + 결제 화면으로 연결
- 결제 완료 상태 → 홈/잠금화면 위젯 사용 가능, 애플워치 앱 사용 가능, **기기 간 동기화 on/off 토글** 제공
---
## 7. 무료 / 유료(프리미엄) 구분
### 7.1 무료 사용 제한
| 항목 | 무료 한도 |
| 영역 | 인자 |
|---|---|
| 행동(Action) | 최대 **10개** |
| 목표(Goal) | 최대 **2개** |
| 다짐(Quest) | **각 목표당 최대 3개** |
### 7.2 프리미엄 결제 시
- 위 개수 제한 **전부 해제 (무제한)**
- **홈 화면 및 잠금화면 위젯** 사용 가능
- **아이패드, 맥북 앱과의 데이터 동기화** (iCloud)
- **애플워치 앱** + 워치 페이스 **컴플리케이션** 사용 가능
| 모음 | `-startEditing` `-expandGoalCard` `-pinGoals <N>` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` |
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `cloudSync`(standard bool로 강제) |
| 버블 | `-radialExpanded` |
| 일기 | `-diarySeed` `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage <N>` `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |
| 워치 | `-complicationPreview` `-complicationScroll` `-autoRunFirstAction` |
---
## 8. 위젯 명세 (프리미엄)
## 15. 작업 시 주의사항 (컨벤션)
### 8.1 공통 — 위젯 테마 옵션
위젯 설정에서 선택:
1. 앱 테마와 일치
2. 라이트 고정 / 다크 고정
3. **리퀴드 글라스(Liquid Glass) 스타일** — 유리 질감 배경 위에서 잘 보이도록 별도 디자인 변형 필요
### 8.2 행동 실행 및 표기 위젯 (홈 화면)
| 크기 | 구성 |
|---|---|
| 소형 A | 행동 **1개**의 실행 버튼. 시간 추적형은 탭으로 시작/종료 토글 + 누적 시간 표시, 횟수 추적형은 누적 횟수 표시. **표시 기간을 옵션에서 선택: 오늘 / 이번 주 / 이번 달**. 위젯에 기간도 표기되어야함.|
| 소형 B | 특정 목표1개의 하루, 주간, 월별 진행률을 표시. 가로 형태의 진행바 사용.|
| 소형 C | 특정 목표의 특정 다짐 **1개**를 골라서 소형 A와 동일한 기능을 하도록. 이때 소형 A와 다르게 누적 값을 표기하는게 아닌 진행률을 원형 진행바를 활용해서 표현(진행율을 하루일지, 주간일지, 월간일지 옵션 선택)|
| 소형 D | 특정 목표의 다짐을 4개까지 가운데에 아이콘이 있고 그것을 원형 진행바가 감싸는 형태의 객체를 2 * 2 형태로 배치하고 최상단에 목표 이름이 적혀지게 한 위젯. 추가적인 기능은 없음 |
| 중형 A | 소형 A 스타일 셀 **3개**, Interactive Widgets 기능으로 3개 영역으로 나뉜(각 영역 명확히 구분되게) 영역별로 누르면 각 실행버튼의 실행동작을 수행.|
| 중형 B | 소형 B 스타일 셀 **2개** |
| 중형 C | 소형 C 스타일 셀 **2개**, Interactive Widgets 기능으로 3개 영역으로 나뉜(각 영역 명확히 구분되게) 영역별로 누르면 각 실행버튼의 실행동작을 수행.|
| 중형 D | 소형 D 스타일인데 각 객체를 좀 더 크게 해서 1 * 4 형태로 나열, 혹 4개 이상일 경우 2줄로 객체 크기를 줄여서 최대 8개까지 표시 |
| 대형 A | 소형 A 스타일 셀 **4개**(2 by 2) |
| 대형 B | 소형 B 스타일 셀 **4개** |
| 대형 C | 소형 C 스타일 셀 **4개** |
| 대형 D | 중형 D 스타일 셀 **2개** 목표 2개를 중형 B 형태로 해서 2줄로 하거나 혹은 목표 1개에 대해 다짐을 크게 8개 혹은 작게 16개까지 표기 가능하도록|
### 8.3 통계 위젯 (홈 화면)
- 옵션에서 어떤 행동의 통계를 보여줄지 선택(일주일 통계(가로축이 하루하루인 그래프), 한달 통계(가로축이 각 주인 그래프, 혹은 하루하루인 그래프))
- 선택한 행동의 누적값 꺾은선 그래프를 위젯으로 보여줌.
| 크기 | 구성 |
|---|---|
| 소형 | 선택한 행동들의 통계그래프(앱의 통계탭에 있는 꺾은선 그래프처럼 여러 행동들 각각 다르게 해서 같은 그래프에 그려지게)(이때 한달 통계의 경우 가로축이 하루하루인 그래프는 제외)|
| 중형 | 소형과 동일한데 사이즈를 더 키워서, 그리고 한달 통계를 선택했을 경우 가로축이 하루하루인 그래프 선택 가능)|
| 대형 | 중형에서 사이즈를 더 키운 형태 |
### 8.4 잠금화면 위젯
- **소형(1칸) 위주로 최소한만** 구성
- 종류: 특정 목표 1개의 달성률 / 해당 목표에 속한 다짐들의 달성 여부 표시 등 간단한 정보 위주
- 점박이로 표기하거나 혹은 원형 진행바 사용하거나 아니면 단순하게 숫자만 적혀져있게 하던가 등등 간단하면서도 다양한 옵션 제공
---
## 9. 애플워치 앱 (프리미엄)
### 9.1 워치 앱 본체
- 진입 시 **꼬리표(태그) 목록**이 나열됨 → 태그를 탭하면 그 태그에 속한 **행동 목록** 표시
- 행동을 탭하면: 횟수 추적형 → 카운트 +1 / 시간 추적형 → 측정 시작·정지 토글
- **iPhone 앱과 실시간 데이터 연동** 필수 (워치에서 시작한 측정이 폰에도 반영, 그 반대도 동일)
### 9.2 컴플리케이션 (워치 페이스 요소)
1. **현재 현황 컴플리케이션**
- 시간 측정 중이면 어떤 행동이 실행 중인지 표시
- 표시 기준은 다이나믹 아일랜드/잠금화면과 **동일한 설정을 따름** (여러 개면 설정된 기준의 대표 1개 + 추가 실행 개수 별도 표시, 추가 개수가 없으면 개수 표시 생략)
- 실행 중인 것이 없으면 "실행 중 아님" 상태로 표시
2. **목표 달성률 컴플리케이션**
- 지정한 특정 목표의 달성률 표시(이떄 하루 달성일지 주간 달성일지 월간 달성일지 선택가능)
3. **다짐 달성률 컴플리케이션**
- 특정 목표의 특정 다짐 1개만의 하루 혹은 주간, 혹은 월간 달성 정보 표기(크기에 따라서 3개 모두 표기하는 컴플리케이션도 추가)
- **"넘기면 안 되는" 다짐**(시간/횟수 상한형)의 경우 일반 달성형과 **다른 시각 표현** 사용 (예: 한도 대비 사용량으로 표시)
---
## 10. 시리 단축어 (프리미엄)
### 10.1 단축어 앱에서 동작 가능한 기능 나열
- 특정 목표의 하루/주간/월간 진행률 데이터 가져오기
- 특정 목표에 소속된 다짐의 하루/주간/월간 진행률 데이터 가져오기
- 특정 행동의 하루/주간/월간 누적값 데이터 가져오기
- 시간 측정 타입 행동 시작하기, 종료하기
- 횟수 측정 타입 행동의 횟수 바로 추가하기
1. **CloudKit 모델 규칙 준수** (§3) — 새 필드는 반드시 기본값, 관계는 optional 저장 + 접근자
2. mutation 후 `DataChange.commit` 호출 잊지 말 것 (위젯·워치·LiveActivity 갱신 경로)
3. 배치·노출·접힘 같은 "보기 설정"은 모델이 아니라 LocalPrefs(App Group defaults)에
4. 집계는 항상 DayMath/Aggregator 경유 — 하루 경계 분할·주 시작 요일이 자동 반영됨
5. 진행률 게이지에는 `ratio`, 퍼센트 문구에는 `displayRatio` — atMost 다짐의 이중 의미 주의
6. 사용자 문구는 `String(localized:)`(또는 SwiftUI 리터럴)만 — 추가 후 §2.3 l10n 루틴 실행. 개발용 문구는 `Text(verbatim:)`
7. 화면과 내보내기가 같은 값을 보여야 하는 곳은 ExportBuilder 스냅숏을 공유할 것 (일기 요약이 그 예)
8. 위젯에서 흰색 하드코딩 금지 — `widgetRenderingMode` 분기 (§10)
9. 시뮬레이터 검증 스크린샷은 `xcrun simctl io <sim> screenshot`, 런치 인자는 콜드 스타트로

View File

@ -451,6 +451,23 @@
}
}
},
"%@회": {
"comment": "횟수 평균 (소수 1자리)",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%@ times"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%@回"
}
}
}
},
"%lld": {
"localizations": {
"en": {
@ -10156,4 +10173,4 @@
}
},
"version": "1.0"
}
}

View File

@ -787,6 +787,7 @@ private struct DiarySummaryPage: View {
guard hidden != entry.hiddenActionIDs else { return }
entry.hiddenActionIDs = hidden
entry.updatedAt = .now
try? context.save()
}
.task(id: entry.calendarEventIDs) {
#if DEBUG

View File

@ -664,7 +664,7 @@ enum ExportBuilder {
type == .time ? Format.durationShort(value) : String(localized: "\(Int(value.rounded()))")
}
func averageLabel(_ value: Double, type: TrackingType) -> String {
type == .time ? Format.durationShort(value) : String(format: "%.1f회", value)
type == .time ? Format.durationShort(value) : Format.countAverage(value)
}
func tableRow(name: String, color: Color, symbol: String, total: Double, type: TrackingType) -> ExportTableData.Row {
var values = [valueLabel(total, type: type), averageLabel(total / Double(elapsedDays), type: type)]

View File

@ -712,7 +712,7 @@ struct StatsTabView: View {
private func averageLabel(_ value: Double, type: TrackingType) -> String {
switch type {
case .time: return Format.durationShort(value)
case .count: return String(format: "%.1f회", value)
case .count: return Format.countAverage(value)
}
}

View File

@ -59,4 +59,12 @@ enum Format {
static func percent(_ ratio: Double) -> String {
"\(Int((ratio * 100).rounded()))%"
}
/// ("3.5"). String(format:)
/// · '' .
static func countAverage(_ value: Double) -> String {
let rounded = (value * 10).rounded() / 10
return String(localized: "\(rounded.formatted(.number.precision(.fractionLength(1))))",
comment: "횟수 평균 (소수 1자리)")
}
}

View File

@ -27,15 +27,17 @@ extension Color {
})
}
/// "#RRGGBB" hex
/// "#RRGGBB" hex .
/// (P3) getRed 0...1
/// /1 %02X 릿 hex("#FFFFFFE6") .
var hexString: String {
let ui = UIColor(self)
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
ui.getRed(&r, green: &g, blue: &b, alpha: &a)
return String(
format: "#%02X%02X%02X",
Int(round(r * 255)), Int(round(g * 255)), Int(round(b * 255))
)
func component(_ value: CGFloat) -> Int {
Int(round(min(max(value, 0), 1) * 255))
}
return String(format: "#%02X%02X%02X", component(r), component(g), component(b))
}
}