사용자 요구('이거까지 하고 배포'): 통계·기록에서도 건강 데이터를 하루다님 스타일로.
전부 표시 전용 — HealthCache 인메모리 읽기만, HK 신규 조회·스키마·집계 수학 무변경.
- 통계 탭 맨 아래: 하루=지표 값 칩 카드 / 주간·월간(롤링 포함)=
'건강 지표 (일별)' 추이 카드(지표 선택 캡슐 메뉴·요일/일 축·단위 라벨, 분홍 단일 선)
+ '건강 지표 합계·평균' 표(분모=기존 '이미 시작된 날/주' 규칙 상속, 0 지표 생략)
- 기록 탭 목록 맨 아래 '건강 데이터' 섹션 — 그날 칩(과거 날짜도 캐시로)
- 표시 규칙: 지표=타일 선택 상속, 마스터 스위치=설정 '건강 데이터 표시'(문구 확장),
미연결·맥=숨김, 내보내기 미포함(비교 카드 전례)
- 값 공급 단일 지점 HealthDisplayValues 신설(수면=유효 표시 구간 키, 일기 카드도 위임),
칩 UI HealthValueChipGrid 공용 — 빌드 9 최적화 그대로 유지
- 도움말·팝업·설정 푸터·whats-new 3언어 갱신(새 키 5·수정 3, 전 카탈로그 0/0)
검증: 자가 검증 62/20/27/10 ALL PASS(수치 불변), 시각 QA(하루·주간[합계 53,966보 시드
검산 일치]·월간 롤링[30일 분모]·기록 목록 과거 날짜·ja·18.5[경과일 분모 4일 검산 일치]),
Debug·Store·18.5 빌드 그린. 종목별 운동 통계는 1.6 후보 보류. CURRENT_PROJECT_VERSION 10.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
1344 lines
56 KiB
Swift
1344 lines
56 KiB
Swift
//
|
||
// StatsTabView.swift
|
||
// Haru_Danim
|
||
//
|
||
// 통계 탭 (독립 탭): 하루/주간/월간 차트 + 꼬리표·행동 다중 선택 필터
|
||
// - 하루: 꼬리표별 시간/횟수 가로 막대 (비율 비교)
|
||
// - 주간: 행동별 일별 꺾은선(태그 색 + 범례) + 주간 합계 막대 + 행동·꼬리표별 합계/평균 표
|
||
// - 월간: 행동별 주차별/일별 꺾은선 + 월간 합계 막대 + 행동·꼬리표별 합계/평균 표
|
||
//
|
||
|
||
import SwiftUI
|
||
import SwiftData
|
||
import Charts
|
||
|
||
/// 통계 탭 전용 기간 계산 (1.4 '오늘 기준' 롤링).
|
||
/// ⚠️ StatSpan enum 자체는 다짐 진행률(위젯 ②③④·워치·시리)과 공유되므로 케이스를
|
||
/// 추가하지 말 것 — 롤링은 통계 표면에서만 이 함수·rolling 플래그로 처리한다.
|
||
func statsRange(span: StatSpan, anchorDayKey: Date, rolling: Bool, math: DayMath) -> Range<Date> {
|
||
let anchor = math.dayRange(forKey: anchorDayKey).lowerBound
|
||
switch span {
|
||
case .day:
|
||
return math.dayRange(containing: anchor)
|
||
case .week:
|
||
return rolling
|
||
? math.rollingRange(endingAtKey: anchorDayKey, days: 7)
|
||
: math.weekRange(containing: anchor)
|
||
case .month:
|
||
return rolling
|
||
? math.rollingRange(endingAtKey: anchorDayKey, days: 30)
|
||
: math.monthRange(containing: anchor)
|
||
}
|
||
}
|
||
|
||
struct StatsTabView: View {
|
||
@Environment(AppRouter.self) private var router
|
||
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
|
||
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
|
||
|
||
// 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고)
|
||
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
|
||
|
||
private var allActions: [Action] {
|
||
LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw)
|
||
}
|
||
|
||
@State private var span: StatSpan = {
|
||
#if DEBUG
|
||
if let raw = UserDefaults.standard.string(forKey: "statSpan"),
|
||
let span = StatSpan(rawValue: raw) {
|
||
return span
|
||
}
|
||
#endif
|
||
return .day
|
||
}()
|
||
@State private var anchorDayKey: Date = DayMath().dayKey(for: .now)
|
||
@State private var showingFilter = false
|
||
@State private var showingExport = false
|
||
/// '오늘 기준' 롤링 보기 (주간→지난 7일, 월간→지난 30일). 표시 설정이라 기기 로컬
|
||
@AppStorage(SettingsKeys.statsRollingWindow) private var rollingWindow = false
|
||
/// 필터는 "해제한 행동"만 저장 → 기본값이 전체 선택이고, 새로 만든 행동도 자동 포함됨.
|
||
/// 꼬리표는 시트에서 소속 행동 일괄 토글용일 뿐, 판정은 행동 단위로만 한다.
|
||
@State private var excludedActionIDs: Set<PersistentIdentifier> = []
|
||
|
||
private var math: DayMath { DayMath() }
|
||
|
||
/// 하루 span에는 롤링 개념이 없다
|
||
private var isRolling: Bool { rollingWindow && span != .day }
|
||
|
||
private var spanRange: Range<Date> {
|
||
statsRange(span: span, anchorDayKey: anchorDayKey, rolling: isRolling, math: math)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
periodHeader
|
||
Picker("기간", selection: $span) {
|
||
ForEach(StatSpan.allCases) { span in
|
||
Text(span.label).tag(span)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.padding(.horizontal)
|
||
.padding(.bottom, 8)
|
||
|
||
// 주간/월간의 '오늘 기준' 롤링 토글 (1.4) — 월초·주초에 달력 주기가 거의
|
||
// 비어 보이는 허전함의 해소. 하루 세그먼트에서는 의미가 없어 숨긴다
|
||
if span != .day {
|
||
Toggle(isOn: $rollingWindow.animation()) {
|
||
Text(span == .week ? "지난 7일 단위로 보기" : "지난 30일 단위로 보기")
|
||
.font(.caption.weight(.semibold))
|
||
}
|
||
.toggleStyle(.switch)
|
||
.controlSize(.mini)
|
||
.tint(AppTheme.green)
|
||
.padding(.horizontal)
|
||
.padding(.bottom, 8)
|
||
}
|
||
|
||
if isFiltering {
|
||
filterChip
|
||
}
|
||
|
||
// 통계 조회는 보고 있는 기간(하루/주/월) 범위로만 DB에서 가져온다 (전량 로드 방지).
|
||
// 기간·날짜가 바뀌면 새 범위의 쿼리로 다시 만들어진다 (SwiftData 동적 쿼리 패턴).
|
||
StatsChartsView(
|
||
span: span,
|
||
anchorDayKey: anchorDayKey,
|
||
rolling: isRolling,
|
||
excludedActionIDs: excludedActionIDs,
|
||
orderedActions: allActions,
|
||
showingExport: $showingExport
|
||
)
|
||
}
|
||
.background(AppTheme.background)
|
||
.navigationTitle("통계")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
showingExport = true
|
||
} label: {
|
||
Image(systemName: "square.and.arrow.up")
|
||
}
|
||
.accessibilityLabel(Text("내보내기"))
|
||
}
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
showingFilter = true
|
||
} label: {
|
||
Image(systemName: isFiltering
|
||
? "line.3.horizontal.decrease.circle.fill"
|
||
: "line.3.horizontal.decrease.circle")
|
||
}
|
||
.accessibilityLabel(Text("통계 필터"))
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingFilter) {
|
||
RecordFilterSheet(
|
||
title: "통계 필터",
|
||
tags: tags,
|
||
actions: allActions,
|
||
excludedActionIDs: $excludedActionIDs
|
||
)
|
||
}
|
||
.onAppear {
|
||
consumePendingFilter()
|
||
#if DEBUG
|
||
// 검증용: -excludeActions "이름,이름"으로 필터 상태 주입, -statShowFilter YES로 시트 표시
|
||
if let raw = UserDefaults.standard.string(forKey: "excludeActions") {
|
||
let names = Set(raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) })
|
||
excludedActionIDs = Set(
|
||
allActions.filter { names.contains($0.name) }.map(\.persistentModelID)
|
||
)
|
||
}
|
||
if UserDefaults.standard.bool(forKey: "statShowFilter") {
|
||
showingFilter = true
|
||
}
|
||
// 검증용: -statRolling YES → '오늘 기준' 롤링 토글 켠 상태로 시작
|
||
if UserDefaults.standard.bool(forKey: "statRolling") {
|
||
rollingWindow = true
|
||
}
|
||
// 검증용: -showExport YES → 내보내기 시트 표시
|
||
if UserDefaults.standard.bool(forKey: "showExport") {
|
||
showingExport = true
|
||
}
|
||
#endif
|
||
}
|
||
.onChange(of: router.pendingStatsActionID) {
|
||
consumePendingFilter()
|
||
}
|
||
}
|
||
|
||
/// 모음 탭 '통계 보기'에서 예약한 행동 필터를 적용 (오늘 기준 기간으로 이동)
|
||
private func consumePendingFilter() {
|
||
guard let id = router.pendingStatsActionID else { return }
|
||
// 해당 행동만 남기고 모두 해제
|
||
excludedActionIDs = Set(allActions.map(\.persistentModelID)).subtracting([id])
|
||
anchorDayKey = math.dayKey(for: .now)
|
||
router.pendingStatsActionID = nil
|
||
}
|
||
|
||
// MARK: 기간 이동 헤더
|
||
|
||
private var periodHeader: some View {
|
||
HStack {
|
||
Button {
|
||
movePeriod(-1)
|
||
} label: {
|
||
Image(systemName: "chevron.left")
|
||
.frame(width: 44, height: 36)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.accessibilityLabel(Text("이전 기간"))
|
||
Spacer()
|
||
VStack(spacing: 1) {
|
||
Text(periodLabel)
|
||
.font(.headline)
|
||
if spanRange.contains(.now) {
|
||
Text(currentPeriodLabel)
|
||
.font(.caption2)
|
||
.foregroundStyle(AppTheme.green)
|
||
}
|
||
}
|
||
Spacer()
|
||
Button {
|
||
movePeriod(1)
|
||
} label: {
|
||
Image(systemName: "chevron.right")
|
||
.frame(width: 44, height: 36)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.accessibilityLabel(Text("다음 기간"))
|
||
Button(currentPeriodLabel) {
|
||
anchorDayKey = math.dayKey(for: .now)
|
||
}
|
||
.font(.caption)
|
||
.buttonStyle(.bordered)
|
||
.buttonBorderShape(.capsule)
|
||
}
|
||
.padding(.horizontal)
|
||
.padding(.vertical, 10)
|
||
.tint(AppTheme.green)
|
||
}
|
||
|
||
private var currentPeriodLabel: String {
|
||
switch span {
|
||
case .day: return String(localized: "오늘")
|
||
case .week: return isRolling ? String(localized: "최근 7일") : String(localized: "이번 주")
|
||
case .month: return isRolling ? String(localized: "최근 30일") : String(localized: "이번 달")
|
||
}
|
||
}
|
||
|
||
private var periodLabel: String {
|
||
let keys = math.dayKeys(in: spanRange)
|
||
switch span {
|
||
case .day:
|
||
return Format.fullDate(anchorDayKey)
|
||
case .week:
|
||
guard let first = keys.first, let last = keys.last else { return "" }
|
||
return "\(Format.shortDate(first)) ~ \(Format.shortDate(last))"
|
||
case .month:
|
||
if isRolling {
|
||
guard let first = keys.first, let last = keys.last else { return "" }
|
||
return "\(Format.shortDate(first)) ~ \(Format.shortDate(last))"
|
||
}
|
||
return anchorDayKey.formatted(.dateTime.year().month())
|
||
}
|
||
}
|
||
|
||
private func movePeriod(_ delta: Int) {
|
||
let cal = math.calendar
|
||
switch span {
|
||
case .day:
|
||
anchorDayKey = cal.date(byAdding: .day, value: delta, to: anchorDayKey)!
|
||
case .week:
|
||
anchorDayKey = cal.date(byAdding: .day, value: 7 * delta, to: anchorDayKey)!
|
||
case .month:
|
||
// 롤링(지난 30일)은 30일씩, 달력 월은 한 달씩 점프
|
||
if isRolling {
|
||
anchorDayKey = cal.date(byAdding: .day, value: 30 * delta, to: anchorDayKey)!
|
||
} else {
|
||
anchorDayKey = cal.date(byAdding: .month, value: delta, to: anchorDayKey)!
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: 필터 (꼬리표 트리에서 행동 다중 선택, 기본 전체 선택)
|
||
|
||
private var isFiltering: Bool {
|
||
// 삭제된 행동의 잔존 제외 ID는 무시 — 실재 행동이 하나라도 제외됐을 때만 필터 활성
|
||
// (안 그러면 제외했던 행동을 삭제한 뒤에도 칩·내보내기 필터 문구가 허위로 남는다)
|
||
allActions.contains { !passesFilter($0) }
|
||
}
|
||
|
||
/// 행동이 통계에 포함되는지: 행동 단위로만 판정 (꼬리표 일부 행동만 선택해도 정상 반영)
|
||
private func passesFilter(_ action: Action) -> Bool {
|
||
!excludedActionIDs.contains(action.persistentModelID)
|
||
}
|
||
|
||
private var filteredActions: [Action] {
|
||
allActions.filter(passesFilter)
|
||
}
|
||
|
||
private var filterChip: some View {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "line.3.horizontal.decrease")
|
||
.font(.caption2.weight(.semibold))
|
||
Text("행동 \(filteredActions.count)/\(allActions.count)개 표시 중")
|
||
.font(.caption.weight(.semibold))
|
||
.lineLimit(1)
|
||
Button {
|
||
withAnimation {
|
||
excludedActionIDs = []
|
||
}
|
||
} label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.padding(4)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(Text("필터 해제"))
|
||
}
|
||
.foregroundStyle(AppTheme.green)
|
||
.padding(.leading, 10)
|
||
.padding(.trailing, 2)
|
||
.padding(.vertical, 4)
|
||
.background(AppTheme.green.opacity(0.12), in: Capsule())
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal)
|
||
.padding(.bottom, 6)
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - 통계 차트 (보고 있는 기간 범위로만 DB 조회 + 1회 버킷 집계)
|
||
|
||
/// 통계 차트 본체. 두 가지 성능 원칙:
|
||
/// 1) **범위 조회**: 보고 있는 기간(하루/주/월)에 겹치는 기록만 @Query로 가져온다 —
|
||
/// 하루 경계를 걸친 세션·진행 중 세션은 "시작 < 범위끝 AND (종료 없음 OR 종료 > 범위시작)"
|
||
/// 겹침 조건으로 포함. 전량 로드하던 예전 구조는 기록이 수만 건이면 탭 진입이 느려졌다.
|
||
/// 2) **1회 버킷 집계**: 카드(꺾은선·막대·표)마다 Aggregator로 기록을 다시 훑지 않고,
|
||
/// 조회된 기록을 한 번만 지나가며 행동별·하루별 버킷을 만들어 모든 카드가 공유한다.
|
||
/// 하루 분할은 DayMath.splitByDay — 기존 Aggregator와 같은 규칙이라 수치가 동일하다.
|
||
private struct StatsChartsView: View {
|
||
let span: StatSpan
|
||
let anchorDayKey: Date
|
||
/// '오늘 기준' 롤링 보기 (주간→지난 7일, 월간→지난 30일 — 하루 span에서는 항상 false)
|
||
let rolling: Bool
|
||
let excludedActionIDs: Set<PersistentIdentifier>
|
||
let orderedActions: [Action]
|
||
@Binding var showingExport: Bool
|
||
|
||
@Query private var sessions: [TimeSession]
|
||
@Query private var entries: [CountEntry]
|
||
|
||
// 1.5(10): 건강 카드 — 표시 여부·지표는 모음 탭 타일 설정을 그대로 상속 (별도 설정 없음).
|
||
// '건강 데이터 표시'(설정 → 건강 데이터)가 마스터 스위치라 끄면 타일과 함께 사라진다
|
||
@AppStorage(LocalPrefsKeys.healthTilesEnabled, store: AppGroup.defaults) private var healthTilesEnabled = true
|
||
@AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults) private var healthMetricsRaw = ""
|
||
/// 추이 카드에서 마지막으로 고른 지표 (기기 로컬 표시 설정 — statsRollingWindow와 같은 계층)
|
||
@AppStorage(SettingsKeys.statsHealthMetric) private var statsHealthMetricRaw = ""
|
||
|
||
private let math = DayMath()
|
||
|
||
init(span: StatSpan, anchorDayKey: Date, rolling: Bool,
|
||
excludedActionIDs: Set<PersistentIdentifier>, orderedActions: [Action],
|
||
showingExport: Binding<Bool>) {
|
||
self.span = span
|
||
self.anchorDayKey = anchorDayKey
|
||
self.rolling = rolling
|
||
self.excludedActionIDs = excludedActionIDs
|
||
self.orderedActions = orderedActions
|
||
self._showingExport = showingExport
|
||
|
||
let math = DayMath()
|
||
let range = statsRange(span: span, anchorDayKey: anchorDayKey, rolling: rolling, math: math)
|
||
let lower = range.lowerBound
|
||
let upper = range.upperBound
|
||
let farFuture = Date.distantFuture
|
||
_sessions = Query(filter: #Predicate<TimeSession> {
|
||
$0.startAt < upper && ($0.endAt ?? farFuture) > lower
|
||
})
|
||
_entries = Query(filter: #Predicate<CountEntry> {
|
||
$0.timestamp >= lower && $0.timestamp < upper
|
||
})
|
||
}
|
||
|
||
private var spanRange: Range<Date> {
|
||
statsRange(span: span, anchorDayKey: anchorDayKey, rolling: rolling, math: math)
|
||
}
|
||
|
||
// MARK: 이전 기간 비교 (화면 전용 — 내보내기 리포트에는 넣지 않는다, §6.6)
|
||
|
||
/// 현재 기간 바로 앞의 같은 단위 기간 (롤링은 바로 앞 7일/30일 창)
|
||
private var previousRange: Range<Date> {
|
||
if rolling {
|
||
let days = span == .week ? 7 : 30
|
||
let prevEndKey = math.calendar.date(byAdding: .day, value: -days, to: anchorDayKey)
|
||
?? anchorDayKey
|
||
return math.rollingRange(endingAtKey: prevEndKey, days: days)
|
||
}
|
||
let justBefore = spanRange.lowerBound.addingTimeInterval(-1)
|
||
switch span {
|
||
case .day: return math.dayRange(containing: justBefore)
|
||
case .week: return math.weekRange(containing: justBefore)
|
||
case .month: return math.monthRange(containing: justBefore)
|
||
}
|
||
}
|
||
|
||
/// 필터 통과 행동들의 기간 합계. 이전 기간 기록은 화면 @Query 범위 밖이라
|
||
/// 관계 기반 Aggregator로 직접 집계한다 (행동 상세의 누적 표시와 같은 경로 — 규칙 동일)
|
||
private func periodTotals(in range: Range<Date>) -> (seconds: TimeInterval, count: Int) {
|
||
let agg = Aggregator(math: math)
|
||
var seconds: TimeInterval = 0
|
||
var count = 0
|
||
for action in filteredActions {
|
||
switch action.trackingType {
|
||
case .time: seconds += agg.seconds(for: action, in: range)
|
||
case .count: count += agg.count(for: action, in: range)
|
||
}
|
||
}
|
||
return (seconds, count)
|
||
}
|
||
|
||
private var previousPeriodTitle: LocalizedStringKey {
|
||
if rolling {
|
||
return span == .week ? "이전 7일과 비교" : "이전 30일과 비교"
|
||
}
|
||
switch span {
|
||
case .day: return "어제와 비교"
|
||
case .week: return "지난주와 비교"
|
||
case .month: return "지난달과 비교"
|
||
}
|
||
}
|
||
|
||
/// 총 시간·횟수를 바로 앞 기간과 비교하는 요약 카드 — 두 기간 모두 0인 유형은 줄을 생략하고,
|
||
/// 둘 다 아무것도 없으면 카드 자체를 그리지 않는다
|
||
@ViewBuilder
|
||
private var comparisonCard: some View {
|
||
let current = periodTotals(in: spanRange)
|
||
let previous = periodTotals(in: previousRange)
|
||
let showTime = current.seconds > 0 || previous.seconds > 0
|
||
let showCount = current.count > 0 || previous.count > 0
|
||
if showTime || showCount {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(previousPeriodTitle)
|
||
.font(.subheadline.weight(.semibold))
|
||
if showTime {
|
||
comparisonRow(
|
||
symbol: "timer",
|
||
title: String(localized: "시간"),
|
||
currentLabel: Format.durationShort(current.seconds),
|
||
previousLabel: Format.durationShort(previous.seconds),
|
||
current: current.seconds,
|
||
previous: previous.seconds
|
||
)
|
||
}
|
||
if showCount {
|
||
comparisonRow(
|
||
symbol: "number",
|
||
title: String(localized: "횟수"),
|
||
currentLabel: String(localized: "\(current.count)회"),
|
||
previousLabel: String(localized: "\(previous.count)회"),
|
||
current: Double(current.count),
|
||
previous: Double(previous.count)
|
||
)
|
||
}
|
||
}
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
}
|
||
}
|
||
|
||
private func comparisonRow(symbol: String, title: String,
|
||
currentLabel: String, previousLabel: String,
|
||
current: Double, previous: Double) -> some View {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: symbol)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(AppTheme.green)
|
||
.frame(width: 18)
|
||
Text(title)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
VStack(alignment: .trailing, spacing: 1) {
|
||
HStack(spacing: 6) {
|
||
Text(currentLabel)
|
||
.font(.footnote.weight(.semibold).monospacedDigit())
|
||
deltaBadge(current: current, previous: previous)
|
||
}
|
||
Text("이전 \(previousLabel)")
|
||
.font(.caption2)
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 증감 배지 — 이전 기록이 없으면 퍼센트 대신 상태 문구 (0으로 나누는 왜곡 방지)
|
||
@ViewBuilder
|
||
private func deltaBadge(current: Double, previous: Double) -> some View {
|
||
if previous <= 0 {
|
||
Text("이전 기록 없음")
|
||
.font(.caption2)
|
||
.foregroundStyle(.tertiary)
|
||
} else {
|
||
let percent = Int(((current - previous) / previous * 100).rounded())
|
||
Group {
|
||
if percent > 0 {
|
||
Text(verbatim: "▲") + Text("\(percent)%")
|
||
} else if percent < 0 {
|
||
Text(verbatim: "▼") + Text("\(abs(percent))%")
|
||
} else {
|
||
Text("변화 없음")
|
||
}
|
||
}
|
||
.font(.caption2.weight(.bold).monospacedDigit())
|
||
.foregroundStyle(percent > 0 ? AnyShapeStyle(AppTheme.green) : AnyShapeStyle(.secondary))
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
let data = makeStatsData()
|
||
ScrollViewReader { proxy in
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
comparisonCard
|
||
switch span {
|
||
case .day:
|
||
dayCharts(data)
|
||
case .week:
|
||
weekCharts(data)
|
||
case .month:
|
||
monthCharts(data)
|
||
}
|
||
// 건강 카드는 행동 통계 아래에 덧붙인다 (1.5(10) — 기존 화면 배치 불변)
|
||
healthSection
|
||
}
|
||
.padding()
|
||
Color.clear
|
||
.frame(height: 1)
|
||
.id("statsBottom")
|
||
}
|
||
.onAppear {
|
||
#if DEBUG
|
||
// 검증용: -statScrollBottom YES면 하단 요약 카드까지 자동 스크롤
|
||
if UserDefaults.standard.bool(forKey: "statScrollBottom") {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||
withAnimation { proxy.scrollTo("statsBottom", anchor: .bottom) }
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingExport) {
|
||
// 지금 보고 있는 기간/필터 그대로 스냅숏을 만들어 이미지로 내보낸다
|
||
ExportImageSheet(snapshot: ExportBuilder.stats(
|
||
span: span,
|
||
anchorDayKey: anchorDayKey,
|
||
rolling: rolling,
|
||
sessions: sessions,
|
||
entries: entries,
|
||
orderedActions: orderedActions,
|
||
excludedActionIDs: excludedActionIDs,
|
||
math: math
|
||
))
|
||
}
|
||
}
|
||
|
||
// MARK: 필터
|
||
|
||
private func passesFilter(_ action: Action) -> Bool {
|
||
!excludedActionIDs.contains(action.persistentModelID)
|
||
}
|
||
|
||
private var filteredActions: [Action] {
|
||
orderedActions.filter(passesFilter)
|
||
}
|
||
|
||
// MARK: 1회 버킷 집계
|
||
|
||
/// 행동별·하루별 집계 버킷 (조회된 기록을 한 번만 순회해 생성)
|
||
private struct StatsData {
|
||
var seconds: [PersistentIdentifier: [Date: TimeInterval]] = [:]
|
||
var counts: [PersistentIdentifier: [Date: Int]] = [:]
|
||
/// 기간의 논리적 하루 키 목록
|
||
var dayKeys: [Date] = []
|
||
|
||
func seconds(for id: PersistentIdentifier, days: [Date]) -> TimeInterval {
|
||
guard let byDay = seconds[id] else { return 0 }
|
||
return days.reduce(0) { $0 + (byDay[$1] ?? 0) }
|
||
}
|
||
|
||
func count(for id: PersistentIdentifier, days: [Date]) -> Int {
|
||
guard let byDay = counts[id] else { return 0 }
|
||
return days.reduce(0) { $0 + (byDay[$1] ?? 0) }
|
||
}
|
||
|
||
/// 행동의 추적 방식에 맞는 값 (시간=초, 횟수=회)
|
||
func value(for action: Action, type: TrackingType, days: [Date]) -> Double {
|
||
switch type {
|
||
case .time: return seconds(for: action.persistentModelID, days: days)
|
||
case .count: return Double(count(for: action.persistentModelID, days: days))
|
||
}
|
||
}
|
||
}
|
||
|
||
private func makeStatsData() -> StatsData {
|
||
var data = StatsData()
|
||
let range = spanRange
|
||
data.dayKeys = math.dayKeys(in: range)
|
||
let now = Date.now
|
||
for session in sessions {
|
||
guard let action = session.action, passesFilter(action) else { continue }
|
||
let start = max(session.startAt, range.lowerBound)
|
||
let end = min(session.endAt ?? now, range.upperBound)
|
||
guard start < end else { continue }
|
||
for segment in math.splitByDay(start: start, end: end) {
|
||
data.seconds[action.persistentModelID, default: [:]][segment.dayKey, default: 0]
|
||
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
|
||
}
|
||
}
|
||
for entry in entries {
|
||
guard range.contains(entry.timestamp),
|
||
let action = entry.action, passesFilter(action) else { continue }
|
||
data.counts[action.persistentModelID, default: [:]][math.dayKey(for: entry.timestamp), default: 0]
|
||
+= entry.amount
|
||
}
|
||
return data
|
||
}
|
||
|
||
/// 평균 계산용: 기간 내 "이미 시작된" 날 수 (미래 날짜로 평균이 희석되지 않게)
|
||
private var elapsedDayCount: Int {
|
||
max(
|
||
math.dayKeys(in: spanRange)
|
||
.filter { math.dayRange(forKey: $0).lowerBound <= .now }
|
||
.count,
|
||
1
|
||
)
|
||
}
|
||
|
||
// MARK: 하루 — 꼬리표별 가로 막대 (비율 비교)
|
||
|
||
@ViewBuilder
|
||
private func dayCharts(_ data: StatsData) -> some View {
|
||
let stats = tagStats(data)
|
||
tagTimeBarCard(stats)
|
||
tagCountBarCard(stats)
|
||
}
|
||
|
||
/// 꼬리표별 합계 — 행동별 버킷 합계를 소속 꼬리표들에 배분 (기존 세그먼트 순회와 동일한 값).
|
||
/// 키는 꼬리표 identity('꼬리표 없음' 자리는 nil) — 동명 꼬리표가 이름 키로 합산되던 것 방지,
|
||
/// 표시 이름은 마지막에 Format.disambiguated로 구분한다
|
||
private func tagStats(_ data: StatsData) -> [TagStat] {
|
||
struct Acc { var name: String; var color: Color; var seconds: TimeInterval = 0; var count: Int = 0 }
|
||
var accs: [PersistentIdentifier?: Acc] = [:]
|
||
var order: [PersistentIdentifier?] = [] // 첫 등장 순서 (구분 번호가 결정적이게)
|
||
|
||
func tagKeys(for action: Action) -> [(PersistentIdentifier?, String, Color)] {
|
||
if action.tags.isEmpty { return [(nil, String(localized: "꼬리표 없음"), Color.gray)] }
|
||
return action.sortedTags.map { ($0.persistentModelID, $0.name, $0.color) }
|
||
}
|
||
|
||
for action in filteredActions {
|
||
let totalSeconds = data.seconds(for: action.persistentModelID, days: data.dayKeys)
|
||
let totalCount = data.count(for: action.persistentModelID, days: data.dayKeys)
|
||
guard totalSeconds > 0 || totalCount > 0 else { continue }
|
||
for (key, name, color) in tagKeys(for: action) {
|
||
if accs[key] == nil {
|
||
accs[key] = Acc(name: name, color: color)
|
||
order.append(key)
|
||
}
|
||
accs[key]?.seconds += totalSeconds
|
||
accs[key]?.count += totalCount
|
||
}
|
||
}
|
||
|
||
let ordered = order.compactMap { accs[$0] }
|
||
let names = Format.disambiguated(ordered.map(\.name))
|
||
return zip(ordered, names)
|
||
.map { acc, name in
|
||
TagStat(name: name, color: acc.color, seconds: acc.seconds, count: acc.count)
|
||
}
|
||
.sorted { $0.seconds > $1.seconds }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func tagTimeBarCard(_ stats: [TagStat]) -> some View {
|
||
let timeStats = stats.filter { $0.seconds > 0 }
|
||
chartCard("꼬리표별 시간 비교") {
|
||
if timeStats.isEmpty {
|
||
emptyChartText
|
||
} else {
|
||
Chart(timeStats) { stat in
|
||
BarMark(
|
||
x: .value("시간", stat.seconds / 3600),
|
||
y: .value("꼬리표", stat.name)
|
||
)
|
||
.foregroundStyle(stat.color)
|
||
.cornerRadius(4)
|
||
.annotation(position: .trailing) {
|
||
Text(Format.durationShort(stat.seconds))
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.chartXAxisLabel("시간(h)")
|
||
.frame(height: CGFloat(timeStats.count) * 44 + 30)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func tagCountBarCard(_ stats: [TagStat]) -> some View {
|
||
let countStats = stats.filter { $0.count > 0 }.sorted { $0.count > $1.count }
|
||
chartCard("꼬리표별 횟수 비교") {
|
||
if countStats.isEmpty {
|
||
emptyChartText
|
||
} else {
|
||
Chart(countStats) { stat in
|
||
BarMark(
|
||
x: .value("횟수", stat.count),
|
||
y: .value("꼬리표", stat.name)
|
||
)
|
||
.foregroundStyle(stat.color)
|
||
.cornerRadius(4)
|
||
.annotation(position: .trailing) {
|
||
Text("\(stat.count)회")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.chartXAxisLabel("횟수")
|
||
.frame(height: CGFloat(countStats.count) * 44 + 30)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: 주간 — 행동별 일별 꺾은선 + 주간 합계 막대
|
||
|
||
/// 합계 막대 제목 — 롤링이면 "지난 7일/30일", 달력 주기면 "주간/월간"
|
||
private var totalsTimeTitle: LocalizedStringKey {
|
||
if rolling { return span == .week ? "지난 7일 시간 합계" : "지난 30일 시간 합계" }
|
||
return span == .week ? "주간 시간 합계" : "월간 시간 합계"
|
||
}
|
||
|
||
private var totalsCountTitle: LocalizedStringKey {
|
||
if rolling { return span == .week ? "지난 7일 횟수 합계" : "지난 30일 횟수 합계" }
|
||
return span == .week ? "주간 횟수 합계" : "월간 횟수 합계"
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func weekCharts(_ data: StatsData) -> some View {
|
||
dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: true, data: data)
|
||
dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: true, data: data)
|
||
totalsBarCard(totalsTimeTitle, type: .time, data: data)
|
||
totalsBarCard(totalsCountTitle, type: .count, data: data)
|
||
summaryTableCard(
|
||
"행동별 합계·평균",
|
||
timeRows: actionSummaryRows(for: .time, data: data),
|
||
countRows: actionSummaryRows(for: .count, data: data)
|
||
)
|
||
summaryTableCard(
|
||
"꼬리표별 합계·평균",
|
||
timeRows: tagSummaryRows(for: .time, data: data),
|
||
countRows: tagSummaryRows(for: .count, data: data)
|
||
)
|
||
}
|
||
|
||
// MARK: 월간 — 행동별 주차별/일별 꺾은선 + 월간 합계 막대
|
||
|
||
@ViewBuilder
|
||
private func monthCharts(_ data: StatsData) -> some View {
|
||
// 롤링(지난 30일)은 창이 달력 월과 어긋나 "N주차" 표기가 성립하지 않는다 —
|
||
// 주차별 카드 없이 일별 선 + 합계 + 표 구성 (확장된 주간 보기와 동일한 문법)
|
||
if !rolling {
|
||
weeklyLineCard("행동별 시간 (주차별)", type: .time, data: data)
|
||
weeklyLineCard("행동별 횟수 (주차별)", type: .count, data: data)
|
||
}
|
||
dailyLineCard("행동별 시간 (일별)", type: .time, weekdayAxis: false, data: data)
|
||
dailyLineCard("행동별 횟수 (일별)", type: .count, weekdayAxis: false, data: data)
|
||
totalsBarCard(totalsTimeTitle, type: .time, data: data)
|
||
totalsBarCard(totalsCountTitle, type: .count, data: data)
|
||
summaryTableCard(
|
||
"행동별 합계·평균",
|
||
timeRows: actionSummaryRows(for: .time, data: data),
|
||
countRows: actionSummaryRows(for: .count, data: data)
|
||
)
|
||
summaryTableCard(
|
||
"꼬리표별 합계·평균",
|
||
timeRows: tagSummaryRows(for: .time, data: data),
|
||
countRows: tagSummaryRows(for: .count, data: data)
|
||
)
|
||
}
|
||
|
||
// MARK: 행동별 시리즈 계산
|
||
|
||
private struct ActionDayPoint: Identifiable {
|
||
let dayKey: Date
|
||
let actionName: String
|
||
let value: Double
|
||
|
||
var id: String { "\(dayKey.timeIntervalSinceReferenceDate)-\(actionName)" }
|
||
}
|
||
|
||
private struct ActionWeekPoint: Identifiable {
|
||
let weekLabel: String
|
||
let actionName: String
|
||
let value: Double
|
||
|
||
var id: String { "\(weekLabel)-\(actionName)" }
|
||
}
|
||
|
||
private struct ActionTotal: Identifiable {
|
||
let name: String
|
||
let color: Color
|
||
let value: Double
|
||
|
||
var id: String { name }
|
||
}
|
||
|
||
/// 기간 내 값이 있는(0이 아닌) 필터 통과 행동들 — 시리즈/범례 대상
|
||
private func activeActions(for type: TrackingType, data: StatsData) -> [Action] {
|
||
filteredActions
|
||
.filter { $0.trackingType == type }
|
||
.filter { data.value(for: $0, type: type, days: data.dayKeys) > 0 }
|
||
}
|
||
|
||
private func dailyPoints(for actions: [Action], names: [String], type: TrackingType, data: StatsData) -> [ActionDayPoint] {
|
||
var result: [ActionDayPoint] = []
|
||
let series = Array(zip(actions, names))
|
||
for key in data.dayKeys {
|
||
for (action, name) in series {
|
||
let raw = data.value(for: action, type: type, days: [key])
|
||
result.append(ActionDayPoint(
|
||
dayKey: key, actionName: name,
|
||
value: type == .time ? raw / 3600 : raw
|
||
))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
/// 한 달을 주 단위(주 시작 요일 설정 기준, 월 경계에서 잘림)로 쪼갠 범위들
|
||
private var weekRangesInMonth: [Range<Date>] {
|
||
let range = spanRange
|
||
var result: [Range<Date>] = []
|
||
var cursor = range.lowerBound
|
||
while cursor < range.upperBound {
|
||
let week = math.weekRange(containing: cursor)
|
||
result.append(max(week.lowerBound, range.lowerBound)..<min(week.upperBound, range.upperBound))
|
||
cursor = week.upperBound
|
||
}
|
||
return result
|
||
}
|
||
|
||
private func weeklyPoints(for actions: [Action], names: [String], type: TrackingType, data: StatsData) -> [ActionWeekPoint] {
|
||
var result: [ActionWeekPoint] = []
|
||
let series = Array(zip(actions, names))
|
||
for (index, weekRange) in weekRangesInMonth.enumerated() {
|
||
// 주 경계는 하루 단위로 정렬되므로 그 주에 속한 하루 버킷 합 = 기존 구간 집계와 동일
|
||
let weekDays = math.dayKeys(in: weekRange)
|
||
for (action, name) in series {
|
||
let raw = data.value(for: action, type: type, days: weekDays)
|
||
// String(localized:)로 감싸야 en/ja에서도 지역화됨 (내보내기 ExportBuilder와 동일 키)
|
||
result.append(ActionWeekPoint(weekLabel: String(localized: "\(index + 1)주차"),
|
||
actionName: name,
|
||
value: type == .time ? raw / 3600 : raw))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
private func totals(for type: TrackingType, data: StatsData) -> [ActionTotal] {
|
||
let actions = activeActions(for: type, data: data)
|
||
let names = Format.disambiguated(actions.map(\.name))
|
||
return zip(actions, names)
|
||
.map { action, name in
|
||
ActionTotal(name: name, color: action.color,
|
||
value: data.value(for: action, type: type, days: data.dayKeys))
|
||
}
|
||
.sorted { $0.value > $1.value }
|
||
}
|
||
|
||
private func valueLabel(_ value: Double, type: TrackingType) -> String {
|
||
switch type {
|
||
case .time: return Format.durationShort(value)
|
||
case .count: return String(localized: "\(Int(value.rounded()))회")
|
||
}
|
||
}
|
||
|
||
// MARK: 꺾은선 카드 (선 색 = 행동/태그 색, 범례 표시)
|
||
|
||
@ViewBuilder
|
||
private func dailyLineCard(_ title: LocalizedStringKey, type: TrackingType, weekdayAxis: Bool,
|
||
data: StatsData) -> some View {
|
||
let actions = activeActions(for: type, data: data)
|
||
// 동명 행동이 한 시리즈로 합쳐지지 않게 표시 이름 구분 (내보내기·⑤ 위젯과 동일 규칙)
|
||
let names = Format.disambiguated(actions.map(\.name))
|
||
let colors = actions.map(\.color)
|
||
chartCard(title) {
|
||
if actions.isEmpty {
|
||
emptyChartText
|
||
} else {
|
||
Chart(dailyPoints(for: actions, names: names, type: type, data: data)) { point in
|
||
LineMark(
|
||
x: .value("날짜", point.dayKey, unit: .day),
|
||
y: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), point.value)
|
||
)
|
||
.foregroundStyle(by: .value("행동", point.actionName))
|
||
.symbol(by: .value("행동", point.actionName))
|
||
.interpolationMethod(.monotone)
|
||
}
|
||
.chartForegroundStyleScale(domain: names, range: colors)
|
||
.chartXAxis {
|
||
if weekdayAxis {
|
||
AxisMarks(values: .stride(by: .day)) { value in
|
||
AxisGridLine()
|
||
AxisValueLabel {
|
||
if let date = value.as(Date.self) {
|
||
Text(Format.weekdayShort(math.calendar.component(.weekday, from: date)))
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
AxisMarks { _ in
|
||
AxisGridLine()
|
||
AxisValueLabel(format: .dateTime.day())
|
||
}
|
||
}
|
||
}
|
||
.chartYAxisLabel(type == .time ? String(localized: "시간(h)") : String(localized: "횟수"))
|
||
.frame(height: 200)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func weeklyLineCard(_ title: LocalizedStringKey, type: TrackingType,
|
||
data: StatsData) -> some View {
|
||
let actions = activeActions(for: type, data: data)
|
||
// 동명 행동이 한 시리즈로 합쳐지지 않게 표시 이름 구분 (내보내기·⑤ 위젯과 동일 규칙)
|
||
let names = Format.disambiguated(actions.map(\.name))
|
||
let colors = actions.map(\.color)
|
||
chartCard(title) {
|
||
if actions.isEmpty {
|
||
emptyChartText
|
||
} else {
|
||
Chart(weeklyPoints(for: actions, names: names, type: type, data: data)) { point in
|
||
LineMark(
|
||
x: .value("주차", point.weekLabel),
|
||
y: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), point.value)
|
||
)
|
||
.foregroundStyle(by: .value("행동", point.actionName))
|
||
.symbol(by: .value("행동", point.actionName))
|
||
.interpolationMethod(.monotone)
|
||
}
|
||
.chartForegroundStyleScale(domain: names, range: colors)
|
||
.chartYAxisLabel(type == .time ? String(localized: "시간(h)") : String(localized: "횟수"))
|
||
.frame(height: 200)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: 합계 막대 카드 (기간 누적 비율 비교)
|
||
|
||
@ViewBuilder
|
||
private func totalsBarCard(_ title: LocalizedStringKey, type: TrackingType,
|
||
data: StatsData) -> some View {
|
||
let items = totals(for: type, data: data)
|
||
chartCard(title) {
|
||
if items.isEmpty {
|
||
emptyChartText
|
||
} else {
|
||
Chart(items) { item in
|
||
BarMark(
|
||
x: .value(type == .time ? String(localized: "시간") : String(localized: "횟수"), type == .time ? item.value / 3600 : item.value),
|
||
y: .value("행동", item.name)
|
||
)
|
||
.foregroundStyle(item.color)
|
||
.cornerRadius(4)
|
||
.annotation(position: .trailing) {
|
||
Text(valueLabel(item.value, type: type))
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.chartXAxisLabel(type == .time ? String(localized: "시간(h)") : String(localized: "횟수"))
|
||
.frame(height: CGFloat(items.count) * 40 + 30)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: 행동·꼬리표별 합계/평균 요약 표 (주간/월간, 필터 반영)
|
||
|
||
private struct SummaryRowItem: Identifiable {
|
||
let name: String
|
||
let color: Color
|
||
let symbol: String
|
||
/// 시간형은 초, 횟수형은 횟수
|
||
let total: Double
|
||
let type: TrackingType
|
||
|
||
var id: String { "\(type == .time ? "time" : "count")-\(name)" }
|
||
}
|
||
|
||
/// 평균 계산용: 월 안에서 이미 시작된 주 수 (미래 주로 평균이 희석되지 않게)
|
||
private var elapsedWeekCount: Int {
|
||
max(weekRangesInMonth.filter { $0.lowerBound <= .now }.count, 1)
|
||
}
|
||
|
||
private func actionSummaryRows(for type: TrackingType, data: StatsData) -> [SummaryRowItem] {
|
||
let actions = activeActions(for: type, data: data)
|
||
let names = Format.disambiguated(actions.map(\.name))
|
||
return zip(actions, names)
|
||
.map { action, name in
|
||
SummaryRowItem(name: name, color: action.color, symbol: action.symbolName,
|
||
total: data.value(for: action, type: type, days: data.dayKeys),
|
||
type: type)
|
||
}
|
||
.sorted { $0.total > $1.total }
|
||
}
|
||
|
||
private func tagSummaryRows(for type: TrackingType, data: StatsData) -> [SummaryRowItem] {
|
||
let stats = tagStats(data)
|
||
let rows: [SummaryRowItem] = switch type {
|
||
case .time:
|
||
stats.filter { $0.seconds > 0 }
|
||
.map { SummaryRowItem(name: $0.name, color: $0.color, symbol: "tag.fill", total: $0.seconds, type: .time) }
|
||
case .count:
|
||
stats.filter { $0.count > 0 }
|
||
.map { SummaryRowItem(name: $0.name, color: $0.color, symbol: "tag.fill", total: Double($0.count), type: .count) }
|
||
}
|
||
return rows.sorted { $0.total > $1.total }
|
||
}
|
||
|
||
private func averageLabel(_ value: Double, type: TrackingType) -> String {
|
||
switch type {
|
||
case .time: return Format.durationShort(value)
|
||
case .count: return Format.countAverage(value)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func summaryTableCard(_ title: LocalizedStringKey, timeRows: [SummaryRowItem], countRows: [SummaryRowItem]) -> some View {
|
||
chartCard(title) {
|
||
if timeRows.isEmpty && countRows.isEmpty {
|
||
emptyChartText
|
||
} else {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
if !timeRows.isEmpty {
|
||
summaryGrid(sectionLabel: "시간", rows: timeRows)
|
||
}
|
||
if !timeRows.isEmpty && !countRows.isEmpty {
|
||
Divider()
|
||
}
|
||
if !countRows.isEmpty {
|
||
summaryGrid(sectionLabel: "횟수", rows: countRows)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func summaryGrid(sectionLabel: LocalizedStringKey, rows: [SummaryRowItem]) -> some View {
|
||
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 12) {
|
||
GridRow {
|
||
Text(sectionLabel)
|
||
.fontWeight(.semibold)
|
||
Text("합계")
|
||
.gridColumnAlignment(.trailing)
|
||
Text("하루 평균")
|
||
.gridColumnAlignment(.trailing)
|
||
if span == .month && !rolling {
|
||
Text("주 평균")
|
||
.gridColumnAlignment(.trailing)
|
||
}
|
||
}
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
ForEach(rows) { row in
|
||
GridRow {
|
||
HStack(spacing: 8) {
|
||
Image(safeSymbol: row.symbol)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 24, height: 24)
|
||
.background(row.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||
Text(row.name)
|
||
.font(.footnote)
|
||
.lineLimit(1)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Text(valueLabel(row.total, type: row.type))
|
||
.font(.footnote.weight(.semibold).monospacedDigit())
|
||
.foregroundStyle(AppTheme.green)
|
||
Text(averageLabel(row.total / Double(elapsedDayCount), type: row.type))
|
||
.font(.footnote.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
if span == .month && !rolling {
|
||
Text(averageLabel(row.total / Double(elapsedWeekCount), type: row.type))
|
||
.font(.footnote.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: 건강 데이터 (1.5(10) — HealthCache 인메모리 읽기만 하는 표시 전용 카드.
|
||
// 통계 내보내기 리포트에는 넣지 않는다 — '이전 기간 비교 카드'와 같은 의도된 화면 전용)
|
||
|
||
/// 표시 판정 — 건강 지원 기기 + 설정 '건강 데이터 표시' 켬 + 애플 건강 연결됨.
|
||
/// 연결 전에는 조용히 숨긴다 (연결 유도는 모음 탭 CTA 담당 — 통계에서 권유하지 않음)
|
||
private var showsHealth: Bool {
|
||
HealthDataStore.isAvailable && healthTilesEnabled && HealthDataStore.shared.hasRequestedAuth
|
||
}
|
||
|
||
/// 표시 지표 = 모음 탭 타일 선택 상속 (일기 건강 카드와 같은 상속 문법)
|
||
private var healthMetricsList: [HealthMetric] {
|
||
HealthMetric.selectedList(raw: healthMetricsRaw)
|
||
}
|
||
|
||
/// 추이 카드의 현재 지표 — 저장값이 목록에 없으면(타일 선택 변경) 첫 지표로 폴백
|
||
private var chartHealthMetric: HealthMetric {
|
||
if let saved = HealthMetric(rawValue: statsHealthMetricRaw),
|
||
healthMetricsList.contains(saved) {
|
||
return saved
|
||
}
|
||
return healthMetricsList.first ?? .steps
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var healthSection: some View {
|
||
if showsHealth {
|
||
if span == .day {
|
||
// 하루: 그날 지표 값 칩 (일기 건강 카드와 같은 모습 — 값 없으면 대시)
|
||
chartCard("건강 데이터") {
|
||
HealthValueChipGrid(dayKey: anchorDayKey, metrics: healthMetricsList)
|
||
}
|
||
} else {
|
||
let days = math.dayKeys(in: spanRange)
|
||
let hasAny = days.contains { day in
|
||
healthMetricsList.contains { HealthDisplayValues.value($0, dayKey: day) != nil }
|
||
}
|
||
if hasAny {
|
||
healthTrendCard(days: days)
|
||
healthSummaryCard(days: days)
|
||
} else {
|
||
// 캐시 보존(400일) 밖 과거 등 — 빈 카드 하나로 정리
|
||
chartCard("건강 데이터") { emptyHealthText }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct HealthDayPoint: Identifiable {
|
||
let dayKey: Date
|
||
let value: Double
|
||
var id: Date { dayKey }
|
||
}
|
||
|
||
/// 차트에 그릴 값 변환 — 시간류는 시간(h), 거리·물은 km·L, 나머지는 원 단위
|
||
private func healthChartValue(_ metric: HealthMetric, _ raw: Double) -> Double {
|
||
if metric.isDuration { return raw / 3600 }
|
||
switch metric {
|
||
case .distance, .water: return raw / 1000
|
||
default: return raw
|
||
}
|
||
}
|
||
|
||
/// 차트 Y축 단위 라벨 (kcal·km·L은 라틴 표기라 언어 무관)
|
||
@ViewBuilder
|
||
private func healthUnitLabel(_ metric: HealthMetric) -> some View {
|
||
if metric.isDuration || metric == .standHours {
|
||
Text("시간(h)")
|
||
} else {
|
||
switch metric {
|
||
case .steps: Text("보")
|
||
case .activeEnergy: Text(verbatim: "kcal")
|
||
case .distance: Text(verbatim: "km")
|
||
case .water: Text(verbatim: "L")
|
||
default: Text(verbatim: "")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 지표별 일별 추이 카드 — 행동 꺾은선 카드와 같은 문법, 지표는 우상단 메뉴에서 선택
|
||
private func healthTrendCard(days: [Date]) -> some View {
|
||
let metric = chartHealthMetric
|
||
let points = days.map { day in
|
||
HealthDayPoint(
|
||
dayKey: day,
|
||
value: healthChartValue(metric, HealthDisplayValues.value(metric, dayKey: day) ?? 0)
|
||
)
|
||
}
|
||
return VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("건강 지표 (일별)")
|
||
.font(.subheadline.weight(.semibold))
|
||
Spacer()
|
||
Menu {
|
||
ForEach(healthMetricsList) { candidate in
|
||
Button {
|
||
statsHealthMetricRaw = candidate.rawValue
|
||
} label: {
|
||
if candidate == metric {
|
||
Label(candidate.name, systemImage: "checkmark")
|
||
} else {
|
||
Text(candidate.name)
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
HStack(spacing: 4) {
|
||
Image(safeSymbol: metric.symbolName)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
Text(metric.name)
|
||
.font(.caption.weight(.semibold))
|
||
Image(systemName: "chevron.up.chevron.down")
|
||
.font(.system(size: 9, weight: .semibold))
|
||
}
|
||
.foregroundStyle(.pink)
|
||
.padding(.horizontal, 9)
|
||
.padding(.vertical, 5)
|
||
.background(Color.pink.opacity(0.12), in: Capsule())
|
||
}
|
||
.accessibilityLabel(Text("추이를 볼 지표 선택"))
|
||
}
|
||
Chart(points) { point in
|
||
LineMark(
|
||
x: .value("날짜", point.dayKey, unit: .day),
|
||
y: .value(metric.name, point.value)
|
||
)
|
||
.foregroundStyle(.pink)
|
||
.symbol(.circle)
|
||
.interpolationMethod(.monotone)
|
||
}
|
||
.chartXAxis {
|
||
if span == .week {
|
||
AxisMarks(values: .stride(by: .day)) { value in
|
||
AxisGridLine()
|
||
AxisValueLabel {
|
||
if let date = value.as(Date.self) {
|
||
Text(Format.weekdayShort(math.calendar.component(.weekday, from: date)))
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
AxisMarks { _ in
|
||
AxisGridLine()
|
||
AxisValueLabel(format: .dateTime.day())
|
||
}
|
||
}
|
||
}
|
||
.chartYAxisLabel { healthUnitLabel(metric) }
|
||
.frame(height: 200)
|
||
}
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
}
|
||
|
||
/// 지표별 합계·하루 평균 표 — 행동 요약 표와 같은 문법 (값 없는 지표 행은 생략)
|
||
private func healthSummaryCard(days: [Date]) -> some View {
|
||
let rows: [(metric: HealthMetric, total: Double)] = healthMetricsList.compactMap { metric in
|
||
let total = days.reduce(0.0) { $0 + (HealthDisplayValues.value(metric, dayKey: $1) ?? 0) }
|
||
return total > 0 ? (metric, total) : nil
|
||
}
|
||
return chartCard("건강 지표 합계·평균") {
|
||
if rows.isEmpty {
|
||
emptyHealthText
|
||
} else {
|
||
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 12) {
|
||
GridRow {
|
||
Text("지표")
|
||
.fontWeight(.semibold)
|
||
Text("합계")
|
||
.gridColumnAlignment(.trailing)
|
||
Text("하루 평균")
|
||
.gridColumnAlignment(.trailing)
|
||
if span == .month && !rolling {
|
||
Text("주 평균")
|
||
.gridColumnAlignment(.trailing)
|
||
}
|
||
}
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
ForEach(rows, id: \.metric) { row in
|
||
GridRow {
|
||
HStack(spacing: 8) {
|
||
Image(safeSymbol: row.metric.symbolName)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 24, height: 24)
|
||
.background(Color.pink, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||
Text(row.metric.name)
|
||
.font(.footnote)
|
||
.lineLimit(1)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Text(row.metric.valueLabel(row.total))
|
||
.font(.footnote.weight(.semibold).monospacedDigit())
|
||
.foregroundStyle(.pink)
|
||
Text(row.metric.valueLabel(row.total / Double(elapsedDayCount)))
|
||
.font(.footnote.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
if span == .month && !rolling {
|
||
Text(row.metric.valueLabel(row.total / Double(elapsedWeekCount)))
|
||
.font(.footnote.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var emptyHealthText: some View {
|
||
Text("이 기간의 건강 데이터가 없어요")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 24)
|
||
}
|
||
|
||
// MARK: 공통
|
||
|
||
private func chartCard(_ title: LocalizedStringKey, @ViewBuilder content: () -> some View) -> some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text(title)
|
||
.font(.subheadline.weight(.semibold))
|
||
content()
|
||
}
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
}
|
||
|
||
private var emptyChartText: some View {
|
||
Text("표시할 기록이 없어요")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 24)
|
||
}
|
||
}
|
||
|
||
// MARK: - 태그별 집계 항목
|
||
|
||
struct TagStat: Identifiable {
|
||
let name: String
|
||
let color: Color
|
||
let seconds: TimeInterval
|
||
let count: Int
|
||
|
||
var id: String { name }
|
||
}
|
||
|