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
|
// 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 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 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user