Add library exercises mid-workout with plan defaults
Pick any exercise from the full library while a workout is running, not just the ones in its split. The new exercise's plan is seeded from the most recent log of that exercise, else the library's authored Defaults line, else a plain 3x10. Adds a searchable two-section picker sheet and a **Defaults:** bullet to all 47 library reference pages. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -254,12 +254,16 @@ struct ExerciseListView: View {
|
||||
let newDocs = names
|
||||
.filter { !existingNames.contains($0) }
|
||||
.enumerated()
|
||||
.map { i, exName in
|
||||
ExerciseDocument(
|
||||
.map { i, exName -> ExerciseDocument in
|
||||
// Seed from the library's authored `**Defaults:**` when available,
|
||||
// falling back to a plain 3×10 weighted guess; weight always starts
|
||||
// at 0 (there's no prior lift to seed it from).
|
||||
let defaults = ExerciseInfoLibrary.info(for: exName)?.defaults
|
||||
return ExerciseDocument(
|
||||
id: ULID.make(), name: exName, order: base + i,
|
||||
sets: 3, reps: 10, weight: 40,
|
||||
loadType: LoadType.weight.rawValue,
|
||||
durationSeconds: 0
|
||||
sets: defaults?.sets ?? 3, reps: defaults?.reps ?? 10, weight: 0,
|
||||
loadType: (defaults?.loadType ?? .weight).rawValue,
|
||||
durationSeconds: defaults?.durationSeconds ?? 0
|
||||
)
|
||||
}
|
||||
doc.exercises.append(contentsOf: newDocs)
|
||||
|
||||
@@ -175,12 +175,16 @@ struct SplitDetailView: View {
|
||||
let newDocs = names
|
||||
.filter { !existingNames.contains($0) }
|
||||
.enumerated()
|
||||
.map { i, exName in
|
||||
ExerciseDocument(
|
||||
.map { i, exName -> ExerciseDocument in
|
||||
// Seed from the library's authored `**Defaults:**` when available,
|
||||
// falling back to a plain 3×10 weighted guess; weight always starts
|
||||
// at 0 (there's no prior lift to seed it from).
|
||||
let defaults = ExerciseInfoLibrary.info(for: exName)?.defaults
|
||||
return ExerciseDocument(
|
||||
id: ULID.make(), name: exName, order: base + i,
|
||||
sets: 3, reps: 10, weight: 40,
|
||||
loadType: LoadType.weight.rawValue,
|
||||
durationSeconds: 0
|
||||
sets: defaults?.sets ?? 3, reps: defaults?.reps ?? 10, weight: 0,
|
||||
loadType: (defaults?.loadType ?? .weight).rawValue,
|
||||
durationSeconds: defaults?.durationSeconds ?? 0
|
||||
)
|
||||
}
|
||||
doc.exercises.append(contentsOf: newDocs)
|
||||
|
||||
@@ -182,8 +182,11 @@ struct WorkoutLogListView: View {
|
||||
SplitExercisePickerSheet(
|
||||
split: split,
|
||||
existingExerciseNames: Set(sortedLogs.map { $0.exerciseName })
|
||||
) { exercise in
|
||||
addExerciseFromSplit(exercise)
|
||||
) { selection in
|
||||
switch selection {
|
||||
case .fromSplit(let exercise): addExerciseFromSplit(exercise)
|
||||
case .fromLibrary(let name): addExerciseFromLibrary(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
@@ -305,6 +308,21 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
|
||||
private func addExerciseFromSplit(_ exercise: Exercise) {
|
||||
appendExercise(ExerciseDocument(from: exercise))
|
||||
}
|
||||
|
||||
/// Add an exercise that isn't part of this workout's split, picked from the full
|
||||
/// exercise library. Seeds a plan from, in priority order: the most recent logged
|
||||
/// set of this exercise (any workout), the library's authored `**Defaults:**`, or
|
||||
/// a plain 3×10 fallback when neither exists.
|
||||
private func addExerciseFromLibrary(_ name: String) {
|
||||
appendExercise(libraryPlan(for: name))
|
||||
}
|
||||
|
||||
/// Append a freshly-planned exercise to the working doc, persist, and push
|
||||
/// straight into its progress flow. Shared tail of `addExerciseFromSplit` and
|
||||
/// `addExerciseFromLibrary`.
|
||||
private func appendExercise(_ plan: ExerciseDocument) {
|
||||
let now = Date()
|
||||
|
||||
// Reuse the workout's start time only when it's the very first exercise.
|
||||
@@ -313,7 +331,7 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
doc.end = nil
|
||||
|
||||
let newLog = WorkoutLogDocument(planFrom: ExerciseDocument(from: exercise), order: doc.logs.count, date: now)
|
||||
let newLog = WorkoutLogDocument(planFrom: plan, order: doc.logs.count, date: now)
|
||||
doc.logs.append(newLog)
|
||||
save()
|
||||
|
||||
@@ -321,6 +339,46 @@ struct WorkoutLogListView: View {
|
||||
addedLog = LogRoute(id: newLog.id)
|
||||
}
|
||||
|
||||
/// Plan defaults for a library exercise with no split entry: copy the most recent
|
||||
/// logged set of this exercise (sets/reps/weight/loadType/duration and machine
|
||||
/// settings) if one exists anywhere; otherwise the library's authored defaults
|
||||
/// (weight always starts at 0 — there's no prior lift to copy); otherwise a plain
|
||||
/// 3×10 bodyweight-style fallback.
|
||||
private func libraryPlan(for name: String) -> ExerciseDocument {
|
||||
if let recent = mostRecentLog(named: name) {
|
||||
return ExerciseDocument(
|
||||
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
|
||||
)
|
||||
}
|
||||
let info = ExerciseInfoLibrary.info(for: name)
|
||||
if let defaults = info?.defaults {
|
||||
return ExerciseDocument(
|
||||
id: ULID.make(), name: name, order: doc.logs.count,
|
||||
sets: defaults.sets, reps: defaults.reps, weight: 0,
|
||||
loadType: defaults.loadType.rawValue, durationSeconds: defaults.durationSeconds,
|
||||
machineSettings: info?.isMachineBased == true ? [] : nil
|
||||
)
|
||||
}
|
||||
return ExerciseDocument(
|
||||
id: ULID.make(), name: name, order: doc.logs.count,
|
||||
sets: 3, reps: 10, weight: 0, loadType: LoadType.none.rawValue, durationSeconds: 0
|
||||
)
|
||||
}
|
||||
|
||||
/// Most recent `WorkoutLog` cache entity logged under `name`, across all
|
||||
/// workouts — the best available signal for "what did I actually do last time."
|
||||
private func mostRecentLog(named name: String) -> WorkoutLog? {
|
||||
var descriptor = FetchDescriptor<WorkoutLog>(
|
||||
predicate: #Predicate { $0.exerciseName == name },
|
||||
sortBy: [SortDescriptor(\.date, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Apply edited machine settings from the sheet: update the log's plan-time
|
||||
/// snapshot (persisted with the workout), then write the same settings back to
|
||||
/// the originating split's exercise as the durable default. Sequenced in one
|
||||
@@ -490,57 +548,129 @@ struct SplitExercisePickerSheet: View {
|
||||
|
||||
let split: Split?
|
||||
let existingExerciseNames: Set<String>
|
||||
let onExerciseSelected: (Exercise) -> Void
|
||||
let onSelected: (Selection) -> Void
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
@State private var searchText = ""
|
||||
/// Parsed once per sheet presentation (`onAppear`), not per row render — the
|
||||
/// library section's name list is stable for the sheet's lifetime.
|
||||
@State private var libraryInfoByName: [String: ExerciseInfo] = [:]
|
||||
|
||||
private var availableExercises: [Exercise] {
|
||||
/// A pick from either source: the split path hands back the live `Exercise`
|
||||
/// entity (as today), the library path just the exercise name.
|
||||
enum Selection {
|
||||
case fromSplit(Exercise)
|
||||
case fromLibrary(String)
|
||||
}
|
||||
|
||||
/// Section 1 — this workout's split, minus exercises already added.
|
||||
private var splitExercises: [Exercise] {
|
||||
guard let split else { return [] }
|
||||
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
|
||||
}
|
||||
|
||||
/// Section 2 — every library exercise not already in the workout and not
|
||||
/// already offered in section 1.
|
||||
private var libraryExerciseNames: [String] {
|
||||
let shown = Set(splitExercises.map(\.name))
|
||||
return ExerciseMotionLibrary.exerciseNames.filter {
|
||||
!existingExerciseNames.contains($0) && !shown.contains($0)
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredSplitExercises: [Exercise] {
|
||||
guard !searchText.isEmpty else { return splitExercises }
|
||||
return splitExercises.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
private var filteredLibraryNames: [String] {
|
||||
guard !searchText.isEmpty else { return libraryExerciseNames }
|
||||
return libraryExerciseNames.filter { $0.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
/// Whether there's anything left to add at all — independent of the current
|
||||
/// search, so a no-results search doesn't get confused with a truly empty sheet.
|
||||
private var hasAnyExercises: Bool {
|
||||
!splitExercises.isEmpty || !libraryExerciseNames.isEmpty
|
||||
}
|
||||
|
||||
/// "3 × 12" / "3 × 30 s" — the library's suggested plan for a row's caption.
|
||||
private func defaultsCaption(_ defaults: ExerciseInfo.Defaults) -> String {
|
||||
switch defaults.loadType {
|
||||
case .duration: "\(defaults.sets) × \(defaults.durationSeconds) s"
|
||||
case .weight, .none: "\(defaults.sets) × \(defaults.reps)"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if !availableExercises.isEmpty {
|
||||
if !hasAnyExercises {
|
||||
ContentUnavailableView(
|
||||
"All Exercises Added",
|
||||
systemImage: "checkmark.circle",
|
||||
description: Text("Every exercise is already part of this workout.")
|
||||
)
|
||||
} else if filteredSplitExercises.isEmpty && filteredLibraryNames.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
List {
|
||||
ForEach(availableExercises) { exercise in
|
||||
Button {
|
||||
onExerciseSelected(exercise)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(exercise.name)
|
||||
.foregroundColor(.primary)
|
||||
Text(exercise.planSummary(weightUnit: weightUnit))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
if !filteredSplitExercises.isEmpty, let split {
|
||||
Section("From \(split.name)") {
|
||||
ForEach(filteredSplitExercises) { exercise in
|
||||
Button {
|
||||
onSelected(.fromSplit(exercise))
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(exercise.name)
|
||||
.foregroundColor(.primary)
|
||||
Text(exercise.planSummary(weightUnit: weightUnit))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !filteredLibraryNames.isEmpty {
|
||||
Section("All Exercises") {
|
||||
ForEach(filteredLibraryNames, id: \.self) { name in
|
||||
Button {
|
||||
onSelected(.fromLibrary(name))
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(name)
|
||||
.foregroundColor(.primary)
|
||||
if let defaults = libraryInfoByName[name]?.defaults {
|
||||
Text(defaultsCaption(defaults))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if split == nil {
|
||||
ContentUnavailableView(
|
||||
"No Split Selected",
|
||||
systemImage: "dumbbell",
|
||||
description: Text("This workout has no associated split.")
|
||||
)
|
||||
} else if split?.exercisesArray.isEmpty == true {
|
||||
ContentUnavailableView(
|
||||
"No Exercises in Split",
|
||||
systemImage: "dumbbell",
|
||||
description: Text("Add exercises to your split first.")
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"All Exercises Added",
|
||||
systemImage: "checkmark.circle",
|
||||
description: Text("You've added all exercises from this split.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText)
|
||||
.onAppear {
|
||||
guard libraryInfoByName.isEmpty else { return }
|
||||
for name in libraryExerciseNames {
|
||||
libraryInfoByName[name] = ExerciseInfoLibrary.info(for: name)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Exercise")
|
||||
|
||||
Reference in New Issue
Block a user