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
315 lines
12 KiB
Swift
315 lines
12 KiB
Swift
//
|
|
// ProgressTabView.swift
|
|
// Workouts
|
|
//
|
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
/// The Progress tab — observe by goals (UX-REDESIGN.md). Per-goal adherence tracks
|
|
/// first, then this-week totals, aggregate trends, per-exercise drill-down, the
|
|
/// achievements grid, and full history (absorbed from the old Log tab). Everything on
|
|
/// this screen is derived by `ProgressPlanner` from the workout documents; nothing is
|
|
/// persisted, so it all survives cache rebuilds.
|
|
struct ProgressTabView: View {
|
|
/// Screenshot-capture affordance: `.bottom` opens the tab pre-scrolled to the
|
|
/// end (nil = the normal top). Only the DEBUG screenshot root passes it.
|
|
var initialAnchor: UnitPoint?
|
|
|
|
@Environment(SyncEngine.self) private var sync
|
|
|
|
@Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)])
|
|
private var schedules: [Schedule]
|
|
|
|
@Query(sort: \Workout.start, order: .reverse)
|
|
private var workouts: [Workout]
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
let facts = workoutFacts
|
|
let tracks = ProgressPlanner.goalTracks(
|
|
schedules: scheduleFacts, workouts: facts,
|
|
goalOrder: GoalKind.orderedCases())
|
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
WeekSummaryTiles(workouts: facts)
|
|
|
|
if tracks.isEmpty {
|
|
goalTracksHint
|
|
} else {
|
|
ForEach(tracks) { GoalTrackCard(track: $0) }
|
|
}
|
|
|
|
TrendsSection(workouts: facts)
|
|
|
|
exerciseTrendsLink
|
|
|
|
AchievementsSection(
|
|
achievements: ProgressPlanner.achievements(tracks: tracks, workouts: facts))
|
|
|
|
historySection
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.bottom, 24)
|
|
}
|
|
.defaultScrollAnchor(initialAnchor)
|
|
.background(Color(.systemGroupedBackground))
|
|
.navigationTitle("Progress")
|
|
}
|
|
}
|
|
|
|
// MARK: - Fact building (live-resolve IDs through the seed clone-on-edit redirect)
|
|
|
|
private var scheduleFacts: [ScheduleFacts] {
|
|
schedules.map { schedule in
|
|
ScheduleFacts(
|
|
id: schedule.id,
|
|
goal: schedule.goalKind,
|
|
recurrence: schedule.recurrenceEnum,
|
|
weekdays: schedule.weekdays,
|
|
timesPerWeek: schedule.timesPerWeek,
|
|
onceDate: schedule.date,
|
|
createdAt: schedule.createdAt,
|
|
routineID: sync.currentRoutineID(for: schedule.routineID),
|
|
routineName: schedule.routineName)
|
|
}
|
|
}
|
|
|
|
private var workoutFacts: [WorkoutFacts] {
|
|
workouts.map { workout in
|
|
WorkoutFacts(
|
|
routineID: workout.routineID.map { sync.currentRoutineID(for: $0) },
|
|
start: workout.start,
|
|
end: workout.end,
|
|
completed: workout.status == .completed,
|
|
volume: Self.volume(of: workout),
|
|
activeMinutes: workout.end.map { max(0, $0.timeIntervalSince(workout.start) / 60) } ?? 0,
|
|
energyKcal: workout.metricActiveEnergyKcal ?? 0)
|
|
}
|
|
}
|
|
|
|
/// Total lifted volume for one workout: the recorded watch metric when present,
|
|
/// else derived from per-set actuals, else the plan (the same fallback ladder the
|
|
/// documents' `effectiveSetEntries` uses).
|
|
static func volume(of workout: Workout) -> Double {
|
|
if let recorded = workout.metricTotalVolume { return recorded }
|
|
return workout.logs.reduce(0) { sum, log in
|
|
guard log.status == .completed, log.loadTypeEnum == .weight else { return sum }
|
|
if let entries = log.setEntries, !entries.isEmpty {
|
|
return sum + entries.reduce(0) { $0 + Double($1.reps ?? 0) * ($1.weight ?? 0) }
|
|
}
|
|
return sum + Double(log.sets * log.reps) * log.weight
|
|
}
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
private var goalTracksHint: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Label("No Goal Tracks Yet", systemImage: "chart.bar.fill")
|
|
.font(.headline)
|
|
Text("Add schedules on the Today tab and each goal builds a weekly adherence track here.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.progressCard()
|
|
}
|
|
|
|
private var exerciseTrendsLink: some View {
|
|
NavigationLink {
|
|
ExerciseTrendsListView()
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "chart.line.uptrend.xyaxis")
|
|
.font(.title3)
|
|
.foregroundStyle(.blue)
|
|
.frame(width: 32)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Exercise Trends")
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
Text("Weight progression for every exercise you've logged")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.progressCard()
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var historySection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("History")
|
|
.font(.headline)
|
|
Spacer()
|
|
NavigationLink("All Workouts") {
|
|
WorkoutHistoryView()
|
|
}
|
|
.font(.subheadline)
|
|
}
|
|
if workouts.isEmpty {
|
|
Text("No workouts yet. Start one from the Today tab.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(workouts.prefix(3)) { workout in
|
|
NavigationLink {
|
|
WorkoutLogListView(workout: workout)
|
|
} label: {
|
|
HStack {
|
|
CalendarListItem(
|
|
date: workout.start,
|
|
title: workout.routineName ?? Routine.unnamed,
|
|
subtitle: subtitle(for: workout),
|
|
subtitle2: workout.statusName)
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.progressCard()
|
|
}
|
|
|
|
private func subtitle(for workout: Workout) -> String {
|
|
if workout.status == .completed, let endDate = workout.end {
|
|
return workout.start.humanTimeInterval(to: endDate)
|
|
}
|
|
return workout.start.formattedDate()
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared card chrome
|
|
|
|
extension View {
|
|
/// The Progress tab's card treatment — one shared background so every section
|
|
/// reads as part of the same system.
|
|
func progressCard() -> some View {
|
|
padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
Color(.secondarySystemGroupedBackground),
|
|
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
}
|
|
|
|
// MARK: - This-week stat tiles
|
|
|
|
/// Three at-a-glance totals for the current calendar week, each with a delta arrow
|
|
/// against last week.
|
|
struct WeekSummaryTiles: View {
|
|
let workouts: [WorkoutFacts]
|
|
|
|
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
|
|
|
var body: some View {
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
let this = totals(in: calendar.dateInterval(of: .weekOfYear, for: now))
|
|
let last = totals(in: calendar.date(byAdding: .weekOfYear, value: -1, to: now)
|
|
.flatMap { calendar.dateInterval(of: .weekOfYear, for: $0) })
|
|
|
|
HStack(spacing: 12) {
|
|
ProgressStatTile(
|
|
title: "This Week",
|
|
value: "\(this.workouts)",
|
|
unit: this.workouts == 1 ? "workout" : "workouts",
|
|
delta: Double(this.workouts - last.workouts),
|
|
deltaText: "\(abs(this.workouts - last.workouts))")
|
|
ProgressStatTile(
|
|
title: "Volume",
|
|
value: this.volume.formatted(.number.notation(.compactName).precision(.significantDigits(3))),
|
|
unit: weightUnit.abbreviation,
|
|
delta: this.volume - last.volume,
|
|
deltaText: abs(this.volume - last.volume)
|
|
.formatted(.number.notation(.compactName).precision(.significantDigits(2))))
|
|
ProgressStatTile(
|
|
title: "Active Time",
|
|
value: Self.hoursMinutes(this.minutes),
|
|
unit: nil,
|
|
delta: this.minutes - last.minutes,
|
|
deltaText: Self.hoursMinutes(abs(this.minutes - last.minutes)))
|
|
}
|
|
}
|
|
|
|
private func totals(in interval: DateInterval?) -> (workouts: Int, volume: Double, minutes: Double) {
|
|
guard let interval else { return (0, 0, 0) }
|
|
let matching = workouts.filter { $0.completed && interval.contains($0.start) }
|
|
return (matching.count,
|
|
matching.reduce(0) { $0 + $1.volume },
|
|
matching.reduce(0) { $0 + $1.activeMinutes })
|
|
}
|
|
|
|
static func hoursMinutes(_ minutes: Double) -> String {
|
|
let total = Int(minutes.rounded())
|
|
let (h, m) = (total / 60, total % 60)
|
|
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
|
|
}
|
|
}
|
|
|
|
/// One compact stat tile: caption, big value, and a vs-last-week arrow. Shared by the
|
|
/// week summary row and the exercise drill-down.
|
|
struct ProgressStatTile: View {
|
|
let title: String
|
|
let value: String
|
|
var unit: String?
|
|
/// Signed change vs the comparison period; only its sign is displayed.
|
|
var delta: Double?
|
|
/// Preformatted magnitude for the delta line (callers know their unit best).
|
|
var deltaText: String?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
|
Text(value)
|
|
.font(.title3.weight(.semibold))
|
|
.monospacedDigit()
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
if let unit {
|
|
Text(unit)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if let delta, delta != 0 {
|
|
Label("\(deltaText ?? fallbackDeltaText(delta)) vs last wk",
|
|
systemImage: delta > 0 ? "arrow.up" : "arrow.down")
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundStyle(delta > 0 ? .green : .secondary)
|
|
} else {
|
|
// Keep tile heights equal whether or not a delta renders.
|
|
Text("—")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
Color(.secondarySystemGroupedBackground),
|
|
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
|
|
private func fallbackDeltaText(_ delta: Double) -> String {
|
|
abs(delta).formatted(.number.notation(.compactName).precision(.significantDigits(2)))
|
|
}
|
|
}
|