diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 3333e71..fa57e11 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -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=' build +# 스토어 동일 구성 (Release + HaruDanim.storekit) +xcodebuild -project Haru_Danim.xcodeproj -scheme Haru_Danim-Store \ + -destination 'platform=iOS Simulator,id=' build +``` + +- 자주 쓰는 시뮬레이터: iPhone `B1346141-3991-46BF-A12B-A66184FAEBC2`, iPad Pro 11(M5) `CC81FCE1-20D6-460F-9E90-BE949E7388FC` +- **SourceKit 인라인 진단은 항상 오탐 노이즈**("No such module 'UIKit'" 등) — 판정은 xcodebuild 결과만 신뢰 +- DEBUG 런치 인자 검증은 **콜드 스타트 필수** (terminate 후 재실행). 목록은 §14 +- 캘린더 권한 부여: `xcrun simctl privacy 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-`로 보존 후 새로 시작 (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 `, `-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 ` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` | +| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` | +| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) | +| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `cloudSync`(standard bool로 강제) | +| 버블 | `-radialExpanded` | +| 일기 | `-diarySeed` `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage ` `-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 단축어 앱에서 동작 가능한 기능 나열 -- 특정 목표의 하루/주간/월간 진행률 데이터 가져오기 -- 특정 목표에 소속된 다짐의 하루/주간/월간 진행률 데이터 가져오기 -- 특정 행동의 하루/주간/월간 누적값 데이터 가져오기 -- 시간 측정 타입 행동 시작하기, 종료하기 -- 횟수 측정 타입 행동의 횟수 바로 추가하기 \ No newline at end of file +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 screenshot`, 런치 인자는 콜드 스타트로 diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 299a606..e5d637b 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -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" -} +} \ No newline at end of file diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index c30d595..e5dfc47 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -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 diff --git a/myApp/HaruDanim/IOS/Views/ExportImageView.swift b/myApp/HaruDanim/IOS/Views/ExportImageView.swift index 2bc529e..4b35499 100644 --- a/myApp/HaruDanim/IOS/Views/ExportImageView.swift +++ b/myApp/HaruDanim/IOS/Views/ExportImageView.swift @@ -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)] diff --git a/myApp/HaruDanim/IOS/Views/StatsTabView.swift b/myApp/HaruDanim/IOS/Views/StatsTabView.swift index 894327d..ec0bc7f 100644 --- a/myApp/HaruDanim/IOS/Views/StatsTabView.swift +++ b/myApp/HaruDanim/IOS/Views/StatsTabView.swift @@ -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) } } diff --git a/myApp/HaruDanim/Shared/Formatters.swift b/myApp/HaruDanim/Shared/Formatters.swift index cf55799..218ada1 100644 --- a/myApp/HaruDanim/Shared/Formatters.swift +++ b/myApp/HaruDanim/Shared/Formatters.swift @@ -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자리)") + } } diff --git a/myApp/HaruDanim/Shared/Theme.swift b/myApp/HaruDanim/Shared/Theme.swift index a024095..248ae8e 100644 --- a/myApp/HaruDanim/Shared/Theme.swift +++ b/myApp/HaruDanim/Shared/Theme.swift @@ -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)) } }