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:
2026-07-16 19:55:06 -04:00
parent f6c5bc911f
commit cbdf02bca7
17 changed files with 2212 additions and 9 deletions
@@ -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)
}
}