1228 lines
51 KiB
Swift
1228 lines
51 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
|
|
|
|
@State private var formValues: [String: String] = [:]
|
|
@State private var showFlagIssue = false
|
|
@State private var showSubmitAlert = false
|
|
@State private var isSaving = false
|
|
@State private var isSubmitting = false
|
|
@State private var submitResult: SubmitResult?
|
|
|
|
// Location manager — created lazily when submit alert fires so the
|
|
// permission prompt only appears at the point of actual submission.
|
|
@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 ?? [] }
|
|
|
|
// ── 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 {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
ConnectivityBadge()
|
|
}
|
|
}
|
|
.onAppear {
|
|
formValues = inspection.formData.compactMapValues { "\($0)" }
|
|
}
|
|
.onDisappear {
|
|
saveDraft()
|
|
}
|
|
.task {
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(autoSaveInterval))
|
|
saveDraft()
|
|
}
|
|
}
|
|
.sheet(isPresented: $showFlagIssue) {
|
|
FlagIssueView(inspection: inspection)
|
|
}
|
|
.alert("Submit Inspection", isPresented: $showSubmitAlert) {
|
|
Button("Submit") { 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.")
|
|
}
|
|
.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() }
|
|
}
|
|
// Result overlay
|
|
.overlay(alignment: .top) {
|
|
if let result = submitResult {
|
|
submitResultBanner(result)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
.zIndex(10)
|
|
}
|
|
}
|
|
.animation(.spring(duration: 0.35), value: submitResult != nil)
|
|
}
|
|
|
|
// ── Form Content ──────────────────────────────────────────────────────
|
|
|
|
@ViewBuilder
|
|
private var formContent: some View {
|
|
|
|
// Offline banner
|
|
if !sync.isOnline {
|
|
offlineBanner
|
|
.padding(.bottom, 12)
|
|
}
|
|
|
|
// Inspection header card
|
|
headerCard
|
|
.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))
|
|
}
|
|
|
|
// ── 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 {
|
|
showSubmitAlert = 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 let s = v as? String, s.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) ─────────────────────
|
|
|
|
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 let s = v as? String, s.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()
|
|
|
|
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 dismiss
|
|
try? await Task.sleep(for: .seconds(2.5))
|
|
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
|
|
}
|
|
}
|
|
|
|
// ── Photo Handling ────────────────────────────────────────────────────
|
|
|
|
private func handlePhotoSelected(localPath: String, field: [String: Any]) {
|
|
let fid = fieldId(field)
|
|
formValues[fid] = "local://\(localPath)"
|
|
let photo = PendingPhoto(
|
|
localFilePath: localPath,
|
|
entityType: "inspection",
|
|
entityLocalId: inspection.localId,
|
|
fieldId: fid
|
|
)
|
|
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
|
|
|
|
// 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 {
|
|
ZStack(alignment: .topLeading) {
|
|
// ── Card background ────────────────────────────────────────────
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.secondarySystemBackground))
|
|
|
|
// ── Width probe — zero-size overlay, reports container width ───
|
|
// Using a background Color.clear with a GeometryReader that sends
|
|
// its width via PreferenceKey is the idiomatic SwiftUI pattern that
|
|
// works correctly inside ScrollView on all iOS versions.
|
|
Color.clear
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 0)
|
|
.background(
|
|
GeometryReader { geo in
|
|
Color.clear.preference(
|
|
key: WidthPreferenceKey.self,
|
|
value: geo.size.width
|
|
)
|
|
}
|
|
)
|
|
|
|
// ── 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onPreferenceChange(WidthPreferenceKey.self) { width in
|
|
if width > 0 { containerWidth = width }
|
|
}
|
|
// Height is always derived from the same arithmetic as cell offsets —
|
|
// the ScrollView measures this frame and can never be wrong.
|
|
// When containerWidth is 0, canvasHeight() still returns the correct
|
|
// value (it uses computedCellW which returns 0 when containerWidth is 0),
|
|
// so the card reserves space and avoids a layout jump.
|
|
.frame(height: containerWidth > 0 ? canvasHeight() + 2 * Self.cardPadding : 0)
|
|
}
|
|
|
|
// ── 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: { ISO8601DateFormatter().date(from: value) ?? Date() },
|
|
set: { value = ISO8601DateFormatter().string(from: $0) }
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
DatePicker("", selection: dateBinding, displayedComponents: .date)
|
|
.labelsHidden()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// Requests a single best-accuracy fix when the Submit confirmation dialog
|
|
// appears. The fix is stored in lastLocation and read synchronously at the
|
|
// moment the inspector confirms submission.
|
|
//
|
|
// 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()
|
|
}
|
|
}
|
|
}
|