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:
parent
1dea41b472
commit
ac40e14cfe
@ -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용) |
|
||||
|
||||
---
|
||||
|
||||
@ -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
@ -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)
|
||||
|
||||
@ -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.2→0.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,5 +64,6 @@ struct RefreshButton: View {
|
||||
}
|
||||
.disabled(refresh.isRefreshing)
|
||||
.accessibilityLabel(Text("새로고침"))
|
||||
.accessibilityHint(Text("다른 기기에서 바뀐 내용을 다시 불러오고 위젯도 갱신해요"))
|
||||
}
|
||||
}
|
||||
|
||||
65
myApp/HaruDanim/IOS/Core/DiaryLock.swift
Normal file
65
myApp/HaruDanim/IOS/Core/DiaryLock.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
// 종료일이 지난 목표 판정 — 목표 탭을 열지 않아도
|
||||
// 위젯·워치·모음 탭이 오래 '진행 중'으로 남지 않게 한다
|
||||
|
||||
@ -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)가 결정하고,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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" : "アクションボタンの色は指定したタグの色に従います。複数指定した場合は、最初に作ったタグの色が使われます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -503,7 +503,7 @@ struct ActionEditorView: View {
|
||||
} header: {
|
||||
Text("꼬리표 (복수 선택 가능)")
|
||||
} footer: {
|
||||
Text("행동 버튼의 색은 지정한 꼬리표의 색을 따라요.")
|
||||
Text("행동 버튼의 색은 지정한 꼬리표의 색을 따라요. 여러 개를 지정하면 가장 먼저 만든 꼬리표의 색이 쓰여요.")
|
||||
}
|
||||
Section {
|
||||
Picker("추적 방식", selection: $trackingType) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 또는 기기 암호로 잠금을 해제해야 해요. 기기가 지원하는 방식이 자동으로 쓰이고, 앱을 나갔다 돌아오면 다시 잠겨요. 이 설정은 이 기기에만 적용돼요."
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
107
myApp/HaruDanim/IOS/Views/OnboardingView.swift
Normal file
107
myApp/HaruDanim/IOS/Views/OnboardingView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user