05/02 Phase C 4

This commit is contained in:
Nguyen Ngo
2026-05-04 13:15:28 -04:00
parent 2baa67766b
commit 09715e9c11
3 changed files with 549 additions and 71 deletions
@@ -101,13 +101,17 @@ struct DashboardView: View {
.safeAreaInset(edge: .bottom) { syncStatusFooter } .safeAreaInset(edge: .bottom) { syncStatusFooter }
} detail: { } 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 { switch selectedTab {
case 0: MyInspectionsView() case 0: NavigationStack { MyInspectionsView() }
case 1: InspectionHistoryView() case 1: NavigationStack { InspectionHistoryView() }
case 2: FacilitiesListView() case 2: NavigationStack { FacilitiesListView() }
case 3: TemplatesListView() case 3: NavigationStack { TemplatesListView() }
case 4: SyncStatusView() case 4: NavigationStack { SyncStatusView() }
default: SettingsView() default: NavigationStack { SettingsView() }
} }
} }
.sheet(isPresented: $showNewInspection) { .sheet(isPresented: $showNewInspection) {
@@ -1,10 +1,14 @@
// Views/Inspection/ExecuteInspectionView.swift // Views/Dashboard/ExecuteInspectionView.swift
// -------------------------------------------- // -------------------------------------------
// Primary work surface for completing an inspection. // Primary work surface for completing an inspection.
// Phase C fixes: //
// 1. submitInspection() is async shows success/failure toast, // CHANGED (grid layout update):
// then navigates back to My Inspections on success. // - formFieldCards replaced with GridFormView a geometry-driven 12-column
// 2. Form fields rendered in a card-based layout suited for iPad. // 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 SwiftUI
import SwiftData import SwiftData
@@ -58,7 +62,7 @@ struct ExecuteInspectionView: View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
formContent formContent
} }
.frame(maxWidth: 780) .frame(maxWidth: 1000)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 16) .padding(.vertical, 16)
@@ -120,9 +124,21 @@ struct ExecuteInspectionView: View {
headerCard headerCard
.padding(.bottom, 16) .padding(.bottom, 16)
// Form fields grouped into cards by section // CHANGED: grid-based form rendering (replaces card-grouped linear list)
formFieldCards if formSchema.isEmpty {
emptyFormPlaceholder
.padding(.bottom, 16) .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 // Inspector notes card
notesCard notesCard
@@ -173,44 +189,19 @@ struct ExecuteInspectionView: View {
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
} }
// Form Field Cards // Empty Form Placeholder
// Groups fields into visual cards, starting a new card at each `section` field.
private var formFieldCards: some View { private var emptyFormPlaceholder: some View {
let groups = groupFieldsBySection(formSchema) VStack(spacing: 12) {
return ForEach(groups.indices, id: \.self) { groupIdx in Image(systemName: "layout.sidebar.right")
let group = groups[groupIdx] .font(.system(size: 40))
fieldGroupCard(group) .foregroundStyle(.tertiary)
.padding(.bottom, 12) Text("This template has no form fields.")
.font(.callout)
.foregroundStyle(.secondary)
} }
} .frame(maxWidth: .infinity)
.padding(40)
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)
.background(Color(.secondarySystemGroupedBackground)) .background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
} }
@@ -402,28 +393,504 @@ struct ExecuteInspectionView: View {
if let id = field["id"] as? Int { return String(id) } if let id = field["id"] as? Int { return String(id) }
return UUID().uuidString return UUID().uuidString
} }
}
/// Groups form fields into arrays split at each `section` field. // MARK: - GridFormView
/// Each resulting array begins with the section header (if any). // CHANGED: new view renders the form schema using the same 12-column grid
private func groupFieldsBySection(_ schema: [[String: Any]]) -> [[[String: Any]]] { // layout as the web app's .form-grid CSS grid. Each field is positioned using
var groups: [[[String: Any]]] = [] // its col/row/colSpan/rowSpan attributes from the JSON schema.
var current: [[String: Any]] = []
for field in schema { struct GridFormView: View {
let ftype = field["type"] as? String ?? ""
if ftype == "section" && !current.isEmpty { let schema: [[String: Any]]
groups.append(current) @Binding var formValues: [String: String]
current = [field] var onPhotoSelected: ((String, [String: Any]) -> Void)?
} else { var onFieldChanged: (() -> Void)?
current.append(field)
// 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 { struct ConnectivityBadge: View {
@EnvironmentObject private var sync: SyncManager @EnvironmentObject private var sync: SyncManager
@@ -1,13 +1,20 @@
// Views/Inspection/FormRenderer/FormFieldView.swift // Views/Dashboard/FormFieldView.swift
// ------------------------------------------------- // ------------------------------------
// Renders a single form field from the inspection template form_schema. // Shared form field sub-components used across the app.
// Designed to sit inside a card (secondarySystemGroupedBackground). //
// All fields use consistent styling suited for iPad. // 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 SwiftUI
import PencilKit import PencilKit
// MARK: - FormFieldView // MARK: - FormFieldView
// Retained for standalone/legacy usage outside the grid inspection form.
struct FormFieldView: View { struct FormFieldView: View {