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>
66 lines
2.5 KiB
Swift
66 lines
2.5 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|