Let routines rename exercises and repeat them for interval segments

An exercise's name is now a per-routine display name: a new optional
libraryName on ExerciseDocument (snapshotted onto WorkoutLogDocument at
plan time) keeps the link to the bundled library exercise, and every
figure/info/cue lookup resolves libraryName ?? name. Deliberately not
schema-bumped — an older app dropping the key only strands the
figure link, same rationale as activityType. Cache schema bumped to 9
for the new columns.

The picker no longer filters out exercises already in the routine
(an "×N" badge marks them instead), exercise rows gain a leading
Duplicate swipe that clones an entry in place, and the edit sheet gets
a Name field with the library exercise shown read-only above it.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
2026-07-12 18:38:14 -04:00
parent d04526826c
commit df5c77eee1
15 changed files with 203 additions and 38 deletions
@@ -23,6 +23,11 @@ struct ExerciseAddEditView: View {
// Local editable state
@State private var exerciseName: String
// The library exercise this row derives from nil only when `exerciseName`
// doesn't (yet) match any bundled exercise. Set alongside `exerciseName` when
// the picker is used; carried through unchanged when the user just retypes the
// name in the TextField, so a rename keeps its figure/info link.
@State private var libraryName: String?
@State private var loadType: LoadType
@State private var minutes: Int
@State private var seconds: Int
@@ -45,6 +50,8 @@ struct ExerciseAddEditView: View {
// shows (and saves back) its whole part until UX #3's UI lands.
let w = Int(exercise.weight)
_exerciseName = State(initialValue: exercise.name)
_libraryName = State(initialValue: exercise.libraryName
?? (ExerciseMotionLibrary.exerciseNames.contains(exercise.name) ? exercise.name : nil))
_loadType = State(initialValue: exercise.loadTypeEnum)
_minutes = State(initialValue: exercise.durationMinutes)
_seconds = State(initialValue: exercise.durationSeconds)
@@ -59,7 +66,10 @@ struct ExerciseAddEditView: View {
var body: some View {
NavigationStack {
Form {
Section(header: Text("Exercise")) {
Section(
header: Text("Exercise"),
footer: Text("You can rename this exercise for this routine — for example \"Warmup Walk\" for Treadmill. The animated figure and exercise guide still come from the original exercise.")
) {
if exerciseName.isEmpty {
Button(action: {
showingExercisePicker = true
@@ -72,7 +82,10 @@ struct ExerciseAddEditView: View {
}
}
} else {
ListItem(title: exerciseName)
if let libraryName {
ListItem(title: libraryName)
}
TextField("Name", text: $exerciseName)
}
}
@@ -172,7 +185,9 @@ struct ExerciseAddEditView: View {
}
.sheet(isPresented: $showingExercisePicker) {
ExercisePickerView { exerciseNames in
exerciseName = exerciseNames.first ?? "Unnamed"
let picked = exerciseNames.first ?? "Unnamed"
exerciseName = picked
libraryName = picked
}
}
.navigationTitle(exerciseName.isEmpty ? "New Exercise" : exerciseName)
@@ -197,9 +212,17 @@ struct ExerciseAddEditView: View {
let newWeight = weightTens + weightOnes
let durationSecs = minutes * 60 + seconds
// An emptied name falls back to the library exercise (or, failing that, the
// prior name) rather than saving a blank display name.
let trimmedName = exerciseName.trimmingCharacters(in: .whitespacesAndNewlines)
let finalName = trimmedName.isEmpty ? (libraryName ?? exercise.name) : trimmedName
var doc = RoutineDocument(from: routine)
if let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) {
doc.exercises[idx].name = exerciseName
doc.exercises[idx].name = finalName
// nil when the name matches the library exercise (unrenamed) keeps an
// unrenamed exercise byte-identical on disk.
doc.exercises[idx].libraryName = (libraryName == finalName) ? nil : libraryName
doc.exercises[idx].sets = sets
doc.exercises[idx].reps = reps
doc.exercises[idx].weight = Double(newWeight)