// Views/Inspection/FormRenderer/FormFieldView.swift // ------------------------------------------------- // Renders a single form field from the inspection template's form_schema. // Supports all field types used by the JQC web app. import SwiftUI import PencilKit // MARK: - FormFieldView struct FormFieldView: View { let field: [String: Any] @Binding var value: String // All values stored as strings; lists as JSON var onPhotoSelected: ((String) -> Void)? = nil // callback with local file path 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: 6) { // Field label (skip for section/label types which render themselves) if !["section", "label", "button_submit", "button_print", "button_email"] .contains(fieldType), !label.isEmpty { HStack(spacing: 4) { Text(label) .font(.subheadline) .fontWeight(.medium) if required { Text("*") .foregroundStyle(.red) .font(.subheadline) } } } // Field input fieldInput } .padding(.vertical, 2) } @ViewBuilder private var fieldInput: some View { switch fieldType { case "section": Text(label) .font(.headline) .foregroundStyle(.blue) .padding(.top, 8) case "label": let textContent = field["text_content"] as? String ?? field["text"] as? String ?? label Text(textContent) .font(.callout) .foregroundStyle(.secondary) case "text": TextField(placeholder.isEmpty ? label : placeholder, text: $value) .textFieldStyle(.roundedBorder) case "textarea": TextEditor(text: $value) .frame(minHeight: 80) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(.systemGray4))) case "number": TextField(placeholder.isEmpty ? "0" : placeholder, text: $value) .textFieldStyle(.roundedBorder) .keyboardType(.decimalPad) case "email": TextField(placeholder.isEmpty ? "email@example.com" : placeholder, text: $value) .textFieldStyle(.roundedBorder) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) case "date": DateFieldView(value: $value) case "checkbox": Toggle(label, isOn: Binding( get: { value == "true" }, set: { value = $0 ? "true" : "false" } )) case "checkbox_group": CheckboxGroupView(field: field, value: $value) case "radio": RadioGroupView(field: field, value: $value) case "select": SelectFieldView(field: field, value: $value) 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) case "signature": SignatureFieldView(value: $value) case "image": ImageFieldView( fieldId: field["id"] as? String ?? UUID().uuidString, currentValue: value, onPhotoSelected: onPhotoSelected ) case "table": TableFieldView(field: field, value: $value) default: TextField(placeholder.isEmpty ? label : placeholder, text: $value) .textFieldStyle(.roundedBorder) } } } // MARK: - DateFieldView struct DateFieldView: View { @Binding var value: String private var dateBinding: Binding { Binding( get: { let formatter = ISO8601DateFormatter() return formatter.date(from: value) ?? Date() }, set: { let formatter = ISO8601DateFormatter() value = formatter.string(from: $0) } ) } var body: some View { DatePicker("", selection: dateBinding, displayedComponents: .date) .labelsHidden() } } // MARK: - CheckboxGroupView struct CheckboxGroupView: View { let field: [String: Any] @Binding var value: String // JSON array of selected values private var options: [String] { field["options"] as? [String] ?? [] } private var selectedValues: 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 = selectedValues if current.contains(option) { current.remove(option) } else { current.insert(option) } let sorted = options.filter { current.contains($0) } if let data = try? JSONSerialization.data(withJSONObject: sorted), 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 { Image(systemName: selectedValues.contains(option) ? "checkmark.square.fill" : "square") .foregroundStyle(selectedValues.contains(option) ? .blue : .secondary) Text(option) .foregroundStyle(.primary) } } .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: 4) { ForEach(options, id: \.self) { option in Button { value = option } label: { HStack { Image(systemName: value == option ? "largecircle.fill.circle" : "circle") .foregroundStyle(value == option ? .blue : .secondary) Text(option) .foregroundStyle(.primary) } } .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 { Picker("", selection: $value) { Text("Select…").tag("") ForEach(options, id: \.self) { option in Text(option).tag(option) } } .pickerStyle(.menu) } } // MARK: - RatingFieldView struct RatingFieldView: View { let maxRating: Int @Binding var value: Int var body: some View { HStack(spacing: 8) { ForEach(1...maxRating, id: \.self) { star in Button { value = (value == star) ? 0 : star // tap same star to clear } label: { Image(systemName: star <= value ? "star.fill" : "star") .font(.title2) .foregroundStyle(star <= value ? .yellow : .secondary) } .buttonStyle(.plain) } if value > 0 { Text("\(value)/\(maxRating)") .font(.caption) .foregroundStyle(.secondary) .padding(.leading, 4) } } } } // MARK: - PassFailFieldView struct PassFailFieldView: View { @Binding var value: String var body: some View { HStack(spacing: 12) { Button { value = value == "pass" ? "" : "pass" } label: { Label("Pass", systemImage: "checkmark.circle.fill") .padding(.horizontal, 20) .padding(.vertical, 10) .background(value == "pass" ? Color.green : Color(.systemGray5)) .foregroundStyle(value == "pass" ? .white : .primary) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) Button { value = value == "fail" ? "" : "fail" } label: { Label("Fail", systemImage: "xmark.circle.fill") .padding(.horizontal, 20) .padding(.vertical, 10) .background(value == "fail" ? Color.red : Color(.systemGray5)) .foregroundStyle(value == "fail" ? .white : .primary) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) } } } // MARK: - SignatureFieldView struct SignatureFieldView: UIViewRepresentable { @Binding var value: String // stored as base64 PNG data URL func makeUIView(context: Context) -> PKCanvasView { let canvas = PKCanvasView() canvas.drawingPolicy = .anyInput canvas.backgroundColor = UIColor.systemBackground canvas.layer.borderColor = UIColor.systemGray4.cgColor canvas.layer.borderWidth = 1 canvas.layer.cornerRadius = 6 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(_ canvasView: PKCanvasView) { let image = canvasView.drawing.image(from: canvasView.bounds, scale: 1) 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 showPicker = false @State private var selectedImage: UIImage? var body: some View { VStack(alignment: .leading, spacing: 8) { // Preview if let img = selectedImage { Image(uiImage: img) .resizable() .scaledToFit() .frame(maxHeight: 200) .clipShape(RoundedRectangle(cornerRadius: 8)) } else if currentValue.hasPrefix("uploads/") { // Already uploaded in a previous session — show a placeholder HStack { Image(systemName: "photo.fill") .foregroundStyle(.secondary) Text("Photo attached") .font(.callout) .foregroundStyle(.secondary) } } Button { showPicker = true } label: { Label( selectedImage != nil || currentValue.hasPrefix("uploads/") ? "Replace Photo" : "Attach Photo", systemImage: "camera" ) } .buttonStyle(.bordered) } .sheet(isPresented: $showPicker) { ImagePickerView(image: $selectedImage) { img in saveAndCallback(img) } } } private func saveAndCallback(_ img: UIImage) { guard let data = img.jpegData(compressionQuality: 0.8) else { return } let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true) try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true) let filename = "\(UUID().uuidString).jpg" let fileURL = photosDir.appendingPathComponent(filename) try? data.write(to: fileURL) selectedImage = img onPhotoSelected?(fileURL.path) } } // MARK: - ImagePickerView struct ImagePickerView: UIViewControllerRepresentable { @Binding var image: UIImage? var onSelected: (UIImage) -> Void func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.delegate = context.coordinator picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary return picker } func updateUIViewController(_ vc: UIImagePickerController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { let parent: ImagePickerView init(_ parent: ImagePickerView) { self.parent = parent } func imagePickerController( _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] ) { if let img = info[.originalImage] as? UIImage { parent.image = img parent.onSelected(img) } picker.dismiss(animated: true) } } } // MARK: - TableFieldView struct TableFieldView: View { let field: [String: Any] @Binding var value: String // JSON: [[String: 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]] { get { guard let data = value.data(using: .utf8), let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] else { // Initialize empty table 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) { Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 4) { // Header row GridRow { ForEach(columns, id: \.self) { col in Text(col) .font(.caption) .fontWeight(.semibold) .foregroundStyle(.secondary) } } Divider() // Data rows ForEach(0..