mycode/myApp/Keging/IOS/StatsView.swift
songyc macbook 8f634c83f4 fix(keging): 실기기 3차 피드백 6건 — DI 멈춤 근본 원인(햅틱 엔진 세션 충돌) 제거·워치 세팅 pull 동기화·완료 진동·하루 타임라인·칼로리 정합
- CHHapticEngine을 공유 AVAudioSession에 부착(기본 생성 시 독자 오디오 정책이
  백그라운드 무음 루프를 끊던 것이 DI 멈춤 원인) + 오디오 키퍼 하드닝(루트 변경·
  미디어 서버 리셋·ensureAlive) + 운동 중 2초 하트비트(오디오·엔진·LA 자가 치유)
- Live Activity: 실제 phaseStart 사용·동일 상태 no-op·staleDate 8초·isStale 흐림
- 워치 세팅 동기화 3중화: 변경 push + reachability 재푸시 + 워치 앱 열릴 때 sendMessage pull
- 완주 진동 신설: 폰 2.4초 연속(백그라운드 6방 체인, 오디오 정지 4초 지연), 워치 retry×2+success
- 통계 하루 탭 맨 위 '오늘의 타임라인'(24시간 축 점 그래프, 시각 라벨·지금 룰)
- 폰 칼로리 공식을 애플워치 '기타' 운동 규칙(빠르게 걷기 상당)에 정합: MET 4.0+강도×0.15
- 도움말 4건 갱신·en/ja 번역·CFBundleName 보충, CLAUDE.md 현행화

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
2026-09-02 14:30:08 +09:00

381 lines
14 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)
let showTimeLabels = sessions.count <= 5
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)
.annotation(position: .top, spacing: 5) {
if showTimeLabels {
Text(record.startedAt, format: .dateTime.hour().minute())
.font(.caption2.weight(.medium))
.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))
}
}