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:
parent
fbd4810628
commit
f7ddd42260
@ -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" : {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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] {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user