06/18 Update Inspection details view layout
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user