mycode/myApp/HaruDanim/Widgets/StatsChartWidget.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

281 lines
10 KiB
Swift

//
// StatsChartWidget.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §8.3)
// : = , h ,
//
import SwiftUI
import WidgetKit
import AppIntents
import Charts
// MARK: -
enum StatsChartSpan: String, AppEnum {
case week
case monthByDay
case monthByWeek
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "통계 기간")
static let caseDisplayRepresentations: [StatsChartSpan: DisplayRepresentation] = [
.week: "일주일 (하루 단위)",
.monthByDay: "한 달 (하루 단위, 중형 이상)",
.monthByWeek: "한 달 (주 단위)",
]
var title: String {
switch self {
case .week: return String(localized: "일주일 통계")
case .monthByDay: return String(localized: "한 달 통계 (일별)")
case .monthByWeek: return String(localized: "한 달 통계 (주별)")
}
}
}
struct StatsChartConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "통계 위젯"
static let description = IntentDescription("그래프로 보여줄 행동과 기간을 선택하세요.")
@Parameter(title: "통계 기간", default: .week)
var span: StatsChartSpan
@Parameter(title: "행동 1")
var action1: ActionEntity?
@Parameter(title: "행동 2")
var action2: ActionEntity?
@Parameter(title: "행동 3")
var action3: ActionEntity?
}
// MARK: -
struct StatsPoint: Identifiable {
let id = UUID()
/// x ()
let day: Date?
/// x ("1" )
let weekLabel: String?
let actionName: String
let value: Double
}
struct StatsChartEntry: TimelineEntry {
let date: Date
let locked: Bool
let title: String
let isWeekAxis: Bool
let isTimeType: Bool
let names: [String]
let colorHexes: [String]
let points: [StatsPoint]
}
struct StatsChartProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: StatsChartConfigIntent, family: WidgetFamily) -> StatsChartEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return StatsChartEntry(date: .now, locked: true, title: "", isWeekAxis: false,
isTimeType: true, names: [], colorHexes: [], points: [])
}
// " ( )" (CLAUDE.md §8.3)
var span = configuration.span
if family == .systemSmall && span == .monthByDay { span = .monthByWeek }
let chosen = [configuration.action1, configuration.action2, configuration.action3]
.compactMap { $0 }
.compactMap { WidgetStore.action($0.id) }
let actions = chosen.isEmpty ? WidgetStore.defaultActions(3) : chosen
let isTimeType = !actions.contains { $0.trackingType == .count }
let math = DayMath()
let agg = Aggregator(math: math)
let now = Date.now
var points: [StatsPoint] = []
func value(_ action: Action, in range: Range<Date>) -> Double {
switch action.trackingType {
case .time: return agg.seconds(for: action, in: range, now: now) / 3600
case .count: return Double(agg.count(for: action, in: range))
}
}
switch span {
case .week, .monthByDay:
let range = span == .week ? math.weekRange(containing: now) : math.monthRange(containing: now)
for key in math.dayKeys(in: range) {
let dayRange = math.dayRange(forKey: key)
for action in actions {
points.append(StatsPoint(day: key, weekLabel: nil, actionName: action.name,
value: value(action, in: dayRange)))
}
}
case .monthByWeek:
let month = math.monthRange(containing: now)
var cursor = month.lowerBound
var index = 1
while cursor < month.upperBound {
let week = math.weekRange(containing: cursor)
let clipped = max(week.lowerBound, month.lowerBound)..<min(week.upperBound, month.upperBound)
for action in actions {
points.append(StatsPoint(day: nil, weekLabel: "\(index)", actionName: action.name,
value: value(action, in: clipped)))
}
cursor = week.upperBound
index += 1
}
}
return StatsChartEntry(
date: now,
locked: false,
title: span.title,
isWeekAxis: span == .monthByWeek,
isTimeType: isTimeType,
names: actions.map(\.name),
colorHexes: actions.map { $0.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex ?? "#2F6B4F" },
points: points
)
}
/// ·
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, title: String(localized: "일주일 통계"), isWeekAxis: false,
isTimeType: true, names: [String(localized: "독서"), String(localized: "달리기")],
colorHexes: ["#2F6B4F", "#9D5B4A"], points: Self.samplePoints
)
}
@MainActor
func snapshot(for configuration: StatsChartConfigIntent, in context: Context) async -> StatsChartEntry {
Self.makeEntry(configuration, family: context.family)
}
@MainActor
func timeline(for configuration: StatsChartConfigIntent, in context: Context) async -> Timeline<StatsChartEntry> {
// / reloadAllTimelines
// ( )
let first = Self.makeEntry(configuration, family: context.family)
return WidgetRefresh.timeline(first: first, live: false) { _ in first }
}
}
// MARK: -
struct StatsChartWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: StatsChartEntry
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
} else if entry.points.isEmpty {
WidgetEmptyView(symbolName: "chart.xyaxis.line", message: "앱에서 행동을 만들고 기록하면\n그래프가 표시돼요")
} else {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
chart
}
}
}
.padding(13)
.widgetAppTheme()
}
private var colors: [Color] {
entry.colorHexes.map { Color(hex: $0) }
}
@ViewBuilder
private var chart: some View {
Chart(entry.points) { point in
if let day = point.day {
LineMark(
x: .value("날짜", day, unit: .day),
y: .value(entry.isTimeType ? String(localized: "시간") : String(localized: ""), point.value)
)
.foregroundStyle(by: .value("행동", point.actionName))
.interpolationMethod(.monotone)
} else if let weekLabel = point.weekLabel {
LineMark(
x: .value("주차", weekLabel),
y: .value(entry.isTimeType ? String(localized: "시간") : String(localized: ""), point.value)
)
.foregroundStyle(by: .value("행동", point.actionName))
.interpolationMethod(.monotone)
}
}
.chartForegroundStyleScale(domain: entry.names, range: colors)
.chartLegend(family == .systemSmall ? .hidden : .visible)
.chartYAxisLabel(family == .systemSmall ? "" : (entry.isTimeType ? String(localized: "시간(h)") : String(localized: "횟수")))
.chartXAxis {
if entry.isWeekAxis {
AxisMarks { _ in
AxisGridLine()
AxisValueLabel()
}
} else if dayCount > 7 {
// ()
AxisMarks(values: .stride(by: .day)) { _ in
AxisGridLine()
AxisTick()
}
} else {
AxisMarks(values: .stride(by: .day, count: family == .systemSmall ? 2 : 1)) { value in
AxisGridLine()
AxisValueLabel(format: .dateTime.day(), centered: false)
}
}
}
}
/// X ( )
private var dayCount: Int {
Set(entry.points.compactMap(\.day)).count
}
}
// MARK: -
struct StatsChartWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruStatsChartWidget",
intent: StatsChartConfigIntent.self,
provider: StatsChartProvider()
) { entry in
StatsChartWidgetView(entry: entry)
}
.configurationDisplayName("행동 통계")
.description("선택한 행동들의 통계 꺾은선 그래프를 확인해요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}