mycode/myApp/HaruDanim/Widgets/StatsChartWidget.swift
songyc macbook 49dd1d0ff2 feat(widgets): complete redesign of 5 widget types with modular Intents, fixed floating menu UI/Strings
Widget system rebuilt as 5 independent widgets, each with its own
WidgetConfigurationIntent registered in the WidgetBundle:

- ① ActionRun: multi-action selection, value display option (day/week/
  month/hidden), full tag-color cells; small 1 / medium 4 (2x2) / large
  4 big cells or 8 compact cells (2x4) via largeStyle. Unfilled slots
  render as dashed blank cells.
- ② GoalProgress: multi-goal selection; medium [2 goals | goal+top
  quests], large [4 goals | 2x goal+quests | 1 goal + all quests] via
  medium/largeStyle. New QuestBarRow / GoalWithQuestsCellView.
- ③ QuestRing: multi-quest selection + span; surface-card ring (tag
  color) with centered icon, name and "오늘 N%" text; interactive run
  via RunActionIntent; small 1 / medium 2 / large 4 (2x2).
- ④ QuestStatus (display-only): goal selection + span; rings with name
  only (no %), goal icon+name top-left; small 4 (2x2) / medium 8 (4x2) /
  large [16 (4x4) | 4 goals | 2 goals] via largeStyle.
- ⑤ StatsChart: multi-action selection capped per family (2/4/6) +
  3 span modes; same design scaled by family.

Common foundation:
- Theme option revived on every intent: match app / light fixed / dark
  fixed (WidgetThemeOption AppEnum) applied through widgetAppTheme(_:)
  with proper iOS 17 containerBackground; non-fullColor rendering modes
  (tinted/clear/accented) keep the Color.clear + semantic-color defense
  so content never goes white-on-white.
- Providers are family-aware (capacity by size) and slot-pad selections
  (WidgetStore.slots); defaults fill from existing data so fresh widgets
  never look empty.
- Interactive path unchanged: RunActionIntent(actionID:) +
  IntentStore.performWrite -> reloadAllTimelines.

Floating menu polish:
- All glass capsules now share a uniform width (longest label wins,
  locale-proof) for a tidy popped-up list.
- Settings strings updated: nav style renamed to '플로팅 글래스 캡슐'
  with description matching the capsule popup, order editor retitled
  '캡슐 메뉴 순서' with bottom-to-top wording.

Verified: BUILD SUCCEEDED (iPhone 17 Pro sim); widget preview harness
extended with per-variant scroll anchors and all 15 layout variants
screenshot-checked, including forced accented mode (legible translucent
cells) and blank-slot rendering; capsule menu and settings text
screenshot-checked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 01:17:39 +09:00

291 lines
11 KiB
Swift

//
// 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 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,
theme: configuration.theme,
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, 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<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(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()
}
}