mycode/myApp/HaruDanim/CLAUDE.md
songyc macbook 0aed7a9ec5 fix(goal): evaluate ended goals at launch and foreground, not only on goal tab
종료일이 지난 목표의 자동 판정(evaluateIfEnded)이 목표 탭 진입 시에만
실행돼, 목표 탭을 열지 않으면 위젯·워치·모음 탭 카드에 '진행 중'으로
계속 남아 있었다. 앱 실행/포그라운드 복귀(scenePhase .active) 시점에
같은 판정을 추가로 실행한다.

- 목표 탭 onAppear 로직과 동일한 코드 — 진행 중 목표만 조회, 변경이
  있을 때만 DataChange.commit (위젯·워치·라이브 액티비티 갱신)
- 다짐 없는 목표는 evaluateIfEnded가 건드리지 않으므로 '확인 필요'
  직접 선택 흐름은 그대로
- 위젯 확장에서는 판정하지 않음 (프로바이더 경합·CloudKit 내보내기
  지연 위험 회피)
- 검증용 DEBUG 인자 -endGoalYesterday "제목" 추가. 시뮬레이터에서
  목표 탭을 열지 않은 콜드 스타트만으로 statusRaw가 achieved로
  전환되는 것을 스토어 조회로 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 19:37:43 +09:00

28 KiB
Raw Blame History

하루 다님 (HaruDanim) — 프로젝트 가이드

한 줄 소개: 하루의 습관과 시간을 추적하는 iOS 앱. "행동"의 시간을 측정하거나 횟수를 기록하고, 이를 바탕으로 목표(Goal)와 다짐(Quest) 의 달성률을 관리한다. 프리미엄(StoreKit 2)으로 위젯·워치·iCloud 동기화·iPad 일기를 제공한다.

문서 버전: v2.0 (2026-07-13) — 실제 구현 코드를 기준으로 작성된 현행 명세. 새 세션에서 이 문서만 읽어도 앱 전체를 파악할 수 있도록 유지한다. 기능이 바뀌면 이 문서도 함께 갱신할 것.


1. 개요

항목 내용
앱 이름 하루 다님 (스플래시 멘트: "당신의 하루에 다녀가다")
플랫폼 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.1 프로젝트 구조 (타깃별 폴더)

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 빌드 커맨드

# 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:) 문자열 추가 후:

# 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:) 사용.

모델 핵심 필드 비고
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

4. 핵심 계산 규칙 (Shared/DayMath.swift, Shared/QuestProgress.swift)

4.1 논리적 하루 (DayMath)

  • 설정 "하루 시작 시간"(dayStartMinutes)·"주 시작 요일"(weekStartWeekday, 기본 월요일)이 모든 집계의 기준
  • dayKey(for:): 시각 → 그 시각이 속한 논리적 하루의 달력일 자정. 경계 이전 시각은 전날 귀속
  • 세션은 실제 시각 그대로 하나로 저장하고, 집계는 구간 겹침(overlap)으로 자동 분할 — 하루 경계를 걸친 세션이 날짜별로 나뉘어 계산됨. Aggregator.seconds/count가 이 규칙의 단일 구현이며 화면·위젯·내보내기 모두 이를 공유

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 다짐은 그날 모수에서 제외

4.3 목표 진행률·판정 (Goal extension)

  • combinedSpanRatio(span): 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급). 모음 탭 카드·위젯·워치·시리가 공유
  • 달성 판정: 종료일 경과 자동(evaluateIfEnded — 앱 실행/포그라운드 복귀와 목표 탭 진입 시 실행. 위젯 확장에서는 판정하지 않음) 또는 수동 종료(manualFinish). questAchievementRatio(달성 다짐=1, 미달성=현재 주기 ratio 클램프, 수행일 아님=1) × 100 ≥ achieveThresholdPercent 0.0001 이면 달성. 기준 100 = 레거시 allSatisfy와 동일
  • 다짐 없는 목표: 종료일 도래 시 "확인 필요"(달성했나요?) 상태 → 사용자가 직접 선택. 수동 종료도 동일
  • 진행률 표시 연산(combinedSpanRatio 등)은 판정 기준과 무관 — 건드리지 말 것

