import SwiftUI import SwiftData /// A schedule flagged for deletion — captured as plain values (id + a display 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 schedule by id at delete time. Mirrors /// `MacContentView.PendingRoutineDelete`. private struct PendingScheduleDelete: Identifiable { let id: String let name: String } /// A schedule flagged for editing — captured as just its id, not a retained `@Model` /// (see `PendingScheduleDelete`). The sheet re-resolves the live schedule 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 PendingScheduleEdit: Identifiable { let id: String } /// The Schedules management pane: every `Schedules/.json` document as a row /// (routine name, recurrence summary, reminder time, goal tag), with add/edit/delete. /// All writes go through `syncEngine.save(schedule:)` / `delete(schedule:)` — never the /// SwiftData cache directly. Mac does not schedule notifications; the iPhone's /// `ReminderScheduler` resyncs from the document once it observes the change. Mirrors /// iOS `TodayView`'s schedule rows, sans the per-day "today" status (Mac doesn't run /// workouts). struct MacSchedulesView: View { @Environment(SyncEngine.self) private var syncEngine // Mirrors iOS `TodayView`'s schedule ordering. @Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)]) private var schedules: [Schedule] @Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)]) private var routines: [Routine] @State private var showingNewSchedule = false @State private var scheduleToEdit: PendingScheduleEdit? @State private var pendingDelete: PendingScheduleDelete? var body: some View { Group { if schedules.isEmpty { ContentUnavailableView( "No Schedules", systemImage: "calendar", description: Text("Schedule a routine to see it here.") ) } else { List(schedules) { schedule in ScheduleRow(schedule: schedule, routine: resolvedRoutine(for: schedule)) .contentShape(Rectangle()) .onTapGesture(count: 2) { scheduleToEdit = PendingScheduleEdit(id: schedule.id) } .contextMenu { Button("Edit…") { scheduleToEdit = PendingScheduleEdit(id: schedule.id) } Divider() Button("Delete", role: .destructive) { pendingDelete = PendingScheduleDelete( id: schedule.id, name: resolvedRoutine(for: schedule)?.name ?? schedule.routineName) } } } } } .navigationTitle("Schedules") .toolbar { ToolbarItem { Button { showingNewSchedule = true } label: { Label("New Schedule…", systemImage: "plus") } .help("New Schedule") } } .sheet(isPresented: $showingNewSchedule) { MacScheduleEditorSheet(schedule: nil) } .sheet(item: $scheduleToEdit) { pending in if let schedule = schedules.first(where: { $0.id == pending.id }), schedule.isLive { MacScheduleEditorSheet(schedule: schedule) } } .confirmationDialog( "Delete Schedule?", 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("This removes \"\(pending.name)\" from your schedule. The routine itself is not affected.") } } // MARK: - Actions /// The live routine this schedule points at, following the seed clone-on-edit /// redirect; nil when the routine has been deleted. Mirrors iOS /// `TodayView.resolvedRoutine(for:)`. private func resolvedRoutine(for schedule: Schedule) -> Routine? { let liveID = syncEngine.currentRoutineID(for: schedule.routineID) return routines.first { $0.id == liveID } } private func confirmDelete(_ pending: PendingScheduleDelete) { if let schedule = schedules.first(where: { $0.id == pending.id }), schedule.isLive { Task { await syncEngine.delete(schedule: schedule) } } pendingDelete = nil } } /// One schedule row: a color badge (the routine's own icon/color, falling back to the /// goal's color — or plain secondary — when the routine is gone), the routine name /// (falling back to the schedule's denormalized `routineName`), the recurrence summary, /// an optional goal tag, and the reminder time if one is set. private struct ScheduleRow: View { let schedule: Schedule /// The resolved live routine, or nil when it's been deleted — a dangling /// `routineID` is handled the same way iOS's `TodayView` handles it: fall back to /// the schedule's own denormalized name/goal color rather than showing an error. let routine: Routine? var body: some View { HStack(spacing: 12) { Image(systemName: routine?.systemImage ?? "figure.strengthtraining.traditional") .font(.title3) .foregroundStyle(badgeColor) .frame(width: 32, height: 32) .background(badgeColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) VStack(alignment: .leading, spacing: 2) { Text(routine?.name ?? schedule.routineName) HStack(spacing: 8) { Text(schedule.recurrenceSummary) .font(.caption) .foregroundStyle(.secondary) if let goal = schedule.goalKind { Label(goal.displayName, systemImage: goal.systemImage) .font(.caption) .foregroundStyle(Color.color(from: goal.colorName)) } } } Spacer() if let minutes = schedule.reminderMinutes { Label(Self.timeString(fromMinutes: minutes), systemImage: "bell.fill") .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 3) } private var badgeColor: Color { if let routine { return Color.color(from: routine.color) } return Color.color(from: schedule.goalKind?.colorName ?? "") } /// Render minutes-from-midnight (local wall-clock) as a short time string, e.g. /// "8:00 AM" — the same interpretation `ScheduleAddEditView` writes on save. private static func timeString(fromMinutes minutes: Int) -> String { ScheduleEditing.time(fromMinutes: minutes).formattedTime() } }