mycode/myApp/HaruDanim/Widgets/StatsChartWidget.swift
songyc macbook bc6cd010b7 fix(widgets): 리퀴드 글라스 테마 시인성 개선 및 틴트/클리어 렌더링 모드 대응
- 글라스 테마: 틴트 셀 불투명도 0.5→0.72, 테두리 강화로 흰 텍스트 대비 확보
- 글라스 테마 전용 텍스트 그림자(glassTextShadow) 추가 — 홈 위젯 5종 셀 콘텐츠에 적용
- widgetRenderingMode != .fullColor(홈 화면 틴트/클리어)일 때 커스텀 배경·강제
  컬러 스킴을 끄고 시스템 바이브런트 렌더링에 맡기도록 분기
- 잠금화면 위젯: AccessoryWidgetBackground + widgetAccentable + .primary 전경색으로
  비브런트/악센트 렌더링에서 시인성 확보
- 미리보기 하네스: 글라스 테마가 시스템 컬러 스킴을 따르도록 수정

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-10 21:18:43 +09:00

257 lines
9.2 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: "위젯 테마", default: .matchApp)
var theme: WidgetThemeOption
@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 theme: WidgetThemeOption
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, 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 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,
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
)
}
func placeholder(in context: Context) -> StatsChartEntry {
StatsChartEntry(date: .now, locked: false, theme: .matchApp, 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<StatsChartEntry> {
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
}
}
}
.glassTextShadow()
.padding(13)
.widgetTheme(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 {
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])
.contentMarginsDisabled()
}
}