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