mycode/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift
songyc macbook ac40e14cfe 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>
2026-07-23 02:02:38 +09:00

429 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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