Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/FormFieldView.swift
T

702 lines
25 KiB
Swift

// 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<Date> {
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: - CheckboxGroupView
struct CheckboxGroupView: View {
let field: [String: Any]
@Binding var value: String
private var options: [String] { field["options"] as? [String] ?? [] }
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 = 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<String>
init(value: Binding<String>) { 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 {
parent.image = img
parent.onSelected(img)
}
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 }
var images: [UIImage] = []
let group = DispatchGroup()
for result in results {
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 { images.append(img) }
group.leave()
}
}
group.notify(queue: .main) {
self.parent.onSelected(images)
}
}
}
}
// 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..<rowCount, id: \.self) { row in
HStack(spacing: 0) {
ForEach(columns, id: \.self) { col in
let cellValue = tableData.indices.contains(row)
? tableData[row][col] ?? "" : ""
TextField("", text: Binding(
get: { cellValue },
set: { updateCell(row: row, col: col, newValue: $0) }
))
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(minWidth: 120, alignment: .leading)
}
}
if row < rowCount - 1 { Divider() }
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(Color(.systemGray4), lineWidth: 1))
}
}
}