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:
2026-07-11 07:53:01 -04:00
parent 5c201289fb
commit 6e440317c4
97 changed files with 5354 additions and 1291 deletions
@@ -7,10 +7,10 @@
import SwiftUI
/// Developer-facing tool: scans iCloud Drive for splits/workouts that are exact
/// Developer-facing tool: scans iCloud Drive for routines/workouts that are exact
/// content duplicates (see `DuplicateCleanupPlanner`) typically the residue of
/// testing, a restore, or a sync hiccup and offers to delete the extras. Never
/// touches a split referenced by a workout, a bundled starter split, or an
/// touches a routine referenced by a workout, a bundled starter routine, or an
/// in-progress workout.
struct DuplicateCleanupView: View {
@Environment(SyncEngine.self) private var sync
@@ -21,7 +21,7 @@ struct DuplicateCleanupView: View {
}
private struct CleanupSummary {
var splitsDeleted: Int
var routinesDeleted: Int
var workoutsDeleted: Int
var skipped: Int
}
@@ -58,7 +58,7 @@ struct DuplicateCleanupView: View {
pendingPlan = nil
}
} message: { _ in
Text("This permanently deletes the duplicate copies shown below. Referenced splits, starter splits, and in-progress workouts are always kept.")
Text("This permanently deletes the duplicate copies shown below. Referenced routines, starter routines, and in-progress workouts are always kept.")
}
}
@@ -93,7 +93,7 @@ struct DuplicateCleanupView: View {
Label("Scan for Duplicates", systemImage: "magnifyingglass")
}
} footer: {
Text("Looks for splits and workouts that are exact content duplicates — for example, left over from testing or a sync hiccup — and offers to delete the extra copies. Splits referenced by a workout, starter splits, and in-progress workouts are never deleted.")
Text("Looks for routines and workouts that are exact content duplicates — for example, left over from testing or a sync hiccup — and offers to delete the extra copies. Routines referenced by a workout, starter routines, and in-progress workouts are never deleted.")
}
}
}
@@ -110,10 +110,10 @@ struct DuplicateCleanupView: View {
)
} else {
List {
if !plan.splitGroups.isEmpty {
Section("Duplicate Splits") {
ForEach(plan.splitGroups) { group in
splitGroupRow(group)
if !plan.routineGroups.isEmpty {
Section("Duplicate Routines") {
ForEach(plan.routineGroups) { group in
routineGroupRow(group)
}
}
}
@@ -139,7 +139,7 @@ struct DuplicateCleanupView: View {
}
}
private func splitGroupRow(_ group: DuplicateCleanupPlan.SplitGroup) -> some View {
private func routineGroupRow(_ group: DuplicateCleanupPlan.RoutineGroup) -> some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(group.keep) { doc in
memberRow(name: doc.name, detail: "\(doc.exercises.count) exercises", id: doc.id, isKeep: true)
@@ -154,14 +154,14 @@ struct DuplicateCleanupView: View {
private func workoutGroupRow(_ group: DuplicateCleanupPlan.WorkoutGroup) -> some View {
VStack(alignment: .leading, spacing: 6) {
memberRow(
name: group.keep.splitName ?? "(no split)",
name: group.keep.routineName ?? "(no routine)",
detail: group.keep.start.formattedDate(),
id: group.keep.id,
isKeep: true
)
ForEach(group.delete) { doc in
memberRow(
name: doc.splitName ?? "(no split)",
name: doc.routineName ?? "(no routine)",
detail: doc.start.formattedDate(),
id: doc.id,
isKeep: false
@@ -237,7 +237,7 @@ struct DuplicateCleanupView: View {
List {
Section {
Label(
"Deleted \(summary.splitsDeleted) split\(summary.splitsDeleted == 1 ? "" : "s"), \(summary.workoutsDeleted) workout\(summary.workoutsDeleted == 1 ? "" : "s"). \(summary.skipped) skipped (referenced or active).",
"Deleted \(summary.routinesDeleted) routine\(summary.routinesDeleted == 1 ? "" : "s"), \(summary.workoutsDeleted) workout\(summary.workoutsDeleted == 1 ? "" : "s"). \(summary.skipped) skipped (referenced or active).",
systemImage: "checkmark.circle.fill"
)
.foregroundStyle(.green)
@@ -273,7 +273,7 @@ struct DuplicateCleanupView: View {
phase = .deleting
let result = await sync.performCleanup(plan)
phase = .done(CleanupSummary(
splitsDeleted: result.splitsDeleted,
routinesDeleted: result.routinesDeleted,
workoutsDeleted: result.workoutsDeleted,
skipped: result.skipped
))
@@ -0,0 +1,41 @@
//
// GoalsListView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// A reorderable list of the practice-goal kinds. The chosen order is persisted via
/// `GoalKind.saveOrder` and drives how schedules group on the Today screen. Pushed
/// from Settings Library, so it relies on Settings' own NavigationStack.
struct GoalsListView: View {
@State private var order: [GoalKind] = []
var body: some View {
List {
Section {
ForEach(order) { kind in
Label(kind.displayName, systemImage: kind.systemImage)
.foregroundStyle(Color.color(from: kind.colorName))
}
.onMove(perform: move)
} footer: {
Text("Goals group your schedules on the Today screen. Drag to reorder.")
}
}
.navigationTitle("Goals")
.toolbar {
EditButton()
}
.onAppear {
if order.isEmpty { order = GoalKind.orderedCases() }
}
}
private func move(from source: IndexSet, to destination: Int) {
order.move(fromOffsets: source, toOffset: destination)
GoalKind.saveOrder(order)
}
}
+21 -10
View File
@@ -15,7 +15,7 @@ struct SettingsView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Query private var splits: [Split]
@Query private var routines: [Routine]
@AppStorage("restSeconds") private var restSeconds: Int = 45
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
@@ -78,12 +78,23 @@ struct SettingsView: View {
// MARK: - Library Section
Section(header: Text("Library")) {
NavigationLink {
SplitListView()
GoalsListView()
} label: {
HStack {
Label("Splits", systemImage: "dumbbell.fill")
Label("Goals", systemImage: "target")
Spacer()
Text("\(splits.count)")
Text("\(GoalKind.allCases.count)")
.foregroundStyle(.secondary)
}
}
NavigationLink {
RoutineListView()
} label: {
HStack {
Label("Routines", systemImage: "dumbbell.fill")
Spacer()
Text("\(routines.count)")
.foregroundStyle(.secondary)
}
}
@@ -100,7 +111,7 @@ struct SettingsView: View {
}
}
// MARK: - Starter Splits Section
// MARK: - Starter Routines Section
Section {
Button {
Task {
@@ -109,12 +120,12 @@ struct SettingsView: View {
let n = await sync.restoreSeeds()
isRestoringSeeds = false
restoreSeedsMessage = n == 0
? "All starter splits are already present."
: "Restored \(n) starter split\(n == 1 ? "" : "s")."
? "All starter routines are already present."
: "Restored \(n) starter routine\(n == 1 ? "" : "s")."
}
} label: {
HStack {
Label("Restore Starter Splits", systemImage: "arrow.counterclockwise")
Label("Restore Starter Routines", systemImage: "arrow.counterclockwise")
if isRestoringSeeds {
Spacer()
ProgressView()
@@ -123,12 +134,12 @@ struct SettingsView: View {
}
.disabled(isRestoringSeeds || sync.iCloudStatus != .available)
} header: {
Text("Starter Splits")
Text("Starter Routines")
} footer: {
if let restoreSeedsMessage {
Text(restoreSeedsMessage)
} else {
Text("Brings back the bundled starter splits you've deleted. Splits you've edited or created are never touched.")
Text("Brings back the bundled starter routines you've deleted. Routines you've edited or created are never touched.")
}
}