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.
251 lines
10 KiB
Swift
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) }
|
|
}
|