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

1043 lines
42 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(.systemBackground))
.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)
}
// 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: - 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.
// Default 952 = 1000 max-width 2×24 outer padding (safe iPad landscape floor).
@State private var containerWidth: CGFloat = 952
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 ─────────────────────────────────────────────
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.
.frame(height: canvasHeight() + 2 * Self.cardPadding)
}
// ── 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 {
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)
}
}
}