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.
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user