// 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 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? // 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 ?? [] } // ── 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(.systemGroupedBackground)) .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.") } // 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) } // Inspector notes card notesCard .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 } var data: [String: Any] = [:] for (k, v) in formValues { 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 var data: [String: Any] = [:] for (k, v) in formValues { data[k] = v } inspection.formData = data inspection.overallScore = inspection.computeScore(fromSchema: formSchema) inspection.status = "completed" inspection.completedAt = Date() inspection.syncStatus = "pending" 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() } // ── 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: - GridFormView // CHANGED: new view — renders the form schema using the same 12-column grid // layout as the web app's .form-grid CSS grid. Each field is positioned using // its col/row/colSpan/rowSpan attributes from the JSON schema. struct GridFormView: View { let schema: [[String: Any]] @Binding var formValues: [String: String] var onPhotoSelected: ((String, [String: Any]) -> Void)? var onFieldChanged: (() -> Void)? // Grid constants — match the web app static let totalColumns: Int = 12 static let cellGap: CGFloat = 4 // column gap (web: 4px) static let rowGap: CGFloat = 4 // row gap (web: 4px) static let cellAspect: CGFloat = 52/72 // cellH / cellW (web: 52px / 72px) static let cardPadding: CGFloat = 16 // card inset on all sides // @State to capture the rendered grid width from the background GeometryReader. // Starts at a reasonable iPad default (952 = 1000 max-width − 2×24 outer padding). @State private var gridWidth: CGFloat = 952 var body: some View { // Use a zero-height background reader so the ScrollView sees the correct // intrinsic height of the ZStack, not GeometryReader's proposed size. ZStack(alignment: .topLeading) { // Card background RoundedRectangle(cornerRadius: 12) .fill(Color(.secondarySystemGroupedBackground)) // Width probe — invisible, sits behind the grid, reads available width Color.clear .frame(height: 1) .background( GeometryReader { geo in Color.clear.onAppear { gridWidth = max(geo.size.width, 100) } } ) // Field overlays — positioned using live gridWidth let cellW = (gridWidth - 2 * Self.cardPadding - CGFloat(Self.totalColumns - 1) * Self.cellGap) / CGFloat(Self.totalColumns) 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) } } } // Explicit height derived from the same cellW/cellH arithmetic — // this is what the ScrollView measures, so it can never be wrong. .frame(height: canvasHeight() + 2 * Self.cardPadding) } // ── Canvas height ───────────────────────────────────────────────────── private func canvasHeight() -> CGFloat { let cellW = (gridWidth - 2 * Self.cardPadding - CGFloat(Self.totalColumns - 1) * Self.cellGap) / CGFloat(Self.totalColumns) 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 let height = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap 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 fills remaining cell height ── fieldInput .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) // ── Help text — matches .help-text ── if !helpText.isEmpty { Text(helpText) .font(.system(size: 11)) .foregroundStyle(Color(.secondaryLabel)) .lineLimit(2) } } .clipped() } @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 ────────────────────────────────────────── case "image": ImageFieldView( 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: - 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 { HStack(spacing: 6) { ForEach(options, id: \.self) { opt in let isPass = isPassOption(opt) let isActive = value == opt let baseColor: Color = isPass ? .green : .red Button { value = isActive ? "" : opt } label: { Text(opt) .font(.system(size: 13, weight: .semibold)) .padding(.horizontal, 14) .padding(.vertical, 6) .background(isActive ? baseColor : Color.clear) .foregroundStyle(isActive ? .white : baseColor) .clipShape(Capsule()) .overlay(Capsule().stroke(baseColor, lineWidth: 2)) } .buttonStyle(.plain) } Spacer() } } } // 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) } } }