// Views/Inspection/InspectionHistoryView.swift // -------------------------------------------- // Shows the inspector's synced inspection history fetched from the server. // Only available when online. Displays score, facility, template, and date. import SwiftUI import SwiftData import MessageUI struct InspectionHistoryView: View { @EnvironmentObject private var sync: SyncManager @State private var inspections: [APIInspectionSummary] = [] @State private var isLoading = false @State private var errorMessage: String? @State private var total = 0 @State private var offset = 0 private let limit = 30 @State private var searchText = "" @State private var showFilterSheet = false @State private var filterFromDate: Date? = nil @State private var filterToDate: Date? = nil // Draft values edited in the sheet before applying @State private var draftFromDate: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() @State private var draftToDate: Date = Date() @State private var draftFromEnabled = false @State private var draftToEnabled = false private var hasActiveFilter: Bool { filterFromDate != nil || filterToDate != nil } /// Date formatter for search matching — formats to e.g. "Jun 19, 2026 4:55 PM" /// so partial strings like "jun", "2026", "19" all match. private static let searchDateFmt: DateFormatter = { let f = DateFormatter() f.dateStyle = .medium f.timeStyle = .short return f }() private func dateString(for insp: APIInspectionSummary) -> String { guard let d = insp.inspectionDateParsed else { return insp.inspectionDate ?? "" } return Self.searchDateFmt.string(from: d) } /// Client-side filter over the already-fetched page. /// Matches: ID (exact or #-prefixed), date string (partial), facility /// name, area name (together = "location"). Load More still fetches more /// server pages so the inspector isn't limited to the first 30 results /// when searching by date or location. private var filteredInspections: [APIInspectionSummary] { guard !searchText.isEmpty else { return inspections } let q = searchText.lowercased() return inspections.filter { // ID — match "#12" or bare "12" let idStr = String($0.id) let idMatch = idStr == q || idStr == q.replacingOccurrences(of: "#", with: "") // Location = facility name + area name let locationMatch = $0.facilityName.lowercased().contains(q) || ($0.areaName?.lowercased().contains(q) ?? false) return idMatch || locationMatch || dateString(for: $0).lowercased().contains(q) || $0.templateName.lowercased().contains(q) } } var body: some View { Group { if !sync.isOnline && inspections.isEmpty { ContentUnavailableView( "Offline", systemImage: "wifi.slash", description: Text("Inspection history requires an internet connection.") ) } else if isLoading && inspections.isEmpty { ProgressView("Loading history…") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = errorMessage, inspections.isEmpty { ContentUnavailableView( "Could Not Load", systemImage: "exclamationmark.triangle", description: Text(error) ) } else if inspections.isEmpty { ContentUnavailableView( "No History", systemImage: "clock.arrow.circlepath", description: Text("Completed inspections will appear here after syncing.") ) } else if filteredInspections.isEmpty { ContentUnavailableView.search(text: searchText) } else { List { ForEach(filteredInspections) { inspection in NavigationLink(value: inspection) { HistoryRowView(inspection: inspection) } } // Load More — still available while searching so the // inspector can expand beyond the current page. if inspections.count < total { HStack { Spacer() Button("Load More") { Task { await loadMore() } } .disabled(isLoading) Spacer() } .listRowSeparator(.hidden) } if isLoading { HStack { Spacer() ProgressView() Spacer() } .listRowSeparator(.hidden) } } .refreshable { await load(reset: true) } } } .navigationTitle("Inspection History") .searchable(text: $searchText, prompt: "Search ID, date, facility, area…") .toolbar { ToolbarItem(placement: .primaryAction) { Button { showFilterSheet = true } label: { Image(systemName: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") .foregroundStyle(hasActiveFilter ? .orange : .primary) } } } .sheet(isPresented: $showFilterSheet) { DateFilterSheet( fromEnabled: $draftFromEnabled, fromDate: $draftFromDate, toEnabled: $draftToEnabled, toDate: $draftToDate ) { // Apply — update active filters and reload from server filterFromDate = draftFromEnabled ? draftFromDate : nil filterToDate = draftToEnabled ? draftToDate : nil showFilterSheet = false Task { await load(reset: true) } } onClear: { draftFromEnabled = false draftToEnabled = false filterFromDate = nil filterToDate = nil showFilterSheet = false Task { await load(reset: true) } } onCancel: { // Discard the draft edits, keep whatever is currently applied, // and do NOT reload — backing out must change nothing. draftFromEnabled = filterFromDate != nil draftToEnabled = filterToDate != nil if let f = filterFromDate { draftFromDate = f } if let t = filterToDate { draftToDate = t } showFilterSheet = false } } .task { if sync.isOnline { await load(reset: true) } } .onChange(of: sync.isOnline) { if sync.isOnline && inspections.isEmpty { Task { await load(reset: true) } } } } private func load(reset: Bool) async { if reset { offset = 0 } isLoading = true errorMessage = nil defer { isLoading = false } do { let result = try await APIClient.shared.fetchInspectionHistory( limit: limit, offset: reset ? 0 : offset, fromDate: filterFromDate, toDate: filterToDate ) if reset { inspections = result.inspections } else { inspections.append(contentsOf: result.inspections) } total = result.total offset = inspections.count } catch { errorMessage = error.localizedDescription } } private func loadMore() async { await load(reset: false) } } // MARK: - Date Filter Sheet struct DateFilterSheet: View { @Binding var fromEnabled: Bool @Binding var fromDate: Date @Binding var toEnabled: Bool @Binding var toDate: Date let onApply: () -> Void let onClear: () -> Void /// Dismiss without touching the active filter. Distinct from `onClear`: /// Cancel used to call that, so backing out of the sheet silently wiped /// whatever date range was already applied and reloaded the list. let onCancel: () -> Void var body: some View { NavigationStack { Form { Section("From Date") { Toggle("Filter by start date", isOn: $fromEnabled) if fromEnabled { DatePicker("From", selection: $fromDate, displayedComponents: .date) .datePickerStyle(.compact) } } Section("To Date") { Toggle("Filter by end date", isOn: $toEnabled) if toEnabled { DatePicker("To", selection: $toDate, displayedComponents: .date) .datePickerStyle(.compact) } } Section { Button("Clear Filters", role: .destructive, action: onClear) } } .navigationTitle("Date Filter") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { onCancel() } } ToolbarItem(placement: .confirmationAction) { Button("Apply", action: onApply) .bold() } } } .presentationDetents([.medium]) } } // MARK: - History Row struct HistoryRowView: View { let inspection: APIInspectionSummary private var scoreColor: Color { guard let score = inspection.overallScore else { return .secondary } return score >= 80 ? .green : score >= 60 ? .orange : .red } private var dateText: String { guard let date = inspection.inspectionDateParsed else { return inspection.inspectionDate ?? "" } return date.formatted(date: .abbreviated, time: .shortened) } var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 2) { Text(inspection.templateName) .font(.headline) .lineLimit(1) Text(inspection.facilityName) .font(.callout) .foregroundStyle(.secondary) .lineLimit(1) if let area = inspection.areaName { Text(area) .font(.caption) .foregroundStyle(.tertiary) } } Spacer() if let score = inspection.overallScore { VStack(alignment: .trailing, spacing: 2) { Text(String(format: "%.1f%%", score)) .font(.title3.bold()) .foregroundStyle(scoreColor) Text("Score") .font(.caption2) .foregroundStyle(.tertiary) } } } HStack { Image(systemName: "calendar") .font(.caption2) .foregroundStyle(.tertiary) Text(dateText) .font(.caption2) .foregroundStyle(.tertiary) Spacer() // Sync origin badge if inspection.mobileLocalId != nil { Label("Mobile", systemImage: "ipad") .font(.caption2) .foregroundStyle(.blue) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.blue.opacity(0.1)) .clipShape(Capsule()) } } // ── 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) } } // MARK: - History Detail View // Shows submitted inspection details. // For inspections originally submitted from this device (matched via mobileLocalId), // the filled-in form responses are shown using the same grid as ExecuteInspectionView. // For inspections submitted elsewhere, only summary fields are shown. struct HistoryDetailView: View { let inspection: APIInspectionSummary @Environment(\.modelContext) private var context @EnvironmentObject private var auth: AuthManager @EnvironmentObject private var sync: SyncManager @State private var showReInspect = false @State private var showMailCompose = false @State private var isGeneratingPDF = false @State private var generatedPDFData: Data? = nil // ── Schedule Follow-up (phase45) ────────────────────────────────────── @State private var showScheduleSheet = false /// Defaults to tomorrow: the point of this action is to plan the follow-up /// for another day. Today is still selectable — the server allows it. @State private var followUpDate = Calendar.current.date( byAdding: .day, value: 1, to: Date() ) ?? Date() @State private var followUpNotes = "" @State private var isSchedulingFollowUp = false @State private var scheduleError: String? = nil @State private var scheduleConfirmation: String? = nil /// Auditors are read-only everywhere else and the API rejects them (403), /// so the two action buttons are hidden rather than shown failing. /// /// `issueActors` is the same set minus auditor, and — unlike the literal /// list this replaced — it includes Customer Inspectors, who perform /// inspections exactly as our own do. private var canStartFollowUp: Bool { Constants.Roles.issueActors.contains(auth.currentUserRole) } // Local SwiftData copy — used only for follow-up sync-back. // Form data and schema come from the server response directly so // History works even after app reinstall or on a different device. @State private var localCopy: LocalInspection? = nil private var formSchema: [[String: Any]] { inspection.formSchema } private var savedValues: [String: String] { inspection.formValues } /// Load the local SwiftData copy once on appear (for follow-up sync only). /// Fetch-all + filter in Swift — #Predicate with captured String is banned /// under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (CLAUDE.md rules 3, 25). private func loadLocalData() { guard let lid = inspection.mobileLocalId else { return } let all = (try? context.fetch(FetchDescriptor())) ?? [] localCopy = all.first { $0.localId == lid } } 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, 24) } // ── Is a re-inspection — parent link ─────────────────────── if let parentId = inspection.parentInspectionId { infoRow(icon: "arrow.uturn.right.circle", text: "Re-inspection of inspection #\(parentId)") .padding(.horizontal, 24) } // ── Summary card ─────────────────────────────────────────── summaryCard // ── Flagged issues ───────────────────────────────────────── if let copy = localCopy, !copy.localIssues.isEmpty { issuesCard(copy.localIssues) } // ── Inspector notes ──────────────────────────────────────── if !inspection.inspectorNotes.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("Inspector Notes") .font(.headline) .padding(.horizontal, 24) Text(inspection.inspectorNotes) .font(.callout) .foregroundStyle(.primary) .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 24) } } // ── Form responses ───────────────────────────────────────── if !formSchema.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Form Responses") .font(.headline) .padding(.horizontal, 24) ReadOnlyGridFormView( schema: formSchema, formValues: savedValues ) .environment(\.mediaURLByPath, inspection.mediaURLByPath) .padding(.horizontal, 24) } } } .padding(.vertical, 16) } .background(Color(.systemBackground)) .navigationTitle(inspection.templateName) .navigationBarTitleDisplayMode(.inline) .toolbar { // ── Re-inspect now ──────────────────────────────────────────── // The immediate half of the follow-up pair. Opens the same linked // re-inspection flow the follow-up banner has always used, but // without waiting to be asked for one. if canStartFollowUp { ToolbarItem(placement: .primaryAction) { Button { showReInspect = true } label: { Label("Re-inspect Now", systemImage: "arrow.uturn.right.circle") } } // ── Schedule follow-up ──────────────────────────────────── // The deferred half. Needs the network: it creates a schedule // server-side rather than a local record, so unlike starting an // inspection it cannot be queued offline. ToolbarItem(placement: .primaryAction) { Button { scheduleError = nil showScheduleSheet = true } label: { Label("Schedule Follow-up", systemImage: "calendar.badge.plus") } .disabled(!sync.isOnline) } } ToolbarItem(placement: .primaryAction) { Button { Task { await prepareAndShowMail() } } label: { if isGeneratingPDF { ProgressView() } else { Label("Share via Email", systemImage: "envelope") } } .disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF) } } .sheet(isPresented: $showScheduleSheet) { scheduleFollowUpSheet } // Confirmation of a successful schedule. An alert rather than an inline // banner because the sheet has already dismissed by this point. .alert("Follow-up Scheduled", isPresented: Binding(get: { scheduleConfirmation != nil }, set: { if !$0 { scheduleConfirmation = nil } })) { Button("OK") { scheduleConfirmation = nil } } message: { Text(scheduleConfirmation ?? "") } .onAppear { loadLocalData() syncFollowUpToLocalCopy() } // Full-screen, not a sheet: every inspection-start flow is full-screen // (rule 66), and this one is now reachable from the toolbar on any // completed inspection rather than only the follow-up banner. .fullScreenCover(isPresented: $showReInspect) { StartInspectionView( preFillTemplateId: inspection.templateId, preFillFacilityId: inspection.facilityId, parentServerId: inspection.id, parentLocalId: inspection.mobileLocalId, // History is served from the API, so this inspection is often // not on this device at all and the local-parent lookup finds // nothing — the form would open blank (rule 79). The answers are // already in this very response, so pass them straight through. preFillParentFormDataJSON: parentFormDataJSON ) } .sheet(isPresented: $showMailCompose) { if let pdfData = generatedPDFData { MailComposeView( subject: emailSubject, body: emailBody, pdfData: pdfData, pdfFilename: "Inspection_\(inspection.id)_\(inspection.facilityName).pdf" .replacingOccurrences(of: " ", with: "_") ) } } } /// This inspection's answers, JSON-encoded for `StartInspectionView`'s /// parent prefill. Raw values, not the flattened `formValues`, so an array /// field survives as an array (rule 79). private var parentFormDataJSON: String { let raw = inspection.formDataRaw.mapValues(\.anyValue) guard JSONSerialization.isValidJSONObject(raw), let data = try? JSONSerialization.data(withJSONObject: raw), let str = String(data: data, encoding: .utf8) else { return "{}" } return str } // ── Schedule Follow-up sheet (phase45) ──────────────────────────────── /// Date + note picker for planning a follow-up re-inspection. /// /// Only the date and an optional note are collected: the server derives /// facility, template and assignee from the parent inspection, so there is /// nothing else for the inspector to get wrong. private var scheduleFollowUpSheet: some View { NavigationStack { Form { Section { Text(inspection.templateName) .font(.callout.bold()) Text(inspection.facilityName) .font(.caption) .foregroundStyle(.secondary) } header: { Text("Follow-up of Inspection #\(inspection.id)") } Section { DatePicker( "Due Date", selection: $followUpDate, in: Date()..., // the server rejects a past date displayedComponents: .date ) .datePickerStyle(.graphical) } header: { Text("When") } footer: { Text("The follow-up appears in Scheduled on this date, " + "assigned to the inspector who did the original.") } Section { TextField( "What should the follow-up address?", text: $followUpNotes, axis: .vertical ) .lineLimit(3...6) } header: { Text("Instructions (optional)") } if let err = scheduleError { Section { Label(err, systemImage: "exclamationmark.triangle") .font(.callout) .foregroundStyle(.red) } } } .navigationTitle("Schedule Follow-up") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showScheduleSheet = false } .disabled(isSchedulingFollowUp) } ToolbarItem(placement: .confirmationAction) { Button { Task { await submitScheduledFollowUp() } } label: { if isSchedulingFollowUp { ProgressView() } else { Text("Schedule") } } .disabled(isSchedulingFollowUp) } } } } /// Create the follow-up schedule on the server, then refresh so it appears /// in the Scheduled lists without waiting for the next timed sync. /// /// Online-only by nature: this writes a server-side plan, not a local /// record, so there is nothing meaningful to queue offline — the button is /// disabled when offline and this reports any failure inline rather than /// dismissing as if it had worked. private func submitScheduledFollowUp() async { isSchedulingFollowUp = true scheduleError = nil let due = Self.dueDateFormatter.string(from: followUpDate) do { _ = try await APIClient.shared.createScheduledFollowUp( parentInspectionId: inspection.id, dueDate: due, notes: followUpNotes ) // Pull the new schedule straight into the Scheduled section. await sync.pullScheduledInspections(context: context) isSchedulingFollowUp = false showScheduleSheet = false followUpNotes = "" scheduleConfirmation = "A follow-up re-inspection of \(inspection.facilityName) is scheduled for " + followUpDate.formatted(date: .abbreviated, time: .omitted) + "." } catch { isSchedulingFollowUp = false scheduleError = (error as? APIError)?.localizedDescription ?? "Could not schedule the follow-up. Check your connection and try again." } } /// `yyyy-MM-dd` for the API's `due_date`. Fixed POSIX locale so a non- /// Gregorian device calendar cannot emit a date the server can't parse. private static let dueDateFormatter: DateFormatter = { let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") f.dateFormat = "yyyy-MM-dd" return f }() /// Generates the PDF (fetching any server photos over the network), /// then presents the mail compose sheet with it attached. /// Photo fetches happen here, off the synchronous PDF drawing pass. private func prepareAndShowMail() async { isGeneratingPDF = true let data = await InspectionPDFGenerator.generate(inspection: inspection) generatedPDFData = data isGeneratingPDF = false showMailCompose = true } /// Write the server's follow-up fields back onto the local SwiftData copy /// so that MyInspectionsView and CompletedInspectionView reflect the latest state. private func syncFollowUpToLocalCopy() { guard let copy = localCopy else { return } var changed = false if copy.followUpRequired != inspection.followUpRequired { copy.followUpRequired = inspection.followUpRequired changed = true } if copy.followUpNote != inspection.followUpNote { copy.followUpNote = inspection.followUpNote changed = true } if copy.parentServerId != inspection.parentInspectionId { copy.parentServerId = inspection.parentInspectionId changed = true } if changed { try? context.save() } } // ── Email content ─────────────────────────────────────────────────────── private var emailSubject: String { "Inspection Report — \(inspection.facilityName) (\(inspection.templateName))" } private var emailBody: String { var lines: [String] = [] lines.append("INSPECTION REPORT") lines.append(String(repeating: "=", count: 40)) lines.append("") lines.append("Template : \(inspection.templateName)") lines.append("Facility : \(inspection.facilityName)") if let area = inspection.areaName { lines.append("Area : \(area)") } if let date = inspection.inspectionDateParsed { lines.append("Date : \(date.formatted(date: .long, time: .shortened))") } if let score = inspection.overallScore { lines.append("Score : \(String(format: "%.1f%%", score))") } if inspection.followUpRequired { lines.append("Follow-up: REQUIRED") if let note = inspection.followUpNote, !note.isEmpty { lines.append(" \(note)") } } if !inspection.inspectorNotes.isEmpty { lines.append("") lines.append("INSPECTOR NOTES") lines.append(String(repeating: "-", count: 40)) lines.append(inspection.inspectorNotes) } lines.append("") lines.append("— Sent from JanitorialQC Inspector") return lines.joined(separator: "\n") } // ── Summary card ─────────────────────────────────────────────────────── private var summaryCard: some View { VStack(alignment: .leading, spacing: 12) { // Score 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) } } Divider() infoRow(icon: "building.2", text: inspection.facilityName) if let area = inspection.areaName { infoRow(icon: "mappin", text: area) } if let date = inspection.inspectionDateParsed { infoRow(icon: "calendar", text: date.formatted(date: .long, time: .shortened)) } if inspection.mobileLocalId != nil { infoRow(icon: "ipad", text: "Submitted from this device") } } .padding(16) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 24) } // ── Flagged issues card ──────────────────────────────────────────────── private func issuesCard(_ issues: [LocalIssue]) -> some View { VStack(alignment: .leading, spacing: 10) { Text("Flagged Issues (\(issues.count))") .font(.headline) ForEach(issues) { issue in HStack(alignment: .top, spacing: 10) { 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, 5) VStack(alignment: .leading, spacing: 2) { Text(issue.severity.capitalized) .font(.caption.bold()) .foregroundStyle(.secondary) Text(issue.issueDescription) .font(.callout) } } } } .padding(16) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 24) } // ── Helper ───────────────────────────────────────────────────────────── private func infoRow(icon: String, text: String) -> some View { HStack(spacing: 10) { Image(systemName: icon) .foregroundStyle(.secondary) .frame(width: 18) Text(text) .font(.callout) .foregroundStyle(.primary) } } } // MARK: - ReadOnlyGridFormView // Renders answered form fields row by row. // Each original schema row becomes one HStack; each field is sized // proportionally to its colSpan (out of 12 columns). // Fields that start past col 1 get a leading spacer. // Only rows containing at least one answered field are shown. struct ReadOnlyGridFormView: View { let schema: [[String: Any]] let formValues: [String: String] @Environment(\.horizontalSizeClass) private var hSizeClass // ── Field visibility filtering ──────────────────────────────────────── // A row group: all visible fields that share the same original `row`. private struct RowGroup { let fields: [[String: Any]] // visible fields in this row, schema order } private var visibleRowGroups: [RowGroup] { let skipTypes: Set = ["label", "section", "button_submit", "button_print", "button_email"] // Sort schema by (row, col) once — same as web PDF and InspectionPDFGenerator. let sortedSchema = schema.sorted { let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0 if r0 != r1 { return r0 < r1 } let c0 = $0["col"] as? Int ?? 0, c1 = $1["col"] as? Int ?? 0 return c0 < c1 } // Pass 1 — answered data field IDs var answeredIds = Set() for f in sortedSchema { guard let ftype = f["type"] as? String, !skipTypes.contains(ftype) else { continue } let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" let val = formValues[fid] ?? "" let answered = ftype == "rating" ? (Int(val) ?? 0) > 0 : !val.isEmpty if answered { answeredIds.insert(fid) } } // Pass 2 — label IDs that immediately precede an answered field var visibleLabelIds = Set() var lbuf: [String] = [] for f in sortedSchema { let ftype = f["type"] as? String ?? "" let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" if ftype == "label" { lbuf.append(fid) } else if !skipTypes.contains(ftype) { if answeredIds.contains(fid) { visibleLabelIds.formUnion(lbuf) } lbuf.removeAll() } } // Pass 3 — section IDs that precede at least one answered field var visibleSectionIds = Set() var pendingSecId: String? = nil for f in sortedSchema { let ftype = f["type"] as? String ?? "" let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" if ftype == "section" { pendingSecId = fid } else if !skipTypes.contains(ftype), answeredIds.contains(fid) { if let sid = pendingSecId { visibleSectionIds.insert(sid); pendingSecId = nil } } } // Pass 4 — group fields by original row, keep schema order. // sortedSchema already computed above — reuse it. var rowGroups: [Int: [[String: Any]]] = [:] var rowOrder: [Int] = [] for f in sortedSchema { let row = f["row"] as? Int ?? 1 if rowGroups[row] == nil { rowOrder.append(row); rowGroups[row] = [] } rowGroups[row]!.append(f) } // Pass 5 — for each row, collect visible fields; skip rows with none. // Iterate in rowOrder (insertion order of sorted schema = row order). var result: [RowGroup] = [] for origRow in rowOrder { guard let group = rowGroups[origRow] else { continue } var visibleInRow: [[String: Any]] = [] for f in group { let ftype = f["type"] as? String ?? "" let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" switch ftype { case "button_submit", "button_print", "button_email": continue case "section": if visibleSectionIds.contains(fid) { visibleInRow.append(f) } case "label": if visibleLabelIds.contains(fid) { visibleInRow.append(f) } default: if answeredIds.contains(fid) { visibleInRow.append(f) } } } if !visibleInRow.isEmpty { result.append(RowGroup(fields: visibleInRow)) } } return result } var body: some View { if visibleRowGroups.isEmpty { Text("No form responses recorded.") .font(.callout) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(16) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } else { VStack(alignment: .leading, spacing: hSizeClass == .compact ? 10 : 3) { ForEach(visibleRowGroups.indices, id: \.self) { idx in if hSizeClass == .compact { stackedRowView(visibleRowGroups[idx]) } else { rowView(visibleRowGroups[idx]) } } } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } } // Render one row as a GeometryReader-based HStack so each field // occupies exactly (colSpan/12) of the available width, and leading // space before col > 1 is filled with a transparent spacer. // // On a narrow screen the same 12-column division that works on iPad // leaves each field a few dozen points wide inside a fixed 36 pt row, so // labels and values collide. Below `minGridWidth` each field gets its own // full-width line at its natural height instead. @ViewBuilder private func rowView(_ group: RowGroup) -> some View { GeometryReader { geo in let totalW = geo.size.width let colW = totalW / 12.0 let fields = group.fields ZStack(alignment: .topLeading) { ForEach(fields.indices, id: \.self) { i in let f = fields[i] let col = max(1, f["col"] as? Int ?? 1) let colSpan = max(1, min(f["colSpan"] as? Int ?? 6, 13 - col)) let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" let value = formValues[fid] ?? "" let ftype = f["type"] as? String ?? "text" let label = f["label"] as? String ?? "" ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label) .frame(width: colW * CGFloat(colSpan), alignment: .topLeading) .offset(x: colW * CGFloat(col - 1)) } } } .frame(height: rowHeight(group)) } /// Stacked equivalent of `rowView` for narrow screens: no proportional /// widths, no fixed row height, so nothing can overlap. @ViewBuilder private func stackedRowView(_ group: RowGroup) -> some View { VStack(alignment: .leading, spacing: 8) { ForEach(group.fields.indices, id: \.self) { i in let f = group.fields[i] let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" let value = formValues[fid] ?? "" let ftype = f["type"] as? String ?? "text" let label = f["label"] as? String ?? "" ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label) .frame(maxWidth: .infinity, alignment: .topLeading) } } } // Row height: fixed 36pt for most fields; taller for section headers. private func rowHeight(_ group: RowGroup) -> CGFloat { let hasSection = group.fields.contains { ($0["type"] as? String) == "section" } return hasSection ? 28 : 36 } } // MARK: - ReadOnlyCellView // Displays a single form cell as plain text — no editable controls. struct ReadOnlyCellView: View { let field: [String: Any] let value: String let fieldType: String let label: String var body: some View { VStack(alignment: .leading, spacing: 2) { // Label (same as GridCellContentView) if !["section", "label", "checkbox", "button_submit", "button_print", "button_email"].contains(fieldType), !label.isEmpty { Text(label) .font(.system(size: 11, weight: .medium)) .foregroundStyle(Color(.secondaryLabel)) .lineLimit(1) .truncationMode(.tail) } // Value display valueView .frame(maxWidth: .infinity, alignment: .topLeading) } } @ViewBuilder private var valueView: some View { switch fieldType { case "section": VStack(alignment: .leading, spacing: 0) { Divider() Text(label) .font(.system(size: 15, weight: .bold)) .foregroundStyle(Color(.label)) .padding(.top, 4) } .frame(maxWidth: .infinity) case "label": let fsMap: [String: CGFloat] = ["small": 11, "normal": 13, "large": 15, "x-large": 18] let fs = fsMap[field["font_size"] as? String ?? "normal"] ?? 13 let fw: Font.Weight = (field["font_weight"] as? String == "bold") ? .bold : .regular Text(field["text_content"] as? String ?? "") .font(.system(size: fs, weight: fw)) .foregroundStyle(Color(.label)) .fixedSize(horizontal: false, vertical: true) case "checkbox": HStack(spacing: 6) { Image(systemName: value == "true" ? "checkmark.square.fill" : "square") .foregroundStyle(value == "true" ? .blue : Color(.systemGray3)) .font(.system(size: 14)) Text(label) .font(.system(size: 12)) .foregroundStyle(Color(.secondaryLabel)) } case "pass_fail": let options = field["options"] as? [String] ?? ["Pass", "Fail"] HStack(spacing: 6) { ForEach(options, id: \.self) { opt in let isPass = ["pass","yes","ok","good","acceptable","compliant"].contains(opt.lowercased()) let isActive = value == opt Text(opt) .font(.system(size: 12, weight: .semibold)) .padding(.horizontal, 10).padding(.vertical, 4) .background(isActive ? (isPass ? Color.green : Color.red) : Color.clear) .foregroundStyle(isActive ? .white : (isPass ? Color.green : Color.red)) .clipShape(Capsule()) .overlay(Capsule().stroke(isPass ? Color.green : Color.red, lineWidth: 1.5)) } } case "rating": let intVal = Int(value) ?? 0 let maxRating = field["max"] as? Int ?? 5 HStack(spacing: 2) { ForEach(1...Swift.max(maxRating, 1), id: \.self) { star in Text("★") .font(.system(size: 16)) .foregroundStyle(star <= intVal ? Color.yellow : Color(.systemGray4)) } } case "image": PhotoThumbnailView(value: value) default: // Text, textarea, number, email, date, select, radio, checkbox_group Text(value.isEmpty ? "—" : value) .font(.system(size: 12)) .foregroundStyle(value.isEmpty ? Color(.tertiaryLabel) : Color(.label)) .lineLimit(3) .padding(.horizontal, 6) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .topLeading) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 5)) .overlay(RoundedRectangle(cornerRadius: 5) .stroke(Color(.systemGray5), lineWidth: 1)) } } } // MARK: - PhotoThumbnailView // Compact thumbnail that fits inside a single grid row (cellAspect 0.5). // Tap opens a fullscreen lightbox sheet. struct PhotoThumbnailView: View { let value: String @Environment(\.mediaURLByPath) private var mediaURLByPath @State private var showLightbox = false var body: some View { Group { if value.hasPrefix("local://") { let path = String(value.dropFirst("local://".count)) if let live = PhotoStore.resolve(path), let img = UIImage(contentsOfFile: live) { thumbnailButton { Image(uiImage: img) .resizable().scaledToFill() .frame(width: 32, height: 32) .clipShape(RoundedRectangle(cornerRadius: 4)) } .sheet(isPresented: $showLightbox) { ZStack { Color.black.ignoresSafeArea() Image(uiImage: img) .resizable().scaledToFit() } .onTapGesture { showLightbox = false } } } else { Label("No longer on device", systemImage: "exclamationmark.triangle") .font(.system(size: 11)).foregroundStyle(.secondary) } } else if value.hasPrefix("uploads/") { let url = ServerConfig.mediaURL(absolute: mediaURLByPath[value], path: value) thumbnailButton { AsyncImage(url: url) { phase in switch phase { case .success(let img): img.resizable().scaledToFill() .frame(width: 32, height: 32) .clipShape(RoundedRectangle(cornerRadius: 4)) case .failure: Image(systemName: "exclamationmark.triangle") .font(.system(size: 14)).foregroundStyle(.secondary) .frame(width: 32, height: 32) default: ProgressView().frame(width: 32, height: 32) } } } .sheet(isPresented: $showLightbox) { ZStack { Color.black.ignoresSafeArea() RetryablePhotoView(url: url) } .onTapGesture { showLightbox = false } } } else if !value.isEmpty { Label("Photo attached", systemImage: "photo") .font(.system(size: 11)).foregroundStyle(.secondary) } } } @ViewBuilder private func thumbnailButton(@ViewBuilder content: () -> Content) -> some View { Button { showLightbox = true } label: { HStack(spacing: 4) { content() Image(systemName: "arrow.up.left.and.arrow.down.right") .font(.system(size: 9)) .foregroundStyle(.secondary) } } .buttonStyle(.plain) } } // MARK: - MailComposeView // UIViewControllerRepresentable wrapping MFMailComposeViewController. // Attaches a PDF file and pre-fills subject + plain-text body. // Presents as a sheet; dismissed automatically on send/cancel/save. struct MailComposeView: UIViewControllerRepresentable { let subject: String let body: String let pdfData: Data let pdfFilename: String @Environment(\.dismiss) private var dismiss func makeUIViewController(context: Context) -> MFMailComposeViewController { let vc = MFMailComposeViewController() vc.setSubject(subject) vc.setMessageBody(body, isHTML: false) vc.addAttachmentData(pdfData, mimeType: "application/pdf", fileName: pdfFilename) vc.mailComposeDelegate = context.coordinator return vc } func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(dismiss: dismiss) } final class Coordinator: NSObject, MFMailComposeViewControllerDelegate { private let dismiss: DismissAction init(dismiss: DismissAction) { self.dismiss = dismiss } func mailComposeController( _ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error? ) { dismiss() } } } // MARK: - Media URL environment // Injects a {relative_path: absolute_url} map (from APIInspectionSummary.mediaURLByPath) // so image cells deep inside the read-only grid can resolve presigned R2 URLs // without threading field IDs through every layer. Empty map → the resolver // falls back to building a /static/ URL from the relative path. private struct MediaURLByPathKey: EnvironmentKey { static let defaultValue: [String: String] = [:] } extension EnvironmentValues { var mediaURLByPath: [String: String] { get { self[MediaURLByPathKey.self] } set { self[MediaURLByPathKey.self] = newValue } } }