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
@@ -220,12 +220,17 @@ struct ExerciseProgressView: View {
}?.id
}
/// The next not-yet-resolved log after this one, in plan order the flow hand-off
/// target's full document, so both its display and library names are reachable.
/// Nil when this is the last unfinished exercise.
private var nextLog: WorkoutLogDocument? {
guard let id = nextUnfinishedLogID else { return nil }
return doc.logs.first { $0.id == id }
}
/// Name of the next not-yet-resolved exercise (the flow hand-off target), or nil when
/// this is the last one. Drives the between-exercise rest's "Coming up " preview.
private var nextExerciseName: String? {
guard let id = nextUnfinishedLogID else { return nil }
return doc.logs.first { $0.id == id }?.exerciseName
}
private var nextExerciseName: String? { nextLog?.exerciseName }
/// The between-exercise rest flow mode's terminal page when another exercise follows.
/// This is the only rest whose "next" is a *different* exercise, so it's the one that
@@ -235,12 +240,22 @@ struct ExerciseProgressView: View {
}
/// Which exercise the bottom-half figure should show: the *next* one while resting
/// between exercises (so you see what's coming up), otherwise this exercise.
/// between exercises (so you see what's coming up), otherwise this exercise. This
/// is the display name (the headline); `figureLibraryExerciseName` is its figure/
/// info counterpart.
private var figureExerciseName: String {
if isOnBetweenExerciseRest, let next = nextExerciseName { return next }
return log?.exerciseName ?? ""
}
/// The library name to resolve the bottom-half figure against the same
/// this-exercise-or-next-during-rest choice as `figureExerciseName`, but the
/// library name the animation/info actually key on.
private var figureLibraryExerciseName: String {
if isOnBetweenExerciseRest, let next = nextLog { return next.libraryExerciseName }
return log?.libraryExerciseName ?? ""
}
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
private var base: Int { showsReady ? 1 : 0 }
@@ -333,21 +348,22 @@ struct ExerciseProgressView: View {
}
/// The figure half of the split, with the big-type exercise headline above the
/// figure in landscape.
/// figure in landscape. `display` names the headline; `figure` is the library
/// name the animated rig is keyed on they diverge once an exercise is renamed.
@ViewBuilder
private func figureHalf(_ name: String) -> some View {
private func figureHalf(display: String, figure: String) -> some View {
if isLandscape {
VStack(spacing: 0) {
Text(name)
Text(display)
.font(.system(size: landscapeNameFontSize, weight: .bold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.5)
.padding(.horizontal)
.padding(.top, 8)
ExerciseFigureSlot(exerciseName: name)
ExerciseFigureSlot(exerciseName: figure)
}
} else {
ExerciseFigureSlot(exerciseName: name)
ExerciseFigureSlot(exerciseName: figure)
}
}
@@ -358,14 +374,14 @@ struct ExerciseProgressView: View {
// the paged flow would be, so the form-guide figure stays on screen.
splitLayout {
CompletedPhaseView(log: log)
figureHalf(log?.exerciseName ?? "")
figureHalf(display: log?.exerciseName ?? "", figure: log?.libraryExerciseName ?? "")
}
.navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline)
} else if startsSkipped {
splitLayout {
SkippedPhaseView()
figureHalf(log?.exerciseName ?? "")
figureHalf(display: log?.exerciseName ?? "", figure: log?.libraryExerciseName ?? "")
}
.navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline)
@@ -403,7 +419,7 @@ struct ExerciseProgressView: View {
// resting between exercises (a live preview of what's coming up), otherwise this
// one. The name swap re-loads the figure and carries through the hand-off, so it
// stays put as the next exercise takes over.
figureHalf(figureExerciseName)
figureHalf(display: figureExerciseName, figure: figureLibraryExerciseName)
}
.navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline)
@@ -839,7 +855,7 @@ struct ExerciseProgressView: View {
private func announceCue() {
guard speakExerciseCues, spokenCueStyle.readsInstructions,
let announcer = speechAnnouncer, let log else { return }
guard let info = ExerciseInfoLibrary.info(for: log.exerciseName) else { return }
guard let info = ExerciseInfoLibrary.info(for: log.libraryExerciseName) else { return }
announcer.speak(info.spokenScript(name: log.exerciseName, brief: true))
}
@@ -119,7 +119,7 @@ struct ExerciseView: View {
// any exercise the authored library identifies as machine-based, so the
// section is discoverable before settings are first recorded.
if log.machineSettings != nil
|| ExerciseInfoLibrary.info(for: log.exerciseName)?.isMachineBased == true {
|| ExerciseInfoLibrary.info(for: log.libraryExerciseName)?.isMachineBased == true {
let settings = log.machineSettings ?? []
Section {
if settings.isEmpty {
@@ -365,7 +365,7 @@ struct WorkoutLogListView: View {
id: ULID.make(), name: name, order: doc.logs.count,
sets: recent.sets, reps: recent.reps, weight: recent.weight,
loadType: recent.loadType, durationSeconds: recent.durationTotalSeconds,
machineSettings: recent.machineSettings
machineSettings: recent.machineSettings, libraryName: recent.libraryName
)
}
let info = ExerciseInfoLibrary.info(for: name)