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