mycode/myApp/HaruDanim/Widgets/StatsChartWidget.swift
songyc macbook c7024ea612 fix(widget): fix AppIntent button interactions and streamline widget configuration options while preserving real-time sync
Two independent widget issues, both confined to the widget/intent layer.
The app→widget real-time sync path (IntentStore.refresh + performWrite +
reloadAllTimelines) is intentionally left untouched.

1. Interactive button taps were unreliable ("touch feedback but nothing
   changes; works only after several taps"). The run buttons passed a full
   AppEntity parameter — RunActionIntent(action: cell.entity). On every tap
   WidgetKit re-resolves that AppEntity through ActionEntityQuery.entities(for:),
   which opens a brand-new ModelContainer from disk each time. That heavy
   entity-resolution round-trip on the tap hot path is the classic cause of
   dropped/laggy interactive-widget taps — the write logic itself was already
   proven reliable last session. Replaced RunActionIntent's AppEntity parameter
   with a plain UUID String (actionID) that deserializes instantly with no
   query; perform() looks the action up inside the existing single-context
   performWrite path. One tap now fires immediately. RunActionIntent is
   widget-only (not a Siri AppShortcut), so this touches no shortcut. Both call
   sites (ActionRunCellView, QuestRingActionCellView) updated; the now-unused
   ActionCellSnapshot.entity helper removed.

2. Configuration screens were cluttered — every widget exposed action/goal/
   quest slots 1–4 regardless of family, so a Small widget's long-press config
   listed slots it never renders. Added a parameterSummary to all six
   WidgetConfigurationIntents so the config reads as one clean sentence, and
   labeled secondary slots with the size that uses them (e.g. "행동 2 (중형·대형)",
   "목표 3 (대형)"). WidgetKit can't hide parameters by family, so this makes the
   optional slots self-explanatory instead of noise.

Verified: BUILD SUCCEEDED (app + embedded Haru_DanimWidgets.appex); cold launch
with the widget preview renders without crashing. Physical home/lock-screen
button taps still warrant a real-device pass (the simulator can't tap widget
overlays).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-12 21:50:27 +09:00

289 lines
11 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: "행동")
var action1: ActionEntity?
@Parameter(title: "행동 2")
var action2: ActionEntity?
@Parameter(title: "행동 3")
var action3: ActionEntity?
static var parameterSummary: some ParameterSummary {
Summary("행동 통계를 \(\.$span)(으)로 표시") {
\.$action1
\.$action2
\.$action3
}
}
}
// 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()
}
}