From 32caf735e878f51fe448068d19d5a26e339f7c28 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Tue, 23 Jun 2026 12:12:47 -0400 Subject: [PATCH] 06/23 Fix inspection layout display and exported PDF file (sent via email) --- .../Utils/InspectionPDFGenerator.swift | 48 +++++++++++++------ .../Dashboard/ExecuteInspectionView.swift | 4 +- .../Inspection/InspectionHistoryView.swift | 24 +++++++--- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/JanitorialQC/Utils/InspectionPDFGenerator.swift b/JanitorialQC/Utils/InspectionPDFGenerator.swift index 2bd9661..7b0cfab 100644 --- a/JanitorialQC/Utils/InspectionPDFGenerator.swift +++ b/JanitorialQC/Utils/InspectionPDFGenerator.swift @@ -29,17 +29,13 @@ private let kFtrH: CGFloat = 39.6 // footer area height private let kTopY: CGFloat = kHdrH + 14 // first content Y on a fresh page private let kBotY: CGFloat = kH - kFtrH // lowest Y before footer -// Image compression — keeps the PDF (and thus the email attachment) small -// regardless of source photo resolution. Same values as IssuePDFGenerator. -private let kImgMaxPx: CGFloat = 700 -private let kImgJPEGQuality: CGFloat = 0.55 - -/// Downscale to kImgMaxPx on the longer side and re-encode as JPEG at -/// kImgJPEGQuality, returning a fresh UIImage built from the compressed -/// bytes. Applied to every photo before it's embedded in the PDF — this is -/// the main lever for keeping file size minimal. +/// Downscale to 700px on the longer side and re-encode as JPEG quality 0.55, +/// returning a fresh UIImage built from the compressed bytes. Applied to +/// every photo before it's embedded in the PDF — keeps file size minimal. /// nonisolated: called from inside a TaskGroup (concurrent), not @MainActor. private nonisolated func compress(_ image: UIImage) -> UIImage? { + let kImgMaxPx: CGFloat = 700 + let kImgJPEGQuality: CGFloat = 0.55 let size = image.size guard size.width > 0, size.height > 0 else { return nil } let scale = min(kImgMaxPx / size.width, kImgMaxPx / size.height, 1.0) @@ -412,26 +408,48 @@ private func drawFormFields(_ pctx: PDFContext, let baseRowH: CGFloat = 28 let photoRowH: CGFloat = 92 // taller row to fit an embedded photo - // Group by original row + // Sort fields by (row, col) before grouping — mirrors the web PDF generator: + // form_fields = sorted(schema, key=lambda f: (f['row'], f['col'])) + // Without this, schema fields arrive in JSON-array/editor-insertion order, + // which can place section headers after the data rows they belong to. + 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 + } + + // Group by original row, tracking which section header precedes each new row. + // Uses a queue (not a single pendingSec variable) so that when a section + // field is encountered but the next few fields are on rows already in + // rowGroups, the section label is not silently dropped or overwritten by the + // next section before it was consumed. var rowGroups: [Int: [[String: Any]]] = [:] var rowOrder: [Int] = [] - var pendingSec: String? = nil + var sectionQueue: [String] = [] // pending section labels, in schema order var secForRow: [Int: String] = [:] - for f in schema { + for f in sortedSchema { let ftype = f["type"] as? String ?? "" if skipTypes.contains(ftype) { continue } - if ftype == "section" { pendingSec = f["label"] as? String ?? ""; continue } + if ftype == "section" { + sectionQueue.append(f["label"] as? String ?? "") + continue + } let row = f["row"] as? Int ?? 1 if rowGroups[row] == nil { rowOrder.append(row) rowGroups[row] = [] - if let s = pendingSec { secForRow[row] = s; pendingSec = nil } + // Assign the oldest pending section label to this new row. + if !sectionQueue.isEmpty { + secForRow[row] = sectionQueue.removeFirst() + } } rowGroups[row]!.append(f) } - for origRow in rowOrder.sorted() { + // Iterate in rowOrder (which is now schema-sorted insertion order = row order) + for origRow in rowOrder { guard let fields = rowGroups[origRow] else { continue } // Section banner diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index f08c7ee..9fc465d 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -459,7 +459,7 @@ struct ExecuteInspectionView: View { let existingFormData = inspection.formData var data: [String: Any] = [:] for (k, v) in formValues { - if let s = v as? String, s.hasPrefix("local://"), + if v.hasPrefix("local://"), let saved = existingFormData[k] as? String, saved.hasPrefix("uploads/") { data[k] = saved } else { @@ -532,7 +532,7 @@ struct ExecuteInspectionView: View { let existingFormData = inspection.formData var data: [String: Any] = [:] for (k, v) in formValues { - if let s = v as? String, s.hasPrefix("local://"), + if v.hasPrefix("local://"), let saved = existingFormData[k] as? String, saved.hasPrefix("uploads/") { // processPhotoQueue already uploaded this photo — keep the server path data[k] = saved diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index aaaf87e..deecf04 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -678,9 +678,17 @@ struct ReadOnlyGridFormView: View { 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 schema { + 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] ?? "" @@ -691,7 +699,7 @@ struct ReadOnlyGridFormView: View { // Pass 2 — label IDs that immediately precede an answered field var visibleLabelIds = Set() var lbuf: [String] = [] - for f in schema { + 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" { @@ -705,7 +713,7 @@ struct ReadOnlyGridFormView: View { // Pass 3 — section IDs that precede at least one answered field var visibleSectionIds = Set() var pendingSecId: String? = nil - for f in schema { + 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" { @@ -715,18 +723,20 @@ struct ReadOnlyGridFormView: View { } } - // Pass 4 — group fields by original row, keep schema order + // 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 schema { + 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 + // 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.sorted() { + for origRow in rowOrder { guard let group = rowGroups[origRow] else { continue } var visibleInRow: [[String: Any]] = [] for f in group {