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
+4
View File
@@ -1,5 +1,9 @@
**July 2026** **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. 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. Exercise figures no longer glitch mid-rep — bars, hands, and feet stay planted instead of snapping or windmilling.
+3 -1
View File
@@ -14,7 +14,9 @@ your own iCloud Drive.
(Upper Body / Core / Lower Body machine routines, plus an equipment-free (Upper Body / Core / Lower Body machine routines, plus an equipment-free
**Bodyweight Core** circuit with its own warm-up) appear automatically on first **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 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 - **Exercise library** — a bundled library of 67 exercises to populate your
splits, authored in `Exercise Library/` with per-exercise setup, cues, splits, authored in `Exercise Library/` with per-exercise setup, cues,
mistakes, progressions, and an anatomically-rigged 3D stick-figure visual mistakes, progressions, and an anatomically-rigged 3D stick-figure visual
+2
View File
@@ -56,6 +56,7 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by
| `loadType` | Int | | Raw `LoadType` | | `loadType` | Int | | Raw `LoadType` |
| `durationSeconds` | Int | | Total seconds; 0 when not a timed exercise | | `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 | | `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` ## 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* | | `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 | | `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 | | `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) ## MachineSetting (embedded in ExerciseDocument and WorkoutLogDocument)
+24 -1
View File
@@ -71,6 +71,19 @@ struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
/// empty a machine exercise with nothing recorded yet. This optionality /// empty a machine exercise with nothing recorded yet. This optionality
/// doubles as the machine-based flag there is no separate Bool. /// doubles as the machine-based flag there is no separate Bool.
var machineSettings: [MachineSetting]? = nil 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 /// 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; /// on two devices merges by the newer `updatedAt`). Nothing writes it yet;
/// absent in older files. /// absent in older files.
var updatedAt: Date? = nil 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 { 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 /// 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 /// fields (sets/reps/weight/duration) *and* the machine comfort settings, so a
/// running workout carries the exercise's settings as its own editable copy /// running workout carries the exercise's settings as its own editable copy
@@ -274,7 +296,8 @@ extension WorkoutLogDocument {
status: WorkoutStatus.notStarted.rawValue, status: WorkoutStatus.notStarted.rawValue,
notes: nil, notes: nil,
date: date, date: date,
machineSettings: exercise.machineSettings machineSettings: exercise.machineSettings,
libraryName: exercise.libraryName
) )
} }
+18 -2
View File
@@ -80,11 +80,15 @@ final class Exercise {
// `Workout` stores `hrZoneSeconds` as a plain `[Double]?`. `nil` not a // `Workout` stores `hrZoneSeconds` as a plain `[Double]?`. `nil` not a
// machine exercise; empty a machine exercise with nothing recorded yet. // machine exercise; empty a machine exercise with nothing recorded yet.
var machineSettings: [MachineSetting]? 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? var routine: Routine?
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Double, 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.id = id
self.name = name self.name = name
self.order = order self.order = order
@@ -94,6 +98,7 @@ final class Exercise {
self.loadType = loadType self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds self.durationTotalSeconds = durationTotalSeconds
self.machineSettings = machineSettings self.machineSettings = machineSettings
self.libraryName = libraryName
} }
var loadTypeEnum: LoadType { var loadTypeEnum: LoadType {
@@ -101,6 +106,9 @@ final class Exercise {
set { loadType = newValue.rawValue } 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 /// 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 /// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones
/// keep the full "3 × 10 × 40 lb". /// 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 // Mirrors the document's per-log `updatedAt` (named to dodge a future
// SwiftData column collision). Reserved for H1's per-log merge; unused. // SwiftData column collision). Reserved for H1's per-log merge; unused.
var logUpdatedAt: Date? var logUpdatedAt: Date?
// Plan-time snapshot of the exercise's `libraryName`. Mirrors
// `WorkoutLogDocument.libraryName`.
var libraryName: String?
var workout: Workout? var workout: Workout?
@@ -278,7 +289,8 @@ final class WorkoutLog {
statusRaw: String, notes: String?, date: Date, statusRaw: String, notes: String?, date: Date,
machineSettings: [MachineSetting]? = nil, machineSettings: [MachineSetting]? = nil,
startedAt: Date? = nil, completedAt: Date? = 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.id = id
self.exerciseName = exerciseName self.exerciseName = exerciseName
self.order = order self.order = order
@@ -296,6 +308,7 @@ final class WorkoutLog {
self.completedAt = completedAt self.completedAt = completedAt
self.setEntries = setEntries self.setEntries = setEntries
self.logUpdatedAt = logUpdatedAt self.logUpdatedAt = logUpdatedAt
self.libraryName = libraryName
} }
var status: WorkoutStatus { var status: WorkoutStatus {
@@ -308,6 +321,9 @@ final class WorkoutLog {
set { loadType = newValue.rawValue } set { loadType = newValue.rawValue }
} }
/// The name to resolve figure/info/cues against see `ExerciseDocument.libraryExerciseName`.
var libraryExerciseName: String { libraryName ?? exerciseName }
var durationMinutes: Int { var durationMinutes: Int {
get { durationTotalSeconds / 60 } get { durationTotalSeconds / 60 }
set { durationTotalSeconds = newValue * 60 + durationSeconds } set { durationTotalSeconds = newValue * 60 + durationSeconds }
+7 -4
View File
@@ -17,7 +17,7 @@ extension ExerciseDocument {
init(from e: Exercise) { init(from e: Exercise) {
self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps, 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, 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, status: log.statusRaw, notes: log.notes, date: log.date,
machineSettings: log.machineSettings, machineSettings: log.machineSettings,
startedAt: log.startedAt, completedAt: log.completedAt, 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 { 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, 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, loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
machineSettings: d.machineSettings) machineSettings: d.machineSettings, libraryName: d.libraryName)
} }
private static func apply(_ d: ExerciseDocument, to e: Exercise) { private static func apply(_ d: ExerciseDocument, to e: Exercise) {
@@ -176,6 +177,7 @@ enum CacheMapper {
e.loadType = d.loadType e.loadType = d.loadType
e.durationTotalSeconds = d.durationSeconds e.durationTotalSeconds = d.durationSeconds
e.machineSettings = d.machineSettings e.machineSettings = d.machineSettings
e.libraryName = d.libraryName
} }
// MARK: Workout // MARK: Workout
@@ -228,7 +230,7 @@ enum CacheMapper {
currentStateIndex: d.currentStateIndex, statusRaw: d.status, currentStateIndex: d.currentStateIndex, statusRaw: d.status,
notes: d.notes, date: d.date, machineSettings: d.machineSettings, notes: d.notes, date: d.date, machineSettings: d.machineSettings,
startedAt: d.startedAt, completedAt: d.completedAt, 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) { private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) {
@@ -248,6 +250,7 @@ enum CacheMapper {
l.completedAt = d.completedAt l.completedAt = d.completedAt
l.setEntries = d.setEntries l.setEntries = d.setEntries
l.logUpdatedAt = d.updatedAt l.logUpdatedAt = d.updatedAt
l.libraryName = d.libraryName
} }
// MARK: Schedule // MARK: Schedule
@@ -15,7 +15,8 @@ enum WorkoutsModelContainer {
/// 6: added `deletedLogIDs` to `Workout` (per-log merge deletion tombstones). /// 6: added `deletedLogIDs` to `Workout` (per-log merge deletion tombstones).
/// 7: renamed `Split` entity to `Routine`. /// 7: renamed `Split` entity to `Routine`.
/// 8: added `Schedule` cache entity. /// 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 schemaVersionKey = "Workouts.persistenceSchemaVersion"
private static let identityTokenKey = "Workouts.iCloudIdentityToken" private static let identityTokenKey = "Workouts.iCloudIdentityToken"
@@ -166,7 +166,8 @@ struct WorkoutLogListView: View {
currentStateIndex: 0, currentStateIndex: 0,
status: WorkoutStatus.notStarted.rawValue, status: WorkoutStatus.notStarted.rawValue,
notes: nil, notes: nil,
date: doc.start date: doc.start,
libraryName: exercise.libraryName
) )
newLog.touch() // a fresh log carries a definite creation time for the merge newLog.touch() // a fresh log carries a definite creation time for the merge
doc.logs.append(newLog) doc.logs.append(newLog)
@@ -23,6 +23,11 @@ struct ExerciseAddEditView: View {
// Local editable state // Local editable state
@State private var exerciseName: String @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 loadType: LoadType
@State private var minutes: Int @State private var minutes: Int
@State private var seconds: 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. // shows (and saves back) its whole part until UX #3's UI lands.
let w = Int(exercise.weight) let w = Int(exercise.weight)
_exerciseName = State(initialValue: exercise.name) _exerciseName = State(initialValue: exercise.name)
_libraryName = State(initialValue: exercise.libraryName
?? (ExerciseMotionLibrary.exerciseNames.contains(exercise.name) ? exercise.name : nil))
_loadType = State(initialValue: exercise.loadTypeEnum) _loadType = State(initialValue: exercise.loadTypeEnum)
_minutes = State(initialValue: exercise.durationMinutes) _minutes = State(initialValue: exercise.durationMinutes)
_seconds = State(initialValue: exercise.durationSeconds) _seconds = State(initialValue: exercise.durationSeconds)
@@ -59,7 +66,10 @@ struct ExerciseAddEditView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { 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 { if exerciseName.isEmpty {
Button(action: { Button(action: {
showingExercisePicker = true showingExercisePicker = true
@@ -72,7 +82,10 @@ struct ExerciseAddEditView: View {
} }
} }
} else { } else {
ListItem(title: exerciseName) if let libraryName {
ListItem(title: libraryName)
}
TextField("Name", text: $exerciseName)
} }
} }
@@ -172,7 +185,9 @@ struct ExerciseAddEditView: View {
} }
.sheet(isPresented: $showingExercisePicker) { .sheet(isPresented: $showingExercisePicker) {
ExercisePickerView { exerciseNames in ExercisePickerView { exerciseNames in
exerciseName = exerciseNames.first ?? "Unnamed" let picked = exerciseNames.first ?? "Unnamed"
exerciseName = picked
libraryName = picked
} }
} }
.navigationTitle(exerciseName.isEmpty ? "New Exercise" : exerciseName) .navigationTitle(exerciseName.isEmpty ? "New Exercise" : exerciseName)
@@ -197,9 +212,17 @@ struct ExerciseAddEditView: View {
let newWeight = weightTens + weightOnes let newWeight = weightTens + weightOnes
let durationSecs = minutes * 60 + seconds 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) var doc = RoutineDocument(from: routine)
if let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) { 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].sets = sets
doc.exercises[idx].reps = reps doc.exercises[idx].reps = reps
doc.exercises[idx].weight = Double(newWeight) doc.exercises[idx].weight = Double(newWeight)
@@ -57,6 +57,14 @@ struct ExerciseListView: View {
title: item.name, title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(weightUnit.format(item.weight))" 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 { .swipeActions {
Button { Button {
itemToDelete = item itemToDelete = item
@@ -90,7 +98,11 @@ struct ExerciseListView: View {
.sheet(isPresented: $showingAddSheet) { .sheet(isPresented: $showingAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames) addExercises(names: exerciseNames)
}, allowMultiSelect: true) }, allowMultiSelect: true,
inRoutineCounts: Dictionary(
routine.exercisesArray.map { ($0.libraryExerciseName, 1) },
uniquingKeysWith: +
))
} }
.sheet(item: $itemToEdit) { item in .sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item, routine: routine) ExerciseAddEditView(exercise: item, routine: routine)
@@ -135,10 +147,8 @@ struct ExerciseListView: View {
private func addExercises(names: [String]) { private func addExercises(names: [String]) {
guard let routine else { return } guard let routine else { return }
var doc = RoutineDocument(from: routine) var doc = RoutineDocument(from: routine)
let existingNames = Set(doc.exercises.map { $0.name })
let base = doc.exercises.count let base = doc.exercises.count
let newDocs = names let newDocs = names
.filter { !existingNames.contains($0) }
.enumerated() .enumerated()
.map { i, exName -> ExerciseDocument in .map { i, exName -> ExerciseDocument in
// Seed from the library's authored `**Defaults:**` when available, // Seed from the library's authored `**Defaults:**` when available,
@@ -167,4 +177,22 @@ struct ExerciseListView: View {
doc.updatedAt = Date() doc.updatedAt = Date()
Task { await sync.save(routine: doc) } 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 onExerciseSelected: ([String]) -> Void
var allowMultiSelect: Bool = false 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.onExerciseSelected = onExerciseSelected
self.allowMultiSelect = allowMultiSelect self.allowMultiSelect = allowMultiSelect
self.inRoutineCounts = inRoutineCounts
} }
var body: some View { var body: some View {
@@ -35,6 +42,10 @@ struct ExercisePickerView: View {
HStack { HStack {
Text(exerciseName) Text(exerciseName)
Spacer() Spacer()
if let count = inRoutineCounts[exerciseName], count > 0 {
Text("×\(count)")
.foregroundStyle(.secondary)
}
if selectedExercises.contains(exerciseName) { if selectedExercises.contains(exerciseName) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
@@ -46,7 +57,14 @@ struct ExercisePickerView: View {
onExerciseSelected([exerciseName]) onExerciseSelected([exerciseName])
dismiss() 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, title: item.name,
subtitle: item.planSummary(weightUnit: weightUnit) subtitle: item.planSummary(weightUnit: weightUnit)
) )
.swipeActions(edge: .leading) {
Button {
duplicateExercise(item)
} label: {
Label("Duplicate", systemImage: "plus.square.on.square")
}
.tint(.teal)
}
.swipeActions { .swipeActions {
Button { Button {
itemToDelete = item itemToDelete = item
@@ -128,7 +136,11 @@ struct RoutineDetailView: View {
.sheet(isPresented: $showingExerciseAddSheet) { .sheet(isPresented: $showingExerciseAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames) addExercises(names: exerciseNames)
}, allowMultiSelect: true) }, allowMultiSelect: true,
inRoutineCounts: Dictionary(
routine.exercisesArray.map { ($0.libraryExerciseName, 1) },
uniquingKeysWith: +
))
} }
.sheet(isPresented: $showingRoutineEditSheet) { .sheet(isPresented: $showingRoutineEditSheet) {
RoutineAddEditView(routine: routine) { RoutineAddEditView(routine: routine) {
@@ -179,10 +191,8 @@ struct RoutineDetailView: View {
private func addExercises(names: [String]) { private func addExercises(names: [String]) {
guard let routine else { return } guard let routine else { return }
var doc = RoutineDocument(from: routine) var doc = RoutineDocument(from: routine)
let existingNames = Set(doc.exercises.map { $0.name })
let base = doc.exercises.count let base = doc.exercises.count
let newDocs = names let newDocs = names
.filter { !existingNames.contains($0) }
.enumerated() .enumerated()
.map { i, exName -> ExerciseDocument in .map { i, exName -> ExerciseDocument in
// Seed from the library's authored `**Defaults:**` when available, // Seed from the library's authored `**Defaults:**` when available,
@@ -215,4 +225,22 @@ struct RoutineDetailView: View {
doc.updatedAt = Date() doc.updatedAt = Date()
Task { await sync.save(routine: doc) } 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 }?.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 /// 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. /// this is the last one. Drives the between-exercise rest's "Coming up " preview.
private var nextExerciseName: String? { private var nextExerciseName: String? { nextLog?.exerciseName }
guard let id = nextUnfinishedLogID else { return nil }
return doc.logs.first { $0.id == id }?.exerciseName
}
/// The between-exercise rest flow mode's terminal page when another exercise follows. /// 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 /// 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 /// 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 { private var figureExerciseName: String {
if isOnBetweenExerciseRest, let next = nextExerciseName { return next } if isOnBetweenExerciseRest, let next = nextExerciseName { return next }
return log?.exerciseName ?? "" 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`. /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
private var base: Int { showsReady ? 1 : 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 /// 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 @ViewBuilder
private func figureHalf(_ name: String) -> some View { private func figureHalf(display: String, figure: String) -> some View {
if isLandscape { if isLandscape {
VStack(spacing: 0) { VStack(spacing: 0) {
Text(name) Text(display)
.font(.system(size: landscapeNameFontSize, weight: .bold, design: .rounded)) .font(.system(size: landscapeNameFontSize, weight: .bold, design: .rounded))
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.5) .minimumScaleFactor(0.5)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 8) .padding(.top, 8)
ExerciseFigureSlot(exerciseName: name) ExerciseFigureSlot(exerciseName: figure)
} }
} else { } 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. // the paged flow would be, so the form-guide figure stays on screen.
splitLayout { splitLayout {
CompletedPhaseView(log: log) CompletedPhaseView(log: log)
figureHalf(log?.exerciseName ?? "") figureHalf(display: log?.exerciseName ?? "", figure: log?.libraryExerciseName ?? "")
} }
.navigationTitle(navBarTitle) .navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} else if startsSkipped { } else if startsSkipped {
splitLayout { splitLayout {
SkippedPhaseView() SkippedPhaseView()
figureHalf(log?.exerciseName ?? "") figureHalf(display: log?.exerciseName ?? "", figure: log?.libraryExerciseName ?? "")
} }
.navigationTitle(navBarTitle) .navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -403,7 +419,7 @@ struct ExerciseProgressView: View {
// resting between exercises (a live preview of what's coming up), otherwise this // 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 // one. The name swap re-loads the figure and carries through the hand-off, so it
// stays put as the next exercise takes over. // stays put as the next exercise takes over.
figureHalf(figureExerciseName) figureHalf(display: figureExerciseName, figure: figureLibraryExerciseName)
} }
.navigationTitle(navBarTitle) .navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -839,7 +855,7 @@ struct ExerciseProgressView: View {
private func announceCue() { private func announceCue() {
guard speakExerciseCues, spokenCueStyle.readsInstructions, guard speakExerciseCues, spokenCueStyle.readsInstructions,
let announcer = speechAnnouncer, let log else { return } 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)) 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 // any exercise the authored library identifies as machine-based, so the
// section is discoverable before settings are first recorded. // section is discoverable before settings are first recorded.
if log.machineSettings != nil if log.machineSettings != nil
|| ExerciseInfoLibrary.info(for: log.exerciseName)?.isMachineBased == true { || ExerciseInfoLibrary.info(for: log.libraryExerciseName)?.isMachineBased == true {
let settings = log.machineSettings ?? [] let settings = log.machineSettings ?? []
Section { Section {
if settings.isEmpty { if settings.isEmpty {
@@ -365,7 +365,7 @@ struct WorkoutLogListView: View {
id: ULID.make(), name: name, order: doc.logs.count, id: ULID.make(), name: name, order: doc.logs.count,
sets: recent.sets, reps: recent.reps, weight: recent.weight, sets: recent.sets, reps: recent.reps, weight: recent.weight,
loadType: recent.loadType, durationSeconds: recent.durationTotalSeconds, loadType: recent.loadType, durationSeconds: recent.durationTotalSeconds,
machineSettings: recent.machineSettings machineSettings: recent.machineSettings, libraryName: recent.libraryName
) )
} }
let info = ExerciseInfoLibrary.info(for: name) let info = ExerciseInfoLibrary.info(for: name)