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