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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user