refactor(widget): complete widget architecture overhaul for reliable interactions and removed bloated configurations

- 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.
This commit is contained in:
songyc macbook 2026-07-11 22:41:54 +09:00
parent fbd4810628
commit f7ddd42260
10 changed files with 314 additions and 322 deletions

View File

@ -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" : {

View File

@ -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)
}

View File

@ -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<Double> & ProvidesDialog {
try IntentStore.requirePremium()
IntentStore.refresh()
let model = try IntentStore.action(action.id)
let math = DayMath()
let range: Range<Date>
@ -497,6 +525,7 @@ struct GoalProgressIntent: AppIntent {
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<Double> & 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<Double> & 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()

View File

@ -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<ActionRunEntry> {
// / 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()
}
}

View File

@ -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<GoalBarsEntry> {
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<GoalQuestGridEntry> {
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 {

View File

@ -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<LockGoalEntry> {
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)
}
}
}

View File

@ -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<QuestRingEntry> {
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()
}
}

View File

@ -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<StatsChartEntry> {
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] {

View File

@ -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<E: TimelineEntry>(first: E, live: Bool, make: (Date) -> E) -> Timeline<E> {
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 {

View File

@ -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)
}
}
}