- 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.
324 lines
12 KiB
Swift
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))
|
|
}
|
|
}
|