677 lines
27 KiB
Swift
677 lines
27 KiB
Swift
// 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(value: inspection) {
|
|
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<LocalInspection>(
|
|
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<LocalTemplate>(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))
|
|
}
|
|
}
|
|
}
|