718 lines
26 KiB
Swift
718 lines
26 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 }
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
}
|