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
+27 -27
View File
@@ -15,41 +15,41 @@ struct ExerciseListView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
// Resolve the split by id, not a captured entity: editing a seed's exercise from
// Resolve the routine by id, not a captured entity: editing a seed's exercise from
// here clones the seed (new identity, old entity deleted), which would dangle a
// stored `Split`. `currentSplitID` follows that swap.
@State private var splitID: String
@Query private var splits: [Split]
// stored `Routine`. `currentRoutineID` follows that swap.
@State private var routineID: String
@Query private var routines: [Routine]
@State private var showingAddSheet: Bool = false
@State private var itemToEdit: Exercise? = nil
@State private var itemToDelete: Exercise? = nil
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(split: Split) {
_splitID = State(initialValue: split.id)
init(routine: Routine) {
_routineID = State(initialValue: routine.id)
}
private var split: Split? {
let id = sync.currentSplitID(for: splitID)
return splits.first { $0.id == id }
private var routine: Routine? {
let id = sync.currentRoutineID(for: routineID)
return routines.first { $0.id == id }
}
var body: some View {
Group {
if let split {
content(for: split)
if let routine {
content(for: routine)
} else {
ContentUnavailableView("Split Unavailable", systemImage: "dumbbell")
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
.task { dismiss() }
}
}
}
@ViewBuilder
private func content(for split: Split) -> some View {
private func content(for routine: Routine) -> some View {
Form {
let sortedExercises = split.exercisesArray
let sortedExercises = routine.exercisesArray
if !sortedExercises.isEmpty {
ForEach(sortedExercises) { item in
@@ -86,14 +86,14 @@ struct ExerciseListView: View {
}
}
}
.navigationTitle(split.name)
.navigationTitle(routine.name)
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
}, allowMultiSelect: true)
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item, split: split)
ExerciseAddEditView(exercise: item, routine: routine)
}
.confirmationDialog(
"Delete Exercise?",
@@ -112,29 +112,29 @@ struct ExerciseListView: View {
itemToDelete = nil
}
} message: { item in
Text("Remove \"\(item.name)\" from this split?")
Text("Remove \"\(item.name)\" from this routine?")
}
}
// MARK: - Helpers
private func moveExercises(from source: IndexSet, to destination: Int) {
guard let split else { return }
var exercises = split.exercisesArray
guard let routine else { return }
var exercises = routine.exercisesArray
exercises.move(fromOffsets: source, toOffset: destination)
var doc = SplitDocument(from: split)
var doc = RoutineDocument(from: routine)
doc.exercises = exercises.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex)
ed.order = i
return ed
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
Task { await sync.save(routine: doc) }
}
private func addExercises(names: [String]) {
guard let split else { return }
var doc = SplitDocument(from: split)
guard let routine else { return }
var doc = RoutineDocument(from: routine)
let existingNames = Set(doc.exercises.map { $0.name })
let base = doc.exercises.count
let newDocs = names
@@ -154,17 +154,17 @@ struct ExerciseListView: View {
}
doc.exercises.append(contentsOf: newDocs)
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
Task { await sync.save(routine: doc) }
}
private func deleteExercise(_ exercise: Exercise) {
guard let split else { return }
var doc = SplitDocument(from: split)
guard let routine else { return }
var doc = RoutineDocument(from: routine)
doc.exercises.removeAll { $0.id == exercise.id }
for i in doc.exercises.indices {
doc.exercises[i].order = i
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
Task { await sync.save(routine: doc) }
}
}