909 lines
36 KiB
Swift
909 lines
36 KiB
Swift
// Views/Dashboard/ExecuteInspectionView.swift
|
||
// -------------------------------------------
|
||
// Primary work surface for completing an inspection.
|
||
//
|
||
// CHANGED (grid layout update):
|
||
// - formFieldCards replaced with GridFormView — a geometry-driven 12-column
|
||
// grid that positions each field using its col/row/colSpan/rowSpan attributes,
|
||
// mirroring the web app's CSS grid layout exactly.
|
||
// - groupFieldsBySection helper removed (no longer needed).
|
||
// - All other logic (save draft, submit, photo handling, notes, banners) is
|
||
// completely unchanged.
|
||
|
||
import SwiftUI
|
||
import SwiftData
|
||
|
||
struct ExecuteInspectionView: View {
|
||
|
||
@Environment(\.modelContext) private var context
|
||
@Environment(\.dismiss) private var dismiss
|
||
@EnvironmentObject private var sync: SyncManager
|
||
|
||
let inspection: LocalInspection
|
||
|
||
@State private var formValues: [String: String] = [:]
|
||
@State private var showFlagIssue = false
|
||
@State private var showSubmitAlert = false
|
||
@State private var isSaving = false
|
||
@State private var isSubmitting = false
|
||
@State private var submitResult: SubmitResult?
|
||
|
||
// Auto-save interval
|
||
private let autoSaveInterval: TimeInterval = 30
|
||
|
||
enum SubmitResult {
|
||
case success(score: Double?)
|
||
case failure(String)
|
||
}
|
||
|
||
// ── Computed ──────────────────────────────────────────────────────────
|
||
|
||
private var template: LocalTemplate? {
|
||
let id = inspection.templateServerId
|
||
return try? context.fetch(
|
||
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
||
).first
|
||
}
|
||
|
||
private var facility: LocalFacility? {
|
||
let id = inspection.facilityServerId
|
||
return try? context.fetch(
|
||
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||
).first
|
||
}
|
||
|
||
private var formSchema: [[String: Any]] { template?.formSchema ?? [] }
|
||
|
||
// ── Body ──────────────────────────────────────────────────────────────
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
// Centre content with max-width on iPad
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
formContent
|
||
}
|
||
.frame(maxWidth: 1000)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 16)
|
||
}
|
||
.background(Color(.systemGroupedBackground))
|
||
.navigationTitle(template?.name ?? "Inspection")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
ConnectivityBadge()
|
||
}
|
||
}
|
||
.onAppear {
|
||
formValues = inspection.formData.compactMapValues { "\($0)" }
|
||
}
|
||
.onDisappear {
|
||
saveDraft()
|
||
}
|
||
.task {
|
||
while !Task.isCancelled {
|
||
try? await Task.sleep(for: .seconds(autoSaveInterval))
|
||
saveDraft()
|
||
}
|
||
}
|
||
.sheet(isPresented: $showFlagIssue) {
|
||
FlagIssueView(inspection: inspection)
|
||
}
|
||
.alert("Submit Inspection", isPresented: $showSubmitAlert) {
|
||
Button("Submit") { Task { await submitInspection() } }
|
||
Button("Cancel", role: .cancel) {}
|
||
} message: {
|
||
Text(sync.isOnline
|
||
? "Once submitted the inspection cannot be edited. It will be sent to the server now."
|
||
: "Once submitted the inspection cannot be edited. It will sync automatically when you're back online.")
|
||
}
|
||
// Result overlay
|
||
.overlay(alignment: .top) {
|
||
if let result = submitResult {
|
||
submitResultBanner(result)
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
.zIndex(10)
|
||
}
|
||
}
|
||
.animation(.spring(duration: 0.35), value: submitResult != nil)
|
||
}
|
||
|
||
// ── Form Content ──────────────────────────────────────────────────────
|
||
|
||
@ViewBuilder
|
||
private var formContent: some View {
|
||
|
||
// Offline banner
|
||
if !sync.isOnline {
|
||
offlineBanner
|
||
.padding(.bottom, 12)
|
||
}
|
||
|
||
// Inspection header card
|
||
headerCard
|
||
.padding(.bottom, 16)
|
||
|
||
// CHANGED: grid-based form rendering (replaces card-grouped linear list)
|
||
if formSchema.isEmpty {
|
||
emptyFormPlaceholder
|
||
.padding(.bottom, 16)
|
||
} else {
|
||
GridFormView(
|
||
schema: formSchema,
|
||
formValues: $formValues,
|
||
onPhotoSelected: { path, field in
|
||
handlePhotoSelected(localPath: path, field: field)
|
||
},
|
||
onFieldChanged: { saveDraft() }
|
||
)
|
||
.padding(.bottom, 16)
|
||
}
|
||
|
||
// Inspector notes card
|
||
notesCard
|
||
.padding(.bottom, 16)
|
||
|
||
// Action buttons
|
||
actionButtons
|
||
.padding(.bottom, 32)
|
||
}
|
||
|
||
// ── Offline Banner ────────────────────────────────────────────────────
|
||
|
||
private var offlineBanner: some View {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "wifi.slash")
|
||
.foregroundStyle(.orange)
|
||
Text("Offline — your work saves locally and syncs automatically.")
|
||
.font(.callout)
|
||
.foregroundStyle(.orange)
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Color.orange.opacity(0.12))
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
|
||
// ── Header Card ───────────────────────────────────────────────────────
|
||
|
||
private var headerCard: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(template?.name ?? "Inspection Form")
|
||
.font(.title2.bold())
|
||
if let facilityName = facility?.name {
|
||
Label(facilityName, systemImage: "building.2")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Label(
|
||
inspection.inspectionDate.formatted(date: .long, time: .shortened),
|
||
systemImage: "calendar"
|
||
)
|
||
.font(.caption)
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(16)
|
||
.background(Color(.secondarySystemGroupedBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
|
||
// ── Empty Form Placeholder ────────────────────────────────────────────
|
||
|
||
private var emptyFormPlaceholder: some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "layout.sidebar.right")
|
||
.font(.system(size: 40))
|
||
.foregroundStyle(.tertiary)
|
||
Text("This template has no form fields.")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(40)
|
||
.background(Color(.secondarySystemGroupedBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
|
||
// ── Notes Card ────────────────────────────────────────────────────────
|
||
|
||
private var notesCard: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Label("Inspector Notes", systemImage: "note.text")
|
||
.font(.subheadline.weight(.semibold))
|
||
TextEditor(text: Binding(
|
||
get: { inspection.inspectorNotes },
|
||
set: { inspection.inspectorNotes = $0 }
|
||
))
|
||
.frame(minHeight: 100)
|
||
.padding(8)
|
||
.background(Color(.systemBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(Color(.systemGray4), lineWidth: 1)
|
||
)
|
||
}
|
||
.padding(16)
|
||
.background(Color(.secondarySystemGroupedBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
|
||
// ── Action Buttons ────────────────────────────────────────────────────
|
||
|
||
private var actionButtons: some View {
|
||
VStack(spacing: 12) {
|
||
Button {
|
||
showFlagIssue = true
|
||
} label: {
|
||
Label("Flag an Issue", systemImage: "exclamationmark.triangle")
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 12)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.tint(.orange)
|
||
|
||
Button {
|
||
saveDraft(force: true)
|
||
} label: {
|
||
Label(isSaving ? "Saving…" : "Save Draft",
|
||
systemImage: "square.and.arrow.down")
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 12)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.disabled(isSaving)
|
||
|
||
Button {
|
||
showSubmitAlert = true
|
||
} label: {
|
||
Group {
|
||
if isSubmitting {
|
||
HStack(spacing: 8) {
|
||
ProgressView().tint(.white)
|
||
Text("Submitting…")
|
||
}
|
||
} else {
|
||
Label("Submit Inspection", systemImage: "checkmark.circle.fill")
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 14)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(isSubmitting)
|
||
}
|
||
}
|
||
|
||
// ── Submit Result Banner ──────────────────────────────────────────────
|
||
|
||
private func submitResultBanner(_ result: SubmitResult) -> some View {
|
||
HStack(spacing: 12) {
|
||
switch result {
|
||
case .success(let score):
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(.title2)
|
||
.foregroundStyle(.green)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Inspection Submitted")
|
||
.font(.headline)
|
||
if let score {
|
||
Text(String(format: "Score: %.1f%%", score))
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Text(sync.isOnline ? "Sent to server." : "Queued — will sync when online.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
case .failure(let msg):
|
||
Image(systemName: "xmark.circle.fill")
|
||
.font(.title2)
|
||
.foregroundStyle(.red)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Submission Failed")
|
||
.font(.headline)
|
||
Text(msg)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(16)
|
||
.background(Color(.secondarySystemGroupedBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
.shadow(color: .black.opacity(0.1), radius: 8, y: 4)
|
||
.padding(.horizontal, 24)
|
||
.padding(.top, 8)
|
||
}
|
||
|
||
// ── Save Draft ────────────────────────────────────────────────────────
|
||
|
||
private func saveDraft(force: Bool = false) {
|
||
guard inspection.status == "draft" else { return }
|
||
if force { isSaving = true }
|
||
|
||
var data: [String: Any] = [:]
|
||
for (k, v) in formValues { data[k] = v }
|
||
inspection.formData = data
|
||
inspection.lastModifiedAt = Date()
|
||
try? context.save()
|
||
|
||
if force {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||
isSaving = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Submit (async — shows result, then dismisses) ─────────────────────
|
||
|
||
private func submitInspection() async {
|
||
isSubmitting = true
|
||
|
||
// Persist final form data
|
||
var data: [String: Any] = [:]
|
||
for (k, v) in formValues { data[k] = v }
|
||
inspection.formData = data
|
||
inspection.overallScore = inspection.computeScore(fromSchema: formSchema)
|
||
inspection.status = "completed"
|
||
inspection.completedAt = Date()
|
||
inspection.syncStatus = "pending"
|
||
try? context.save()
|
||
|
||
isSubmitting = false
|
||
|
||
// Show success banner
|
||
withAnimation {
|
||
submitResult = .success(score: inspection.overallScore)
|
||
}
|
||
|
||
// Trigger sync in background if online
|
||
if sync.isOnline {
|
||
Task { await sync.triggerSync() }
|
||
}
|
||
|
||
// Wait 2.5 seconds so inspector reads the result, then dismiss
|
||
try? await Task.sleep(for: .seconds(2.5))
|
||
dismiss()
|
||
}
|
||
|
||
// ── Photo Handling ────────────────────────────────────────────────────
|
||
|
||
private func handlePhotoSelected(localPath: String, field: [String: Any]) {
|
||
let fid = fieldId(field)
|
||
formValues[fid] = "local://\(localPath)"
|
||
let photo = PendingPhoto(
|
||
localFilePath: localPath,
|
||
entityType: "inspection",
|
||
entityLocalId: inspection.localId,
|
||
fieldId: fid
|
||
)
|
||
inspection.pendingPhotos.append(photo)
|
||
context.insert(photo)
|
||
try? context.save()
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────
|
||
|
||
private func fieldId(_ field: [String: Any]) -> String {
|
||
if let id = field["id"] as? String { return id }
|
||
if let id = field["id"] as? Int { return String(id) }
|
||
return UUID().uuidString
|
||
}
|
||
}
|
||
|
||
// MARK: - GridFormView
|
||
// CHANGED: new view — renders the form schema using the same 12-column grid
|
||
// layout as the web app's .form-grid CSS grid. Each field is positioned using
|
||
// its col/row/colSpan/rowSpan attributes from the JSON schema.
|
||
|
||
struct GridFormView: View {
|
||
|
||
let schema: [[String: Any]]
|
||
@Binding var formValues: [String: String]
|
||
var onPhotoSelected: ((String, [String: Any]) -> Void)?
|
||
var onFieldChanged: (() -> Void)?
|
||
|
||
// Grid constants — match the web app
|
||
static let totalColumns: Int = 12
|
||
static let cellGap: CGFloat = 4 // column gap (web: 4px)
|
||
static let rowGap: CGFloat = 4 // row gap (web: 4px)
|
||
static let cellAspect: CGFloat = 52/72 // cellH / cellW (web: 52px / 72px)
|
||
static let cardPadding: CGFloat = 16 // card inset on all sides
|
||
|
||
// @State to capture the rendered grid width from the background GeometryReader.
|
||
// Starts at a reasonable iPad default (952 = 1000 max-width − 2×24 outer padding).
|
||
@State private var gridWidth: CGFloat = 952
|
||
|
||
var body: some View {
|
||
// Use a zero-height background reader so the ScrollView sees the correct
|
||
// intrinsic height of the ZStack, not GeometryReader's proposed size.
|
||
ZStack(alignment: .topLeading) {
|
||
// Card background
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(Color(.secondarySystemGroupedBackground))
|
||
|
||
// Width probe — invisible, sits behind the grid, reads available width
|
||
Color.clear
|
||
.frame(height: 1)
|
||
.background(
|
||
GeometryReader { geo in
|
||
Color.clear.onAppear { gridWidth = max(geo.size.width, 100) }
|
||
}
|
||
)
|
||
|
||
// Field overlays — positioned using live gridWidth
|
||
let cellW = (gridWidth - 2 * Self.cardPadding
|
||
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
|
||
/ CGFloat(Self.totalColumns)
|
||
let cellH = cellW * Self.cellAspect
|
||
|
||
ForEach(schema.indices, id: \.self) { idx in
|
||
let field = schema[idx]
|
||
let ftype = field["type"] as? String ?? "text"
|
||
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
||
gridCell(field: field, cellW: cellW, cellH: cellH)
|
||
}
|
||
}
|
||
}
|
||
// Explicit height derived from the same cellW/cellH arithmetic —
|
||
// this is what the ScrollView measures, so it can never be wrong.
|
||
.frame(height: canvasHeight() + 2 * Self.cardPadding)
|
||
}
|
||
|
||
// ── Canvas height ─────────────────────────────────────────────────────
|
||
|
||
private func canvasHeight() -> CGFloat {
|
||
let cellW = (gridWidth - 2 * Self.cardPadding
|
||
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
|
||
/ CGFloat(Self.totalColumns)
|
||
let cellH = cellW * Self.cellAspect
|
||
let maxRow = schema.reduce(0) { acc, f in
|
||
let r = f["row"] as? Int ?? 1
|
||
let rs = f["rowSpan"] as? Int ?? 2
|
||
return max(acc, r + rs - 1)
|
||
}
|
||
return CGFloat(maxRow) * cellH + CGFloat(max(maxRow - 1, 0)) * Self.rowGap
|
||
}
|
||
|
||
// ── Single positioned grid cell ───────────────────────────────────────
|
||
|
||
@ViewBuilder
|
||
private func gridCell(field: [String: Any], cellW: CGFloat, cellH: CGFloat) -> some View {
|
||
let col = max(1, field["col"] as? Int ?? 1)
|
||
let row = max(1, field["row"] as? Int ?? 1)
|
||
let colSpan = max(1, field["colSpan"] as? Int ?? 6)
|
||
let rowSpan = max(1, field["rowSpan"] as? Int ?? 2)
|
||
|
||
let xOffset = CGFloat(col - 1) * (cellW + Self.cellGap) + Self.cardPadding
|
||
let yOffset = CGFloat(row - 1) * (cellH + Self.rowGap) + Self.cardPadding
|
||
|
||
let width = CGFloat(colSpan) * cellW + CGFloat(colSpan - 1) * Self.cellGap
|
||
let height = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap
|
||
|
||
let fid = fieldId(field)
|
||
|
||
GridCellContentView(
|
||
field: field,
|
||
value: Binding(
|
||
get: { formValues[fid] ?? "" },
|
||
set: { formValues[fid] = $0; onFieldChanged?() }
|
||
),
|
||
onPhotoSelected: { path in onPhotoSelected?(path, field) }
|
||
)
|
||
.frame(width: width, height: height, alignment: .topLeading)
|
||
.offset(x: xOffset, y: yOffset)
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────
|
||
|
||
private func fieldId(_ field: [String: Any]) -> String {
|
||
if let id = field["id"] as? String { return id }
|
||
if let id = field["id"] as? Int { return String(id) }
|
||
return UUID().uuidString
|
||
}
|
||
}
|
||
|
||
// MARK: - GridCellContentView
|
||
// CHANGED: new view — renders the interior of a single grid cell.
|
||
// Mirrors the web app's .fg-cell structure:
|
||
// label (small, slate) + input widget filling remaining space.
|
||
|
||
struct GridCellContentView: View {
|
||
|
||
let field: [String: Any]
|
||
@Binding var value: String
|
||
var onPhotoSelected: ((String) -> Void)?
|
||
|
||
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 ?? "" }
|
||
private var helpText: String { field["help_text"] as? String ?? "" }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
// ── Label row — matches .field-lbl (small, slate, clipped) ──
|
||
if !["section", "label", "checkbox",
|
||
"button_submit", "button_print", "button_email"].contains(fieldType),
|
||
!label.isEmpty {
|
||
HStack(spacing: 2) {
|
||
Text(label)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(Color(.secondaryLabel))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
if required {
|
||
Text("*")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.red)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Input widget fills remaining cell height ──
|
||
fieldInput
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
|
||
// ── Help text — matches .help-text ──
|
||
if !helpText.isEmpty {
|
||
Text(helpText)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(Color(.secondaryLabel))
|
||
.lineLimit(2)
|
||
}
|
||
}
|
||
.clipped()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var fieldInput: some View {
|
||
switch fieldType {
|
||
|
||
// ── Section label — matches .section-divider ──────────────────────
|
||
case "section":
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Divider()
|
||
Text(label)
|
||
.font(.system(size: 15, weight: .bold))
|
||
.foregroundStyle(Color(.label))
|
||
.padding(.top, 4)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
// ── Static label ──────────────────────────────────────────────────
|
||
case "label":
|
||
let fontSizeMap: [String: CGFloat] = [
|
||
"small": 11, "normal": 13, "large": 15, "x-large": 18
|
||
]
|
||
let fontSize = fontSizeMap[field["font_size"] as? String ?? "normal"] ?? 13
|
||
let fontWeight: Font.Weight = (field["font_weight"] as? String == "bold") ? .bold : .regular
|
||
Text(field["text_content"] as? String ?? field["text"] as? String ?? "")
|
||
.font(.system(size: fontSize, weight: fontWeight))
|
||
.foregroundStyle(Color(.label))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
// ── Text ──────────────────────────────────────────────────────────
|
||
case "text":
|
||
cellTextField(placeholder: placeholder.isEmpty ? label : placeholder)
|
||
|
||
// ── Textarea ─────────────────────────────────────────────────────
|
||
case "textarea":
|
||
TextEditor(text: $value)
|
||
.font(.system(size: 12))
|
||
.padding(.horizontal, 4)
|
||
.padding(.vertical, 2)
|
||
.background(Color(.systemBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||
.overlay(RoundedRectangle(cornerRadius: 5)
|
||
.stroke(Color(.systemGray4), lineWidth: 1))
|
||
|
||
// ── Number ────────────────────────────────────────────────────────
|
||
case "number":
|
||
cellTextField(placeholder: placeholder.isEmpty ? "0" : placeholder)
|
||
.keyboardType(.decimalPad)
|
||
|
||
// ── Email ─────────────────────────────────────────────────────────
|
||
case "email":
|
||
cellTextField(placeholder: placeholder.isEmpty ? "name@example.com" : placeholder)
|
||
.keyboardType(.emailAddress)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
|
||
// ── Date ──────────────────────────────────────────────────────────
|
||
case "date":
|
||
CellDatePicker(value: $value)
|
||
|
||
// ── Checkbox ──────────────────────────────────────────────────────
|
||
case "checkbox":
|
||
Toggle(isOn: Binding(
|
||
get: { value == "true" },
|
||
set: { value = $0 ? "true" : "false" }
|
||
)) {
|
||
HStack(spacing: 2) {
|
||
Text(label)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(Color(.secondaryLabel))
|
||
if required {
|
||
Text("*").font(.system(size: 11)).foregroundStyle(.red)
|
||
}
|
||
}
|
||
}
|
||
.toggleStyle(.automatic)
|
||
|
||
// ── Checkbox group ────────────────────────────────────────────────
|
||
case "checkbox_group":
|
||
CellCheckboxGroup(field: field, value: $value)
|
||
|
||
// ── Radio ─────────────────────────────────────────────────────────
|
||
case "radio":
|
||
CellRadioGroup(field: field, value: $value)
|
||
|
||
// ── Pass / Fail ───────────────────────────────────────────────────
|
||
case "pass_fail":
|
||
CellPassFail(field: field, value: $value)
|
||
|
||
// ── Select / Dropdown ─────────────────────────────────────────────
|
||
case "select":
|
||
CellSelect(field: field, value: $value)
|
||
|
||
// ── Rating (stars) ────────────────────────────────────────────────
|
||
case "rating":
|
||
CellRatingStars(
|
||
maxRating: field["max"] as? Int ?? 5,
|
||
value: Binding(
|
||
get: { Int(value) ?? 0 },
|
||
set: { value = String($0) }
|
||
)
|
||
)
|
||
|
||
// ── Image / Photo upload ──────────────────────────────────────────
|
||
case "image":
|
||
ImageFieldView(
|
||
fieldId: field["id"] as? String ?? UUID().uuidString,
|
||
currentValue: value,
|
||
onPhotoSelected: onPhotoSelected
|
||
)
|
||
|
||
// ── Signature ─────────────────────────────────────────────────────
|
||
case "signature":
|
||
SignatureFieldView(value: $value)
|
||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||
.overlay(RoundedRectangle(cornerRadius: 6)
|
||
.stroke(Color(.systemGray4), lineWidth: 1))
|
||
|
||
// ── Table ─────────────────────────────────────────────────────────
|
||
case "table":
|
||
TableFieldView(field: field, value: $value)
|
||
|
||
default:
|
||
cellTextField(placeholder: placeholder.isEmpty ? label : placeholder)
|
||
}
|
||
}
|
||
|
||
// ── Shared compact text field — matches .form-control in grid cell ────
|
||
|
||
private func cellTextField(placeholder: String) -> some View {
|
||
TextField(placeholder, text: $value)
|
||
.font(.system(size: 12))
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 3)
|
||
.background(Color(.systemBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||
.overlay(RoundedRectangle(cornerRadius: 5)
|
||
.stroke(Color(.systemGray4), lineWidth: 1))
|
||
}
|
||
}
|
||
|
||
// MARK: - CellDatePicker
|
||
// Compact date picker for a grid cell — shows a short date format.
|
||
|
||
struct CellDatePicker: 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: - CellCheckboxGroup
|
||
|
||
struct CellCheckboxGroup: 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: 4) {
|
||
ForEach(options, id: \.self) { option in
|
||
Button { toggle(option) } label: {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: selected.contains(option)
|
||
? "checkmark.square.fill" : "square")
|
||
.foregroundStyle(selected.contains(option) ? .blue : .secondary)
|
||
.font(.system(size: 14))
|
||
Text(option)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.primary)
|
||
Spacer()
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - CellRadioGroup
|
||
|
||
struct CellRadioGroup: 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(spacing: 6) {
|
||
Image(systemName: value == option
|
||
? "largecircle.fill.circle" : "circle")
|
||
.foregroundStyle(value == option ? .blue : .secondary)
|
||
.font(.system(size: 14))
|
||
Text(option)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.primary)
|
||
Spacer()
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - CellPassFail
|
||
// Matches web app's .pf-btn pill buttons with green/red colors and toggle behaviour.
|
||
|
||
struct CellPassFail: View {
|
||
let field: [String: Any]
|
||
@Binding var value: String
|
||
|
||
private var options: [String] {
|
||
if let opts = field["options"] as? [String], !opts.isEmpty { return opts }
|
||
return ["Pass", "Fail"]
|
||
}
|
||
|
||
private func isPassOption(_ opt: String) -> Bool {
|
||
["pass", "yes", "ok", "good", "acceptable", "compliant"].contains(opt.lowercased())
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 6) {
|
||
ForEach(options, id: \.self) { opt in
|
||
let isPass = isPassOption(opt)
|
||
let isActive = value == opt
|
||
let baseColor: Color = isPass ? .green : .red
|
||
Button {
|
||
value = isActive ? "" : opt
|
||
} label: {
|
||
Text(opt)
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 6)
|
||
.background(isActive ? baseColor : Color.clear)
|
||
.foregroundStyle(isActive ? .white : baseColor)
|
||
.clipShape(Capsule())
|
||
.overlay(Capsule().stroke(baseColor, lineWidth: 2))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - CellSelect
|
||
|
||
struct CellSelect: 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)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(value.isEmpty ? Color(.placeholderText) : .primary)
|
||
Spacer()
|
||
Image(systemName: "chevron.up.chevron.down")
|
||
.foregroundStyle(.secondary)
|
||
.font(.system(size: 10))
|
||
}
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 3)
|
||
.background(Color(.systemBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||
.overlay(RoundedRectangle(cornerRadius: 5)
|
||
.stroke(Color(.systemGray4), lineWidth: 1))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - CellRatingStars
|
||
// Matches web app's .rating-stars — compact star row with toggle-off support.
|
||
|
||
struct CellRatingStars: View {
|
||
let maxRating: Int
|
||
@Binding var value: Int
|
||
|
||
var body: some View {
|
||
HStack(spacing: 3) {
|
||
ForEach(1...max(maxRating, 1), id: \.self) { star in
|
||
Button {
|
||
value = (value == star) ? 0 : star
|
||
} label: {
|
||
Text("★")
|
||
.font(.system(size: 19))
|
||
.foregroundStyle(star <= value ? Color.yellow : Color(.systemGray3))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.top, 1)
|
||
}
|
||
}
|
||
|
||
// MARK: - Connectivity Badge (unchanged)
|
||
|
||
struct ConnectivityBadge: View {
|
||
@EnvironmentObject private var sync: SyncManager
|
||
|
||
var body: some View {
|
||
HStack(spacing: 5) {
|
||
Circle()
|
||
.fill(sync.isOnline ? Color.green : Color.orange)
|
||
.frame(width: 8, height: 8)
|
||
Text(sync.isOnline ? "Online" : "Offline")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|