// Views/Dashboard/MyInspectionsView.swift import SwiftUI import SwiftData import MessageUI // MARK: - My Inspections struct MyInspectionsView: View { @Query( filter: #Predicate { $0.status != "synced" }, sort: \LocalInspection.lastModifiedAt, order: .reverse ) private var inspections: [LocalInspection] /// Scheduled assignments (phase36) — rendered as the top section and used /// for the empty-state decision. Sorted by due date (ISO strings sort /// chronologically). @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) private var scheduledAll: [LocalScheduledInspection] /// Rows still awaiting action — see ScheduledInspectionsCard.visible. private var scheduledVisible: [LocalScheduledInspection] { scheduledAll.filter { !$0.fulfilledLocally } } /// Follow-up requests raised on the web — rendered as the top section, above /// Scheduled, and counted in the empty-state decision. Sorted by the flagged /// inspection's date (ISO strings sort chronologically), oldest first: the /// longest-outstanding request is the one to clear next. @Query(sort: \LocalFollowUpRequest.inspectionDateString, order: .forward) private var followUpsAll: [LocalFollowUpRequest] /// Rows still awaiting action — see FollowUpRequestsCard.visible. private var followUpsVisible: [LocalFollowUpRequest] { followUpsAll.filter { !$0.fulfilledLocally } } @Environment(\.modelContext) private var context @State private var showNewInspection = false @State private var scheduledStartTarget: ScheduledStartTarget? @State private var followUpStartTarget: FollowUpStartTarget? // Deletion confirmation state @State private var pendingDelete: LocalInspection? @State private var showDeleteAlert = false var body: some View { Group { if inspections.isEmpty && scheduledVisible.isEmpty && followUpsVisible.isEmpty { ContentUnavailableView( "No Inspections", systemImage: "checklist", description: Text("Tap + to start a new inspection.") ) } else { List { // Follow-up requests — self-hides when empty. First section: // remedial work on a facility that already failed once // outranks a routine scheduled visit. if !followUpsVisible.isEmpty { Section("Follow-up Requested") { ForEach(followUpsVisible) { r in Button { followUpStartTarget = FollowUpStartTarget(r) } label: { FollowUpRow(request: r) } .buttonStyle(.plain) } } } // Scheduled assignments (phase36) — self-hides when empty. if !scheduledVisible.isEmpty { Section("Scheduled") { ForEach(scheduledVisible) { s in Button { scheduledStartTarget = ScheduledStartTarget(s) } label: { ScheduledRow(schedule: s) } .buttonStyle(.plain) } } } if !inspections.isEmpty { Section("In Progress") { ForEach(inspections) { inspection in NavigationLink(value: inspection) { InspectionRowView(inspection: inspection, context: context) } // Only drafts may be deleted — submitted/pending-sync inspections are kept .swipeActions(edge: .trailing, allowsFullSwipe: false) { if inspection.status == "draft" { Button(role: .destructive) { pendingDelete = inspection showDeleteAlert = true } label: { Label("Delete", systemImage: "trash") } } } } } } } } } // Cover attached to the enclosing Group, not the List and never a // Section (rule 64). The List itself is conditional: submitting the last // scheduled inspection can flip this view to ContentUnavailableView while // the cover is still presented, which would tear the form down mid-submit. // The Group is always present. .fullScreenCover(item: $scheduledStartTarget) { t in StartInspectionView( preFillTemplateId: t.templateServerId, preFillFacilityId: t.facilityServerId, // phase45 — nil for an ordinary schedule; set when this row is a // planned follow-up, which makes the run a linked re-inspection. // Must precede preFillScheduleId: argument order follows the // property declaration order in StartInspectionView. parentServerId: t.parentServerId, preFillScheduleId: t.id, preFillScheduleInstructions: t.instructions ) } // Also on the Group, not the List — see the comment above. Submitting the // last follow-up can flip this view to ContentUnavailableView while the // cover is still presented. `parentServerId` is what links the run back // to the flagged inspection; see the twin cover in DashboardStatsView. .fullScreenCover(item: $followUpStartTarget) { t in StartInspectionView( preFillTemplateId: t.templateServerId, preFillFacilityId: t.facilityServerId, parentServerId: t.id, preFillFollowUpNote: t.note, preFillParentFormDataJSON: t.parentFormDataJSON ) } .navigationTitle("My Inspections") // Confirmation before deletion — destructive action cannot be undone .alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in Button("Delete", role: .destructive) { deleteDraft(inspection) } Button("Cancel", role: .cancel) { pendingDelete = nil } } message: { inspection in Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.") } .toolbar { ToolbarItem(placement: .primaryAction) { // Labelled, not a bare "+". `.titleAndIcon` is required: // SwiftUI collapses a toolbar Label to icon-only on its own, // which is what made this read as an unlabelled plus sign. Button { showNewInspection = true } label: { Label("New Inspection", systemImage: "plus") } .labelStyle(.titleAndIcon) .buttonStyle(.borderedProminent) } } .fullScreenCover(isPresented: $showNewInspection) { StartInspectionView() } } private func draftName(_ inspection: LocalInspection) -> String { let templateId = inspection.templateServerId // plain Int — safe to capture in #Predicate return (try? context.fetch( FetchDescriptor( predicate: #Predicate { $0.serverId == templateId } ) ).first?.name) ?? "this inspection" } private func deleteDraft(_ inspection: LocalInspection) { // Delete associated pending photos from disk and SwiftData for photo in inspection.pendingPhotos { PhotoStore.remove(at: photo.localFilePath) context.delete(photo) } // Delete associated local issues for issue in inspection.localIssues { for path in issue.photoLocalPaths { PhotoStore.remove(at: path) } context.delete(issue) } context.delete(inspection) try? context.save() pendingDelete = nil } } struct InspectionRowView: View { let inspection: LocalInspection let context: ModelContext private var facilityName: String { let id = inspection.facilityServerId return (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first?.name) ?? "Unknown Facility" } private var templateName: String { let id = inspection.templateServerId return (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first?.name) ?? "Unknown Template" } var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(templateName).font(.headline) Spacer() StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) } Text(facilityName).font(.callout).foregroundStyle(.secondary) HStack { Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened)) .font(.caption2).foregroundStyle(.tertiary) if let score = inspection.overallScore { Spacer() Text(String(format: "%.1f%%", score)) .font(.caption).fontWeight(.medium) .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) } } // ── Follow-up badge ──────────────────────────────────────────── if inspection.followUpRequired { HStack(spacing: 4) { Image(systemName: "exclamationmark.arrow.circlepath") .font(.caption2) Text("Follow-up Required") .font(.caption2.bold()) } .padding(.horizontal, 8).padding(.vertical, 3) .background(Color.orange.opacity(0.15)) .foregroundStyle(.orange) .clipShape(Capsule()) } } .padding(.vertical, 4) } } struct StatusBadge: View { let status: String let syncStatus: String var label: String { switch status { case "draft": return "Draft" case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed" case "failed": return "Sync Failed" default: return status.capitalized } } var color: Color { switch status { case "draft": return .blue case "completed": return syncStatus == "pending" ? .orange : .green case "failed": return .red default: return .secondary } } var body: some View { Text(label) .font(.caption2) .padding(.horizontal, 8).padding(.vertical, 3) .background(color.opacity(0.15)) .foregroundStyle(color) .clipShape(Capsule()) } } // MARK: - Completed Inspection (read-only) struct CompletedInspectionView: View { let inspection: LocalInspection @Environment(\.modelContext) private var context @State private var showReInspect = false private var templateName: String { let id = inspection.templateServerId return (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first?.name) ?? "Inspection" } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { // ── Follow-up required banner ────────────────────────────── if inspection.followUpRequired { HStack(alignment: .top, spacing: 12) { Image(systemName: "exclamationmark.arrow.circlepath") .foregroundStyle(.orange) .font(.title3) VStack(alignment: .leading, spacing: 4) { Text("Follow-up Inspection Required") .font(.callout.bold()) .foregroundStyle(.orange) if let note = inspection.followUpNote, !note.isEmpty { Text(note) .font(.callout) .foregroundStyle(.secondary) } Button { showReInspect = true } label: { Label("Start Re-inspection", systemImage: "arrow.uturn.right.circle.fill") .font(.callout.bold()) } .buttonStyle(.borderedProminent) .tint(.orange) .padding(.top, 4) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal) } // ── Is a re-inspection — parent link ─────────────────────── if let parentId = inspection.parentServerId { HStack(spacing: 10) { Image(systemName: "arrow.uturn.right.circle") .foregroundStyle(.secondary) Text("Re-inspection of inspection #\(parentId)") .font(.callout) .foregroundStyle(.secondary) } .padding(.horizontal) } GroupBox { VStack(alignment: .leading, spacing: 8) { if let score = inspection.overallScore { HStack { Text("Overall Score").font(.subheadline).foregroundStyle(.secondary) Spacer() Text(String(format: "%.1f%%", score)) .font(.title2.bold()) .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) } } if let completedAt = inspection.completedAt { HStack { Text("Completed").font(.subheadline).foregroundStyle(.secondary) Spacer() Text(completedAt.formatted(date: .abbreviated, time: .shortened)) .font(.callout) } } HStack { Text("Sync Status").font(.subheadline).foregroundStyle(.secondary) Spacer() StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) } if let error = inspection.syncErrorMessage { Text("Error: \(error)").font(.caption).foregroundStyle(.red) } } } .padding(.horizontal) if !inspection.localIssues.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Flagged Issues (\(inspection.localIssues.count))") .font(.headline).padding(.horizontal) ForEach(inspection.localIssues) { issue in HStack(alignment: .top, spacing: 12) { Circle() .fill(issue.severity == "critical" ? Color.red : issue.severity == "high" ? Color.orange : issue.severity == "medium" ? Color.yellow : Color.blue) .frame(width: 8, height: 8).padding(.top, 4) VStack(alignment: .leading, spacing: 2) { Text(issue.severity.capitalized) .font(.caption.bold()).foregroundStyle(.secondary) Text(issue.issueDescription).font(.callout) } } .padding(.horizontal) } } } } .padding(.vertical) } .navigationTitle(templateName) .navigationBarTitleDisplayMode(.inline) .fullScreenCover(isPresented: $showReInspect) { StartInspectionView( preFillTemplateId: inspection.templateServerId, preFillFacilityId: inspection.facilityServerId, parentServerId: inspection.serverId, parentLocalId: inspection.localId ) } } }