From f7ddd422608e1e29e497717f6f26a5bd968329b8 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sat, 11 Jul 2026 22:41:54 +0900 Subject: [PATCH] refactor(widget): complete widget architecture overhaul for reliable interactions and removed bloated configurations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IntentStore now recreates the ModelContainer on demand in the widget extension (IntentStore.refresh()), called before every AppIntent perform() and every provider's makeEntry(). Fixes stale/blank widget buttons and double-toggle bugs caused by the extension process reading an old in-memory snapshot after CloudKit remote changes. - Replaced the fixed 15/30-minute polling Timeline policy (which burns WidgetKit's daily reload budget across 6 widget kinds) with WidgetRefresh.timeline: idle widgets wait until the next logical-day boundary since value changes are already pushed via reloadAllTimelines, while widgets showing a live ratio (a currently running quest/goal) get pre-computed future entries so progress rings and bars keep advancing without extra reloads. - Removed the per-widget "위젯 테마" (match app / light / dark / liquid glass) configuration option from all 5 home-screen widgets. It duplicated the same enum five times, and the liquid-glass variant's custom background/scheme override fought the system's own vibrant/tinted rendering in tinted Home Screens, which was part of the "블랙 화면"/렌더링 오류 reports. Widgets now simply follow the app's own theme setting (WidgetAppearance.appScheme), which is what "앱 테마와 일치" already defaulted to. - Added shared gallery/placeholder sample data (ActionCellSnapshot, QuestCellSnapshot, GoalSnapshot, StatsChart sample points) so the widget gallery and redacted placeholders show a realistic shape instead of an empty/blank card. - Unified empty-state UI via WidgetEmptyView across all 5 widgets. Scope: Widgets/*, Shared/HaruDanimIntents.swift (widget-facing IntentStore only), IOS/Views/WidgetPreviewScreen.swift (DEBUG preview tool). No changes to app core data models, sync, or view logic. --- myApp/HaruDanim/IOS/Localizable.xcstrings | 106 +++++------ .../IOS/Views/WidgetPreviewScreen.swift | 47 +---- myApp/HaruDanim/Shared/HaruDanimIntents.swift | 46 ++++- myApp/HaruDanim/Widgets/ActionRunWidget.swift | 27 ++- myApp/HaruDanim/Widgets/GoalWidgets.swift | 62 +++---- .../HaruDanim/Widgets/LockScreenWidgets.swift | 19 +- myApp/HaruDanim/Widgets/QuestRingWidget.swift | 33 ++-- .../HaruDanim/Widgets/StatsChartWidget.swift | 45 +++-- myApp/HaruDanim/Widgets/WidgetSupport.swift | 87 +++++++++- myApp/HaruDanim/Widgets/WidgetTheme.swift | 164 ++++-------------- 10 files changed, 314 insertions(+), 322 deletions(-) diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index b0d3ef2..37c34b9 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -927,6 +927,22 @@ } } }, + "가장 먼저 시작한 행동" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Earliest started action" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最初に開始したアクション" + } + } + } + }, "값" : { "localizations" : { "en" : { @@ -959,22 +975,6 @@ } } }, - "가장 먼저 시작한 행동" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Earliest started action" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "最初に開始したアクション" - } - } - } - }, "개발용" : { "extractionState" : "stale", "localizations" : { @@ -1664,6 +1664,9 @@ } } } + }, + "다짐 %lld개 더 보기" : { + }, "다짐 1" : { "localizations" : { @@ -2472,23 +2475,6 @@ } } }, - "목" : { - "comment" : "요일 축약(목요일)", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thu" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "木" - } - } - } - }, "모음" : { "localizations" : { "en" : { @@ -2521,6 +2507,23 @@ } } }, + "목" : { + "comment" : "요일 축약(목요일)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "木" + } + } + } + }, "목록" : { "localizations" : { "en" : { @@ -4449,23 +4452,6 @@ } } }, - "일" : { - "comment" : "요일 축약(일요일)", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sun" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "日" - } - } - } - }, "이동" : { "localizations" : { "en" : { @@ -4610,6 +4596,23 @@ } } }, + "일" : { + "comment" : "요일 축약(일요일)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sun" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日" + } + } + } + }, "일·업무" : { "localizations" : { "en" : { @@ -4817,6 +4820,9 @@ } } } + }, + "접기" : { + }, "종료" : { "localizations" : { diff --git a/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift b/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift index 997ff23..16feb56 100644 --- a/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift +++ b/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift @@ -13,11 +13,6 @@ import WidgetKit struct WidgetPreviewScreen: View { let showLock: Bool - @Environment(\.colorScheme) private var systemScheme - - /// -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? @@ -40,16 +35,7 @@ struct WidgetPreviewScreen: View { .padding() } .background { - if theme == .glass { - // 리퀴드 글라스 확인용: 유리 질감이 드러나도록 화려한 배경 위에 렌더링 - LinearGradient( - colors: [.blue, .purple, .orange, .pink], - startPoint: .topLeading, endPoint: .bottomTrailing - ) - .ignoresSafeArea() - } else { - Color(white: 0.9).ignoresSafeArea() - } + Color(white: 0.9).ignoresSafeArea() } .onAppear { load() @@ -79,21 +65,11 @@ struct WidgetPreviewScreen: View { ("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) + actionEntry = ActionRunProvider.makeEntry(ActionRunConfigIntent()) + goalBarsEntry = GoalBarsProvider.makeEntry(GoalBarsConfigIntent()) + questRingEntry = QuestRingProvider.makeEntry(QuestRingConfigIntent()) + gridEntry = GoalQuestGridProvider.makeEntry(GoalQuestGridConfigIntent()) let statsConfig = StatsChartConfigIntent() - statsConfig.theme = theme statsEntrySmall = StatsChartProvider.makeEntry(statsConfig, family: .systemSmall) statsEntryMedium = StatsChartProvider.makeEntry(statsConfig, family: .systemMedium) } @@ -199,19 +175,12 @@ struct WidgetPreviewScreen: View { default: CGSize(width: 158, height: 158) } // containerBackground는 실제 위젯 컨텍스트 밖에서는 무시되므로 - // 미리보기 박스가 테마 배경/컬러 스킴을 직접 재현한다 + // 미리보기 박스가 앱 테마 배경/컬러 스킴을 직접 재현한다 // contentMarginsDisabled 적용 후에는 위젯 뷰가 여백을 스스로 관리한다 return content() .frame(width: size.width, height: size.height) - .background { - if theme == .glass { - GlassWidgetBackground() - } else { - AppTheme.background - } - } - // 글라스는 시스템 모드를 따르므로 스킴을 강제하지 않는다 - .environment(\.colorScheme, theme.forcedScheme ?? systemScheme) + .background(AppTheme.background) + .environment(\.colorScheme, WidgetAppearance.appScheme) .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) .shadow(color: .black.opacity(0.08), radius: 6, y: 2) } diff --git a/myApp/HaruDanim/Shared/HaruDanimIntents.swift b/myApp/HaruDanim/Shared/HaruDanimIntents.swift index 7b0d1c1..1bff7b9 100644 --- a/myApp/HaruDanim/Shared/HaruDanimIntents.swift +++ b/myApp/HaruDanim/Shared/HaruDanimIntents.swift @@ -35,10 +35,23 @@ struct IntentTargetError: Error, CustomLocalizedStringResourceConvertible { var localizedStringResource: LocalizedStringResource { message } } -/// 인텐트가 공용으로 쓰는 데이터 접근 (MainActor: DataStore.shared.mainContext) +/// 인텐트가 공용으로 쓰는 데이터 접근 (MainActor) @MainActor enum IntentStore { - static var context: ModelContext { DataStore.shared.mainContext } + /// 현재 작업이 사용하는 컨테이너. + /// 앱 프로세스: 항상 공유 컨테이너(DataStore.shared) — 앱 화면과 같은 인스턴스. + /// 확장 프로세스: 오래 살아남으면 캐시된 컨테이너가 메인 앱이 저장한 최신 데이터를 + /// 놓칠 수 있어(낡은 값 표시·이중 시작의 원인), 작업 시작 시 refresh()로 새로 연다. + private static var container: ModelContainer = DataStore.shared + + static var context: ModelContext { container.mainContext } + + /// 위젯 타임라인 생성·인텐트 실행·엔티티 조회 직전에 호출해 디스크의 최신 상태를 보장. + /// 한 작업(perform/makeEntry) 안에서는 한 번만 호출해야 조회한 모델과 컨텍스트가 일치한다. + static func refresh() { + guard DataStore.isExtensionProcess else { return } + container = DataStore.makeContainer() + } static func requirePremium() throws { guard PremiumGate.isUnlocked(.siriShortcuts) else { throw PremiumRequiredError() } @@ -173,6 +186,7 @@ struct ActionEntity: AppEntity { struct ActionEntityQuery: EntityStringQuery { @MainActor func entities(for identifiers: [UUID]) async throws -> [ActionEntity] { + IntentStore.refresh() var result = IntentStore.actions().filter { identifiers.contains($0.uuid) }.map(ActionEntity.init) if identifiers.contains(NoneEntityID.uuid) { result.append(.none) } return result @@ -180,12 +194,14 @@ struct ActionEntityQuery: EntityStringQuery { @MainActor func suggestedEntities() async throws -> [ActionEntity] { - [.none] + IntentStore.actions().map(ActionEntity.init) + IntentStore.refresh() + return IntentStore.actions().map(ActionEntity.init) } @MainActor func entities(matching string: String) async throws -> [ActionEntity] { - IntentStore.actions() + IntentStore.refresh() + return IntentStore.actions() .filter { $0.name.localizedStandardContains(string) } .map(ActionEntity.init) } @@ -227,6 +243,7 @@ struct GoalEntity: AppEntity { struct GoalEntityQuery: EntityStringQuery { @MainActor func entities(for identifiers: [UUID]) async throws -> [GoalEntity] { + IntentStore.refresh() var result = IntentStore.goals().filter { identifiers.contains($0.uuid) }.map(GoalEntity.init) if identifiers.contains(NoneEntityID.uuid) { result.append(.none) } return result @@ -234,7 +251,9 @@ struct GoalEntityQuery: EntityStringQuery { @MainActor func suggestedEntities() async throws -> [GoalEntity] { - // 진행 중인 목표를 먼저 제안 + IntentStore.refresh() + // 진행 중인 목표를 먼저 제안. '선택 안 함'은 단일 선택 파라미터(잠금화면 목표)의 + // 선택 해제용으로 유지 let goals = IntentStore.goals() return [.none] + (goals.filter { $0.status == .inProgress } + goals.filter { $0.status != .inProgress }) .map(GoalEntity.init) @@ -242,7 +261,8 @@ struct GoalEntityQuery: EntityStringQuery { @MainActor func entities(matching string: String) async throws -> [GoalEntity] { - IntentStore.goals() + IntentStore.refresh() + return IntentStore.goals() .filter { $0.title.localizedStandardContains(string) } .map(GoalEntity.init) } @@ -287,6 +307,7 @@ struct QuestEntity: AppEntity { struct QuestEntityQuery: EntityStringQuery { @MainActor func entities(for identifiers: [UUID]) async throws -> [QuestEntity] { + IntentStore.refresh() var result = IntentStore.quests().filter { identifiers.contains($0.uuid) }.map(QuestEntity.init) if identifiers.contains(NoneEntityID.uuid) { result.append(.none) } return result @@ -294,12 +315,14 @@ struct QuestEntityQuery: EntityStringQuery { @MainActor func suggestedEntities() async throws -> [QuestEntity] { - [.none] + IntentStore.quests().map(QuestEntity.init) + IntentStore.refresh() + return IntentStore.quests().map(QuestEntity.init) } @MainActor func entities(matching string: String) async throws -> [QuestEntity] { - IntentStore.quests() + IntentStore.refresh() + return IntentStore.quests() .filter { $0.targetName.localizedStandardContains(string) || ($0.goal?.title.localizedStandardContains(string) ?? false) @@ -327,6 +350,7 @@ struct RunActionIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { guard PremiumGate.isUnlocked(.homeWidgets) else { throw PremiumRequiredError() } + IntentStore.refresh() let model = try IntentStore.action(action.id) ActionRunner.run(model, context: IntentStore.context) IntentStore.commit() @@ -370,6 +394,7 @@ struct StartTimeActionIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { try IntentStore.requirePremium() + IntentStore.refresh() let model = try IntentStore.action(action.id) guard model.trackingType == .time else { throw IntentTargetError(message: "'\(model.name)'은(는) 횟수 기록 행동이에요. 횟수 추가를 사용해 주세요.") @@ -395,6 +420,7 @@ struct StopTimeActionIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { try IntentStore.requirePremium() + IntentStore.refresh() let model = try IntentStore.action(action.id) guard let session = model.runningSession else { return .result(dialog: "\(model.name)은(는) 측정 중이 아니에요.") @@ -429,6 +455,7 @@ struct AddCountIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { try IntentStore.requirePremium() + IntentStore.refresh() let model = try IntentStore.action(action.id) guard model.trackingType == .count else { throw IntentTargetError(message: "'\(model.name)'은(는) 시간 측정 행동이에요. 측정 시작/종료를 사용해 주세요.") @@ -456,6 +483,7 @@ struct ActionTotalIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { try IntentStore.requirePremium() + IntentStore.refresh() let model = try IntentStore.action(action.id) let math = DayMath() let range: Range @@ -497,6 +525,7 @@ struct GoalProgressIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { try IntentStore.requirePremium() + IntentStore.refresh() let model = try IntentStore.goal(goal.id) guard !model.quests.isEmpty else { throw IntentTargetError(message: "'\(model.title)' 목표에는 아직 다짐이 없어요.") @@ -524,6 +553,7 @@ struct QuestProgressIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { try IntentStore.requirePremium() + IntentStore.refresh() let model = try IntentStore.quest(quest.id) let result = QuestProgress(quest: model).spanProgress(span.statSpan) let percent = (result.displayRatio * 100).rounded() diff --git a/myApp/HaruDanim/Widgets/ActionRunWidget.swift b/myApp/HaruDanim/Widgets/ActionRunWidget.swift index ca07a15..a8d576d 100644 --- a/myApp/HaruDanim/Widgets/ActionRunWidget.swift +++ b/myApp/HaruDanim/Widgets/ActionRunWidget.swift @@ -19,9 +19,6 @@ struct ActionRunConfigIntent: WidgetConfigurationIntent { @Parameter(title: "표시 기간", default: .day) var period: SpanOption - @Parameter(title: "위젯 테마", default: .matchApp) - var theme: WidgetThemeOption - @Parameter(title: "행동 1") var action1: ActionEntity? @@ -40,7 +37,6 @@ struct ActionRunConfigIntent: WidgetConfigurationIntent { struct ActionRunEntry: TimelineEntry { let date: Date let locked: Bool - let theme: WidgetThemeOption let periodLabel: String let cells: [ActionCellSnapshot] } @@ -48,8 +44,9 @@ struct ActionRunEntry: TimelineEntry { struct ActionRunProvider: AppIntentTimelineProvider { @MainActor static func makeEntry(_ configuration: ActionRunConfigIntent) -> ActionRunEntry { + IntentStore.refresh() guard WidgetStore.isUnlocked else { - return ActionRunEntry(date: .now, locked: true, theme: configuration.theme, periodLabel: "", cells: []) + return ActionRunEntry(date: .now, locked: true, periodLabel: "", cells: []) } let span = configuration.period.statSpan let chosen = [configuration.action1, configuration.action2, configuration.action3, configuration.action4] @@ -59,14 +56,13 @@ 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, theme: .matchApp, periodLabel: "오늘", cells: []) + ActionRunEntry(date: .now, locked: false, periodLabel: "오늘", cells: ActionCellSnapshot.samples) } @MainActor @@ -76,9 +72,11 @@ struct ActionRunProvider: AppIntentTimelineProvider { @MainActor func timeline(for configuration: ActionRunConfigIntent, in context: Context) async -> Timeline { - // 값 자체는 인텐트 실행/앱 사용 시 reloadAllTimelines로 갱신되고, - // 날짜 경계를 넘길 때를 대비해 15분 주기로도 새로 고침 - Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60))) + // 값 변경은 인텐트 실행/앱 사용 시 reloadAllTimelines가 즉시 반영하고, + // 측정 중 누적 시간은 Text(_:style:.timer)가 엔트리 없이도 스스로 갱신되므로 + // 여기서는 날짜 경계 안전망만 둔다 + let first = Self.makeEntry(configuration) + return WidgetRefresh.timeline(first: first, live: false) { _ in first } } } @@ -123,7 +121,6 @@ struct ActionRunCellView: View { .lineLimit(1) } .foregroundStyle(.white) - .glassTextShadow() .padding(fullBleed ? 14 : (compact ? 8 : 12)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .background(WidgetTintedCellBackground(color: cell.color, fullBleed: fullBleed)) @@ -156,11 +153,7 @@ struct ActionRunWidgetView: View { WidgetLockedView() .padding(12) } else if entry.cells.isEmpty { - Text("앱에서 행동을 만들면 여기서 실행할 수 있어요.") - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(12) + WidgetEmptyView(symbolName: "bolt.fill", message: "앱에서 행동을 만들면\n여기서 실행할 수 있어요") } else { switch family { case .systemMedium: @@ -195,7 +188,7 @@ struct ActionRunWidgetView: View { } } } - .widgetTheme(entry.theme) + .widgetAppTheme() } } diff --git a/myApp/HaruDanim/Widgets/GoalWidgets.swift b/myApp/HaruDanim/Widgets/GoalWidgets.swift index 8967dda..c7bb7ee 100644 --- a/myApp/HaruDanim/Widgets/GoalWidgets.swift +++ b/myApp/HaruDanim/Widgets/GoalWidgets.swift @@ -26,33 +26,33 @@ 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] + + /// 소속 다짐 중 측정 중인 것이 있으면 진행바가 계속 채워지므로 미래 엔트리를 미리 계산해 둔다 + var isLive: Bool { goals.contains { $0.quests.contains { $0.isRunning } } } } struct GoalBarsProvider: AppIntentTimelineProvider { @MainActor - static func makeEntry(_ configuration: GoalBarsConfigIntent) -> GoalBarsEntry { + static func makeEntry(_ configuration: GoalBarsConfigIntent, now: Date = .now) -> GoalBarsEntry { + IntentStore.refresh() guard WidgetStore.isUnlocked else { - return GoalBarsEntry(date: .now, locked: true, theme: configuration.theme, goals: []) + return GoalBarsEntry(date: now, locked: true, 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, theme: configuration.theme, goals: goals.map { GoalSnapshot.make(goal: $0) }) + return GoalBarsEntry(date: now, locked: false, goals: goals.map { GoalSnapshot.make(goal: $0, now: now) }) } func placeholder(in context: Context) -> GoalBarsEntry { - GoalBarsEntry(date: .now, locked: false, theme: .matchApp, goals: []) + GoalBarsEntry(date: .now, locked: false, goals: [.sample]) } @MainActor @@ -62,7 +62,10 @@ struct GoalBarsProvider: AppIntentTimelineProvider { @MainActor func timeline(for configuration: GoalBarsConfigIntent, in context: Context) async -> Timeline { - Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60))) + let first = Self.makeEntry(configuration) + return WidgetRefresh.timeline(first: first, live: first.isLive) { date in + Self.makeEntry(configuration, now: date) + } } } @@ -85,7 +88,6 @@ struct GoalBarsCellView: View { SpanBarRow(label: "주간", ratio: goal.weekRatio, color: goal.color) SpanBarRow(label: "월간", ratio: goal.monthRatio, color: goal.color) } - .glassTextShadow() .padding(fullBleed ? 14 : 10) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(WidgetCellBackground(fullBleed: fullBleed)) @@ -113,11 +115,7 @@ struct GoalBarsWidgetView: View { WidgetLockedView() .padding(12) } else if entry.goals.isEmpty { - Text("앱의 목표 탭에서 목표를 만들면 진행률이 표시돼요.") - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(12) + WidgetEmptyView(symbolName: "flag.fill", message: "목표 탭에서 목표를 만들면\n진행률이 표시돼요") } else { let goals = Array(entry.goals.prefix(visibleCount)) switch family { @@ -146,7 +144,7 @@ struct GoalBarsWidgetView: View { } } } - .widgetTheme(entry.theme) + .widgetAppTheme() } } @@ -180,24 +178,23 @@ 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] + + var isLive: Bool { goals.contains { $0.quests.contains { $0.isRunning } } } } struct GoalQuestGridProvider: AppIntentTimelineProvider { @MainActor - static func makeEntry(_ configuration: GoalQuestGridConfigIntent) -> GoalQuestGridEntry { + static func makeEntry(_ configuration: GoalQuestGridConfigIntent, now: Date = .now) -> GoalQuestGridEntry { + IntentStore.refresh() guard WidgetStore.isUnlocked else { - return GoalQuestGridEntry(date: .now, locked: true, theme: configuration.theme, spanLabel: "", goals: []) + return GoalQuestGridEntry(date: now, locked: true, spanLabel: "", goals: []) } let span = configuration.span.statSpan var goals: [Goal] = [] @@ -205,16 +202,15 @@ struct GoalQuestGridProvider: AppIntentTimelineProvider { if let second = WidgetStore.goal(configuration.secondGoal?.id) { goals.append(second) } if goals.isEmpty { goals = WidgetStore.defaultGoals(2) } return GoalQuestGridEntry( - date: .now, + date: now, locked: false, - theme: configuration.theme, spanLabel: configuration.span.label, - goals: goals.map { GoalSnapshot.make(goal: $0, span: span) } + goals: goals.map { GoalSnapshot.make(goal: $0, span: span, now: now) } ) } func placeholder(in context: Context) -> GoalQuestGridEntry { - GoalQuestGridEntry(date: .now, locked: false, theme: .matchApp, spanLabel: "오늘", goals: []) + GoalQuestGridEntry(date: .now, locked: false, spanLabel: "오늘", goals: [.sample]) } @MainActor @@ -224,7 +220,10 @@ struct GoalQuestGridProvider: AppIntentTimelineProvider { @MainActor func timeline(for configuration: GoalQuestGridConfigIntent, in context: Context) async -> Timeline { - Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60))) + let first = Self.makeEntry(configuration) + return WidgetRefresh.timeline(first: first, live: first.isLive) { date in + Self.makeEntry(configuration, now: date) + } } } @@ -241,7 +240,6 @@ struct QuestRingCellView: View { .lineLimit(1) .foregroundStyle(.secondary) } - .glassTextShadow() } } @@ -257,10 +255,7 @@ struct GoalQuestGridWidgetView: View { if entry.locked { WidgetLockedView() } else if entry.goals.isEmpty { - Text("앱의 목표 탭에서 목표와 다짐을 만들어 보세요.") - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + WidgetEmptyView(symbolName: "square.grid.2x2", message: "목표 탭에서 목표와\n다짐을 만들어 보세요") } else { switch family { case .systemMedium: @@ -287,9 +282,8 @@ struct GoalQuestGridWidgetView: View { } } } - .glassTextShadow() .padding(13) - .widgetTheme(entry.theme) + .widgetAppTheme() } private func goalSection(_ goal: GoalSnapshot, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View { diff --git a/myApp/HaruDanim/Widgets/LockScreenWidgets.swift b/myApp/HaruDanim/Widgets/LockScreenWidgets.swift index 30a90a8..a44653e 100644 --- a/myApp/HaruDanim/Widgets/LockScreenWidgets.swift +++ b/myApp/HaruDanim/Widgets/LockScreenWidgets.swift @@ -48,19 +48,22 @@ struct LockGoalEntry: TimelineEntry { let style: LockStyleOption let goal: GoalSnapshot? let ratio: Double + + var isLive: Bool { goal?.quests.contains { $0.isRunning } ?? false } } struct LockGoalProvider: AppIntentTimelineProvider { @MainActor - static func makeEntry(_ configuration: LockGoalConfigIntent) -> LockGoalEntry { + static func makeEntry(_ configuration: LockGoalConfigIntent, now: Date = .now) -> LockGoalEntry { + IntentStore.refresh() guard PremiumGate.isUnlocked(.lockScreenWidgets) else { - return LockGoalEntry(date: .now, locked: true, spanLabel: "", style: .gauge, goal: nil, ratio: 0) + return LockGoalEntry(date: now, locked: true, spanLabel: "", style: .gauge, goal: nil, ratio: 0) } let span = configuration.span.statSpan let model = WidgetStore.goal(configuration.goal?.id) ?? WidgetStore.defaultGoals(1).first - let snapshot = model.map { GoalSnapshot.make(goal: $0, span: span) } + let snapshot = model.map { GoalSnapshot.make(goal: $0, span: span, now: now) } return LockGoalEntry( - date: .now, + date: now, locked: false, spanLabel: configuration.span.label, style: configuration.style, @@ -70,7 +73,8 @@ struct LockGoalProvider: AppIntentTimelineProvider { } func placeholder(in context: Context) -> LockGoalEntry { - LockGoalEntry(date: .now, locked: false, spanLabel: "오늘", style: .gauge, goal: nil, ratio: 0) + let goal = GoalSnapshot.sample + return LockGoalEntry(date: .now, locked: false, spanLabel: "오늘", style: .gauge, goal: goal, ratio: goal.dayRatio) } @MainActor @@ -80,7 +84,10 @@ struct LockGoalProvider: AppIntentTimelineProvider { @MainActor func timeline(for configuration: LockGoalConfigIntent, in context: Context) async -> Timeline { - Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60))) + let first = Self.makeEntry(configuration) + return WidgetRefresh.timeline(first: first, live: first.isLive) { date in + Self.makeEntry(configuration, now: date) + } } } diff --git a/myApp/HaruDanim/Widgets/QuestRingWidget.swift b/myApp/HaruDanim/Widgets/QuestRingWidget.swift index 34dc322..c325562 100644 --- a/myApp/HaruDanim/Widgets/QuestRingWidget.swift +++ b/myApp/HaruDanim/Widgets/QuestRingWidget.swift @@ -19,9 +19,6 @@ struct QuestRingConfigIntent: WidgetConfigurationIntent { @Parameter(title: "진행률 기간", default: .day) var span: SpanOption - @Parameter(title: "위젯 테마", default: .matchApp) - var theme: WidgetThemeOption - @Parameter(title: "다짐 1") var quest1: QuestEntity? @@ -40,16 +37,19 @@ struct QuestRingConfigIntent: WidgetConfigurationIntent { struct QuestRingEntry: TimelineEntry { let date: Date let locked: Bool - let theme: WidgetThemeOption let spanLabel: String let cells: [QuestCellSnapshot] + + /// 시간형 다짐의 대상 행동이 측정 중이면 링이 계속 채워지므로 미래 엔트리를 미리 계산해 둔다 + var isLive: Bool { cells.contains { $0.isRunning } } } struct QuestRingProvider: AppIntentTimelineProvider { @MainActor - static func makeEntry(_ configuration: QuestRingConfigIntent) -> QuestRingEntry { + static func makeEntry(_ configuration: QuestRingConfigIntent, now: Date = .now) -> QuestRingEntry { + IntentStore.refresh() guard WidgetStore.isUnlocked else { - return QuestRingEntry(date: .now, locked: true, theme: configuration.theme, spanLabel: "", cells: []) + return QuestRingEntry(date: now, locked: true, spanLabel: "", cells: []) } let span = configuration.span.statSpan var quests = [configuration.quest1, configuration.quest2, configuration.quest3, configuration.quest4] @@ -59,16 +59,15 @@ struct QuestRingProvider: AppIntentTimelineProvider { quests = Array(WidgetStore.defaultGoals(1).flatMap(\.sortedQuests).prefix(4)) } return QuestRingEntry( - date: .now, + date: now, locked: false, - theme: configuration.theme, spanLabel: configuration.span.label, - cells: quests.map { QuestCellSnapshot.make(quest: $0, span: span) } + cells: quests.map { QuestCellSnapshot.make(quest: $0, span: span, now: now) } ) } func placeholder(in context: Context) -> QuestRingEntry { - QuestRingEntry(date: .now, locked: false, theme: .matchApp, spanLabel: "오늘", cells: []) + QuestRingEntry(date: .now, locked: false, spanLabel: "오늘", cells: QuestCellSnapshot.samples) } @MainActor @@ -78,7 +77,10 @@ struct QuestRingProvider: AppIntentTimelineProvider { @MainActor func timeline(for configuration: QuestRingConfigIntent, in context: Context) async -> Timeline { - Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60))) + let first = Self.makeEntry(configuration) + return WidgetRefresh.timeline(first: first, live: first.isLive) { date in + Self.makeEntry(configuration, now: date) + } } } @@ -122,7 +124,6 @@ struct QuestRingActionCellView: View { .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } - .glassTextShadow() .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(fullBleed ? 10 : 6) .background(WidgetCellBackground(fullBleed: fullBleed)) @@ -159,11 +160,7 @@ struct QuestRingWidgetView: View { WidgetLockedView() .padding(12) } else if entry.cells.isEmpty { - Text("앱의 목표 탭에서 다짐을 만들면 진행률이 표시돼요.") - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(12) + WidgetEmptyView(symbolName: "checkmark.circle", message: "목표 탭에서 다짐을 만들면\n진행률이 표시돼요") } else { let cells = Array(entry.cells.prefix(visibleCount)) switch family { @@ -196,7 +193,7 @@ struct QuestRingWidgetView: View { } } } - .widgetTheme(entry.theme) + .widgetAppTheme() } } diff --git a/myApp/HaruDanim/Widgets/StatsChartWidget.swift b/myApp/HaruDanim/Widgets/StatsChartWidget.swift index 3fd44c9..d9ff5ec 100644 --- a/myApp/HaruDanim/Widgets/StatsChartWidget.swift +++ b/myApp/HaruDanim/Widgets/StatsChartWidget.swift @@ -41,9 +41,6 @@ struct StatsChartConfigIntent: WidgetConfigurationIntent { @Parameter(title: "통계 기간", default: .week) var span: StatsChartSpan - @Parameter(title: "위젯 테마", default: .matchApp) - var theme: WidgetThemeOption - @Parameter(title: "행동 1") var action1: ActionEntity? @@ -69,7 +66,6 @@ struct StatsPoint: Identifiable { struct StatsChartEntry: TimelineEntry { let date: Date let locked: Bool - let theme: WidgetThemeOption let title: String let isWeekAxis: Bool let isTimeType: Bool @@ -81,8 +77,9 @@ struct StatsChartEntry: TimelineEntry { struct StatsChartProvider: AppIntentTimelineProvider { @MainActor static func makeEntry(_ configuration: StatsChartConfigIntent, family: WidgetFamily) -> StatsChartEntry { + IntentStore.refresh() guard WidgetStore.isUnlocked else { - return StatsChartEntry(date: .now, locked: true, theme: configuration.theme, title: "", isWeekAxis: false, + return StatsChartEntry(date: .now, locked: true, title: "", isWeekAxis: false, isTimeType: true, names: [], colorHexes: [], points: []) } // 소형은 "한 달 (하루 단위)" 제외 (CLAUDE.md §8.3) → 주 단위로 대체 @@ -136,7 +133,6 @@ struct StatsChartProvider: AppIntentTimelineProvider { return StatsChartEntry( date: now, locked: false, - theme: configuration.theme, title: span.title, isWeekAxis: span == .monthByWeek, isTimeType: isTimeType, @@ -146,9 +142,28 @@ struct StatsChartProvider: AppIntentTimelineProvider { ) } + /// 갤러리·잠금 등에서 실데이터 없이도 그래프 모양이 보이도록 하는 샘플 + private static let samplePoints: [StatsPoint] = { + let cal = Calendar.current + let today = cal.startOfDay(for: .now) + let names = [String(localized: "독서"), String(localized: "달리기")] + let curves: [[Double]] = [[0.5, 1.2, 0.8, 1.5, 0.6, 1.8, 1.0], [0.3, 0.5, 0.7, 0.4, 0.9, 0.6, 0.8]] + var points: [StatsPoint] = [] + for offset in 0..<7 { + let day = cal.date(byAdding: .day, value: offset - 6, to: today)! + for (index, name) in names.enumerated() { + points.append(StatsPoint(day: day, weekLabel: nil, actionName: name, value: curves[index][offset])) + } + } + return points + }() + func placeholder(in context: Context) -> StatsChartEntry { - StatsChartEntry(date: .now, locked: false, theme: .matchApp, title: "일주일 통계", isWeekAxis: false, - isTimeType: true, names: [], colorHexes: [], points: []) + StatsChartEntry( + date: .now, locked: false, title: String(localized: "일주일 통계"), isWeekAxis: false, + isTimeType: true, names: [String(localized: "독서"), String(localized: "달리기")], + colorHexes: ["#2F6B4F", "#9D5B4A"], points: Self.samplePoints + ) } @MainActor @@ -158,8 +173,10 @@ struct StatsChartProvider: AppIntentTimelineProvider { @MainActor func timeline(for configuration: StatsChartConfigIntent, in context: Context) async -> Timeline { - Timeline(entries: [Self.makeEntry(configuration, family: context.family)], - policy: .after(.now.addingTimeInterval(30 * 60))) + // 값 변경은 인텐트 실행/앱 사용 시 reloadAllTimelines가 즉시 반영하므로 + // 여기서는 날짜 경계 안전망만 둔다 (측정 중 오늘 막대는 다음 갱신에 반영) + let first = Self.makeEntry(configuration, family: context.family) + return WidgetRefresh.timeline(first: first, live: false) { _ in first } } } @@ -177,10 +194,7 @@ struct StatsChartWidgetView: View { if entry.locked { WidgetLockedView() } else if entry.points.isEmpty { - Text("앱에서 행동을 만들고 기록하면 그래프가 표시돼요.") - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + WidgetEmptyView(symbolName: "chart.xyaxis.line", message: "앱에서 행동을 만들고 기록하면\n그래프가 표시돼요") } else { VStack(alignment: .leading, spacing: 4) { Text(entry.title) @@ -190,9 +204,8 @@ struct StatsChartWidgetView: View { } } } - .glassTextShadow() .padding(13) - .widgetTheme(entry.theme) + .widgetAppTheme() } private var colors: [Color] { diff --git a/myApp/HaruDanim/Widgets/WidgetSupport.swift b/myApp/HaruDanim/Widgets/WidgetSupport.swift index 3e7b903..90ef9e8 100644 --- a/myApp/HaruDanim/Widgets/WidgetSupport.swift +++ b/myApp/HaruDanim/Widgets/WidgetSupport.swift @@ -4,13 +4,36 @@ // // 홈/잠금화면 위젯 공용 인프라 (CLAUDE.md §8) // - 타임라인 시점에 SwiftData(App Group DB)에서 읽어 만든 스냅숏 구조체 -// - 프리미엄 잠금 표시, 원형/막대 게이지 등 공용 뷰 +// - 타임라인 갱신 전략: 값이 실제로 바뀌는 순간(앱 기록·인텐트·원격 동기화)에는 +// 앱과 인텐트가 reloadAllTimelines를 호출하므로 주기 폴링을 하지 않는다. +// 측정 중일 때만 미리 계산한 미래 엔트리로 진행률을 따라간다 (WidgetKit 예산 절약). +// - 프리미엄 잠금 표시, 원형/막대 게이지, 갤러리 미리보기 샘플 등 공용 뷰/데이터 // import SwiftUI import SwiftData import WidgetKit +// MARK: - 타임라인 갱신 전략 + +enum WidgetRefresh { + /// 예전의 "15분마다 .after" 정책은 위젯 6종이 하루 종일 리로드 예산을 태워 + /// 예산 소진 후 갱신이 멈추는 원인이었다. 지금은: + /// - 측정 중(live): 10분 간격 미래 엔트리 1시간치를 미리 계산해 넣고 .atEnd + /// (타임라인 1개 안의 엔트리 전환은 예산을 쓰지 않음) + /// - 평상시: 다음 하루 경계(최대 4시간 안전망)까지 대기 — 값 변경은 명시적 리로드가 담당 + static func timeline(first: E, live: Bool, make: (Date) -> E) -> Timeline { + let now = first.date + if live { + let future = stride(from: 600, through: 3600, by: 600) + .map { make(now.addingTimeInterval(Double($0))) } + return Timeline(entries: [first] + future, policy: .atEnd) + } + let boundary = DayMath().dayRange(containing: now).upperBound + return Timeline(entries: [first], policy: .after(min(boundary, now.addingTimeInterval(4 * 3600)))) + } +} + // MARK: - 스냅숏 모델 /// 행동 실행 셀 스냅숏 (소형/중형/대형 A) @@ -137,6 +160,68 @@ struct GoalSnapshot: Identifiable { } } +// MARK: - 갤러리 미리보기 샘플 + +/// 위젯 갤러리(placeholder/preview)에서 실데이터가 없거나 잠겨 있어도 +/// 위젯이 어떤 모습인지 보이도록 하는 샘플 스냅숏 +extension ActionCellSnapshot { + static let samples: [ActionCellSnapshot] = [ + ActionCellSnapshot(id: UUID(), name: String(localized: "독서"), symbolName: "book.fill", + colorHex: "#2F6B4F", isCount: false, isRunning: true, value: 45 * 60, tickingBase: nil), + ActionCellSnapshot(id: UUID(), name: String(localized: "달리기"), symbolName: "figure.run", + colorHex: "#9D5B4A", isCount: false, isRunning: false, value: 30 * 60, tickingBase: nil), + ActionCellSnapshot(id: UUID(), name: String(localized: "물 마시기"), symbolName: "drop.fill", + colorHex: "#4A7A9D", isCount: true, isRunning: false, value: 5, tickingBase: nil), + ActionCellSnapshot(id: UUID(), name: String(localized: "팔굽혀펴기"), symbolName: "dumbbell.fill", + colorHex: "#7A5C9D", isCount: true, isRunning: false, value: 20, tickingBase: nil), + ] +} + +extension QuestCellSnapshot { + static let samples: [QuestCellSnapshot] = [ + QuestCellSnapshot(id: UUID(), targetName: String(localized: "독서"), symbolName: "book.fill", + colorHex: "#2F6B4F", ratio: 0.75, displayRatio: 0.75, + isAtMost: false, isAchieved: false, runTarget: nil, isRunning: true), + QuestCellSnapshot(id: UUID(), targetName: String(localized: "달리기"), symbolName: "figure.run", + colorHex: "#9D5B4A", ratio: 0.4, displayRatio: 0.4, + isAtMost: false, isAchieved: false, runTarget: nil, isRunning: false), + QuestCellSnapshot(id: UUID(), targetName: String(localized: "물 마시기"), symbolName: "drop.fill", + colorHex: "#4A7A9D", ratio: 1, displayRatio: 1.1, + isAtMost: false, isAchieved: true, runTarget: nil, isRunning: false), + QuestCellSnapshot(id: UUID(), targetName: String(localized: "영상 시청"), symbolName: "play.rectangle.fill", + colorHex: "#7A5C9D", ratio: 1, displayRatio: 1, + isAtMost: true, isAchieved: true, runTarget: nil, isRunning: false), + ] +} + +extension GoalSnapshot { + static let sample = GoalSnapshot( + id: UUID(), title: String(localized: "건강한 생활 습관"), symbolName: "heart.fill", + colorHex: "#2F6B4F", dayRatio: 0.62, weekRatio: 0.48, monthRatio: 0.7, + quests: QuestCellSnapshot.samples + ) +} + +// MARK: - 빈 상태 + +struct WidgetEmptyView: View { + let symbolName: String + let message: String + + var body: some View { + VStack(spacing: 6) { + Image(systemName: symbolName) + .font(.title3) + .foregroundStyle(AppTheme.green) + Text(message) + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(12) + } +} + // MARK: - 프리미엄 잠금 struct WidgetLockedView: View { diff --git a/myApp/HaruDanim/Widgets/WidgetTheme.swift b/myApp/HaruDanim/Widgets/WidgetTheme.swift index 3438c5e..f400472 100644 --- a/myApp/HaruDanim/Widgets/WidgetTheme.swift +++ b/myApp/HaruDanim/Widgets/WidgetTheme.swift @@ -2,55 +2,22 @@ // WidgetTheme.swift // Haru_DanimWidgets // -// 위젯 테마 옵션 (CLAUDE.md §8.1) -// 1. 앱 테마와 일치 2. 라이트/다크 고정 3. 리퀴드 글라스(유리 질감 배경) +// 위젯 외형 (CLAUDE.md §8.1) +// - 위젯은 항상 앱 설정의 테마(라이트/다크)를 따른다. 위젯별 테마 설정 옵션은 +// 강제 컬러 스킴 재정의가 렌더링 버그를 유발해 제거했다 (설정도 단순해짐). +// - 홈 화면이 틴트/클리어(리퀴드 글라스) 모드일 때는 시스템이 배경을 자체 유리로 +// 대체하고 색을 재해석하므로, 배경만 비우고 시스템 렌더링에 맡긴다. // -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 } +enum WidgetAppearance { + /// 앱 설정의 테마 ("light" | "dark") → 위젯에 그대로 적용할 컬러 스킴 + static var appScheme: ColorScheme { + let raw = AppGroup.defaults.string(forKey: SettingsKeys.theme) + ?? UserDefaults.standard.string(forKey: SettingsKeys.theme) ?? "light" + return raw == "dark" ? .dark : .light } } @@ -58,129 +25,60 @@ extension EnvironmentValues { struct WidgetThemeModifier: ViewModifier { @Environment(\.widgetRenderingMode) private var renderingMode - let theme: WidgetThemeOption func body(content: Content) -> some View { if renderingMode != .fullColor { - // 틴트/클리어(리퀴드 글라스) 홈 화면: 시스템이 배경을 자체 유리로 대체하고 - // 색을 단색조로 재해석하므로, 커스텀 배경·강제 컬러 스킴을 얹으면 + // 틴트/클리어(리퀴드 글라스) 홈 화면: 커스텀 배경·강제 스킴을 얹으면 // 시스템 유리 위에서 텍스트가 묻힌다 → 시스템 기본(바이브런트) 렌더링에 맡긴다 content .containerBackground(for: .widget) { Color.clear } } else { - switch theme { - case .glass: - // 유리 질감 배경: 반투명 머티리얼 위에서 잘 보이도록 셀도 머티리얼로 변형 - content - .environment(\.widgetGlass, true) - .containerBackground(for: .widget) { - GlassWidgetBackground() - } - case .light, .dark, .matchApp: - let scheme = theme.forcedScheme ?? .light - content - .environment(\.colorScheme, scheme) - .containerBackground(for: .widget) { - // containerBackground는 시스템이 위젯 자체 환경(시스템 모드)으로 그리므로 - // 콘텐츠에 적용한 강제 스킴이 전파되지 않는다 → 배경에도 직접 지정해야 - // '라이트/다크 고정'과 '앱 테마와 일치'가 실제 위젯에서 적용된다 - AppTheme.background - .environment(\.colorScheme, scheme) - } - } - } - } -} - -/// 리퀴드 글라스 배경 (§8.1-3): 반투명 머티리얼 + 대각 하이라이트로 유리 질감을 표현 -struct GlassWidgetBackground: View { - var body: some View { - ZStack { - Rectangle().fill(.ultraThinMaterial) - LinearGradient( - colors: [.white.opacity(0.28), .white.opacity(0.06), .clear], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } - } -} - -/// 리퀴드 글라스 위에서 텍스트·아이콘이 배경과 분리돼 보이도록 은은한 그림자를 얹는다. -/// 글라스 테마가 아닐 때는 아무것도 하지 않으므로 셀 콘텐츠에 무조건 걸어도 된다. -struct GlassTextShadowModifier: ViewModifier { - @Environment(\.widgetGlass) private var glass - - func body(content: Content) -> some View { - if glass { - content.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) - } else { + let scheme = WidgetAppearance.appScheme content + .environment(\.colorScheme, scheme) + .containerBackground(for: .widget) { + // containerBackground는 시스템이 위젯 자체 환경(시스템 모드)으로 그리므로 + // 콘텐츠에 적용한 스킴을 배경에도 직접 지정해야 앱 테마가 실제로 적용된다 + AppTheme.background + .environment(\.colorScheme, scheme) + } } } } extension View { - /// 위젯 루트에 테마 옵션 적용 (컬러 스킴 강제 + 배경) - func widgetTheme(_ theme: WidgetThemeOption) -> some View { - modifier(WidgetThemeModifier(theme: theme)) - } - - /// 리퀴드 글라스 테마에서만 적용되는 텍스트 그림자 (시인성 보강) - func glassTextShadow() -> some View { - modifier(GlassTextShadowModifier()) + /// 위젯 루트에 앱 테마 적용 (컬러 스킴 + 배경) + func widgetAppTheme() -> some View { + modifier(WidgetThemeModifier()) } } -/// 셀 카드 배경: 일반 테마는 표면색, 리퀴드 글라스는 머티리얼 + 유리 테두리 +// MARK: - 셀 배경 + +/// 셀 카드 배경 (표면색). /// fullBleed = 셀 하나가 위젯 전체를 차지할 때 위젯 모서리(ContainerRelativeShape)에 맞춤 struct WidgetCellBackground: View { - @Environment(\.widgetGlass) private var glass var fullBleed = false var body: some View { if fullBleed { - styled(ContainerRelativeShape()) + ContainerRelativeShape().fill(AppTheme.surface) } else { - styled(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - } - - @ViewBuilder - private func styled(_ shape: some InsettableShape) -> some View { - if glass { - shape.fill(.thinMaterial) - .overlay { shape.strokeBorder(.white.opacity(0.25), lineWidth: 1) } - } else { - shape.fill(AppTheme.surface) + RoundedRectangle(cornerRadius: 14, style: .continuous).fill(AppTheme.surface) } } } -/// 행동 실행 셀처럼 꼬리표 색이 배경인 셀의 글라스 변형: -/// 반투명 머티리얼 위에 색을 얹어 유리 너머로 색이 비치는 느낌을 낸다 +/// 행동 실행 셀처럼 꼬리표 색이 배경인 셀 struct WidgetTintedCellBackground: View { - @Environment(\.widgetGlass) private var glass let color: Color var fullBleed = false var body: some View { if fullBleed { - styled(ContainerRelativeShape()) + ContainerRelativeShape().fill(color) } else { - styled(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - } - - @ViewBuilder - private func styled(_ shape: some InsettableShape) -> some View { - if glass { - // 흰 텍스트가 밝은 배경화면에 씻겨 보이지 않도록 틴트를 충분히 진하게 얹는다 - shape.fill(.thinMaterial) - .overlay { shape.fill(color.opacity(0.72)) } - .overlay { shape.strokeBorder(.white.opacity(0.35), lineWidth: 1) } - } else { - shape.fill(color) + RoundedRectangle(cornerRadius: 14, style: .continuous).fill(color) } } }