feat(widgets): 위젯 테마 옵션 추가 — 앱 테마 일치/라이트·다크 고정/리퀴드 글라스 (CLAUDE.md §8.1)
- 홈 위젯 5종(행동 실행/목표 진행률/다짐 진행률/다짐 현황/통계) 설정에 '위젯 테마' 옵션 추가 - 앱 테마와 일치: 앱의 라이트/다크 설정을 따름 (테마 키를 App Group으로 이관해 위젯이 읽을 수 있게 함) - 라이트/다크 고정: 컬러 스킴 강제 + 해당 모드 팔레트 - 리퀴드 글라스: 반투명 머티리얼 배경 + 표면 셀도 머티리얼로 변형해 유리 질감 위에서 가독성 유지 - DEBUG 미리보기가 테마를 재현하도록 개선 (-widgetTheme light|dark|glass, 글라스는 그라데이션 배경 위에 렌더링) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
8e69bbb25f
commit
9bf617f3e5
@ -10,7 +10,7 @@ import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@AppStorage(SettingsKeys.theme) private var theme: String = "light"
|
||||
@AppStorage(SettingsKeys.theme, store: AppGroup.defaults) private var theme: String = "light"
|
||||
@State private var showSplash = true
|
||||
|
||||
var body: some View {
|
||||
|
||||
@ -10,7 +10,8 @@ import SwiftData
|
||||
|
||||
struct SettingsView: View {
|
||||
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
|
||||
@AppStorage(SettingsKeys.theme) private var theme = "light"
|
||||
// 테마는 위젯('앱 테마와 일치' 옵션)이 읽어야 하므로 App Group defaults에 저장
|
||||
@AppStorage(SettingsKeys.theme, store: AppGroup.defaults) private var theme = "light"
|
||||
@AppStorage(SettingsKeys.language) private var language = AppLanguage.ko.rawValue
|
||||
// 집계 기준 설정은 위젯·워치와 공유해야 하므로 App Group defaults에 저장
|
||||
@AppStorage(SettingsKeys.weekStartWeekday, store: AppGroup.defaults) private var weekStartWeekday = 2
|
||||
|
||||
@ -14,6 +14,10 @@ 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?
|
||||
@ -34,7 +38,18 @@ struct WidgetPreviewScreen: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Color(white: 0.9))
|
||||
.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 → 해당 섹션으로 자동 스크롤
|
||||
@ -63,12 +78,23 @@ struct WidgetPreviewScreen: View {
|
||||
("dots", LockGoalProvider.makeEntry(dots)),
|
||||
]
|
||||
} else {
|
||||
actionEntry = ActionRunProvider.makeEntry(ActionRunConfigIntent())
|
||||
goalBarsEntry = GoalBarsProvider.makeEntry(GoalBarsConfigIntent())
|
||||
questRingEntry = QuestRingProvider.makeEntry(QuestRingConfigIntent())
|
||||
gridEntry = GoalQuestGridProvider.makeEntry(GoalQuestGridConfigIntent())
|
||||
statsEntrySmall = StatsChartProvider.makeEntry(StatsChartConfigIntent(), family: .systemSmall)
|
||||
statsEntryMedium = StatsChartProvider.makeEntry(StatsChartConfigIntent(), family: .systemMedium)
|
||||
// 모든 위젯 설정에 테마 주입 (§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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,10 +197,19 @@ struct WidgetPreviewScreen: View {
|
||||
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(AppTheme.background)
|
||||
.background {
|
||||
if theme == .glass {
|
||||
Rectangle().fill(.ultraThinMaterial)
|
||||
} else {
|
||||
AppTheme.background
|
||||
}
|
||||
}
|
||||
.environment(\.colorScheme, theme.forcedScheme ?? .light)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, y: 2)
|
||||
}
|
||||
|
||||
@ -133,7 +133,8 @@ nonisolated enum SettingsMigration {
|
||||
guard !group.bool(forKey: doneKey) else { return }
|
||||
let standard = UserDefaults.standard
|
||||
for key in [SettingsKeys.weekStartWeekday, SettingsKeys.dayStartMinutes,
|
||||
SettingsKeys.liveActivityMode, SettingsKeys.minSessionSeconds] {
|
||||
SettingsKeys.liveActivityMode, SettingsKeys.minSessionSeconds,
|
||||
SettingsKeys.theme] {
|
||||
if group.object(forKey: key) == nil, let value = standard.object(forKey: key) {
|
||||
group.set(value, forKey: key)
|
||||
}
|
||||
|
||||
@ -19,6 +19,9 @@ struct ActionRunConfigIntent: WidgetConfigurationIntent {
|
||||
@Parameter(title: "표시 기간", default: .day)
|
||||
var period: SpanOption
|
||||
|
||||
@Parameter(title: "위젯 테마", default: .matchApp)
|
||||
var theme: WidgetThemeOption
|
||||
|
||||
@Parameter(title: "행동 1")
|
||||
var action1: ActionEntity?
|
||||
|
||||
@ -37,6 +40,7 @@ struct ActionRunConfigIntent: WidgetConfigurationIntent {
|
||||
struct ActionRunEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let theme: WidgetThemeOption
|
||||
let periodLabel: String
|
||||
let cells: [ActionCellSnapshot]
|
||||
}
|
||||
@ -45,7 +49,7 @@ struct ActionRunProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: ActionRunConfigIntent) -> ActionRunEntry {
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return ActionRunEntry(date: .now, locked: true, periodLabel: "", cells: [])
|
||||
return ActionRunEntry(date: .now, locked: true, theme: configuration.theme, periodLabel: "", cells: [])
|
||||
}
|
||||
let span = configuration.period.statSpan
|
||||
let chosen = [configuration.action1, configuration.action2, configuration.action3, configuration.action4]
|
||||
@ -55,13 +59,14 @@ struct ActionRunProvider: AppIntentTimelineProvider {
|
||||
return ActionRunEntry(
|
||||
date: .now,
|
||||
locked: false,
|
||||
theme: configuration.theme,
|
||||
periodLabel: configuration.period.label,
|
||||
cells: actions.map { ActionCellSnapshot.make(action: $0, span: span) }
|
||||
)
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> ActionRunEntry {
|
||||
ActionRunEntry(date: .now, locked: false, periodLabel: "오늘", cells: [])
|
||||
ActionRunEntry(date: .now, locked: false, theme: .matchApp, periodLabel: "오늘", cells: [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -180,7 +185,7 @@ struct ActionRunWidgetView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
.widgetTheme(entry.theme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -26,11 +26,15 @@ struct GoalBarsConfigIntent: WidgetConfigurationIntent {
|
||||
|
||||
@Parameter(title: "목표 4")
|
||||
var goal4: GoalEntity?
|
||||
|
||||
@Parameter(title: "위젯 테마", default: .matchApp)
|
||||
var theme: WidgetThemeOption
|
||||
}
|
||||
|
||||
struct GoalBarsEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let theme: WidgetThemeOption
|
||||
let goals: [GoalSnapshot]
|
||||
}
|
||||
|
||||
@ -38,17 +42,17 @@ struct GoalBarsProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: GoalBarsConfigIntent) -> GoalBarsEntry {
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return GoalBarsEntry(date: .now, locked: true, goals: [])
|
||||
return GoalBarsEntry(date: .now, locked: true, theme: configuration.theme, goals: [])
|
||||
}
|
||||
let chosen = [configuration.goal1, configuration.goal2, configuration.goal3, configuration.goal4]
|
||||
.compactMap { $0 }
|
||||
.compactMap { WidgetStore.goal($0.id) }
|
||||
let goals = chosen.isEmpty ? WidgetStore.defaultGoals(4) : chosen
|
||||
return GoalBarsEntry(date: .now, locked: false, goals: goals.map { GoalSnapshot.make(goal: $0) })
|
||||
return GoalBarsEntry(date: .now, locked: false, theme: configuration.theme, goals: goals.map { GoalSnapshot.make(goal: $0) })
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> GoalBarsEntry {
|
||||
GoalBarsEntry(date: .now, locked: false, goals: [])
|
||||
GoalBarsEntry(date: .now, locked: false, theme: .matchApp, goals: [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -81,10 +85,7 @@ struct GoalBarsCellView: View {
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(AppTheme.surface)
|
||||
)
|
||||
.background(WidgetCellBackground())
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,7 +138,7 @@ struct GoalBarsWidgetView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
.widgetTheme(entry.theme)
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,11 +171,15 @@ struct GoalQuestGridConfigIntent: WidgetConfigurationIntent {
|
||||
|
||||
@Parameter(title: "진행률 기간", default: .day)
|
||||
var span: SpanOption
|
||||
|
||||
@Parameter(title: "위젯 테마", default: .matchApp)
|
||||
var theme: WidgetThemeOption
|
||||
}
|
||||
|
||||
struct GoalQuestGridEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let theme: WidgetThemeOption
|
||||
let spanLabel: String
|
||||
let goals: [GoalSnapshot]
|
||||
}
|
||||
@ -183,7 +188,7 @@ struct GoalQuestGridProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: GoalQuestGridConfigIntent) -> GoalQuestGridEntry {
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return GoalQuestGridEntry(date: .now, locked: true, spanLabel: "", goals: [])
|
||||
return GoalQuestGridEntry(date: .now, locked: true, theme: configuration.theme, spanLabel: "", goals: [])
|
||||
}
|
||||
let span = configuration.span.statSpan
|
||||
var goals: [Goal] = []
|
||||
@ -193,13 +198,14 @@ struct GoalQuestGridProvider: AppIntentTimelineProvider {
|
||||
return GoalQuestGridEntry(
|
||||
date: .now,
|
||||
locked: false,
|
||||
theme: configuration.theme,
|
||||
spanLabel: configuration.span.label,
|
||||
goals: goals.map { GoalSnapshot.make(goal: $0, span: span) }
|
||||
)
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> GoalQuestGridEntry {
|
||||
GoalQuestGridEntry(date: .now, locked: false, spanLabel: "오늘", goals: [])
|
||||
GoalQuestGridEntry(date: .now, locked: false, theme: .matchApp, spanLabel: "오늘", goals: [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -271,7 +277,7 @@ struct GoalQuestGridWidgetView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
.widgetTheme(entry.theme)
|
||||
}
|
||||
|
||||
private func goalSection(_ goal: GoalSnapshot, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View {
|
||||
|
||||
@ -19,6 +19,9 @@ struct QuestRingConfigIntent: WidgetConfigurationIntent {
|
||||
@Parameter(title: "진행률 기간", default: .day)
|
||||
var span: SpanOption
|
||||
|
||||
@Parameter(title: "위젯 테마", default: .matchApp)
|
||||
var theme: WidgetThemeOption
|
||||
|
||||
@Parameter(title: "다짐 1")
|
||||
var quest1: QuestEntity?
|
||||
|
||||
@ -37,6 +40,7 @@ struct QuestRingConfigIntent: WidgetConfigurationIntent {
|
||||
struct QuestRingEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let theme: WidgetThemeOption
|
||||
let spanLabel: String
|
||||
let cells: [QuestCellSnapshot]
|
||||
}
|
||||
@ -45,7 +49,7 @@ struct QuestRingProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: QuestRingConfigIntent) -> QuestRingEntry {
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return QuestRingEntry(date: .now, locked: true, spanLabel: "", cells: [])
|
||||
return QuestRingEntry(date: .now, locked: true, theme: configuration.theme, spanLabel: "", cells: [])
|
||||
}
|
||||
let span = configuration.span.statSpan
|
||||
var quests = [configuration.quest1, configuration.quest2, configuration.quest3, configuration.quest4]
|
||||
@ -57,13 +61,14 @@ struct QuestRingProvider: AppIntentTimelineProvider {
|
||||
return QuestRingEntry(
|
||||
date: .now,
|
||||
locked: false,
|
||||
theme: configuration.theme,
|
||||
spanLabel: configuration.span.label,
|
||||
cells: quests.map { QuestCellSnapshot.make(quest: $0, span: span) }
|
||||
)
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> QuestRingEntry {
|
||||
QuestRingEntry(date: .now, locked: false, spanLabel: "오늘", cells: [])
|
||||
QuestRingEntry(date: .now, locked: false, theme: .matchApp, spanLabel: "오늘", cells: [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -116,10 +121,7 @@ struct QuestRingActionCellView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(AppTheme.surface)
|
||||
)
|
||||
.background(WidgetCellBackground())
|
||||
}
|
||||
|
||||
private var percentText: String {
|
||||
@ -183,7 +185,7 @@ struct QuestRingWidgetView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
.widgetTheme(entry.theme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -41,6 +41,9 @@ struct StatsChartConfigIntent: WidgetConfigurationIntent {
|
||||
@Parameter(title: "통계 기간", default: .week)
|
||||
var span: StatsChartSpan
|
||||
|
||||
@Parameter(title: "위젯 테마", default: .matchApp)
|
||||
var theme: WidgetThemeOption
|
||||
|
||||
@Parameter(title: "행동 1")
|
||||
var action1: ActionEntity?
|
||||
|
||||
@ -66,6 +69,7 @@ struct StatsPoint: Identifiable {
|
||||
struct StatsChartEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let theme: WidgetThemeOption
|
||||
let title: String
|
||||
let isWeekAxis: Bool
|
||||
let isTimeType: Bool
|
||||
@ -78,7 +82,7 @@ struct StatsChartProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: StatsChartConfigIntent, family: WidgetFamily) -> StatsChartEntry {
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return StatsChartEntry(date: .now, locked: true, title: "", isWeekAxis: false,
|
||||
return StatsChartEntry(date: .now, locked: true, theme: configuration.theme, title: "", isWeekAxis: false,
|
||||
isTimeType: true, names: [], colorHexes: [], points: [])
|
||||
}
|
||||
// 소형은 "한 달 (하루 단위)" 제외 (CLAUDE.md §8.3) → 주 단위로 대체
|
||||
@ -132,6 +136,7 @@ struct StatsChartProvider: AppIntentTimelineProvider {
|
||||
return StatsChartEntry(
|
||||
date: now,
|
||||
locked: false,
|
||||
theme: configuration.theme,
|
||||
title: span.title,
|
||||
isWeekAxis: span == .monthByWeek,
|
||||
isTimeType: isTimeType,
|
||||
@ -142,7 +147,7 @@ struct StatsChartProvider: AppIntentTimelineProvider {
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> StatsChartEntry {
|
||||
StatsChartEntry(date: .now, locked: false, title: "일주일 통계", isWeekAxis: false,
|
||||
StatsChartEntry(date: .now, locked: false, theme: .matchApp, title: "일주일 통계", isWeekAxis: false,
|
||||
isTimeType: true, names: [], colorHexes: [], points: [])
|
||||
}
|
||||
|
||||
@ -185,7 +190,7 @@ struct StatsChartWidgetView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
.widgetTheme(entry.theme)
|
||||
}
|
||||
|
||||
private var colors: [Color] {
|
||||
|
||||
94
myApp/HaruDanim/Widgets/WidgetTheme.swift
Normal file
94
myApp/HaruDanim/Widgets/WidgetTheme.swift
Normal file
@ -0,0 +1,94 @@
|
||||
//
|
||||
// WidgetTheme.swift
|
||||
// Haru_DanimWidgets
|
||||
//
|
||||
// 위젯 테마 옵션 (CLAUDE.md §8.1)
|
||||
// 1. 앱 테마와 일치 2. 라이트/다크 고정 3. 리퀴드 글라스(유리 질감 배경)
|
||||
//
|
||||
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
enum WidgetThemeOption: String, AppEnum {
|
||||
case matchApp
|
||||
case light
|
||||
case dark
|
||||
case glass
|
||||
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "위젯 테마")
|
||||
static let caseDisplayRepresentations: [WidgetThemeOption: DisplayRepresentation] = [
|
||||
.matchApp: "앱 테마와 일치",
|
||||
.light: "라이트 고정",
|
||||
.dark: "다크 고정",
|
||||
.glass: "리퀴드 글라스",
|
||||
]
|
||||
|
||||
/// 강제할 컬러 스킴. 리퀴드 글라스는 시스템 모드를 따른다.
|
||||
var forcedScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .light:
|
||||
return .light
|
||||
case .dark:
|
||||
return .dark
|
||||
case .matchApp:
|
||||
let raw = AppGroup.defaults.string(forKey: SettingsKeys.theme)
|
||||
?? UserDefaults.standard.string(forKey: SettingsKeys.theme) ?? "light"
|
||||
return raw == "dark" ? .dark : .light
|
||||
case .glass:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 환경값: 리퀴드 글라스 여부 (셀 표면 스타일 변형용)
|
||||
|
||||
private struct WidgetGlassKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var widgetGlass: Bool {
|
||||
get { self[WidgetGlassKey.self] }
|
||||
set { self[WidgetGlassKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 테마 적용 모디파이어
|
||||
|
||||
struct WidgetThemeModifier: ViewModifier {
|
||||
let theme: WidgetThemeOption
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
switch theme {
|
||||
case .glass:
|
||||
// 유리 질감 배경: 반투명 머티리얼 위에서 잘 보이도록 셀도 머티리얼로 변형
|
||||
content
|
||||
.environment(\.widgetGlass, true)
|
||||
.containerBackground(for: .widget) {
|
||||
Rectangle().fill(.ultraThinMaterial)
|
||||
}
|
||||
case .light, .dark, .matchApp:
|
||||
content
|
||||
.environment(\.colorScheme, theme.forcedScheme ?? .light)
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// 위젯 루트에 테마 옵션 적용 (컬러 스킴 강제 + 배경)
|
||||
func widgetTheme(_ theme: WidgetThemeOption) -> some View {
|
||||
modifier(WidgetThemeModifier(theme: theme))
|
||||
}
|
||||
}
|
||||
|
||||
/// 셀 카드 배경: 일반 테마는 표면색, 리퀴드 글라스는 머티리얼
|
||||
struct WidgetCellBackground: View {
|
||||
@Environment(\.widgetGlass) private var glass
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(glass ? AnyShapeStyle(.thinMaterial) : AnyShapeStyle(AppTheme.surface))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user