06/18 Update Inspection details view layout

This commit is contained in:
Nguyen Ngo
2026-06-18 15:55:34 -04:00
parent 7b4496a456
commit 95c8238c00
7 changed files with 284 additions and 108 deletions
@@ -434,89 +434,151 @@ struct HistoryDetailView: View {
}
// MARK: - ReadOnlyGridFormView
// Renders a submitted form in the same 12-column grid as ExecuteInspectionView
// but with all inputs disabled/display-only no editing allowed.
// Renders answered form fields row by row.
// Each original schema row becomes one HStack; each field is sized
// proportionally to its colSpan (out of 12 columns).
// Fields that start past col 1 get a leading spacer.
// Only rows containing at least one answered field are shown.
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())
}
// Field visibility filtering
// A row group: all visible fields that share the same original `row`.
private struct RowGroup {
let fields: [[String: Any]] // visible fields in this row, schema order
}
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
private var visibleRowGroups: [RowGroup] {
let skipTypes: Set<String> = ["label", "section",
"button_submit", "button_print", "button_email"]
@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)
// Pass 1 answered data field IDs
var answeredIds = Set<String>()
for f in schema {
guard let ftype = f["type"] as? String, !skipTypes.contains(ftype) else { continue }
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
let val = formValues[fid] ?? ""
let answered = ftype == "rating" ? (Int(val) ?? 0) > 0 : !val.isEmpty
if answered { answeredIds.insert(fid) }
}
return CGFloat(maxRow) * cellH + CGFloat(max(maxRow - 1, 0)) * Self.rowGap
// Pass 2 label IDs that immediately precede an answered field
var visibleLabelIds = Set<String>()
var lbuf: [String] = []
for f in schema {
let ftype = f["type"] as? String ?? ""
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
if ftype == "label" {
lbuf.append(fid)
} else if !skipTypes.contains(ftype) {
if answeredIds.contains(fid) { visibleLabelIds.formUnion(lbuf) }
lbuf.removeAll()
}
}
// Pass 3 section IDs that precede at least one answered field
var visibleSectionIds = Set<String>()
var pendingSecId: String? = nil
for f in schema {
let ftype = f["type"] as? String ?? ""
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
if ftype == "section" {
pendingSecId = fid
} else if !skipTypes.contains(ftype), answeredIds.contains(fid) {
if let sid = pendingSecId { visibleSectionIds.insert(sid); pendingSecId = nil }
}
}
// Pass 4 group fields by original row, keep schema order
var rowGroups: [Int: [[String: Any]]] = [:]
var rowOrder: [Int] = []
for f in schema {
let row = f["row"] as? Int ?? 1
if rowGroups[row] == nil { rowOrder.append(row); rowGroups[row] = [] }
rowGroups[row]!.append(f)
}
// Pass 5 for each row, collect visible fields; skip rows with none
var result: [RowGroup] = []
for origRow in rowOrder.sorted() {
guard let group = rowGroups[origRow] else { continue }
var visibleInRow: [[String: Any]] = []
for f in group {
let ftype = f["type"] as? String ?? ""
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
switch ftype {
case "button_submit", "button_print", "button_email": continue
case "section": if visibleSectionIds.contains(fid) { visibleInRow.append(f) }
case "label": if visibleLabelIds.contains(fid) { visibleInRow.append(f) }
default: if answeredIds.contains(fid) { visibleInRow.append(f) }
}
}
if !visibleInRow.isEmpty {
result.append(RowGroup(fields: visibleInRow))
}
}
return result
}
var body: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
if visibleRowGroups.isEmpty {
Text("No form responses recorded.")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
VStack(alignment: .leading, spacing: 3) {
ForEach(visibleRowGroups.indices, id: \.self) { idx in
rowView(visibleRowGroups[idx])
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
Color.clear
.frame(maxWidth: .infinity).frame(height: 0)
.background(GeometryReader { geo in
Color.clear.preference(key: ReadOnlyWidthKey.self, value: geo.size.width)
})
// Render one row as a GeometryReader-based HStack so each field
// occupies exactly (colSpan/12) of the available width, and leading
// space before col > 1 is filled with a transparent spacer.
@ViewBuilder
private func rowView(_ group: RowGroup) -> some View {
GeometryReader { geo in
let totalW = geo.size.width
let colW = totalW / 12.0
let fields = group.fields
let cellW = computedCellW
let cellH = cellW * Self.cellAspect
ZStack(alignment: .topLeading) {
ForEach(fields.indices, id: \.self) { i in
let f = fields[i]
let col = max(1, f["col"] as? Int ?? 1)
let colSpan = max(1, min(f["colSpan"] as? Int ?? 6, 13 - col))
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
let value = formValues[fid] ?? ""
let ftype = f["type"] as? String ?? "text"
let label = f["label"] as? String ?? ""
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)
ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label)
.frame(width: colW * CGFloat(colSpan), alignment: .topLeading)
.offset(x: colW * CGFloat(col - 1))
}
}
}
.onPreferenceChange(ReadOnlyWidthKey.self) { if $0 > 0 { containerWidth = $0 } }
.frame(height: canvasHeight() + 2 * Self.cardPadding)
.frame(height: rowHeight(group))
}
@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)
// Row height: fixed 36pt for most fields; taller for section headers.
private func rowHeight(_ group: RowGroup) -> CGFloat {
let hasSection = group.fields.contains { ($0["type"] as? String) == "section" }
return hasSection ? 28 : 36
}
}
@@ -609,35 +671,7 @@ struct ReadOnlyCellView: View {
}
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: "exclamationmark.triangle")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
} else if value.hasPrefix("uploads/") {
// Photo synced to server load with retry support
RetryablePhotoView(
url: URL(string: "\(ServerConfig.current)/static/\(value)")
)
} 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))
}
PhotoThumbnailView(value: value)
default:
// Text, textarea, number, email, date, select, radio, checkbox_group
@@ -655,3 +689,80 @@ struct ReadOnlyCellView: View {
}
}
}
// MARK: - PhotoThumbnailView
// Compact thumbnail that fits inside a single grid row (cellAspect 0.5).
// Tap opens a fullscreen lightbox sheet.
struct PhotoThumbnailView: View {
let value: String
@State private var showLightbox = false
var body: some View {
Group {
if value.hasPrefix("local://") {
let path = String(value.dropFirst("local://".count))
if let img = UIImage(contentsOfFile: path) {
thumbnailButton {
Image(uiImage: img)
.resizable().scaledToFill()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
.sheet(isPresented: $showLightbox) {
ZStack {
Color.black.ignoresSafeArea()
Image(uiImage: img)
.resizable().scaledToFit()
}
.onTapGesture { showLightbox = false }
}
} else {
Label("No longer on device", systemImage: "exclamationmark.triangle")
.font(.system(size: 11)).foregroundStyle(.secondary)
}
} else if value.hasPrefix("uploads/") {
let url = URL(string: "\(ServerConfig.current)/static/\(value)")
thumbnailButton {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().scaledToFill()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
case .failure:
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 14)).foregroundStyle(.secondary)
.frame(width: 32, height: 32)
default:
ProgressView().frame(width: 32, height: 32)
}
}
}
.sheet(isPresented: $showLightbox) {
ZStack {
Color.black.ignoresSafeArea()
RetryablePhotoView(url: url)
}
.onTapGesture { showLightbox = false }
}
} else if !value.isEmpty {
Label("Photo attached", systemImage: "photo")
.font(.system(size: 11)).foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private func thumbnailButton<Content: View>(@ViewBuilder content: () -> Content) -> some View {
Button { showLightbox = true } label: {
HStack(spacing: 4) {
content()
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
}
}