Files
workouts/Workouts Mac/Views/MacContentView.swift
T
rzen cbdf02bca7 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.
2026-07-16 19:55:06 -04:00

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