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,101 @@
|
||||
//
|
||||
// AchievementsSection.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The achievements badge grid. Every badge is a pure derivation of history
|
||||
/// (`ProgressPlanner.achievements`) — earned ones glow in their color, locked ones
|
||||
/// wear a progress ring showing how close they are. Tapping shows the how-to-earn
|
||||
/// detail.
|
||||
struct AchievementsSection: View {
|
||||
let achievements: [Achievement]
|
||||
|
||||
@State private var selected: Achievement?
|
||||
|
||||
private var earnedCount: Int { achievements.filter(\.earned).count }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Achievements")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(earnedCount) of \(achievements.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4),
|
||||
spacing: 16) {
|
||||
ForEach(achievements) { achievement in
|
||||
Button {
|
||||
selected = achievement
|
||||
} label: {
|
||||
AchievementBadge(achievement: achievement)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.progressCard()
|
||||
.alert(
|
||||
selected?.title ?? "",
|
||||
isPresented: Binding(
|
||||
get: { selected != nil },
|
||||
set: { if !$0 { selected = nil } }
|
||||
),
|
||||
presenting: selected
|
||||
) { _ in
|
||||
Button("OK") { selected = nil }
|
||||
} message: { achievement in
|
||||
Text(achievement.earned
|
||||
? achievement.detail
|
||||
: "\(achievement.detail) — \(Int((achievement.progress * 100).rounded()))% there.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One badge: a tinted circle with the symbol, the title beneath. Locked badges are
|
||||
/// desaturated with a thin progress arc around the circle.
|
||||
struct AchievementBadge: View {
|
||||
let achievement: Achievement
|
||||
|
||||
private var color: Color { Color.color(from: achievement.colorName) }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(achievement.earned ? color.opacity(0.18) : Color(.systemFill))
|
||||
Image(systemName: achievement.systemImage)
|
||||
.font(.title3)
|
||||
.foregroundStyle(achievement.earned ? color : Color(.tertiaryLabel))
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
.overlay {
|
||||
if !achievement.earned, achievement.progress > 0 {
|
||||
Circle()
|
||||
.trim(from: 0, to: achievement.progress)
|
||||
.stroke(color.opacity(0.6),
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
}
|
||||
|
||||
Text(achievement.title)
|
||||
.font(.caption2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.foregroundStyle(achievement.earned ? .primary : .secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(
|
||||
"\(achievement.title), \(achievement.earned ? "earned" : "locked"): \(achievement.detail)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// GoalTrackCard.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// One goal's adherence card: a 12-week strip (each square filled by that week's
|
||||
/// met/expected fraction), the weekly streak flame, and a this-week dot row per
|
||||
/// member schedule. The goal is the ledger category — this track continues unbroken
|
||||
/// across schedule rewrites.
|
||||
struct GoalTrackCard: View {
|
||||
let track: GoalTrack
|
||||
|
||||
private var color: Color {
|
||||
track.goal.map { Color.color(from: $0.colorName) } ?? Color(.systemGray)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Label(track.goal?.displayName ?? "Unassigned",
|
||||
systemImage: track.goal?.systemImage ?? "tag")
|
||||
.font(.headline)
|
||||
.foregroundStyle(color)
|
||||
Spacer()
|
||||
if track.streakWeeks > 0 {
|
||||
streakBadge
|
||||
}
|
||||
}
|
||||
|
||||
weekStrip
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(track.scheduleTracks, id: \.schedule.id) { scheduleTrack in
|
||||
scheduleRow(scheduleTrack)
|
||||
}
|
||||
}
|
||||
}
|
||||
.progressCard()
|
||||
}
|
||||
|
||||
/// The streak flame: orange while the current week is still meetable, dimmed the
|
||||
/// moment it can no longer be (the streak is at stake, not yet erased).
|
||||
private var streakBadge: some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "flame.fill")
|
||||
Text("\(track.streakWeeks) wk")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(track.streakAlive ? Color.orange : Color.secondary)
|
||||
.accessibilityLabel("\(track.streakWeeks)-week streak\(track.streakAlive ? "" : ", at risk")")
|
||||
}
|
||||
|
||||
private var weekStrip: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(track.weeks.enumerated()), id: \.offset) { index, week in
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(fill(for: week))
|
||||
.frame(height: 16)
|
||||
.overlay {
|
||||
// Outline the in-flight week so "unfilled" reads as "not over
|
||||
// yet" rather than "missed".
|
||||
if index == track.weeks.count - 1 {
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.strokeBorder(color.opacity(0.8), lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(stripSummary)
|
||||
}
|
||||
|
||||
private func fill(for week: WeekMark) -> Color {
|
||||
if week.isVacuous { return Color(.systemFill).opacity(0.4) }
|
||||
if week.fraction == 0 { return Color(.systemFill) }
|
||||
return color.opacity(0.25 + 0.75 * week.fraction)
|
||||
}
|
||||
|
||||
private var stripSummary: String {
|
||||
let hit = track.weeks.filter { !$0.isVacuous && $0.hit }.count
|
||||
let active = track.weeks.filter { !$0.isVacuous }.count
|
||||
return "Hit \(hit) of the last \(active) weeks"
|
||||
}
|
||||
|
||||
private func scheduleRow(_ scheduleTrack: ScheduleTrack) -> some View {
|
||||
let schedule = scheduleTrack.schedule
|
||||
return HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(schedule.routineName)
|
||||
.font(.subheadline)
|
||||
Text(schedule.recurrence.summary(
|
||||
weekdays: schedule.weekdays,
|
||||
timesPerWeek: schedule.timesPerWeek,
|
||||
date: schedule.onceDate))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
thisWeekDots(scheduleTrack)
|
||||
}
|
||||
}
|
||||
|
||||
/// This week for one schedule: `expected` dots, `met` of them filled. A broken
|
||||
/// week adds a quiet "missed" tag — informative, not scolding.
|
||||
@ViewBuilder
|
||||
private func thisWeekDots(_ scheduleTrack: ScheduleTrack) -> some View {
|
||||
if let week = scheduleTrack.currentWeek, !week.isVacuous {
|
||||
HStack(spacing: 5) {
|
||||
if scheduleTrack.currentWeekBroken {
|
||||
Text("missed")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(0..<week.expected, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index < week.met ? color : Color.clear)
|
||||
.overlay {
|
||||
if index >= week.met {
|
||||
Circle().strokeBorder(color.opacity(0.4), lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
.frame(width: 9, height: 9)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(week.met) of \(week.expected) this week")
|
||||
} else {
|
||||
Text("—")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user