import IndieSync import SwiftUI import SwiftData /// Add or edit one exercise in a routine. Add mode picks a name from the searchable, /// grouped bundled catalog (`ExerciseCatalog.sections`) and seeds its authored /// defaults; edit mode pre-fills from the existing exercise. Every save rebuilds the /// parent `RoutineDocument` and goes through `syncEngine.save(routine:)`. Uses only /// macOS-safe controls — Steppers/TextField/menu Pickers, never `.pickerStyle(.wheel)` /// or `.textInputAutocapitalization`. struct MacExerciseEditorSheet: View { @Environment(SyncEngine.self) private var syncEngine @Environment(\.dismiss) private var dismiss let routineID: String /// nil in add mode. Captured as a value in `init` so save never reads a retained /// (possibly since-deleted) `@Model`. private let exerciseID: String? @Query private var routines: [Routine] @State private var name: String /// The library exercise this row derives from — carried through a rename so the /// figure/info link survives. Mirrors `ExerciseDocument.libraryName`. @State private var libraryName: String? @State private var loadType: LoadType @State private var sets: Int @State private var reps: Int @State private var weight: Double @State private var minutes: Int @State private var seconds: Int @State private var machineEnabled: Bool @State private var machineSettings: [MachineSetting] @State private var showingCatalogPicker = false @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb var isAdding: Bool { exerciseID == nil } init(routineID: String, exercise: Exercise?) { self.routineID = routineID self.exerciseID = exercise?.id _name = State(initialValue: exercise?.name ?? "") _libraryName = State(initialValue: exercise.flatMap { ex in ex.libraryName ?? (ExerciseMotionLibrary.exerciseNames.contains(ex.name) ? ex.name : nil) }) _loadType = State(initialValue: exercise?.loadTypeEnum ?? .weight) _sets = State(initialValue: exercise?.sets ?? 3) _reps = State(initialValue: exercise?.reps ?? 10) _weight = State(initialValue: exercise?.weight ?? 0) _minutes = State(initialValue: exercise?.durationMinutes ?? 0) _seconds = State(initialValue: exercise?.durationSeconds ?? 0) _machineEnabled = State(initialValue: exercise?.machineSettings != nil) _machineSettings = State(initialValue: exercise?.machineSettings ?? []) } private var routine: Routine? { let id = syncEngine.currentRoutineID(for: routineID) return routines.first { $0.id == id } } var body: some View { NavigationStack { editorForm } .frame(width: 460, height: 560) .sheet(isPresented: $showingCatalogPicker) { MacExerciseCatalogPicker { picked in pick(picked) } } } private var editorForm: some View { Form { Section("Exercise") { if name.isEmpty { Button("Select Exercise…") { showingCatalogPicker = true } } else { if let libraryName, libraryName != name { LabeledContent("From", value: libraryName) } TextField("Name", text: $name) Button("Choose a Different Exercise…") { showingCatalogPicker = true } .font(.caption) } } Section("Sets & Reps") { Stepper(value: $sets, in: 0...20) { LabeledContent("Sets", value: "\(sets)") } Stepper(value: $reps, in: 0...100) { LabeledContent("Reps", value: "\(reps)") } } Section("Load Type") { Picker("Load Type", selection: $loadType) { ForEach(LoadType.allCases, id: \.self) { load in Text(load.name).tag(load) } } .pickerStyle(.segmented) .labelsHidden() } if loadType == .weight { Section("Weight") { HStack(spacing: 8) { TextField("Weight", value: $weight, format: .number) .frame(width: 70) .multilineTextAlignment(.trailing) .textFieldStyle(.roundedBorder) Text(weightUnit.abbreviation).foregroundStyle(.secondary) Spacer() Stepper("Weight", value: $weight, in: 0...2000, step: 5).labelsHidden() } } } if loadType == .duration { Section("Duration") { Stepper(value: $minutes, in: 0...59) { LabeledContent("Minutes", value: "\(minutes)") } Stepper(value: $seconds, in: 0...59) { LabeledContent("Seconds", value: "\(seconds)") } } } Section { Toggle("Machine-based", isOn: $machineEnabled.animation()) if machineEnabled { MacMachineSettingsEditor(settings: $machineSettings) } } header: { Text("Machine") } footer: { Text("Turn on for machine-based exercises to record comfort settings (seat height, back-rest incline, pin position…).") .font(.caption) .foregroundStyle(.secondary) } } .formStyle(.grouped) .navigationTitle(isAdding ? "Add Exercise" : (name.isEmpty ? "Edit Exercise" : name)) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { save(); dismiss() } .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) } } } /// Apply a picked library exercise. Add mode seeds the library's authored defaults /// (sets/reps/load/duration); edit mode only relinks the name (parity with iOS, /// which never resets an existing exercise's plan on a re-pick). private func pick(_ exerciseName: String) { name = exerciseName libraryName = exerciseName guard isAdding, let defaults = ExerciseInfoLibrary.info(for: exerciseName)?.defaults else { return } sets = defaults.sets reps = defaults.reps loadType = defaults.loadType minutes = defaults.durationSeconds / 60 seconds = defaults.durationSeconds % 60 } private func save() { guard let routine else { return } let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) let finalName = trimmed.isEmpty ? (libraryName ?? "") : trimmed guard !finalName.isEmpty else { return } // nil when the name matches the library exercise (unrenamed) — keeps an // unrenamed exercise byte-identical on disk. let storedLibraryName = (libraryName == finalName) ? nil : libraryName let durationSecs = minutes * 60 + seconds var doc = RoutineDocument(from: routine) if let exerciseID, let idx = doc.exercises.firstIndex(where: { $0.id == exerciseID }) { doc.exercises[idx].name = finalName doc.exercises[idx].libraryName = storedLibraryName doc.exercises[idx].sets = sets doc.exercises[idx].reps = reps doc.exercises[idx].weight = weight doc.exercises[idx].loadType = loadType.rawValue doc.exercises[idx].durationSeconds = durationSecs doc.exercises[idx].machineSettings = machineEnabled ? machineSettings : nil } else { let ed = ExerciseDocument( id: ULID.make(), name: finalName, order: doc.exercises.count, sets: sets, reps: reps, weight: weight, loadType: loadType.rawValue, durationSeconds: durationSecs, machineSettings: machineEnabled ? machineSettings : nil, libraryName: storedLibraryName ) doc.exercises.append(ed) } doc.updatedAt = Date() Task { await syncEngine.save(routine: doc) } } } /// A searchable, catalog-grouped exercise-name picker (`ExerciseCatalog.sections`), /// single-select. Returns the chosen name to `onSelect` and dismisses. private struct MacExerciseCatalogPicker: View { @Environment(\.dismiss) private var dismiss let onSelect: (String) -> Void @State private var query = "" var body: some View { NavigationStack { List { ForEach(ExerciseCatalog.sections(matching: query), id: \.title) { section in Section(section.title) { ForEach(section.names, id: \.self) { name in Button { onSelect(name) dismiss() } label: { Text(name).frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) } } } } .searchable(text: $query, prompt: "Search exercises") .navigationTitle("Choose Exercise") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } .frame(width: 440, height: 520) } } /// macOS-safe editor for an ordered list of machine comfort settings — name/value /// TextField rows with an explicit remove button and an add row. Mirrors the iOS /// `MachineSettingsEditor` semantics without `.textInputAutocapitalization` (which /// doesn't exist on macOS). Reorder is dropped (minor on Mac); add/edit/delete stay. private struct MacMachineSettingsEditor: View { @Binding var settings: [MachineSetting] @State private var rows: [Row] init(settings: Binding<[MachineSetting]>) { _settings = settings _rows = State(initialValue: settings.wrappedValue.map(Row.init)) } var body: some View { ForEach($rows) { $row in HStack { TextField("Setting", text: $row.name) TextField("Value", text: $row.value) .multilineTextAlignment(.trailing) .foregroundStyle(.secondary) .frame(maxWidth: 120) Button { rows.removeAll { $0.id == row.id } } label: { Image(systemName: "minus.circle.fill").foregroundStyle(.red) } .buttonStyle(.plain) } } Button { rows.append(Row(MachineSetting(name: "", value: ""))) } label: { Label("Add Setting", systemImage: "plus.circle.fill") } .onChange(of: rows) { _, newRows in settings = newRows.map(\.setting) } } /// Session-only identified wrapper so `ForEach` has a stable identity that /// survives text edits. fileprivate struct Row: Identifiable, Equatable { let id = UUID() var name: String var value: String init(_ setting: MachineSetting) { name = setting.name value = setting.value } var setting: MachineSetting { MachineSetting(name: name, value: value) } } }