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
145 lines
5.0 KiB
Swift
145 lines
5.0 KiB
Swift
//
|
|
// ExerciseTrendsView.swift
|
|
// Workouts
|
|
//
|
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
/// Per-exercise drill-down: every weighted exercise with completed history, most
|
|
/// recently trained first, each opening its weight-progression detail. This promotes
|
|
/// the progression chart to a first-class Progress surface (it also remains on the
|
|
/// library's reference pages).
|
|
struct ExerciseTrendsListView: View {
|
|
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
|
|
|
/// Completed weighted logs, newest first.
|
|
@Query private var logs: [WorkoutLog]
|
|
|
|
init() {
|
|
let completedRaw = WorkoutStatus.completed.rawValue
|
|
let weightRaw = LoadType.weight.rawValue
|
|
_logs = Query(
|
|
filter: #Predicate<WorkoutLog> {
|
|
$0.statusRaw == completedRaw && $0.loadType == weightRaw
|
|
},
|
|
sort: \WorkoutLog.date,
|
|
order: .reverse)
|
|
}
|
|
|
|
private struct ExerciseSummary: Identifiable {
|
|
var name: String
|
|
var sessions: Int
|
|
var best: Double
|
|
var last: Date
|
|
|
|
var id: String { name }
|
|
}
|
|
|
|
/// One row per exercise name: session count, best top-set weight, last trained.
|
|
/// `logs` is newest-first, so the first log seen per name fixes `last`.
|
|
private var summaries: [ExerciseSummary] {
|
|
var byName: [String: ExerciseSummary] = [:]
|
|
for log in logs {
|
|
let top = log.setEntries?.compactMap(\.weight).max() ?? log.weight
|
|
if var summary = byName[log.exerciseName] {
|
|
summary.sessions += 1
|
|
summary.best = max(summary.best, top)
|
|
byName[log.exerciseName] = summary
|
|
} else {
|
|
byName[log.exerciseName] = ExerciseSummary(
|
|
name: log.exerciseName, sessions: 1, best: top, last: log.date)
|
|
}
|
|
}
|
|
return byName.values.sorted { $0.last > $1.last }
|
|
}
|
|
|
|
var body: some View {
|
|
List(summaries) { summary in
|
|
NavigationLink {
|
|
ExerciseTrendDetailView(exerciseName: summary.name)
|
|
} label: {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(summary.name)
|
|
Text("\(summary.sessions) session\(summary.sessions == 1 ? "" : "s") · last \(summary.last.daysAgoLabel())")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(weightUnit.format(summary.best))
|
|
.font(.subheadline.weight(.semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.overlay {
|
|
if summaries.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Weighted Exercises Yet",
|
|
systemImage: "dumbbell",
|
|
description: Text("Complete weighted sets and each exercise's progression shows up here."))
|
|
}
|
|
}
|
|
.navigationTitle("Exercise Trends")
|
|
}
|
|
}
|
|
|
|
/// One exercise's progression: headline stats over its completed history plus the
|
|
/// weight-progression chart.
|
|
struct ExerciseTrendDetailView: View {
|
|
let exerciseName: String
|
|
|
|
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
|
|
|
/// Completed logs for this exercise, oldest first (matching the chart's order).
|
|
@Query private var logs: [WorkoutLog]
|
|
|
|
init(exerciseName: String) {
|
|
self.exerciseName = exerciseName
|
|
let name = exerciseName
|
|
let completedRaw = WorkoutStatus.completed.rawValue
|
|
_logs = Query(
|
|
filter: #Predicate<WorkoutLog> {
|
|
$0.exerciseName == name && $0.statusRaw == completedRaw
|
|
},
|
|
sort: \WorkoutLog.date,
|
|
order: .forward)
|
|
}
|
|
|
|
private var bestWeight: Double {
|
|
logs.map { $0.setEntries?.compactMap(\.weight).max() ?? $0.weight }.max() ?? 0
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack(spacing: 12) {
|
|
ProgressStatTile(
|
|
title: "Best",
|
|
value: weightUnit.format(bestWeight),
|
|
unit: nil)
|
|
ProgressStatTile(
|
|
title: "Sessions",
|
|
value: "\(logs.count)",
|
|
unit: nil)
|
|
ProgressStatTile(
|
|
title: "Last",
|
|
value: logs.last?.date.daysAgoLabel() ?? "—",
|
|
unit: nil)
|
|
}
|
|
|
|
WeightProgressionChartView(exerciseName: exerciseName)
|
|
.progressCard()
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
.background(Color(.systemGroupedBackground))
|
|
.navigationTitle(exerciseName)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|