The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
135 lines
4.2 KiB
Swift
135 lines
4.2 KiB
Swift
//
|
|
// TrendsSection.swift
|
|
// Workouts
|
|
//
|
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Charts
|
|
|
|
/// Aggregate trends over time: one chart, a metric picker (workouts, volume, active
|
|
/// time, energy), and the standard 7D/30D/90D/1Y/All range picker. Counts render as
|
|
/// bars (discrete events); the other metrics as the smooth line + points pattern.
|
|
struct TrendsSection: View {
|
|
let workouts: [WorkoutFacts]
|
|
|
|
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
|
@State private var range: TrendRange = .month
|
|
@State private var metric: Metric = .workouts
|
|
|
|
enum Metric: String, CaseIterable, Identifiable {
|
|
case workouts = "Workouts"
|
|
case volume = "Volume"
|
|
case time = "Time"
|
|
case energy = "Energy"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var color: Color {
|
|
switch self {
|
|
case .workouts: .blue
|
|
case .volume: .purple
|
|
case .time: .green
|
|
case .energy: .orange
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Trends")
|
|
.font(.headline)
|
|
|
|
Picker("Metric", selection: $metric) {
|
|
ForEach(Metric.allCases) { metric in
|
|
Text(metric.rawValue).tag(metric)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
Picker("Range", selection: $range) {
|
|
ForEach(TrendRange.allCases) { range in
|
|
Text(range.rawValue).tag(range)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
chart
|
|
}
|
|
.progressCard()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var chart: some View {
|
|
let points = ProgressPlanner.trendPoints(workouts: workouts, range: range)
|
|
if points.isEmpty || points.allSatisfy({ $0.workouts == 0 }) {
|
|
ContentUnavailableView(
|
|
"No Data",
|
|
systemImage: "chart.xyaxis.line",
|
|
description: Text(workouts.contains(where: \.completed)
|
|
? "No workouts in this time range. Try a wider range."
|
|
: "Complete workouts and your trends build up here."))
|
|
.frame(height: 220)
|
|
} else {
|
|
Chart(points) { point in
|
|
if metric == .workouts {
|
|
BarMark(
|
|
x: .value("Date", point.date, unit: range.bucket),
|
|
y: .value(metric.rawValue, value(of: point))
|
|
)
|
|
.foregroundStyle(metric.color.gradient)
|
|
.cornerRadius(3)
|
|
} else {
|
|
LineMark(
|
|
x: .value("Date", point.date, unit: range.bucket),
|
|
y: .value(metric.rawValue, value(of: point))
|
|
)
|
|
.interpolationMethod(.catmullRom)
|
|
.foregroundStyle(metric.color.gradient)
|
|
|
|
PointMark(
|
|
x: .value("Date", point.date, unit: range.bucket),
|
|
y: .value(metric.rawValue, value(of: point))
|
|
)
|
|
.symbolSize(30)
|
|
.foregroundStyle(metric.color)
|
|
}
|
|
}
|
|
.chartYAxisLabel(unitLabel)
|
|
.chartXAxis {
|
|
AxisMarks(values: .automatic) { _ in
|
|
AxisGridLine()
|
|
AxisValueLabel(format: axisDateFormat)
|
|
}
|
|
}
|
|
.frame(height: 220)
|
|
}
|
|
}
|
|
|
|
private func value(of point: TrendPoint) -> Double {
|
|
switch metric {
|
|
case .workouts: Double(point.workouts)
|
|
case .volume: point.volume
|
|
case .time: point.activeMinutes
|
|
case .energy: point.energyKcal
|
|
}
|
|
}
|
|
|
|
private var unitLabel: String {
|
|
switch metric {
|
|
case .workouts: ""
|
|
case .volume: weightUnit.abbreviation
|
|
case .time: "min"
|
|
case .energy: "kcal"
|
|
}
|
|
}
|
|
|
|
private var axisDateFormat: Date.FormatStyle {
|
|
switch range.bucket {
|
|
case .month: .dateTime.month(.abbreviated)
|
|
default: .dateTime.month().day()
|
|
}
|
|
}
|
|
}
|