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) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// An exercise flagged for deletion — captured as plain values, never a retained
|
||||
/// `@Model` (see `PendingRoutineDelete`). The routine document is rebuilt by
|
||||
/// exercise id, so the id is all the delete needs; the name is only for the prompt.
|
||||
private struct PendingExerciseDelete: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// An exercise flagged for editing — captured as just its id, not a retained `@Model`
|
||||
/// (see `PendingExerciseDelete`). The sheet re-resolves the live exercise by id off the
|
||||
/// current `routine` when it presents, so a remote delete between the tap and the
|
||||
/// sheet's next body evaluation can't hand the editor a dangling entity.
|
||||
private struct PendingExerciseEdit: Identifiable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
/// The detail pane for one routine: a header (color badge, name, activity type, Edit)
|
||||
/// over a reorderable exercise list. All mutations rebuild the `RoutineDocument` and go
|
||||
/// through `syncEngine.save(routine:)` — never the SwiftData cache directly — so editing
|
||||
/// a starter forks it to a fresh ULID for free. The routine is resolved by id every
|
||||
/// render (never a captured entity) so an open detail follows that fork, mirroring iOS
|
||||
/// `RoutineDetailView`.
|
||||
struct MacRoutineDetailView: View {
|
||||
@Environment(SyncEngine.self) private var syncEngine
|
||||
|
||||
@State private var routineID: String
|
||||
@Query private var routines: [Routine]
|
||||
|
||||
@State private var showingRoutineEdit = false
|
||||
@State private var showingAddExercise = false
|
||||
@State private var exerciseToEdit: PendingExerciseEdit?
|
||||
@State private var pendingExerciseDelete: PendingExerciseDelete?
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
init(routineID: String) {
|
||||
_routineID = State(initialValue: routineID)
|
||||
}
|
||||
|
||||
/// The live routine behind the id we hold, resolved through the seed clone-on-edit
|
||||
/// redirect so a fork mid-screen keeps resolving.
|
||||
private var routine: Routine? {
|
||||
let id = syncEngine.currentRoutineID(for: routineID)
|
||||
return routines.first { $0.id == id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let routine {
|
||||
content(for: routine)
|
||||
} else {
|
||||
// The id no longer maps to a live routine (deleted elsewhere, or a
|
||||
// transient mid-clone frame).
|
||||
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingRoutineEdit) {
|
||||
if let routine { MacRoutineEditorSheet(routine: routine) }
|
||||
}
|
||||
.sheet(isPresented: $showingAddExercise) {
|
||||
MacExerciseEditorSheet(routineID: syncEngine.currentRoutineID(for: routineID), exercise: nil)
|
||||
}
|
||||
.sheet(item: $exerciseToEdit) { pending in
|
||||
if let exercise = routine?.exercisesArray.first(where: { $0.id == pending.id }), exercise.isLive {
|
||||
MacExerciseEditorSheet(routineID: syncEngine.currentRoutineID(for: routineID), exercise: exercise)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: Binding(
|
||||
get: { pendingExerciseDelete != nil },
|
||||
set: { if !$0 { pendingExerciseDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: pendingExerciseDelete
|
||||
) { pending in
|
||||
Button("Delete", role: .destructive) {
|
||||
deleteExercise(id: pending.id)
|
||||
pendingExerciseDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingExerciseDelete = nil }
|
||||
} message: { pending in
|
||||
Text("Remove \"\(pending.name)\" from this routine?")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(for routine: Routine) -> some View {
|
||||
List {
|
||||
Section {
|
||||
header(for: routine)
|
||||
}
|
||||
|
||||
Section("Exercises") {
|
||||
if routine.exercisesArray.isEmpty {
|
||||
Text("No exercises added yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(routine.exercisesArray) { exercise in
|
||||
ExerciseRow(exercise: exercise, weightUnit: weightUnit)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) { exerciseToEdit = PendingExerciseEdit(id: exercise.id) }
|
||||
.contextMenu {
|
||||
Button("Edit…") { exerciseToEdit = PendingExerciseEdit(id: exercise.id) }
|
||||
Button("Duplicate") { duplicateExercise(id: exercise.id) }
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) {
|
||||
pendingExerciseDelete = PendingExerciseDelete(id: exercise.id, name: exercise.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
moveExercises(routine: routine, from: source, to: destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(routine.name)
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
showingAddExercise = true
|
||||
} label: {
|
||||
Label("Add Exercise", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func header(for routine: Routine) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: routine.systemImage)
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(Color.color(from: routine.color))
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.color(from: routine.color).opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(routine.name)
|
||||
.font(.title2.weight(.semibold))
|
||||
Label(routine.activityTypeEnum.displayName, systemImage: routine.activityTypeEnum.systemImage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showingRoutineEdit = true
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// MARK: - Mutations (all rebuild the document, never the cache)
|
||||
|
||||
/// Reorder and renumber the exercises, then save. Resolves the routine at call time
|
||||
/// so it follows a clone-on-edit. Stamps `updatedAt` — an exercise reorder is a real
|
||||
/// content edit that should fork a starter (unlike a routine-list reorder).
|
||||
private func moveExercises(routine: Routine, from source: IndexSet, to destination: Int) {
|
||||
var ordered = routine.exercisesArray
|
||||
ordered.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
var doc = RoutineDocument(from: routine)
|
||||
doc.exercises = ordered.enumerated().map { i, ex in
|
||||
var ed = ExerciseDocument(from: ex)
|
||||
ed.order = i
|
||||
return ed
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await syncEngine.save(routine: doc) }
|
||||
}
|
||||
|
||||
private func deleteExercise(id: String) {
|
||||
guard let routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
doc.exercises.removeAll { $0.id == id }
|
||||
for i in doc.exercises.indices { doc.exercises[i].order = i }
|
||||
doc.updatedAt = Date()
|
||||
Task { await syncEngine.save(routine: doc) }
|
||||
}
|
||||
|
||||
/// Copy an exercise in place, right after itself (the interval-segment case). The
|
||||
/// clone is an exact duplicate under a fresh id; renaming it is a follow-up edit.
|
||||
/// Mirrors iOS `RoutineDetailView.duplicateExercise`.
|
||||
private func duplicateExercise(id: String) {
|
||||
guard let routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
guard let idx = doc.exercises.firstIndex(where: { $0.id == id }) else { return }
|
||||
var copy = doc.exercises[idx]
|
||||
copy.id = ULID.make()
|
||||
doc.exercises.insert(copy, at: idx + 1)
|
||||
for i in doc.exercises.indices { doc.exercises[i].order = i }
|
||||
doc.updatedAt = Date()
|
||||
Task { await syncEngine.save(routine: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
/// One exercise row: name over a one-line plan summary (`ExerciseDocument+PlanSummary`
|
||||
/// parity via `Exercise.planSummary`).
|
||||
private struct ExerciseRow: View {
|
||||
let exercise: Exercise
|
||||
let weightUnit: WeightUnit
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(exercise.name)
|
||||
Text(exercise.planSummary(weightUnit: weightUnit))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Create or edit a routine's identity: name, icon, color, and activity type. Mac-
|
||||
/// native sheet (grouped Form, fixed width, Cancel/Save). The workout-run settings
|
||||
/// the iOS editor also carries (rest, auto-advance, target heart rate) are omitted —
|
||||
/// the Mac never runs a workout — but any values already set on the routine are
|
||||
/// preserved on save, since we rebuild from `RoutineDocument(from:)` and touch only
|
||||
/// these four fields.
|
||||
struct MacRoutineEditorSheet: View {
|
||||
@Environment(SyncEngine.self) private var syncEngine
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Resolve by id, not a captured entity: a clone-on-edit swaps a seed's identity
|
||||
// mid-edit. `routineID` is nil in create mode. Mirrors iOS `RoutineAddEditView`.
|
||||
@State private var routineID: String?
|
||||
@Query private var routines: [Routine]
|
||||
|
||||
@State private var name: String
|
||||
@State private var color: String
|
||||
@State private var systemImage: String
|
||||
@State private var activityType: WorkoutActivityType
|
||||
|
||||
var isEditing: Bool { routineID != nil }
|
||||
|
||||
init(routine: Routine?) {
|
||||
_routineID = State(initialValue: routine?.id)
|
||||
_name = State(initialValue: routine?.name ?? "")
|
||||
_color = State(initialValue: routine?.color ?? "indigo")
|
||||
_systemImage = State(initialValue: routine?.systemImage ?? "dumbbell.fill")
|
||||
_activityType = State(initialValue: routine?.activityTypeEnum ?? .traditionalStrength)
|
||||
}
|
||||
|
||||
private var routine: Routine? {
|
||||
guard let routineID else { return nil }
|
||||
let id = syncEngine.currentRoutineID(for: routineID)
|
||||
return routines.first { $0.id == id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Name") {
|
||||
TextField("Name", text: $name)
|
||||
if let routineID, SeedLibrary.isSeed(id: routineID) {
|
||||
Text("Saving changes to a starter routine creates your own copy; the starter stays available in the gallery.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Icon") {
|
||||
IconGridPicker(selection: $systemImage, tint: Color.color(from: color))
|
||||
}
|
||||
|
||||
Section("Color") {
|
||||
ColorSwatchPicker(selection: $color)
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Activity Type", selection: $activityType) {
|
||||
ForEach(WorkoutActivityType.allCases, id: \.self) { type in
|
||||
Label(type.displayName, systemImage: type.systemImage).tag(type)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Activity Type")
|
||||
} footer: {
|
||||
Text("Determines how this workout is categorized in Apple Health and how it credits your Activity rings.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(isEditing ? "Edit Routine" : "New Routine")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save(); dismiss() }
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 460, height: 520)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if isEditing {
|
||||
guard let routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
doc.name = name
|
||||
doc.color = color
|
||||
doc.systemImage = systemImage
|
||||
doc.activityType = activityType.rawValue
|
||||
doc.updatedAt = Date()
|
||||
Task { await syncEngine.save(routine: doc) }
|
||||
} else {
|
||||
let existing = (try? modelContext.fetch(FetchDescriptor<Routine>())) ?? []
|
||||
let now = Date()
|
||||
let doc = RoutineDocument(
|
||||
schemaVersion: RoutineDocument.currentSchemaVersion,
|
||||
id: ULID.make(),
|
||||
name: name,
|
||||
color: color,
|
||||
systemImage: systemImage,
|
||||
order: existing.count,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
exercises: [],
|
||||
activityType: activityType.rawValue
|
||||
)
|
||||
Task { await syncEngine.save(routine: doc) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid of the curated `availableIcons`, single-select, with the chosen one ringed.
|
||||
private struct IconGridPicker: View {
|
||||
@Binding var selection: String
|
||||
let tint: Color
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 44), spacing: 8)]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, spacing: 8) {
|
||||
ForEach(availableIcons, id: \.self) { icon in
|
||||
Button {
|
||||
selection = icon
|
||||
} label: {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(icon == selection ? tint : .secondary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
(icon == selection ? tint.opacity(0.15) : Color.secondary.opacity(0.08)),
|
||||
in: RoundedRectangle(cornerRadius: 8)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(tint, lineWidth: icon == selection ? 2 : 0)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of the canonical `availableColors` as tappable swatches, single-select.
|
||||
private struct ColorSwatchPicker: View {
|
||||
@Binding var selection: String
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 36), spacing: 10)]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, spacing: 10) {
|
||||
ForEach(availableColors, id: \.self) { name in
|
||||
Button {
|
||||
selection = name
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(Color.color(from: name))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(.primary, lineWidth: name == selection ? 2 : 0)
|
||||
.padding(-2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(name.capitalized)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Every bundled starter routine — including deleted ones — with a per-seed Add/Restore
|
||||
/// action and a bulk "re-add everything missing" fallback. Reads straight from
|
||||
/// `SeedLibrary.seeds` (the bundle source of truth), so a deleted starter still has a
|
||||
/// row. Presented as a sheet from the sidebar; selecting a seed pushes its full plan.
|
||||
/// Mirrors iOS `StarterGalleryView`.
|
||||
struct MacStarterGalleryView: View {
|
||||
@Environment(SyncEngine.self) private var syncEngine
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
|
||||
private var routines: [Routine]
|
||||
|
||||
@State private var isRestoringAll = false
|
||||
@State private var restoreAllMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(SeedLibrary.seeds, id: \.id) { seed in
|
||||
NavigationLink(value: seed.id) {
|
||||
row(for: seed)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Every routine Workouts ships with, whether or not you've kept it. Adding one never touches a routine you've created or edited.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let restoreAllMessage {
|
||||
Section {
|
||||
Text(restoreAllMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Starter Routines")
|
||||
.navigationDestination(for: String.self) { seedID in
|
||||
if let seed = SeedLibrary.seed(id: seedID) {
|
||||
MacStarterPreview(seed: seed)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
ToolbarItem {
|
||||
Button {
|
||||
Task {
|
||||
isRestoringAll = true
|
||||
restoreAllMessage = nil
|
||||
let n = await syncEngine.restoreSeeds()
|
||||
isRestoringAll = false
|
||||
restoreAllMessage = n == 0
|
||||
? "All starter routines are already present."
|
||||
: "Restored \(n) starter routine\(n == 1 ? "" : "s")."
|
||||
}
|
||||
} label: {
|
||||
Label("Re-add All Missing", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.disabled(isRestoringAll || syncEngine.iCloudStatus != .available)
|
||||
.help("Re-add every starter routine you've removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 520, height: 560)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(for seed: SeedLibrary.Seed) -> some View {
|
||||
HStack {
|
||||
content(for: seed.doc)
|
||||
Spacer()
|
||||
switch seed.state(among: routines) {
|
||||
case .added:
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
case .nameTaken:
|
||||
EmptyView()
|
||||
case .restorable:
|
||||
Button("Add") {
|
||||
Task { _ = await syncEngine.restoreSeed(id: seed.id) }
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(syncEngine.iCloudStatus != .available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func content(for doc: RoutineDocument) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: doc.systemImage)
|
||||
.font(.body)
|
||||
.foregroundStyle(Color.color(from: doc.color))
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Color.color(from: doc.color).opacity(0.12), in: RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(doc.name)
|
||||
Text("\(doc.exercises.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only preview of one bundled starter — icon, activity type, full exercise plan —
|
||||
/// with the state-aware install action. Mirrors iOS `StarterPreviewView`.
|
||||
private struct MacStarterPreview: View {
|
||||
@Environment(SyncEngine.self) private var syncEngine
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
|
||||
private var routines: [Routine]
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
let seed: SeedLibrary.Seed
|
||||
|
||||
@State private var isAdding = false
|
||||
|
||||
private var state: StarterSeedState { seed.state(among: routines) }
|
||||
|
||||
private var activityType: WorkoutActivityType {
|
||||
seed.doc.activityType.flatMap(WorkoutActivityType.init(rawValue:)) ?? .traditionalStrength
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
Divider()
|
||||
exerciseList
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) { actionBar }
|
||||
.navigationTitle(seed.doc.name)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: seed.doc.systemImage)
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(Color.color(from: seed.doc.color))
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.color(from: seed.doc.color).opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(seed.doc.name)
|
||||
.font(.title2.weight(.semibold))
|
||||
Label(activityType.displayName, systemImage: activityType.systemImage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var exerciseList: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Exercises").font(.headline)
|
||||
ForEach(seed.doc.exercises.sorted { $0.order < $1.order }) { exercise in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(exercise.name)
|
||||
Text(exercise.planSummary(weightUnit: weightUnit))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionBar: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .added:
|
||||
Label("Added to My Routines", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.frame(maxWidth: .infinity)
|
||||
case .nameTaken:
|
||||
Text("A routine named \"\(seed.doc.name)\" already exists.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
case .restorable:
|
||||
Button {
|
||||
Task {
|
||||
isAdding = true
|
||||
let added = await syncEngine.restoreSeed(id: seed.id)
|
||||
isAdding = false
|
||||
if added { dismiss() }
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
if isAdding { ProgressView().controlSize(.small) }
|
||||
Text(isAdding ? "Adding…" : "Add to My Routines")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isAdding || syncEngine.iCloudStatus != .available)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user