5. 데이터 계층

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

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(기기 로컬)

5.3 변경 전파

  • 앱 내 모든 mutation 끝에 DataChange.commit(context:): save → LiveActivity sync → WidgetCenter.reloadAllTimelines() → 워치 스냅숏 push. 위젯/시리 경로는 IntentStore.performWrite가 같은 역할(한 컨텍스트로 조회·수정·저장 원자 처리 + 저장 1회 재시도)
  • CloudSyncMonitor: NSPersistentStoreRemoteChange를 2초 디바운스로 받아 위젯·워치·LiveActivity 갱신 (화면은 건드리지 않음)
  • AppRefresh(모음 탭 새로고침 버튼): 루트 뷰 .id(token) 리셋으로 모든 @Query 재조회 — 다른 기기 변경이 화면에 안 보일 때의 수동 해결책

6. 내비게이션·화면 구성

  • 스플래시: 앱 로고(AppLogo) + 이름 + 멘트, 1.2초 후 페이드아웃 (ContentView)
  • 탭 정의 (AppTab): 모음(main) · 행동 · 꼬리표 · 목표 · 기록 · 통계 · 일기(iPad 전용) · 설정
  • iPhone 기본: 하단 탭바에 노출 탭 1~3개(기본 모음·행동·기록) + "더보기" 탭(나머지 목록). 설정에서 구성
  • iPhone 실험 옵션: "글래스 버블 메뉴"(RadialNavigationView) — 하단 중앙 FAB를 누르면 유리 구슬 탭 버튼들이 부채꼴로 펼쳐짐. 라벨은 글라스 알약 배경. 순서는 설정에서 편집(기기 로컬)
  • iPad·Mac: 항상 NavigationSplitView 사이드바 (SidebarRootView, DeviceLayout.isPad로 판정)
  • AppRouter: 탭 전환 + 탭 간 전달값(모음 탭 롱프레스 → 기록/통계 탭 행동 필터 예약, 더보기 내부 경로 진입 포함)

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 그린 틴트 블랙
Surface(카드) white #1B211D
  • 노랑 = "측정 중" 시그널(테두리·record 점·정지 버튼)과 강조. 꼬리표 프리셋 12색
  • AppGroup.defaults는 반드시 단일 인스턴스 사용 — @AppStorage(store:)가 인스턴스를 관찰하므로 매번 새로 만들면 변경이 전파 안 됨(테마 즉시 적용 버그의 원인이었음)
  • 아이콘: light/dark/tinted 변형 등록(로고 v4: 시계 행성 위 걷는 사람)

14. DEBUG 런치 인자 (검증용, 모두 DEBUG 빌드 전용)

공통: -seedDemo YES(데모 데이터: 태그3·행동6·기록 7일치·목표6·자정 걸친 세션), -premium YES/NO, -startTab <main|action|tag|goal|history|stats|diary|settings>, -navStyle radial, -themeAutoToggle YES

영역 인자
모음 -startEditing -expandGoalCard -pinGoals <N> -openStatsFor "이름" -openHistoryFor "이름" -autoStart "이름"
목표 -goalShowEditor -goalShowFinished -goalReorder -goalScrollBottom -endGoalYesterday "제목"(종료일을 어제로 — 실행 시점 자동 판정 검증)
기록/통계 -historyMode timetable -historyWeekly -excludeActions "이름,이름" -historyShowFilter -statShowFilter `-statSpan <day
설정/기타 -settingsScrollGoal -settingsScrollPremium -premiumPreview -helpPreview `-widgetPreview YES
버블 -radialExpanded
일기 -diarySeed -diaryOpenToday(-startTab diary 필수) -diaryPage <N> -diaryShowConfig -diaryShowExport(달력 화면 onAppear — -diaryOpenToday와 함께 쓰면 안 뜸) `-diaryExportRun pdf
워치 -complicationPreview -complicationScroll -autoRunFirstAction

15. 작업 시 주의사항 (컨벤션)

  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, 런치 인자는 콜드 스타트로