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,314 @@
|
||||
//
|
||||
// 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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user