// // StatsChartWidget.swift // Haru_DanimWidgets // // ⑤ 행동 통계 위젯 (CLAUDE.md §8.3) — 선택한 행동들의 꺾은선 그래프 // 앱 통계 탭과 동일: 선 색 = 행동 색, 시간형은 h 단위, 횟수형은 회. // 어수선하지 않도록 표시 행동 수를 소형 2 / 중형 4 / 대형 6개로 제한. // 디자인은 크기와 무관하게 동일하고 그래프 영역만 확장된다. // 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("그래프로 보여줄 행동들과 기간, 테마를 선택하세요. 크기에 따라 소형 2개·중형 4개·대형 6개까지 그려져요.") @Parameter(title: "행동") var actions: [ActionEntity]? @Parameter(title: "통계 기간", default: .week) var span: StatsChartSpan @Parameter(title: "테마", default: .matchApp) var theme: WidgetThemeOption } // 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 theme: WidgetThemeOption let title: String let isWeekAxis: Bool let isTimeType: Bool let names: [String] let colorHexes: [String] let points: [StatsPoint] } struct StatsChartProvider: AppIntentTimelineProvider { /// 어수선함 방지: 패밀리별 최대 행동(선) 수 (소형 2 / 중형 4 / 대형 6) static func maxActions(_ family: WidgetFamily) -> Int { switch family { case .systemMedium: return 4 case .systemLarge: return 6 default: return 2 } } @MainActor static func makeEntry(_ configuration: StatsChartConfigIntent, family: WidgetFamily) -> StatsChartEntry { IntentStore.refresh() guard WidgetStore.isUnlocked else { return StatsChartEntry(date: .now, locked: true, theme: configuration.theme, title: "", isWeekAxis: false, isTimeType: true, names: [], colorHexes: [], points: []) } // 소형은 "한 달 (하루 단위)" 제외 (CLAUDE.md §8.3) → 주 단위로 대체 var span = configuration.span if family == .systemSmall && span == .monthByDay { span = .monthByWeek } let actions = Array(WidgetStore.selectedActions(configuration.actions, defaultCount: Self.maxActions(family)) .prefix(Self.maxActions(family))) // 동명 행동 구분 표시 이름 — 이름이 시리즈 키·범례라 같으면 한 선으로 합쳐진다 (앱 통계 탭과 동일 규칙) let seriesNames = Format.disambiguated(actions.map(\.name)) 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, seriesName) in zip(actions, seriesNames) { points.append(StatsPoint(day: key, weekLabel: nil, actionName: seriesName, 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, theme: .matchApp, 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 { // 값 변경은 인텐트 실행/앱 사용 시 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(entry.theme) } 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() } }