Files
workouts/Workouts/Views/Exercises/ExerciseAddEditView.swift
T
rzen df5c77eee1 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
2026-07-12 18:38:14 -04:00

238 lines
9.9 KiB
Swift

//
// ExerciseAddEditView.swift
// Workouts
//
// Created by rzen on 7/15/25 at 7:12 AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct ExerciseAddEditView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@State private var showingExercisePicker = false
// The exercise entity provides initial values (read-only).
let exercise: Exercise
// The parent routine is needed to rebuild and save the RoutineDocument.
let routine: Routine
// 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
@State private var weightTens: Int
@State private var weightOnes: Int
@State private var reps: Int
@State private var sets: Int
// Machine comfort settings. `machineEnabled` mirrors the optionality of the
// stored `machineSettings` — ON persists a (possibly empty) array, OFF persists
// nil. `machineSettings` holds the ordered rows while the toggle is on.
@State private var machineEnabled: Bool
@State private var machineSettings: [MachineSetting]
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(exercise: Exercise, routine: Routine) {
self.exercise = exercise
self.routine = routine
// The tens/ones pickers step whole values; a fractional stored weight
// 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)
_weightTens = State(initialValue: (w / 10) * 10)
_weightOnes = State(initialValue: w % 10)
_reps = State(initialValue: exercise.reps)
_sets = State(initialValue: exercise.sets)
_machineEnabled = State(initialValue: exercise.machineSettings != nil)
_machineSettings = State(initialValue: exercise.machineSettings ?? [])
}
var body: some View {
NavigationStack {
Form {
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
}) {
HStack {
Text("Select Exercise")
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
}
} else {
if let libraryName {
ListItem(title: libraryName)
}
TextField("Name", text: $exerciseName)
}
}
Section(header: Text("Sets/Reps")) {
HStack(alignment: .bottom) {
VStack(alignment: .center) {
Text("Sets")
Picker("", selection: $sets) {
ForEach(0..<20) { s in
Text("\(s)").tag(s)
}
}
.frame(height: 100)
.pickerStyle(.wheel)
}
VStack(alignment: .center) {
Text("Reps")
Picker("", selection: $reps) {
ForEach(0..<100) { r in
Text("\(r)").tag(r)
}
}
.frame(height: 100)
.pickerStyle(.wheel)
}
}
}
Section(
header: Text("Load Type"),
footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")
) {
Picker("", selection: $loadType) {
ForEach(LoadType.allCases, id: \.self) { load in
Text(load.name)
.tag(load)
}
}
.pickerStyle(.segmented)
}
if loadType == .weight {
Section(header: Text("Weight")) {
HStack {
Picker("", selection: $weightTens) {
ForEach(0..<100) { lbs in
Text("\(lbs * 10)").tag(lbs * 10)
}
}
.frame(height: 100)
.pickerStyle(.wheel)
Picker("", selection: $weightOnes) {
ForEach(0..<10) { lbs in
Text("\(lbs)").tag(lbs)
}
}
.frame(height: 100)
.pickerStyle(.wheel)
Text(weightUnit.abbreviation)
}
}
}
if loadType == .duration {
Section(header: Text("Duration")) {
HStack {
Picker("Minutes", selection: $minutes) {
ForEach(0..<60) { minute in
Text("\(minute) min").tag(minute)
}
}
.pickerStyle(.wheel)
Picker("Seconds", selection: $seconds) {
ForEach(0..<60) { second in
Text("\(second) sec").tag(second)
}
}
.pickerStyle(.wheel)
}
}
}
Section(
header: Text("Machine"),
footer: Text("Turn on for machine-based exercises to record comfort settings (seat height, back-rest incline, pin position…). During a workout you can adjust these and update this default.")
) {
Toggle("Machine-based", isOn: $machineEnabled.animation())
if machineEnabled {
MachineSettingsEditor(settings: $machineSettings)
}
}
}
.sheet(isPresented: $showingExercisePicker) {
ExercisePickerView { exerciseNames in
let picked = exerciseNames.first ?? "Unnamed"
exerciseName = picked
libraryName = picked
}
}
.navigationTitle(exerciseName.isEmpty ? "New Exercise" : exerciseName)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveExercise()
dismiss()
}
}
}
}
}
private func saveExercise() {
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 = 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)
doc.exercises[idx].loadType = loadType.rawValue
doc.exercises[idx].durationSeconds = durationSecs
// ON → a (possibly empty) array; OFF → nil, discarding any prior settings.
doc.exercises[idx].machineSettings = machineEnabled ? machineSettings : nil
}
doc.updatedAt = Date()
Task { await sync.save(routine: doc) }
}
}