diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 63d2ce8..ba1cd46 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -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) { diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 177e1ad..186e109 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -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 { + 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 { + 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 diff --git a/JanitorialQC/Views/Dashboard/FormFieldView.swift b/JanitorialQC/Views/Dashboard/FormFieldView.swift index 4c4097c..dedb781 100644 --- a/JanitorialQC/Views/Dashboard/FormFieldView.swift +++ b/JanitorialQC/Views/Dashboard/FormFieldView.swift @@ -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 {