diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ffb32..71dfe59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ **July 2026** +A routine can now include the same exercise more than once — swipe right on an exercise to duplicate it for interval-style segments. + +Exercises can now be renamed per routine, like "Warmup Walk" for a treadmill segment, while their animated figure and guide still follow the original exercise. + A new Running exercise joins the library with its own animated stride figure. Exercise figures no longer glitch mid-rep — bars, hands, and feet stay planted instead of snapping or windmilling. diff --git a/README.md b/README.md index 1c11b99..e39f047 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ your own iCloud Drive. (Upper Body / Core / Lower Body machine routines, plus an equipment-free **Bodyweight Core** circuit with its own warm-up) appear automatically on first launch; editing one makes it your own copy, and deleted starters can be restored - from Settings. + from Settings. Any exercise can be duplicated and renamed for this routine only + (its figure and guide still come from the original) — build an interval routine + like a treadmill's Warmup / Brisk Walk / Cooldown from one library exercise. - **Exercise library** — a bundled library of 67 exercises to populate your splits, authored in `Exercise Library/` with per-exercise setup, cues, mistakes, progressions, and an anatomically-rigged 3D stick-figure visual diff --git a/SCHEMA.md b/SCHEMA.md index 4df17f7..6a1d579 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -56,6 +56,7 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by | `loadType` | Int | | Raw `LoadType` | | `durationSeconds` | Int | | Total seconds; 0 when not a timed exercise | | `machineSettings` | [MachineSetting] | ✓ | Ordered machine comfort settings; nil → not a machine exercise, empty → machine exercise with nothing recorded. Doubles as the machine-based flag | +| `libraryName` | String | ✓ | The library exercise `name` was customized from (e.g. one Treadmill segment renamed "Brisk Walk 10 min"); nil → `name` IS the library name. Figure/info/cue lookups resolve `libraryName ?? name`, never `name` directly. *Not schema-bumped* | ## WorkoutDocument — `currentSchemaVersion: 5` @@ -98,6 +99,7 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by | `completedAt` | Date | ✓ | Last move to `.completed`. *Not schema-bumped* | | `setEntries` | [SetEntry] | ✓ | Per-set actuals in set order; nil → legacy file / nothing recorded. Appended as sets complete (pre-filled from the plan); `transition(to:)` fills the missing tail on `.completed` and clears on `.notStarted`; `.skipped` keeps partials. Added in schema v4 | | `updatedAt` | Date | ✓ | Per-log modification time driving the per-log merge (`WorkoutMergePlanner`); stamped by `transition(to:)` on status flips and `touch()` at content-only edit sites. Nil in files written before v5. Added (unused) in schema v4, written since v5 | +| `libraryName` | String | ✓ | Plan-time snapshot of the exercise's `libraryName` (like sets/reps/weight); nil → `exerciseName` IS the library name. *Not schema-bumped* | ## MachineSetting (embedded in ExerciseDocument and WorkoutLogDocument) diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift index b3c7fc9..ba4aec7 100644 --- a/Shared/Model/Documents.swift +++ b/Shared/Model/Documents.swift @@ -71,6 +71,19 @@ struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable { /// empty → a machine exercise with nothing recorded yet. This optionality /// doubles as the machine-based flag — there is no separate Bool. var machineSettings: [MachineSetting]? = nil + /// The library exercise `name` was customized from (e.g. "Treadmill" renamed to + /// "Brisk Walk 10 min" for one segment of an interval routine); nil → `name` IS + /// the library name. Optional and deliberately NOT schema-bumped, same rationale + /// as `RoutineDocument.activityType`: an older app dropping it on rewrite only + /// strands the figure/info link for the renamed exercise, which is preferable to + /// quarantining the user's whole routine. + var libraryName: String? = nil +} + +extension ExerciseDocument { + /// The name to resolve figure/info/cues against — `name` itself unless it was + /// customized, in which case the library exercise it derives from. + var libraryExerciseName: String { libraryName ?? name } } /// A single free-form comfort setting for a machine-based exercise. `value` is a @@ -251,9 +264,18 @@ struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable { /// on two devices merges by the newer `updatedAt`). Nothing writes it yet; /// absent in older files. var updatedAt: Date? = nil + /// Plan-time snapshot of the exercise's `libraryName` (like sets/reps/weight). + /// Optional and deliberately NOT schema-bumped, same rationale as + /// `RoutineDocument.activityType`: an older app dropping it on rewrite only + /// strands the figure/info link for a renamed exercise, which is preferable to + /// quarantining the whole workout. + var libraryName: String? = nil } extension WorkoutLogDocument { + /// The name to resolve figure/info/cues against — see `ExerciseDocument.libraryExerciseName`. + var libraryExerciseName: String { libraryName ?? exerciseName } + /// Build a fresh plan-time log from a routine's exercise. Snapshots the plan /// fields (sets/reps/weight/duration) *and* the machine comfort settings, so a /// running workout carries the exercise's settings as its own editable copy — @@ -274,7 +296,8 @@ extension WorkoutLogDocument { status: WorkoutStatus.notStarted.rawValue, notes: nil, date: date, - machineSettings: exercise.machineSettings + machineSettings: exercise.machineSettings, + libraryName: exercise.libraryName ) } diff --git a/Shared/Model/Entities.swift b/Shared/Model/Entities.swift index e4fd1b1..a001521 100644 --- a/Shared/Model/Entities.swift +++ b/Shared/Model/Entities.swift @@ -80,11 +80,15 @@ final class Exercise { // `Workout` stores `hrZoneSeconds` as a plain `[Double]?`. `nil` → not a // machine exercise; empty → a machine exercise with nothing recorded yet. var machineSettings: [MachineSetting]? + // The library exercise `name` was customized from; nil → `name` IS the + // library name. Mirrors `ExerciseDocument.libraryName`. + var libraryName: String? var routine: Routine? init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Double, - loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil) { + loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil, + libraryName: String? = nil) { self.id = id self.name = name self.order = order @@ -94,6 +98,7 @@ final class Exercise { self.loadType = loadType self.durationTotalSeconds = durationTotalSeconds self.machineSettings = machineSettings + self.libraryName = libraryName } var loadTypeEnum: LoadType { @@ -101,6 +106,9 @@ final class Exercise { set { loadType = newValue.rawValue } } + /// The name to resolve figure/info/cues against — see `ExerciseDocument.libraryExerciseName`. + var libraryExerciseName: String { libraryName ?? name } + /// One-line plan summary for list rows — duration exercises show their hold time /// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones /// keep the full "3 × 10 × 40 lb". @@ -270,6 +278,9 @@ final class WorkoutLog { // Mirrors the document's per-log `updatedAt` (named to dodge a future // SwiftData column collision). Reserved for H1's per-log merge; unused. var logUpdatedAt: Date? + // Plan-time snapshot of the exercise's `libraryName`. Mirrors + // `WorkoutLogDocument.libraryName`. + var libraryName: String? var workout: Workout? @@ -278,7 +289,8 @@ final class WorkoutLog { statusRaw: String, notes: String?, date: Date, machineSettings: [MachineSetting]? = nil, startedAt: Date? = nil, completedAt: Date? = nil, - setEntries: [SetEntry]? = nil, logUpdatedAt: Date? = nil) { + setEntries: [SetEntry]? = nil, logUpdatedAt: Date? = nil, + libraryName: String? = nil) { self.id = id self.exerciseName = exerciseName self.order = order @@ -296,6 +308,7 @@ final class WorkoutLog { self.completedAt = completedAt self.setEntries = setEntries self.logUpdatedAt = logUpdatedAt + self.libraryName = libraryName } var status: WorkoutStatus { @@ -308,6 +321,9 @@ final class WorkoutLog { set { loadType = newValue.rawValue } } + /// The name to resolve figure/info/cues against — see `ExerciseDocument.libraryExerciseName`. + var libraryExerciseName: String { libraryName ?? exerciseName } + var durationMinutes: Int { get { durationTotalSeconds / 60 } set { durationTotalSeconds = newValue * 60 + durationSeconds } diff --git a/Shared/Model/Mappers.swift b/Shared/Model/Mappers.swift index 224cb4c..920b967 100644 --- a/Shared/Model/Mappers.swift +++ b/Shared/Model/Mappers.swift @@ -17,7 +17,7 @@ extension ExerciseDocument { init(from e: Exercise) { self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps, weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds, - machineSettings: e.machineSettings) + machineSettings: e.machineSettings, libraryName: e.libraryName) } } @@ -41,7 +41,8 @@ extension WorkoutLogDocument { status: log.statusRaw, notes: log.notes, date: log.date, machineSettings: log.machineSettings, startedAt: log.startedAt, completedAt: log.completedAt, - setEntries: log.setEntries, updatedAt: log.logUpdatedAt) + setEntries: log.setEntries, updatedAt: log.logUpdatedAt, + libraryName: log.libraryName) } } @@ -164,7 +165,7 @@ enum CacheMapper { private static func makeExercise(_ d: ExerciseDocument) -> Exercise { Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds, - machineSettings: d.machineSettings) + machineSettings: d.machineSettings, libraryName: d.libraryName) } private static func apply(_ d: ExerciseDocument, to e: Exercise) { @@ -176,6 +177,7 @@ enum CacheMapper { e.loadType = d.loadType e.durationTotalSeconds = d.durationSeconds e.machineSettings = d.machineSettings + e.libraryName = d.libraryName } // MARK: Workout @@ -228,7 +230,7 @@ enum CacheMapper { currentStateIndex: d.currentStateIndex, statusRaw: d.status, notes: d.notes, date: d.date, machineSettings: d.machineSettings, startedAt: d.startedAt, completedAt: d.completedAt, - setEntries: d.setEntries, logUpdatedAt: d.updatedAt) + setEntries: d.setEntries, logUpdatedAt: d.updatedAt, libraryName: d.libraryName) } private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) { @@ -248,6 +250,7 @@ enum CacheMapper { l.completedAt = d.completedAt l.setEntries = d.setEntries l.logUpdatedAt = d.updatedAt + l.libraryName = d.libraryName } // MARK: Schedule diff --git a/Shared/Persistence/WorkoutsModelContainer.swift b/Shared/Persistence/WorkoutsModelContainer.swift index 81ab4c3..177e442 100644 --- a/Shared/Persistence/WorkoutsModelContainer.swift +++ b/Shared/Persistence/WorkoutsModelContainer.swift @@ -15,7 +15,8 @@ enum WorkoutsModelContainer { /// 6: added `deletedLogIDs` to `Workout` (per-log merge deletion tombstones). /// 7: renamed `Split` entity to `Routine`. /// 8: added `Schedule` cache entity. - static let currentSchemaVersion = 8 + /// 9: added `libraryName` to `Exercise` and `WorkoutLog`. + static let currentSchemaVersion = 9 private static let schemaVersionKey = "Workouts.persistenceSchemaVersion" private static let identityTokenKey = "Workouts.iCloudIdentityToken" diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift index c299ff4..8063178 100644 --- a/Workouts Watch App/Views/WorkoutLogListView.swift +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -166,7 +166,8 @@ struct WorkoutLogListView: View { currentStateIndex: 0, status: WorkoutStatus.notStarted.rawValue, notes: nil, - date: doc.start + date: doc.start, + libraryName: exercise.libraryName ) newLog.touch() // a fresh log carries a definite creation time for the merge doc.logs.append(newLog) diff --git a/Workouts/Views/Exercises/ExerciseAddEditView.swift b/Workouts/Views/Exercises/ExerciseAddEditView.swift index 8d100d9..6ef1374 100644 --- a/Workouts/Views/Exercises/ExerciseAddEditView.swift +++ b/Workouts/Views/Exercises/ExerciseAddEditView.swift @@ -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) diff --git a/Workouts/Views/Exercises/ExerciseListView.swift b/Workouts/Views/Exercises/ExerciseListView.swift index d932d49..1f673ff 100644 --- a/Workouts/Views/Exercises/ExerciseListView.swift +++ b/Workouts/Views/Exercises/ExerciseListView.swift @@ -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) } + } } diff --git a/Workouts/Views/Exercises/ExercisePickerView.swift b/Workouts/Views/Exercises/ExercisePickerView.swift index c6681e2..73af94f 100644 --- a/Workouts/Views/Exercises/ExercisePickerView.swift +++ b/Workouts/Views/Exercises/ExercisePickerView.swift @@ -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) + } + } } } } diff --git a/Workouts/Views/Routines/RoutineDetailView.swift b/Workouts/Views/Routines/RoutineDetailView.swift index 1c8a6ae..803c37f 100644 --- a/Workouts/Views/Routines/RoutineDetailView.swift +++ b/Workouts/Views/Routines/RoutineDetailView.swift @@ -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) } + } } diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index eda62f7..5eb194e 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -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)) } diff --git a/Workouts/Views/WorkoutLogs/ExerciseView.swift b/Workouts/Views/WorkoutLogs/ExerciseView.swift index 8aadf5d..2384abb 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseView.swift @@ -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 { diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index c9ee76a..7875b8e 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -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)