// Views/Dashboard/FormFieldView.swift // ------------------------------------ // Shared form field sub-components used across the app. // // CHANGED (grid layout update): // - FormFieldView top-level struct retained for any standalone usage. // - RatingFieldView, PassFailFieldView, CheckboxGroupView, RadioGroupView, // SelectFieldView, DateFieldView remain here for backward compatibility. // - ImageFieldView, SignatureFieldView, TableFieldView remain here; // GridCellContentView (in ExecuteInspectionView.swift) references these directly. // - No components removed. import SwiftUI import PencilKit import PhotosUI // MARK: - FormFieldView // Retained for standalone/legacy usage outside the grid inspection form. struct FormFieldView: View { let field: [String: Any] @Binding var value: String var onPhotoSelected: ((String) -> Void)? = nil 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 ?? "" } var body: some View { VStack(alignment: .leading, spacing: 8) { // Label row — skip for types that render their own header if !["section", "label", "checkbox", "button_submit", "button_print", "button_email"].contains(fieldType), !label.isEmpty { HStack(spacing: 3) { Text(label) .font(.subheadline) .fontWeight(.medium) .foregroundStyle(.primary) if required { Text("*").foregroundStyle(.red).font(.subheadline) } } } fieldInput } } @ViewBuilder private var fieldInput: some View { switch fieldType { // ── Display-only ─────────────────────────────────────────────────── case "section": Text(label) .font(.headline) .foregroundStyle(.blue) .padding(.top, 4) case "label": Text(field["text_content"] as? String ?? field["text"] as? String ?? label) .font(.callout) .foregroundStyle(.secondary) // ── Text inputs ──────────────────────────────────────────────────── case "text": styledTextField(placeholder: placeholder.isEmpty ? label : placeholder) case "textarea": TextEditor(text: $value) .frame(minHeight: 90) .padding(8) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8) .stroke(Color(.systemGray4), lineWidth: 1)) case "number": styledTextField(placeholder: placeholder.isEmpty ? "0" : placeholder) .keyboardType(.decimalPad) case "email": styledTextField(placeholder: placeholder.isEmpty ? "email@example.com" : placeholder) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() case "date": DateFieldView(value: $value) // ── Selection ────────────────────────────────────────────────────── case "checkbox": Toggle(isOn: Binding( get: { value == "true" }, set: { value = $0 ? "true" : "false" } )) { HStack(spacing: 3) { Text(label) .font(.subheadline) .fontWeight(.medium) if required { Text("*").foregroundStyle(.red).font(.subheadline) } } } case "checkbox_group": CheckboxGroupView(field: field, value: $value) case "radio": RadioGroupView(field: field, value: $value) case "select": SelectFieldView(field: field, value: $value) // ── Scoring ──────────────────────────────────────────────────────── case "rating": RatingFieldView( maxRating: field["max"] as? Int ?? 5, value: Binding( get: { Int(value) ?? 0 }, set: { value = String($0) } ) ) case "pass_fail": PassFailFieldView(value: $value) // ── Rich inputs ──────────────────────────────────────────────────── case "signature": VStack(alignment: .leading, spacing: 6) { Text("Sign below") .font(.caption) .foregroundStyle(.secondary) SignatureFieldView(value: $value) .frame(height: 140) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8) .stroke(Color(.systemGray4), lineWidth: 1)) } case "image": ImageFieldView( fieldId: field["id"] as? String ?? UUID().uuidString, currentValue: value, onPhotoSelected: onPhotoSelected ) case "table": TableFieldView(field: field, value: $value) default: styledTextField(placeholder: placeholder.isEmpty ? label : placeholder) } } // ── Shared styled text field ─────────────────────────────────────────── private func styledTextField(placeholder: String) -> some View { TextField(placeholder, text: $value) .padding(10) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8) .stroke(Color(.systemGray4), lineWidth: 1)) } } // MARK: - DateFieldView struct DateFieldView: View { @Binding var value: String private var dateBinding: Binding { Binding( get: { FormDateFormat.date(from: value) ?? Date() }, set: { value = FormDateFormat.string(from: $0) } ) } var body: some View { // Same two problems as CellDatePicker, same fix — see the comments // there. Empty must look empty, and the stored format is "yyyy-MM-dd" // to match the web's , not an ISO 8601 timestamp. if value.isEmpty { Button { value = FormDateFormat.string(from: Date()) } label: { HStack(spacing: 6) { Image(systemName: "calendar") Text("Set date") } .font(.callout) .foregroundStyle(Color(.placeholderText)) .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8) .stroke(Color(.systemGray4), lineWidth: 1)) } .buttonStyle(.plain) } else { HStack(spacing: 6) { DatePicker("", selection: dateBinding, displayedComponents: .date) .labelsHidden() Button { value = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) } .frame(maxWidth: .infinity, alignment: .leading) } } } // MARK: - CheckboxGroupView struct CheckboxGroupView: 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: 10) { ForEach(options, id: \.self) { option in Button { toggle(option) } label: { HStack(spacing: 10) { Image(systemName: selected.contains(option) ? "checkmark.square.fill" : "square") .foregroundStyle(selected.contains(option) ? .blue : .secondary) .font(.title3) Text(option) .foregroundStyle(.primary) .font(.callout) Spacer() } } .buttonStyle(.plain) } } } } // MARK: - RadioGroupView struct RadioGroupView: 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: 10) { ForEach(options, id: \.self) { option in Button { value = option } label: { HStack(spacing: 10) { Image(systemName: value == option ? "largecircle.fill.circle" : "circle") .foregroundStyle(value == option ? .blue : .secondary) .font(.title3) Text(option) .foregroundStyle(.primary) .font(.callout) Spacer() } } .buttonStyle(.plain) } } } } // MARK: - SelectFieldView struct SelectFieldView: 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) .foregroundStyle(value.isEmpty ? Color(.placeholderText) : .primary) .font(.callout) Spacer() Image(systemName: "chevron.up.chevron.down") .foregroundStyle(.secondary) .font(.caption) } .padding(10) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8) .stroke(Color(.systemGray4), lineWidth: 1)) } } } // MARK: - RatingFieldView struct RatingFieldView: View { let maxRating: Int @Binding var value: Int var body: some View { HStack(spacing: 10) { ForEach(1...max(maxRating, 1), id: \.self) { star in Button { value = (value == star) ? 0 : star } label: { Image(systemName: star <= value ? "star.fill" : "star") .font(.title) .foregroundStyle(star <= value ? .yellow : Color(.systemGray3)) } .buttonStyle(.plain) } if value > 0 { Text("\(value) / \(maxRating)") .font(.callout) .foregroundStyle(.secondary) .padding(.leading, 4) } } .padding(.vertical, 4) } } // MARK: - PassFailFieldView struct PassFailFieldView: View { @Binding var value: String var body: some View { HStack(spacing: 16) { Button { value = (value == "pass") ? "" : "pass" } label: { HStack(spacing: 6) { Image(systemName: value == "pass" ? "checkmark.circle.fill" : "checkmark.circle") Text("Pass") .fontWeight(.semibold) } .padding(.horizontal, 28) .padding(.vertical, 12) .background(value == "pass" ? Color.green : Color(.systemGray5)) .foregroundStyle(value == "pass" ? .white : .primary) .clipShape(RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) Button { value = (value == "fail") ? "" : "fail" } label: { HStack(spacing: 6) { Image(systemName: value == "fail" ? "xmark.circle.fill" : "xmark.circle") Text("Fail") .fontWeight(.semibold) } .padding(.horizontal, 28) .padding(.vertical, 12) .background(value == "fail" ? Color.red : Color(.systemGray5)) .foregroundStyle(value == "fail" ? .white : .primary) .clipShape(RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) Spacer() } .padding(.vertical, 4) } } // MARK: - SignatureFieldView struct SignatureFieldView: UIViewRepresentable { @Binding var value: String func makeUIView(context: Context) -> PKCanvasView { let canvas = PKCanvasView() canvas.drawingPolicy = .anyInput canvas.backgroundColor = UIColor.systemBackground canvas.layer.cornerRadius = 8 canvas.delegate = context.coordinator return canvas } func updateUIView(_ canvas: PKCanvasView, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(value: $value) } class Coordinator: NSObject, PKCanvasViewDelegate { var value: Binding init(value: Binding) { self.value = value } func canvasViewDrawingDidChange(_ canvas: PKCanvasView) { let image = canvas.drawing.image(from: canvas.bounds, scale: UIScreen.main.scale) if let data = image.pngData() { value.wrappedValue = "data:image/png;base64,\(data.base64EncodedString())" } } } } // MARK: - ImageFieldView struct ImageFieldView: View { let fieldId: String let currentValue: String var onPhotoSelected: ((String) -> Void)? @State private var selectedImage: UIImage? @State private var showChoice = false @State private var showCamera = false @State private var showLibrary = false private var cameraAvailable: Bool { UIImagePickerController.isSourceTypeAvailable(.camera) } var body: some View { VStack(alignment: .leading, spacing: 10) { if let img = selectedImage { Image(uiImage: img) .resizable() .scaledToFit() .frame(maxHeight: 220) .clipShape(RoundedRectangle(cornerRadius: 10)) } else if currentValue.hasPrefix("uploads/") { HStack(spacing: 8) { Image(systemName: "photo.fill") .foregroundStyle(.secondary) Text("Photo attached") .font(.callout) .foregroundStyle(.secondary) } .padding(10) .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 8)) } Button { if cameraAvailable { showChoice = true } else { showLibrary = true } } label: { Label( selectedImage != nil || currentValue.hasPrefix("uploads/") ? "Replace Photo" : "Attach Photo", systemImage: "camera" ) .padding(.horizontal, 16) .padding(.vertical, 10) .background(Color(.systemGray5)) .clipShape(RoundedRectangle(cornerRadius: 8)) } .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 url = photosDir.appendingPathComponent("\(UUID().uuidString).jpg") try? data.write(to: url) selectedImage = img onPhotoSelected?(url.path) } } // MARK: - ImagePickerView // Retained as a thin typealias so existing call sites that reference // ImagePickerView(image:onSelected:) continue to compile without changes. // Internally it now just shows the library picker directly — callers that // need the camera+library choice should use the inline pattern in ImageFieldView. // NOTE: FlagIssueView and CompactImageFieldView have been updated to use // the inline confirmationDialog pattern instead. typealias ImagePickerView = LibraryPickerView // ── Camera — UIImagePickerController with .camera source ───────────────────── struct CameraPickerView: UIViewControllerRepresentable { @Binding var image: UIImage? var onSelected: (UIImage) -> Void func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.sourceType = .camera picker.delegate = context.coordinator return picker } func updateUIViewController(_ vc: UIImagePickerController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { let parent: CameraPickerView init(_ parent: CameraPickerView) { self.parent = parent } func imagePickerController( _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] ) { if let img = info[.originalImage] as? UIImage { // UIImagePickerController with .camera source delivers images whose // imageOrientation reflects the physical device orientation at capture // time. On iPad in landscape the raw UIImage is rotated 90° relative // to what the user sees in the viewfinder. Drawing into a new context // at the display size bakes the transform into the pixel buffer, // producing a correctly-oriented image regardless of how it was held. let normalised = img.normalised() parent.image = normalised parent.onSelected(normalised) } picker.dismiss(animated: true) } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true) } } } // ── Photo Library — PHPickerViewController (no permission required) ─────────── struct LibraryPickerView: UIViewControllerRepresentable { @Binding var image: UIImage? var onSelected: (UIImage) -> Void func makeUIViewController(context: Context) -> PHPickerViewController { var config = PHPickerConfiguration() config.filter = .images config.selectionLimit = 1 let picker = PHPickerViewController(configuration: config) picker.delegate = context.coordinator return picker } func updateUIViewController(_ vc: PHPickerViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, PHPickerViewControllerDelegate { let parent: LibraryPickerView init(_ parent: LibraryPickerView) { self.parent = parent } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } provider.loadObject(ofClass: UIImage.self) { object, _ in DispatchQueue.main.async { if let img = object as? UIImage { self.parent.image = img self.parent.onSelected(img) } } } } } } // ── Multi-image Library Picker — PHPickerViewController with configurable limit ─ struct MultiLibraryPickerView: UIViewControllerRepresentable { /// Maximum number of images the user may select in this session. var selectionLimit: Int var onSelected: ([UIImage]) -> Void func makeUIViewController(context: Context) -> PHPickerViewController { var config = PHPickerConfiguration() config.filter = .images config.selectionLimit = selectionLimit let picker = PHPickerViewController(configuration: config) picker.delegate = context.coordinator return picker } func updateUIViewController(_ vc: PHPickerViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, PHPickerViewControllerDelegate { let parent: MultiLibraryPickerView init(_ parent: MultiLibraryPickerView) { self.parent = parent } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard !results.isEmpty else { return } // PHItemProvider.loadObject's completion handler can be invoked on // arbitrary background queues, potentially concurrently for // different results. Appending to a shared `[UIImage]` from // multiple threads without synchronization is a data race — // Swift's Array is not thread-safe, and concurrent mutation can // corrupt its storage, manifesting as duplicated, dropped, or // reordered photos in the final result. This was the root cause // of duplicated Photo Library attachments on issues. // // Fix: pre-size the array to one slot per result and write each // loaded image into its own fixed index, guarded by a single // serial queue so writes never overlap. Each slot is written at // most once, so duplication is structurally impossible. var images = [UIImage?](repeating: nil, count: results.count) let writeQueue = DispatchQueue(label: "jqc.photopicker.write") let group = DispatchGroup() for (index, result) in results.enumerated() { guard result.itemProvider.canLoadObject(ofClass: UIImage.self) else { continue } group.enter() result.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in if let img = object as? UIImage { writeQueue.sync { images[index] = img } } group.leave() } } group.notify(queue: .main) { self.parent.onSelected(images.compactMap { $0 }) } } } } // MARK: - TableFieldView struct TableFieldView: View { let field: [String: Any] @Binding var value: String private var columns: [String] { field["col_headers"] as? [String] ?? ["Column 1"] } private var rowCount: Int { field["table_rows"] as? Int ?? 3 } private var tableData: [[String: String]] { guard let data = value.data(using: .utf8), let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] else { return Array( repeating: Dictionary(uniqueKeysWithValues: columns.map { ($0, "") }), count: rowCount ) } return array } private func updateCell(row: Int, col: String, newValue: String) { var table = tableData while table.count <= row { table.append(Dictionary(uniqueKeysWithValues: columns.map { ($0, "") })) } table[row][col] = newValue if let data = try? JSONSerialization.data(withJSONObject: table), let str = String(data: data, encoding: .utf8) { value = str } } var body: some View { ScrollView(.horizontal, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { // Header HStack(spacing: 0) { ForEach(columns, id: \.self) { col in Text(col) .font(.caption.bold()) .foregroundStyle(.secondary) .frame(minWidth: 120, alignment: .leading) .padding(.horizontal, 10) .padding(.vertical, 8) .background(Color(.systemGray6)) } } Divider() // Rows ForEach(0.. UIImage { guard imageOrientation != .up else { return self } let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { _ in draw(in: CGRect(origin: .zero, size: size)) } } }