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)
@@ -57,6 +57,14 @@ struct ExerciseListView: View {
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(weightUnit.format(item.weight))"
)
.swipeActions(edge: .leading) {
Button {
duplicateExercise(item)
} label: {
Label("Duplicate", systemImage: "plus.square.on.square")
}
.tint(.teal)
}
.swipeActions {
Button {
itemToDelete = item
@@ -90,7 +98,11 @@ struct ExerciseListView: View {
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
}, allowMultiSelect: true)
}, allowMultiSelect: true,
inRoutineCounts: Dictionary(
routine.exercisesArray.map { ($0.libraryExerciseName, 1) },
uniquingKeysWith: +
))
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item, routine: routine)
@@ -135,10 +147,8 @@ struct ExerciseListView: View {
private func addExercises(names: [String]) {
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
.filter { !existingNames.contains($0) }
.enumerated()
.map { i, exName -> ExerciseDocument in
// Seed from the library's authored `**Defaults:**` when available,
@@ -167,4 +177,22 @@ struct ExerciseListView: View {
doc.updatedAt = Date()
Task { await sync.save(routine: doc) }
}
/// Copy an exercise in place, right after itself the interval-routine case
/// (a treadmill's "Warmup 5 min" / "Brisk Walk 10 min" / segments all derive
/// from the same library exercise). The clone starts out an exact duplicate,
/// plan and any customized name included; renaming it is a follow-up edit.
private func duplicateExercise(_ exercise: Exercise) {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
guard let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) else { return }
var copy = doc.exercises[idx]
copy.id = ULID.make()
doc.exercises.insert(copy, at: idx + 1)
for i in doc.exercises.indices {
doc.exercises[i].order = i
}
doc.updatedAt = Date()
Task { await sync.save(routine: doc) }
}
}
@@ -15,10 +15,17 @@ struct ExercisePickerView: View {
var onExerciseSelected: ([String]) -> Void
var allowMultiSelect: Bool = false
/// How many times each exercise already appears in the routine being edited
/// shown as a "×N" badge so picking it again (for interval-style segments like
/// a treadmill's warmup/brisk/cooldown) doesn't read as a mistake. Keyed by
/// library name. Empty for callers with no notion of "already in this routine".
var inRoutineCounts: [String: Int] = [:]
init(onExerciseSelected: @escaping ([String]) -> Void, allowMultiSelect: Bool = false) {
init(onExerciseSelected: @escaping ([String]) -> Void, allowMultiSelect: Bool = false,
inRoutineCounts: [String: Int] = [:]) {
self.onExerciseSelected = onExerciseSelected
self.allowMultiSelect = allowMultiSelect
self.inRoutineCounts = inRoutineCounts
}
var body: some View {
@@ -35,6 +42,10 @@ struct ExercisePickerView: View {
HStack {
Text(exerciseName)
Spacer()
if let count = inRoutineCounts[exerciseName], count > 0 {
Text("×\(count)")
.foregroundStyle(.secondary)
}
if selectedExercises.contains(exerciseName) {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
@@ -46,7 +57,14 @@ struct ExercisePickerView: View {
onExerciseSelected([exerciseName])
dismiss()
}) {
Text(exerciseName)
HStack {
Text(exerciseName)
Spacer()
if let count = inRoutineCounts[exerciseName], count > 0 {
Text("×\(count)")
.foregroundStyle(.secondary)
}
}
}
}
}
@@ -88,6 +88,14 @@ struct RoutineDetailView: View {
title: item.name,
subtitle: item.planSummary(weightUnit: weightUnit)
)
.swipeActions(edge: .leading) {
Button {
duplicateExercise(item)
} label: {
Label("Duplicate", systemImage: "plus.square.on.square")
}
.tint(.teal)
}
.swipeActions {
Button {
itemToDelete = item
@@ -128,7 +136,11 @@ struct RoutineDetailView: View {
.sheet(isPresented: $showingExerciseAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
}, allowMultiSelect: true)
}, allowMultiSelect: true,
inRoutineCounts: Dictionary(
routine.exercisesArray.map { ($0.libraryExerciseName, 1) },
uniquingKeysWith: +
))
}
.sheet(isPresented: $showingRoutineEditSheet) {
RoutineAddEditView(routine: routine) {
@@ -179,10 +191,8 @@ struct RoutineDetailView: View {
private func addExercises(names: [String]) {
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
.filter { !existingNames.contains($0) }
.enumerated()
.map { i, exName -> ExerciseDocument in
// Seed from the library's authored `**Defaults:**` when available,
@@ -215,4 +225,22 @@ struct RoutineDetailView: View {
doc.updatedAt = Date()
Task { await sync.save(routine: doc) }
}
/// Copy an exercise in place, right after itself the interval-routine case
/// (a treadmill's "Warmup 5 min" / "Brisk Walk 10 min" / segments all derive
/// from the same library exercise). The clone starts out an exact duplicate,
/// plan and any customized name included; renaming it is a follow-up edit.
private func duplicateExercise(_ exercise: Exercise) {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
guard let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) else { return }
var copy = doc.exercises[idx]
copy.id = ULID.make()
doc.exercises.insert(copy, at: idx + 1)
for i in doc.exercises.indices {
doc.exercises[i].order = i
}
doc.updatedAt = Date()
Task { await sync.save(routine: doc) }
}
}
@@ -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)