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,246 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Which sidebar item is selected. `routine` carries the routine's ULID; the two
|
||||
/// Library items are singletons. Starter Gallery and New Routine are actions
|
||||
/// (sheets), not selectable destinations, so they're deliberately absent here.
|
||||
enum SidebarSelection: Hashable {
|
||||
case routine(String) // ULID
|
||||
case schedules
|
||||
case exercises
|
||||
}
|
||||
|
||||
/// A routine flagged for deletion — captured as plain values (id + name), not a
|
||||
/// retained `@Model`. Reading a persisted property on a since-deleted entity traps
|
||||
/// (the repo's known crash class), so the confirmation dialog reads only these
|
||||
/// captured strings and re-resolves the live routine by id at delete time.
|
||||
private struct PendingRoutineDelete: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// A routine flagged for editing — captured as just its id, not a retained `@Model`
|
||||
/// (see `PendingRoutineDelete`). The sheet re-resolves the live routine by id 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 PendingRoutineEdit: Identifiable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
/// Root content once iCloud is available: a Mac-native `NavigationSplitView` shell.
|
||||
/// The sidebar lists the two Library destinations plus every routine (reorderable,
|
||||
/// with a per-row context menu); the detail column swaps on selection. New Routine
|
||||
/// and the Starter Gallery are surfaced as sheets from the sidebar toolbar.
|
||||
struct MacContentView: View {
|
||||
@Environment(SyncEngine.self) private var syncEngine
|
||||
|
||||
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
|
||||
private var routines: [Routine]
|
||||
@Query private var schedules: [Schedule]
|
||||
|
||||
@State private var selection: SidebarSelection?
|
||||
@State private var showingNewRoutine = false
|
||||
@State private var showingStarterGallery = false
|
||||
@State private var routineToEdit: PendingRoutineEdit?
|
||||
@State private var pendingDelete: PendingRoutineDelete?
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
sidebar
|
||||
} detail: {
|
||||
detail
|
||||
}
|
||||
.sheet(isPresented: $showingNewRoutine) {
|
||||
MacRoutineEditorSheet(routine: nil)
|
||||
}
|
||||
.sheet(isPresented: $showingStarterGallery) {
|
||||
MacStarterGalleryView()
|
||||
}
|
||||
.sheet(item: $routineToEdit) { pending in
|
||||
if let routine = routines.first(where: { $0.id == pending.id }), routine.isLive {
|
||||
MacRoutineEditorSheet(routine: routine)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Routine?",
|
||||
isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: pendingDelete
|
||||
) { pending in
|
||||
Button("Delete", role: .destructive) { confirmDelete(pending) }
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: { pending in
|
||||
Text(deleteMessage(for: pending))
|
||||
}
|
||||
// Follow a seed fork: a clone-on-edit swaps the selected routine's id, so
|
||||
// keep the sidebar highlight pointed at the live clone. The detail column
|
||||
// follows on its own (it re-resolves the id every render).
|
||||
.onChange(of: syncEngine.cloneRedirects) {
|
||||
guard case let .routine(id) = selection else { return }
|
||||
let resolved = syncEngine.currentRoutineID(for: id)
|
||||
if resolved != id { selection = .routine(resolved) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sidebar
|
||||
|
||||
private var sidebar: some View {
|
||||
List(selection: $selection) {
|
||||
Section("Library") {
|
||||
Label("Schedules", systemImage: "calendar")
|
||||
.tag(SidebarSelection.schedules)
|
||||
Label("Exercises", systemImage: "figure.strengthtraining.traditional")
|
||||
.tag(SidebarSelection.exercises)
|
||||
}
|
||||
|
||||
Section("Routines") {
|
||||
ForEach(routines) { routine in
|
||||
RoutineSidebarRow(routine: routine)
|
||||
.tag(SidebarSelection.routine(routine.id))
|
||||
.contextMenu {
|
||||
Button("Edit…") { routineToEdit = PendingRoutineEdit(id: routine.id) }
|
||||
Button("Duplicate") {
|
||||
Task { await syncEngine.duplicate(routine: routine) }
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) {
|
||||
pendingDelete = PendingRoutineDelete(id: routine.id, name: routine.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveRoutines)
|
||||
|
||||
if routines.isEmpty {
|
||||
Text("No routines yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Workouts")
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
Button {
|
||||
showingNewRoutine = true
|
||||
} label: {
|
||||
Label("New Routine", systemImage: "plus")
|
||||
}
|
||||
.help("New Routine")
|
||||
|
||||
Button {
|
||||
showingStarterGallery = true
|
||||
} label: {
|
||||
Label("Starter Gallery", systemImage: "sparkles")
|
||||
}
|
||||
.help("Browse Starter Routines")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detail
|
||||
|
||||
@ViewBuilder
|
||||
private var detail: some View {
|
||||
switch selection {
|
||||
case .routine(let id):
|
||||
// Reading `currentRoutineID` here establishes the observation dependency
|
||||
// on `cloneRedirects`, so a fork re-renders this branch and the `.id`
|
||||
// reseeds the detail's own state onto the clone.
|
||||
let resolved = syncEngine.currentRoutineID(for: id)
|
||||
MacRoutineDetailView(routineID: resolved)
|
||||
.id(resolved)
|
||||
case .schedules:
|
||||
MacSchedulesView()
|
||||
case .exercises:
|
||||
MacExercisesBrowserView()
|
||||
case nil:
|
||||
ContentUnavailableView(
|
||||
"Your Library",
|
||||
systemImage: "dumbbell",
|
||||
description: Text("Select a routine, or pick Schedules or Exercises from the sidebar.")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
/// Reorder and renumber via `RoutineOrdering`, writing only the routines whose
|
||||
/// order actually changed. Order-only writes deliberately leave `updatedAt`
|
||||
/// alone — touching it would fork a starter (its `order` is normalized out of the
|
||||
/// pristine check, but `updatedAt` is stamped on every real edit). Mirrors iOS
|
||||
/// `RoutinesLibraryView.moveRoutines`.
|
||||
private func moveRoutines(from source: IndexSet, to destination: Int) {
|
||||
let ids = routines.map(\.id)
|
||||
let currentOrders = Dictionary(uniqueKeysWithValues: routines.map { ($0.id, $0.order) })
|
||||
let changes = RoutineOrdering.changedOrders(
|
||||
ids: ids, currentOrders: currentOrders, from: source, to: destination)
|
||||
guard !changes.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
for change in changes {
|
||||
guard let routine = routines.first(where: { $0.id == change.id }), routine.isLive else { continue }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
doc.order = change.order
|
||||
await syncEngine.save(routine: doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func confirmDelete(_ pending: PendingRoutineDelete) {
|
||||
if let routine = routines.first(where: { $0.id == pending.id }), routine.isLive {
|
||||
Task { await syncEngine.delete(routine: routine) }
|
||||
}
|
||||
if case .routine(pending.id) = selection { selection = nil }
|
||||
pendingDelete = nil
|
||||
}
|
||||
|
||||
/// Delete-confirmation body, extended with a note about any schedules that point
|
||||
/// at this routine — deleting the routine leaves them behind. Mirrors iOS.
|
||||
private func deleteMessage(for pending: PendingRoutineDelete) -> String {
|
||||
let base = "This will permanently delete \"\(pending.name)\" and all its exercises. Past workouts are kept."
|
||||
let count = schedules.filter { syncEngine.currentRoutineID(for: $0.routineID) == pending.id }.count
|
||||
guard count > 0 else { return base }
|
||||
return base + " Its \(count) scheduled day\(count == 1 ? "" : "s") will stay on the schedule without a startable routine."
|
||||
}
|
||||
}
|
||||
|
||||
/// A single routine row in the sidebar: a rounded-square color badge with the
|
||||
/// routine's symbol, its name (with a "Starter" capsule for a bundled seed), and an
|
||||
/// exercise-count caption. Mirrors iOS `RoutineRow`, sans the last-trained line
|
||||
/// (Mac doesn't run workouts).
|
||||
private struct RoutineSidebarRow: View {
|
||||
let routine: Routine
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: routine.systemImage)
|
||||
.font(.body)
|
||||
.foregroundStyle(routineColor)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(routineColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 6) {
|
||||
Text(routine.name)
|
||||
if SeedLibrary.isSeed(id: routine.id) {
|
||||
Text("Starter")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(Color.secondary.opacity(0.15), in: Capsule())
|
||||
}
|
||||
}
|
||||
Text("\(routine.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var routineColor: Color { Color.color(from: routine.color) }
|
||||
}
|
||||
Reference in New Issue
Block a user