// 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 @State private var formValues: [String: String] = [:] @State private var showFlagIssue = false @State private var showSubmitAlert = false @State private var showNoGPSAlert = 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? // 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(predicate: #Predicate { $0.serverId == id }) ).first } private var facility: LocalFacility? { let id = inspection.facilityServerId return try? context.fetch( FetchDescriptor(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": if let v = Int(val), v > 0 { earned += v; total += field["max"] as? Int ?? 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)" } // 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. showNoGPSAlert = true } else { Task { await submitInspection() } } } Button("Cancel", role: .cancel) {} } message: { Text(sync.isOnline ? "Once submitted the inspection cannot be edited. It will be sent to the server now." : "Once submitted the inspection cannot be edited. It will sync automatically when you're back online.") } .alert("No GPS Location", isPresented: $showNoGPSAlert) { Button("Submit Anyway") { Task { await submitInspection() } } Button("Wait & Retry", role: .cancel) { locationManager.requestLocation() } } message: { Text("This inspection's location could not be recorded — Location permission may be denied, or no GPS signal is available right now. You can submit without it, or wait a moment and try again.") } .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() } } // 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) // 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 { // 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 = ["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() 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(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()) .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()) .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)" // 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 // 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 { 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 { 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() } } }