'처음 보는 리뷰어' 관점 전체 검증(핵심 계산·데이터 계층·기록/통계 완료,
나머지 영역 진행 중)에서 확인된 결함 수정. 핵심 계산부(DayMath·
QuestProgress·Models)는 발견 0건.
[M] DataStore: 스토어 손상 백업 복구 직후 legacy 이관 가드(타깃 없음)가
다시 열려 구 샌드박스 스토어(영구 잔존)가 부활 — 수개월 전 데이터로
조용한 롤백. App Group 플래그(migration.legacyStoreImported)로 평생
1회 보장 (§5.1 문서화)
[M] 통계 시리즈가 행동/꼬리표 이름 키 — 동명 2개면 차트가 한 선으로 합쳐
지고 Identifiable id 충돌, 동명 꼬리표는 합산. Format.disambiguated
(이름 (2) 형식)로 통계 탭·통계 내보내기·⑤ 위젯 3표면 통일, 꼬리표
집계는 identity 키로 재작성 (수치는 기존과 동일, 표시·범례만 구분)
[L] SessionAlertManager: 시작→즉시 종료 연타 시 조회(await)~추가 사이
경합으로 종료된 세션의 장시간 알림이 잔존 — 세대 카운터로 마지막
호출만 확정
[L] 세션 편집기: 분 절사 값을 무조건 덮어써 1분 미만 세션이 메모만
고쳐도 0초로 파괴, 시작=종료 0길이 기록은 저장돼도 어디에도 안 보임
— 안 움직인 필드는 원본 시각(초) 보존 + 0길이 저장 차단(문구 갱신)
[L] 타임테이블 시간축: 하루 시작이 정시가 아니면(06:30) 라벨이 시만
표기해 최대 59분 어긋남 — 분 성분 포함(HH:mm), 화면·내보내기 동일
[L] 기록·통계 필터: 제외했던 행동을 삭제하면 잔존 ID로 칩·내보내기
필터 문구가 허위 활성 — 실재 행동 기준으로 판정
[L] 목표 편집: 시작일을 종료일 뒤로 옮기면 종료<시작 저장 가능(DatePicker
in: 은 표시 제약만) — 저장 시 정규화
+ QuestEditor·TagViews·GoalViews·인텐트·WidgetSupport 정독 — 추가 발견 없음
+ §15-11 서브 에이전트 금지 명문화 (사용자 지시)
검증: Debug/Store 빌드, 카탈로그 missing/stale 0 (새 문구 2키 en/ja)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
295 lines
12 KiB
Swift
295 lines
12 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 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<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, 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)..<min(week.upperBound, month.upperBound)
|
|
for (action, seriesName) in zip(actions, seriesNames) {
|
|
// 앱 통계 탭과 같은 표기("N주차") + String(localized:)로 en/ja 지역화
|
|
points.append(StatsPoint(day: nil, weekLabel: String(localized: "\(index)주차"),
|
|
actionName: seriesName,
|
|
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: seriesNames,
|
|
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()
|
|
}
|
|
}
|