- 잠금화면 위젯 '다짐 점'이 달성/미달성 이진 채움이라 진행 정도를 알 수 없다는 보고 → 각 점이 진행률(ratio)만큼 아래에서부터 차오르게 변경. 표시 전용 소형 뷰 수정이라 위험 낮음 판단 후 진행 - ratio를 그대로 쓰므로 달성=완전 채움(기존과 동일), '이하 유지'는 ratio가 1/0이라 한도 안=채움/초과=빈 점 동작 불변. 작은 크기(5~6pt)에선 부채꼴보다 물높이 방식이 가독성 좋아 채택 - 위젯 미리보기 잠금 섹션에 '다짐 여러 개+주간' 항목 추가 — 부분 채움(반쯤 찬 점/빈 점/가득 점) 시뮬 스크린샷으로 circular/rectangular 모두 확인 - 새 문자열 없음(카탈로그 클린), 도움말 무변경(스타일 세부 서술 없음). CLAUDE.md §10 갱신. Debug/Store 빌드 성공 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
426 lines
18 KiB
Swift
426 lines
18 KiB
Swift
//
|
||
// WidgetPreviewScreen.swift
|
||
// Haru_Danim
|
||
//
|
||
// DEBUG 전용: 위젯 뷰들을 앱 안에서 실제 크기로 렌더링해 검증하는 화면.
|
||
// 런치 인자 `-widgetPreview YES`(홈 위젯) / `-widgetPreview lock`(잠금화면)으로 진입.
|
||
// (시뮬레이터 홈 화면에 위젯을 자동으로 추가할 방법이 없어 만든 검증 도구)
|
||
//
|
||
|
||
#if DEBUG
|
||
import SwiftUI
|
||
import WidgetKit
|
||
|
||
struct WidgetPreviewScreen: View {
|
||
let showLock: Bool
|
||
|
||
// ① 행동 실행
|
||
@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)
|
||
.environment(\.colorScheme, WidgetAppearance.appScheme)
|
||
.environment(\.widgetRenderingMode, forcedRenderingMode)
|
||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||
.shadow(color: .black.opacity(0.08), radius: 6, y: 2)
|
||
}
|
||
}
|
||
#endif
|