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