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