05/02 Phase C 4
This commit is contained in:
@@ -101,13 +101,17 @@ struct DashboardView: View {
|
||||
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
||||
|
||||
} detail: {
|
||||
// CHANGED: each tab is wrapped in its own NavigationStack.
|
||||
// Without this, NavigationLink pushes from MyInspectionsView accumulate
|
||||
// on a shared implicit stack — switching sidebar tabs does not clear
|
||||
// the pushed ExecuteInspectionView, leaving the form stuck on screen.
|
||||
switch selectedTab {
|
||||
case 0: MyInspectionsView()
|
||||
case 1: InspectionHistoryView()
|
||||
case 2: FacilitiesListView()
|
||||
case 3: TemplatesListView()
|
||||
case 4: SyncStatusView()
|
||||
default: SettingsView()
|
||||
case 0: NavigationStack { MyInspectionsView() }
|
||||
case 1: NavigationStack { InspectionHistoryView() }
|
||||
case 2: NavigationStack { FacilitiesListView() }
|
||||
case 3: NavigationStack { TemplatesListView() }
|
||||
case 4: NavigationStack { SyncStatusView() }
|
||||
default: NavigationStack { SettingsView() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showNewInspection) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// Views/Inspection/ExecuteInspectionView.swift
|
||||
// --------------------------------------------
|
||||
// Views/Dashboard/ExecuteInspectionView.swift
|
||||
// -------------------------------------------
|
||||
// Primary work surface for completing an inspection.
|
||||
// Phase C fixes:
|
||||
// 1. submitInspection() is async — shows success/failure toast,
|
||||
// then navigates back to My Inspections on success.
|
||||
// 2. Form fields rendered in a card-based layout suited for iPad.
|
||||
//
|
||||
// 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
|
||||
@@ -58,7 +62,7 @@ struct ExecuteInspectionView: View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
formContent
|
||||
}
|
||||
.frame(maxWidth: 780)
|
||||
.frame(maxWidth: 1000)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
@@ -120,9 +124,21 @@ struct ExecuteInspectionView: View {
|
||||
headerCard
|
||||
.padding(.bottom, 16)
|
||||
|
||||
// Form fields grouped into cards by section
|
||||
formFieldCards
|
||||
// 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
|
||||
@@ -173,44 +189,19 @@ struct ExecuteInspectionView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// ── Form Field Cards ──────────────────────────────────────────────────
|
||||
// Groups fields into visual cards, starting a new card at each `section` field.
|
||||
// ── Empty Form Placeholder ────────────────────────────────────────────
|
||||
|
||||
private var formFieldCards: some View {
|
||||
let groups = groupFieldsBySection(formSchema)
|
||||
return ForEach(groups.indices, id: \.self) { groupIdx in
|
||||
let group = groups[groupIdx]
|
||||
fieldGroupCard(group)
|
||||
.padding(.bottom, 12)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private func fieldGroupCard(_ fields: [[String: Any]]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(fields.indices, id: \.self) { idx in
|
||||
let field = fields[idx]
|
||||
let ftype = field["type"] as? String ?? ""
|
||||
let fid = fieldId(field)
|
||||
|
||||
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
||||
FormFieldView(
|
||||
field: field,
|
||||
value: Binding(
|
||||
get: { formValues[fid] ?? "" },
|
||||
set: { formValues[fid] = $0; saveDraft() }
|
||||
),
|
||||
onPhotoSelected: { path in
|
||||
handlePhotoSelected(localPath: path, field: field)
|
||||
}
|
||||
)
|
||||
|
||||
if idx < fields.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(40)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
@@ -402,28 +393,504 @@ struct ExecuteInspectionView: View {
|
||||
if let id = field["id"] as? Int { return String(id) }
|
||||
return UUID().uuidString
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups form fields into arrays split at each `section` field.
|
||||
/// Each resulting array begins with the section header (if any).
|
||||
private func groupFieldsBySection(_ schema: [[String: Any]]) -> [[[String: Any]]] {
|
||||
var groups: [[[String: Any]]] = []
|
||||
var current: [[String: Any]] = []
|
||||
// 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.
|
||||
|
||||
for field in schema {
|
||||
let ftype = field["type"] as? String ?? ""
|
||||
if ftype == "section" && !current.isEmpty {
|
||||
groups.append(current)
|
||||
current = [field]
|
||||
} else {
|
||||
current.append(field)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if !current.isEmpty { groups.append(current) }
|
||||
return groups.isEmpty ? [[]] : groups
|
||||
}
|
||||
// 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: - Connectivity Badge
|
||||
// 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
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
// Views/Inspection/FormRenderer/FormFieldView.swift
|
||||
// -------------------------------------------------
|
||||
// Renders a single form field from the inspection template form_schema.
|
||||
// Designed to sit inside a card (secondarySystemGroupedBackground).
|
||||
// All fields use consistent styling suited for iPad.
|
||||
// Views/Dashboard/FormFieldView.swift
|
||||
// ------------------------------------
|
||||
// Shared form field sub-components used across the app.
|
||||
//
|
||||
// CHANGED (grid layout update):
|
||||
// - FormFieldView top-level struct retained for any standalone usage.
|
||||
// - RatingFieldView, PassFailFieldView, CheckboxGroupView, RadioGroupView,
|
||||
// SelectFieldView, DateFieldView remain here for backward compatibility.
|
||||
// - ImageFieldView, SignatureFieldView, TableFieldView remain here;
|
||||
// GridCellContentView (in ExecuteInspectionView.swift) references these directly.
|
||||
// - No components removed.
|
||||
|
||||
import SwiftUI
|
||||
import PencilKit
|
||||
|
||||
// MARK: - FormFieldView
|
||||
// Retained for standalone/legacy usage outside the grid inspection form.
|
||||
|
||||
struct FormFieldView: View {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user