diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index 8101d43..2fecf95 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -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 { diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 5d3a229..e399b5e 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -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 diff --git a/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift b/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift index 7c0b258..e398d51 100644 --- a/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift +++ b/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift @@ -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) } diff --git a/myApp/HaruDanim/Shared/DataStore.swift b/myApp/HaruDanim/Shared/DataStore.swift index cfc8eec..ca39811 100644 --- a/myApp/HaruDanim/Shared/DataStore.swift +++ b/myApp/HaruDanim/Shared/DataStore.swift @@ -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) } diff --git a/myApp/HaruDanim/Widgets/ActionRunWidget.swift b/myApp/HaruDanim/Widgets/ActionRunWidget.swift index f1d5f08..5170708 100644 --- a/myApp/HaruDanim/Widgets/ActionRunWidget.swift +++ b/myApp/HaruDanim/Widgets/ActionRunWidget.swift @@ -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) } } diff --git a/myApp/HaruDanim/Widgets/GoalWidgets.swift b/myApp/HaruDanim/Widgets/GoalWidgets.swift index c336ee4..74945d5 100644 --- a/myApp/HaruDanim/Widgets/GoalWidgets.swift +++ b/myApp/HaruDanim/Widgets/GoalWidgets.swift @@ -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 { diff --git a/myApp/HaruDanim/Widgets/QuestRingWidget.swift b/myApp/HaruDanim/Widgets/QuestRingWidget.swift index 0e30382..a088198 100644 --- a/myApp/HaruDanim/Widgets/QuestRingWidget.swift +++ b/myApp/HaruDanim/Widgets/QuestRingWidget.swift @@ -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) } } diff --git a/myApp/HaruDanim/Widgets/StatsChartWidget.swift b/myApp/HaruDanim/Widgets/StatsChartWidget.swift index 60c47b2..336f350 100644 --- a/myApp/HaruDanim/Widgets/StatsChartWidget.swift +++ b/myApp/HaruDanim/Widgets/StatsChartWidget.swift @@ -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] { diff --git a/myApp/HaruDanim/Widgets/WidgetTheme.swift b/myApp/HaruDanim/Widgets/WidgetTheme.swift new file mode 100644 index 0000000..f7aed4f --- /dev/null +++ b/myApp/HaruDanim/Widgets/WidgetTheme.swift @@ -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)) + } +}