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

771 lines
29 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: { 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 <input type="date">, 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<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 {
// 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..<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))
}
}
}
// MARK: - UIImage orientation normalisation
// UIImagePickerController delivers camera photos whose imageOrientation encodes
// the device tilt at capture time. Consumers (savePhotoToDisk, PDF generator,
// issue thumbnails) all call .jpegData() which honours the EXIF orientation —
// but some downstream renderers (UIGraphicsImageRenderer, PDF drawing) ignore it
// and display the raw rotated pixels. This extension bakes the orientation
// transform into the pixel buffer so all consumers see an upright image.
extension UIImage {
/// Returns a copy of the image with imageOrientation == .up, redrawing
/// the pixels into a new context if the orientation is not already correct.
func normalised() -> UIImage {
guard imageOrientation != .up else { return self }
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
draw(in: CGRect(origin: .zero, size: size))
}
}
}