Files
workouts/Workouts Mac/Views/Routines/MacExerciseEditorSheet.swift
T
rzen cbdf02bca7 Add the macOS app: a library-management companion (routines, schedules, exercise browser)
A new 'Workouts Mac' target (same bundle ID as iOS, universal purchase)
with a NavigationSplitView shell over the shared data/sync layers:
routine management with starter gallery and seed-fork follow, schedule
editing (reminders stay iPhone-scheduled), and a browse-only exercise
library with the animated figures and reference guides.

Multi-writer stance: the Mac writes only routine and schedule documents
(whole-document last-writer-wins) and never workout documents, whose
per-log merge exists only on the watch ingest path. The Mac reconciles
on window activation (throttled) since iCloud syncs while the app is
closed, and flushes pending writes on deactivation.
2026-07-16 19:55:06 -04:00

298 lines
12 KiB
Swift

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) }
}
}