From ac40e14cfe5876b664d80247a1cb347ce5519bb5 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Thu, 23 Jul 2026 02:02:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(ux):=20UX=20=EA=B0=90=EC=82=AC=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EB=B0=B0=EC=B9=98=20=E2=80=94=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=ED=85=8C=EB=A7=88=C2=B7=EC=98=A8=EB=B3=B4=EB=94=A9?= =?UTF-8?q?=C2=B7=EC=9D=BC=EA=B8=B0=20=EC=9E=A0=EA=B8=88=C2=B7=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=EB=B9=84=EA=B5=90=20=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- myApp/HaruDanim/CLAUDE.md | 17 +- .../WatchStore.swift | 3 + .../Localizable.xcstrings | 802 +++++++++--------- .../RateComplications.swift | 11 +- myApp/HaruDanim/IOS/ContentView.swift | 24 +- myApp/HaruDanim/IOS/Core/AppRefresh.swift | 1 + myApp/HaruDanim/IOS/Core/DiaryLock.swift | 65 ++ myApp/HaruDanim/IOS/Haru_DanimApp.swift | 4 + myApp/HaruDanim/IOS/Info.plist | 4 + myApp/HaruDanim/IOS/InfoPlist.xcstrings | 23 + myApp/HaruDanim/IOS/Localizable.xcstrings | 472 ++++++++++- myApp/HaruDanim/IOS/Views/ActionViews.swift | 2 +- myApp/HaruDanim/IOS/Views/DiaryView.swift | 52 +- myApp/HaruDanim/IOS/Views/HelpView.swift | 15 + .../HaruDanim/IOS/Views/OnboardingView.swift | 107 +++ myApp/HaruDanim/IOS/Views/SettingsView.swift | 46 +- myApp/HaruDanim/IOS/Views/StatsTabView.swift | 123 +++ .../IOS/Views/WidgetPreviewScreen.swift | 5 +- myApp/HaruDanim/Widgets/WidgetTheme.swift | 25 +- 19 files changed, 1388 insertions(+), 413 deletions(-) create mode 100644 myApp/HaruDanim/IOS/Core/DiaryLock.swift create mode 100644 myApp/HaruDanim/IOS/Views/OnboardingView.swift diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 54bc234..008dcba 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -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 `(설정 주입) `-longSessionAlertTestSeconds `(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) | | 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals `(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 37건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) | | 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor `(최신 기록 수정 시트) | -| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo `: 이미지 주제로 스크롤) `-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 `: 이미지 주제로 스크롤) `-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 ` `-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 ` `-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 ` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) | --- diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift index 32a6eb1..4dc67a2 100644 --- a/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift @@ -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, diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/Localizable.xcstrings b/myApp/HaruDanim/Haru_DanimWatchWidgets/Localizable.xcstrings index 6dc3329..317f081 100644 --- a/myApp/HaruDanim/Haru_DanimWatchWidgets/Localizable.xcstrings +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/Localizable.xcstrings @@ -1,616 +1,648 @@ { - "sourceLanguage": "ko", - "strings": { - "%@": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@" + "sourceLanguage" : "ko", + "strings" : { + "%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" } } } }, - "%@ %@": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ %@" + "%@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %@" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ %@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %@" } }, - "ko": { - "stringUnit": { - "state": "new", - "value": "%1$@ %2$@" + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" } } } }, - "%@ %lld%%": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ %lld%%" + "%@ %lld%%" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %lld%%" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ %lld%%" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %lld%%" } }, - "ko": { - "stringUnit": { - "state": "new", - "value": "%1$@ %2$lld%%" + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$lld%%" } } } }, - "%@ +%lld": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ +%lld" + "%@ +%lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ +%lld" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ +%lld" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ +%lld" } }, - "ko": { - "stringUnit": { - "state": "new", - "value": "%1$@ +%2$lld" + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ +%2$lld" } } } }, - "%lld": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld" + "%lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" } } } }, - "%lld%%": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld%%" + "%lld%%" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld%%" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" } } } }, - "+%lld": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "+%lld" + "+%lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "+%lld" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "+%lld" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "+%lld" } } } }, - "기간": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Period" + "기간" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Period" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "期間" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "期間" } } } }, - "다짐": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Quest" + "다짐" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quest" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "クエスト" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クエスト" } } } }, - "다짐 달성률": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Quest Progress" + "다짐 달성률" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quest Progress" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "クエスト達成率" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クエスト達成率" } } } }, - "다짐 없음": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No quest" + "다짐 없음" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No quest" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "クエストなし" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クエストなし" } } } }, - "독서": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reading" + "독서" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reading" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "読書" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "読書" } } } }, - "목표": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Goals" + "목표" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Goals" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "目標" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標" } } } }, - "목표 달성률": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Goal Progress" + "목표 달성률" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Goal Progress" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "目標達成率" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標達成率" } } } }, - "목표 없음": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No goal" + "목표 없음" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No goal" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "目標なし" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標なし" } } } }, - "새로고침": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Refresh" + "새로고침" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "更新" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新" } } } }, - "선택한 다짐의 달성률을 표시해요. 한도형 다짐은 상태로 표시돼요. (프리미엄)": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Shows the selected quest's progress. Limit quests show their status. (Premium)" + "선택한 다짐의 달성률을 표시해요. 한도형 다짐은 상태로 표시돼요. (프리미엄)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows the selected quest's progress. Limit quests show their status. (Premium)" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "選択したクエストの達成率を表示します。上限型はステータス表示になります。(プレミアム)" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "選択したクエストの達成率を表示します。上限型はステータス表示になります。(プレミアム)" } } } }, - "선택한 목표의 하루/주간/월간 달성률을 표시해요. (프리미엄)": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Shows the selected goal's daily/weekly/monthly progress. (Premium)" + "선택한 목표의 하루/주간/월간 달성률을 표시해요. (프리미엄)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows the selected goal's daily/weekly/monthly progress. (Premium)" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "選択した目標の日/週/月の達成率を表示します。(プレミアム)" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "選択した目標の日/週/月の達成率を表示します。(プレミアム)" } } } }, - "실행 중 아님": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Not running" + "실행 중 아님" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not running" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "実行中なし" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "実行中なし" } } } }, - "앱을 열면 새로고침돼요": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open the app to refresh" + "앱을 열면 새로고침돼요" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open the app to refresh" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリを開くと更新されます" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを開くと更新されます" } } } }, - "없음": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "None" + "없음" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "None" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "なし" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "なし" } } } }, - "월간": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Monthly" + "월간" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monthly" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "月間" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "月間" } } } }, - "주간": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Weekly" + "주간" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weekly" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "週間" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "週間" } } } }, - "초과": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Over" + "초과" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Over" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "超過" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "超過" } } } }, - "측정 중": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Tracking" + "측정 중" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracking" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "計測中" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "計測中" } } } }, - "측정 중인 행동과 경과 시간을 표시해요. (프리미엄)": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Shows the running action and elapsed time. (Premium)" + "측정 중인 행동과 경과 시간을 표시해요. (프리미엄)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows the running action and elapsed time. (Premium)" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "計測中のアクションと経過時間を表示します。(プレミアム)" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "計測中のアクションと経過時間を表示します。(プレミアム)" } } } }, - "측정 표시 새로고침 필요": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Timer display needs a refresh" + "측정 표시 새로고침 필요" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timer display needs a refresh" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "計測表示の更新が必要" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "計測表示の更新が必要" } } } }, - "측정 표시가 오래됐어요": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Timer display is out of date" + "측정 표시가 오래됐어요" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timer display is out of date" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "計測表示が古くなっています" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "計測表示が古くなっています" } } } }, - "표시할 다짐과 기간을 선택하세요.": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose a quest and period to display." + "표시할 다짐과 기간을 선택하세요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a quest and period to display." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "表示するクエストと期間を選んでください。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "表示するクエストと期間を選んでください。" } } } }, - "표시할 목표와 기간을 선택하세요.": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose a goal and period to display." + "표시할 다짐이 없어요 — iPhone 목표 편집에서 '애플워치에서 보기'를 켜면 나타나요" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No quests to show — turn on 'Show on Apple Watch' when editing a goal on iPhone" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "表示する目標と期間を選んでください。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "表示するクエストがありません — iPhoneの目標編集で「Apple Watchで表示」をオンにすると表示されます" } } } }, - "프리미엄": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Premium" + "표시할 목표가 없어요 — iPhone 목표 편집에서 '애플워치에서 보기'를 켜면 나타나요" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No goals to show — turn on 'Show on Apple Watch' when editing a goal on iPhone" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プレミアム" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "表示する目標がありません — iPhoneの目標編集で「Apple Watchで表示」をオンにすると表示されます" } } } }, - "하루": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Daily" + "표시할 목표와 기간을 선택하세요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a goal and period to display." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "1日" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "表示する目標と期間を選んでください。" } } } }, - "하루 다님": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Haru Danim" + "프리미엄" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Premium" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Haru Danim" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プレミアム" } } } }, - "한도": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Limit" + "하루" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daily" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "上限" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1日" } } } }, - "한도 안": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Within" + "하루 다님" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haru Danim" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "上限内" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haru Danim" } } } }, - "한도 초과": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Over limit" + "한도" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limit" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "上限超過" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "上限" } } } }, - "현재 현황": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Current Status" + "한도 안" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Within" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "現在の状況" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "上限内" + } + } + } + }, + "한도 초과" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Over limit" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "上限超過" + } + } + } + }, + "현재 현황" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current Status" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在の状況" } } } } }, - "version": "1.0" + "version" : "1.0" } \ No newline at end of file diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/RateComplications.swift b/myApp/HaruDanim/Haru_DanimWatchWidgets/RateComplications.swift index a785d11..b5f1df5 100644 --- a/myApp/HaruDanim/Haru_DanimWatchWidgets/RateComplications.swift +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/RateComplications.swift @@ -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) diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index 30402b9..23c483f 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -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())) ?? 0) == 0 { + showOnboarding = true + } } } } diff --git a/myApp/HaruDanim/IOS/Core/AppRefresh.swift b/myApp/HaruDanim/IOS/Core/AppRefresh.swift index 193a219..6f2eab9 100644 --- a/myApp/HaruDanim/IOS/Core/AppRefresh.swift +++ b/myApp/HaruDanim/IOS/Core/AppRefresh.swift @@ -64,5 +64,6 @@ struct RefreshButton: View { } .disabled(refresh.isRefreshing) .accessibilityLabel(Text("새로고침")) + .accessibilityHint(Text("다른 기기에서 바뀐 내용을 다시 불러오고 위젯도 갱신해요")) } } diff --git a/myApp/HaruDanim/IOS/Core/DiaryLock.swift b/myApp/HaruDanim/IOS/Core/DiaryLock.swift new file mode 100644 index 0000000..1f8c3d9 --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/DiaryLock.swift @@ -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) + } + } + } +} diff --git a/myApp/HaruDanim/IOS/Haru_DanimApp.swift b/myApp/HaruDanim/IOS/Haru_DanimApp.swift index cd876b2..e04e79c 100644 --- a/myApp/HaruDanim/IOS/Haru_DanimApp.swift +++ b/myApp/HaruDanim/IOS/Haru_DanimApp.swift @@ -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 { // 종료일이 지난 목표 판정 — 목표 탭을 열지 않아도 // 위젯·워치·모음 탭이 오래 '진행 중'으로 남지 않게 한다 diff --git a/myApp/HaruDanim/IOS/Info.plist b/myApp/HaruDanim/IOS/Info.plist index e5bf930..bb4c447 100644 --- a/myApp/HaruDanim/IOS/Info.plist +++ b/myApp/HaruDanim/IOS/Info.plist @@ -14,6 +14,10 @@ 빌드 업로드 때마다 뜨는 규정 준수 질문을 생략한다 --> ITSAppUsesNonExemptEncryption + + NSFaceIDUsageDescription + 일기 잠금을 해제하는 데 사용돼요.