Widget system rebuilt as 5 independent widgets, each with its own WidgetConfigurationIntent registered in the WidgetBundle: - ① ActionRun: multi-action selection, value display option (day/week/ month/hidden), full tag-color cells; small 1 / medium 4 (2x2) / large 4 big cells or 8 compact cells (2x4) via largeStyle. Unfilled slots render as dashed blank cells. - ② GoalProgress: multi-goal selection; medium [2 goals | goal+top quests], large [4 goals | 2x goal+quests | 1 goal + all quests] via medium/largeStyle. New QuestBarRow / GoalWithQuestsCellView. - ③ QuestRing: multi-quest selection + span; surface-card ring (tag color) with centered icon, name and "오늘 N%" text; interactive run via RunActionIntent; small 1 / medium 2 / large 4 (2x2). - ④ QuestStatus (display-only): goal selection + span; rings with name only (no %), goal icon+name top-left; small 4 (2x2) / medium 8 (4x2) / large [16 (4x4) | 4 goals | 2 goals] via largeStyle. - ⑤ StatsChart: multi-action selection capped per family (2/4/6) + 3 span modes; same design scaled by family. Common foundation: - Theme option revived on every intent: match app / light fixed / dark fixed (WidgetThemeOption AppEnum) applied through widgetAppTheme(_:) with proper iOS 17 containerBackground; non-fullColor rendering modes (tinted/clear/accented) keep the Color.clear + semantic-color defense so content never goes white-on-white. - Providers are family-aware (capacity by size) and slot-pad selections (WidgetStore.slots); defaults fill from existing data so fresh widgets never look empty. - Interactive path unchanged: RunActionIntent(actionID:) + IntentStore.performWrite -> reloadAllTimelines. Floating menu polish: - All glass capsules now share a uniform width (longest label wins, locale-proof) for a tidy popped-up list. - Settings strings updated: nav style renamed to '플로팅 글래스 캡슐' with description matching the capsule popup, order editor retitled '캡슐 메뉴 순서' with bottom-to-top wording. Verified: BUILD SUCCEEDED (iPhone 17 Pro sim); widget preview harness extended with per-variant scroll anchors and all 15 layout variants screenshot-checked, including forced accented mode (legible translucent cells) and blank-slot rendering; capsule menu and settings text screenshot-checked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
347 lines
14 KiB
Swift
347 lines
14 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 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)
|
||
|
||
// ④ 다짐 현황: 대형 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 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
|