06/19 Update inspections/issues sent via email, and duplicated issue photos issues
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user