③ 다짐 진행률 위젯에서 '이하 유지' 다짐은 시작부터 링이 가득 차 있고
문구도 '오늘 한도 지킴'뿐이라 지금 몇 회/몇 시간 했는지 알 수 없던 문제.
퍼센트(또는 한도 지킴/초과) 문구와 연속 달성 문구 사이에 위젯 옵션의
진행률 기간 기준 실제 누적값 한 줄을 추가한다 ("1시간 20분" / "3회").
'이상 달성' 다짐에도 동일하게 표시. 주간·월간 진행률 캡과 무관한
원본 누적값이며, 다른 위젯(②④)과 나머지 레이아웃은 그대로.
- QuestCellSnapshot.valueLabel 추가 (spanRange 원본 값 포맷)
- QuestProgress.spanRange(_:now:) 헬퍼 추출
- 위젯 미리보기에 '이하 유지(소형)' 행 신설 (앵커 CM)
- l10n: '0분' ko/en/ja (IOS·Widgets 카탈로그)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
360 lines
15 KiB
Swift
360 lines
15 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 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
|
||
lockEntries = [
|
||
("gauge", LockGoalProvider.makeEntry(gauge)),
|
||
("number", LockGoalProvider.makeEntry(number)),
|
||
("dots", LockGoalProvider.makeEntry(dots)),
|
||
]
|
||
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)
|
||
}
|
||
|
||
// MARK: 홈 위젯
|
||
|
||
@ViewBuilder
|
||
private var homeSection: some View {
|
||
actionRows
|
||
goalRows
|
||
ringRows
|
||
statusRows
|
||
statsRows
|
||
}
|
||
|
||
@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")
|
||
}
|
||
}
|
||
|
||
// 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
|