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 window 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 } // Attached to the split view, not the sidebar List: sidebar-column toolbar // items are confined to the sidebar's (narrow) section of the unified // toolbar and collapse into the overflow menu. Here they get the full // trailing section instead. .toolbar { ToolbarItemGroup(placement: .primaryAction) { 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") } } .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") } // 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) } }