Files

482 lines
20 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { PhotoStore.resolve($0).flatMap(UIImage.init(contentsOfFile:)) }
}
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()
}