06/19 Update inspections/issues sent via email, and duplicated issue photos issues
This commit is contained in:
@@ -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<PendingPhoto>()) 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<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 {
|
||||
do {
|
||||
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 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..<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))
|
||||
}
|
||||
@@ -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 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 {
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MessageUI
|
||||
|
||||
struct InspectionHistoryView: View {
|
||||
|
||||
@@ -210,6 +211,9 @@ struct HistoryDetailView: View {
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user