Add Workout Stats screen (daily/weekly/monthly) — KC-14
Aggregate the per-day workout logs into performance stats: - WorkoutDayLog.volume + StatPeriod/WorkoutStat/StatBucket. - WorkoutViewModel.stat/statInterval/statBuckets (current vs previous). - WorkoutStatsView: Day/Week/Month selector, Workouts/Sets/Volume cards with %-delta vs previous period, metric chooser, and a Swift Charts bar trend. Reached via a chart button in the Workout header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import AudioToolbox
|
||||
import Charts
|
||||
|
||||
// MARK: - Rest Timer Banner
|
||||
private struct RestTimerBanner: View {
|
||||
@@ -62,6 +63,7 @@ struct WorkoutView: View {
|
||||
@State private var showManage = false
|
||||
@State private var showAddSection = false
|
||||
@State private var showHistory = false
|
||||
@State private var showStats = false
|
||||
@State private var isReordering = false
|
||||
@State private var trainAnyway = false // override the rest-day state for today
|
||||
@ObservedObject private var tm = TutorialManager.shared
|
||||
@@ -101,6 +103,7 @@ struct WorkoutView: View {
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Spacer()
|
||||
IButton(icon: "chart.bar.xaxis") { showStats = true }
|
||||
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
|
||||
IButton(icon: "ellipsis") { showManage = true }
|
||||
}
|
||||
@@ -293,6 +296,11 @@ struct WorkoutView: View {
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showStats) {
|
||||
WorkoutStatsView().environmentObject(vm)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showAddSection) {
|
||||
AddSectionSheet().environmentObject(vm)
|
||||
.presentationDetents([.height(240)])
|
||||
@@ -559,6 +567,130 @@ struct DotProgressCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout Stats
|
||||
struct WorkoutStatsView: View {
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var period: StatPeriod = .week
|
||||
@State private var metric: Metric = .volume
|
||||
|
||||
enum Metric: String, CaseIterable, Identifiable {
|
||||
case volume = "Volume", sets = "Sets", workouts = "Workouts"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private var current: WorkoutStat { vm.stat(period, offset: 0) }
|
||||
private var previous: WorkoutStat { vm.stat(period, offset: -1) }
|
||||
private var buckets: [StatBucket] {
|
||||
switch period {
|
||||
case .day: return vm.statBuckets(.day, count: 14)
|
||||
case .week: return vm.statBuckets(.week, count: 8)
|
||||
case .month: return vm.statBuckets(.month, count: 6)
|
||||
}
|
||||
}
|
||||
|
||||
private func value(_ s: WorkoutStat) -> Double {
|
||||
switch metric { case .volume: return s.volume; case .sets: return Double(s.sets); case .workouts: return Double(s.workouts) }
|
||||
}
|
||||
private static let num: NumberFormatter = {
|
||||
let f = NumberFormatter(); f.numberStyle = .decimal; f.maximumFractionDigits = 0; return f
|
||||
}()
|
||||
private func volStr(_ v: Double) -> String { (Self.num.string(from: NSNumber(value: v)) ?? "0") + " kg" }
|
||||
|
||||
private func delta(_ cur: Double, _ prev: Double) -> (String, Bool)? {
|
||||
guard prev > 0 else { return cur > 0 ? ("New", true) : nil }
|
||||
let pct = (cur - prev) / prev * 100
|
||||
if abs(pct) < 1 { return ("±0%", true) }
|
||||
return (String(format: "%+.0f%%", pct), pct >= 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Workout Stats").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 12)
|
||||
|
||||
Picker("", selection: $period) {
|
||||
ForEach(StatPeriod.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented).padding(.horizontal, 20).padding(.bottom, 12)
|
||||
|
||||
Divider().background(AppColors.border(cs))
|
||||
|
||||
if vm.history.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "chart.bar.xaxis").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
|
||||
Text("No data yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
||||
Text("Complete workouts and your stats appear here.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 14) {
|
||||
// ── Summary cards (this period vs last) ──
|
||||
HStack(spacing: 10) {
|
||||
statCard("Workouts", "\(current.workouts)", delta(Double(current.workouts), Double(previous.workouts)))
|
||||
statCard("Sets", "\(current.sets)", delta(Double(current.sets), Double(previous.sets)))
|
||||
}
|
||||
statCard("Volume", volStr(current.volume), delta(current.volume, previous.volume), wide: true)
|
||||
|
||||
// ── Metric chooser ──
|
||||
Picker("", selection: $metric) {
|
||||
ForEach(Metric.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
// ── Trend chart ──
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("\(metric.rawValue.uppercased()) · LAST \(buckets.count) \(period.rawValue.uppercased())S")
|
||||
.font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
Chart(buckets) { b in
|
||||
BarMark(
|
||||
x: .value("Period", b.label),
|
||||
y: .value(metric.rawValue, value(b.stat))
|
||||
)
|
||||
.foregroundStyle(AppColors.accent.gradient)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.frame(height: 180)
|
||||
.chartYAxis { AxisMarks(position: .leading) }
|
||||
}
|
||||
.padding(14).cardStyle()
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statCard(_ title: String, _ value: String, _ delta: (String, Bool)?, wide: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased()).font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(value).font(AppFonts.mono(wide ? 24 : 20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
if let d = delta {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: d.1 ? "arrow.up.right" : "arrow.down.right").font(.system(size: 9, weight: .bold))
|
||||
Text(d.0).font(AppFonts.mono(10, weight: .bold))
|
||||
}
|
||||
.foregroundColor(d.1 ? AppColors.green : AppColors.red)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Text("vs last \(period.rawValue.lowercased())").font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14).cardStyle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout History
|
||||
struct WorkoutHistoryView: View {
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
|
||||
Reference in New Issue
Block a user