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

1253 lines
52 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
import CoreLocation
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 showNoGPSAlert = false
@State private var isSaving = false
@State private var isSubmitting = false
@State private var submitResult: SubmitResult?
// Location manager — created on view init. requestLocation() is called in
// onAppear so the permission prompt (and GPS fix acquisition) starts as
// soon as the inspector opens the inspection, maximising the chance of
// having a fix ready by submit time.
@State private var locationManager = InspectionLocationManager()
// 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(.systemBackground))
.navigationTitle(template?.name ?? "Inspection")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
ConnectivityBadge()
}
}
.onAppear {
formValues = inspection.formData.compactMapValues { "\($0)" }
// Request Location permission (and start acquiring a fix) the moment
// the inspector opens the inspection — gives GPS the entire duration
// of the inspection to get a fix, rather than only the few seconds
// the confirm dialog is on screen. requestLocation() is idempotent.
locationManager.requestLocation()
}
.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") {
if locationManager.lastLocation == nil {
// No GPS fix yet — warn before proceeding rather than
// silently submitting without a location.
showNoGPSAlert = true
} else {
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.")
}
.alert("No GPS Location", isPresented: $showNoGPSAlert) {
Button("Submit Anyway") { Task { await submitInspection() } }
Button("Wait & Retry", role: .cancel) { locationManager.requestLocation() }
} message: {
Text("This inspection's location could not be recorded — Location permission may be denied, or no GPS signal is available right now. You can submit without it, or wait a moment and try again.")
}
.onChange(of: showSubmitAlert) { _, showing in
// Begin acquiring a GPS fix the moment the confirm dialog appears
// so a location is likely ready by the time the inspector taps Submit.
if showing { locationManager.requestLocation() }
}
// 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)
}
// 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 }
// Preserve server paths already written by processPhotoQueue — same
// logic as submitInspection(). Auto-save fires every 30 s and would
// overwrite uploads/... with local://... if it runs after a sync that
// was triggered by FlagIssueView uploading the inspection's photos.
let existingFormData = inspection.formData
var data: [String: Any] = [:]
for (k, v) in formValues {
if let s = v as? String, s.hasPrefix("local://"),
let saved = existingFormData[k] as? String, saved.hasPrefix("uploads/") {
data[k] = saved
} else {
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.
// IMPORTANT: formValues is an in-memory SwiftUI state dict that is NOT
// updated when processPhotoQueue writes server paths back into
// inspection.formData (e.g. during the sync triggered by FlagIssueView).
// For any field whose formValues entry is still "local://..." (the photo
// hasn't been uploaded yet by THIS submit's sync pass), check whether
// processPhotoQueue already wrote a real server path into inspection.formData
// for that field. If so, preserve it — otherwise the server path gets
// overwritten with "local://..." here and sanitised to "" in APIClient,
// making photos disappear on inspections that also flagged an issue.
let existingFormData = inspection.formData
var data: [String: Any] = [:]
for (k, v) in formValues {
if let s = v as? String, s.hasPrefix("local://"),
let saved = existingFormData[k] as? String, saved.hasPrefix("uploads/") {
// processPhotoQueue already uploaded this photo — keep the server path
data[k] = saved
} else {
data[k] = v
}
}
inspection.formData = data
inspection.overallScore = inspection.computeScore(fromSchema: formSchema)
inspection.status = "completed"
inspection.completedAt = Date()
inspection.syncStatus = "pending"
// ── GPS ───────────────────────────────────────────────────────────
// Use whatever fix the location manager has at this moment.
// lastLocation is nil if permission was denied or no fix arrived yet;
// the fields stay nil and the server silently omits them — same as web
// submissions where the user declined the browser location prompt.
if let loc = locationManager.lastLocation {
inspection.submitLatitude = loc.coordinate.latitude
inspection.submitLongitude = loc.coordinate.longitude
}
// ── Clear follow-up flag on parent immediately ────────────────────
// Do this at submit time rather than relying solely on SyncManager,
// so the badge disappears the moment the inspector taps Submit —
// regardless of connectivity or sync timing.
clearParentFollowUpFlag()
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()
}
/// Find the parent LocalInspection and clear its followUpRequired flag.
/// Tries parentLocalId first (set for new re-inspections), then falls back
/// to parentServerId (set after parent has synced), then as a last resort
/// matches by template+facility for re-inspections created before these
/// fields were added (parentLocalId=nil, parentServerId=nil).
private func clearParentFollowUpFlag() {
var parent: LocalInspection?
// Primary: match by the parent's localId UUID (always available if set)
if let lid = inspection.parentLocalId {
parent = try? context.fetch(
FetchDescriptor<LocalInspection>(predicate: #Predicate { $0.localId == lid })
).first
}
// Fallback 1: match by server ID (available once parent has synced)
if parent == nil, let sid = inspection.parentServerId {
parent = try? context.fetch(FetchDescriptor<LocalInspection>())
.first(where: { $0.serverId == sid })
}
// Fallback 2: for stale re-inspections created before parentLocalId existed,
// find any LocalInspection with the same template+facility that has
// followUpRequired=true and is not this inspection itself.
if parent == nil {
let tid = inspection.templateServerId
let fid = inspection.facilityServerId
let selfId = inspection.localId
parent = try? context.fetch(FetchDescriptor<LocalInspection>())
.first(where: {
$0.templateServerId == tid &&
$0.facilityServerId == fid &&
$0.followUpRequired == true &&
$0.localId != selfId
})
}
if let parent {
parent.followUpRequired = false
parent.followUpNote = nil
}
}
// ── 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: - WidthPreferenceKey
// Used by GridFormView to read its container width reliably on any device
// orientation, split-screen size change, or rotation — without GeometryReader's
// ScrollView height ambiguity.
private struct WidthPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
// MARK: - GridFormView
// Renders the form schema using the same 12-column grid as the web app's
// .form-grid CSS grid. Each field is positioned using col/row/colSpan/rowSpan.
struct GridFormView: View {
let schema: [[String: Any]]
@Binding var formValues: [String: String]
var onPhotoSelected: ((String, [String: Any]) -> Void)?
var onFieldChanged: (() -> Void)?
// ── Grid constants — kept in sync with the web form editor ──────────────
// Web editor JS: COLS=12 CELL_W=72 CELL_H=52 GAP=8 (col gap = 8px)
// Web CSS execute: gap: 4px 8px (row-gap=4px, col-gap=8px)
static let totalColumns: Int = 12
static let cellGap: CGFloat = 8 // column gap — matches editor GAP=8 and CSS col-gap
static let rowGap: CGFloat = 4 // row gap — matches CSS row-gap
static let cellAspect: CGFloat = 52/72 // cellH / cellW — matches editor CELL_H/CELL_W
static let cardPadding: CGFloat = 16 // card inset on all sides
// Minimum cell height (points) per field type — ensures 44pt touch targets
// on iPad even when the template author assigned a very short rowSpan.
static let minCellH: [String: CGFloat] = [
"pass_fail": 44,
"rating": 36,
"checkbox": 36,
"checkbox_group": 44,
"radio": 44,
"select": 36,
"date": 36,
"image": 60,
"signature": 80,
"table": 80,
]
// Width captured via PreferenceKey — updates on rotation, split-screen,
// and sheet presentation.
// 0 = unmeasured; the grid does NOT render fields until a real width arrives.
// This prevents the first-frame overflow that occurred when a sheet modal
// (narrower than full screen on iPad 10th gen) was rendered with the old
// hardcoded 952 pt fallback, causing fields to overflow the modal bounds.
@State private var containerWidth: CGFloat = 0
var body: some View {
ZStack(alignment: .topLeading) {
// ── Card background ────────────────────────────────────────────
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
// ── Width probe — zero-size overlay, reports container width ───
// Using a background Color.clear with a GeometryReader that sends
// its width via PreferenceKey is the idiomatic SwiftUI pattern that
// works correctly inside ScrollView on all iOS versions.
Color.clear
.frame(maxWidth: .infinity)
.frame(height: 0)
.background(
GeometryReader { geo in
Color.clear.preference(
key: WidthPreferenceKey.self,
value: geo.size.width
)
}
)
// ── Field overlays — only rendered after width is measured ─────
// containerWidth == 0 means the PreferenceKey has not fired yet
// (first layout pass). Skipping the overlay pass on the zero frame
// prevents fields from being positioned using a stale width and
// overflowing the modal on narrow sheet presentations (iPad 10th gen).
if containerWidth > 0 {
let cellW = computedCellW
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)
}
}
}
}
.onPreferenceChange(WidthPreferenceKey.self) { width in
if width > 0 { containerWidth = width }
}
// Height is always derived from the same arithmetic as cell offsets —
// the ScrollView measures this frame and can never be wrong.
// When containerWidth is 0, canvasHeight() still returns the correct
// value (it uses computedCellW which returns 0 when containerWidth is 0),
// so the card reserves space and avoids a layout jump.
.frame(height: containerWidth > 0 ? canvasHeight() + 2 * Self.cardPadding : 0)
}
// ── Derived cell width from current containerWidth ────────────────────
private var computedCellW: CGFloat {
(containerWidth - 2 * Self.cardPadding
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
/ CGFloat(Self.totalColumns)
}
// ── Canvas height ─────────────────────────────────────────────────────
private func canvasHeight() -> CGFloat {
let cellW = computedCellW
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
// Apply per-type minimum height so touch targets are always reachable.
let ftype = field["type"] as? String ?? "text"
let rawHeight = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap
let minH = Self.minCellH[ftype] ?? 0
let height = max(rawHeight, minH)
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 — sized naturally, not stretched to fill cell ──
// maxHeight:.infinity caused a large gap between the label and the
// input widget when the cell was taller than the content needed.
fieldInput
.frame(maxWidth: .infinity, alignment: .topLeading)
// ── Help text — matches .help-text ──
if !helpText.isEmpty {
Text(helpText)
.font(.system(size: 11))
.foregroundStyle(Color(.secondaryLabel))
.lineLimit(2)
}
}
// No .clipped() — overflow is intentionally visible so tall content
// (dropdowns, multi-line labels) is never silently truncated.
// Matches the web form's .fg-cell { overflow: visible } rule.
}
@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 ──────────────────────────────────────────
// Uses a compact inline zone to match the web's .upload-zone dashed style.
case "image":
CompactImageFieldView(
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: - CompactImageFieldView
// Grid-cell-sized photo upload zone — mirrors the web's .upload-zone style:
// dashed border, small icon + text, filename shown inline when a photo is chosen.
// CHANGED: replaces the full-size ImageFieldView inside grid cells to fix the
// oversized "Attach Photo" button that was too large for compact grid cells.
struct CompactImageFieldView: View {
let fieldId: String
let currentValue: String
var onPhotoSelected: ((String) -> Void)?
@State private var selectedImage: UIImage?
@State private var chosenName: String = ""
@State private var showChoice = false
@State private var showCamera = false
@State private var showLibrary = false
private var cameraAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
var hasPhoto: Bool { selectedImage != nil || currentValue.hasPrefix("uploads/") || currentValue.hasPrefix("local://") }
var body: some View {
Button {
if cameraAvailable { showChoice = true } else { showLibrary = true }
} label: {
HStack(spacing: 6) {
Image(systemName: hasPhoto ? "photo.fill" : "camera")
.font(.system(size: 13))
.foregroundStyle(hasPhoto ? .blue : Color(.secondaryLabel))
VStack(alignment: .leading, spacing: 1) {
Text(hasPhoto ? (chosenName.isEmpty ? "Photo attached" : chosenName)
: "Upload photo")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(hasPhoto ? .blue : Color(.secondaryLabel))
.lineLimit(1)
.truncationMode(.middle)
if !hasPhoto {
Text("Tap to choose")
.font(.system(size: 10))
.foregroundStyle(Color(.tertiaryLabel))
}
}
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, minHeight: 44)
.background(hasPhoto ? Color.blue.opacity(0.07) : Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
hasPhoto ? Color.blue.opacity(0.4) : Color(.systemGray4),
style: StrokeStyle(lineWidth: 1.5, dash: [4, 3])
)
)
}
.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 filename = "\(UUID().uuidString).jpg"
let url = photosDir.appendingPathComponent(filename)
try? data.write(to: url)
selectedImage = img
chosenName = filename
onPhotoSelected?(url.path)
}
}
// 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 {
// Each button expands equally to fill the available cell width.
// .lineLimit(1) + fixedSize prevents multi-line wrapping when
// custom option labels are long or the cell is narrow.
HStack(spacing: 6) {
ForEach(options, id: \.self) { opt in
let isPass = isPassOption(opt)
let isActive = value == opt
let color: Color = isPass ? .green : .red
Button {
value = isActive ? "" : opt
} label: {
Text(opt)
.font(.system(size: 13, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.75)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.background(isActive ? color : Color.clear)
.foregroundStyle(isActive ? .white : color)
.clipShape(Capsule())
.overlay(Capsule().stroke(color, lineWidth: 2))
}
.buttonStyle(.plain)
}
}
}
}
// 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)
}
}
}
// MARK: - InspectionLocationManager
// Thin CLLocationManager wrapper used only by ExecuteInspectionView.
// requestLocation() is called as soon as the inspection opens (onAppear),
// giving GPS the full duration of the inspection to acquire a fix, and again
// when the Submit confirmation dialog appears as a fallback retry. The fix
// is stored in lastLocation and read synchronously at submit time; if still
// nil, the view warns the inspector and lets them choose to submit anyway
// or wait and retry — GPS is not silently dropped.
//
// Design constraints:
// - @Observable is unavailable before iOS 17 WWDC beta; use plain class +
// manual @State on the call site (already done above).
// - CLLocationManager delegate callbacks arrive on the main thread when the
// manager is created on the main thread (which @State guarantees here).
// - requestWhenInUseAuthorization() is a no-op if permission was already
// granted or permanently denied; it only shows the system prompt once.
// - NSLocationWhenInUseUsageDescription must be present in the app's
// Info.plist / build settings (add via Xcode target → Info tab).
final class InspectionLocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
/// Most recent location fix, or nil if unavailable.
private(set) var lastLocation: CLLocation?
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = kCLDistanceFilterNone
}
/// Request permission (if needed) and start a single location update.
/// Safe to call multiple times — CLLocationManager ignores duplicate requests.
func requestLocation() {
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization()
// Delegate callback didChangeAuthorization will call requestLocation()
// again once the user responds.
case .authorizedWhenInUse, .authorizedAlways:
manager.requestLocation()
default:
// Denied / restricted — lastLocation stays nil; GPS fields stay nil.
break
}
}
// CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
// Keep the most accurate fix received.
lastLocation = locations.last
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Non-fatal — GPS fields will be nil; submission still proceeds.
print("[JQC] Location fix failed: \(error.localizedDescription)")
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
// If the user just granted permission, start the fix immediately.
if manager.authorizationStatus == .authorizedWhenInUse ||
manager.authorizationStatus == .authorizedAlways {
manager.requestLocation()
}
}
}