1806 lines
80 KiB
Swift
1806 lines
80 KiB
Swift
// Views/Dashboard/ExecuteInspectionView.swift
|
|
// -------------------------------------------
|
|
// Primary work surface for completing an inspection.
|
|
//
|
|
// CHANGED (grid layout update):
|
|
// - formFieldCards replaced with GridFormView — a geometry-driven 12-column
|
|
// grid that positions each field using its col/row/colSpan/rowSpan attributes,
|
|
// mirroring the web app's CSS grid layout exactly.
|
|
// - groupFieldsBySection helper removed (no longer needed).
|
|
// - All other logic (save draft, submit, photo handling, notes, banners) is
|
|
// completely unchanged.
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import CoreLocation
|
|
|
|
struct ExecuteInspectionView: View {
|
|
|
|
@Environment(\.modelContext) private var context
|
|
@Environment(\.dismiss) private var dismiss
|
|
@EnvironmentObject private var sync: SyncManager
|
|
|
|
let inspection: LocalInspection
|
|
|
|
/// True when presented as the root of a fullScreenCover/sheet (e.g. the
|
|
/// dashboard "Resume" banner) rather than pushed onto a NavigationStack.
|
|
/// In that case there is no navigation back button, so a leading "Close"
|
|
/// button is shown so the inspector can return home without submitting.
|
|
/// Work is preserved either way — .onDisappear calls saveDraft().
|
|
var isModallyPresented: Bool = false
|
|
|
|
/// What to do when the inspection has been submitted, instead of the
|
|
/// default `dismiss()`.
|
|
///
|
|
/// `StartInspectionView` PUSHES this view onto the NavigationStack inside
|
|
/// its own `.fullScreenCover`, so a plain `dismiss()` only pops — landing
|
|
/// the inspector back on the "New Inspection" form they just started from,
|
|
/// with Cancel as the only way out. It passes its own dismiss here so the
|
|
/// whole cover closes and they return to the dashboard.
|
|
///
|
|
/// Left nil everywhere else, where popping IS correct: the My Inspections
|
|
/// row pushes onto the list's stack, and the dashboard's Resume banner
|
|
/// presents this view as the cover root.
|
|
var onFinished: (() -> Void)? = nil
|
|
|
|
@State private var formValues: [String: String] = [:]
|
|
@State private var showFlagIssue = false
|
|
@State private var showSubmitAlert = false
|
|
@State private var showNoGPSAlert = false
|
|
/// Set when Submit is tapped with no GPS fix; consumed by
|
|
/// `onChange(of: showSubmitAlert)` once the confirm alert has dismissed.
|
|
@State private var pendingNoGPSPrompt = false
|
|
@State private var showValidationAlert = false
|
|
@State private var missingFields: [String] = []
|
|
@State private var isSaving = false
|
|
@State private var autoSavedAt: Date? = nil // drives the auto-save toast
|
|
@State private var isSubmitting = false
|
|
@State private var submitResult: SubmitResult?
|
|
|
|
// ── Schedule instructions ─────────────────────────────────────────────
|
|
// Snapshotted into @State in onAppear rather than read from SwiftData on
|
|
// every body pass: resolveAndFulfillSchedule() DELETES the cached
|
|
// LocalScheduledInspection row at submit time, while this view is still on
|
|
// screen showing the success banner. Reading a deleted PersistentModel traps.
|
|
@State private var scheduleInstructions: String? = nil
|
|
@State private var instructionsExpanded = true
|
|
|
|
// 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
|
|
private let autoSaveInterval: TimeInterval = 30
|
|
|
|
enum SubmitResult {
|
|
case success(score: Double?)
|
|
case failure(String)
|
|
}
|
|
|
|
// ── Computed ──────────────────────────────────────────────────────────
|
|
|
|
private var template: LocalTemplate? {
|
|
let id = inspection.templateServerId
|
|
return try? context.fetch(
|
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
|
).first
|
|
}
|
|
|
|
private var facility: LocalFacility? {
|
|
let id = inspection.facilityServerId
|
|
return try? context.fetch(
|
|
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
|
).first
|
|
}
|
|
|
|
private var formSchema: [[String: Any]] { template?.formSchema ?? [] }
|
|
|
|
/// Live score computed directly from `formValues` (in-memory SwiftUI state)
|
|
/// so it updates as the inspector fills in each field — without waiting for
|
|
/// `saveDraft()` to flush to SwiftData and `computeScore()` to run.
|
|
/// Returns nil when the schema has no scoreable fields.
|
|
private var liveScore: Double? {
|
|
let scoreable = formSchema.filter {
|
|
["rating", "checkbox", "radio", "pass_fail"].contains($0["type"] as? String ?? "")
|
|
}
|
|
guard !scoreable.isEmpty else { return nil }
|
|
|
|
var total = 0; var earned = 0
|
|
for field in scoreable {
|
|
let fid: String
|
|
if let s = field["id"] as? String { fid = s }
|
|
else if let n = field["id"] as? Int { fid = String(n) }
|
|
else { continue }
|
|
guard let ftype = field["type"] as? String else { continue }
|
|
let val = formValues[fid] ?? ""
|
|
|
|
switch ftype {
|
|
case "rating":
|
|
// Denominator is a FLAT 5, never the field's `max`.
|
|
// `_compute_score_from_form()` in the Flask app (routes/
|
|
// inspections.py) hardcodes `total += 5`, and LocalInspection
|
|
// .computeScore() mirrors it — this was the only site reading
|
|
// `max`, so a template with max != 5 showed one percentage in
|
|
// the toolbar and submitted a different one.
|
|
if let v = Int(val), v > 0 { earned += v; total += 5 }
|
|
case "checkbox":
|
|
total += 1; if val == "true" { earned += 1 }
|
|
case "radio":
|
|
total += 1
|
|
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { earned += 1 }
|
|
case "pass_fail":
|
|
guard !val.isEmpty else { continue }
|
|
total += 1
|
|
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { earned += 1 }
|
|
default: break
|
|
}
|
|
}
|
|
guard total > 0 else { return nil }
|
|
return (Double(earned) / Double(total) * 100).rounded(toPlaces: 2)
|
|
}
|
|
|
|
// ── Body ──────────────────────────────────────────────────────────────
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
// Centre content with max-width on iPad
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
formContent
|
|
}
|
|
.frame(maxWidth: 1000)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 16)
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.navigationTitle(template?.name ?? "Inspection")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
if isModallyPresented {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
// Root of a modal presentation — no nav back button exists.
|
|
// Draft is saved on disappear, so closing loses nothing.
|
|
Button("Close") { dismiss() }
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
HStack(spacing: 10) {
|
|
// Live score — updates on every field change via formValues binding.
|
|
// Only shown when the schema has at least one scoreable field.
|
|
if let score = liveScore {
|
|
let color: Color = score >= 80 ? .green : score >= 60 ? .orange : .red
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "chart.bar.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(color)
|
|
Text(String(format: "%.0f%%", score))
|
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(color)
|
|
}
|
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
.background(color.opacity(0.12))
|
|
.clipShape(Capsule())
|
|
}
|
|
ConnectivityBadge()
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
formValues = inspection.formData.compactMapValues { "\($0)" }
|
|
loadScheduleInstructions()
|
|
// 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()
|
|
}
|
|
.task {
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(autoSaveInterval))
|
|
saveDraft()
|
|
// Show "Draft saved" toast and dismiss after 2 seconds
|
|
withAnimation { autoSavedAt = Date() }
|
|
try? await Task.sleep(for: .seconds(2))
|
|
withAnimation { autoSavedAt = nil }
|
|
}
|
|
}
|
|
.sheet(isPresented: $showFlagIssue) {
|
|
FlagIssueView(inspection: inspection)
|
|
}
|
|
.alert("Submit Inspection", isPresented: $showSubmitAlert) {
|
|
Button("Submit") {
|
|
if locationManager.lastLocation == nil {
|
|
// No GPS fix yet — warn before proceeding rather than
|
|
// silently submitting without a location.
|
|
//
|
|
// Deferred, NOT set here: raising a second alert from
|
|
// inside the first one's action, with both attached to the
|
|
// same view, is dropped by SwiftUI — the confirm alert is
|
|
// still tearing down, so the new presentation is discarded.
|
|
// The visible effect was that tapping Submit without a fix
|
|
// did nothing at all: no warning, no submission. Handing it
|
|
// to onChange(of: showSubmitAlert) below presents it only
|
|
// once the first alert has actually gone.
|
|
pendingNoGPSPrompt = 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.")
|
|
}
|
|
.alert("Required Fields Missing", isPresented: $showValidationAlert) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
let list = missingFields.prefix(5).joined(separator: "\n• ")
|
|
let suffix = missingFields.count > 5
|
|
? "\n…and \(missingFields.count - 5) more."
|
|
: ""
|
|
Text("Please fill in the following required fields before submitting:\n\n• \(list)\(suffix)")
|
|
}
|
|
.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.
|
|
if showing {
|
|
locationManager.requestLocation()
|
|
return
|
|
}
|
|
// Confirm alert has closed. If Submit was tapped without a fix,
|
|
// raise the warning now that the presentation slot is free.
|
|
guard pendingNoGPSPrompt else { return }
|
|
pendingNoGPSPrompt = false
|
|
Task {
|
|
// One runloop hop. `showing == false` means the binding flipped,
|
|
// not that the dismissal animation has finished, and presenting
|
|
// into the tail of that animation is unreliable.
|
|
try? await Task.sleep(for: .milliseconds(350))
|
|
// Re-check: the fix may have landed while the dialog was up.
|
|
if locationManager.lastLocation == nil {
|
|
showNoGPSAlert = true
|
|
} else {
|
|
await submitInspection()
|
|
}
|
|
}
|
|
}
|
|
// Result overlay
|
|
.overlay(alignment: .top) {
|
|
if let result = submitResult {
|
|
submitResultBanner(result)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
.zIndex(10)
|
|
}
|
|
}
|
|
// Auto-save toast — bottom of screen, fades out after 2 seconds
|
|
.overlay(alignment: .bottom) {
|
|
if autoSavedAt != nil {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text("Draft saved")
|
|
.font(.subheadline.bold())
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
|
.background(.regularMaterial, in: Capsule())
|
|
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
|
.padding(.bottom, 24)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.zIndex(9)
|
|
}
|
|
}
|
|
.animation(.spring(duration: 0.35), value: submitResult != nil)
|
|
.animation(.easeInOut(duration: 0.25), value: autoSavedAt)
|
|
}
|
|
|
|
// ── Form Content ──────────────────────────────────────────────────────
|
|
|
|
@ViewBuilder
|
|
private var formContent: some View {
|
|
|
|
// Offline banner
|
|
if !sync.isOnline {
|
|
offlineBanner
|
|
.padding(.bottom, 12)
|
|
}
|
|
|
|
// Inspection header card
|
|
headerCard
|
|
.padding(.bottom, 16)
|
|
|
|
// Instructions from the schedule this inspection fulfils. Sits directly
|
|
// above the form so the inspector can re-read it mid-inspection without
|
|
// leaving the screen. Collapsible because long instructions would
|
|
// otherwise push the first form field below the fold.
|
|
if let instructions = scheduleInstructions {
|
|
instructionsBanner(instructions)
|
|
.padding(.bottom, 16)
|
|
}
|
|
|
|
// CHANGED: grid-based form rendering (replaces card-grouped linear list)
|
|
if formSchema.isEmpty {
|
|
emptyFormPlaceholder
|
|
.padding(.bottom, 16)
|
|
} else {
|
|
GridFormView(
|
|
schema: formSchema,
|
|
formValues: $formValues,
|
|
onPhotoSelected: { path, field in
|
|
handlePhotoSelected(localPath: path, field: field)
|
|
},
|
|
onFieldChanged: { saveDraft() }
|
|
)
|
|
.padding(.bottom, 16)
|
|
}
|
|
|
|
// Action buttons
|
|
actionButtons
|
|
.padding(.bottom, 32)
|
|
}
|
|
|
|
// ── Offline Banner ────────────────────────────────────────────────────
|
|
|
|
private var offlineBanner: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "wifi.slash")
|
|
.foregroundStyle(.orange)
|
|
Text("Offline — your work saves locally and syncs automatically.")
|
|
.font(.callout)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color.orange.opacity(0.12))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
// ── Instructions Banner ───────────────────────────────────────────────
|
|
|
|
/// Manager-authored instructions for the schedule this inspection fulfils.
|
|
/// Wire/DB field is `notes`; "Instructions" is the user-facing wording.
|
|
private func instructionsBanner(_ text: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
instructionsExpanded.toggle()
|
|
}
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "info.circle.fill")
|
|
.foregroundStyle(.blue)
|
|
Text("Instructions")
|
|
.font(.callout.bold())
|
|
.foregroundStyle(.blue)
|
|
Spacer()
|
|
Image(systemName: instructionsExpanded ? "chevron.up" : "chevron.down")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.blue)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if instructionsExpanded {
|
|
Text(text)
|
|
.font(.callout)
|
|
.foregroundStyle(.primary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color.blue.opacity(0.10))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
/// Copy the schedule's instructions into `@State` once, at appear.
|
|
///
|
|
/// Deliberately a snapshot, not a computed lookup: the cached
|
|
/// `LocalScheduledInspection` is deleted by `resolveAndFulfillSchedule()`
|
|
/// the instant Submit is tapped, and this view stays on screen for another
|
|
/// 2.5 s afterwards. A computed property would re-read a deleted
|
|
/// `PersistentModel` during that window and trap.
|
|
private func loadScheduleInstructions() {
|
|
guard let schedId = inspection.scheduledInspectionServerId else { return }
|
|
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
|
let all = (try? context.fetch(FetchDescriptor<LocalScheduledInspection>())) ?? []
|
|
scheduleInstructions = all.first(where: { $0.serverId == schedId })?.instructions
|
|
}
|
|
|
|
// ── Header Card ───────────────────────────────────────────────────────
|
|
|
|
private var headerCard: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(template?.name ?? "Inspection Form")
|
|
.font(.title2.bold())
|
|
if let facilityName = facility?.name {
|
|
Label(facilityName, systemImage: "building.2")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Label(
|
|
inspection.inspectionDate.formatted(date: .long, time: .shortened),
|
|
systemImage: "calendar"
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(16)
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
// ── Empty Form Placeholder ────────────────────────────────────────────
|
|
|
|
private var emptyFormPlaceholder: some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "layout.sidebar.right")
|
|
.font(.system(size: 40))
|
|
.foregroundStyle(.tertiary)
|
|
Text("This template has no form fields.")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(40)
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
// ── Notes Card ────────────────────────────────────────────────────────
|
|
|
|
private var notesCard: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label("Inspector Notes", systemImage: "note.text")
|
|
.font(.subheadline.weight(.semibold))
|
|
TextEditor(text: Binding(
|
|
get: { inspection.inspectorNotes },
|
|
set: { inspection.inspectorNotes = $0 }
|
|
))
|
|
.frame(minHeight: 100)
|
|
.padding(8)
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color(.systemGray4), lineWidth: 1)
|
|
)
|
|
}
|
|
.padding(16)
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
// ── Action Buttons ────────────────────────────────────────────────────
|
|
|
|
private var actionButtons: some View {
|
|
VStack(spacing: 12) {
|
|
Button {
|
|
showFlagIssue = true
|
|
} label: {
|
|
Label("Flag an Issue", systemImage: "exclamationmark.triangle")
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.orange)
|
|
|
|
Button {
|
|
saveDraft(force: true)
|
|
} label: {
|
|
Label(isSaving ? "Saving…" : "Save Draft",
|
|
systemImage: "square.and.arrow.down")
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(isSaving)
|
|
|
|
Button {
|
|
// Validate required fields before showing the confirm dialog.
|
|
// Collects every unfilled required field's label so the alert
|
|
// can name them specifically rather than just saying "missing fields".
|
|
let missing = missingRequiredFields()
|
|
if missing.isEmpty {
|
|
showSubmitAlert = true
|
|
} else {
|
|
missingFields = missing
|
|
showValidationAlert = true
|
|
}
|
|
} label: {
|
|
Group {
|
|
if isSubmitting {
|
|
HStack(spacing: 8) {
|
|
ProgressView().tint(.white)
|
|
Text("Submitting…")
|
|
}
|
|
} else {
|
|
Label("Submit Inspection", systemImage: "checkmark.circle.fill")
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isSubmitting)
|
|
}
|
|
}
|
|
|
|
// ── Submit Result Banner ──────────────────────────────────────────────
|
|
|
|
private func submitResultBanner(_ result: SubmitResult) -> some View {
|
|
HStack(spacing: 12) {
|
|
switch result {
|
|
case .success(let score):
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.green)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Inspection Submitted")
|
|
.font(.headline)
|
|
if let score {
|
|
Text(String(format: "Score: %.1f%%", score))
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text(sync.isOnline ? "Sent to server." : "Queued — will sync when online.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
case .failure(let msg):
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.red)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Submission Failed")
|
|
.font(.headline)
|
|
Text(msg)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(16)
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.shadow(color: .black.opacity(0.1), radius: 8, y: 4)
|
|
.padding(.horizontal, 24)
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
// ── Save Draft ────────────────────────────────────────────────────────
|
|
|
|
private func saveDraft(force: Bool = false) {
|
|
guard inspection.status == "draft" else { return }
|
|
if force { isSaving = true }
|
|
|
|
// Preserve server paths already written by processPhotoQueue — same
|
|
// logic as submitInspection(). Auto-save fires every 30 s and would
|
|
// overwrite uploads/... with local://... if it runs after a sync that
|
|
// was triggered by FlagIssueView uploading the inspection's photos.
|
|
let existingFormData = inspection.formData
|
|
var data: [String: Any] = [:]
|
|
for (k, v) in formValues {
|
|
if v.hasPrefix("local://"),
|
|
let saved = existingFormData[k] as? String, saved.hasPrefix("uploads/") {
|
|
data[k] = saved
|
|
} else {
|
|
data[k] = v
|
|
}
|
|
}
|
|
inspection.formData = data
|
|
inspection.lastModifiedAt = Date()
|
|
try? context.save()
|
|
|
|
if force {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
|
isSaving = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Submit (async — shows result, then dismisses) ─────────────────────
|
|
|
|
/// Returns the labels of required fields that have no value in formValues.
|
|
/// Skips structural fields (section, label, buttons) and image/signature
|
|
/// fields since those have complex "filled" semantics (local:// counts as
|
|
/// filled — the photo exists even if not yet uploaded to the server).
|
|
private func missingRequiredFields() -> [String] {
|
|
let skipTypes: Set<String> = ["section", "label",
|
|
"button_submit", "button_print", "button_email"]
|
|
var missing: [String] = []
|
|
for field in formSchema {
|
|
guard let ftype = field["type"] as? String,
|
|
!skipTypes.contains(ftype),
|
|
field["required"] as? Bool == true
|
|
else { continue }
|
|
|
|
let fid = field["id"] as? String ?? (field["id"] as? Int).map(String.init) ?? ""
|
|
let val = formValues[fid] ?? ""
|
|
|
|
let isFilled: Bool
|
|
switch ftype {
|
|
case "rating":
|
|
isFilled = (Int(val) ?? 0) > 0
|
|
case "image", "signature":
|
|
isFilled = !val.isEmpty // local:// or uploads/ both count
|
|
case "checkbox":
|
|
isFilled = val == "true"
|
|
default:
|
|
isFilled = !val.trimmingCharacters(in: .whitespaces).isEmpty
|
|
}
|
|
|
|
if !isFilled {
|
|
let label = field["label"] as? String ?? "Field \(fid)"
|
|
missing.append(label.isEmpty ? "Field \(fid)" : label)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
private func submitInspection() async {
|
|
isSubmitting = true
|
|
|
|
// Persist final form data.
|
|
// IMPORTANT: formValues is an in-memory SwiftUI state dict that is NOT
|
|
// updated when processPhotoQueue writes server paths back into
|
|
// inspection.formData (e.g. during the sync triggered by FlagIssueView).
|
|
// For any field whose formValues entry is still "local://..." (the photo
|
|
// hasn't been uploaded yet by THIS submit's sync pass), check whether
|
|
// processPhotoQueue already wrote a real server path into inspection.formData
|
|
// for that field. If so, preserve it — otherwise the server path gets
|
|
// overwritten with "local://..." here and sanitised to "" in APIClient,
|
|
// making photos disappear on inspections that also flagged an issue.
|
|
let existingFormData = inspection.formData
|
|
var data: [String: Any] = [:]
|
|
for (k, v) in formValues {
|
|
if v.hasPrefix("local://"),
|
|
let saved = existingFormData[k] as? String, saved.hasPrefix("uploads/") {
|
|
// processPhotoQueue already uploaded this photo — keep the server path
|
|
data[k] = saved
|
|
} else {
|
|
data[k] = v
|
|
}
|
|
}
|
|
inspection.formData = data
|
|
inspection.overallScore = inspection.computeScore(fromSchema: formSchema)
|
|
inspection.status = "completed"
|
|
inspection.completedAt = Date()
|
|
inspection.syncStatus = "pending"
|
|
|
|
// ── GPS ───────────────────────────────────────────────────────────
|
|
// Use whatever fix the location manager has at this moment.
|
|
// lastLocation is nil if permission was denied or no fix arrived yet;
|
|
// the fields stay nil and the server silently omits them — same as web
|
|
// submissions where the user declined the browser location prompt.
|
|
if let loc = locationManager.lastLocation {
|
|
inspection.submitLatitude = loc.coordinate.latitude
|
|
inspection.submitLongitude = loc.coordinate.longitude
|
|
}
|
|
|
|
// ── Clear follow-up flag on parent immediately ────────────────────
|
|
// Do this at submit time rather than relying solely on SyncManager,
|
|
// so the badge disappears the moment the inspector taps Submit —
|
|
// regardless of connectivity or sync timing.
|
|
clearParentFollowUpFlag()
|
|
|
|
// ── Drop the cached follow-up request row ─────────────────────────
|
|
// Same immediacy as above, for the other surface: the FOLLOW-UP
|
|
// REQUESTED card on the Dashboard and My Inspections reads its own
|
|
// pulled cache, not LocalInspection, so clearing the flag above is not
|
|
// enough to make the row disappear.
|
|
fulfillFollowUpRequest()
|
|
|
|
// ── Fulfil the originating scheduled inspection ───────────────────
|
|
// Two jobs, both mirroring the web app's execute route:
|
|
// 1. Make sure the submission carries scheduled_inspection_id, even
|
|
// when the inspector reached this form via "+" instead of the
|
|
// Scheduled row — the server cannot fulfil an unlinked inspection.
|
|
// 2. Drop the cached schedule row so the SCHEDULED card clears the
|
|
// moment Submit is tapped, online or offline.
|
|
resolveAndFulfillSchedule()
|
|
|
|
try? context.save()
|
|
|
|
isSubmitting = false
|
|
|
|
// Show success banner
|
|
withAnimation {
|
|
submitResult = .success(score: inspection.overallScore)
|
|
}
|
|
|
|
// Trigger sync in background if online
|
|
if sync.isOnline {
|
|
Task { await sync.triggerSync() }
|
|
}
|
|
|
|
// Wait 2.5 seconds so inspector reads the result, then leave.
|
|
try? await Task.sleep(for: .seconds(2.5))
|
|
if let onFinished { onFinished() } else { dismiss() }
|
|
}
|
|
|
|
/// Find the parent LocalInspection and clear its followUpRequired flag.
|
|
/// Tries parentLocalId first (set for new re-inspections), then falls back
|
|
/// to parentServerId (set after parent has synced), then as a last resort
|
|
/// matches by template+facility for re-inspections created before these
|
|
/// fields were added (parentLocalId=nil, parentServerId=nil).
|
|
private func clearParentFollowUpFlag() {
|
|
var parent: LocalInspection?
|
|
|
|
// Primary: match by the parent's localId UUID (always available if set)
|
|
if let lid = inspection.parentLocalId {
|
|
parent = try? context.fetch(
|
|
FetchDescriptor<LocalInspection>(predicate: #Predicate { $0.localId == lid })
|
|
).first
|
|
}
|
|
|
|
// Fallback 1: match by server ID (available once parent has synced)
|
|
if parent == nil, let sid = inspection.parentServerId {
|
|
parent = try? context.fetch(FetchDescriptor<LocalInspection>())
|
|
.first(where: { $0.serverId == sid })
|
|
}
|
|
|
|
// Fallback 2: for stale re-inspections created before parentLocalId existed,
|
|
// find any LocalInspection with the same template+facility that has
|
|
// followUpRequired=true and is not this inspection itself.
|
|
if parent == nil {
|
|
let tid = inspection.templateServerId
|
|
let fid = inspection.facilityServerId
|
|
let selfId = inspection.localId
|
|
parent = try? context.fetch(FetchDescriptor<LocalInspection>())
|
|
.first(where: {
|
|
$0.templateServerId == tid &&
|
|
$0.facilityServerId == fid &&
|
|
$0.followUpRequired == true &&
|
|
$0.localId != selfId
|
|
})
|
|
}
|
|
|
|
if let parent {
|
|
parent.followUpRequired = false
|
|
parent.followUpNote = nil
|
|
}
|
|
}
|
|
|
|
/// Invalidate the cached follow-up request this submission satisfies, so the
|
|
/// FOLLOW-UP REQUESTED card and section clear the moment Submit is tapped —
|
|
/// online or offline — rather than waiting for the round trip.
|
|
///
|
|
/// Matches on `parentServerId` alone. Unlike the schedule fallback there is
|
|
/// no facility+template guess here: a request is keyed by the exact
|
|
/// inspection it was raised against, and that id is set whenever the run was
|
|
/// launched from a follow-up row or from CompletedInspectionView's banner.
|
|
/// An ad-hoc inspection of the same facility is genuinely not the follow-up
|
|
/// the director asked for, and must not clear it.
|
|
///
|
|
/// The row is flagged, not deleted, for the same reason as
|
|
/// `LocalScheduledInspection.fulfilledLocally`: the server is authoritative,
|
|
/// and `pullFollowUpRequests()` deletes the row once the flag actually
|
|
/// clears — or brings it back if the submission never landed.
|
|
private func fulfillFollowUpRequest() {
|
|
guard let sid = inspection.parentServerId else { return }
|
|
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
|
let all = (try? context.fetch(FetchDescriptor<LocalFollowUpRequest>())) ?? []
|
|
all.first { $0.serverId == sid }?.fulfilledLocally = true
|
|
}
|
|
|
|
// ── Scheduled inspection fulfilment ───────────────────────────────────
|
|
|
|
/// Link this submission to the schedule it satisfies, then drop the cached
|
|
/// schedule row.
|
|
///
|
|
/// **Why the fallback link exists.** On the web, `scheduled_inspections.start`
|
|
/// is the *only* way to open a scheduled inspection, so the link is always
|
|
/// present. On the iPad the Scheduled row merely pre-selects facility +
|
|
/// template — the inspector can reach the identical form through the "+"
|
|
/// button, and that path leaves `scheduledInspectionServerId` nil. The
|
|
/// server then stores `scheduled_inspection_id = NULL`, never calls
|
|
/// `_fulfill_schedule()`, and the schedule stays **Active** on the web.
|
|
/// Matching facility + template here restores parity of outcome between the
|
|
/// two entry points.
|
|
///
|
|
/// **Why the match is narrow.** Only schedules already due (due date on or
|
|
/// before today) are eligible, so an ad-hoc inspection today cannot silently
|
|
/// close out an occurrence planned for next month. Assignment must also fit:
|
|
/// unassigned schedules, or ones assigned to this inspector. When several
|
|
/// qualify, the earliest due date wins — that is the occurrence being worked.
|
|
///
|
|
/// **Why the row is deleted rather than rolled forward.** `LocalScheduledInspection`
|
|
/// is a read-only cache and does not carry the phase43 recurrence detail
|
|
/// (weekdays / month_mode / day_of_month / nth_week / nth_weekday), so the
|
|
/// next due date cannot be computed correctly on device. Deleting invalidates
|
|
/// the cache instead: `pullScheduledInspections()` re-inserts recurring
|
|
/// schedules with the server-authoritative `next_due_date` on the next pull,
|
|
/// and a one-time schedule stays gone because the server has deactivated it.
|
|
/// The same pull also restores the row if the submission never lands, so a
|
|
/// failed sync self-heals.
|
|
private func resolveAndFulfillSchedule() {
|
|
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
|
let all = (try? context.fetch(FetchDescriptor<LocalScheduledInspection>())) ?? []
|
|
guard !all.isEmpty else { return }
|
|
|
|
// Fallback link for inspections not started from a Scheduled row.
|
|
// Re-inspections are excluded: a follow-up shares its parent's facility
|
|
// and template, so it would otherwise close out an unrelated planned
|
|
// occurrence. The web app keeps the two workflows separate the same way.
|
|
let isReInspection = inspection.parentServerId != nil || inspection.parentLocalId != nil
|
|
if inspection.scheduledInspectionServerId == nil && !isReInspection {
|
|
let tid = inspection.templateServerId
|
|
let fid = inspection.facilityServerId
|
|
let uid = inspection.inspectorUserId
|
|
let today = Self.dueDateFormatter.string(from: Date())
|
|
|
|
let candidate = all
|
|
.filter {
|
|
$0.templateServerId == tid &&
|
|
$0.facilityServerId == fid &&
|
|
($0.inspectorId == nil || $0.inspectorId == uid) &&
|
|
!$0.fulfilledLocally && // already satisfied, awaiting sync
|
|
!$0.dueDateString.isEmpty &&
|
|
$0.dueDateString <= today // ISO strings sort chronologically
|
|
}
|
|
.sorted { $0.dueDateString < $1.dueDateString }
|
|
.first
|
|
|
|
if let candidate {
|
|
inspection.scheduledInspectionServerId = candidate.serverId
|
|
}
|
|
}
|
|
|
|
// Hide the row until the server confirms what happened to it.
|
|
//
|
|
// NOT a delete. A recurring schedule comes back from the server on its
|
|
// next occurrence, so deleting turned every completion into a
|
|
// delete-then-reinsert against the `@Attribute(.unique)` serverId, and
|
|
// the reinserted row did not reliably pick up the new due date — a
|
|
// daily schedule kept showing today's date after being completed.
|
|
// One-time schedules masked it, because the server stops returning them
|
|
// and they are never reinserted. Flagging leaves `update(from:)` as the
|
|
// single path that ever writes a cached schedule's dates.
|
|
guard let schedId = inspection.scheduledInspectionServerId,
|
|
let sched = all.first(where: { $0.serverId == schedId })
|
|
else { return }
|
|
|
|
sched.fulfilledLocally = true
|
|
}
|
|
|
|
/// "yyyy-MM-dd", matching `LocalScheduledInspection.dueDateString`.
|
|
private static let dueDateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
return f
|
|
}()
|
|
|
|
// ── Photo Handling ────────────────────────────────────────────────────
|
|
|
|
private func handlePhotoSelected(localPath: String, field: [String: Any]) {
|
|
let fid = fieldId(field)
|
|
formValues[fid] = "local://\(localPath)"
|
|
// Stamp capture time + the current fix now; the upload may be hours
|
|
// later on a slow sync and must not use its own clock.
|
|
let fix = locationManager.lastLocation ?? PhotoLocationProvider.shared.lastLocation
|
|
let photo = PendingPhoto(
|
|
localFilePath: localPath,
|
|
entityType: "inspection",
|
|
entityLocalId: inspection.localId,
|
|
fieldId: fid,
|
|
capturedAt: Date(),
|
|
captureLatitude: fix?.coordinate.latitude,
|
|
captureLongitude: fix?.coordinate.longitude
|
|
)
|
|
inspection.pendingPhotos.append(photo)
|
|
context.insert(photo)
|
|
try? context.save()
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
private func fieldId(_ field: [String: Any]) -> String {
|
|
if let id = field["id"] as? String { return id }
|
|
if let id = field["id"] as? Int { return String(id) }
|
|
return UUID().uuidString
|
|
}
|
|
}
|
|
|
|
// MARK: - WidthPreferenceKey
|
|
// Used by GridFormView to read its container width reliably on any device
|
|
// orientation, split-screen size change, or rotation — without GeometryReader's
|
|
// ScrollView height ambiguity.
|
|
|
|
private struct WidthPreferenceKey: PreferenceKey {
|
|
static var defaultValue: CGFloat = 0
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
value = max(value, nextValue())
|
|
}
|
|
}
|
|
|
|
// MARK: - GridFormView
|
|
// Renders the form schema using the same 12-column grid as the web app's
|
|
// .form-grid CSS grid. Each field is positioned using col/row/colSpan/rowSpan.
|
|
|
|
struct GridFormView: View {
|
|
|
|
let schema: [[String: Any]]
|
|
@Binding var formValues: [String: String]
|
|
var onPhotoSelected: ((String, [String: Any]) -> Void)?
|
|
var onFieldChanged: (() -> Void)?
|
|
|
|
// ── Grid constants — kept in sync with the web form editor ──────────────
|
|
// Web editor JS: COLS=12 CELL_W=72 CELL_H=52 GAP=8 (col gap = 8px)
|
|
// Web CSS execute: gap: 4px 8px (row-gap=4px, col-gap=8px)
|
|
static let totalColumns: Int = 12
|
|
static let cellGap: CGFloat = 8 // column gap — matches editor GAP=8 and CSS col-gap
|
|
static let rowGap: CGFloat = 4 // row gap — matches CSS row-gap
|
|
static let cellAspect: CGFloat = 52/72 // cellH / cellW — matches editor CELL_H/CELL_W
|
|
static let cardPadding: CGFloat = 16 // card inset on all sides
|
|
|
|
// Below this container width the 12-column grid stops being usable: at
|
|
// 375 pt (iPhone SE/6/7/8) a column is only ~21 pt wide and a row ~15 pt
|
|
// tall, so a normal 6x2 field renders ~167x35 pt — the label alone eats
|
|
// most of it. Cells are absolutely positioned and deliberately unclipped
|
|
// (see body), so the overflow draws on top of the row beneath and the
|
|
// form becomes an unreadable pile. Under this width we reflow to one
|
|
// field per line instead. 600 pt keeps a column at >=40 pt.
|
|
static let minGridWidth: CGFloat = 600
|
|
|
|
// Heights for widgets that have no intrinsic size of their own. In the
|
|
// absolute grid these are driven by rowSpan; in the stacked layout there
|
|
// is no rowSpan to read, so they would otherwise collapse to nothing.
|
|
static let stackedMinH: [String: CGFloat] = [
|
|
"textarea": 96,
|
|
"signature": 120,
|
|
"table": 120,
|
|
"image": 88,
|
|
]
|
|
|
|
// Minimum cell height (points) per field type — ensures 44pt touch targets
|
|
// on iPad even when the template author assigned a very short rowSpan.
|
|
static let minCellH: [String: CGFloat] = [
|
|
"pass_fail": 44,
|
|
"rating": 36,
|
|
"checkbox": 36,
|
|
"checkbox_group": 44,
|
|
"radio": 44,
|
|
"select": 36,
|
|
"date": 36,
|
|
"image": 60,
|
|
"signature": 80,
|
|
"table": 80,
|
|
]
|
|
|
|
// Width captured via PreferenceKey — updates on rotation, split-screen,
|
|
// and sheet presentation.
|
|
// 0 = unmeasured; the grid does NOT render fields until a real width arrives.
|
|
// This prevents the first-frame overflow that occurred when a sheet modal
|
|
// (narrower than full screen on iPad 10th gen) was rendered with the old
|
|
// hardcoded 952 pt fallback, causing fields to overflow the modal bounds.
|
|
@State private var containerWidth: CGFloat = 0
|
|
|
|
var body: some View {
|
|
Group {
|
|
if containerWidth > 0 && isCompact {
|
|
// ── Compact: content drives the height ─────────────────────
|
|
// The card is a .background modifier rather than a ZStack
|
|
// sibling so it takes its size FROM the stack. As a ZStack
|
|
// sibling the flexible RoundedRectangle competes with the
|
|
// VStack for the container's size and the card ends up
|
|
// shorter than its own content, cutting off the last fields.
|
|
stackedLayout
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.secondarySystemBackground))
|
|
)
|
|
} else {
|
|
// ── Regular: absolute 12-column canvas ─────────────────────
|
|
ZStack(alignment: .topLeading) {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.secondarySystemBackground))
|
|
|
|
// Field overlays — only rendered after width is measured.
|
|
// containerWidth == 0 means the PreferenceKey has not fired
|
|
// yet (first layout pass). Skipping the overlay pass on the
|
|
// zero frame prevents fields from being positioned using a
|
|
// stale width and overflowing the modal on narrow sheet
|
|
// presentations (iPad 10th gen).
|
|
if containerWidth > 0 {
|
|
let cellW = computedCellW
|
|
let cellH = cellW * Self.cellAspect
|
|
|
|
ForEach(schema.indices, id: \.self) { idx in
|
|
let field = schema[idx]
|
|
let ftype = field["type"] as? String ?? "text"
|
|
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
|
gridCell(field: field, cellW: cellW, cellH: cellH)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Height is derived from the same arithmetic as the cell
|
|
// offsets — the ScrollView measures this frame and can never
|
|
// be wrong.
|
|
.frame(height: containerWidth > 0
|
|
? canvasHeight() + 2 * Self.cardPadding
|
|
: 0)
|
|
}
|
|
}
|
|
// ── Width probe ───────────────────────────────────────────────────
|
|
// Attached as a background so it reports the resolved container width
|
|
// without taking part in sizing the content itself.
|
|
.background(
|
|
GeometryReader { geo in
|
|
Color.clear.preference(
|
|
key: WidthPreferenceKey.self,
|
|
value: geo.size.width
|
|
)
|
|
}
|
|
)
|
|
.onPreferenceChange(WidthPreferenceKey.self) { width in
|
|
if width > 0 { containerWidth = width }
|
|
}
|
|
}
|
|
|
|
// ── Compact (narrow) layout ───────────────────────────────────────────
|
|
// One field per line, full width, natural height. Fields are ordered by
|
|
// (row, col) because the form editor stores them in drag/creation order,
|
|
// not visual order — the same sort the PDF and read-only renderers use
|
|
// (rule 62). Nothing is absolutely positioned here, so nothing can
|
|
// overlap regardless of how narrow the screen gets.
|
|
|
|
private var isCompact: Bool { containerWidth < Self.minGridWidth }
|
|
|
|
private var orderedFields: [[String: Any]] {
|
|
schema
|
|
.filter { f in
|
|
let t = f["type"] as? String ?? "text"
|
|
return !["button_submit", "button_print", "button_email"].contains(t)
|
|
}
|
|
.sorted {
|
|
let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0
|
|
if r0 != r1 { return r0 < r1 }
|
|
return ($0["col"] as? Int ?? 0) < ($1["col"] as? Int ?? 0)
|
|
}
|
|
}
|
|
|
|
private var stackedLayout: some View {
|
|
let fields = orderedFields
|
|
return VStack(alignment: .leading, spacing: 14) {
|
|
ForEach(fields.indices, id: \.self) { idx in
|
|
let field = fields[idx]
|
|
let ftype = field["type"] as? String ?? "text"
|
|
let fid = fieldId(field)
|
|
|
|
GridCellContentView(
|
|
field: field,
|
|
value: Binding(
|
|
get: { formValues[fid] ?? "" },
|
|
set: { formValues[fid] = $0; onFieldChanged?() }
|
|
),
|
|
onPhotoSelected: { path in onPhotoSelected?(path, field) }
|
|
)
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
minHeight: Self.stackedMinH[ftype] ?? 0,
|
|
alignment: .topLeading
|
|
)
|
|
}
|
|
}
|
|
.padding(Self.cardPadding)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
// ── Derived cell width from current containerWidth ────────────────────
|
|
|
|
private var computedCellW: CGFloat {
|
|
(containerWidth - 2 * Self.cardPadding
|
|
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
|
|
/ CGFloat(Self.totalColumns)
|
|
}
|
|
|
|
// ── Canvas height ─────────────────────────────────────────────────────
|
|
|
|
private func canvasHeight() -> CGFloat {
|
|
let cellW = computedCellW
|
|
let cellH = cellW * Self.cellAspect
|
|
let maxRow = schema.reduce(0) { acc, f in
|
|
let r = f["row"] as? Int ?? 1
|
|
let rs = f["rowSpan"] as? Int ?? 2
|
|
return max(acc, r + rs - 1)
|
|
}
|
|
return CGFloat(maxRow) * cellH + CGFloat(max(maxRow - 1, 0)) * Self.rowGap
|
|
}
|
|
|
|
// ── Single positioned grid cell ───────────────────────────────────────
|
|
|
|
@ViewBuilder
|
|
private func gridCell(field: [String: Any], cellW: CGFloat, cellH: CGFloat) -> some View {
|
|
let col = max(1, field["col"] as? Int ?? 1)
|
|
let row = max(1, field["row"] as? Int ?? 1)
|
|
let colSpan = max(1, field["colSpan"] as? Int ?? 6)
|
|
let rowSpan = max(1, field["rowSpan"] as? Int ?? 2)
|
|
|
|
let xOffset = CGFloat(col - 1) * (cellW + Self.cellGap) + Self.cardPadding
|
|
let yOffset = CGFloat(row - 1) * (cellH + Self.rowGap) + Self.cardPadding
|
|
|
|
let width = CGFloat(colSpan) * cellW + CGFloat(colSpan - 1) * Self.cellGap
|
|
|
|
// Apply per-type minimum height so touch targets are always reachable.
|
|
let ftype = field["type"] as? String ?? "text"
|
|
let rawHeight = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap
|
|
let minH = Self.minCellH[ftype] ?? 0
|
|
let height = max(rawHeight, minH)
|
|
|
|
let fid = fieldId(field)
|
|
|
|
GridCellContentView(
|
|
field: field,
|
|
value: Binding(
|
|
get: { formValues[fid] ?? "" },
|
|
set: { formValues[fid] = $0; onFieldChanged?() }
|
|
),
|
|
onPhotoSelected: { path in onPhotoSelected?(path, field) }
|
|
)
|
|
.frame(width: width, height: height, alignment: .topLeading)
|
|
.offset(x: xOffset, y: yOffset)
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
private func fieldId(_ field: [String: Any]) -> String {
|
|
if let id = field["id"] as? String { return id }
|
|
if let id = field["id"] as? Int { return String(id) }
|
|
return UUID().uuidString
|
|
}
|
|
}
|
|
|
|
// MARK: - GridCellContentView
|
|
// CHANGED: new view — renders the interior of a single grid cell.
|
|
// Mirrors the web app's .fg-cell structure:
|
|
// label (small, slate) + input widget filling remaining space.
|
|
|
|
struct GridCellContentView: View {
|
|
|
|
let field: [String: Any]
|
|
@Binding var value: String
|
|
var onPhotoSelected: ((String) -> Void)?
|
|
|
|
private var fieldType: String { field["type"] as? String ?? "text" }
|
|
private var label: String { field["label"] as? String ?? "" }
|
|
private var required: Bool { field["required"] as? Bool ?? false }
|
|
private var placeholder: String { field["placeholder"] as? String ?? "" }
|
|
private var helpText: String { field["help_text"] as? String ?? "" }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
// ── Label row — matches .field-lbl (small, slate, clipped) ──
|
|
if !["section", "label", "checkbox",
|
|
"button_submit", "button_print", "button_email"].contains(fieldType),
|
|
!label.isEmpty {
|
|
HStack(spacing: 2) {
|
|
Text(label)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
if required {
|
|
Text("*")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Input widget — sized naturally, not stretched to fill cell ──
|
|
// maxHeight:.infinity caused a large gap between the label and the
|
|
// input widget when the cell was taller than the content needed.
|
|
fieldInput
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
|
|
// ── Help text — matches .help-text ──
|
|
if !helpText.isEmpty {
|
|
Text(helpText)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
// No .clipped() — overflow is intentionally visible so tall content
|
|
// (dropdowns, multi-line labels) is never silently truncated.
|
|
// Matches the web form's .fg-cell { overflow: visible } rule.
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var fieldInput: some View {
|
|
switch fieldType {
|
|
|
|
// ── Section label — matches .section-divider ──────────────────────
|
|
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, alignment: .leading)
|
|
|
|
// ── Static label ──────────────────────────────────────────────────
|
|
case "label":
|
|
let fontSizeMap: [String: CGFloat] = [
|
|
"small": 11, "normal": 13, "large": 15, "x-large": 18
|
|
]
|
|
let fontSize = fontSizeMap[field["font_size"] as? String ?? "normal"] ?? 13
|
|
let fontWeight: Font.Weight = (field["font_weight"] as? String == "bold") ? .bold : .regular
|
|
Text(field["text_content"] as? String ?? field["text"] as? String ?? "")
|
|
.font(.system(size: fontSize, weight: fontWeight))
|
|
.foregroundStyle(Color(.label))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
// ── Text ──────────────────────────────────────────────────────────
|
|
case "text":
|
|
cellTextField(placeholder: placeholder.isEmpty ? label : placeholder)
|
|
|
|
// ── Textarea ─────────────────────────────────────────────────────
|
|
case "textarea":
|
|
TextEditor(text: $value)
|
|
.font(.system(size: 12))
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 2)
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
.overlay(RoundedRectangle(cornerRadius: 5)
|
|
.stroke(Color(.systemGray4), lineWidth: 1))
|
|
|
|
// ── Number ────────────────────────────────────────────────────────
|
|
case "number":
|
|
cellTextField(placeholder: placeholder.isEmpty ? "0" : placeholder)
|
|
.keyboardType(.decimalPad)
|
|
|
|
// ── Email ─────────────────────────────────────────────────────────
|
|
case "email":
|
|
cellTextField(placeholder: placeholder.isEmpty ? "name@example.com" : placeholder)
|
|
.keyboardType(.emailAddress)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
|
|
// ── Date ──────────────────────────────────────────────────────────
|
|
case "date":
|
|
CellDatePicker(value: $value)
|
|
|
|
// ── Checkbox ──────────────────────────────────────────────────────
|
|
case "checkbox":
|
|
Toggle(isOn: Binding(
|
|
get: { value == "true" },
|
|
set: { value = $0 ? "true" : "false" }
|
|
)) {
|
|
HStack(spacing: 2) {
|
|
Text(label)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Color(.secondaryLabel))
|
|
if required {
|
|
Text("*").font(.system(size: 11)).foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
.toggleStyle(.automatic)
|
|
|
|
// ── Checkbox group ────────────────────────────────────────────────
|
|
case "checkbox_group":
|
|
CellCheckboxGroup(field: field, value: $value)
|
|
|
|
// ── Radio ─────────────────────────────────────────────────────────
|
|
case "radio":
|
|
CellRadioGroup(field: field, value: $value)
|
|
|
|
// ── Pass / Fail ───────────────────────────────────────────────────
|
|
case "pass_fail":
|
|
CellPassFail(field: field, value: $value)
|
|
|
|
// ── Select / Dropdown ─────────────────────────────────────────────
|
|
case "select":
|
|
CellSelect(field: field, value: $value)
|
|
|
|
// ── Rating (stars) ────────────────────────────────────────────────
|
|
case "rating":
|
|
CellRatingStars(
|
|
maxRating: field["max"] as? Int ?? 5,
|
|
value: Binding(
|
|
get: { Int(value) ?? 0 },
|
|
set: { value = String($0) }
|
|
)
|
|
)
|
|
|
|
// ── Image / Photo upload ──────────────────────────────────────────
|
|
// Uses a compact inline zone to match the web's .upload-zone dashed style.
|
|
case "image":
|
|
CompactImageFieldView(
|
|
fieldId: field["id"] as? String ?? UUID().uuidString,
|
|
currentValue: value,
|
|
onPhotoSelected: onPhotoSelected
|
|
)
|
|
|
|
// ── Signature ─────────────────────────────────────────────────────
|
|
case "signature":
|
|
SignatureFieldView(value: $value)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
.overlay(RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color(.systemGray4), lineWidth: 1))
|
|
|
|
// ── Table ─────────────────────────────────────────────────────────
|
|
case "table":
|
|
TableFieldView(field: field, value: $value)
|
|
|
|
default:
|
|
cellTextField(placeholder: placeholder.isEmpty ? label : placeholder)
|
|
}
|
|
}
|
|
|
|
// ── Shared compact text field — matches .form-control in grid cell ────
|
|
|
|
private func cellTextField(placeholder: String) -> some View {
|
|
TextField(placeholder, text: $value)
|
|
.font(.system(size: 12))
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
.overlay(RoundedRectangle(cornerRadius: 5)
|
|
.stroke(Color(.systemGray4), lineWidth: 1))
|
|
}
|
|
}
|
|
|
|
// MARK: - CompactImageFieldView
|
|
// Grid-cell-sized photo upload zone — mirrors the web's .upload-zone style:
|
|
// dashed border, small icon + text, filename shown inline when a photo is chosen.
|
|
// CHANGED: replaces the full-size ImageFieldView inside grid cells to fix the
|
|
// oversized "Attach Photo" button that was too large for compact grid cells.
|
|
|
|
struct CompactImageFieldView: View {
|
|
let fieldId: String
|
|
let currentValue: String
|
|
var onPhotoSelected: ((String) -> Void)?
|
|
|
|
@State private var selectedImage: UIImage?
|
|
@State private var chosenName: String = ""
|
|
@State private var showChoice = false
|
|
@State private var showCamera = false
|
|
@State private var showLibrary = false
|
|
|
|
private var cameraAvailable: Bool {
|
|
UIImagePickerController.isSourceTypeAvailable(.camera)
|
|
}
|
|
|
|
var hasPhoto: Bool { selectedImage != nil || currentValue.hasPrefix("uploads/") || currentValue.hasPrefix("local://") }
|
|
|
|
var body: some View {
|
|
Button {
|
|
if cameraAvailable { showChoice = true } else { showLibrary = true }
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: hasPhoto ? "photo.fill" : "camera")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(hasPhoto ? .blue : Color(.secondaryLabel))
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(hasPhoto ? (chosenName.isEmpty ? "Photo attached" : chosenName)
|
|
: "Upload photo")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(hasPhoto ? .blue : Color(.secondaryLabel))
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
if !hasPhoto {
|
|
Text("Tap to choose")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
.frame(maxWidth: .infinity, minHeight: 44)
|
|
.background(hasPhoto ? Color.blue.opacity(0.07) : Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(
|
|
hasPhoto ? Color.blue.opacity(0.4) : Color(.systemGray4),
|
|
style: StrokeStyle(lineWidth: 1.5, dash: [4, 3])
|
|
)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) {
|
|
Button("Take Photo") { showCamera = true }
|
|
Button("Photo Library") { showLibrary = true }
|
|
Button("Cancel", role: .cancel) {}
|
|
}
|
|
.fullScreenCover(isPresented: $showCamera) {
|
|
CameraPickerView(image: $selectedImage, onSelected: saveAndCallback)
|
|
.ignoresSafeArea()
|
|
}
|
|
.sheet(isPresented: $showLibrary) {
|
|
LibraryPickerView(image: $selectedImage, onSelected: saveAndCallback)
|
|
}
|
|
}
|
|
|
|
private func saveAndCallback(_ img: UIImage) {
|
|
guard let data = img.jpegData(compressionQuality: 0.8) else { return }
|
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
let photosDir = docs.appendingPathComponent("JQC/Photos", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true)
|
|
let filename = "\(UUID().uuidString).jpg"
|
|
let url = photosDir.appendingPathComponent(filename)
|
|
try? data.write(to: url)
|
|
selectedImage = img
|
|
chosenName = filename
|
|
onPhotoSelected?(url.path)
|
|
}
|
|
}
|
|
|
|
// MARK: - CellDatePicker
|
|
// Compact date picker for a grid cell — shows a short date format.
|
|
|
|
struct CellDatePicker: View {
|
|
@Binding var value: String
|
|
|
|
private var dateBinding: Binding<Date> {
|
|
Binding(
|
|
get: { FormDateFormat.date(from: value) ?? Date() },
|
|
set: { value = FormDateFormat.string(from: $0) }
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
// An empty value must LOOK empty until the inspector acts.
|
|
//
|
|
// The old version bound the DatePicker straight to the value: when it
|
|
// was empty the picker still displayed TODAY, so the field looked
|
|
// answered — but the setter only fires on a CHANGE, so selecting the
|
|
// already-displayed date wrote nothing and missingRequiredFields()
|
|
// reported the field missing with a date plainly visible on screen.
|
|
// The inspector had to pick a different day and navigate back. This
|
|
// explicit step makes unanswered look unanswered and makes today
|
|
// selectable in one tap.
|
|
if value.isEmpty {
|
|
Button {
|
|
value = FormDateFormat.string(from: Date())
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "calendar").font(.system(size: 11))
|
|
Text("Set date").font(.system(size: 12))
|
|
}
|
|
.foregroundStyle(Color(.placeholderText))
|
|
.padding(.horizontal, 6).padding(.vertical, 3)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
.overlay(RoundedRectangle(cornerRadius: 5)
|
|
.stroke(Color(.systemGray4), lineWidth: 1))
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
HStack(spacing: 2) {
|
|
DatePicker("", selection: dateBinding, displayedComponents: .date)
|
|
.labelsHidden()
|
|
Button { value = "" } label: { // back to unanswered
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - FormDateFormat
|
|
// Wire format for `date` form fields: "yyyy-MM-dd", matching the web's
|
|
// <input type="date"> (templates/inspections/execute.html), so a value entered
|
|
// on the iPad and one entered in a browser are the same string in form_data.
|
|
//
|
|
// Both date widgets previously used ISO8601DateFormatter, which round-tripped a
|
|
// full timestamp ("2026-08-18T14:30:00Z") into a field that the web renders and
|
|
// the PDF prints verbatim.
|
|
//
|
|
// UTC + POSIX locale so the day cannot shift with device timezone or calendar.
|
|
// nonisolated for the same reason as PhotoCaptureFormat (rule 82).
|
|
nonisolated enum FormDateFormat {
|
|
static let formatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = TimeZone(identifier: "UTC")
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
return f
|
|
}()
|
|
|
|
static func string(from date: Date) -> String { formatter.string(from: date) }
|
|
|
|
/// Parses the canonical form, and tolerates a leading `yyyy-MM-dd` inside a
|
|
/// longer timestamp so values written by earlier builds still display.
|
|
static func date(from value: String) -> Date? {
|
|
if let d = formatter.date(from: value) { return d }
|
|
guard value.count >= 10 else { return nil }
|
|
return formatter.date(from: String(value.prefix(10)))
|
|
}
|
|
}
|
|
|
|
// MARK: - CellCheckboxGroup
|
|
|
|
struct CellCheckboxGroup: View {
|
|
let field: [String: Any]
|
|
@Binding var value: String
|
|
|
|
private var options: [String] { field["options"] as? [String] ?? [] }
|
|
|
|
private var selected: Set<String> {
|
|
guard let data = value.data(using: .utf8),
|
|
let array = try? JSONSerialization.jsonObject(with: data) as? [String]
|
|
else { return [] }
|
|
return Set(array)
|
|
}
|
|
|
|
private func toggle(_ option: String) {
|
|
var current = selected
|
|
if current.contains(option) { current.remove(option) } else { current.insert(option) }
|
|
let ordered = options.filter { current.contains($0) }
|
|
if let data = try? JSONSerialization.data(withJSONObject: ordered),
|
|
let str = String(data: data, encoding: .utf8) { value = str }
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(options, id: \.self) { option in
|
|
Button { toggle(option) } label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: selected.contains(option)
|
|
? "checkmark.square.fill" : "square")
|
|
.foregroundStyle(selected.contains(option) ? .blue : .secondary)
|
|
.font(.system(size: 14))
|
|
Text(option)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.primary)
|
|
Spacer()
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CellRadioGroup
|
|
|
|
struct CellRadioGroup: View {
|
|
let field: [String: Any]
|
|
@Binding var value: String
|
|
|
|
private var options: [String] { field["options"] as? [String] ?? [] }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(options, id: \.self) { option in
|
|
Button { value = option } label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: value == option
|
|
? "largecircle.fill.circle" : "circle")
|
|
.foregroundStyle(value == option ? .blue : .secondary)
|
|
.font(.system(size: 14))
|
|
Text(option)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.primary)
|
|
Spacer()
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CellPassFail
|
|
// Matches web app's .pf-btn pill buttons with green/red colors and toggle behaviour.
|
|
|
|
struct CellPassFail: View {
|
|
let field: [String: Any]
|
|
@Binding var value: String
|
|
|
|
private var options: [String] {
|
|
if let opts = field["options"] as? [String], !opts.isEmpty { return opts }
|
|
return ["Pass", "Fail"]
|
|
}
|
|
|
|
private func isPassOption(_ opt: String) -> Bool {
|
|
["pass", "yes", "ok", "good", "acceptable", "compliant"].contains(opt.lowercased())
|
|
}
|
|
|
|
var body: some View {
|
|
// Each button expands equally to fill the available cell width.
|
|
// .lineLimit(1) + fixedSize prevents multi-line wrapping when
|
|
// custom option labels are long or the cell is narrow.
|
|
HStack(spacing: 6) {
|
|
ForEach(options, id: \.self) { opt in
|
|
let isPass = isPassOption(opt)
|
|
let isActive = value == opt
|
|
let color: Color = isPass ? .green : .red
|
|
Button {
|
|
value = isActive ? "" : opt
|
|
} label: {
|
|
Text(opt)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.frame(maxWidth: .infinity)
|
|
.background(isActive ? color : Color.clear)
|
|
.foregroundStyle(isActive ? .white : color)
|
|
.clipShape(Capsule())
|
|
.overlay(Capsule().stroke(color, lineWidth: 2))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CellSelect
|
|
|
|
struct CellSelect: View {
|
|
let field: [String: Any]
|
|
@Binding var value: String
|
|
|
|
private var options: [String] { field["options"] as? [String] ?? [] }
|
|
|
|
var body: some View {
|
|
Menu {
|
|
Button("— Select —") { value = "" }
|
|
ForEach(options, id: \.self) { option in
|
|
Button(option) { value = option }
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Text(value.isEmpty ? "Select…" : value)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(value.isEmpty ? Color(.placeholderText) : .primary)
|
|
Spacer()
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.foregroundStyle(.secondary)
|
|
.font(.system(size: 10))
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(Color(.systemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
.overlay(RoundedRectangle(cornerRadius: 5)
|
|
.stroke(Color(.systemGray4), lineWidth: 1))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CellRatingStars
|
|
// Matches web app's .rating-stars — compact star row with toggle-off support.
|
|
|
|
struct CellRatingStars: View {
|
|
let maxRating: Int
|
|
@Binding var value: Int
|
|
|
|
var body: some View {
|
|
HStack(spacing: 3) {
|
|
ForEach(1...max(maxRating, 1), id: \.self) { star in
|
|
Button {
|
|
value = (value == star) ? 0 : star
|
|
} label: {
|
|
Text("★")
|
|
.font(.system(size: 19))
|
|
.foregroundStyle(star <= value ? Color.yellow : Color(.systemGray3))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.top, 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Connectivity Badge (unchanged)
|
|
|
|
struct ConnectivityBadge: View {
|
|
@EnvironmentObject private var sync: SyncManager
|
|
|
|
var body: some View {
|
|
HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(sync.isOnline ? Color.green : Color.orange)
|
|
.frame(width: 8, height: 8)
|
|
Text(sync.isOnline ? "Online" : "Offline")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - InspectionLocationManager
|
|
// Thin CLLocationManager wrapper used only by ExecuteInspectionView.
|
|
// 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 +
|
|
// manual @State on the call site (already done above).
|
|
// - CLLocationManager delegate callbacks arrive on the main thread when the
|
|
// manager is created on the main thread (which @State guarantees here).
|
|
// - requestWhenInUseAuthorization() is a no-op if permission was already
|
|
// granted or permanently denied; it only shows the system prompt once.
|
|
// - NSLocationWhenInUseUsageDescription must be present in the app's
|
|
// Info.plist / build settings (add via Xcode target → Info tab).
|
|
|
|
final class InspectionLocationManager: NSObject, CLLocationManagerDelegate {
|
|
|
|
private let manager = CLLocationManager()
|
|
|
|
/// Most recent location fix, or nil if unavailable.
|
|
private(set) var lastLocation: CLLocation?
|
|
|
|
override init() {
|
|
super.init()
|
|
manager.delegate = self
|
|
manager.desiredAccuracy = kCLLocationAccuracyBest
|
|
manager.distanceFilter = kCLDistanceFilterNone
|
|
}
|
|
|
|
/// Request permission (if needed) and start a single location update.
|
|
/// Safe to call multiple times — CLLocationManager ignores duplicate requests.
|
|
func requestLocation() {
|
|
switch manager.authorizationStatus {
|
|
case .notDetermined:
|
|
manager.requestWhenInUseAuthorization()
|
|
// Delegate callback didChangeAuthorization will call requestLocation()
|
|
// again once the user responds.
|
|
case .authorizedWhenInUse, .authorizedAlways:
|
|
manager.requestLocation()
|
|
default:
|
|
// Denied / restricted — lastLocation stays nil; GPS fields stay nil.
|
|
break
|
|
}
|
|
}
|
|
|
|
// CLLocationManagerDelegate
|
|
|
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
// Keep the most accurate fix received.
|
|
lastLocation = locations.last
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
// Non-fatal — GPS fields will be nil; submission still proceeds.
|
|
print("[JQC] Location fix failed: \(error.localizedDescription)")
|
|
}
|
|
|
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
// If the user just granted permission, start the fix immediately.
|
|
if manager.authorizationStatus == .authorizedWhenInUse ||
|
|
manager.authorizationStatus == .authorizedAlways {
|
|
manager.requestLocation()
|
|
}
|
|
}
|
|
}
|