// Views/Inspection/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. 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(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 ?? [] } // ── Body ────────────────────────────────────────────────────────────── var body: some View { ScrollView { // Centre content with max-width on iPad VStack(alignment: .leading, spacing: 0) { formContent } .frame(maxWidth: 780) .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) // Form fields grouped into cards by section formFieldCards .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)) } // ── Form Field Cards ────────────────────────────────────────────────── // Groups fields into visual cards, starting a new card at each `section` field. 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 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)) .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 } /// 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]] = [] for field in schema { let ftype = field["type"] as? String ?? "" if ftype == "section" && !current.isEmpty { groups.append(current) current = [field] } else { current.append(field) } } if !current.isEmpty { groups.append(current) } return groups.isEmpty ? [[]] : groups } } // MARK: - Connectivity Badge 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) } } }