feat(ux): UX 감사 반영 배치 — 시스템 테마·온보딩·일기 잠금·기간 비교 외

2026-07-23 사용성 감사(A그룹 5건 + 지시 추가분)를 한 번에 반영.

[테마] '시스템 설정 따름' 추가 + 기본값 light→system. "system"은 스킴을
  강제하지 않아(preferredColorScheme nil) 앱·위젯('앱 일치' 옵션,
  WidgetThemeOption.scheme: ColorScheme?)·DEBUG 위젯 미리보기까지
  시스템 라이트/다크를 그대로 따른다
[온보딩] 첫 실행 3장 소개(OnboardingView — 행동→목표·다짐→정리).
  데이터가 하나도 없고 본 적 없을 때만 1회(건너뛰기 포함 재표시 없음,
  onboarding.done), 시드·마케팅 촬영 플로우는 자동 건너뜀.
  설정 → 지원 '앱 소개 다시 보기'로 재열람
[일기 잠금] 설정 → 일기(iPad 전용 표시) 토글 — DiaryLock이
  .deviceOwnerAuthentication으로 Face ID/Touch ID/Optic ID+암호 폴백을
  기기별 분기 없이 처리. 일기 탭 진입 게이트(DiaryLockGateView),
  백그라운드 재잠금, 토글 변경 시 인증 요구, 잠글 수단 없는 기기는
  통과(영구 잠김 방지). NSFaceIDUsageDescription 추가(Info.plist+
  InfoPlist.xcstrings ko/en/ja)
[통계] 이전 기간 비교 카드(맨 위) — 총 시간·횟수를 어제/지난주/지난달과
  비교(▲▼%, 이전 기록 없으면 상태 문구). 이전 기간은 관계 기반
  Aggregator로 직접 집계, 필터 반영. 화면 전용(내보내기 미포함 의도)
[스플래시] 1.2→0.7초 단축 (하루에도 여러 번 여는 앱)
[워치] 행동 실행 탭 시 WKInterfaceDevice.play(.click) 햅틱 — 화면을
  안 보고 탭해도 접수 확인
