Files
workouts/Workouts Mac/Views/Routines/MacRoutineDetailView.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

220 lines
8.8 KiB
Swift

import IndieSync
import SwiftUI
import SwiftData
/// An exercise flagged for deletion — captured as plain values, never a retained
/// `@Model` (see `PendingRoutineDelete`). The routine document is rebuilt by
/// exercise id, so the id is all the delete needs; the name is only for the prompt.
private struct PendingExerciseDelete: Identifiable {
let id: String
let name: String
}
/// An exercise flagged for editing — captured as just its id, not a retained `@Model`
/// (see `PendingExerciseDelete`). The sheet re-resolves the live exercise by id off the
/// current `routine` 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 PendingExerciseEdit: Identifiable {
let id: String
}
/// The detail pane for one routine: a header (color badge, name, activity type, Edit)
/// over a reorderable exercise list. All mutations rebuild the `RoutineDocument` and go
/// through `syncEngine.save(routine:)` — never the SwiftData cache directly — so editing
/// a starter forks it to a fresh ULID for free. The routine is resolved by id every
/// render (never a captured entity) so an open detail follows that fork, mirroring iOS
/// `RoutineDetailView`.
struct MacRoutineDetailView: View {
@Environment(SyncEngine.self) private var syncEngine
@State private var routineID: String
@Query private var routines: [Routine]
@State private var showingRoutineEdit = false
@State private var showingAddExercise = false
@State private var exerciseToEdit: PendingExerciseEdit?
@State private var pendingExerciseDelete: PendingExerciseDelete?
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(routineID: String) {
_routineID = State(initialValue: routineID)
}
/// The live routine behind the id we hold, resolved through the seed clone-on-edit
/// redirect so a fork mid-screen keeps resolving.
private var routine: Routine? {
let id = syncEngine.currentRoutineID(for: routineID)
return routines.first { $0.id == id }
}
var body: some View {
Group {
if let routine {
content(for: routine)
} else {
// The id no longer maps to a live routine (deleted elsewhere, or a
// transient mid-clone frame).
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
}
}
.sheet(isPresented: $showingRoutineEdit) {
if let routine { MacRoutineEditorSheet(routine: routine) }
}
.sheet(isPresented: $showingAddExercise) {
MacExerciseEditorSheet(routineID: syncEngine.currentRoutineID(for: routineID), exercise: nil)
}
.sheet(item: $exerciseToEdit) { pending in
if let exercise = routine?.exercisesArray.first(where: { $0.id == pending.id }), exercise.isLive {
MacExerciseEditorSheet(routineID: syncEngine.currentRoutineID(for: routineID), exercise: exercise)
}
}
.confirmationDialog(
"Delete Exercise?",
isPresented: Binding(
get: { pendingExerciseDelete != nil },
set: { if !$0 { pendingExerciseDelete = nil } }
),
titleVisibility: .visible,
presenting: pendingExerciseDelete
) { pending in
Button("Delete", role: .destructive) {
deleteExercise(id: pending.id)
pendingExerciseDelete = nil
}
Button("Cancel", role: .cancel) { pendingExerciseDelete = nil }
} message: { pending in
Text("Remove \"\(pending.name)\" from this routine?")
}
}
@ViewBuilder
private func content(for routine: Routine) -> some View {
List {
Section {
header(for: routine)
}
Section("Exercises") {
if routine.exercisesArray.isEmpty {
Text("No exercises added yet.")
.foregroundStyle(.secondary)
} else {
ForEach(routine.exercisesArray) { exercise in
ExerciseRow(exercise: exercise, weightUnit: weightUnit)
.contentShape(Rectangle())
.onTapGesture(count: 2) { exerciseToEdit = PendingExerciseEdit(id: exercise.id) }
.contextMenu {
Button("Edit…") { exerciseToEdit = PendingExerciseEdit(id: exercise.id) }
Button("Duplicate") { duplicateExercise(id: exercise.id) }
Divider()
Button("Delete", role: .destructive) {
pendingExerciseDelete = PendingExerciseDelete(id: exercise.id, name: exercise.name)
}
}
}
.onMove { source, destination in
moveExercises(routine: routine, from: source, to: destination)
}
}
}
}
.navigationTitle(routine.name)
.toolbar {
ToolbarItem {
Button {
showingAddExercise = true
} label: {
Label("Add Exercise", systemImage: "plus")
}
}
}
}
private func header(for routine: Routine) -> some View {
HStack(spacing: 14) {
Image(systemName: routine.systemImage)
.font(.largeTitle)
.foregroundStyle(Color.color(from: routine.color))
.frame(width: 56, height: 56)
.background(Color.color(from: routine.color).opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
VStack(alignment: .leading, spacing: 4) {
Text(routine.name)
.font(.title2.weight(.semibold))
Label(routine.activityTypeEnum.displayName, systemImage: routine.activityTypeEnum.systemImage)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button {
showingRoutineEdit = true
} label: {
Label("Edit", systemImage: "pencil")
}
}
.padding(.vertical, 6)
}
// MARK: - Mutations (all rebuild the document, never the cache)
/// Reorder and renumber the exercises, then save. Resolves the routine at call time
/// so it follows a clone-on-edit. Stamps `updatedAt` — an exercise reorder is a real
/// content edit that should fork a starter (unlike a routine-list reorder).
private func moveExercises(routine: Routine, from source: IndexSet, to destination: Int) {
var ordered = routine.exercisesArray
ordered.move(fromOffsets: source, toOffset: destination)
var doc = RoutineDocument(from: routine)
doc.exercises = ordered.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex)
ed.order = i
return ed
}
doc.updatedAt = Date()
Task { await syncEngine.save(routine: doc) }
}
private func deleteExercise(id: String) {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
doc.exercises.removeAll { $0.id == id }
for i in doc.exercises.indices { doc.exercises[i].order = i }
doc.updatedAt = Date()
Task { await syncEngine.save(routine: doc) }
}
/// Copy an exercise in place, right after itself (the interval-segment case). The
/// clone is an exact duplicate under a fresh id; renaming it is a follow-up edit.
/// Mirrors iOS `RoutineDetailView.duplicateExercise`.
private func duplicateExercise(id: String) {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
guard let idx = doc.exercises.firstIndex(where: { $0.id == id }) else { return }
var copy = doc.exercises[idx]
copy.id = ULID.make()
doc.exercises.insert(copy, at: idx + 1)
for i in doc.exercises.indices { doc.exercises[i].order = i }
doc.updatedAt = Date()
Task { await syncEngine.save(routine: doc) }
}
}
/// One exercise row: name over a one-line plan summary (`ExerciseDocument+PlanSummary`
/// parity via `Exercise.planSummary`).
private struct ExerciseRow: View {
let exercise: Exercise
let weightUnit: WeightUnit
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(exercise.name)
Text(exercise.planSummary(weightUnit: weightUnit))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}