Files
JQC_iOS_App/JanitorialQC/Views/Inspection/InspectionHistoryView.swift
T

1306 lines
54 KiB
Swift

// Views/Inspection/InspectionHistoryView.swift
// --------------------------------------------
// Shows the inspector's synced inspection history fetched from the server.
// Only available when online. Displays score, facility, template, and date.
import SwiftUI
import SwiftData
import MessageUI
struct InspectionHistoryView: View {
@EnvironmentObject private var sync: SyncManager
@State private var inspections: [APIInspectionSummary] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var total = 0
@State private var offset = 0
private let limit = 30
@State private var searchText = ""
@State private var showFilterSheet = false
@State private var filterFromDate: Date? = nil
@State private var filterToDate: Date? = nil
// Draft values edited in the sheet before applying
@State private var draftFromDate: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
@State private var draftToDate: Date = Date()
@State private var draftFromEnabled = false
@State private var draftToEnabled = false
private var hasActiveFilter: Bool { filterFromDate != nil || filterToDate != nil }
/// Date formatter for search matching — formats to e.g. "Jun 19, 2026 4:55 PM"
/// so partial strings like "jun", "2026", "19" all match.
private static let searchDateFmt: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
private func dateString(for insp: APIInspectionSummary) -> String {
guard let d = insp.inspectionDateParsed else { return insp.inspectionDate ?? "" }
return Self.searchDateFmt.string(from: d)
}
/// Client-side filter over the already-fetched page.
/// Matches: ID (exact or #-prefixed), date string (partial), facility
/// name, area name (together = "location"). Load More still fetches more
/// server pages so the inspector isn't limited to the first 30 results
/// when searching by date or location.
private var filteredInspections: [APIInspectionSummary] {
guard !searchText.isEmpty else { return inspections }
let q = searchText.lowercased()
return inspections.filter {
// ID — match "#12" or bare "12"
let idStr = String($0.id)
let idMatch = idStr == q || idStr == q.replacingOccurrences(of: "#", with: "")
// Location = facility name + area name
let locationMatch = $0.facilityName.lowercased().contains(q)
|| ($0.areaName?.lowercased().contains(q) ?? false)
return idMatch
|| locationMatch
|| dateString(for: $0).lowercased().contains(q)
|| $0.templateName.lowercased().contains(q)
}
}
var body: some View {
Group {
if !sync.isOnline && inspections.isEmpty {
ContentUnavailableView(
"Offline",
systemImage: "wifi.slash",
description: Text("Inspection history requires an internet connection.")
)
} else if isLoading && inspections.isEmpty {
ProgressView("Loading history…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = errorMessage, inspections.isEmpty {
ContentUnavailableView(
"Could Not Load",
systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else if inspections.isEmpty {
ContentUnavailableView(
"No History",
systemImage: "clock.arrow.circlepath",
description: Text("Completed inspections will appear here after syncing.")
)
} else if filteredInspections.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
List {
ForEach(filteredInspections) { inspection in
NavigationLink(value: inspection) {
HistoryRowView(inspection: inspection)
}
}
// Load More — still available while searching so the
// inspector can expand beyond the current page.
if inspections.count < total {
HStack {
Spacer()
Button("Load More") {
Task { await loadMore() }
}
.disabled(isLoading)
Spacer()
}
.listRowSeparator(.hidden)
}
if isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
.listRowSeparator(.hidden)
}
}
.refreshable {
await load(reset: true)
}
}
}
.navigationTitle("Inspection History")
.searchable(text: $searchText,
prompt: "Search ID, date, facility, area…")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showFilterSheet = true
} label: {
Image(systemName: hasActiveFilter
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.foregroundStyle(hasActiveFilter ? .orange : .primary)
}
}
}
.sheet(isPresented: $showFilterSheet) {
DateFilterSheet(
fromEnabled: $draftFromEnabled,
fromDate: $draftFromDate,
toEnabled: $draftToEnabled,
toDate: $draftToDate
) {
// Apply — update active filters and reload from server
filterFromDate = draftFromEnabled ? draftFromDate : nil
filterToDate = draftToEnabled ? draftToDate : nil
showFilterSheet = false
Task { await load(reset: true) }
} onClear: {
draftFromEnabled = false
draftToEnabled = false
filterFromDate = nil
filterToDate = nil
showFilterSheet = false
Task { await load(reset: true) }
} onCancel: {
// Discard the draft edits, keep whatever is currently applied,
// and do NOT reload — backing out must change nothing.
draftFromEnabled = filterFromDate != nil
draftToEnabled = filterToDate != nil
if let f = filterFromDate { draftFromDate = f }
if let t = filterToDate { draftToDate = t }
showFilterSheet = false
}
}
.task {
if sync.isOnline {
await load(reset: true)
}
}
.onChange(of: sync.isOnline) {
if sync.isOnline && inspections.isEmpty {
Task { await load(reset: true) }
}
}
}
private func load(reset: Bool) async {
if reset { offset = 0 }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let result = try await APIClient.shared.fetchInspectionHistory(
limit: limit,
offset: reset ? 0 : offset,
fromDate: filterFromDate,
toDate: filterToDate
)
if reset {
inspections = result.inspections
} else {
inspections.append(contentsOf: result.inspections)
}
total = result.total
offset = inspections.count
} catch {
errorMessage = error.localizedDescription
}
}
private func loadMore() async {
await load(reset: false)
}
}
// MARK: - Date Filter Sheet
struct DateFilterSheet: View {
@Binding var fromEnabled: Bool
@Binding var fromDate: Date
@Binding var toEnabled: Bool
@Binding var toDate: Date
let onApply: () -> Void
let onClear: () -> Void
/// Dismiss without touching the active filter. Distinct from `onClear`:
/// Cancel used to call that, so backing out of the sheet silently wiped
/// whatever date range was already applied and reloaded the list.
let onCancel: () -> Void
var body: some View {
NavigationStack {
Form {
Section("From Date") {
Toggle("Filter by start date", isOn: $fromEnabled)
if fromEnabled {
DatePicker("From", selection: $fromDate,
displayedComponents: .date)
.datePickerStyle(.compact)
}
}
Section("To Date") {
Toggle("Filter by end date", isOn: $toEnabled)
if toEnabled {
DatePicker("To", selection: $toDate,
displayedComponents: .date)
.datePickerStyle(.compact)
}
}
Section {
Button("Clear Filters", role: .destructive, action: onClear)
}
}
.navigationTitle("Date Filter")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { onCancel() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Apply", action: onApply)
.bold()
}
}
}
.presentationDetents([.medium])
}
}
// MARK: - History Row
struct HistoryRowView: View {
let inspection: APIInspectionSummary
private var scoreColor: Color {
guard let score = inspection.overallScore else { return .secondary }
return score >= 80 ? .green : score >= 60 ? .orange : .red
}
private var dateText: String {
guard let date = inspection.inspectionDateParsed else {
return inspection.inspectionDate ?? ""
}
return date.formatted(date: .abbreviated, time: .shortened)
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2) {
Text(inspection.templateName)
.font(.headline)
.lineLimit(1)
Text(inspection.facilityName)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
if let area = inspection.areaName {
Text(area)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
Spacer()
if let score = inspection.overallScore {
VStack(alignment: .trailing, spacing: 2) {
Text(String(format: "%.1f%%", score))
.font(.title3.bold())
.foregroundStyle(scoreColor)
Text("Score")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
HStack {
Image(systemName: "calendar")
.font(.caption2)
.foregroundStyle(.tertiary)
Text(dateText)
.font(.caption2)
.foregroundStyle(.tertiary)
Spacer()
// Sync origin badge
if inspection.mobileLocalId != nil {
Label("Mobile", systemImage: "ipad")
.font(.caption2)
.foregroundStyle(.blue)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
}
}
// ── Follow-up badge ────────────────────────────────────────────
if inspection.followUpRequired {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.arrow.circlepath")
.font(.caption2)
Text("Follow-up Required")
.font(.caption2.bold())
}
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.orange.opacity(0.15))
.foregroundStyle(.orange)
.clipShape(Capsule())
}
}
.padding(.vertical, 4)
}
}
// MARK: - History Detail View
// Shows submitted inspection details.
// For inspections originally submitted from this device (matched via mobileLocalId),
// the filled-in form responses are shown using the same grid as ExecuteInspectionView.
// For inspections submitted elsewhere, only summary fields are shown.
struct HistoryDetailView: View {
let inspection: APIInspectionSummary
@Environment(\.modelContext) private var context
@EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager
@State private var showReInspect = false
@State private var showMailCompose = false
@State private var isGeneratingPDF = false
@State private var generatedPDFData: Data? = nil
// ── Schedule Follow-up (phase45) ──────────────────────────────────────
@State private var showScheduleSheet = false
/// Defaults to tomorrow: the point of this action is to plan the follow-up
/// for another day. Today is still selectable — the server allows it.
@State private var followUpDate = Calendar.current.date(
byAdding: .day, value: 1, to: Date()
) ?? Date()
@State private var followUpNotes = ""
@State private var isSchedulingFollowUp = false
@State private var scheduleError: String? = nil
@State private var scheduleConfirmation: String? = nil
/// Auditors are read-only everywhere else and the API rejects them (403),
/// so the two action buttons are hidden rather than shown failing.
///
/// `issueActors` is the same set minus auditor, and — unlike the literal
/// list this replaced — it includes Customer Inspectors, who perform
/// inspections exactly as our own do.
private var canStartFollowUp: Bool {
Constants.Roles.issueActors.contains(auth.currentUserRole)
}
// Local SwiftData copy — used only for follow-up sync-back.
// Form data and schema come from the server response directly so
// History works even after app reinstall or on a different device.
@State private var localCopy: LocalInspection? = nil
private var formSchema: [[String: Any]] { inspection.formSchema }
private var savedValues: [String: String] { inspection.formValues }
/// Load the local SwiftData copy once on appear (for follow-up sync only).
/// Fetch-all + filter in Swift — #Predicate with captured String is banned
/// under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (CLAUDE.md rules 3, 25).
private func loadLocalData() {
guard let lid = inspection.mobileLocalId else { return }
let all = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
localCopy = all.first { $0.localId == lid }
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// ── Follow-up required banner ──────────────────────────────
if inspection.followUpRequired {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.arrow.circlepath")
.foregroundStyle(.orange)
.font(.title3)
VStack(alignment: .leading, spacing: 4) {
Text("Follow-up Inspection Required")
.font(.callout.bold())
.foregroundStyle(.orange)
if let note = inspection.followUpNote, !note.isEmpty {
Text(note)
.font(.callout)
.foregroundStyle(.secondary)
}
Button {
showReInspect = true
} label: {
Label("Start Re-inspection", systemImage: "arrow.uturn.right.circle.fill")
.font(.callout.bold())
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.padding(.top, 4)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 24)
}
// ── Is a re-inspection — parent link ───────────────────────
if let parentId = inspection.parentInspectionId {
infoRow(icon: "arrow.uturn.right.circle",
text: "Re-inspection of inspection #\(parentId)")
.padding(.horizontal, 24)
}
// ── Summary card ───────────────────────────────────────────
summaryCard
// ── Flagged issues ─────────────────────────────────────────
if let copy = localCopy, !copy.localIssues.isEmpty {
issuesCard(copy.localIssues)
}
// ── Inspector notes ────────────────────────────────────────
if !inspection.inspectorNotes.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("Inspector Notes")
.font(.headline)
.padding(.horizontal, 24)
Text(inspection.inspectorNotes)
.font(.callout)
.foregroundStyle(.primary)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 24)
}
}
// ── Form responses ─────────────────────────────────────────
if !formSchema.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Form Responses")
.font(.headline)
.padding(.horizontal, 24)
ReadOnlyGridFormView(
schema: formSchema,
formValues: savedValues
)
.environment(\.mediaURLByPath, inspection.mediaURLByPath)
.padding(.horizontal, 24)
}
}
}
.padding(.vertical, 16)
}
.background(Color(.systemBackground))
.navigationTitle(inspection.templateName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
// ── Re-inspect now ────────────────────────────────────────────
// The immediate half of the follow-up pair. Opens the same linked
// re-inspection flow the follow-up banner has always used, but
// without waiting to be asked for one.
if canStartFollowUp {
ToolbarItem(placement: .primaryAction) {
Button {
showReInspect = true
} label: {
Label("Re-inspect Now", systemImage: "arrow.uturn.right.circle")
}
}
// ── Schedule follow-up ────────────────────────────────────
// The deferred half. Needs the network: it creates a schedule
// server-side rather than a local record, so unlike starting an
// inspection it cannot be queued offline.
ToolbarItem(placement: .primaryAction) {
Button {
scheduleError = nil
showScheduleSheet = true
} label: {
Label("Schedule Follow-up", systemImage: "calendar.badge.plus")
}
.disabled(!sync.isOnline)
}
}
ToolbarItem(placement: .primaryAction) {
Button {
Task { await prepareAndShowMail() }
} label: {
if isGeneratingPDF {
ProgressView()
} else {
Label("Share via Email", systemImage: "envelope")
}
}
.disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF)
}
}
.sheet(isPresented: $showScheduleSheet) { scheduleFollowUpSheet }
// Confirmation of a successful schedule. An alert rather than an inline
// banner because the sheet has already dismissed by this point.
.alert("Follow-up Scheduled",
isPresented: Binding(get: { scheduleConfirmation != nil },
set: { if !$0 { scheduleConfirmation = nil } })) {
Button("OK") { scheduleConfirmation = nil }
} message: {
Text(scheduleConfirmation ?? "")
}
.onAppear {
loadLocalData()
syncFollowUpToLocalCopy()
}
// Full-screen, not a sheet: every inspection-start flow is full-screen
// (rule 66), and this one is now reachable from the toolbar on any
// completed inspection rather than only the follow-up banner.
.fullScreenCover(isPresented: $showReInspect) {
StartInspectionView(
preFillTemplateId: inspection.templateId,
preFillFacilityId: inspection.facilityId,
parentServerId: inspection.id,
parentLocalId: inspection.mobileLocalId,
// History is served from the API, so this inspection is often
// not on this device at all and the local-parent lookup finds
// nothing — the form would open blank (rule 79). The answers are
// already in this very response, so pass them straight through.
preFillParentFormDataJSON: parentFormDataJSON
)
}
.sheet(isPresented: $showMailCompose) {
if let pdfData = generatedPDFData {
MailComposeView(
subject: emailSubject,
body: emailBody,
pdfData: pdfData,
pdfFilename: "Inspection_\(inspection.id)_\(inspection.facilityName).pdf"
.replacingOccurrences(of: " ", with: "_")
)
}
}
}
/// This inspection's answers, JSON-encoded for `StartInspectionView`'s
/// parent prefill. Raw values, not the flattened `formValues`, so an array
/// field survives as an array (rule 79).
private var parentFormDataJSON: String {
let raw = inspection.formDataRaw.mapValues(\.anyValue)
guard JSONSerialization.isValidJSONObject(raw),
let data = try? JSONSerialization.data(withJSONObject: raw),
let str = String(data: data, encoding: .utf8)
else { return "{}" }
return str
}
// ── Schedule Follow-up sheet (phase45) ────────────────────────────────
/// Date + note picker for planning a follow-up re-inspection.
///
/// Only the date and an optional note are collected: the server derives
/// facility, template and assignee from the parent inspection, so there is
/// nothing else for the inspector to get wrong.
private var scheduleFollowUpSheet: some View {
NavigationStack {
Form {
Section {
Text(inspection.templateName)
.font(.callout.bold())
Text(inspection.facilityName)
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Follow-up of Inspection #\(inspection.id)")
}
Section {
DatePicker(
"Due Date",
selection: $followUpDate,
in: Date()..., // the server rejects a past date
displayedComponents: .date
)
.datePickerStyle(.graphical)
} header: {
Text("When")
} footer: {
Text("The follow-up appears in Scheduled on this date, "
+ "assigned to the inspector who did the original.")
}
Section {
TextField(
"What should the follow-up address?",
text: $followUpNotes,
axis: .vertical
)
.lineLimit(3...6)
} header: {
Text("Instructions (optional)")
}
if let err = scheduleError {
Section {
Label(err, systemImage: "exclamationmark.triangle")
.font(.callout)
.foregroundStyle(.red)
}
}
}
.navigationTitle("Schedule Follow-up")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showScheduleSheet = false }
.disabled(isSchedulingFollowUp)
}
ToolbarItem(placement: .confirmationAction) {
Button {
Task { await submitScheduledFollowUp() }
} label: {
if isSchedulingFollowUp {
ProgressView()
} else {
Text("Schedule")
}
}
.disabled(isSchedulingFollowUp)
}
}
}
}
/// Create the follow-up schedule on the server, then refresh so it appears
/// in the Scheduled lists without waiting for the next timed sync.
///
/// Online-only by nature: this writes a server-side plan, not a local
/// record, so there is nothing meaningful to queue offline — the button is
/// disabled when offline and this reports any failure inline rather than
/// dismissing as if it had worked.
private func submitScheduledFollowUp() async {
isSchedulingFollowUp = true
scheduleError = nil
let due = Self.dueDateFormatter.string(from: followUpDate)
do {
_ = try await APIClient.shared.createScheduledFollowUp(
parentInspectionId: inspection.id,
dueDate: due,
notes: followUpNotes
)
// Pull the new schedule straight into the Scheduled section.
await sync.pullScheduledInspections(context: context)
isSchedulingFollowUp = false
showScheduleSheet = false
followUpNotes = ""
scheduleConfirmation =
"A follow-up re-inspection of \(inspection.facilityName) is scheduled for "
+ followUpDate.formatted(date: .abbreviated, time: .omitted) + "."
} catch {
isSchedulingFollowUp = false
scheduleError = (error as? APIError)?.localizedDescription
?? "Could not schedule the follow-up. Check your connection and try again."
}
}
/// `yyyy-MM-dd` for the API's `due_date`. Fixed POSIX locale so a non-
/// Gregorian device calendar cannot emit a date the server can't parse.
private static let dueDateFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
return f
}()
/// 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
/// so that MyInspectionsView and CompletedInspectionView reflect the latest state.
private func syncFollowUpToLocalCopy() {
guard let copy = localCopy else { return }
var changed = false
if copy.followUpRequired != inspection.followUpRequired {
copy.followUpRequired = inspection.followUpRequired
changed = true
}
if copy.followUpNote != inspection.followUpNote {
copy.followUpNote = inspection.followUpNote
changed = true
}
if copy.parentServerId != inspection.parentInspectionId {
copy.parentServerId = inspection.parentInspectionId
changed = true
}
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 {
VStack(alignment: .leading, spacing: 12) {
// Score
if let score = inspection.overallScore {
HStack {
Text("Overall Score")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Text(String(format: "%.1f%%", score))
.font(.title2.bold())
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
}
}
Divider()
infoRow(icon: "building.2", text: inspection.facilityName)
if let area = inspection.areaName {
infoRow(icon: "mappin", text: area)
}
if let date = inspection.inspectionDateParsed {
infoRow(icon: "calendar", text: date.formatted(date: .long, time: .shortened))
}
if inspection.mobileLocalId != nil {
infoRow(icon: "ipad", text: "Submitted from this device")
}
}
.padding(16)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 24)
}
// ── Flagged issues card ────────────────────────────────────────────────
private func issuesCard(_ issues: [LocalIssue]) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Flagged Issues (\(issues.count))")
.font(.headline)
ForEach(issues) { issue in
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(issue.severity == "critical" ? Color.red :
issue.severity == "high" ? Color.orange :
issue.severity == "medium" ? Color.yellow : Color.blue)
.frame(width: 8, height: 8)
.padding(.top, 5)
VStack(alignment: .leading, spacing: 2) {
Text(issue.severity.capitalized)
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(issue.issueDescription)
.font(.callout)
}
}
}
}
.padding(16)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 24)
}
// ── Helper ─────────────────────────────────────────────────────────────
private func infoRow(icon: String, text: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.foregroundStyle(.secondary)
.frame(width: 18)
Text(text)
.font(.callout)
.foregroundStyle(.primary)
}
}
}
// MARK: - ReadOnlyGridFormView
// Renders answered form fields row by row.
// Each original schema row becomes one HStack; each field is sized
// proportionally to its colSpan (out of 12 columns).
// Fields that start past col 1 get a leading spacer.
// Only rows containing at least one answered field are shown.
struct ReadOnlyGridFormView: View {
let schema: [[String: Any]]
let formValues: [String: String]
@Environment(\.horizontalSizeClass) private var hSizeClass
// ── Field visibility filtering ────────────────────────────────────────
// A row group: all visible fields that share the same original `row`.
private struct RowGroup {
let fields: [[String: Any]] // visible fields in this row, schema order
}
private var visibleRowGroups: [RowGroup] {
let skipTypes: Set<String> = ["label", "section",
"button_submit", "button_print", "button_email"]
// Sort schema by (row, col) once — same as web PDF and InspectionPDFGenerator.
let sortedSchema = schema.sorted {
let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0
if r0 != r1 { return r0 < r1 }
let c0 = $0["col"] as? Int ?? 0, c1 = $1["col"] as? Int ?? 0
return c0 < c1
}
// Pass 1 — answered data field IDs
var answeredIds = Set<String>()
for f in sortedSchema {
guard let ftype = f["type"] as? String, !skipTypes.contains(ftype) else { continue }
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
let val = formValues[fid] ?? ""
let answered = ftype == "rating" ? (Int(val) ?? 0) > 0 : !val.isEmpty
if answered { answeredIds.insert(fid) }
}
// Pass 2 — label IDs that immediately precede an answered field
var visibleLabelIds = Set<String>()
var lbuf: [String] = []
for f in sortedSchema {
let ftype = f["type"] as? String ?? ""
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
if ftype == "label" {
lbuf.append(fid)
} else if !skipTypes.contains(ftype) {
if answeredIds.contains(fid) { visibleLabelIds.formUnion(lbuf) }
lbuf.removeAll()
}
}
// Pass 3 — section IDs that precede at least one answered field
var visibleSectionIds = Set<String>()
var pendingSecId: String? = nil
for f in sortedSchema {
let ftype = f["type"] as? String ?? ""
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
if ftype == "section" {
pendingSecId = fid
} else if !skipTypes.contains(ftype), answeredIds.contains(fid) {
if let sid = pendingSecId { visibleSectionIds.insert(sid); pendingSecId = nil }
}
}
// Pass 4 — group fields by original row, keep schema order.
// sortedSchema already computed above — reuse it.
var rowGroups: [Int: [[String: Any]]] = [:]
var rowOrder: [Int] = []
for f in sortedSchema {
let row = f["row"] as? Int ?? 1
if rowGroups[row] == nil { rowOrder.append(row); rowGroups[row] = [] }
rowGroups[row]!.append(f)
}
// Pass 5 — for each row, collect visible fields; skip rows with none.
// Iterate in rowOrder (insertion order of sorted schema = row order).
var result: [RowGroup] = []
for origRow in rowOrder {
guard let group = rowGroups[origRow] else { continue }
var visibleInRow: [[String: Any]] = []
for f in group {
let ftype = f["type"] as? String ?? ""
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
switch ftype {
case "button_submit", "button_print", "button_email": continue
case "section": if visibleSectionIds.contains(fid) { visibleInRow.append(f) }
case "label": if visibleLabelIds.contains(fid) { visibleInRow.append(f) }
default: if answeredIds.contains(fid) { visibleInRow.append(f) }
}
}
if !visibleInRow.isEmpty {
result.append(RowGroup(fields: visibleInRow))
}
}
return result
}
var body: some View {
if visibleRowGroups.isEmpty {
Text("No form responses recorded.")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
VStack(alignment: .leading, spacing: hSizeClass == .compact ? 10 : 3) {
ForEach(visibleRowGroups.indices, id: \.self) { idx in
if hSizeClass == .compact {
stackedRowView(visibleRowGroups[idx])
} else {
rowView(visibleRowGroups[idx])
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// Render one row as a GeometryReader-based HStack so each field
// occupies exactly (colSpan/12) of the available width, and leading
// space before col > 1 is filled with a transparent spacer.
//
// On a narrow screen the same 12-column division that works on iPad
// leaves each field a few dozen points wide inside a fixed 36 pt row, so
// labels and values collide. Below `minGridWidth` each field gets its own
// full-width line at its natural height instead.
@ViewBuilder
private func rowView(_ group: RowGroup) -> some View {
GeometryReader { geo in
let totalW = geo.size.width
let colW = totalW / 12.0
let fields = group.fields
ZStack(alignment: .topLeading) {
ForEach(fields.indices, id: \.self) { i in
let f = fields[i]
let col = max(1, f["col"] as? Int ?? 1)
let colSpan = max(1, min(f["colSpan"] as? Int ?? 6, 13 - col))
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
let value = formValues[fid] ?? ""
let ftype = f["type"] as? String ?? "text"
let label = f["label"] as? String ?? ""
ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label)
.frame(width: colW * CGFloat(colSpan), alignment: .topLeading)
.offset(x: colW * CGFloat(col - 1))
}
}
}
.frame(height: rowHeight(group))
}
/// Stacked equivalent of `rowView` for narrow screens: no proportional
/// widths, no fixed row height, so nothing can overlap.
@ViewBuilder
private func stackedRowView(_ group: RowGroup) -> some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(group.fields.indices, id: \.self) { i in
let f = group.fields[i]
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
let value = formValues[fid] ?? ""
let ftype = f["type"] as? String ?? "text"
let label = f["label"] as? String ?? ""
ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
}
// Row height: fixed 36pt for most fields; taller for section headers.
private func rowHeight(_ group: RowGroup) -> CGFloat {
let hasSection = group.fields.contains { ($0["type"] as? String) == "section" }
return hasSection ? 28 : 36
}
}
// MARK: - ReadOnlyCellView
// Displays a single form cell as plain text — no editable controls.
struct ReadOnlyCellView: View {
let field: [String: Any]
let value: String
let fieldType: String
let label: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
// Label (same as GridCellContentView)
if !["section", "label", "checkbox",
"button_submit", "button_print", "button_email"].contains(fieldType),
!label.isEmpty {
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(.secondaryLabel))
.lineLimit(1)
.truncationMode(.tail)
}
// Value display
valueView
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
@ViewBuilder
private var valueView: some View {
switch fieldType {
case "section":
VStack(alignment: .leading, spacing: 0) {
Divider()
Text(label)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(Color(.label))
.padding(.top, 4)
}
.frame(maxWidth: .infinity)
case "label":
let fsMap: [String: CGFloat] = ["small": 11, "normal": 13, "large": 15, "x-large": 18]
let fs = fsMap[field["font_size"] as? String ?? "normal"] ?? 13
let fw: Font.Weight = (field["font_weight"] as? String == "bold") ? .bold : .regular
Text(field["text_content"] as? String ?? "")
.font(.system(size: fs, weight: fw))
.foregroundStyle(Color(.label))
.fixedSize(horizontal: false, vertical: true)
case "checkbox":
HStack(spacing: 6) {
Image(systemName: value == "true" ? "checkmark.square.fill" : "square")
.foregroundStyle(value == "true" ? .blue : Color(.systemGray3))
.font(.system(size: 14))
Text(label)
.font(.system(size: 12))
.foregroundStyle(Color(.secondaryLabel))
}
case "pass_fail":
let options = field["options"] as? [String] ?? ["Pass", "Fail"]
HStack(spacing: 6) {
ForEach(options, id: \.self) { opt in
let isPass = ["pass","yes","ok","good","acceptable","compliant"].contains(opt.lowercased())
let isActive = value == opt
Text(opt)
.font(.system(size: 12, weight: .semibold))
.padding(.horizontal, 10).padding(.vertical, 4)
.background(isActive ? (isPass ? Color.green : Color.red) : Color.clear)
.foregroundStyle(isActive ? .white : (isPass ? Color.green : Color.red))
.clipShape(Capsule())
.overlay(Capsule().stroke(isPass ? Color.green : Color.red, lineWidth: 1.5))
}
}
case "rating":
let intVal = Int(value) ?? 0
let maxRating = field["max"] as? Int ?? 5
HStack(spacing: 2) {
ForEach(1...Swift.max(maxRating, 1), id: \.self) { star in
Text("★")
.font(.system(size: 16))
.foregroundStyle(star <= intVal ? Color.yellow : Color(.systemGray4))
}
}
case "image":
PhotoThumbnailView(value: value)
default:
// Text, textarea, number, email, date, select, radio, checkbox_group
Text(value.isEmpty ? "—" : value)
.font(.system(size: 12))
.foregroundStyle(value.isEmpty ? Color(.tertiaryLabel) : Color(.label))
.lineLimit(3)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay(RoundedRectangle(cornerRadius: 5)
.stroke(Color(.systemGray5), lineWidth: 1))
}
}
}
// MARK: - PhotoThumbnailView
// Compact thumbnail that fits inside a single grid row (cellAspect 0.5).
// Tap opens a fullscreen lightbox sheet.
struct PhotoThumbnailView: View {
let value: String
@Environment(\.mediaURLByPath) private var mediaURLByPath
@State private var showLightbox = false
var body: some View {
Group {
if value.hasPrefix("local://") {
let path = String(value.dropFirst("local://".count))
if let img = UIImage(contentsOfFile: path) {
thumbnailButton {
Image(uiImage: img)
.resizable().scaledToFill()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
.sheet(isPresented: $showLightbox) {
ZStack {
Color.black.ignoresSafeArea()
Image(uiImage: img)
.resizable().scaledToFit()
}
.onTapGesture { showLightbox = false }
}
} else {
Label("No longer on device", systemImage: "exclamationmark.triangle")
.font(.system(size: 11)).foregroundStyle(.secondary)
}
} else if value.hasPrefix("uploads/") {
let url = ServerConfig.mediaURL(absolute: mediaURLByPath[value], path: value)
thumbnailButton {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().scaledToFill()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
case .failure:
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 14)).foregroundStyle(.secondary)
.frame(width: 32, height: 32)
default:
ProgressView().frame(width: 32, height: 32)
}
}
}
.sheet(isPresented: $showLightbox) {
ZStack {
Color.black.ignoresSafeArea()
RetryablePhotoView(url: url)
}
.onTapGesture { showLightbox = false }
}
} else if !value.isEmpty {
Label("Photo attached", systemImage: "photo")
.font(.system(size: 11)).foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private func thumbnailButton<Content: View>(@ViewBuilder content: () -> Content) -> some View {
Button { showLightbox = true } label: {
HStack(spacing: 4) {
content()
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
}
.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()
}
}
}
// MARK: - Media URL environment
// Injects a {relative_path: absolute_url} map (from APIInspectionSummary.mediaURLByPath)
// so image cells deep inside the read-only grid can resolve presigned R2 URLs
// without threading field IDs through every layer. Empty map → the resolver
// falls back to building a /static/ URL from the relative path.
private struct MediaURLByPathKey: EnvironmentKey {
static let defaultValue: [String: String] = [:]
}
extension EnvironmentValues {
var mediaURLByPath: [String: String] {
get { self[MediaURLByPathKey.self] }
set { self[MediaURLByPathKey.self] = newValue }
}
}