// // StatsView.swift // Keging // // 통계 — 하루(타임라인+합계+오늘 기록)·주간·월간(일별 꺾은선, 달력 기준 ↔ 롤링 전환). // 하루 탭 맨 위는 '오늘 언제 했는지' 한눈에 보이는 타임라인(24시간 점 그래프) — // 시간대별 꺾은선은 타임라인과 중복이라 제거(실기기 6차 피드백). // 첫 화면에서 위로 스와이프해 올라오고, 아래로 스와이프하거나 X로 닫는다. // import SwiftUI import Charts enum StatScope: String, CaseIterable, Identifiable { case day, week, month var id: String { rawValue } var title: LocalizedStringKey { switch self { case .day: "하루" case .week: "주간" case .month: "월간" } } } enum StatRangeMode: String { case calendar, rolling } struct StatsView: View { @EnvironmentObject private var recordStore: RecordStore var onClose: () -> Void @State private var scope: StatScope = .day @State private var weekMode: StatRangeMode = .calendar @State private var monthMode: StatRangeMode = .calendar /// 주 시작 = 월요일 private var calendar: Calendar { var c = Calendar.current c.firstWeekday = 2 return c } var body: some View { ZStack { AppTheme.background.ignoresSafeArea() VStack(spacing: 12) { header Picker("기간", selection: $scope) { ForEach(StatScope.allCases) { Text($0.title).tag($0) } } .pickerStyle(.segmented) .padding(.horizontal, 20) if scope != .day { rangeModePicker } ScrollView { VStack(spacing: 14) { if scope == .day, !todaySessions.isEmpty { timelineCard } summaryCard if scope != .day { chartCard } if scope == .day { sessionListCard } } .padding(.horizontal, 20) .padding(.bottom, 24) } } } .gesture( DragGesture(minimumDistance: 40) .onEnded { value in if value.translation.height > 70, abs(value.translation.width) < 100 { onClose() } } ) .onAppear { #if DEBUG if let index = CommandLine.arguments.firstIndex(of: "-statsScope"), index + 1 < CommandLine.arguments.count, let argScope = StatScope.allCases.first(where: { String(describing: $0) == CommandLine.arguments[index + 1] }) { scope = argScope } if CommandLine.arguments.contains("-statsRolling") { weekMode = .rolling monthMode = .rolling } #endif } } // MARK: 헤더·피커 private var header: some View { VStack(spacing: 6) { // 아래로 쓸어내려 닫기 손잡이 Capsule() .fill(.secondary.opacity(0.5)) .frame(width: 36, height: 5) .padding(.top, 8) HStack { Text("통계") .font(.largeTitle.bold()) Spacer() Button(action: onClose) { Image(systemName: "xmark") .font(.headline) .foregroundStyle(.secondary) .frame(width: 36, height: 36) .background(AppTheme.surface, in: Circle()) } .buttonStyle(.plain) .accessibilityLabel(Text("통계 닫기")) } .padding(.horizontal, 20) } } private var rangeModePicker: some View { Picker("범위", selection: scope == .week ? $weekMode : $monthMode) { Text(scope == .week ? "이번 주" : "이번 달").tag(StatRangeMode.calendar) Text(scope == .week ? "지난 7일" : "지난 30일").tag(StatRangeMode.rolling) } .pickerStyle(.segmented) .padding(.horizontal, 20) } // MARK: 데이터 private struct DayCount: Identifiable { let date: Date let sets: Int let reps: Int var id: Date { date } } /// 현재 범위의 날짜(자정) 목록 private var rangeDates: [Date] { let today = calendar.startOfDay(for: Date()) switch scope { case .day: return [today] case .week: if weekMode == .rolling { return (0..<7).reversed().map { calendar.date(byAdding: .day, value: -$0, to: today)! } } let start = calendar.dateInterval(of: .weekOfYear, for: Date())!.start return (0..<7).map { calendar.date(byAdding: .day, value: $0, to: start)! } case .month: if monthMode == .rolling { return (0..<30).reversed().map { calendar.date(byAdding: .day, value: -$0, to: today)! } } let start = calendar.dateInterval(of: .month, for: Date())!.start let count = calendar.range(of: .day, in: .month, for: Date())!.count return (0.. [SessionRecord] { recordStore.records.filter { calendar.startOfDay(for: $0.startedAt) == day } } private var dayCounts: [DayCount] { rangeDates.map { day in let dayRecords = records(on: day) return DayCount(date: day, sets: dayRecords.count, reps: dayRecords.reduce(0) { $0 + $1.reps }) } } private var totalSets: Int { dayCounts.reduce(0) { $0 + $1.sets } } private var totalReps: Int { dayCounts.reduce(0) { $0 + $1.reps } } // MARK: 카드 private var todaySessions: [SessionRecord] { records(on: calendar.startOfDay(for: Date())).sorted { $0.startedAt < $1.startedAt } } /// 오늘 언제 운동했는지 한눈에 — 24시간 축 위의 점 타임라인 (점 크기 = 횟수) private var timelineCard: some View { let sessions = todaySessions let dayStart = calendar.startOfDay(for: Date()) let dayEnd = dayStart.addingTimeInterval(24 * 3600) return VStack(alignment: .leading, spacing: 10) { Text("오늘의 타임라인") .font(.headline) Chart { RuleMark(y: .value("기준선", 0)) .foregroundStyle(AppTheme.green.opacity(0.18)) .lineStyle(StrokeStyle(lineWidth: 4, lineCap: .round)) RuleMark(x: .value("지금", Date())) .foregroundStyle(.secondary.opacity(0.45)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) .annotation(position: .top, spacing: 2) { Text("지금") .font(.caption2) .foregroundStyle(.secondary) } // 시각 라벨은 겹침 문제로 뺐다 — 정확한 시각은 아래 '오늘 기록' 목록에서 ForEach(sessions) { record in PointMark( x: .value("시각", record.startedAt), y: .value("기준선", 0) ) .symbolSize(140 + Double(min(record.reps, 60)) * 3) .foregroundStyle(AppTheme.green) } } .chartXScale(domain: dayStart.addingTimeInterval(-1200)...dayEnd.addingTimeInterval(1200)) .chartYScale(domain: -1...1) .chartYAxis(.hidden) .chartXAxis { AxisMarks(values: [0, 6, 12, 18, 24].map { dayStart.addingTimeInterval(Double($0) * 3600) }) { value in AxisGridLine() AxisValueLabel { if let date = value.as(Date.self) { let hour = Int((date.timeIntervalSince(dayStart) / 3600).rounded()) Text("\(hour)시") } } } } .frame(height: 96) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16)) } private var summaryCard: some View { HStack(spacing: 0) { summaryItem(value: "\(totalSets)", label: Text("세트")) Divider().frame(height: 36) summaryItem(value: "\(totalReps)", label: Text("횟수")) } .padding(.vertical, 16) .frame(maxWidth: .infinity) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16)) } private func summaryItem(value: String, label: Text) -> some View { VStack(spacing: 4) { Text(value) .font(.title.bold()) .monospacedDigit() .foregroundStyle(AppTheme.green) label .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) } /// 주간·월간 전용 — 하루 탭은 타임라인 카드가 대신한다 private var chartCard: some View { VStack(alignment: .leading, spacing: 12) { Text("일별 횟수") .font(.headline) if totalSets == 0 { Text("이 기간에는 아직 기록이 없어요.") .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, minHeight: 120) } else { rangeChart } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16)) } private var rangeChart: some View { Chart(dayCounts) { item in LineMark( x: .value("날짜", item.date, unit: .day), y: .value("횟수", item.reps) ) .foregroundStyle(AppTheme.green) PointMark( x: .value("날짜", item.date, unit: .day), y: .value("횟수", item.reps) ) .foregroundStyle(AppTheme.green) } .chartXAxis { if scope == .week { AxisMarks(values: .stride(by: .day)) { _ in AxisGridLine() AxisValueLabel(format: .dateTime.weekday(.narrow), centered: true) } } else { AxisMarks(values: .automatic(desiredCount: 6)) { _ in AxisGridLine() AxisValueLabel(format: .dateTime.day(), centered: true) } } } .frame(height: 200) } private var sessionListCard: some View { VStack(alignment: .leading, spacing: 12) { Text("오늘 기록") .font(.headline) let todayRecords = records(on: calendar.startOfDay(for: Date())) if todayRecords.isEmpty { Text("오늘은 아직 기록이 없어요.") .font(.subheadline) .foregroundStyle(.secondary) } else { ForEach(todayRecords.sorted { $0.startedAt > $1.startedAt }) { record in HStack { Text(record.startedAt, format: .dateTime.hour().minute()) .font(.body) Spacer() Text("\(record.reps)회") .font(.body.weight(.semibold)) .monospacedDigit() .foregroundStyle(AppTheme.green) } .padding(.vertical, 2) } } } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16)) } }