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
@@ -32,11 +32,11 @@ struct ExerciseLibraryDetailView: View {
let exerciseName: String
/// This exercise wherever it appears in the user's splits the source of the
/// This exercise wherever it appears in the user's routines the source of the
/// recorded machine settings.
@Query private var exercises: [Exercise]
/// Presents the settings editor; carries the splits the edit writes back to.
/// Presents the settings editor; carries the routines the edit writes back to.
@State private var settingsEdit: SettingsEditRoute?
/// True while content extends below the scroll area drives the bottom fade
@@ -63,31 +63,31 @@ struct ExerciseLibraryDetailView: View {
}
/// Distinct recorded machine-settings lists for this exercise. Usually one; when
/// splits disagree, each distinct list keeps its split's name as a label. Each
/// group carries every split showing that list, so an edit updates them all and
/// routines disagree, each distinct list keeps its routine's name as a label. Each
/// group carries every routine showing that list, so an edit updates them all and
/// they stay deduped.
private var settingsGroups: [SettingsGroup] {
var groups: [SettingsGroup] = []
for exercise in exercises {
guard let settings = exercise.machineSettings, !settings.isEmpty else { continue }
if let i = groups.firstIndex(where: { $0.settings == settings }) {
if let id = exercise.split?.id { groups[i].splitIDs.append(id) }
if let id = exercise.routine?.id { groups[i].routineIDs.append(id) }
continue
}
groups.append(SettingsGroup(
label: exercise.split?.name,
label: exercise.routine?.name,
settings: settings,
splitIDs: exercise.split.map { [$0.id] } ?? []
routineIDs: exercise.routine.map { [$0.id] } ?? []
))
}
if groups.count == 1 { groups[0].label = nil }
return groups
}
/// Every split containing this exercise the write targets when settings are
/// Every routine containing this exercise the write targets when settings are
/// recorded here for the first time.
private var containingSplitIDs: [String] {
exercises.compactMap { $0.split?.id }
private var containingRoutineIDs: [String] {
exercises.compactMap { $0.routine?.id }
}
var body: some View {
@@ -103,10 +103,10 @@ struct ExerciseLibraryDetailView: View {
MachineSettingsCard(
groups: [],
onEdit: edit(group:),
// Recording needs a split exercise to write to without
// Recording needs a routine exercise to write to without
// one the card explains instead of offering a dead button.
onAdd: containingSplitIDs.isEmpty ? nil : {
settingsEdit = SettingsEditRoute(initial: [], targetSplitIDs: containingSplitIDs)
onAdd: containingRoutineIDs.isEmpty ? nil : {
settingsEdit = SettingsEditRoute(initial: [], targetRoutineIDs: containingRoutineIDs)
}
)
}
@@ -165,10 +165,10 @@ struct ExerciseLibraryDetailView: View {
.onDisappear { services.speechAnnouncer.stop() }
.sheet(item: $settingsEdit) { route in
MachineSettingsSheet(exerciseName: exerciseName, initialSettings: route.initial) { updated in
let targets = route.targetSplitIDs
let targets = route.targetRoutineIDs
Task {
for splitID in targets {
await sync.writeBackMachineSettings(updated, exerciseName: exerciseName, splitID: splitID)
for routineID in targets {
await sync.writeBackMachineSettings(updated, exerciseName: exerciseName, routineID: routineID)
}
}
}
@@ -176,29 +176,29 @@ struct ExerciseLibraryDetailView: View {
}
private func edit(group: SettingsGroup) {
settingsEdit = SettingsEditRoute(initial: group.settings, targetSplitIDs: group.splitIDs)
settingsEdit = SettingsEditRoute(initial: group.settings, targetRoutineIDs: group.routineIDs)
}
}
/// One distinct recorded settings list and the splits it came from (the edit targets).
/// One distinct recorded settings list and the routines it came from (the edit targets).
fileprivate struct SettingsGroup {
var label: String?
var settings: [MachineSetting]
var splitIDs: [String]
var routineIDs: [String]
}
/// Sheet route: the settings to seed the editor with and the splits a save writes to.
/// Sheet route: the settings to seed the editor with and the routines a save writes to.
private struct SettingsEditRoute: Identifiable {
let id = UUID()
var initial: [MachineSetting]
var targetSplitIDs: [String]
var targetRoutineIDs: [String]
}
/// The user's recorded machine comfort settings, set apart from the reference text
/// as a card: namevalue rows, grouped per split when the recorded lists differ.
/// as a card: namevalue rows, grouped per routine when the recorded lists differ.
/// Empty `groups` renders the not-yet-recorded state (shown for machine-based
/// exercises so the feature is visible before first use), with an Add button when
/// the exercise lives in at least one split (`onAdd` non-nil). A single group edits
/// the exercise lives in at least one routine (`onAdd` non-nil). A single group edits
/// from the header; disagreeing groups edit per group.
private struct MachineSettingsCard: View {
let groups: [SettingsGroup]
@@ -222,7 +222,7 @@ private struct MachineSettingsCard: View {
if groups.isEmpty {
Text(onAdd != nil
? "None recorded yet — save the seat, pad, and pin positions once and they'll be here next time."
: "None recorded yet. Add this exercise to a split, then record its settings here or from the workout screen.")
: "None recorded yet. Add this exercise to a routine, then record its settings here or from the workout screen.")
.font(.callout)
.foregroundStyle(.secondary)
}