// // 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 "일주일 통계" case .monthByDay: return "한 달 통계 (일별)" case .monthByWeek: return "한 달 통계 (주별)" } } } 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 { 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) -> 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).. StatsChartEntry { StatsChartEntry(date: .now, locked: false, title: "일주일 통계", isWeekAxis: false, isTimeType: true, names: [], colorHexes: [], points: []) } @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 { Timeline(entries: [Self.makeEntry(configuration, family: context.family)], policy: .after(.now.addingTimeInterval(30 * 60))) } } // 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 { Text("앱에서 행동을 만들고 기록하면 그래프가 표시돼요.") .font(.caption2) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } else { VStack(alignment: .leading, spacing: 4) { Text(entry.title) .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) chart } } } .containerBackground(AppTheme.background, for: .widget) } 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 ? "시간" : "값", point.value) ) .foregroundStyle(by: .value("행동", point.actionName)) .interpolationMethod(.monotone) } else if let weekLabel = point.weekLabel { LineMark( x: .value("주차", weekLabel), y: .value(entry.isTimeType ? "시간" : "값", 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 ? "시간(h)" : "횟수")) .chartXAxis { if entry.isWeekAxis { AxisMarks { _ in AxisGridLine() AxisValueLabel() } } else { AxisMarks(values: .stride(by: .day, count: family == .systemSmall ? 2 : 1)) { value in AxisGridLine() AxisValueLabel(format: .dateTime.day(), centered: false) } } } } } // 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]) } }