// Views/Inspection/InspectionHistoryView.swift // -------------------------------------------- // Shows the inspector's synced inspection history fetched from the server. // Only available when online. Displays score, facility, template, and date. import SwiftUI import SwiftData struct InspectionHistoryView: View { @EnvironmentObject private var sync: SyncManager @State private var inspections: [APIInspectionSummary] = [] @State private var isLoading = false @State private var errorMessage: String? @State private var total = 0 @State private var offset = 0 private let limit = 30 var body: some View { Group { if !sync.isOnline && inspections.isEmpty { ContentUnavailableView( "Offline", systemImage: "wifi.slash", description: Text("Inspection history requires an internet connection.") ) } else if isLoading && inspections.isEmpty { ProgressView("Loading history…") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = errorMessage, inspections.isEmpty { ContentUnavailableView( "Could Not Load", systemImage: "exclamationmark.triangle", description: Text(error) ) } else if inspections.isEmpty { ContentUnavailableView( "No History", systemImage: "clock.arrow.circlepath", description: Text("Completed inspections will appear here after syncing.") ) } else { List { ForEach(inspections) { inspection in NavigationLink { HistoryDetailView(inspection: inspection) } label: { HistoryRowView(inspection: inspection) } } // Load more if inspections.count < total { HStack { Spacer() Button("Load More") { Task { await loadMore() } } .disabled(isLoading) Spacer() } .listRowSeparator(.hidden) } if isLoading { HStack { Spacer() ProgressView() Spacer() } .listRowSeparator(.hidden) } } .refreshable { await load(reset: true) } } } .navigationTitle("Inspection History") .task { if sync.isOnline { await load(reset: true) } } .onChange(of: sync.isOnline) { if sync.isOnline && inspections.isEmpty { Task { await load(reset: true) } } } } private func load(reset: Bool) async { if reset { offset = 0 } isLoading = true errorMessage = nil defer { isLoading = false } do { let result = try await APIClient.shared.fetchInspectionHistory( limit: limit, offset: reset ? 0 : offset ) if reset { inspections = result.inspections } else { inspections.append(contentsOf: result.inspections) } total = result.total offset = inspections.count } catch { errorMessage = error.localizedDescription } } private func loadMore() async { await load(reset: false) } } // MARK: - History Row struct HistoryRowView: View { let inspection: APIInspectionSummary private var scoreColor: Color { guard let score = inspection.overallScore else { return .secondary } return score >= 80 ? .green : score >= 60 ? .orange : .red } private var dateText: String { guard let date = inspection.inspectionDateParsed else { return inspection.inspectionDate ?? "" } return date.formatted(date: .abbreviated, time: .shortened) } var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 2) { Text(inspection.templateName) .font(.headline) .lineLimit(1) Text(inspection.facilityName) .font(.callout) .foregroundStyle(.secondary) .lineLimit(1) if let area = inspection.areaName { Text(area) .font(.caption) .foregroundStyle(.tertiary) } } Spacer() if let score = inspection.overallScore { VStack(alignment: .trailing, spacing: 2) { Text(String(format: "%.1f%%", score)) .font(.title3.bold()) .foregroundStyle(scoreColor) Text("Score") .font(.caption2) .foregroundStyle(.tertiary) } } } HStack { Image(systemName: "calendar") .font(.caption2) .foregroundStyle(.tertiary) Text(dateText) .font(.caption2) .foregroundStyle(.tertiary) Spacer() // Sync origin badge if inspection.mobileLocalId != nil { Label("Mobile", systemImage: "ipad") .font(.caption2) .foregroundStyle(.blue) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.blue.opacity(0.1)) .clipShape(Capsule()) } } // ── Follow-up badge ──────────────────────────────────────────── if inspection.followUpRequired { HStack(spacing: 4) { Image(systemName: "exclamationmark.arrow.circlepath") .font(.caption2) Text("Follow-up Required") .font(.caption2.bold()) } .padding(.horizontal, 8).padding(.vertical, 3) .background(Color.orange.opacity(0.15)) .foregroundStyle(.orange) .clipShape(Capsule()) } } .padding(.vertical, 4) } } // MARK: - History Detail View // Shows submitted inspection details. // For inspections originally submitted from this device (matched via mobileLocalId), // the filled-in form responses are shown using the same grid as ExecuteInspectionView. // For inspections submitted elsewhere, only summary fields are shown. struct HistoryDetailView: View { let inspection: APIInspectionSummary @Environment(\.modelContext) private var context @State private var showReInspect = false // Look up the local copy by mobileLocalId — present only for this-device submissions private var localCopy: LocalInspection? { guard let lid = inspection.mobileLocalId else { return nil } return try? context.fetch( FetchDescriptor( predicate: #Predicate { $0.localId == lid } ) ).first } // Fetch the template schema so we can render the form grid private var localTemplate: LocalTemplate? { guard let copy = localCopy else { return nil } let id = copy.templateServerId return try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first } private var formSchema: [[String: Any]] { localTemplate?.formSchema ?? [] } // Convert saved form data to [String: String] for the grid renderer private var savedValues: [String: String] { guard let copy = localCopy else { return [:] } return copy.formData.compactMapValues { "\($0)" } } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { // ── Follow-up required banner ────────────────────────────── if inspection.followUpRequired { HStack(alignment: .top, spacing: 12) { Image(systemName: "exclamationmark.arrow.circlepath") .foregroundStyle(.orange) .font(.title3) VStack(alignment: .leading, spacing: 4) { Text("Follow-up Inspection Required") .font(.callout.bold()) .foregroundStyle(.orange) if let note = inspection.followUpNote, !note.isEmpty { Text(note) .font(.callout) .foregroundStyle(.secondary) } Button { showReInspect = true } label: { Label("Start Re-inspection", systemImage: "arrow.uturn.right.circle.fill") .font(.callout.bold()) } .buttonStyle(.borderedProminent) .tint(.orange) .padding(.top, 4) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 24) } // ── Is a re-inspection — parent link ─────────────────────── if let parentId = inspection.parentInspectionId { infoRow(icon: "arrow.uturn.right.circle", text: "Re-inspection of inspection #\(parentId)") .padding(.horizontal, 24) } // ── Summary card ─────────────────────────────────────────── summaryCard // ── Flagged issues ───────────────────────────────────────── if let copy = localCopy, !copy.localIssues.isEmpty { issuesCard(copy.localIssues) } // ── Form responses ───────────────────────────────────────── if !formSchema.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Form Responses") .font(.headline) .padding(.horizontal, 24) // Read-only form grid — reuses GridFormView with disabled inputs ReadOnlyGridFormView( schema: formSchema, formValues: savedValues ) .padding(.horizontal, 24) } } else if localCopy != nil { // Template schema no longer cached locally infoRow( icon: "doc.text", text: "Form schema not available offline. Sync to view full responses." ) .padding(.horizontal, 24) } } .padding(.vertical, 16) } .background(Color(.systemBackground)) .navigationTitle(inspection.templateName) .navigationBarTitleDisplayMode(.inline) .onAppear { syncFollowUpToLocalCopy() } .sheet(isPresented: $showReInspect) { StartInspectionView( preFillTemplateId: inspection.templateId, preFillFacilityId: inspection.facilityId, parentServerId: inspection.id, parentLocalId: inspection.mobileLocalId ) } } /// Write the server's follow-up fields back onto the local SwiftData copy /// so that MyInspectionsView and CompletedInspectionView reflect the latest state. private func syncFollowUpToLocalCopy() { guard let copy = localCopy else { return } var changed = false if copy.followUpRequired != inspection.followUpRequired { copy.followUpRequired = inspection.followUpRequired changed = true } if copy.followUpNote != inspection.followUpNote { copy.followUpNote = inspection.followUpNote changed = true } if copy.parentServerId != inspection.parentInspectionId { copy.parentServerId = inspection.parentInspectionId changed = true } if changed { try? context.save() } } // ── Summary card ─────────────────────────────────────────────────────── private var summaryCard: some View { VStack(alignment: .leading, spacing: 12) { // Score if let score = inspection.overallScore { HStack { Text("Overall Score") .font(.subheadline) .foregroundStyle(.secondary) Spacer() Text(String(format: "%.1f%%", score)) .font(.title2.bold()) .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) } } Divider() infoRow(icon: "building.2", text: inspection.facilityName) if let area = inspection.areaName { infoRow(icon: "mappin", text: area) } if let date = inspection.inspectionDateParsed { infoRow(icon: "calendar", text: date.formatted(date: .long, time: .shortened)) } if inspection.mobileLocalId != nil { infoRow(icon: "ipad", text: "Submitted from this device") } } .padding(16) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 24) } // ── Flagged issues card ──────────────────────────────────────────────── private func issuesCard(_ issues: [LocalIssue]) -> some View { VStack(alignment: .leading, spacing: 10) { Text("Flagged Issues (\(issues.count))") .font(.headline) ForEach(issues) { issue in HStack(alignment: .top, spacing: 10) { Circle() .fill(issue.severity == "critical" ? Color.red : issue.severity == "high" ? Color.orange : issue.severity == "medium" ? Color.yellow : Color.blue) .frame(width: 8, height: 8) .padding(.top, 5) VStack(alignment: .leading, spacing: 2) { Text(issue.severity.capitalized) .font(.caption.bold()) .foregroundStyle(.secondary) Text(issue.issueDescription) .font(.callout) } } } } .padding(16) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 24) } // ── Helper ───────────────────────────────────────────────────────────── private func infoRow(icon: String, text: String) -> some View { HStack(spacing: 10) { Image(systemName: icon) .foregroundStyle(.secondary) .frame(width: 18) Text(text) .font(.callout) .foregroundStyle(.primary) } } } // MARK: - ReadOnlyGridFormView // Renders a submitted form in the same 12-column grid as ExecuteInspectionView // but with all inputs disabled/display-only — no editing allowed. struct ReadOnlyGridFormView: View { let schema: [[String: Any]] let formValues: [String: String] private struct ReadOnlyWidthKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } } static let totalColumns: Int = 12 static let cellGap: CGFloat = 8 static let rowGap: CGFloat = 4 static let cellAspect: CGFloat = 52/72 static let cardPadding: CGFloat = 16 @State private var containerWidth: CGFloat = 952 private var computedCellW: CGFloat { (containerWidth - 2 * Self.cardPadding - CGFloat(Self.totalColumns - 1) * Self.cellGap) / CGFloat(Self.totalColumns) } private func canvasHeight() -> CGFloat { let cellH = computedCellW * Self.cellAspect let maxRow = schema.reduce(0) { acc, f in max(acc, (f["row"] as? Int ?? 1) + (f["rowSpan"] as? Int ?? 2) - 1) } return CGFloat(maxRow) * cellH + CGFloat(max(maxRow - 1, 0)) * Self.rowGap } var body: some View { ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: 12) .fill(Color(.secondarySystemBackground)) Color.clear .frame(maxWidth: .infinity).frame(height: 0) .background(GeometryReader { geo in Color.clear.preference(key: ReadOnlyWidthKey.self, value: geo.size.width) }) let cellW = computedCellW 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) { readOnlyCell(field: field, cellW: cellW, cellH: cellH) } } } .onPreferenceChange(ReadOnlyWidthKey.self) { if $0 > 0 { containerWidth = $0 } } .frame(height: canvasHeight() + 2 * Self.cardPadding) } @ViewBuilder private func readOnlyCell(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 xOff = CGFloat(col - 1) * (cellW + Self.cellGap) + Self.cardPadding let yOff = 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 = field["id"] as? String ?? (field["id"] as? Int).map(String.init) ?? "" let value = formValues[fid] ?? "" let ftype = field["type"] as? String ?? "text" let label = field["label"] as? String ?? "" ReadOnlyCellView(field: field, value: value, fieldType: ftype, label: label) .frame(width: width, height: height, alignment: .topLeading) .offset(x: xOff, y: yOff) } } // MARK: - ReadOnlyCellView // Displays a single form cell as plain text — no editable controls. struct ReadOnlyCellView: View { let field: [String: Any] let value: String let fieldType: String let label: String var body: some View { VStack(alignment: .leading, spacing: 2) { // Label (same as GridCellContentView) if !["section", "label", "checkbox", "button_submit", "button_print", "button_email"].contains(fieldType), !label.isEmpty { Text(label) .font(.system(size: 11, weight: .medium)) .foregroundStyle(Color(.secondaryLabel)) .lineLimit(1) .truncationMode(.tail) } // Value display valueView .frame(maxWidth: .infinity, alignment: .topLeading) } } @ViewBuilder private var valueView: some View { switch fieldType { 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) case "label": let fsMap: [String: CGFloat] = ["small": 11, "normal": 13, "large": 15, "x-large": 18] let fs = fsMap[field["font_size"] as? String ?? "normal"] ?? 13 let fw: Font.Weight = (field["font_weight"] as? String == "bold") ? .bold : .regular Text(field["text_content"] as? String ?? "") .font(.system(size: fs, weight: fw)) .foregroundStyle(Color(.label)) .fixedSize(horizontal: false, vertical: true) case "checkbox": HStack(spacing: 6) { Image(systemName: value == "true" ? "checkmark.square.fill" : "square") .foregroundStyle(value == "true" ? .blue : Color(.systemGray3)) .font(.system(size: 14)) Text(label) .font(.system(size: 12)) .foregroundStyle(Color(.secondaryLabel)) } case "pass_fail": let options = field["options"] as? [String] ?? ["Pass", "Fail"] HStack(spacing: 6) { ForEach(options, id: \.self) { opt in let isPass = ["pass","yes","ok","good","acceptable","compliant"].contains(opt.lowercased()) let isActive = value == opt Text(opt) .font(.system(size: 12, weight: .semibold)) .padding(.horizontal, 10).padding(.vertical, 4) .background(isActive ? (isPass ? Color.green : Color.red) : Color.clear) .foregroundStyle(isActive ? .white : (isPass ? Color.green : Color.red)) .clipShape(Capsule()) .overlay(Capsule().stroke(isPass ? Color.green : Color.red, lineWidth: 1.5)) } } case "rating": let intVal = Int(value) ?? 0 let maxRating = field["max"] as? Int ?? 5 HStack(spacing: 2) { ForEach(1...Swift.max(maxRating, 1), id: \.self) { star in Text("★") .font(.system(size: 16)) .foregroundStyle(star <= intVal ? Color.yellow : Color(.systemGray4)) } } case "image": if value.hasPrefix("local://") { // Photo taken on this device — may still be on disk let path = String(value.dropFirst("local://".count)) if let img = UIImage(contentsOfFile: path) { Image(uiImage: img) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 5)) } else { // Local file cleaned up — show placeholder Label("Photo no longer on device", systemImage: "photo.badge.exclamationmark") .font(.system(size: 11)) .foregroundStyle(.secondary) } } else if value.hasPrefix("uploads/") { // Photo synced to server — load via AsyncImage let url = URL(string: "\(Constants.baseURL)/static/\(value)") AsyncImage(url: url) { phase in switch phase { case .success(let img): img.resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 5)) case .failure: Label("Could not load photo", systemImage: "photo.badge.exclamationmark") .font(.system(size: 11)) .foregroundStyle(.secondary) case .empty: HStack(spacing: 6) { ProgressView().scaleEffect(0.7) Text("Loading photo…") .font(.system(size: 11)) .foregroundStyle(.secondary) } @unknown default: EmptyView() } } } else if !value.isEmpty { // Unknown path format — generic indicator Label("Photo attached", systemImage: "photo") .font(.system(size: 11)) .foregroundStyle(.secondary) } else { Text("—") .font(.system(size: 12)) .foregroundStyle(Color(.tertiaryLabel)) } default: // Text, textarea, number, email, date, select, radio, checkbox_group Text(value.isEmpty ? "—" : value) .font(.system(size: 12)) .foregroundStyle(value.isEmpty ? Color(.tertiaryLabel) : Color(.label)) .lineLimit(3) .padding(.horizontal, 6) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .topLeading) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 5)) .overlay(RoundedRectangle(cornerRadius: 5) .stroke(Color(.systemGray5), lineWidth: 1)) } } }