06/19 Update inspections/issues sent via email, and duplicated issue photos issues

This commit is contained in:
Nguyen Ngo
2026-06-19 17:28:09 -04:00
parent 95c8238c00
commit a57d6f02ba
7 changed files with 1366 additions and 12 deletions
+481
View File
@@ -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()
}