From a57d6f02bae18fd9298dcb42a31dffe0257e04f1 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Fri, 19 Jun 2026 17:28:09 -0400 Subject: [PATCH] 06/19 Update inspections/issues sent via email, and duplicated issue photos issues --- JanitorialQC/Sync/SyncManager.swift | 45 +- .../Utils/InspectionPDFGenerator.swift | 597 ++++++++++++++++++ JanitorialQC/Utils/IssuePDFGenerator.swift | 481 ++++++++++++++ .../Views/Dashboard/DashboardView.swift | 66 ++ .../Dashboard/ExecuteInspectionView.swift | 37 +- .../Views/Dashboard/FormFieldView.swift | 24 +- .../Inspection/InspectionHistoryView.swift | 128 +++- 7 files changed, 1366 insertions(+), 12 deletions(-) create mode 100644 JanitorialQC/Utils/InspectionPDFGenerator.swift create mode 100644 JanitorialQC/Utils/IssuePDFGenerator.swift diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 9aa348f..7b9ff2c 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -187,6 +187,27 @@ class SyncManager: ObservableObject { // Do not sync unless authenticated — avoids 401 loops before // restoreSession() completes on first launch. guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return } + + // Re-entrancy guard. triggerSync() has many independent call sites + // (issue submit, inspection submit, manual "Sync Now", NWPathMonitor + // reconnect, the 60s poll timer, app-foreground). Although this class + // is @MainActor, the `await` points inside processPhotoQueue/etc. + // yield the actor, so a second triggerSync() call can interleave + // between those awaits and run concurrently with the first. + // + // Without this guard, two overlapping passes both fetch the same + // "pending" PendingPhoto records (neither has flipped uploadStatus + // yet), both upload the same local file, and each appends its own + // distinct server-generated filename to LocalIssue.photoServerPaths. + // The `!paths.contains(serverPath)` dedup check in processPhotoQueue + // never catches this because the two server paths are different + // strings for the same photo content — producing duplicated photos + // in the issue's evidence (and therefore in the exported PDF). + // + // Guarding re-entrancy here closes the race at its source rather + // than trying to dedupe by content downstream. + guard !isSyncing else { return } + isSyncing = true syncError = nil defer { isSyncing = false } @@ -216,10 +237,32 @@ class SyncManager: ObservableObject { // string literals against PendingPhoto.uploadStatus reliably // when the predicate type is inferred across model boundaries. guard let allPhotos = try? context.fetch(FetchDescriptor()) else { return } - let pending = allPhotos + var pending = allPhotos .filter { $0.uploadStatus == "pending" } .sorted { $0.createdAt < $1.createdAt } + // Defense-in-depth: if two PendingPhoto rows somehow reference the + // exact same local file (e.g. a future call site re-submitting the + // same photo array), only upload it once. The primary fix for photo + // duplication is the re-entrancy guard in triggerSync(), but this + // keeps processPhotoQueue itself safe even if it's ever invoked + // outside that guard. + var seenPaths = Set() + var duplicates: [PendingPhoto] = [] + pending = pending.filter { photo in + if seenPaths.contains(photo.localFilePath) { + duplicates.append(photo) + return false + } + seenPaths.insert(photo.localFilePath) + return true + } + for dup in duplicates { + // Mark the duplicate row as uploaded without re-uploading — the + // first row for this file will populate serverPath/photoServerPaths. + dup.uploadStatus = "uploaded" + } + for photo in pending { do { let serverPath = try await APIClient.shared.uploadPhoto( diff --git a/JanitorialQC/Utils/InspectionPDFGenerator.swift b/JanitorialQC/Utils/InspectionPDFGenerator.swift new file mode 100644 index 0000000..c226af0 --- /dev/null +++ b/JanitorialQC/Utils/InspectionPDFGenerator.swift @@ -0,0 +1,597 @@ +// Utils/InspectionPDFGenerator.swift +// ----------------------------------- +// Generates a PDF inspection report that mirrors the web app's layout. +// +// Layout (portrait, US Letter 612×792 pt): +// • Dark header band — title + "Page N" +// • Score banner — coloured score + PASS/FAIL +// • Meta table — facility, area, date, template, inspector, status +// • Form fields — 12-col grid, answered fields only, section headings, +// photo fields render the actual image +// • Inspector notes — if non-empty +// • Footer — generation date +// +// Photo fields require a network fetch for server-synced photos, so PDF +// generation is async: all images are pre-fetched into memory before the +// synchronous UIGraphicsPDFRenderer drawing closure runs (PDFKit drawing +// callbacks cannot be async). + +import UIKit + +// MARK: - Constants + +private let kW: CGFloat = 612 // US Letter width +private let kH: CGFloat = 792 // US Letter height +private let kM: CGFloat = 46.8 // left/right margin +private let kBodyW: CGFloat = kW - 2 * kM +private let kHdrH: CGFloat = 79.2 // 1.1 inch dark header +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. +private func compress(_ image: UIImage) -> UIImage? { + 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) + let newSize = CGSize(width: size.width * scale, height: size.height * scale) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let resized = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + guard let jpegData = resized.jpegData(compressionQuality: kImgJPEGQuality) else { + return resized + } + return UIImage(data: jpegData) ?? resized +} + +// MARK: - Colours + +private let C_DARK = UIColor(red:0.102, green:0.114, blue:0.137, alpha:1) // #1a1d23 +private let C_BLUE = UIColor(red:0.145, green:0.388, blue:0.922, alpha:1) // #2563eb +private let C_SLATE = UIColor(red:0.392, green:0.455, blue:0.545, alpha:1) // #64748b +private let C_LIGHT = UIColor(red:0.945, green:0.961, blue:0.976, alpha:1) // #f1f5f9 +private let C_BORDER = UIColor(red:0.886, green:0.906, blue:0.929, alpha:1) // #e2e8f0 +private let C_GREEN = UIColor(red:0.086, green:0.639, blue:0.290, alpha:1) // #16a34a +private let C_YELLOW = UIColor(red:0.851, green:0.467, blue:0.024, alpha:1) // #d97706 +private let C_RED = UIColor(red:0.863, green:0.149, blue:0.149, alpha:1) // #dc2626 +private let C_MUTED = UIColor(red:0.580, green:0.647, blue:0.722, alpha:1) // #94a3b8 +private let C_AMBER = UIColor(red:0.973, green:0.620, blue:0.043, alpha:1) // star gold +private let C_CELL = UIColor(red:0.973, green:0.984, blue:0.996, alpha:1) // cell bg + +private func scoreColor(_ s: Double?) -> UIColor { + guard let v = s else { return C_SLATE } + return v >= 90 ? C_GREEN : v >= 70 ? C_YELLOW : C_RED +} + +// MARK: - Attributed string helpers + +/// Single-line, truncates with an ellipsis if it doesn't fit. +private func A(_ text: String, + size: CGFloat, + weight: UIFont.Weight = .regular, + color: UIColor = C_DARK, + align: NSTextAlignment = .left) -> NSAttributedString { + let ps = NSMutableParagraphStyle() + ps.alignment = align + ps.lineBreakMode = .byTruncatingTail + return NSAttributedString(string: text, attributes: [ + .font: UIFont.systemFont(ofSize: size, weight: weight), + .foregroundColor: color, + .paragraphStyle: ps, + ]) +} + +/// Multiline, word-wraps instead of truncating. Used anywhere content length +/// is variable (dates, names, notes) so nothing gets cut off with "...". +private func AM(_ text: String, + size: CGFloat, + weight: UIFont.Weight = .regular, + color: UIColor = C_DARK, + align: NSTextAlignment = .left) -> NSAttributedString { + let ps = NSMutableParagraphStyle() + ps.alignment = align + ps.lineBreakMode = .byWordWrapping + return NSAttributedString(string: text, attributes: [ + .font: UIFont.systemFont(ofSize: size, weight: weight), + .foregroundColor: color, + .paragraphStyle: ps, + ]) +} + +/// Height needed to render `text` word-wrapped within `width`. +private func wrappedHeight(_ text: String, size: CGFloat, width: CGFloat) -> CGFloat { + AM(text, size: size).boundingRect( + with: CGSize(width: width, height: 2000), + options: .usesLineFragmentOrigin, context: nil + ).height +} + +// MARK: - Drawing context wrapper (handles pagination) + +private final class PDFContext { + let renderer: UIGraphicsPDFRendererContext + var y: CGFloat = kTopY + var page = 1 + + private let title: String + private let generatedAt: String + + init(_ ctx: UIGraphicsPDFRendererContext, title: String, generatedAt: String) { + self.renderer = ctx + self.title = title + self.generatedAt = generatedAt + } + + var gc: CGContext { renderer.cgContext } + var remaining: CGFloat { kBotY - y } + + /// Ensure `h` points are available; start a new page if not. + func need(_ h: CGFloat) { + guard remaining < h else { return } + renderer.beginPage() + page += 1 + y = kTopY + drawHeader(gc, title: title, page: page) + drawFooter(gc, generatedAt: generatedAt) + } + + func advance(_ h: CGFloat) { y += h } +} + +// MARK: - Public API + +enum InspectionPDFGenerator { + + /// Generate a PDF for the given inspection, including embedded photos. + /// Fetches server-hosted photos over the network before drawing — call + /// from a background context (e.g. inside a Task) and not the main actor + /// hot path. Local (`local://`) photos are read from disk synchronously. + static func generate(inspection: APIInspectionSummary) async -> Data { + let images = await fetchImages(schema: inspection.formSchema, + values: inspection.formValues) + return render(inspection: inspection, images: images) + } + + // ── Image pre-fetch ────────────────────────────────────────────────── + // Returns [fieldId: UIImage] for every "image" field that has a value + // and could be loaded, either from local disk or the server. + // Each image is downscaled and re-encoded as JPEG (see compress()) before + // being returned — full camera-resolution photos (often 3–8MB each) would + // otherwise be embedded into the PDF content stream at full size, making + // an inspection with several photos balloon into a multi-megabyte + // attachment. Compression keeps the PDF small regardless of source size. + + private static func fetchImages( + schema: [[String: Any]], + values: [String: String] + ) async -> [String: UIImage] { + var result: [String: UIImage] = [:] + + await withTaskGroup(of: (String, UIImage?).self) { group in + for field in schema { + guard field["type"] as? String == "image" else { continue } + let fid = field["id"] as? String ?? (field["id"] as? Int).map(String.init) ?? "" + let val = values[fid] ?? "" + guard !val.isEmpty else { continue } + + group.addTask { + if val.hasPrefix("local://") { + let path = String(val.dropFirst("local://".count)) + guard let raw = UIImage(contentsOfFile: path) else { return (fid, nil) } + return (fid, compress(raw)) + } else if val.hasPrefix("uploads/") { + guard let url = URL(string: "\(ServerConfig.current)/static/\(val)") + else { return (fid, nil) } + do { + let (data, _) = try await URLSession.shared.data(from: url) + guard let raw = UIImage(data: data) else { return (fid, nil) } + return (fid, compress(raw)) + } catch { + return (fid, nil) + } + } + return (fid, nil) + } + } + for await (fid, image) in group { + if let image { result[fid] = image } + } + } + return result + } + + // ── Synchronous rendering ─────────────────────────────────────────── + + private static func render(inspection: APIInspectionSummary, + images: [String: UIImage]) -> Data { + let title = "\(inspection.templateName) — \(inspection.facilityName)" + let inspName = KeychainHelper.get(Constants.Keychain.displayName) ?? "Inspector" + let generatedAt: String = { + let f = DateFormatter(); f.dateStyle = .long; f.timeStyle = .short + return f.string(from: Date()) + }() + + let fmt = UIGraphicsPDFRendererFormat() + fmt.documentInfo = [ + kCGPDFContextTitle as String: "Inspection Report — \(inspection.facilityName)", + kCGPDFContextAuthor as String: "JanitorialQC Inspector", + ] as [String: Any] + + let bounds = CGRect(x: 0, y: 0, width: kW, height: kH) + let renderer = UIGraphicsPDFRenderer(bounds: bounds, format: fmt) + + return renderer.pdfData { ctx in + ctx.beginPage() + let pctx = PDFContext(ctx, title: title, generatedAt: generatedAt) + drawHeader(pctx.gc, title: title, page: 1) + drawFooter(pctx.gc, generatedAt: generatedAt) + + drawScoreBanner(pctx, score: inspection.overallScore) + drawMetaTable(pctx, inspection: inspection, inspectorName: inspName) + drawFormFields(pctx, schema: inspection.formSchema, + values: inspection.formValues, images: images) + if !inspection.inspectorNotes.isEmpty { + drawNotes(pctx, notes: inspection.inspectorNotes) + } + } + } +} + +// MARK: - Header & Footer + +private func drawHeader(_ gc: CGContext, title: String, page: Int) { + gc.setFillColor(C_DARK.cgColor) + gc.fill(CGRect(x: 0, y: 0, width: kW, height: kHdrH)) + + A(title, size: 13, weight: .bold, color: .white) + .draw(in: CGRect(x: kM, y: 26, width: kBodyW - 60, height: 20)) + A("Janitorial Quality Control System", size: 8, color: C_MUTED) + .draw(in: CGRect(x: kM, y: 48, width: kBodyW - 60, height: 14)) + A("Page \(page)", size: 8, color: C_MUTED, align: .right) + .draw(in: CGRect(x: kM, y: 38, width: kBodyW, height: 14)) +} + +private func drawFooter(_ gc: CGContext, generatedAt: String) { + let y = kH - kFtrH + gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) + gc.move(to: CGPoint(x: kM, y: y + 6)) + gc.addLine(to: CGPoint(x: kW - kM, y: y + 6)) + gc.strokePath() + + A("Generated: \(generatedAt) | Janitorial QC System", size: 7, color: C_SLATE) + .draw(in: CGRect(x: kM, y: y + 12, width: kBodyW * 0.75, height: 12)) + A("CONFIDENTIAL", size: 7, color: C_SLATE, align: .right) + .draw(in: CGRect(x: kM, y: y + 12, width: kBodyW, height: 12)) +} + +// MARK: - Score Banner + +private func drawScoreBanner(_ pctx: PDFContext, score: Double?) { + let h: CGFloat = 48 + pctx.need(h + 8) + let gc = pctx.gc; let y = pctx.y + let scoreW = kBodyW * 0.70; let gradeW = kBodyW * 0.30 + + gc.setFillColor(scoreColor(score).cgColor) + gc.fill(CGRect(x: kM, y: y, width: scoreW, height: h)) + + let isPassing = (score ?? 0) >= 70 + gc.setFillColor((isPassing ? C_GREEN : C_RED).cgColor) + gc.fill(CGRect(x: kM + scoreW, y: y, width: gradeW, height: h)) + + let scoreStr = score.map { String(format: "%.1f%%", $0) } ?? "N/A" + A("Overall Score", size: 9, weight: .bold, color: .white, align: .center) + .draw(in: CGRect(x: kM, y: y + 6, width: scoreW, height: 14)) + A(scoreStr, size: 22, weight: .bold, color: .white, align: .center) + .draw(in: CGRect(x: kM, y: y + 18, width: scoreW, height: 28)) + A(isPassing ? "PASS" : "FAIL", size: 16, weight: .bold, color: .white, align: .center) + .draw(in: CGRect(x: kM + scoreW, y: y + 14, width: gradeW, height: 22)) + + pctx.advance(h + 8) +} + +// MARK: - Meta Table +// Each cell word-wraps (AM, not A) and the row height is computed from the +// tallest cell content in that row, so full dates ("December 18, 2026 at +// 9:45 AM") and long facility/status strings are never truncated. + +private func drawMetaTable(_ pctx: PDFContext, + inspection: APIInspectionSummary, + inspectorName: String) { + let cols = 6 + let colW = kBodyW / CGFloat(cols) + let valueW = colW - 10 + let labelH: CGFloat = 10 + let valueFontSize: CGFloat = 8 + let cellVPad: CGFloat = 6 // top padding before label, gap before value + + let dateStr: String = { + guard let d = inspection.inspectionDateParsed else { return "—" } + let f = DateFormatter(); f.dateStyle = .long; f.timeStyle = .short + return f.string(from: d) + }() + let completedStr: String = { + guard let s = inspection.completedAt, + let d = SyncManager.isoFormatter.date(from: s) else { return "—" } + let f = DateFormatter(); f.dateStyle = .long; f.timeStyle = .short + return f.string(from: d) + }() + let scoreStr = inspection.overallScore.map { String(format: "%.1f%%", $0) } ?? "—" + let statusTxt = inspection.status == "completed" ? "Submitted" + : inspection.status.replacingOccurrences(of: "_", with: " ").capitalized + let statusScoreStr = "\(statusTxt) \(scoreStr)" + + // Row definitions: (col, label, value) + let row0: [(Int, String, String)] = [ + (0, "INSPECTOR", inspectorName), + (2, "START DATE", dateStr), + (4, "COMPLETED DATE", completedStr), + ] + let row1: [(Int, String, String)] = [ + (0, "FACILITY", inspection.facilityName), + (2, "AREA", inspection.areaName ?? "—"), + (4, "STATUS / SCORE", statusScoreStr), + ] + let row2: [(Int, String, String)] = [ + (0, "TEMPLATE", inspection.templateName), + ] + let allRows = [row0, row1, row2] + + // Compute each row's height from its tallest wrapped value. + func rowHeight(_ row: [(Int, String, String)]) -> CGFloat { + let maxValH = row.map { wrappedHeight($0.2, size: valueFontSize, width: valueW) } + .max() ?? 14 + return cellVPad + labelH + 3 + max(maxValH, 12) + 6 + } + let rowHeights = allRows.map(rowHeight) + let tableH = rowHeights.reduce(0, +) + + pctx.need(tableH + 8) + let gc = pctx.gc; let y0 = pctx.y + + gc.setFillColor(C_LIGHT.cgColor) + gc.fill(CGRect(x: kM, y: y0, width: kBodyW, height: tableH)) + gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) + gc.stroke(CGRect(x: kM, y: y0, width: kBodyW, height: tableH)) + for c in 1.. = ["button_submit", "button_print", "button_email"] + let unitW = kBodyW / 12.0 + let baseRowH: CGFloat = 28 + let photoRowH: CGFloat = 92 // taller row to fit an embedded photo + + // Group by original row + var rowGroups: [Int: [[String: Any]]] = [:] + var rowOrder: [Int] = [] + var pendingSec: String? = nil + var secForRow: [Int: String] = [:] + + for f in schema { + let ftype = f["type"] as? String ?? "" + if skipTypes.contains(ftype) { continue } + if ftype == "section" { pendingSec = 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 } + } + rowGroups[row]!.append(f) + } + + for origRow in rowOrder.sorted() { + guard let fields = rowGroups[origRow] else { continue } + + // Section banner + if let sec = secForRow[origRow] { + pctx.need(26 + baseRowH) + pctx.advance(6) + A(sec, size: 9, weight: .bold) + .draw(in: CGRect(x: kM, y: pctx.y, width: kBodyW, height: 14)) + pctx.advance(16) + drawHR(pctx.gc, y: pctx.y, w: 0.75) + pctx.advance(4) + } + + // Has any visible content? + let hasContent = fields.contains { f in + let ft = f["type"] as? String ?? "" + if ft == "label" { return true } + let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" + let val = values[fid] ?? "" + return ft == "rating" ? (Int(val) ?? 0) > 0 : !val.isEmpty + } + guard hasContent else { continue } + + // Row height: taller if this row contains an image field with a + // loaded photo, so the photo isn't squeezed into a 28pt strip. + let rowHasPhoto = fields.contains { f in + guard f["type"] as? String == "image" else { return false } + let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" + return images[fid] != nil + } + let rowH = rowHasPhoto ? photoRowH : baseRowH + + pctx.need(rowH + 2) + let gc = pctx.gc; let rowY = pctx.y + + for f in fields { + let ftype = f["type"] as? String ?? "" + let col = max(1, f["col"] as? Int ?? 1) + let colSpan = max(1, f["colSpan"] as? Int ?? 6) + let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" + let val = values[fid] ?? "" + let lbl = f["label"] as? String ?? "" + let cellX = kM + CGFloat(col - 1) * unitW + let cellW = CGFloat(colSpan) * unitW - 2 + + switch ftype { + case "label": + let fsMap: [String: CGFloat] = ["small":7,"normal":8,"large":10,"x-large":12] + let fs = fsMap[f["font_size"] as? String ?? "normal"] ?? 8 + let fw: UIFont.Weight = f["font_weight"] as? String == "bold" ? .bold : .regular + let txt = f["text_content"] as? String ?? "" + AM(txt, size: fs, weight: fw) + .draw(in: CGRect(x: cellX + 3, y: rowY + 6, width: cellW - 4, height: rowH - 8)) + + case "section": + continue + + case "rating": + let score = Int(val) ?? 0 + guard score > 0 else { continue } + let maxR = f["max"] as? Int ?? 5 + drawCellBg(gc, x: cellX, y: rowY, w: cellW, h: rowH) + A(lbl, size: 6.5, color: C_SLATE) + .draw(in: CGRect(x: cellX + 4, y: rowY + 3, width: cellW - 6, height: 9)) + let stars = String(repeating: "★", count: score) + + String(repeating: "☆", count: maxR - score) + NSAttributedString(string: "\(stars) \(score)/\(maxR)", attributes: [ + .font: UIFont.systemFont(ofSize: 10), + .foregroundColor: C_AMBER, + ]).draw(in: CGRect(x: cellX + 4, y: rowY + 13, width: cellW - 6, height: 13)) + + case "pass_fail": + guard !val.isEmpty else { continue } + let isPass = ["pass","yes","ok","good","acceptable","compliant"] + .contains(val.lowercased()) + drawCellBg(gc, x: cellX, y: rowY, w: cellW, h: rowH) + A(lbl, size: 6.5, color: C_SLATE) + .draw(in: CGRect(x: cellX + 4, y: rowY + 3, width: cellW - 6, height: 9)) + A(val, size: 9, weight: .semibold, color: isPass ? C_GREEN : C_RED) + .draw(in: CGRect(x: cellX + 4, y: rowY + 13, width: cellW - 6, height: 13)) + + case "image": + guard !val.isEmpty else { continue } + drawCellBg(gc, x: cellX, y: rowY, w: cellW, h: rowH) + A(lbl, size: 6.5, color: C_SLATE) + .draw(in: CGRect(x: cellX + 4, y: rowY + 3, width: cellW - 6, height: 9)) + + if let img = images[fid] { + // Fit the photo into the remaining cell space, preserving + // aspect ratio, anchored top-left under the label. + let availW = cellW - 8 + let availH = rowH - 16 + let imgSize = img.size + let scale = min(availW / imgSize.width, availH / imgSize.height, 1.0) + let drawW = imgSize.width * scale + let drawH = imgSize.height * scale + let imgRect = CGRect(x: cellX + 4, y: rowY + 13, width: drawW, height: drawH) + img.draw(in: imgRect) + gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) + gc.stroke(imgRect) + } else { + A("[Photo unavailable]", size: 7.5, color: C_SLATE) + .draw(in: CGRect(x: cellX + 4, y: rowY + 13, width: cellW - 6, height: 13)) + } + + case "checkbox": + guard val == "true" || val == "yes" else { continue } + drawCellBg(gc, x: cellX, y: rowY, w: cellW, h: rowH) + A(lbl, size: 6.5, color: C_SLATE) + .draw(in: CGRect(x: cellX + 4, y: rowY + 3, width: cellW - 6, height: 9)) + A("✓ Yes", size: 9, weight: .semibold, color: C_GREEN) + .draw(in: CGRect(x: cellX + 4, y: rowY + 13, width: cellW - 6, height: 13)) + + default: + guard !val.isEmpty else { continue } + drawCellBg(gc, x: cellX, y: rowY, w: cellW, h: rowH) + A(lbl, size: 6.5, color: C_SLATE) + .draw(in: CGRect(x: cellX + 4, y: rowY + 3, width: cellW - 6, height: 9)) + AM(val, size: 8.5) + .draw(in: CGRect(x: cellX + 4, y: rowY + 13, width: cellW - 6, height: rowH - 16)) + } + } + + pctx.advance(rowH + 2) + } +} + +// MARK: - Inspector Notes + +private func drawNotes(_ pctx: PDFContext, notes: String) { + pctx.need(40) + A("Inspector Notes", size: 10, weight: .bold) + .draw(in: CGRect(x: kM, y: pctx.y, width: kBodyW, height: 16)) + pctx.advance(18) + drawHR(pctx.gc, y: pctx.y) + pctx.advance(4) + + let boxH = max(wrappedHeight(notes, size: 8.5, width: kBodyW - 12) + 12, 24) + pctx.need(boxH + 4) + + let gc = pctx.gc; let y = pctx.y + gc.setFillColor(C_LIGHT.cgColor) + gc.fill(CGRect(x: kM, y: y, width: kBodyW, height: boxH)) + gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) + gc.stroke(CGRect(x: kM, y: y, width: kBodyW, height: boxH)) + AM(notes, size: 8.5) + .draw(in: CGRect(x: kM + 6, y: y + 4, width: kBodyW - 12, height: boxH)) + pctx.advance(boxH + 8) +} + +// MARK: - Micro helpers + +private func drawHR(_ gc: CGContext, y: CGFloat, w: CGFloat = 1.0) { + gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(w) + gc.move(to: CGPoint(x: kM, y: y)); gc.addLine(to: CGPoint(x: kM + kBodyW, y: y)) + gc.strokePath() +} + +private func drawCellBg(_ gc: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat) { + gc.setFillColor(C_CELL.cgColor) + gc.fill(CGRect(x: x + 1, y: y, width: w, height: h)) + gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) + gc.stroke(CGRect(x: x + 1, y: y, width: w, height: h)) +} diff --git a/JanitorialQC/Utils/IssuePDFGenerator.swift b/JanitorialQC/Utils/IssuePDFGenerator.swift new file mode 100644 index 0000000..c0b0091 --- /dev/null +++ b/JanitorialQC/Utils/IssuePDFGenerator.swift @@ -0,0 +1,481 @@ +// Utils/IssuePDFGenerator.swift +// ------------------------------ +// Generates a PDF issue report mirroring the web app's generate_issue_pdf(). +// +// Layout (portrait, US Letter 612×792 pt): +// • Dark header band — title + "Page N" +// • Severity / status banner +// • Details grid — contract, facility, area, reporter, assignee, +// reported/resolved dates, status +// • Description +// • Photo Evidence — compact grid, JPEG re-encoded to keep file +// size minimal (matches web _compress_image) +// • Resolution Details — notes (no resolution photos on iOS model) +// • Verification — verifier + date + note +// • Footer — generation date +// +// All photos are re-encoded to JPEG at reduced resolution/quality before +// embedding, exactly like the web's _compress_image (800px max side, q=55), +// so attaching several photos doesn't bloat the email attachment. + +import UIKit + +// MARK: - Constants + +private let kW: CGFloat = 612 +private let kH: CGFloat = 792 +private let kM: CGFloat = 46.8 +private let kBodyW: CGFloat = kW - 2 * kM +private let kHdrH: CGFloat = 79.2 +private let kFtrH: CGFloat = 39.6 +private let kTopY: CGFloat = kHdrH + 14 +private let kBotY: CGFloat = kH - kFtrH + +// Image compression — matches web pdf_export.py _compress_image +private let kImgMaxPx: CGFloat = 700 // max px on the longer side +private let kImgJPEGQuality: CGFloat = 0.55 + +// MARK: - Colours (same palette as InspectionPDFGenerator) + +private let C_DARK = UIColor(red:0.102, green:0.114, blue:0.137, alpha:1) +private let C_SLATE = UIColor(red:0.392, green:0.455, blue:0.545, alpha:1) +private let C_LIGHT = UIColor(red:0.945, green:0.961, blue:0.976, alpha:1) +private let C_BORDER = UIColor(red:0.886, green:0.906, blue:0.929, alpha:1) +private let C_GREEN = UIColor(red:0.086, green:0.639, blue:0.290, alpha:1) +private let C_YELLOW = UIColor(red:0.851, green:0.467, blue:0.024, alpha:1) +private let C_RED = UIColor(red:0.863, green:0.149, blue:0.149, alpha:1) +private let C_ORANGE = UIColor(red:0.918, green:0.345, blue:0.047, alpha:1) +private let C_BLUE = UIColor(red:0.145, green:0.388, blue:0.922, alpha:1) +private let C_MUTED = UIColor(red:0.580, green:0.647, blue:0.722, alpha:1) +private let C_GREENBG = UIColor(red:0.941, green:0.992, blue:0.957, alpha:1) // #f0fdf4 + +private func severityColor(_ s: String) -> UIColor { + switch s.lowercased() { + case "critical", "high": return C_RED + case "medium": return C_YELLOW + default: return C_SLATE + } +} + +// MARK: - Attributed string helpers (shared style with InspectionPDFGenerator) + +private func A(_ text: String, size: CGFloat, weight: UIFont.Weight = .regular, + color: UIColor = C_DARK, align: NSTextAlignment = .left) -> NSAttributedString { + let ps = NSMutableParagraphStyle() + ps.alignment = align; ps.lineBreakMode = .byTruncatingTail + return NSAttributedString(string: text, attributes: [ + .font: UIFont.systemFont(ofSize: size, weight: weight), + .foregroundColor: color, .paragraphStyle: ps, + ]) +} + +private func AM(_ text: String, size: CGFloat, weight: UIFont.Weight = .regular, + color: UIColor = C_DARK, align: NSTextAlignment = .left) -> NSAttributedString { + let ps = NSMutableParagraphStyle() + ps.alignment = align; ps.lineBreakMode = .byWordWrapping + return NSAttributedString(string: text, attributes: [ + .font: UIFont.systemFont(ofSize: size, weight: weight), + .foregroundColor: color, .paragraphStyle: ps, + ]) +} + +private func wrappedHeight(_ text: String, size: CGFloat, width: CGFloat) -> CGFloat { + AM(text, size: size).boundingRect( + with: CGSize(width: width, height: 2000), + options: .usesLineFragmentOrigin, context: nil + ).height +} + +// MARK: - Pagination context + +private final class PDFContext { + let renderer: UIGraphicsPDFRendererContext + var y: CGFloat = kTopY + var page = 1 + private let title: String + private let generatedAt: String + + init(_ ctx: UIGraphicsPDFRendererContext, title: String, generatedAt: String) { + self.renderer = ctx; self.title = title; self.generatedAt = generatedAt + } + var gc: CGContext { renderer.cgContext } + var remaining: CGFloat { kBotY - y } + + func need(_ h: CGFloat) { + guard remaining < h else { return } + renderer.beginPage(); page += 1; y = kTopY + drawHeader(gc, title: title, page: page) + drawFooter(gc, generatedAt: generatedAt) + } + func advance(_ h: CGFloat) { y += h } +} + +// MARK: - Public API + +enum IssuePDFGenerator { + + /// Generate a minimal-size PDF for the given issue, with photo evidence + /// re-encoded as compressed JPEGs (matches web _compress_image). + /// Fetches any server-hosted photos over the network — call from a + /// background Task, not the main-actor hot path. + static func generate(issue: LocalIssue, facilityName: String) async -> Data { + let photos = await fetchAndCompressPhotos(issue: issue) + return render(issue: issue, facilityName: facilityName, photos: photos) + } + + // ── Photo fetch + compression ─────────────────────────────────────── + // Returns compressed UIImages ready for low-filesize PDF embedding. + // Local photos are read from disk; server photos are downloaded. + // Every image is downscaled to kImgMaxPx and re-encoded as JPEG at + // kImgJPEGQuality before being wrapped back into a UIImage — this is + // what keeps the final PDF (and thus the email attachment) small even + // with several photos attached. + + private static func fetchAndCompressPhotos(issue: LocalIssue) async -> [UIImage] { + // Prefer server paths once synced (matches IssueDetailView's own logic); + // fall back to local paths while pending/failed. + let isSynced = issue.syncStatus == "synced" + let serverPaths = issue.photoServerPaths + let localPaths = issue.photoLocalPaths + + var raw: [UIImage] = [] + + if isSynced && !serverPaths.isEmpty { + raw = await withTaskGroup(of: UIImage?.self) { group in + for path in serverPaths { + group.addTask { + guard let url = URL(string: "\(ServerConfig.current)/static/\(path)") + else { return nil } + do { + let (data, _) = try await URLSession.shared.data(from: url) + return UIImage(data: data) + } catch { return nil } + } + } + var results: [UIImage] = [] + for await img in group { if let img { results.append(img) } } + return results + } + } else if !localPaths.isEmpty { + raw = localPaths.compactMap { UIImage(contentsOfFile: $0) } + } + + return raw.compactMap { compress($0) } + } + + /// Downscale to kImgMaxPx on the longer side and re-encode as JPEG at + /// kImgJPEGQuality, returning a fresh UIImage built from the compressed + /// bytes. This mirrors the web's _compress_image and is the main lever + /// for keeping the PDF attachment small. + private static func compress(_ image: UIImage) -> UIImage? { + 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) + let newSize = CGSize(width: size.width * scale, height: size.height * scale) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let resized = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + guard let jpegData = resized.jpegData(compressionQuality: kImgJPEGQuality) else { + return resized + } + return UIImage(data: jpegData) ?? resized + } + + // ── Synchronous rendering ─────────────────────────────────────────── + + private static func render(issue: LocalIssue, facilityName: String, + photos: [UIImage]) -> Data { + let title = "Issue Report — Issue #\(issue.serverId ?? 0)" + let generatedAt: String = { + let f = DateFormatter(); f.dateStyle = .long; f.timeStyle = .short + return f.string(from: Date()) + }() + + let fmt = UIGraphicsPDFRendererFormat() + fmt.documentInfo = [ + kCGPDFContextTitle as String: title, + kCGPDFContextAuthor as String: "JanitorialQC Inspector", + ] as [String: Any] + + let bounds = CGRect(x: 0, y: 0, width: kW, height: kH) + let renderer = UIGraphicsPDFRenderer(bounds: bounds, format: fmt) + + return renderer.pdfData { ctx in + ctx.beginPage() + let pctx = PDFContext(ctx, title: title, generatedAt: generatedAt) + drawHeader(pctx.gc, title: title, page: 1) + drawFooter(pctx.gc, generatedAt: generatedAt) + + drawSeverityBanner(pctx, issue: issue) + drawDetailsGrid(pctx, issue: issue, facilityName: facilityName) + drawDescription(pctx, text: issue.issueDescription) + + if !photos.isEmpty { + drawPhotoGrid(pctx, title: "Photo Evidence", photos: photos) + } + + if let notes = issue.resultNotes, !notes.isEmpty { + drawResolution(pctx, notes: notes) + } + + if let vAt = issue.verifiedAt { + drawVerification(pctx, verifiedAt: vAt, note: issue.verificationNote) + } + } + } +} + +// MARK: - Header & Footer + +private func drawHeader(_ gc: CGContext, title: String, page: Int) { + gc.setFillColor(C_DARK.cgColor) + gc.fill(CGRect(x: 0, y: 0, width: kW, height: kHdrH)) + A(title, size: 13, weight: .bold, color: .white) + .draw(in: CGRect(x: kM, y: 26, width: kBodyW - 60, height: 20)) + A("Janitorial Quality Control System", size: 8, color: C_MUTED) + .draw(in: CGRect(x: kM, y: 48, width: kBodyW - 60, height: 14)) + A("Page \(page)", size: 8, color: C_MUTED, align: .right) + .draw(in: CGRect(x: kM, y: 38, width: kBodyW, height: 14)) +} + +private func drawFooter(_ gc: CGContext, generatedAt: String) { + let y = kH - kFtrH + gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) + gc.move(to: CGPoint(x: kM, y: y + 6)); gc.addLine(to: CGPoint(x: kW - kM, y: y + 6)) + gc.strokePath() + A("Generated: \(generatedAt) | Janitorial QC System", size: 7, color: C_SLATE) + .draw(in: CGRect(x: kM, y: y + 12, width: kBodyW * 0.75, height: 12)) + A("CONFIDENTIAL", size: 7, color: C_SLATE, align: .right) + .draw(in: CGRect(x: kM, y: y + 12, width: kBodyW, height: 12)) +} + +// MARK: - Severity / Status Banner + +private func drawSeverityBanner(_ pctx: PDFContext, issue: LocalIssue) { + let h: CGFloat = 36 + pctx.need(h + 10) + let gc = pctx.gc; let y = pctx.y + let sevW = kBodyW * 0.20; let midW = kBodyW * 0.55; let statW = kBodyW * 0.25 + + gc.setFillColor(severityColor(issue.severity).cgColor) + gc.fill(CGRect(x: kM, y: y, width: sevW, height: h)) + gc.setFillColor(C_DARK.cgColor) + gc.fill(CGRect(x: kM + sevW, y: y, width: midW, height: h)) + gc.setFillColor(C_SLATE.cgColor) + gc.fill(CGRect(x: kM + sevW + midW, y: y, width: statW, height: h)) + + A(issue.severity.uppercased(), size: 10, weight: .bold, color: .white, align: .center) + .draw(in: CGRect(x: kM, y: y + 12, width: sevW, height: 14)) + A("Issue #\(issue.serverId ?? 0)", size: 16, weight: .bold, color: .white, align: .center) + .draw(in: CGRect(x: kM + sevW, y: y + 9, width: midW, height: 20)) + let statTxt = issue.issueStatus.replacingOccurrences(of: "_", with: " ").capitalized + A(statTxt, size: 10, weight: .bold, color: .white, align: .center) + .draw(in: CGRect(x: kM + sevW + midW, y: y + 12, width: statW, height: 14)) + + pctx.advance(h + 10) +} + +// MARK: - Details Grid (4 columns, word-wrapped values) + +private func drawDetailsGrid(_ pctx: PDFContext, issue: LocalIssue, facilityName: String) { + let cols = 4 + let colW = kBodyW / CGFloat(cols) + let valueW = colW - 12 + let labelH: CGFloat = 10 + let valFontSize: CGFloat = 8.5 + let vPad: CGFloat = 6 + + let reportedStr: String = { + let d = issue.serverReportedAt ?? issue.createdAt + let f = DateFormatter(); f.dateStyle = .medium; f.timeStyle = .short + return f.string(from: d) + }() + let assignedStr = issue.assignedToName?.isEmpty == false ? issue.assignedToName! : "— Unassigned —" + let reporterStr = issue.reportedByName?.isEmpty == false ? issue.reportedByName! : "—" + let areaStr = issue.areaNameCache?.isEmpty == false ? issue.areaNameCache! : "—" + let statusStr = issue.issueStatus.replacingOccurrences(of: "_", with: " ").capitalized + + let rows: [[(String, String)]] = [ + [("FACILITY", facilityName), ("AREA", areaStr)], + [("REPORTED BY", reporterStr), ("ASSIGNED TO", assignedStr)], + [("REPORTED", reportedStr), ("STATUS", statusStr)], + ] + + func rowHeight(_ row: [(String, String)]) -> CGFloat { + let maxValH = row.map { wrappedHeight($0.1, size: valFontSize, width: valueW) }.max() ?? 14 + return vPad + labelH + 3 + max(maxValH, 12) + 6 + } + let rowHeights = rows.map(rowHeight) + let tableH = rowHeights.reduce(0, +) + + pctx.need(tableH + 10) + let gc = pctx.gc; let y0 = pctx.y + + gc.setFillColor(C_LIGHT.cgColor) + gc.fill(CGRect(x: kM, y: y0, width: kBodyW, height: tableH)) + gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) + gc.stroke(CGRect(x: kM, y: y0, width: kBodyW, height: tableH)) + let x1 = kM + colW + gc.move(to: CGPoint(x: x1, y: y0)); gc.addLine(to: CGPoint(x: x1, y: y0 + tableH)) + let x2 = kM + colW * 2 + gc.move(to: CGPoint(x: x2, y: y0)); gc.addLine(to: CGPoint(x: x2, y: y0 + tableH)) + let x3 = kM + colW * 3 + gc.move(to: CGPoint(x: x3, y: y0)); gc.addLine(to: CGPoint(x: x3, y: y0 + tableH)) + var ry = y0 + for h in rowHeights.dropLast() { + ry += h + gc.move(to: CGPoint(x: kM, y: ry)); gc.addLine(to: CGPoint(x: kM + kBodyW, y: ry)) + } + gc.strokePath() + + var cursorY = y0 + for (rowIdx, row) in rows.enumerated() { + let rh = rowHeights[rowIdx] + for (i, pair) in row.enumerated() { + // Each (label, value) pair occupies 2 of the 4 columns + let col = i * 2 + let cx = kM + CGFloat(col) * colW + 6 + A(pair.0, size: 7, color: C_SLATE) + .draw(in: CGRect(x: cx, y: cursorY + vPad - 2, width: valueW * 2, height: labelH)) + AM(pair.1, size: valFontSize, weight: .semibold) + .draw(in: CGRect(x: cx, y: cursorY + vPad + labelH + 1, + width: valueW, height: rh - labelH - vPad)) + } + cursorY += rh + } + + pctx.advance(tableH + 12) +} + +// MARK: - Description + +private func drawDescription(_ pctx: PDFContext, text: String) { + pctx.need(24) + A("Description", size: 10, weight: .bold) + .draw(in: CGRect(x: kM, y: pctx.y, width: kBodyW, height: 16)) + pctx.advance(18) + drawHR(pctx.gc, y: pctx.y) + pctx.advance(6) + + let display = text.isEmpty ? "—" : text + let boxH = max(wrappedHeight(display, size: 9, width: kBodyW - 16) + 12, 24) + pctx.need(boxH + 8) + + let gc = pctx.gc; let y = pctx.y + gc.setFillColor(UIColor(red: 0.973, green: 0.984, blue: 0.996, alpha: 1).cgColor) + gc.fill(CGRect(x: kM, y: y, width: kBodyW, height: boxH)) + AM(display, size: 9) + .draw(in: CGRect(x: kM + 8, y: y + 6, width: kBodyW - 16, height: boxH)) + pctx.advance(boxH + 10) +} + +// MARK: - Photo Grid (compact, 3 columns, compressed images already provided) + +private func drawPhotoGrid(_ pctx: PDFContext, title: String, photos: [UIImage]) { + pctx.need(24) + A(title, size: 10, weight: .bold) + .draw(in: CGRect(x: kM, y: pctx.y, width: kBodyW, height: 16)) + pctx.advance(18) + drawHR(pctx.gc, y: pctx.y) + pctx.advance(8) + + let cols = 3 + let gap: CGFloat = 8 + let cellW = (kBodyW - CGFloat(cols - 1) * gap) / CGFloat(cols) + let cellH: CGFloat = 130 // fixed thumbnail height keeps layout predictable + + var col = 0 + for photo in photos { + if col == 0 { pctx.need(cellH + gap) } + + let gc = pctx.gc + let cellX = kM + CGFloat(col) * (cellW + gap) + let cellY = pctx.y + + // Fit photo into cell, preserving aspect ratio, centred. + let imgSize = photo.size + let scale = min(cellW / imgSize.width, cellH / imgSize.height, 1.0) + let drawW = imgSize.width * scale + let drawH = imgSize.height * scale + let drawX = cellX + (cellW - drawW) / 2 + let drawY = cellY + (cellH - drawH) / 2 + + let frameRect = CGRect(x: cellX, y: cellY, width: cellW, height: cellH) + gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) + gc.stroke(frameRect) + photo.draw(in: CGRect(x: drawX, y: drawY, width: drawW, height: drawH)) + + col += 1 + if col == cols { + col = 0 + pctx.advance(cellH + gap) + } + } + if col != 0 { pctx.advance(cellH + gap) } // flush partial row + pctx.advance(4) +} + +// MARK: - Resolution Details + +private func drawResolution(_ pctx: PDFContext, notes: String) { + pctx.need(24) + A("Resolution Details", size: 10, weight: .bold, color: C_GREEN) + .draw(in: CGRect(x: kM, y: pctx.y, width: kBodyW, height: 16)) + pctx.advance(18) + drawHR(pctx.gc, y: pctx.y, color: C_GREEN) + pctx.advance(6) + + let boxH = max(wrappedHeight(notes, size: 9, width: kBodyW - 16) + 12, 24) + pctx.need(boxH + 8) + + let gc = pctx.gc; let y = pctx.y + gc.setFillColor(C_GREENBG.cgColor) + gc.fill(CGRect(x: kM, y: y, width: kBodyW, height: boxH)) + AM(notes, size: 9) + .draw(in: CGRect(x: kM + 8, y: y + 6, width: kBodyW - 16, height: boxH)) + pctx.advance(boxH + 10) +} + +// MARK: - Verification + +private func drawVerification(_ pctx: PDFContext, verifiedAt: Date, note: String?) { + pctx.need(24) + A("Verification", size: 10, weight: .bold, color: C_GREEN) + .draw(in: CGRect(x: kM, y: pctx.y, width: kBodyW, height: 16)) + pctx.advance(18) + drawHR(pctx.gc, y: pctx.y, color: C_GREEN) + pctx.advance(6) + + let dateStr: String = { + let f = DateFormatter(); f.dateStyle = .medium; f.timeStyle = .short + return f.string(from: verifiedAt) + }() + let noteText = (note?.isEmpty == false) ? note! : nil + let noteH = noteText.map { wrappedHeight($0, size: 8.5, width: kBodyW - 16) } ?? 0 + let boxH = max(14 + (noteText != nil ? noteH + 8 : 0) + 12, 24) + + pctx.need(boxH + 8) + let gc = pctx.gc; let y = pctx.y + gc.setFillColor(C_GREENBG.cgColor) + gc.fill(CGRect(x: kM, y: y, width: kBodyW, height: boxH)) + gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) + gc.stroke(CGRect(x: kM, y: y, width: kBodyW, height: boxH)) + + A("Verified on \(dateStr)", size: 9, weight: .semibold) + .draw(in: CGRect(x: kM + 8, y: y + 6, width: kBodyW - 16, height: 14)) + if let noteText { + AM(noteText, size: 8.5, color: C_SLATE) + .draw(in: CGRect(x: kM + 8, y: y + 22, width: kBodyW - 16, height: noteH + 4)) + } + pctx.advance(boxH + 8) +} + +// MARK: - Micro helpers + +private func drawHR(_ gc: CGContext, y: CGFloat, w: CGFloat = 1.0, color: UIColor = C_BORDER) { + gc.setStrokeColor(color.cgColor); gc.setLineWidth(w) + gc.move(to: CGPoint(x: kM, y: y)); gc.addLine(to: CGPoint(x: kM + kBodyW, y: y)) + gc.strokePath() +} diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index fcace01..a127680 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -13,6 +13,7 @@ import SwiftUI import SwiftData import Combine +import MessageUI // MARK: - SidebarTab @@ -1029,6 +1030,10 @@ struct IssueDetailView: View { @State private var newCommentText = "" @State private var isPostingComment = false @State private var commentError: String? + // ── Email PDF ───────────────────────────────────────────────────────── + @State private var isGeneratingPDF = false + @State private var showMailCompose = false + @State private var generatedPDFData: Data? = nil private var facilityName: String { let id = issue.facilityServerId @@ -1263,12 +1268,73 @@ struct IssueDetailView: View { } .navigationTitle("Issue Detail") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await prepareAndShowMail() } + } label: { + if isGeneratingPDF { + ProgressView() + } else { + Label("Share via Email", systemImage: "envelope") + } + } + .disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF) + } + } + .sheet(isPresented: $showMailCompose) { + if let pdfData = generatedPDFData { + MailComposeView( + subject: "Issue Report — Issue #\(issue.serverId ?? 0) (\(facilityName))", + body: issueEmailBody, + pdfData: pdfData, + pdfFilename: "Issue_\(issue.serverId ?? 0)_\(facilityName).pdf" + .replacingOccurrences(of: " ", with: "_") + ) + } + } .task { await refreshStatusFromServer() await loadComments() } } + /// Generates the issue PDF (re-encoding photos to keep file size minimal, + /// fetching server photos over the network if synced), then presents the + /// mail compose sheet with it attached. + private func prepareAndShowMail() async { + isGeneratingPDF = true + let data = await IssuePDFGenerator.generate(issue: issue, facilityName: facilityName) + generatedPDFData = data + isGeneratingPDF = false + showMailCompose = true + } + + private var issueEmailBody: String { + var lines: [String] = [] + lines.append("ISSUE REPORT — Issue #\(issue.serverId ?? 0)") + lines.append(String(repeating: "=", count: 40)) + lines.append("") + lines.append("Severity : \(issue.severity.capitalized)") + lines.append("Status : \(statusLabel(for: issue.issueStatus))") + lines.append("Facility : \(facilityName)") + if let area = issue.areaNameCache, !area.isEmpty { + lines.append("Area : \(area)") + } + if let assignee = issue.assignedToName, !assignee.isEmpty { + lines.append("Assigned : \(assignee)") + } + let reportDate = issue.serverReportedAt ?? issue.createdAt + lines.append("Reported : \(reportDate.formatted(date: .long, time: .shortened))") + lines.append("") + lines.append("DESCRIPTION") + lines.append(String(repeating: "-", count: 40)) + lines.append(issue.issueDescription) + lines.append("") + lines.append("— Sent from JanitorialQC Inspector") + return lines.joined(separator: "\n") + } + // ── Fetch fresh status from server ──────────────────────────────────── private func refreshStatusFromServer() async { diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index e7ff1ee..5cc7dd6 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -25,12 +25,15 @@ struct ExecuteInspectionView: View { @State private var formValues: [String: String] = [:] @State private var showFlagIssue = false @State private var showSubmitAlert = false + @State private var showNoGPSAlert = false @State private var isSaving = false @State private var isSubmitting = false @State private var submitResult: SubmitResult? - // Location manager — created lazily when submit alert fires so the - // permission prompt only appears at the point of actual submission. + // Location manager — created on view init. requestLocation() is called in + // onAppear so the permission prompt (and GPS fix acquisition) starts as + // soon as the inspector opens the inspection, maximising the chance of + // having a fix ready by submit time. @State private var locationManager = InspectionLocationManager() // Auto-save interval @@ -82,6 +85,11 @@ struct ExecuteInspectionView: View { } .onAppear { formValues = inspection.formData.compactMapValues { "\($0)" } + // Request Location permission (and start acquiring a fix) the moment + // the inspector opens the inspection — gives GPS the entire duration + // of the inspection to get a fix, rather than only the few seconds + // the confirm dialog is on screen. requestLocation() is idempotent. + locationManager.requestLocation() } .onDisappear { saveDraft() @@ -96,13 +104,27 @@ struct ExecuteInspectionView: View { FlagIssueView(inspection: inspection) } .alert("Submit Inspection", isPresented: $showSubmitAlert) { - Button("Submit") { Task { await submitInspection() } } + Button("Submit") { + if locationManager.lastLocation == nil { + // No GPS fix yet — warn before proceeding rather than + // silently submitting without a location. + showNoGPSAlert = true + } else { + Task { await submitInspection() } + } + } Button("Cancel", role: .cancel) {} } message: { Text(sync.isOnline ? "Once submitted the inspection cannot be edited. It will be sent to the server now." : "Once submitted the inspection cannot be edited. It will sync automatically when you're back online.") } + .alert("No GPS Location", isPresented: $showNoGPSAlert) { + Button("Submit Anyway") { Task { await submitInspection() } } + Button("Wait & Retry", role: .cancel) { locationManager.requestLocation() } + } message: { + Text("This inspection's location could not be recorded — Location permission may be denied, or no GPS signal is available right now. You can submit without it, or wait a moment and try again.") + } .onChange(of: showSubmitAlert) { _, showing in // Begin acquiring a GPS fix the moment the confirm dialog appears // so a location is likely ready by the time the inspector taps Submit. @@ -1161,9 +1183,12 @@ struct ConnectivityBadge: View { // MARK: - InspectionLocationManager // Thin CLLocationManager wrapper used only by ExecuteInspectionView. -// Requests a single best-accuracy fix when the Submit confirmation dialog -// appears. The fix is stored in lastLocation and read synchronously at the -// moment the inspector confirms submission. +// requestLocation() is called as soon as the inspection opens (onAppear), +// giving GPS the full duration of the inspection to acquire a fix, and again +// when the Submit confirmation dialog appears as a fallback retry. The fix +// is stored in lastLocation and read synchronously at submit time; if still +// nil, the view warns the inspector and lets them choose to submit anyway +// or wait and retry — GPS is not silently dropped. // // Design constraints: // - @Observable is unavailable before iOS 17 WWDC beta; use plain class + diff --git a/JanitorialQC/Views/Dashboard/FormFieldView.swift b/JanitorialQC/Views/Dashboard/FormFieldView.swift index b058bcb..10b128c 100644 --- a/JanitorialQC/Views/Dashboard/FormFieldView.swift +++ b/JanitorialQC/Views/Dashboard/FormFieldView.swift @@ -606,20 +606,36 @@ struct MultiLibraryPickerView: UIViewControllerRepresentable { picker.dismiss(animated: true) guard !results.isEmpty else { return } - var images: [UIImage] = [] + // PHItemProvider.loadObject's completion handler can be invoked on + // arbitrary background queues, potentially concurrently for + // different results. Appending to a shared `[UIImage]` from + // multiple threads without synchronization is a data race — + // Swift's Array is not thread-safe, and concurrent mutation can + // corrupt its storage, manifesting as duplicated, dropped, or + // reordered photos in the final result. This was the root cause + // of duplicated Photo Library attachments on issues. + // + // Fix: pre-size the array to one slot per result and write each + // loaded image into its own fixed index, guarded by a single + // serial queue so writes never overlap. Each slot is written at + // most once, so duplication is structurally impossible. + var images = [UIImage?](repeating: nil, count: results.count) + let writeQueue = DispatchQueue(label: "jqc.photopicker.write") let group = DispatchGroup() - for result in results { + for (index, result) in results.enumerated() { guard result.itemProvider.canLoadObject(ofClass: UIImage.self) else { continue } group.enter() result.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in - if let img = object as? UIImage { images.append(img) } + if let img = object as? UIImage { + writeQueue.sync { images[index] = img } + } group.leave() } } group.notify(queue: .main) { - self.parent.onSelected(images) + self.parent.onSelected(images.compactMap { $0 }) } } } diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index 8fe8220..6429a5b 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -5,6 +5,7 @@ import SwiftUI import SwiftData +import MessageUI struct InspectionHistoryView: View { @@ -209,7 +210,10 @@ struct HistoryDetailView: View { let inspection: APIInspectionSummary @Environment(\.modelContext) private var context - @State private var showReInspect = false + @State private var showReInspect = false + @State private var showMailCompose = false + @State private var isGeneratingPDF = false + @State private var generatedPDFData: Data? = nil // Local SwiftData copy — used only for follow-up sync-back. // Form data and schema come from the server response directly so @@ -317,6 +321,20 @@ struct HistoryDetailView: View { .background(Color(.systemBackground)) .navigationTitle(inspection.templateName) .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await prepareAndShowMail() } + } label: { + if isGeneratingPDF { + ProgressView() + } else { + Label("Share via Email", systemImage: "envelope") + } + } + .disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF) + } + } .onAppear { loadLocalData() syncFollowUpToLocalCopy() @@ -329,6 +347,28 @@ struct HistoryDetailView: View { parentLocalId: inspection.mobileLocalId ) } + .sheet(isPresented: $showMailCompose) { + if let pdfData = generatedPDFData { + MailComposeView( + subject: emailSubject, + body: emailBody, + pdfData: pdfData, + pdfFilename: "Inspection_\(inspection.id)_\(inspection.facilityName).pdf" + .replacingOccurrences(of: " ", with: "_") + ) + } + } + } + + /// 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 @@ -351,6 +391,48 @@ struct HistoryDetailView: View { 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 { @@ -766,3 +848,47 @@ struct PhotoThumbnailView: View { .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() + } + } +}