// Views/Dashboard/ScheduledInspectionsView.swift // ---------------------------------------------- // Displays the inspector's planned/recurring inspection assignments (phase36), // pulled read-only from GET /api/v1/scheduled-inspections by // SyncManager.pullScheduledInspections(). // // Two consumers share one ScheduledRow: // • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView. // Presentational only; it reports taps via `onStart` and DashboardStatsView // owns the .fullScreenCover on its always-present ScrollView. // • MyInspectionsView renders its own "Scheduled" List section inline, // reusing ScheduledRow, with the start cover on the enclosing Group. // Both self-hide when there are no scheduled inspections and present // StartInspectionView (facility + template preselected) when a row is tapped. // Both cover owners are views that outlive the schedule rows themselves — // submitting the last scheduled inspection empties the @Query while the cover is // still up, so a cover owned by the self-hiding card would be torn down with it. // // The schedule lifecycle stays server-driven: the submission carries // `scheduled_inspection_id` and the server deactivates (one-time) or rolls // forward (recurring). ExecuteInspectionView only invalidates the local cache // row; pullScheduledInspections() re-reads the authoritative state. import SwiftUI import SwiftData // MARK: - Start target snapshot /// Plain-value snapshot of the tapped schedule, used as the `.fullScreenCover` /// item instead of the `LocalScheduledInspection` itself. /// /// The model object is unsafe to hold across the presentation: the cached row is /// deleted while the cover is still on screen — by `resolveAndFulfillSchedule()` /// the instant Submit is tapped, and by `pullScheduledInspections()` once the /// server stops returning it. Reading a deleted `PersistentModel` traps, and a /// `@Query` that empties out would also tear the cover down mid-submit. Copying /// the values at tap time removes both hazards. struct ScheduledStartTarget: Identifiable { /// Schedule `serverId` — also the identity for `.fullScreenCover(item:)`. let id: Int let templateServerId: Int let facilityServerId: Int /// Manager-authored instructions for this occurrence. Server field is still /// `notes` (API key `notes`, column `scheduled_inspections.notes`); only the /// user-facing wording is "Instructions". let instructions: String? init(_ schedule: LocalScheduledInspection) { self.id = schedule.serverId self.templateServerId = schedule.templateServerId self.facilityServerId = schedule.facilityServerId self.instructions = schedule.instructions } } // MARK: - Shared row struct ScheduledRow: View { let schedule: LocalScheduledInspection private var dueText: String { if let d = schedule.nextDue { return d.formatted(date: .abbreviated, time: .omitted) } return schedule.dueDateString.isEmpty ? "—" : schedule.dueDateString } var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: "calendar.badge.clock") .font(.title3) .foregroundStyle(schedule.isOverdue ? .red : .blue) .padding(.top, 2) VStack(alignment: .leading, spacing: 3) { Text(schedule.templateName.isEmpty ? "Inspection" : schedule.templateName) .font(.callout.bold()) Text(schedule.facilityName.isEmpty ? "Facility" : schedule.facilityName) .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 8) { if schedule.isOverdue { Text("Overdue") .font(.caption2.bold()) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.red.opacity(0.15)) .foregroundStyle(.red) .clipShape(Capsule()) } Text("Due \(dueText)") .font(.caption2) .foregroundStyle(schedule.isOverdue ? .red : .secondary) if !schedule.frequencyLabel.isEmpty { Text("· \(schedule.frequencyLabel)") .font(.caption2) .foregroundStyle(.tertiary) } } // Instructions preview — so the inspector can see there is // something to read before committing to the tap. Truncated to // one line; the full text is shown on the start screen and // again above the form itself. if let instructions = schedule.instructions { HStack(alignment: .top, spacing: 4) { Image(systemName: "info.circle.fill") .font(.caption2) .foregroundStyle(.blue) Text(instructions) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) } .padding(.top, 1) } } Spacer(minLength: 8) Label("Start", systemImage: "play.fill") .font(.caption.bold()) .foregroundStyle(.white) .padding(.horizontal, 10).padding(.vertical, 5) .background(schedule.isOverdue ? Color.red : Color.blue) .clipShape(Capsule()) } .contentShape(Rectangle()) } } // MARK: - Dashboard card (VStack) struct ScheduledInspectionsCard: View { @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) private var scheduled: [LocalScheduledInspection] /// Tap handler. The `.fullScreenCover` deliberately lives in the PARENT /// (`DashboardStatsView`, on its always-present ScrollView) rather than here: /// this card self-hides, and submitting the last scheduled inspection removes /// the final row while the cover is still on screen. A cover owned by a view /// that disappears is torn down with it, yanking the form away from the /// inspector mid-submit. Keeping the card purely presentational also keeps /// the empty case a true `EmptyView`, so the dashboard stack adds no spacing. let onStart: (ScheduledStartTarget) -> Void var body: some View { if !scheduled.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("SCHEDULED") .font(.caption.bold()) .foregroundStyle(.secondary) .tracking(1) ForEach(scheduled) { s in Button { onStart(ScheduledStartTarget(s)) } label: { ScheduledRow(schedule: s) .padding(12) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) } } } } }