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:
@@ -18,8 +18,8 @@ struct ExerciseAddEditView: View {
|
||||
|
||||
// The exercise entity provides initial values (read-only).
|
||||
let exercise: Exercise
|
||||
// The parent split is needed to rebuild and save the SplitDocument.
|
||||
let split: Split
|
||||
// The parent routine is needed to rebuild and save the RoutineDocument.
|
||||
let routine: Routine
|
||||
|
||||
// Local editable state
|
||||
@State private var exerciseName: String
|
||||
@@ -37,9 +37,9 @@ struct ExerciseAddEditView: View {
|
||||
@State private var machineSettings: [MachineSetting]
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
init(exercise: Exercise, split: Split) {
|
||||
init(exercise: Exercise, routine: Routine) {
|
||||
self.exercise = exercise
|
||||
self.split = split
|
||||
self.routine = routine
|
||||
|
||||
// The tens/ones pickers step whole values; a fractional stored weight
|
||||
// shows (and saves back) its whole part until UX #3's UI lands.
|
||||
@@ -197,7 +197,7 @@ struct ExerciseAddEditView: View {
|
||||
let newWeight = weightTens + weightOnes
|
||||
let durationSecs = minutes * 60 + seconds
|
||||
|
||||
var doc = SplitDocument(from: split)
|
||||
var doc = RoutineDocument(from: routine)
|
||||
if let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) {
|
||||
doc.exercises[idx].name = exerciseName
|
||||
doc.exercises[idx].sets = sets
|
||||
@@ -209,6 +209,6 @@ struct ExerciseAddEditView: View {
|
||||
doc.exercises[idx].machineSettings = machineEnabled ? machineSettings : nil
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
Task { await sync.save(routine: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: name–value rows, grouped per split when the recorded lists differ.
|
||||
/// as a card: name–value 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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user