- ActivityKit엔 미래 상태 예약 API가 없음을 확인(푸시 서버 없이는 국면 갱신이 앱 런타임 의존) → 상태에 세트 전체 일정을 실어 시스템 렌더 요소로 심장을 재구성: 국면 색 타임라인 바(하드 스톱 그라데이션) + 실시간 플레이헤드 ProgressView(timerInterval:) + 전체 남은 시간. 플레이헤드가 걸친 색 = 지금 국면 - 국면 라벨·횟수는 앱 갱신 유지(syncCurrent 단일 진입점), stale 시 흐림 - 오디오 키퍼: 완전 무음 대신 ±2 LSB 디더 노이즈(무음 감지 정지 회피) + 백그라운드 전환 ~30초 브리지 태스크 - 통계 타임라인 점별 시각 라벨 제거(겹침 — 시각은 오늘 기록 목록에서) - 도움말·번역 갱신, CLAUDE.md 현행화. 시뮬: DI 백그라운드 80초+(10/20) 진행 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
374 lines
13 KiB
Swift
374 lines
13 KiB
Swift
//
|
|
// StatsView.swift
|
|
// Keging
|
|
//
|
|
// 통계 — 하루·주간·월간, 꺾은선 그래프. 주간·월간은 달력 기준 ↔ 오늘 기준 롤링 전환.
|
|
// 하루 탭 맨 위에는 '오늘 언제 했는지' 한눈에 보이는 타임라인(24시간 점 그래프).
|
|
// 첫 화면에서 위로 스와이프해 올라오고, 아래로 스와이프하거나 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
|
|
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 struct HourCount: Identifiable {
|
|
let hour: Int
|
|
let reps: Int
|
|
var id: Int { hour }
|
|
}
|
|
|
|
/// 현재 범위의 날짜(자정) 목록
|
|
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..<count).map { calendar.date(byAdding: .day, value: $0, to: start)! }
|
|
}
|
|
}
|
|
|
|
private func records(on day: Date) -> [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 hourCounts: [HourCount] {
|
|
let todayRecords = records(on: calendar.startOfDay(for: Date()))
|
|
var byHour: [Int: Int] = [:]
|
|
for record in todayRecords {
|
|
let hour = calendar.component(.hour, from: record.startedAt)
|
|
byHour[hour, default: 0] += record.reps
|
|
}
|
|
return (0..<24).map { HourCount(hour: $0, reps: byHour[$0] ?? 0) }
|
|
}
|
|
|
|
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(scope == .day ? "시간대별 횟수" : "일별 횟수")
|
|
.font(.headline)
|
|
if totalSets == 0 {
|
|
Text("이 기간에는 아직 기록이 없어요.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, minHeight: 120)
|
|
} else if scope == .day {
|
|
dayChart
|
|
} else {
|
|
rangeChart
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
|
|
private var dayChart: some View {
|
|
Chart(hourCounts) { item in
|
|
LineMark(
|
|
x: .value("시", item.hour),
|
|
y: .value("횟수", item.reps)
|
|
)
|
|
.foregroundStyle(AppTheme.green)
|
|
if item.reps > 0 {
|
|
PointMark(
|
|
x: .value("시", item.hour),
|
|
y: .value("횟수", item.reps)
|
|
)
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
}
|
|
.chartXScale(domain: 0...23)
|
|
.chartXAxis {
|
|
AxisMarks(values: [0, 6, 12, 18, 23]) { value in
|
|
AxisGridLine()
|
|
AxisValueLabel {
|
|
if let hour = value.as(Int.self) { Text("\(hour)시") }
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 200)
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|