mycode/myApp/HaruDanim/Widgets/WidgetSupport.swift
songyc macbook f7ddd42260 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.
2026-07-11 22:41:54 +09:00

324 lines
12 KiB
Swift

//
// WidgetSupport.swift
// Haru_DanimWidgets
//
// / (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)
struct ActionCellSnapshot: Identifiable {
let id: UUID
let name: String
let symbolName: String
let colorHex: String
let isCount: Bool
let isRunning: Bool
/// (=, =). ( ).
let value: Double
/// (now - )
let tickingBase: Date?
var color: Color { Color(hex: colorHex) }
var entity: ActionEntity {
ActionEntity(
id: id, name: name, symbolName: symbolName,
trackingTypeRaw: isCount ? TrackingType.count.rawValue : TrackingType.time.rawValue
)
}
@MainActor
static func make(action: Action, span: StatSpan, now: Date = .now) -> ActionCellSnapshot {
let math = DayMath()
let range: Range<Date> = switch span {
case .day: math.dayRange(containing: now)
case .week: math.weekRange(containing: now)
case .month: math.monthRange(containing: now)
}
let agg = Aggregator(math: math)
let isCount = action.trackingType == .count
let value: Double = isCount
? Double(agg.count(for: action, in: range))
: agg.seconds(for: action, in: range, now: now)
let running = action.isRunning
return ActionCellSnapshot(
id: action.uuid,
name: action.name,
symbolName: action.symbolName,
colorHex: action.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex ?? "#2F6B4F",
isCount: isCount,
isRunning: running,
value: value,
tickingBase: running && !isCount ? now.addingTimeInterval(-value) : nil
)
}
}
/// (// C·D, )
struct QuestCellSnapshot: Identifiable {
let id: UUID
let targetName: String
let symbolName: String
let colorHex: String
/// 0...1 (' ' 100% 0%)
let ratio: Double
/// ( 100% )
let displayRatio: Double
let isAtMost: Bool
let isAchieved: Bool
/// ( )
let runTarget: ActionEntity?
let isRunning: Bool
var color: Color { Color(hex: colorHex) }
@MainActor
static func make(quest: Quest, span: StatSpan, now: Date = .now) -> QuestCellSnapshot {
let result = QuestProgress(quest: quest).spanProgress(span, now: now)
let action = quest.targetAction
return QuestCellSnapshot(
id: quest.uuid,
targetName: quest.targetName,
symbolName: quest.targetSymbol,
colorHex: action?.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex
?? quest.targetTag?.colorHex ?? "#2F6B4F",
ratio: result.ratio,
displayRatio: result.displayRatio,
isAtMost: quest.direction == .atMost,
isAchieved: result.isAchieved,
runTarget: action.map(ActionEntity.init),
isRunning: action?.isRunning ?? false
)
}
}
/// (// B, , )
struct GoalSnapshot: Identifiable {
let id: UUID
let title: String
let symbolName: String
let colorHex: String
/// // ( , 0...1)
let dayRatio: Double
let weekRatio: Double
let monthRatio: Double
let quests: [QuestCellSnapshot]
var color: Color { Color(hex: colorHex) }
func ratio(for span: StatSpan) -> Double {
switch span {
case .day: return dayRatio
case .week: return weekRatio
case .month: return monthRatio
}
}
@MainActor
static func make(goal: Goal, span: StatSpan = .day, now: Date = .now) -> GoalSnapshot {
GoalSnapshot(
id: goal.uuid,
title: goal.title,
symbolName: goal.symbolName,
colorHex: goal.colorHex,
dayRatio: goal.combinedSpanRatio(.day, now: now),
weekRatio: goal.combinedSpanRatio(.week, now: now),
monthRatio: goal.combinedSpanRatio(.month, now: now),
quests: goal.sortedQuests.map { QuestCellSnapshot.make(quest: $0, span: span, now: now) }
)
}
}
// 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 {
var body: some View {
VStack(spacing: 6) {
Image(systemName: "crown.fill")
.font(.title3)
.foregroundStyle(AppTheme.yellow)
Text("프리미엄 기능")
.font(.caption.weight(.semibold))
Text("앱의 설정 → 프리미엄에서\n잠금 해제해 주세요")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
}
// MARK: -
/// + ( C·D)
struct QuestRingView: View {
let snapshot: QuestCellSnapshot
var lineWidth: CGFloat = 5
var iconSize: CGFloat = 15
var body: some View {
ZStack {
Circle()
.stroke(snapshot.color.opacity(0.2), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: min(max(snapshot.ratio, 0), 1))
.stroke(
snapshot.isAtMost && !snapshot.isAchieved ? Color.red : snapshot.color,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
Image(systemName: snapshot.symbolName)
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(snapshot.color)
}
}
}
/// ( B)
struct SpanBarRow: View {
let label: String
let ratio: Double
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text(Format.percent(ratio))
.font(.caption2.weight(.semibold).monospacedDigit())
}
ProgressView(value: min(max(ratio, 0), 1))
.progressViewStyle(.linear)
.tint(color)
}
}
}
// MARK: -
@MainActor
enum WidgetStore {
static var isUnlocked: Bool { PremiumGate.isUnlocked(.homeWidgets) }
static func action(_ id: UUID?) -> Action? {
guard let id else { return nil }
return IntentStore.actions().first { $0.uuid == id }
}
static func goal(_ id: UUID?) -> Goal? {
guard let id else { return nil }
return IntentStore.goals().first { $0.uuid == id }
}
static func quest(_ id: UUID?) -> Quest? {
guard let id else { return nil }
return IntentStore.quests().first { $0.uuid == id }
}
///
static func defaultActions(_ count: Int) -> [Action] {
Array(IntentStore.actions().prefix(count))
}
static func defaultGoals(_ count: Int) -> [Goal] {
let goals = IntentStore.goals()
let active = goals.filter { $0.status == .inProgress }
return Array((active.isEmpty ? goals : active).prefix(count))
}
}