[컴플리케이션] rectangular 빈 상태에 해결 안내("목표 편집에서
  '애플워치에서 보기'를 켜면 나타나요") — 옵트인 발견성 보완
[문구] 행동 편집기 꼬리표 색 규칙(여러 개면 가장 먼저 만든 꼬리표),
  새로고침 버튼 accessibilityHint

- 도움말 3주제 추가(새로고침 버튼·일기 잠금·이전 기간과 비교)
- 신규 문구 31키 en/ja 완역 + 워치 카탈로그 2키, missing/stale 0
- CLAUDE.md §6·§6.6·§6.7·§6.8·§10·§11·§14 갱신, DEBUG 인자
  -showOnboarding·-diaryLockScreen 추가
- 검증: Debug/워치/Store 빌드, 온보딩·설정·비교 카드·잠금 게이트·
  컴플리케이션 스크린샷

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
songyc macbook 2026-07-23 02:02:38 +09:00
parent 1dea41b472
commit ac40e14cfe
19 changed files with 1388 additions and 413 deletions

View File

@ -164,7 +164,8 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
## 6. 내비게이션·화면 구성
- **스플래시**: 앱 로고(AppLogo) + 이름 + 멘트, 1.2초 후 페이드아웃 (`ContentView`)
- **스플래시**: 앱 로고(AppLogo) + 이름 + 멘트, **0.7초** 후 페이드아웃 (`ContentView` — 하루에도 여러 번 여는 앱이라 1.2→0.7초로 단축, 2026-07-23 UX 감사)
- **첫 실행 온보딩**(`OnboardingView`, 3장: 행동→목표·다짐→정리): 스플래시 뒤 **데이터가 하나도 없고 본 적 없을 때만** 1회 fullScreenCover(`onboarding.done` standard 플래그, 건너뛰기 포함 재표시 없음). 시드·마케팅 촬영은 시드가 행동을 먼저 만들어 자동 건너뜀. 설정 → 지원 '앱 소개 다시 보기'로 재열람
- **탭 정의** (`AppTab`): 모음(main) · 행동 · 꼬리표 · 목표 · 기록 · 통계 · 일기(iPad 전용) · 설정
- **iPhone 기본**: 하단 탭바에 **노출 탭 1~3개**(기본 모음·행동·기록) + "더보기" 탭(나머지 목록). 설정에서 구성
- **iPhone 실험 옵션**: "글래스 버블 메뉴"(`RadialNavigationView`) — 하단 중앙 FAB를 누르면 리퀴드 글라스(`glassEffect(.regular.interactive())`) 유리 구슬 탭 버튼들이 부채꼴로 펼쳐짐. 아이콘+이름이 구슬 안에 함께. 모든 유리 요소는 하나의 `GlassEffectContainer`에서 한 패스로 렌더링(성능)되고 FAB 근처에서 액체처럼 분리·합체 모핑. 2단계 연출: 삽입(FAB에서 모핑) → bloomed(스태거 스프링으로 아치 확산). ⚠️ 컨테이너 안 유리 요소에는 glassEffect 뒤 opacity/scale이 콘텐츠에 온전히 적용되지 않음 — 숨김은 계층 제거(`if`), 이동은 `offset`으로만. 순서는 설정에서 편집(기기 로컬). **두 번 눌러 바로 이동**(설정 토글+탭 선택, `settings.radialDoubleTapTab` 빈 값=꺼짐): 닫힌 FAB를 0.35초 안에 두 번 누르면 지정 탭으로 즉시 이동 — 첫 탭을 지연시키는 더블탭 제스처 대신 첫 탭은 즉시 열고 두 번째 탭에서 분기(반응성 유지, 버블이 피어나다 흡수되는 연출)
@ -212,6 +213,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
### 6.6 통계 탭 (`StatsTabView`) — 기록과 분리된 독립 탭
- **범위 조회 + 1회 버킷 집계**(`StatsChartsView`): 보고 있는 기간 범위만 @Query로 가져오고, 조회된 기록을 한 번만 순회해 행동별·하루별 버킷(`StatsData`)을 만들어 모든 카드가 공유 — 카드마다 Aggregator로 전체 기록을 재순회하지 않음. 하루 분할은 `splitByDay`(기존과 동일 수치). 대량 검증은 `-seedBulk`
- 하루: 꼬리표별 시간/횟수 가로 막대. 주간: 행동별 일별 꺾은선(행동 색+범례)+합계 막대+합계·평균 표. 월간: 주차별+일별 꺾은선+합계+표. **동명 행동/꼬리표는 표시 이름을 "이름 (2)"로 구분**(`Format.disambiguated` — 이름이 곧 시리즈 키·범례·Identifiable id라 동명이 한 선으로 합쳐지고 id가 충돌하던 결함의 수정. 통계 탭·통계 내보내기·⑤ 위젯 3표면 공통, 꼬리표 집계는 identity 키. 2026-07-22)
- **이전 기간 비교 카드**(맨 위, 2026-07-23): 현재 하루/주/월의 총 시간·횟수를 바로 앞 기간(어제/지난주/지난달)과 비교(▲▼%, 이전 기록 없으면 상태 문구). 이전 기간은 화면 @Query 범위 밖이라 관계 기반 Aggregator로 직접 집계(행동 상세 누적과 같은 경로), 필터 반영. **화면 전용 — 통계 내보내기 리포트에는 넣지 않는 의도된 차이**
- 평균 분모는 "이미 시작된" 날/주만 (미래로 희석 방지). 필터·내보내기는 기록 탭과 동일 패턴
### 6.7 일기 탭 (iPad 전용, 프리미엄 — `DiaryView`, `DiaryNotePage`, `DiaryCalendarEvents`, `DiaryExportSheet`)
@ -221,12 +223,13 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
- **요약 페이지 섹션** (표시·순서는 "첫 화면 구성" 시트, 전 날짜 공통, standard defaults): 오늘 기분(대형 정사각 타일: 이모지/사진) · 요약 지표 · 이 날의 목표(그날 기준 다짐 현황, 주간/월간은 그날까지 누적) · 오늘 할 일 · 타임테이블 · 기록 상세 · 통계 막대. 화면 720pt 이상이면 타임테이블 좌측 고정 2컬럼
- 타임테이블 카드 우상단 버튼: **필터**(그날 기록된 행동만 나열된 팝업에서 표시 선택, 날짜별 저장 `hiddenActionIDs`, 숨긴 개수 노란 배지 — 타임테이블에만 적용, 요약 수치·기록 목록은 전체 유지) + **캘린더**(그날의 EKEvent 선택, 날짜별 저장, **점선 테두리 + 채움 0.3 블록** — 행동 블록은 불투명 채움이라 스타일로 구분, 옅은 채움 0.14는 가시성 부족으로 폐기). 둘 다 내보내기에 동일 반영
- '이 날의 목표' 카드 우상단 **목표 필터**: 그날 진행 중인 목표 중 표시할 것 선택(날짜별 저장 `hiddenGoalIDs`, 숨긴 개수 노란 배지, 열람 전용에선 버튼 숨김). 이미지 내보내기의 요약에는 목표 카드가 없으므로 내보내기 무영향
- **일기 잠금**(설정 → 일기, iPad 전용 표시 — 2026-07-23): 켜면 일기 탭 진입 시 `DiaryLockGateView`가 전체를 가리고 `DiaryLock`(.deviceOwnerAuthentication — Face ID/Touch ID/Optic ID+암호 폴백을 시스템이 자동 처리)으로 해제. 백그라운드 진입 시 재잠금(App scenePhase), 설정 토글은 켜고 끌 때 모두 인증 요구, 잠글 수단이 없는 기기(암호 미설정)는 통과(영구 잠김 방지). 키 `diary.lockEnabled`(standard — 기기 로컬), `NSFaceIDUsageDescription` 필수(IOS/Info.plist+InfoPlist.xcstrings)
- **프리미엄 만료 열람 모드**: 이미 쓴 일기(hasContent)가 있으면 잠금 화면 대신 달력을 열람 전용으로 연다 — 배너 + 일기 있는 날짜만 활성, `environment(\.diaryReadOnly)`로 전 화면의 편집 UI(기분/할일/필기/필터/캘린더/⋯메뉴) 비활성, 열람 진입은 엔트리를 생성하지 않음. 일기를 쓴 적 없으면 기존 잠금 안내
- **양식(속지)** (`DiaryTemplates.swift`): 달력 툴바 '양식 관리'에서 PDF(≤20MB)·사진을 가져와 라이브러리로 관리. 페이지 추가 시 [빈 캔버스/줄 노트/내 양식] 선택(양식 0개면 시트 없이 기존처럼 즉시 추가), 여러 쪽 PDF는 2단계 쪽 썸네일 선택(최대 24쪽, 원본은 통째 1개 저장 + 쪽 인덱스 참조). 렌더: 페이지 논리 크기에 aspect-fit(좌표계 불변), **PDF는 줌 종료 시 현재 배율로 백그라운드 재래스터**(`DiaryTemplateImageView` — 펜슬 캔버스 선명도 훅에 함께 연결), 이미지는 가져올 때 긴 변 3072px 리샘플(필기 래스터 상한 3×와 동일 → 흐림 없음). 양식 페이지에선 줄 노트 토글 숨김. 양식 삭제 시 사용 중 페이지 수 경고 → 해당 페이지는 빈 캔버스 폴백(필기·요소 보존). 내보내기에도 동일 반영
- 노트 페이지: 768×1024 논리 좌표 고정 + scaleEffect. 배치 요소: 사진·도형(사각/원/화살표/선)·**텍스트 상자**(키보드 입력, 배치 모드에서 '글 수정'으로 내용·색 편집). ⋯ 메뉴 '페이지 순서 변경'으로 노트 페이지 드래그 재배열(요약은 항상 첫 장). **실기기 필기는 펜슬 전용, 손가락은 이동/줌만**(시뮬레이터는 anyInput). 핀치줌 최대 4배, `SharpPencilCanvasView`가 줌에 맞춰 래스터 배율 유지(획 선명도). ⚠️ 배율 적용은 **뷰의 contentScaleFactor만** — 서브레이어 contentsScale+setNeedsDisplay를 직접 건드리면 PencilKit 획 타일 픽셀이 지워진 채 재렌더되지 않아 확대 시 필기가 사라진다(수정된 버그). 줄 노트(간격 4단계)·사진·도형(사각/원/화살표/선) 배치 모드(드래그·핀치 크기 조절, 저장은 비율 좌표)
### 6.8 설정 탭 (`SettingsView`)
- 테마(라이트/다크 — 즉시 적용, App Group 저장으로 위젯 공유) / 언어(재시작 적용, AppleLanguages 오버라이드)
- 테마(**시스템 설정 따름(기본)**/라이트/다크 — 즉시 적용, App Group 저장으로 위젯 공유. "system"은 스킴을 강제하지 않아 앱·위젯 모두 시스템 모드를 따름 — 2026-07-23 UX 감사에서 추가, 기본값도 light→system) / 언어(재시작 적용, AppleLanguages 오버라이드)
- iPhone만: 내비게이션 방식(탭바/버블) + 탭바 구성 or 버블 순서·두 번 눌러 바로 이동(토글+대상 탭)
- 모음 탭 목표 카드 선택(개수 무제한)+표시 방식 / 주 시작 요일 / 하루 시작 시간 / 짧은 기록 무시(**기기별 설정** — App Group defaults라 iCloud 동기화 안 됨. 검사는 종료를 실행한 기기의 설정 기준이고 워치 종료는 페어링 아이폰에서 실행되므로 아이폰 설정 적용 — 기기 간 시작·종료 시 값이 다르면 짧은 기록이 저장될 수 있음. 실사용 보고로 확인된 의도된 동작, 설정 푸터·도움말에 명시) / **장시간 측정 알림**(1~12시간, 켜는 순간 알림 권한 요청 — `SessionAlertManager`가 LiveActivityManager.sync 길목에서 진행 세션과 예약을 동기화, 세션 종료·설정 변경 시 예약 제거) / 대표 시간 기준(가장 먼저·나중에 시작 — 기본 latest)
- 데이터: **데이터 내보내기** — 행동·시간·횟수·목표·다짐을 CSV 5파일로 (ISO 8601, UTF-8 BOM, 무료 포함)
@ -267,7 +270,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
## 10. 홈·잠금화면 위젯 (`Widgets/` — 모두 프리미엄 게이트)
번들: TrackingLiveActivity + 6종 홈 위젯 + 잠금화면 1종. 모든 홈 위젯 공통 설정: **테마(앱 일치/라이트/다크)**. 홈 화면이 틴트/클리어(리퀴드 글라스) 렌더링 모드면 커스텀 배경·강제 스킴을 얹지 않고 시스템 바이브런트에 맡김(white-on-white 방어, `WidgetTheme.swift`).
번들: TrackingLiveActivity + 6종 홈 위젯 + 잠금화면 1종. 모든 홈 위젯 공통 설정: **테마(앱 일치/라이트/다크)** — '앱 일치'는 앱 테마가 "system"이면 스킴을 강제하지 않고 위젯 환경의 시스템 모드를 따른다(`WidgetThemeOption.scheme: ColorScheme?`). 홈 화면이 틴트/클리어(리퀴드 글라스) 렌더링 모드면 커스텀 배경·강제 스킴을 얹지 않고 시스템 바이브런트에 맡김(white-on-white 방어, `WidgetTheme.swift`).
| 위젯 (kind) | 내용 |
|---|---|
@ -291,14 +294,14 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
## 11. 애플워치 (프리미엄)
- **워치 앱**: 첫 화면 = [측정 중 섹션 — **행을 탭하면 대표 측정 즉시 종료**(행동 목록과 같은 run 명령 재사용, 오른쪽 정지 아이콘+'누르면 측정이 종료돼요' 푸터. 대표 식별은 `WatchSnapshot.runningActionID`(optional, 구버전 캐시는 이름 매칭 폴백 — `runningAction` 헬퍼)] + **즐겨찾기**(앱에서 즐겨찾기한 행동 직행, 나열 순서 = 아이폰 모음 탭 배치 = 즐겨찾기 순서 시트와 동일, WatchActionInfo.isFavorite는 구버전 캐시 호환 위해 optional) + 꼬리표 목록 → 행동 목록 → 탭으로 실행. iPhone과 실시간 연동
- **워치 앱**: 행동 탭 실행 시 `WKInterfaceDevice.play(.click)` 햅틱(손목에선 화면을 안 보고 탭하는 경우가 많아 접수 확인 — 2026-07-23). 첫 화면 = [측정 중 섹션 — **행을 탭하면 대표 측정 즉시 종료**(행동 목록과 같은 run 명령 재사용, 오른쪽 정지 아이콘+'누르면 측정이 종료돼요' 푸터. 대표 식별은 `WatchSnapshot.runningActionID`(optional, 구버전 캐시는 이름 매칭 폴백 — `runningAction` 헬퍼)] + **즐겨찾기**(앱에서 즐겨찾기한 행동 직행, 나열 순서 = 아이폰 모음 탭 배치 = 즐겨찾기 순서 시트와 동일, WatchActionInfo.isFavorite는 구버전 캐시 호환 위해 optional) + 꼬리표 목록 → 행동 목록 → 탭으로 실행. 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) = 선택지 전부**: 워치는 컴플리케이션 파라미터 편집 UI가 없어 페이스 편집기의 목록이 recommendations()가 돌려준 조합으로 한정된다 — 스냅숏에 담긴 **목표 전부 × 3기간, 다짐 전부 × 3기간**을 상한 없이 나열할 것(예전 prefix(2) 캡이 "3번째 목표부터 페이스에 추가 불가" 버그였음). 워치 앱이 스냅숏을 적용할 때 `invalidateConfigurationRecommendations()`를 호출해 목표·다짐 변경이 목록에 자동 반영된다
- **스냅숏의 목표 = 진행 중 ∩ '애플워치에서 보기' 켠 것**(`makeSnapshot`의 단일 필터 지점, `LocalPrefs.showsOnWatch`) — 목표·다짐이 많아지면 목록이 폭증하므로 옵트인(기본 전부 꺼짐 → 컴플리케이션은 '목표 없음' 빈 상태로 시작). 완료 목표는 진행 중 조건으로 자동 제외. 행동·측정 중 상태(워치 앱 화면, 현재 현황)는 필터와 무관. 페이스에 이미 올린 미체크·완료 목표 컴플리케이션은 스냅숏 첫 목표로 폴백(없으면 빈 상태)
- **스냅숏의 목표 = 진행 중 ∩ '애플워치에서 보기' 켠 것**(`makeSnapshot`의 단일 필터 지점, `LocalPrefs.showsOnWatch`) — 목표·다짐이 많아지면 목록이 폭증하므로 옵트인(기본 전부 꺼짐 → 컴플리케이션은 '목표 없음' 빈 상태로 시작**rectangular 빈 상태는 "목표 편집에서 '애플워치에서 보기'를 켜면 나타나요" 해결 안내 포함**, circular/inline은 공간상 짧은 문구 유지, 2026-07-23). 완료 목표는 진행 중 조건으로 자동 제외. 행동·측정 중 상태(워치 앱 화면, 현재 현황)는 필터와 무관. 페이스에 이미 올린 미체크·완료 목표 컴플리케이션은 스냅숏 첫 목표로 폴백(없으면 빈 상태)
- **유령 타이머 안전장치**(현재 현황): '측정 중' 스냅숏이 `generatedAt` 기준 2시간을 넘기면 무한 타이머 대신 "새로고침 필요" 표시로 강등(`StatusProvider.runningTrustInterval`) — 종료 푸시가 유실돼도 유령 타이머가 몇 시간씩 남지 않음. 워치 앱을 열면 즉시 복구, 진짜 장시간 측정도 다음 스냅숏에 타이머 복귀(데이터 무영향)
- ⚠️ `recommendations()`의 description에 포맷 텍스트 금지 — `Text(verbatim:)`만 (WidgetKit assertion으로 익스텐션 즉사)
@ -337,9 +340,9 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
| 측정 알림 | `-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 수치 37건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) |
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor <session|count>`(최신 기록 수정 시트) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo <help-main 등>`: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-widgetPreview YES|lock` `-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 복사) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
| 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) |
| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage <N>` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |
| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage <N>` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryLockScreen`(잠금 게이트 화면 강제 — 인증 없이 표시) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |
| 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo <stale\|quest>` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) |
---

View File

@ -12,6 +12,7 @@ import Observation
import OSLog
import SwiftUI
import WatchConnectivity
import WatchKit
import WidgetKit
private let watchLog = Logger(subsystem: "com.yechan.HaruDanim.watchkitapp", category: "WatchStore")
@ -51,6 +52,8 @@ final class WatchStore: NSObject {
/// ( / +1)
func run(_ action: WatchActionInfo) {
//
WKInterfaceDevice.current().play(.click)
send([
WatchSync.commandKey: WatchSync.runCommand,
WatchSync.actionIDKey: action.id.uuidString,

File diff suppressed because it is too large Load Diff

View File

@ -138,7 +138,11 @@ struct GoalRateComplicationView: View {
.gaugeStyle(.accessoryCircular)
}
} else {
ComplicationEmptyView(message: String(localized: "목표 없음"))
// ( , §9.1) rectangular
//
ComplicationEmptyView(message: family == .accessoryRectangular
? String(localized: "표시할 목표가 없어요 — iPhone 목표 편집에서 '애플워치에서 보기'를 켜면 나타나요")
: String(localized: "목표 없음"))
}
}
.containerBackground(.clear, for: .widget)
@ -280,7 +284,10 @@ struct QuestRateComplicationView: View {
circular(quest)
}
} else {
ComplicationEmptyView(message: String(localized: "다짐 없음"))
// GoalRate rectangular
ComplicationEmptyView(message: family == .accessoryRectangular
? String(localized: "표시할 다짐이 없어요 — iPhone 목표 편집에서 '애플워치에서 보기'를 켜면 나타나요")
: String(localized: "다짐 없음"))
}
}
.containerBackground(.clear, for: .widget)

View File

@ -10,8 +10,9 @@ import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var context
@AppStorage(SettingsKeys.theme, store: AppGroup.defaults) private var theme: String = "light"
@AppStorage(SettingsKeys.theme, store: AppGroup.defaults) private var theme: String = "system"
@State private var showSplash = true
@State private var showOnboarding = false
var body: some View {
ZStack {
@ -40,7 +41,11 @@ struct ContentView: View {
}
}
.tint(AppTheme.green)
.preferredColorScheme(theme == "dark" ? .dark : .light)
// "system"() /
.preferredColorScheme(theme == "dark" ? .dark : theme == "light" ? .light : nil)
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView { showOnboarding = false }
}
// (Designed for iPad)
// .
.transformEnvironment(\.dynamicTypeSize) { size in
@ -67,10 +72,23 @@ struct ContentView: View {
}
}
#endif
try? await Task.sleep(for: .seconds(1.2))
// (1.20.7)
try? await Task.sleep(for: .seconds(0.7))
withAnimation(.easeOut(duration: 0.4)) {
showSplash = false
}
// : 1 .
// (DEBUG) · .
#if DEBUG
if UserDefaults.standard.bool(forKey: "showOnboarding") {
showOnboarding = true
return
}
#endif
if !UserDefaults.standard.bool(forKey: OnboardingView.doneKey),
((try? context.fetchCount(FetchDescriptor<Action>())) ?? 0) == 0 {
showOnboarding = true
}
}
}
}

View File

@ -64,5 +64,6 @@ struct RefreshButton: View {
}
.disabled(refresh.isRefreshing)
.accessibilityLabel(Text("새로고침"))
.accessibilityHint(Text("다른 기기에서 바뀐 내용을 다시 불러오고 위젯도 갱신해요"))
}
}

View File

@ -0,0 +1,65 @@
//
// DiaryLock.swift
// Haru_Danim
//
// ( , )
// (Face ID·Touch ID·Optic ID) .
// .deviceOwnerAuthentication
// .
// (Haru_DanimApp scenePhase).
//
import Foundation
import LocalAuthentication
import Observation
@MainActor
@Observable
final class DiaryLock {
static let shared = DiaryLock()
static let enabledKey = "diary.lockEnabled"
/// standard()
var isEnabled: Bool = UserDefaults.standard.bool(forKey: DiaryLock.enabledKey) {
didSet { UserDefaults.standard.set(isEnabled, forKey: Self.enabledKey) }
}
/// ( )
private(set) var isUnlocked = false
private var authInFlight = false
var requiresUnlock: Bool {
#if DEBUG
// : -diaryLockScreen YES ()
if UserDefaults.standard.bool(forKey: "diaryLockScreen") { return true }
#endif
return isEnabled && !isUnlocked
}
///
func relock() {
isUnlocked = false
}
/// / . ( )
/// .
func authenticate(reason: String, onDone: @escaping @MainActor (Bool) -> Void) {
guard !authInFlight else { return }
authInFlight = true
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
authInFlight = false
isUnlocked = true
onDone(true)
return
}
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
Task { @MainActor in
self.authInFlight = false
if success { self.isUnlocked = true }
onDone(success)
}
}
}
}

View File

@ -44,6 +44,10 @@ struct Haru_DanimApp: App {
.onChange(of: scenePhase) { _, newPhase in
// (/ )
WatchSyncManager.shared.pushSnapshot()
if newPhase == .background {
// ( ):
DiaryLock.shared.relock()
}
if newPhase == .active {
//
// ·· ' '

View File

@ -14,6 +14,10 @@
빌드 업로드 때마다 뜨는 규정 준수 질문을 생략한다 -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- 일기 잠금(설정 → 일기, DiaryLock): Face ID 사용 사유 — 이 키가 없으면
Face ID 기기에서 인증 시도가 거부된다. Touch ID·암호는 별도 키 불필요 -->
<key>NSFaceIDUsageDescription</key>
<string>일기 잠금을 해제하는 데 사용돼요.</string>
<!-- CFBundleName(맥 메뉴 막대 이름): 생성 Info.plist가 PRODUCT_NAME("Haru_Danim")으로
항상 덮어써 이 파일에 넣어도 무효(검증됨). 실제 표시는 언어별 InfoPlist.strings
(IOS/InfoPlist.xcstrings의 CFBundleName — ko 하루 다님 / en·ja Haru Danim)가 결정하고,

View File

@ -70,6 +70,29 @@
}
}
},
"NSFaceIDUsageDescription" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Used to unlock your diary."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "日記のロック解除に使用されます。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "일기 잠금을 해제하는 데 사용돼요."
}
}
}
},
"NSPhotoLibraryAddUsageDescription" : {
"comment" : "이미지 내보내기 → 사진에 저장 권한 문구",
"extractionState" : "manual",

View File

@ -928,6 +928,22 @@
}
}
},
"'행동'을 만들면 탭 한 번으로 시간 측정을 시작·종료하거나 횟수를 +1 기록할 수 있어요. 여러 행동을 동시에 측정해도 돼요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Create an 'action' and a single tap starts or stops a timer, or adds +1 to a count. You can track several actions at the same time."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "「アクション」を作れば、ワンタップで時間計測の開始・終了や回数の+1記録ができます。複数のアクションを同時に計測してもかまいません。"
}
}
}
},
"+%lld" : {
"localizations" : {
"en" : {
@ -1184,6 +1200,22 @@
}
}
},
"Face ID·Touch ID 또는 기기 암호로\n잠금을 해제해 주세요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unlock with Face ID, Touch ID,\nor your device passcode."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Face ID・Touch IDまたはデバイスの\nパスコードでロックを解除してください。"
}
}
}
},
"PDF 가져오기" : {
"localizations" : {
"en" : {
@ -2653,6 +2685,22 @@
}
}
},
"꼬리표로 행동을 색깔별로 분류하고, 기록·통계 탭에서 하루를 돌아봐요. 더 자세한 사용법은 설정 탭의 도움말에 있어요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sort actions by color with tags, and look back on your day in the History and Stats tabs. For more details, see Help in the Settings tab."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "タグでアクションを色分けし、記録・統計タブで一日を振り返ります。詳しい使い方は設定タブのヘルプにあります。"
}
}
}
},
"꼬리표를 누르면 소속 행동이 한 번에 선택·해제되고, 그 안에서 행동을 개별로 켜고 끌 수 있어요." : {
"localizations" : {
"en" : {
@ -3070,6 +3118,22 @@
}
}
},
"다른 기기에서 바뀐 내용을 다시 불러오고 위젯도 갱신해요" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Reloads changes from other devices and refreshes widgets"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "他のデバイスでの変更を読み込み直し、ウィジェットも更新します"
}
}
}
},
"다시 만들기" : {
"localizations" : {
"en" : {
@ -3102,6 +3166,22 @@
}
}
},
"다음" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Next"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "次へ"
}
}
}
},
"다음 갱신: %@" : {
"localizations" : {
"en" : {
@ -4575,6 +4655,22 @@
}
}
},
"모음 탭 왼쪽 위의 새로고침 버튼은 화면을 처음부터 다시 불러오고, 홈 화면 위젯과 애플워치도 함께 갱신해요. iCloud 동기화로 다른 기기에서 바꾼 내용이 아직 안 보이거나 위젯이 갱신되지 않은 것 같을 때 눌러 보세요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "The refresh button at the top left of the Home tab reloads the screen from scratch and also refreshes your home screen widgets and Apple Watch. Try it when changes made on another device via iCloud sync haven't appeared yet, or when a widget seems out of date."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ホームタブ左上の更新ボタンは画面を最初から読み込み直し、ホーム画面のウィジェットとApple Watchも一緒に更新します。iCloud同期で他のデバイスの変更がまだ表示されないときや、ウィジェットが更新されていないように見えるときに押してみてください。"
}
}
}
},
"모음 탭에 목표 카드 띄우기" : {
"localizations" : {
"en" : {
@ -5168,6 +5264,22 @@
}
}
},
"목표와 다짐으로 꾸준하게" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Stay consistent with goals and quests"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "目標とクエストで続けられる"
}
}
}
},
"목표의 하루/주간/월간 진행률(%)을 가져와요. 소속 다짐들의 진행률 평균이에요." : {
"localizations" : {
"en" : {
@ -5591,6 +5703,22 @@
}
}
},
"버튼 한 번으로 기록" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Track with one tap"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ワンタップで記録"
}
}
}
},
"버튼을 길게 눌러 끌면 순서를 바꿀 수 있어요." : {
"localizations" : {
"en" : {
@ -5623,6 +5751,22 @@
}
}
},
"변화 없음" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No change"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "変化なし"
}
}
}
},
"보기" : {
"localizations" : {
"en" : {
@ -5996,7 +6140,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Refresh Button"
"value" : "The refresh button"
}
},
"ja" : {
@ -6280,6 +6424,22 @@
}
}
},
"설정 → 일기에서 '일기 잠금'을 켜면 일기 탭을 열 때 Face ID·Touch ID 또는 기기 암호로 잠금을 해제해야 해요. 기기가 지원하는 방식이 자동으로 쓰이고, 앱을 나갔다 돌아오면 다시 잠겨요. 이 설정은 이 기기에만 적용돼요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Turn on 'Diary Lock' in Settings → Diary and opening the Diary tab will require Face ID, Touch ID, or your device passcode. Whatever your device supports is used automatically, and the diary locks again when you leave and come back. This setting applies to this device only."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "設定→日記で「日記ロック」をオンにすると、日記タブを開くときにFace ID・Touch IDまたはデバイスのパスコードでロック解除が必要になります。デバイスが対応する方式が自動で使われ、アプリを離れて戻ると再びロックされます。この設定はこのデバイスにのみ適用されます。"
}
}
}
},
"설정 → 측정에서 '짧은 기록 무시'를 켜면 설정한 시간보다 짧게 측정하고 끝낸 기록은 저장하지 않아요. 행동 버튼을 실수로 눌렀다가 바로 껐을 때 의미 없는 기록이 쌓이는 것을 막아 줘요. 기준 시간은 3초부터 1분까지 고를 수 있고, 위젯·워치·시리로 종료할 때도 적용돼요. 이 설정은 기기마다 따로 저장되고 측정을 끝낸 기기의 설정이 적용되니, 아이폰과 아이패드를 함께 쓴다면 두 기기 모두 설정해 주세요. 애플워치에서 끝낸 측정은 페어링된 아이폰의 설정을 따라요." : {
"localizations" : {
"en" : {
@ -6827,6 +6987,22 @@
}
}
},
"시스템 설정 따름" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Follow System"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "システム設定に従う"
}
}
}
},
"시작" : {
"localizations" : {
"en" : {
@ -6907,6 +7083,22 @@
}
}
},
"시작하기" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Get Started"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "はじめる"
}
}
}
},
"실행 상태" : {
"localizations" : {
"en" : {
@ -7180,6 +7372,22 @@
}
}
},
"앱 소개 다시 보기" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Replay App Intro"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "アプリ紹介をもう一度見る"
}
}
}
},
"앱의 설정 → 프리미엄에서\n잠금 해제해 주세요" : {
"localizations" : {
"en" : {
@ -7292,6 +7500,22 @@
}
}
},
"어제와 비교" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Compared to yesterday"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "昨日と比較"
}
}
}
},
"언어" : {
"localizations" : {
"en" : {
@ -8675,6 +8899,22 @@
}
}
},
"이전 %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Previous: %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "前回 %@"
}
}
}
},
"이전 기간" : {
"localizations" : {
"en" : {
@ -8691,6 +8931,38 @@
}
}
},
"이전 기간과 비교" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Compare with the previous period"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "前の期間と比較"
}
}
}
},
"이전 기록 없음" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No previous records"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "前回の記録なし"
}
}
}
},
"이전 날짜" : {
"localizations" : {
"en" : {
@ -8755,6 +9027,22 @@
}
}
},
"인증에 실패했어요. 다시 시도해 주세요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Authentication failed. Please try again."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "認証に失敗しました。もう一度お試しください。"
}
}
}
},
"일" : {
"comment" : "요일 축약(일요일)",
"localizations" : {
@ -8836,6 +9124,38 @@
}
}
},
"일기 잠금" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Diary Lock"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "日記ロック"
}
}
}
},
"일기 잠금 설정을 변경하려면 인증이 필요해요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Authentication is required to change the diary lock setting."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "日記ロックの設定を変更するには認証が必要です。"
}
}
}
},
"일기 탭 달력에서 날짜를 누르면 그날의 일기가 열려요. 첫 페이지는 오늘 기분·요약·목표 진행 현황·할 일·24시간 타임테이블·기록으로 하루를 정리해 보여줘요. 오른쪽 위 ⋯ 메뉴의 '첫 화면 구성'에서 섹션 표시 여부와 순서를 바꿀 수 있어요. 기분 사진을 남긴 날은 달력의 그 날짜 칸에 사진이 배경으로 함께 보여요." : {
"localizations" : {
"en" : {
@ -8906,6 +9226,22 @@
}
}
},
"일기가 잠겨 있어요" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Your diary is locked"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "日記はロックされています"
}
}
}
},
"일기는 프리미엄 기능이에요" : {
"localizations" : {
"en" : {
@ -8922,6 +9258,22 @@
}
}
},
"일기를 보려면 잠금을 해제해 주세요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unlock to view your diary."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "日記を見るにはロックを解除してください。"
}
}
}
},
"일기를 쓴 날 %lld일이 포함돼요" : {
"localizations" : {
"en" : {
@ -9130,6 +9482,22 @@
}
}
},
"잠금 해제" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unlock"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ロック解除"
}
}
}
},
"잠금화면 목표 위젯" : {
"localizations" : {
"en" : {
@ -9323,6 +9691,22 @@
}
}
},
"정리하고 돌아보기" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Organize and look back"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "整理して振り返る"
}
}
}
},
"제어 센터 버튼으로 실행할 행동을 선택하세요." : {
"localizations" : {
"en" : {
@ -9947,6 +10331,38 @@
}
}
},
"지난달과 비교" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Compared to last month"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "先月と比較"
}
}
}
},
"지난주와 비교" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Compared to last week"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "先週と比較"
}
}
}
},
"지우기" : {
"localizations" : {
"en" : {
@ -10555,6 +10971,22 @@
}
}
},
"켜면 일기 탭을 열 때 Face ID·Touch ID 또는 기기 암호로 잠금을 해제해야 해요. 기기가 지원하는 방식이 자동으로 쓰이고, 앱을 나갔다 오면 다시 잠겨요. 이 설정은 이 기기에만 적용돼요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "When on, opening the Diary tab requires Face ID, Touch ID, or your device passcode. Whatever your device supports is used automatically, and the diary locks again when you leave the app. This setting applies to this device only."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "オンにすると、日記タブを開くときにFace ID・Touch IDまたはデバイスのパスコードでロック解除が必要になります。デバイスが対応する方式が自動で使われ、アプリを離れると再びロックされます。この設定はこのデバイスにのみ適用されます。"
}
}
}
},
"켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요." : {
"localizations" : {
"en" : {
@ -10571,6 +11003,22 @@
}
}
},
"큰 '목표' 아래에 실천 단위인 '다짐'을 만들어요. 하루·주간·월간 진행률과 연속 달성이 자동으로 계산되고, 목표가 끝나면 달성 여부도 판정돼요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Under a big 'goal', create small 'quests' — the units you act on. Daily, weekly and monthly progress and streaks are calculated automatically, and when a goal ends it's judged as achieved or not."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "大きな「目標」の下に、実践単位の「クエスト」を作ります。日・週・月の達成率と連続記録は自動で計算され、目標が終わると達成かどうかも判定されます。"
}
}
}
},
"큰 목표를 세우고, 그 안에 다짐을 추가해 보세요." : {
"localizations" : {
"en" : {
@ -10893,6 +11341,22 @@
}
}
},
"통계 탭 맨 위의 비교 카드는 지금 보고 있는 하루·주·월의 총 시간과 횟수를 바로 앞 기간(어제·지난주·지난달)과 비교해 보여줘요. 필터를 걸면 필터된 행동들만으로 비교돼요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "The comparison card at the top of the Stats tab compares the total time and counts of the day, week, or month you're viewing with the period right before it (yesterday, last week, or last month). With a filter on, only the filtered actions are compared."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "統計タブ最上部の比較カードは、表示中の日・週・月の合計時間と回数を、直前の期間(昨日・先週・先月)と比較して表示します。フィルターをかけると、フィルターされたアクションだけで比較されます。"
}
}
}
},
"통계 필터" : {
"localizations" : {
"en" : {
@ -12233,18 +12697,18 @@
}
}
},
"행동 버튼의 색은 지정한 꼬리표의 색을 따라요." : {
"행동 버튼의 색은 지정한 꼬리표의 색을 따라요. 여러 개를 지정하면 가장 먼저 만든 꼬리표의 색이 쓰여요." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Action buttons use the color of their assigned tag."
"value" : "The action button's color follows its tag. With multiple tags, the color of the tag you created first is used."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "アクションボタンの色は指定したタグの色に従います。"
"value" : "アクションボタンの色は指定したタグの色に従います。複数指定した場合は、最初に作ったタグの色が使われます。"
}
}
}

View File

@ -503,7 +503,7 @@ struct ActionEditorView: View {
} header: {
Text("꼬리표 (복수 선택 가능)")
} footer: {
Text("행동 버튼의 색은 지정한 꼬리표의 색을 따라요.")
Text("행동 버튼의 색은 지정한 꼬리표의 색을 따라요. 여러 개를 지정하면 가장 먼저 만든 꼬리표의 색이 쓰여요.")
}
Section {
Picker("추적 방식", selection: $trackingType) {

View File

@ -42,6 +42,7 @@ struct DiaryRootView: View {
@Query private var diaryEntries: [DiaryEntry]
@State private var premium = PremiumManager.shared
@State private var lock = DiaryLock.shared
/// ( 1 )
@State private var monthAnchor: Date = DayMath().dayKey(for: .now)
@State private var showingExport = false
@ -57,7 +58,10 @@ struct DiaryRootView: View {
var body: some View {
Group {
if !premium.isPremium && !isReadOnly {
if lock.requiresUnlock {
// ( )
DiaryLockGateView()
} else if !premium.isPremium && !isReadOnly {
DiaryLockedView()
} else {
#if DEBUG
@ -407,6 +411,52 @@ struct DiaryRootView: View {
#endif
}
// MARK: - ( , DiaryLock)
struct DiaryLockGateView: View {
@State private var lock = DiaryLock.shared
@State private var failed = false
var body: some View {
VStack(spacing: 14) {
Image(systemName: "lock.fill")
.font(.system(size: 40))
.foregroundStyle(AppTheme.green)
Text("일기가 잠겨 있어요")
.font(.title3.weight(.semibold))
Text("Face ID·Touch ID 또는 기기 암호로\n잠금을 해제해 주세요.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
unlock()
} label: {
Label("잠금 해제", systemImage: "lock.open.fill")
.padding(.horizontal, 6)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.tint(AppTheme.green)
.padding(.top, 6)
if failed {
Text("인증에 실패했어요. 다시 시도해 주세요.")
.font(.caption)
.foregroundStyle(.red)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
//
.onAppear { unlock() }
}
private func unlock() {
failed = false
lock.authenticate(reason: String(localized: "일기를 보려면 잠금을 해제해 주세요.")) { ok in
failed = !ok
}
}
}
// MARK: -
struct DiaryLockedView: View {

View File

@ -191,6 +191,11 @@ struct HelpView: View {
title: "즐겨찾기 모아 보기",
body: "행동을 즐겨찾기로 지정하면 모음 탭 행동 목록이 '즐겨찾기'와 '나머지 행동' 두 부분으로 나뉘고, 즐겨찾기가 항상 맨 위에 와요. 새로 지정한 행동은 즐겨찾기 맨 뒤에 붙어요. 순서를 바꾸려면 '배치 편집'에서 버튼을 길게 눌러 끌어 옮기거나, 행동 탭 오른쪽 위 ⋯ 메뉴의 '즐겨찾기 순서 변경'을 쓰면 돼요 — 이 순서는 애플워치의 즐겨찾기 목록에도 똑같이 적용돼요. 배치 편집에서는 즐겨찾기와 나머지 사이로 끌어서 지정·해제할 수도 있어요. '나머지 행동' 제목을 누르면 그 아래를 접어 둘 수 있어서 행동이 많아져도 자주 쓰는 것만 남길 수 있어요(접힘은 기기마다 저장). 지정은 행동 버튼을 길게 눌러 '즐겨찾기'를 선택하거나, 행동 탭에서 행동을 열어 별 아이콘을 눌러도 돼요."
),
HelpTopic(
symbol: "arrow.clockwise",
title: "새로고침 버튼",
body: "모음 탭 왼쪽 위의 새로고침 버튼은 화면을 처음부터 다시 불러오고, 홈 화면 위젯과 애플워치도 함께 갱신해요. iCloud 동기화로 다른 기기에서 바꾼 내용이 아직 안 보이거나 위젯이 갱신되지 않은 것 같을 때 눌러 보세요."
),
HelpTopic(
symbol: "hand.tap.fill",
title: "길게 누르기 메뉴",
@ -272,6 +277,11 @@ struct HelpView: View {
title: "CSV로 백업하기",
body: "설정 → 데이터 → '데이터 내보내기'에서 모든 기록(행동·시간·횟수·목표·다짐)을 CSV 파일로 만들 수 있어요. 스프레드시트에서 열어 분석하거나 백업으로 보관해 두세요. 무료에서도 사용할 수 있어요."
),
HelpTopic(
symbol: "arrow.up.arrow.down",
title: "이전 기간과 비교",
body: "통계 탭 맨 위의 비교 카드는 지금 보고 있는 하루·주·월의 총 시간과 횟수를 바로 앞 기간(어제·지난주·지난달)과 비교해 보여줘요. 필터를 걸면 필터된 행동들만으로 비교돼요."
),
])
}
@ -322,6 +332,11 @@ struct HelpView: View {
title: "일기 내보내기",
body: "달력 화면 오른쪽 위 내보내기 버튼에서 기간(며칠부터 며칠까지)이나 여러 날짜를 골라 PDF 또는 이미지로 저장해요. 요약 페이지와 노트 페이지가 그대로 담겨요."
),
HelpTopic(
symbol: "lock.fill",
title: "일기 잠금",
body: "설정 → 일기에서 '일기 잠금'을 켜면 일기 탭을 열 때 Face ID·Touch ID 또는 기기 암호로 잠금을 해제해야 해요. 기기가 지원하는 방식이 자동으로 쓰이고, 앱을 나갔다 돌아오면 다시 잠겨요. 이 설정은 이 기기에만 적용돼요."
),
])
}

View File

@ -0,0 +1,107 @@
//
// OnboardingView.swift
// Haru_Danim
//
// · , 3 (CLAUDE.md §6).
// 1 ( ),
// ' ' .
// · .
//
import SwiftUI
struct OnboardingView: View {
/// ( ) (standard )
static let doneKey = "onboarding.done"
var onFinish: () -> Void
@State private var page = 0
private let lastPage = 2
var body: some View {
ZStack(alignment: .topTrailing) {
AppTheme.background.ignoresSafeArea()
TabView(selection: $page) {
pageView(
symbol: "bolt.fill",
color: AppTheme.green,
title: "버튼 한 번으로 기록",
text: "'행동'을 만들면 탭 한 번으로 시간 측정을 시작·종료하거나 횟수를 +1 기록할 수 있어요. 여러 행동을 동시에 측정해도 돼요."
)
.tag(0)
pageView(
symbol: "flag.checkered",
color: AppTheme.green,
title: "목표와 다짐으로 꾸준하게",
text: "큰 '목표' 아래에 실천 단위인 '다짐'을 만들어요. 하루·주간·월간 진행률과 연속 달성이 자동으로 계산되고, 목표가 끝나면 달성 여부도 판정돼요."
)
.tag(1)
pageView(
symbol: "chart.xyaxis.line",
color: AppTheme.yellow,
title: "정리하고 돌아보기",
text: "꼬리표로 행동을 색깔별로 분류하고, 기록·통계 탭에서 하루를 돌아봐요. 더 자세한 사용법은 설정 탭의 도움말에 있어요."
)
.tag(2)
}
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
if page < lastPage {
Button("건너뛰기") { finish() }
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
.padding(20)
}
}
.safeAreaInset(edge: .bottom) {
Button {
if page < lastPage {
withAnimation { page += 1 }
} else {
finish()
}
} label: {
Text(page < lastPage ? String(localized: "다음") : String(localized: "시작하기"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.tint(AppTheme.green)
.padding(.horizontal, 28)
.padding(.bottom, 18)
}
}
private func finish() {
UserDefaults.standard.set(true, forKey: Self.doneKey)
onFinish()
}
private func pageView(symbol: String, color: Color,
title: LocalizedStringKey, text: LocalizedStringKey) -> some View {
VStack(spacing: 22) {
Spacer()
Image(systemName: symbol)
.font(.system(size: 64, weight: .semibold))
.foregroundStyle(color)
.frame(width: 130, height: 130)
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 34, style: .continuous))
Text(title)
.font(.title2.bold())
.multilineTextAlignment(.center)
Text(text)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 36)
.frame(maxWidth: 520)
Spacer()
Spacer()
}
.frame(maxWidth: .infinity)
}
}

View File

@ -13,7 +13,7 @@ import WidgetKit
struct SettingsView: View {
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
// (' ' ) App Group defaults
@AppStorage(SettingsKeys.theme, store: AppGroup.defaults) private var theme = "light"
@AppStorage(SettingsKeys.theme, store: AppGroup.defaults) private var theme = "system"
@AppStorage(SettingsKeys.language) private var language = AppLanguage.ko.rawValue
// · App Group defaults
@AppStorage(SettingsKeys.weekStartWeekday, store: AppGroup.defaults) private var weekStartWeekday = 2
@ -43,6 +43,23 @@ struct SettingsView: View {
)
}
/// ( )
@State private var showingOnboarding = false
/// (iPad DiaryLock )
@State private var diaryLock = DiaryLock.shared
///
private var diaryLockBinding: Binding<Bool> {
Binding(
get: { diaryLock.isEnabled },
set: { newValue in
diaryLock.authenticate(reason: String(localized: "일기 잠금 설정을 변경하려면 인증이 필요해요.")) { ok in
if ok { diaryLock.isEnabled = newValue }
}
}
)
}
/// ( )
@State private var showsDayStartWheel: Bool = {
#if DEBUG
@ -69,6 +86,7 @@ struct SettingsView: View {
Form {
Section {
Picker("테마", selection: $theme) {
Text("시스템 설정 따름").tag("system")
Text("라이트").tag("light")
Text("다크").tag("dark")
}
@ -195,6 +213,23 @@ struct SettingsView: View {
} footer: {
Text("여러 행동을 동시에 추적 중일 때 다이나믹 아일랜드·잠금화면과 워치 컴플리케이션에 대표로 표시할 행동이에요.")
}
// iPad ( , §6.7)
if DeviceLayout.isPad && !DeviceLayout.isMac {
Section {
Toggle(isOn: diaryLockBinding) {
Label {
Text("일기 잠금")
} icon: {
Image(systemName: "lock.fill")
.foregroundStyle(AppTheme.green)
}
}
} header: {
Text("일기")
} footer: {
Text("켜면 일기 탭을 열 때 Face ID·Touch ID 또는 기기 암호로 잠금을 해제해야 해요. 기기가 지원하는 방식이 자동으로 쓰이고, 앱을 나갔다 오면 다시 잠겨요. 이 설정은 이 기기에만 적용돼요.")
}
}
Section("프리미엄") {
NavigationLink {
PremiumView()
@ -227,6 +262,12 @@ struct SettingsView: View {
} label: {
Label("도움말", systemImage: "questionmark.circle")
}
Button {
showingOnboarding = true
} label: {
Label("앱 소개 다시 보기", systemImage: "sparkles")
.foregroundStyle(.primary)
}
HStack {
Text("버전")
Spacer()
@ -259,6 +300,9 @@ struct SettingsView: View {
// :
UserDefaults.standard.set([language], forKey: "AppleLanguages")
}
.fullScreenCover(isPresented: $showingOnboarding) {
OnboardingView { showingOnboarding = false }
}
#if DEBUG
// : -settingsScrollGoal YES , -settingsScrollPremium YES
.onAppear {

View File

@ -325,11 +325,134 @@ private struct StatsChartsView: View {
}
}
// MARK: ( , §6.6)
/// ( //)
private var previousRange: Range<Date> {
let justBefore = spanRange.lowerBound.addingTimeInterval(-1)
switch span {
case .day: return math.dayRange(containing: justBefore)
case .week: return math.weekRange(containing: justBefore)
case .month: return math.monthRange(containing: justBefore)
}
}
/// . @Query
/// Aggregator ( )
private func periodTotals(in range: Range<Date>) -> (seconds: TimeInterval, count: Int) {
let agg = Aggregator(math: math)
var seconds: TimeInterval = 0
var count = 0
for action in filteredActions {
switch action.trackingType {
case .time: seconds += agg.seconds(for: action, in: range)
case .count: count += agg.count(for: action, in: range)
}
}
return (seconds, count)
}
private var previousPeriodTitle: LocalizedStringKey {
switch span {
case .day: return "어제와 비교"
case .week: return "지난주와 비교"
case .month: return "지난달과 비교"
}
}
/// · 0 ,
///
@ViewBuilder
private var comparisonCard: some View {
let current = periodTotals(in: spanRange)
let previous = periodTotals(in: previousRange)
let showTime = current.seconds > 0 || previous.seconds > 0
let showCount = current.count > 0 || previous.count > 0
if showTime || showCount {
VStack(alignment: .leading, spacing: 10) {
Text(previousPeriodTitle)
.font(.subheadline.weight(.semibold))
if showTime {
comparisonRow(
symbol: "timer",
title: String(localized: "시간"),
currentLabel: Format.durationShort(current.seconds),
previousLabel: Format.durationShort(previous.seconds),
current: current.seconds,
previous: previous.seconds
)
}
if showCount {
comparisonRow(
symbol: "number",
title: String(localized: "횟수"),
currentLabel: String(localized: "\(current.count)"),
previousLabel: String(localized: "\(previous.count)"),
current: Double(current.count),
previous: Double(previous.count)
)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private func comparisonRow(symbol: String, title: String,
currentLabel: String, previousLabel: String,
current: Double, previous: Double) -> some View {
HStack(spacing: 8) {
Image(systemName: symbol)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AppTheme.green)
.frame(width: 18)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
VStack(alignment: .trailing, spacing: 1) {
HStack(spacing: 6) {
Text(currentLabel)
.font(.footnote.weight(.semibold).monospacedDigit())
deltaBadge(current: current, previous: previous)
}
Text("이전 \(previousLabel)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
/// (0 )
@ViewBuilder
private func deltaBadge(current: Double, previous: Double) -> some View {
if previous <= 0 {
Text("이전 기록 없음")
.font(.caption2)
.foregroundStyle(.tertiary)
} else {
let percent = Int(((current - previous) / previous * 100).rounded())
Group {
if percent > 0 {
Text(verbatim: "") + Text("\(percent)%")
} else if percent < 0 {
Text(verbatim: "") + Text("\(abs(percent))%")
} else {
Text("변화 없음")
}
}
.font(.caption2.weight(.bold).monospacedDigit())
.foregroundStyle(percent > 0 ? AnyShapeStyle(AppTheme.green) : AnyShapeStyle(.secondary))
}
}
var body: some View {
let data = makeStatsData()
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 16) {
comparisonCard
switch span {
case .day:
dayCharts(data)

View File

@ -13,6 +13,8 @@ import WidgetKit
struct WidgetPreviewScreen: View {
let showLock: Bool
/// "system"
@Environment(\.colorScheme) private var colorScheme
//
@State private var actionSmall: ActionRunEntry?
@ -416,7 +418,8 @@ struct WidgetPreviewScreen: View {
return content()
.frame(width: size.width, height: size.height)
.background(AppTheme.background)
.environment(\.colorScheme, WidgetAppearance.appScheme)
// "system"(nil)
.environment(\.colorScheme, WidgetAppearance.appScheme ?? colorScheme)
.environment(\.widgetRenderingMode, forcedRenderingMode)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.08), radius: 6, y: 2)

View File

@ -28,8 +28,8 @@ enum WidgetThemeOption: String, AppEnum {
.dark: "다크 모드 고정",
]
///
var scheme: ColorScheme {
/// (nil = )
var scheme: ColorScheme? {
switch self {
case .matchApp: return WidgetAppearance.appScheme
case .light: return .light
@ -39,11 +39,16 @@ enum WidgetThemeOption: String, AppEnum {
}
enum WidgetAppearance {
/// ("light" | "dark")
static var appScheme: ColorScheme {
/// ("system" | "light" | "dark") .
/// "system"() nil
static var appScheme: ColorScheme? {
let raw = AppGroup.defaults.string(forKey: SettingsKeys.theme)
?? UserDefaults.standard.string(forKey: SettingsKeys.theme) ?? "light"
return raw == "dark" ? .dark : .light
?? UserDefaults.standard.string(forKey: SettingsKeys.theme)
switch raw {
case "dark": return .dark
case "light": return .light
default: return nil
}
}
}
@ -59,8 +64,7 @@ struct WidgetThemeModifier: ViewModifier {
// ()
content
.containerBackground(for: .widget) { Color.clear }
} else {
let scheme = theme.scheme
} else if let scheme = theme.scheme {
content
.environment(\.colorScheme, scheme)
.containerBackground(for: .widget) {
@ -69,6 +73,11 @@ struct WidgetThemeModifier: ViewModifier {
AppTheme.background
.environment(\.colorScheme, scheme)
}
} else {
// ( "system"):
// (AppTheme)
content
.containerBackground(for: .widget) { AppTheme.background }
}
}
}