Files
workouts/Workouts Mac/Views/MacContentView.swift
T
rzen 04ab7ed005 Move the Mac toolbar buttons to the window toolbar
Sidebar-column toolbar items are confined to the sidebar's narrow
section of the unified toolbar and collapse into overflow; attaching
New Routine and Starter Gallery to the split view gives them the full
trailing section.
2026-07-16 20:44:07 -04:00

251 lines
10 KiB
Swift

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