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

This commit is contained in:
Nguyen Ngo
2026-06-19 17:28:09 -04:00
parent 95c8238c00
commit a57d6f02ba
7 changed files with 1366 additions and 12 deletions
@@ -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 {
@@ -209,7 +210,10 @@ struct HistoryDetailView: View {
let inspection: APIInspectionSummary
@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.
// 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()
}
}
}