// Views/Inspection/ExecuteInspectionView.swift // -------------------------------------------- // The primary work surface for completing an inspection. // Renders the dynamic form_schema from the selected template. // All writes go to SwiftData (offline-safe). Auto-saves every 30 seconds. import SwiftUI import SwiftData struct ExecuteInspectionView: View { @Environment(\.modelContext) private var context @EnvironmentObject private var sync: SyncManager @EnvironmentObject private var auth: AuthManager let inspection: LocalInspection // Local state for the form — mirrors inspection.formData @State private var formValues: [String: String] = [:] @State private var showFlagIssue = false @State private var showSubmitAlert = false @State private var showOfflineBanner = false @State private var isSaving = false @State private var isSubmitting = false @State private var submitMessage = "" // Auto-save timer private let autoSaveInterval: TimeInterval = 30 private var template: LocalTemplate? { // Look up the template from SwiftData let id = inspection.templateServerId return try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first } private var facility: LocalFacility? { let id = inspection.facilityServerId return try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first } private var formSchema: [[String: Any]] { template?.formSchema ?? [] } var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { // ── Offline banner ───────────────────────────────────────── if !sync.isOnline { HStack { Image(systemName: "wifi.slash") Text("Offline — your work saves locally and will sync automatically.") .font(.callout) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 8)) .padding(.horizontal) } // ── Inspection header ────────────────────────────────────── VStack(alignment: .leading, spacing: 4) { Text(template?.name ?? "Inspection Form") .font(.title2.bold()) Text(facility?.name ?? "") .font(.subheadline) .foregroundStyle(.secondary) Text(inspection.inspectionDate.formatted(date: .long, time: .shortened)) .font(.caption) .foregroundStyle(.tertiary) } .padding(.horizontal) Divider() // ── Form fields ──────────────────────────────────────────── ForEach(formSchema.indices, id: \.self) { idx in let field = formSchema[idx] let fid = fieldId(field) let ftype = field["type"] as? String ?? "" if !["button_submit", "button_print", "button_email"].contains(ftype) { FormFieldView( field: field, value: Binding( get: { formValues[fid] ?? "" }, set: { formValues[fid] = $0; saveDraft() } ), onPhotoSelected: { localPath in handlePhotoSelected(localPath: localPath, field: field) } ) .padding(.horizontal) } } // ── Inspector notes ──────────────────────────────────────── VStack(alignment: .leading, spacing: 6) { Text("Inspector Notes") .font(.subheadline.weight(.medium)) TextEditor(text: Binding( get: { inspection.inspectorNotes }, set: { inspection.inspectorNotes = $0 } )) .frame(minHeight: 80) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(.systemGray4))) } .padding(.horizontal) Divider() // ── Action buttons ───────────────────────────────────────── VStack(spacing: 12) { // Flag Issue Button { showFlagIssue = true } label: { Label("Flag an Issue", systemImage: "exclamationmark.triangle") .frame(maxWidth: .infinity) .padding(.vertical, 12) } .buttonStyle(.bordered) .tint(.orange) // Save Draft 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) // Submit Inspection Button { showSubmitAlert = true } label: { Label("Submit Inspection", systemImage: "checkmark.circle.fill") .frame(maxWidth: .infinity) .padding(.vertical, 14) } .buttonStyle(.borderedProminent) .disabled(isSubmitting) } .padding(.horizontal) .padding(.bottom, 32) } } .navigationTitle("Inspection") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { ConnectivityBadge() } } .onAppear { // Load saved form data into local state formValues = inspection.formData.compactMapValues { "\($0)" } } .onDisappear { saveDraft(force: true) } // Auto-save every 30 seconds .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", role: .none) { submitInspection() } Button("Cancel", role: .cancel) {} } message: { Text("Once submitted, the inspection cannot be edited. " + (sync.isOnline ? "It will be sent to the server now." : "It will sync automatically when you're back online.")) } } // ── Save Draft ───────────────────────────────────────────────────────── private func saveDraft(force: Bool = false) { guard inspection.status == "draft" else { return } if force { isSaving = true } // Write form values back to the model 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.5) { isSaving = false } } } // ── Submit ───────────────────────────────────────────────────────────── private func submitInspection() { isSubmitting = true // Persist final form data var data: [String: Any] = [:] for (k, v) in formValues { data[k] = v } inspection.formData = data // Compute score inspection.overallScore = inspection.computeScore(fromSchema: formSchema) inspection.status = "completed" inspection.completedAt = Date() inspection.syncStatus = "pending" try? context.save() // Trigger sync if online if sync.isOnline { Task { await sync.triggerSync() } } isSubmitting = false } // ── Photo handling ───────────────────────────────────────────────────── private func handlePhotoSelected(localPath: String, field: [String: Any]) { let fid = fieldId(field) // Store local sentinel in form values formValues[fid] = "local://\(localPath)" // Create PendingPhoto record 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: - Connectivity Badge struct ConnectivityBadge: View { @EnvironmentObject private var sync: SyncManager var body: some View { HStack(spacing: 4) { Circle() .fill(sync.isOnline ? Color.green : Color.orange) .frame(width: 8, height: 8) Text(sync.isOnline ? "Online" : "Offline") .font(.caption2) .foregroundStyle(.secondary) } } }