- AppGroup.defaults를 단일 인스턴스(static let)로 변경: 접근마다 새 UserDefaults를 만들어 @AppStorage(store:) 관찰이 끊기던 문제 수정 → 설정에서 테마를 바꾸면 재시작 없이 즉시 적용됨 - 위젯 containerBackground에 강제 컬러 스킴을 직접 지정: 시스템이 배경을 위젯 환경으로 그려 라이트/다크 고정·앱 테마 일치가 실제 위젯에서 무시되던 문제 수정 - 테마 변경 시 위젯 타임라인 갱신 (앱 테마와 일치 옵션 즉시 반영) - 리퀴드 글라스 실구현: 머티리얼+하이라이트 배경(GlassWidgetBackground), 유리 테두리 셀(WidgetCellBackground), 색이 비치는 틴트 셀 (WidgetTintedCellBackground, 행동 실행 위젯 적용) - 검증 인자 -themeAutoToggle YES 추가 (3초 후 테마 반전) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
218 lines
8.8 KiB
Swift
218 lines
8.8 KiB
Swift
//
|
|
// WidgetPreviewScreen.swift
|
|
// Haru_Danim
|
|
//
|
|
// DEBUG 전용: 위젯 뷰들을 앱 안에서 실제 크기로 렌더링해 검증하는 화면.
|
|
// 런치 인자 `-widgetPreview YES`(홈 위젯) / `-widgetPreview lock`(잠금화면)으로 진입.
|
|
// (시뮬레이터 홈 화면에 위젯을 자동으로 추가할 방법이 없어 만든 검증 도구)
|
|
//
|
|
|
|
#if DEBUG
|
|
import SwiftUI
|
|
import WidgetKit
|
|
|
|
struct WidgetPreviewScreen: View {
|
|
let showLock: Bool
|
|
|
|
/// -widgetTheme light|dark|glass (§8.1 검증용)
|
|
private let theme = UserDefaults.standard.string(forKey: "widgetTheme")
|
|
.flatMap(WidgetThemeOption.init(rawValue:)) ?? .matchApp
|
|
|
|
@State private var actionEntry: ActionRunEntry?
|
|
@State private var goalBarsEntry: GoalBarsEntry?
|
|
@State private var questRingEntry: QuestRingEntry?
|
|
@State private var gridEntry: GoalQuestGridEntry?
|
|
@State private var statsEntrySmall: StatsChartEntry?
|
|
@State private var statsEntryMedium: 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 {
|
|
if theme == .glass {
|
|
// 리퀴드 글라스 확인용: 유리 질감이 드러나도록 화려한 배경 위에 렌더링
|
|
LinearGradient(
|
|
colors: [.blue, .purple, .orange, .pink],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
)
|
|
.ignoresSafeArea()
|
|
} else {
|
|
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)),
|
|
]
|
|
} else {
|
|
// 모든 위젯 설정에 테마 주입 (§8.1 검증)
|
|
let actionConfig = ActionRunConfigIntent()
|
|
actionConfig.theme = theme
|
|
actionEntry = ActionRunProvider.makeEntry(actionConfig)
|
|
let goalConfig = GoalBarsConfigIntent()
|
|
goalConfig.theme = theme
|
|
goalBarsEntry = GoalBarsProvider.makeEntry(goalConfig)
|
|
let questConfig = QuestRingConfigIntent()
|
|
questConfig.theme = theme
|
|
questRingEntry = QuestRingProvider.makeEntry(questConfig)
|
|
let gridConfig = GoalQuestGridConfigIntent()
|
|
gridConfig.theme = theme
|
|
gridEntry = GoalQuestGridProvider.makeEntry(gridConfig)
|
|
let statsConfig = StatsChartConfigIntent()
|
|
statsConfig.theme = theme
|
|
statsEntrySmall = StatsChartProvider.makeEntry(statsConfig, family: .systemSmall)
|
|
statsEntryMedium = StatsChartProvider.makeEntry(statsConfig, family: .systemMedium)
|
|
}
|
|
}
|
|
|
|
// MARK: 홈 위젯
|
|
|
|
@ViewBuilder
|
|
private var homeSection: some View {
|
|
if let entry = actionEntry {
|
|
row("A 행동 실행 · 소형/중형") {
|
|
widgetBox(.systemSmall) { ActionRunWidgetView(previewFamily: .systemSmall, entry: entry) }
|
|
widgetBox(.systemMedium) { ActionRunWidgetView(previewFamily: .systemMedium, entry: entry) }
|
|
}
|
|
row("A 행동 실행 · 대형") {
|
|
widgetBox(.systemLarge) { ActionRunWidgetView(previewFamily: .systemLarge, entry: entry) }
|
|
}
|
|
}
|
|
if let entry = goalBarsEntry {
|
|
row("B 목표 진행률 · 소형/중형") {
|
|
widgetBox(.systemSmall) { GoalBarsWidgetView(previewFamily: .systemSmall, entry: entry) }
|
|
widgetBox(.systemMedium) { GoalBarsWidgetView(previewFamily: .systemMedium, entry: entry) }
|
|
}
|
|
.id("B")
|
|
}
|
|
if let entry = questRingEntry {
|
|
row("C 다짐 진행률 · 소형/중형") {
|
|
widgetBox(.systemSmall) { QuestRingWidgetView(previewFamily: .systemSmall, entry: entry) }
|
|
widgetBox(.systemMedium) { QuestRingWidgetView(previewFamily: .systemMedium, entry: entry) }
|
|
}
|
|
.id("C")
|
|
}
|
|
if let entry = gridEntry {
|
|
row("D 다짐 현황 · 소형/중형") {
|
|
widgetBox(.systemSmall) { GoalQuestGridWidgetView(previewFamily: .systemSmall, entry: entry) }
|
|
widgetBox(.systemMedium) { GoalQuestGridWidgetView(previewFamily: .systemMedium, entry: entry) }
|
|
}
|
|
.id("D")
|
|
row("D 다짐 현황 · 대형(목표 2개)") {
|
|
widgetBox(.systemLarge) { GoalQuestGridWidgetView(previewFamily: .systemLarge, entry: entry) }
|
|
}
|
|
}
|
|
if let small = statsEntrySmall, let medium = statsEntryMedium {
|
|
row("통계 · 소형/중형") {
|
|
widgetBox(.systemSmall) { StatsChartWidgetView(previewFamily: .systemSmall, entry: small) }
|
|
widgetBox(.systemMedium) { StatsChartWidgetView(previewFamily: .systemMedium, entry: medium) }
|
|
}
|
|
.id("S")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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는 실제 위젯 컨텍스트 밖에서는 무시되므로
|
|
// 미리보기 박스가 테마 배경/컬러 스킴을 직접 재현한다
|
|
return content()
|
|
.padding(12)
|
|
.frame(width: size.width, height: size.height)
|
|
.background {
|
|
if theme == .glass {
|
|
GlassWidgetBackground()
|
|
} else {
|
|
AppTheme.background
|
|
}
|
|
}
|
|
.environment(\.colorScheme, theme.forcedScheme ?? .light)
|
|
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
.shadow(color: .black.opacity(0.08), radius: 6, y: 2)
|
|
}
|
|
}
|
|
#endif
|