1306 lines
54 KiB
Swift
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 }
|
|
}
|
|
}
|