Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift
T
2026-05-04 13:15:28 -04:00

909 lines
36 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(.systemGroupedBackground))
.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)
}
// Inspector notes card
notesCard
.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: - 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.
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)
}
}
}
// 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: - 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
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)
}
}
}