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
+44 -1
View File
@@ -187,6 +187,27 @@ class SyncManager: ObservableObject {
// Do not sync unless authenticated avoids 401 loops before // Do not sync unless authenticated avoids 401 loops before
// restoreSession() completes on first launch. // restoreSession() completes on first launch.
guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return } 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 isSyncing = true
syncError = nil syncError = nil
defer { isSyncing = false } defer { isSyncing = false }
@@ -216,10 +237,32 @@ class SyncManager: ObservableObject {
// string literals against PendingPhoto.uploadStatus reliably // string literals against PendingPhoto.uploadStatus reliably
// when the predicate type is inferred across model boundaries. // when the predicate type is inferred across model boundaries.
guard let allPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) else { return } guard let allPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) else { return }
let pending = allPhotos var pending = allPhotos
.filter { $0.uploadStatus == "pending" } .filter { $0.uploadStatus == "pending" }
.sorted { $0.createdAt < $1.createdAt } .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<String>()
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 { for photo in pending {
do { do {
let serverPath = try await APIClient.shared.uploadPhoto( let serverPath = try await APIClient.shared.uploadPhoto(
@@ -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 38MB 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..<cols {
let x = kM + CGFloat(c) * colW
gc.move(to: CGPoint(x: x, y: y0)); gc.addLine(to: CGPoint(x: x, 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()
// Draw cells
var cursorY = y0
for (rowIdx, row) in allRows.enumerated() {
let rh = rowHeights[rowIdx]
for (col, label, value) in row {
let cx = kM + CGFloat(col) * colW + 6
A(label, size: 7, color: C_SLATE)
.draw(in: CGRect(x: cx, y: cursorY + cellVPad - 2, width: valueW, height: labelH))
AM(value, size: valueFontSize, weight: .semibold)
.draw(in: CGRect(x: cx, y: cursorY + cellVPad + labelH + 1,
width: valueW, height: rh - labelH - cellVPad))
}
cursorY += rh
}
pctx.advance(tableH + 12)
}
// MARK: - Form Fields
private func drawFormFields(_ pctx: PDFContext,
schema: [[String: Any]],
values: [String: String],
images: [String: UIImage]) {
// Section heading
pctx.need(22 + 6)
A("Inspection Results", 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 skipTypes: Set<String> = ["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))
}
+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()
}
@@ -13,6 +13,7 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import Combine import Combine
import MessageUI
// MARK: - SidebarTab // MARK: - SidebarTab
@@ -1029,6 +1030,10 @@ struct IssueDetailView: View {
@State private var newCommentText = "" @State private var newCommentText = ""
@State private var isPostingComment = false @State private var isPostingComment = false
@State private var commentError: String? @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 { private var facilityName: String {
let id = issue.facilityServerId let id = issue.facilityServerId
@@ -1263,12 +1268,73 @@ struct IssueDetailView: View {
} }
.navigationTitle("Issue Detail") .navigationTitle("Issue Detail")
.navigationBarTitleDisplayMode(.inline) .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 { .task {
await refreshStatusFromServer() await refreshStatusFromServer()
await loadComments() 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 // Fetch fresh status from server
private func refreshStatusFromServer() async { private func refreshStatusFromServer() async {
@@ -25,12 +25,15 @@ struct ExecuteInspectionView: View {
@State private var formValues: [String: String] = [:] @State private var formValues: [String: String] = [:]
@State private var showFlagIssue = false @State private var showFlagIssue = false
@State private var showSubmitAlert = false @State private var showSubmitAlert = false
@State private var showNoGPSAlert = false
@State private var isSaving = false @State private var isSaving = false
@State private var isSubmitting = false @State private var isSubmitting = false
@State private var submitResult: SubmitResult? @State private var submitResult: SubmitResult?
// Location manager created lazily when submit alert fires so the // Location manager created on view init. requestLocation() is called in
// permission prompt only appears at the point of actual submission. // 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() @State private var locationManager = InspectionLocationManager()
// Auto-save interval // Auto-save interval
@@ -82,6 +85,11 @@ struct ExecuteInspectionView: View {
} }
.onAppear { .onAppear {
formValues = inspection.formData.compactMapValues { "\($0)" } 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 { .onDisappear {
saveDraft() saveDraft()
@@ -96,13 +104,27 @@ struct ExecuteInspectionView: View {
FlagIssueView(inspection: inspection) FlagIssueView(inspection: inspection)
} }
.alert("Submit Inspection", isPresented: $showSubmitAlert) { .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) {} Button("Cancel", role: .cancel) {}
} message: { } message: {
Text(sync.isOnline 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 be sent to the server now."
: "Once submitted the inspection cannot be edited. It will sync automatically when you're back online.") : "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 .onChange(of: showSubmitAlert) { _, showing in
// Begin acquiring a GPS fix the moment the confirm dialog appears // Begin acquiring a GPS fix the moment the confirm dialog appears
// so a location is likely ready by the time the inspector taps Submit. // so a location is likely ready by the time the inspector taps Submit.
@@ -1161,9 +1183,12 @@ struct ConnectivityBadge: View {
// MARK: - InspectionLocationManager // MARK: - InspectionLocationManager
// Thin CLLocationManager wrapper used only by ExecuteInspectionView. // Thin CLLocationManager wrapper used only by ExecuteInspectionView.
// Requests a single best-accuracy fix when the Submit confirmation dialog // requestLocation() is called as soon as the inspection opens (onAppear),
// appears. The fix is stored in lastLocation and read synchronously at the // giving GPS the full duration of the inspection to acquire a fix, and again
// moment the inspector confirms submission. // 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: // Design constraints:
// - @Observable is unavailable before iOS 17 WWDC beta; use plain class + // - @Observable is unavailable before iOS 17 WWDC beta; use plain class +
@@ -606,20 +606,36 @@ struct MultiLibraryPickerView: UIViewControllerRepresentable {
picker.dismiss(animated: true) picker.dismiss(animated: true)
guard !results.isEmpty else { return } 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() let group = DispatchGroup()
for result in results { for (index, result) in results.enumerated() {
guard result.itemProvider.canLoadObject(ofClass: UIImage.self) else { continue } guard result.itemProvider.canLoadObject(ofClass: UIImage.self) else { continue }
group.enter() group.enter()
result.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in 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.leave()
} }
} }
group.notify(queue: .main) { group.notify(queue: .main) {
self.parent.onSelected(images) self.parent.onSelected(images.compactMap { $0 })
} }
} }
} }
@@ -5,6 +5,7 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import MessageUI
struct InspectionHistoryView: View { struct InspectionHistoryView: View {
@@ -209,7 +210,10 @@ struct HistoryDetailView: View {
let inspection: APIInspectionSummary let inspection: APIInspectionSummary
@Environment(\.modelContext) private var context @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. // Local SwiftData copy used only for follow-up sync-back.
// Form data and schema come from the server response directly so // Form data and schema come from the server response directly so
@@ -317,6 +321,20 @@ struct HistoryDetailView: View {
.background(Color(.systemBackground)) .background(Color(.systemBackground))
.navigationTitle(inspection.templateName) .navigationTitle(inspection.templateName)
.navigationBarTitleDisplayMode(.inline) .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 { .onAppear {
loadLocalData() loadLocalData()
syncFollowUpToLocalCopy() syncFollowUpToLocalCopy()
@@ -329,6 +347,28 @@ struct HistoryDetailView: View {
parentLocalId: inspection.mobileLocalId 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 /// 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() } 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 // Summary card
private var summaryCard: some View { private var summaryCard: some View {
@@ -766,3 +848,47 @@ struct PhotoThumbnailView: View {
.buttonStyle(.plain) .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()
}
}
}