Restructure into a three-tab app with Progress, goals, and Meditation
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
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user