Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/FormFieldView.swift
T
2026-05-02 11:46:28 -04:00

523 lines
17 KiB
Swift

// 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<Date> {
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<String> {
guard let data = value.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [String]
else { return [] }
return Set(array)
}
private func toggle(_ option: String) {
var current = 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<String>
init(value: Binding<String>) { 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..<rowCount, id: \.self) { rowIdx in
GridRow {
ForEach(columns, id: \.self) { col in
let cellValue = tableData.indices.contains(rowIdx)
? tableData[rowIdx][col] ?? "" : ""
TextField("", text: Binding(
get: { cellValue },
set: { updateCell(row: rowIdx, col: col, newValue: $0) }
))
.textFieldStyle(.roundedBorder)
.frame(minWidth: 100)
}
}
}
}
.padding(4)
}
}
}