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>
429 lines
19 KiB
Swift
429 lines
19 KiB
Swift
//
|
||
// WidgetPreviewScreen.swift
|
||
// Haru_Danim
|
||
//
|
||
// DEBUG 전용: 위젯 뷰들을 앱 안에서 실제 크기로 렌더링해 검증하는 화면.
|
||
// 런치 인자 `-widgetPreview YES`(홈 위젯) / `-widgetPreview lock`(잠금화면)으로 진입.
|
||
// (시뮬레이터 홈 화면에 위젯을 자동으로 추가할 방법이 없어 만든 검증 도구)
|
||
//
|
||
|
||
#if DEBUG
|
||
import SwiftUI
|
||
import WidgetKit
|
||
|
||
struct WidgetPreviewScreen: View {
|
||
let showLock: Bool
|
||
/// 앱 테마 "system"일 때 미리보기 박스가 따를 현재 스킴
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
// ① 행동 실행
|
||
@State private var actionSmall: ActionRunEntry?
|
||
@State private var actionMedium: ActionRunEntry?
|
||
@State private var actionLarge4: ActionRunEntry?
|
||
@State private var actionLarge8: ActionRunEntry?
|
||
// ② 목표 진행률
|
||
@State private var goalSmall: GoalProgressEntry?
|
||
@State private var goalMediumTwo: GoalProgressEntry?
|
||
@State private var goalMediumQuests: GoalProgressEntry?
|
||
@State private var goalLargeFour: GoalProgressEntry?
|
||
@State private var goalLargeTwoQuests: GoalProgressEntry?
|
||
@State private var goalLargeAllQuests: GoalProgressEntry?
|
||
// ③ 다짐 진행률
|
||
@State private var ringSmall: QuestRingEntry?
|
||
@State private var ringMedium: QuestRingEntry?
|
||
@State private var ringLarge: QuestRingEntry?
|
||
/// '이하 유지' 다짐이 있으면 소형 한 칸 추가 (한도 문구 + 누적값 표시 확인용)
|
||
@State private var ringAtMostSmall: QuestRingEntry?
|
||
// ④ 다짐 현황
|
||
@State private var statusSmall: QuestStatusEntry?
|
||
@State private var statusMedium: QuestStatusEntry?
|
||
@State private var statusLargeMany: QuestStatusEntry?
|
||
@State private var statusLargeFourGoals: QuestStatusEntry?
|
||
@State private var statusLargeTwoGoals: QuestStatusEntry?
|
||
// ⑤ 통계
|
||
@State private var statsSmall: StatsChartEntry?
|
||
@State private var statsMedium: StatsChartEntry?
|
||
@State private var statsLarge: StatsChartEntry?
|
||
// ⑥ 현재 진행 중
|
||
@State private var nowEntry: NowRunningEntry?
|
||
@State private var nowMulti: NowRunningEntry?
|
||
@State private var nowEmpty: NowRunningEntry?
|
||
// 잠금화면
|
||
@State private var lockEntries: [(String, LockGoalEntry)] = []
|
||
|
||
var body: some View {
|
||
ScrollViewReader { proxy in
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
if showLock {
|
||
lockSection
|
||
} else {
|
||
homeSection
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
.background {
|
||
Color(white: 0.9).ignoresSafeArea()
|
||
}
|
||
.onAppear {
|
||
load()
|
||
// -widgetPreviewScroll B|C|D|S → 해당 섹션으로 자동 스크롤
|
||
if let anchor = UserDefaults.standard.string(forKey: "widgetPreviewScroll") {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||
withAnimation { proxy.scrollTo(anchor, anchor: .top) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func load() {
|
||
// -premium 런치 인자가 PremiumGate 캐시에 반영되도록 매니저를 먼저 초기화
|
||
_ = PremiumManager.shared
|
||
if showLock {
|
||
var gauge = LockGoalConfigIntent()
|
||
gauge.style = .gauge
|
||
var number = LockGoalConfigIntent()
|
||
number.style = .number
|
||
var dots = LockGoalConfigIntent()
|
||
dots.style = .dots
|
||
// 다짐이 여러 개고 부분 진행이 섞인 목표 + 주간 기간 — 점의 '차오름' 확인용
|
||
var dotsWeek = LockGoalConfigIntent()
|
||
dotsWeek.style = .dots
|
||
dotsWeek.span = .week
|
||
if let manyQuests = WidgetStore.defaultGoals(8).max(by: { $0.quests.count < $1.quests.count }) {
|
||
dotsWeek.goal = GoalEntity(manyQuests)
|
||
}
|
||
lockEntries = [
|
||
("gauge", LockGoalProvider.makeEntry(gauge)),
|
||
("number", LockGoalProvider.makeEntry(number)),
|
||
("dots", LockGoalProvider.makeEntry(dots)),
|
||
("dots·주간·다짐多", LockGoalProvider.makeEntry(dotsWeek)),
|
||
]
|
||
return
|
||
}
|
||
|
||
// ① 행동 실행: 기본(4셀) + 대형 8셀 구성
|
||
let actionConfig = ActionRunConfigIntent()
|
||
actionSmall = ActionRunProvider.makeEntry(actionConfig, family: .systemSmall)
|
||
actionMedium = ActionRunProvider.makeEntry(actionConfig, family: .systemMedium)
|
||
actionLarge4 = ActionRunProvider.makeEntry(actionConfig, family: .systemLarge)
|
||
var actionEight = ActionRunConfigIntent()
|
||
actionEight.largeStyle = .eightCells
|
||
actionLarge8 = ActionRunProvider.makeEntry(actionEight, family: .systemLarge)
|
||
|
||
// ② 목표 진행률: 중형 2구성 + 대형 3구성
|
||
let goalConfig = GoalProgressConfigIntent()
|
||
goalSmall = GoalProgressProvider.makeEntry(goalConfig, family: .systemSmall)
|
||
goalMediumTwo = GoalProgressProvider.makeEntry(goalConfig, family: .systemMedium)
|
||
var goalQuests = GoalProgressConfigIntent()
|
||
goalQuests.mediumStyle = .goalWithQuests
|
||
goalMediumQuests = GoalProgressProvider.makeEntry(goalQuests, family: .systemMedium)
|
||
goalLargeFour = GoalProgressProvider.makeEntry(goalConfig, family: .systemLarge)
|
||
var goalTwoQ = GoalProgressConfigIntent()
|
||
goalTwoQ.largeStyle = .twoGoalsWithQuests
|
||
goalLargeTwoQuests = GoalProgressProvider.makeEntry(goalTwoQ, family: .systemLarge)
|
||
var goalAllQ = GoalProgressConfigIntent()
|
||
goalAllQ.largeStyle = .oneGoalAllQuests
|
||
goalLargeAllQuests = GoalProgressProvider.makeEntry(goalAllQ, family: .systemLarge)
|
||
|
||
// ③ 다짐 진행률
|
||
let ringConfig = QuestRingConfigIntent()
|
||
ringSmall = QuestRingProvider.makeEntry(ringConfig, family: .systemSmall)
|
||
ringMedium = QuestRingProvider.makeEntry(ringConfig, family: .systemMedium)
|
||
ringLarge = QuestRingProvider.makeEntry(ringConfig, family: .systemLarge)
|
||
if let atMost = IntentStore.quests().first(where: { $0.direction == .atMost }) {
|
||
var atMostConfig = QuestRingConfigIntent()
|
||
atMostConfig.quests = [QuestEntity(atMost)]
|
||
ringAtMostSmall = QuestRingProvider.makeEntry(atMostConfig, family: .systemSmall)
|
||
}
|
||
|
||
// ④ 다짐 현황: 대형 3구성
|
||
let statusConfig = QuestStatusConfigIntent()
|
||
statusSmall = QuestStatusProvider.makeEntry(statusConfig, family: .systemSmall)
|
||
statusMedium = QuestStatusProvider.makeEntry(statusConfig, family: .systemMedium)
|
||
statusLargeMany = QuestStatusProvider.makeEntry(statusConfig, family: .systemLarge)
|
||
var statusFour = QuestStatusConfigIntent()
|
||
statusFour.largeStyle = .fourGoals
|
||
statusLargeFourGoals = QuestStatusProvider.makeEntry(statusFour, family: .systemLarge)
|
||
var statusTwo = QuestStatusConfigIntent()
|
||
statusTwo.largeStyle = .twoGoals
|
||
statusLargeTwoGoals = QuestStatusProvider.makeEntry(statusTwo, family: .systemLarge)
|
||
|
||
// ⑤ 통계
|
||
let statsConfig = StatsChartConfigIntent()
|
||
statsSmall = StatsChartProvider.makeEntry(statsConfig, family: .systemSmall)
|
||
statsMedium = StatsChartProvider.makeEntry(statsConfig, family: .systemMedium)
|
||
statsLarge = StatsChartProvider.makeEntry(statsConfig, family: .systemLarge)
|
||
|
||
// ⑥ 현재 진행 중: 실데이터(시드 데모의 진행 중 세션) + 여러 개 나열/빈 상태는 강제 구성
|
||
nowEntry = NowRunningProvider.makeEntry(NowRunningConfigIntent())
|
||
nowMulti = .sample()
|
||
nowEmpty = NowRunningEntry(date: .now, locked: false, theme: .matchApp,
|
||
primary: nil, others: [], todayTickingBase: nil)
|
||
}
|
||
|
||
// MARK: 홈 위젯
|
||
|
||
@ViewBuilder
|
||
private var homeSection: some View {
|
||
liveActivityRow
|
||
actionRows
|
||
goalRows
|
||
ringRows
|
||
statusRows
|
||
statsRows
|
||
nowRunningRows
|
||
}
|
||
|
||
/// 잠금화면 라이브 액티비티 배너 (종료 버튼 포함) — 실기기 잠금화면 렌더는 자동화가
|
||
/// 안 되므로 배너 뷰를 직접 그려 레이아웃·버튼 표시를 확인한다 (탭 동작은 실기기에서)
|
||
@ViewBuilder
|
||
private var liveActivityRow: some View {
|
||
row("잠금화면 실시간 현황 (측정 종료 버튼)") {
|
||
LockScreenActivityView(state: .init(
|
||
actionName: String(localized: "독서"),
|
||
symbolName: "book.fill",
|
||
colorHex: "#4A7A9D",
|
||
startedAt: .now.addingTimeInterval(-25 * 60),
|
||
extraCount: 1,
|
||
actionID: UUID().uuidString
|
||
))
|
||
.background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||
.frame(width: 372)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var actionRows: some View {
|
||
if let small = actionSmall, let medium = actionMedium {
|
||
row("① 행동 실행 · 소형/중형(2×2)") {
|
||
widgetBox(.systemSmall) { ActionRunWidgetView(previewFamily: .systemSmall, entry: small) }
|
||
widgetBox(.systemMedium) { ActionRunWidgetView(previewFamily: .systemMedium, entry: medium) }
|
||
}
|
||
}
|
||
if let large = actionLarge4 {
|
||
row("① 행동 실행 · 대형(큰 셀 4개)") {
|
||
widgetBox(.systemLarge) { ActionRunWidgetView(previewFamily: .systemLarge, entry: large) }
|
||
}
|
||
.id("A4")
|
||
}
|
||
if let large = actionLarge8 {
|
||
row("① 행동 실행 · 대형(작은 셀 8개)") {
|
||
widgetBox(.systemLarge) { ActionRunWidgetView(previewFamily: .systemLarge, entry: large) }
|
||
}
|
||
.id("A8")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var goalRows: some View {
|
||
if let small = goalSmall, let medium = goalMediumTwo {
|
||
row("② 목표 진행률 · 소형/중형(목표 2개)") {
|
||
widgetBox(.systemSmall) { GoalProgressWidgetView(previewFamily: .systemSmall, entry: small) }
|
||
widgetBox(.systemMedium) { GoalProgressWidgetView(previewFamily: .systemMedium, entry: medium) }
|
||
}
|
||
.id("B")
|
||
}
|
||
if let medium = goalMediumQuests {
|
||
row("② 목표 진행률 · 중형(목표+다짐)") {
|
||
widgetBox(.systemMedium) { GoalProgressWidgetView(previewFamily: .systemMedium, entry: medium) }
|
||
}
|
||
.id("B2")
|
||
}
|
||
if let large = goalLargeFour {
|
||
row("② 목표 진행률 · 대형(목표 4개)") {
|
||
widgetBox(.systemLarge) { GoalProgressWidgetView(previewFamily: .systemLarge, entry: large) }
|
||
}
|
||
.id("B4")
|
||
}
|
||
if let large = goalLargeTwoQuests {
|
||
row("② 목표 진행률 · 대형(목표+다짐 2개)") {
|
||
widgetBox(.systemLarge) { GoalProgressWidgetView(previewFamily: .systemLarge, entry: large) }
|
||
}
|
||
.id("B2Q")
|
||
}
|
||
if let large = goalLargeAllQuests {
|
||
row("② 목표 진행률 · 대형(모든 다짐)") {
|
||
widgetBox(.systemLarge) { GoalProgressWidgetView(previewFamily: .systemLarge, entry: large) }
|
||
}
|
||
.id("BALL")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var ringRows: some View {
|
||
if let small = ringSmall, let medium = ringMedium {
|
||
row("③ 다짐 진행률 · 소형/중형") {
|
||
widgetBox(.systemSmall) { QuestRingWidgetView(previewFamily: .systemSmall, entry: small) }
|
||
widgetBox(.systemMedium) { QuestRingWidgetView(previewFamily: .systemMedium, entry: medium) }
|
||
}
|
||
.id("C")
|
||
}
|
||
if let atMostSmall = ringAtMostSmall {
|
||
row("③ 다짐 진행률 · 이하 유지(소형)") {
|
||
widgetBox(.systemSmall) { QuestRingWidgetView(previewFamily: .systemSmall, entry: atMostSmall) }
|
||
}
|
||
.id("CM")
|
||
}
|
||
if let large = ringLarge {
|
||
row("③ 다짐 진행률 · 대형(2×2)") {
|
||
widgetBox(.systemLarge) { QuestRingWidgetView(previewFamily: .systemLarge, entry: large) }
|
||
}
|
||
.id("CL")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var statusRows: some View {
|
||
if let small = statusSmall, let medium = statusMedium {
|
||
row("④ 다짐 현황 · 소형(2×2)/중형(4×2)") {
|
||
widgetBox(.systemSmall) { QuestStatusWidgetView(previewFamily: .systemSmall, entry: small) }
|
||
widgetBox(.systemMedium) { QuestStatusWidgetView(previewFamily: .systemMedium, entry: medium) }
|
||
}
|
||
.id("D")
|
||
}
|
||
if let large = statusLargeMany {
|
||
row("④ 다짐 현황 · 대형(다짐 16개)") {
|
||
widgetBox(.systemLarge) { QuestStatusWidgetView(previewFamily: .systemLarge, entry: large) }
|
||
}
|
||
.id("D16")
|
||
}
|
||
if let large = statusLargeFourGoals {
|
||
row("④ 다짐 현황 · 대형(목표 4개)") {
|
||
widgetBox(.systemLarge) { QuestStatusWidgetView(previewFamily: .systemLarge, entry: large) }
|
||
}
|
||
.id("D4G")
|
||
}
|
||
if let large = statusLargeTwoGoals {
|
||
row("④ 다짐 현황 · 대형(목표 2개)") {
|
||
widgetBox(.systemLarge) { QuestStatusWidgetView(previewFamily: .systemLarge, entry: large) }
|
||
}
|
||
.id("D2G")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var statsRows: some View {
|
||
if let small = statsSmall, let medium = statsMedium {
|
||
row("⑤ 통계 · 소형/중형") {
|
||
widgetBox(.systemSmall) { StatsChartWidgetView(previewFamily: .systemSmall, entry: small) }
|
||
widgetBox(.systemMedium) { StatsChartWidgetView(previewFamily: .systemMedium, entry: medium) }
|
||
}
|
||
.id("S")
|
||
}
|
||
if let large = statsLarge {
|
||
row("⑤ 통계 · 대형") {
|
||
widgetBox(.systemLarge) { StatsChartWidgetView(previewFamily: .systemLarge, entry: large) }
|
||
}
|
||
.id("SL")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var nowRunningRows: some View {
|
||
if let entry = nowEntry {
|
||
row("⑥ 현재 진행 중 · 소형/중형") {
|
||
widgetBox(.systemSmall) { NowRunningWidgetView(previewFamily: .systemSmall, entry: entry) }
|
||
widgetBox(.systemMedium) { NowRunningWidgetView(previewFamily: .systemMedium, entry: entry) }
|
||
}
|
||
.id("R")
|
||
row("⑥ 현재 진행 중 · 대형") {
|
||
widgetBox(.systemLarge) { NowRunningWidgetView(previewFamily: .systemLarge, entry: entry) }
|
||
}
|
||
.id("RL")
|
||
}
|
||
if let multi = nowMulti {
|
||
row("⑥ 현재 진행 중 · 대형(여러 개, 샘플)") {
|
||
widgetBox(.systemLarge) { NowRunningWidgetView(previewFamily: .systemLarge, entry: multi) }
|
||
}
|
||
.id("RM")
|
||
}
|
||
if let empty = nowEmpty {
|
||
row("⑥ 현재 진행 중 · 빈 상태(소형/중형)") {
|
||
widgetBox(.systemSmall) { NowRunningWidgetView(previewFamily: .systemSmall, entry: empty) }
|
||
widgetBox(.systemMedium) { NowRunningWidgetView(previewFamily: .systemMedium, entry: empty) }
|
||
}
|
||
.id("RE")
|
||
}
|
||
}
|
||
|
||
// MARK: 잠금화면 위젯
|
||
|
||
@ViewBuilder
|
||
private var lockSection: some View {
|
||
ForEach(lockEntries, id: \.0) { name, entry in
|
||
row("잠금화면 · \(name)") {
|
||
ZStack {
|
||
Color.black
|
||
LockGoalWidgetView(previewFamily: .accessoryCircular, entry: entry)
|
||
.foregroundStyle(.white)
|
||
}
|
||
.frame(width: 72, height: 72)
|
||
.clipShape(Circle())
|
||
ZStack {
|
||
Color.black
|
||
LockGoalWidgetView(previewFamily: .accessoryRectangular, entry: entry)
|
||
.foregroundStyle(.white)
|
||
.padding(6)
|
||
}
|
||
.frame(width: 172, height: 76)
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
}
|
||
if let entry = lockEntries.first?.1 {
|
||
row("잠금화면 · inline") {
|
||
ZStack {
|
||
Color.black
|
||
LockGoalWidgetView(previewFamily: .accessoryInline, entry: entry)
|
||
.foregroundStyle(.white)
|
||
}
|
||
.frame(width: 230, height: 32)
|
||
.clipShape(Capsule())
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: 레이아웃 헬퍼
|
||
|
||
private func row(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(title)
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
VStack(alignment: .leading, spacing: 12, content: content)
|
||
}
|
||
}
|
||
|
||
/// -widgetRenderMode accented|vibrant → 색조/투명 홈 화면 렌더링 모드를 강제해
|
||
/// white-on-white 대비 문제를 앱 안에서 검증 (기본은 실제와 동일한 fullColor)
|
||
private var forcedRenderingMode: WidgetRenderingMode {
|
||
switch UserDefaults.standard.string(forKey: "widgetRenderMode") {
|
||
case "accented": return .accented
|
||
case "vibrant": return .vibrant
|
||
default: return .fullColor
|
||
}
|
||
}
|
||
|
||
private func widgetBox(_ family: WidgetFamily, @ViewBuilder content: () -> some View) -> some View {
|
||
let size: CGSize = switch family {
|
||
case .systemMedium: CGSize(width: 338, height: 158)
|
||
case .systemLarge: CGSize(width: 338, height: 354)
|
||
default: CGSize(width: 158, height: 158)
|
||
}
|
||
// containerBackground는 실제 위젯 컨텍스트 밖에서는 무시되므로
|
||
// 미리보기 박스가 앱 테마 배경/컬러 스킴을 직접 재현한다
|
||
// contentMarginsDisabled 적용 후에는 위젯 뷰가 여백을 스스로 관리한다
|
||
return content()
|
||
.frame(width: size.width, height: size.height)
|
||
.background(AppTheme.background)
|
||
// 앱 테마가 "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)
|
||
}
|
||
}
|
||
#endif
|