05/02 Phase C 3

This commit is contained in:
Nguyen Ngo
2026-05-04 12:23:44 -04:00
parent 51d2923e6d
commit 2baa67766b
2 changed files with 530 additions and 307 deletions
+219 -155
View File
@@ -1,7 +1,8 @@
// 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.
// Renders a single form field from the inspection template form_schema.
// Designed to sit inside a card (secondarySystemGroupedBackground).
// All fields use consistent styling suited for iPad.
import SwiftUI
import PencilKit
@@ -11,84 +12,97 @@ import PencilKit
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
@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 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) {
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)
Text("*").foregroundStyle(.red).font(.subheadline)
}
}
}
// Field input
fieldInput
}
.padding(.vertical, 2)
}
@ViewBuilder
private var fieldInput: some View {
switch fieldType {
// Display-only
case "section":
Text(label)
.font(.headline)
.foregroundStyle(.blue)
.padding(.top, 8)
.padding(.top, 4)
case "label":
let textContent = field["text_content"] as? String
?? field["text"] as? String
?? label
Text(textContent)
Text(field["text_content"] as? String
?? field["text"] as? String
?? label)
.font(.callout)
.foregroundStyle(.secondary)
// Text inputs
case "text":
TextField(placeholder.isEmpty ? label : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
styledTextField(placeholder: placeholder.isEmpty ? label : placeholder)
case "textarea":
TextEditor(text: $value)
.frame(minHeight: 80)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(.systemGray4)))
.frame(minHeight: 90)
.padding(8)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(Color(.systemGray4), lineWidth: 1))
case "number":
TextField(placeholder.isEmpty ? "0" : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
styledTextField(placeholder: placeholder.isEmpty ? "0" : placeholder)
.keyboardType(.decimalPad)
case "email":
TextField(placeholder.isEmpty ? "email@example.com" : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
styledTextField(placeholder: placeholder.isEmpty ? "email@example.com" : placeholder)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
case "date":
DateFieldView(value: $value)
// Selection
case "checkbox":
Toggle(label, isOn: Binding(
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)
@@ -99,6 +113,8 @@ struct FormFieldView: View {
case "select":
SelectFieldView(field: field, value: $value)
// Scoring
case "rating":
RatingFieldView(
maxRating: field["max"] as? Int ?? 5,
@@ -111,8 +127,19 @@ struct FormFieldView: View {
case "pass_fail":
PassFailFieldView(value: $value)
// Rich inputs
case "signature":
SignatureFieldView(value: $value)
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(
@@ -125,10 +152,20 @@ struct FormFieldView: View {
TableFieldView(field: field, value: $value)
default:
TextField(placeholder.isEmpty ? label : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
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
@@ -139,12 +176,10 @@ struct DateFieldView: View {
private var dateBinding: Binding<Date> {
Binding(
get: {
let formatter = ISO8601DateFormatter()
return formatter.date(from: value) ?? Date()
ISO8601DateFormatter().date(from: value) ?? Date()
},
set: {
let formatter = ISO8601DateFormatter()
value = formatter.string(from: $0)
value = ISO8601DateFormatter().string(from: $0)
}
)
}
@@ -152,6 +187,7 @@ struct DateFieldView: View {
var body: some View {
DatePicker("", selection: dateBinding, displayedComponents: .date)
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -159,45 +195,41 @@ struct DateFieldView: View {
struct CheckboxGroupView: View {
let field: [String: Any]
@Binding var value: String // JSON array of selected values
@Binding var value: String
private var options: [String] {
field["options"] as? [String] ?? []
}
private var options: [String] { field["options"] as? [String] ?? [] }
private var selectedValues: Set<String> {
guard let data = value.data(using: .utf8),
private var selected: 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),
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) {
VStack(alignment: .leading, spacing: 10) {
ForEach(options, id: \.self) { option in
Button {
toggle(option)
} label: {
HStack {
Image(systemName: selectedValues.contains(option)
Button { toggle(option) } label: {
HStack(spacing: 10) {
Image(systemName: selected.contains(option)
? "checkmark.square.fill" : "square")
.foregroundStyle(selectedValues.contains(option) ? .blue : .secondary)
.foregroundStyle(selected.contains(option) ? .blue : .secondary)
.font(.title3)
Text(option)
.foregroundStyle(.primary)
.font(.callout)
Spacer()
}
}
.buttonStyle(.plain)
@@ -212,22 +244,21 @@ struct RadioGroupView: View {
let field: [String: Any]
@Binding var value: String
private var options: [String] {
field["options"] as? [String] ?? []
}
private var options: [String] { field["options"] as? [String] ?? [] }
var body: some View {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 10) {
ForEach(options, id: \.self) { option in
Button {
value = option
} label: {
HStack {
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)
@@ -242,18 +273,30 @@ struct SelectFieldView: View {
let field: [String: Any]
@Binding var value: String
private var options: [String] {
field["options"] as? [String] ?? []
}
private var options: [String] { field["options"] as? [String] ?? [] }
var body: some View {
Picker("", selection: $value) {
Text("Select").tag("")
Menu {
Button("Select") { value = "" }
ForEach(options, id: \.self) { option in
Text(option).tag(option)
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))
}
.pickerStyle(.menu)
}
}
@@ -264,24 +307,25 @@ struct RatingFieldView: View {
@Binding var value: Int
var body: some View {
HStack(spacing: 8) {
ForEach(1...maxRating, id: \.self) { star in
HStack(spacing: 10) {
ForEach(1...max(maxRating, 1), id: \.self) { star in
Button {
value = (value == star) ? 0 : star // tap same star to clear
value = (value == star) ? 0 : star
} label: {
Image(systemName: star <= value ? "star.fill" : "star")
.font(.title2)
.foregroundStyle(star <= value ? .yellow : .secondary)
.font(.title)
.foregroundStyle(star <= value ? .yellow : Color(.systemGray3))
}
.buttonStyle(.plain)
}
if value > 0 {
Text("\(value)/\(maxRating)")
.font(.caption)
Text("\(value) / \(maxRating)")
.font(.callout)
.foregroundStyle(.secondary)
.padding(.leading, 4)
}
}
.padding(.vertical, 4)
}
}
@@ -291,60 +335,70 @@ struct PassFailFieldView: View {
@Binding var value: String
var body: some View {
HStack(spacing: 12) {
HStack(spacing: 16) {
Button {
value = value == "pass" ? "" : "pass"
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))
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"
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))
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 // stored as base64 PNG data URL
@Binding var value: String
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.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<String>
init(value: Binding<String>) { self.value = value }
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
let image = canvasView.drawing.image(from: canvasView.bounds, scale: 1)
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())"
}
@@ -359,27 +413,28 @@ struct ImageFieldView: View {
let currentValue: String
var onPhotoSelected: ((String) -> Void)?
@State private var showPicker = false
@State private var showPicker = false
@State private var selectedImage: UIImage?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Preview
VStack(alignment: .leading, spacing: 10) {
if let img = selectedImage {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(maxHeight: 220)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else if currentValue.hasPrefix("uploads/") {
// Already uploaded in a previous session show a placeholder
HStack {
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 {
@@ -390,8 +445,12 @@ struct ImageFieldView: View {
? "Replace Photo" : "Attach Photo",
systemImage: "camera"
)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color(.systemGray5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.bordered)
.buttonStyle(.plain)
}
.sheet(isPresented: $showPicker) {
ImagePickerView(image: $selectedImage) { img in
@@ -402,15 +461,14 @@ struct ImageFieldView: View {
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)
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 fileURL = photosDir.appendingPathComponent(filename)
try? data.write(to: fileURL)
let url = photosDir.appendingPathComponent("\(UUID().uuidString).jpg")
try? data.write(to: url)
selectedImage = img
onPhotoSelected?(fileURL.path)
onPhotoSelected?(url.path)
}
}
@@ -422,9 +480,9 @@ struct ImagePickerView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
? .camera : .photoLibrary
picker.delegate = context.coordinator
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
? .camera : .photoLibrary
return picker
}
@@ -445,6 +503,10 @@ struct ImagePickerView: UIViewControllerRepresentable {
}
picker.dismiss(animated: true)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
}
@@ -452,26 +514,21 @@ struct ImagePickerView: UIViewControllerRepresentable {
struct TableFieldView: View {
let field: [String: Any]
@Binding var value: String // JSON: [[String: String]]
@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 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
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) {
@@ -487,36 +544,43 @@ struct TableFieldView: View {
}
var body: some View {
ScrollView(.horizontal) {
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 4) {
// Header row
GridRow {
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 0) {
ForEach(columns, id: \.self) { col in
Text(col)
.font(.caption)
.fontWeight(.semibold)
.font(.caption.bold())
.foregroundStyle(.secondary)
.frame(minWidth: 120, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(Color(.systemGray6))
}
}
Divider()
// Data rows
ForEach(0..<rowCount, id: \.self) { rowIdx in
GridRow {
// Rows
ForEach(0..<rowCount, id: \.self) { row in
HStack(spacing: 0) {
ForEach(columns, id: \.self) { col in
let cellValue = tableData.indices.contains(rowIdx)
? tableData[rowIdx][col] ?? "" : ""
let cellValue = tableData.indices.contains(row)
? tableData[row][col] ?? "" : ""
TextField("", text: Binding(
get: { cellValue },
set: { updateCell(row: rowIdx, col: col, newValue: $0) }
set: { updateCell(row: row, col: col, newValue: $0) }
))
.textFieldStyle(.roundedBorder)
.frame(minWidth: 100)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(minWidth: 120, alignment: .leading)
}
}
if row < rowCount - 1 { Divider() }
}
}
.padding(4)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(Color(.systemGray4), lineWidth: 1))
}
}
}