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
+23 -8
View File
@@ -29,9 +29,22 @@
B37875952FA6358F0088F40B /* JanitorialQCUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JanitorialQCUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
B3C5F1DC2FDB4A38005E0181 /* Exceptions for "JanitorialQC" folder in "JanitorialQC" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = B378757B2FA6358E0088F40B /* JanitorialQC */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
B378757E2FA6358E0088F40B /* JanitorialQC */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
B3C5F1DC2FDB4A38005E0181 /* Exceptions for "JanitorialQC" folder in "JanitorialQC" target */,
);
path = JanitorialQC;
sourceTree = "<group>";
};
@@ -398,26 +411,27 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = SB7DNYC9TY;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = JanitorialQC/Info.plist;
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection to verify it was completed on-site.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Attach photos from your library to inspection issues.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -440,26 +454,27 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = SB7DNYC9TY;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = JanitorialQC/Info.plist;
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection to verify it was completed on-site.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Attach photos from your library to inspection issues.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
+4 -1
View File
@@ -219,6 +219,7 @@ struct APITemplateSummary: Decodable, Identifiable, Sendable {
let name: String
let description: String
let frequency: String
let isActive: Bool
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -226,8 +227,10 @@ struct APITemplateSummary: Decodable, Identifiable, Sendable {
name = try c.decode(String.self, forKey: .name)
description = try c.decode(String.self, forKey: .description)
frequency = try c.decode(String.self, forKey: .frequency)
// Default true backwards compatible if server omits the field
isActive = (try? c.decode(Bool.self, forKey: .isActive)) ?? true
}
private enum CodingKeys: String, CodingKey { case id, name, description, frequency }
private enum CodingKeys: String, CodingKey { case id, name, description, frequency, isActive }
}
struct TemplatesResponseData: Decodable, Sendable {
+15 -2
View File
@@ -2,7 +2,7 @@
> **Audience:** AI assistants and developers working on the JanitorialQC iPad app.
> **Purpose:** Authoritative reference for architecture, conventions, constraints, and decisions.
> **Last reviewed:** May 2026 (Phase 19 complete — server selection, standalone issue creation, multi-photo evidence, facility deduplication, RetryablePhotoView, photo URL /static/ prefix)
> **Last reviewed:** June 2026 (Phase 19 complete — server selection, standalone issue creation, multi-photo evidence, facility deduplication, RetryablePhotoView, photo URL /static/ prefix + Phase 25 GPS capture at submit time + active-template filter + area relationship fix + read-only inspection detail view)
> **Companion:** See the web backend's `CLAUDE.md` for API contract, server-side rules, and migration chain.
---
@@ -192,7 +192,7 @@ LocalInspection.self, LocalIssue.self, PendingPhoto.self, SyncQueueEntry.self
| `LocalFacility` | Read-only cached facility reference | `serverId` (`@Attribute(.unique)`), `name`, `projectId`, `projectName`, `areas` (cascade) |
| `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` |
| `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) |
| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId` |
| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `submitLatitude` (Double?), `submitLongitude` (Double?) |
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON` |
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
@@ -366,6 +366,10 @@ Contract → Facility cascade pickers (same as `StandaloneIssueView`). `onChange
- Submit: computes score, sets `status = "completed"`, `syncStatus = "pending"`, calls `clearParentFollowUpFlag()`, triggers sync.
- Shows success banner 2.5 seconds then dismisses.
**GPS capture (Phase 25):** `InspectionLocationManager` (thin `CLLocationManager` wrapper defined at the bottom of `ExecuteInspectionView.swift`) begins acquiring a fix the moment the submit confirm dialog appears. On successful fix, `inspection.submitLatitude` and `inspection.submitLongitude` are set before the inspection is marked completed. GPS is best-effort — nil on permission denial or location failure; submission still proceeds normally.
`APIClient.submitInspection` sends `submit_latitude` / `submit_longitude` only when non-nil. The `PATCH` endpoint does not accept GPS fields — creation-time (POST) capture only. The server displays a Google Maps embed in `inspections/view.html` for admin/director when both fields are present.
### 3. Draft management
Swipe-left delete (confirmation required). Deletes draft + `PendingPhoto` records + local photo files + associated `LocalIssue` records. Only `status == "draft"` inspections may be deleted.
@@ -640,6 +644,15 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
| 50 | **`NotificationsView.markNotificationsViewed()` on both `.onAppear` and sidebar tap** | Sidebar tap calls `sync.markNotificationsViewed()` inline to clear the badge immediately without waiting for navigation. |
| 51 | **`FlagIssueView.submitIssue()` must copy `inspection.areaServerId` to `issue.areaServerId`** | Without this, the server cannot link the issue to the correct area. `APIClient.submitIssue` sends `area_id` only when `issue.areaServerId` is non-nil. |
| 52 | **`LocalIssue.swift` zip delivery must include all Phase AE fields** | When packaging, copy from the working-tree file and verify every field with `grep` before zipping. Partial field sets cause `SyncManager` build failures. The recurring hotfix pattern traces to this: each phase patched the working tree but the prior phases file was not in working tree. Fix: always `cp` back immediately after creating a phase file. |
| 53 | **`submitLatitude`/`submitLongitude` on `LocalInspection` are `Double?` optionals** | SwiftData lightweight migration supports nil-default optionals without a migration plan. `APIClient.submitInspection` sends them only when non-nil via `if let lat = inspection.submitLatitude`. Never make them non-optional — GPS is best-effort and must not block submission on permission denial or hardware failure. |
| 54 | **`InspectionLocationManager.startUpdating()` called when confirm dialog appears, not at view load** | Starting too early wastes battery. The confirm dialog provides a natural ~12 second window before the user taps Confirm, giving the manager time to acquire a fix. GPS captured into `LocalInspection` immediately before marking `status = "completed"`. |
| 55 | **`StartInspectionView` must filter templates in Swift, not via `@Query` predicate** | `@Query(sort: \LocalTemplate.name)` fetches all into `allTemplates`; computed `var templates` filters `{ $0.isActive }`. `#Predicate` with `isActive` is unreliable under Xcode 26 rule 3. |
| 56 | **`LocalTemplate.isActive` must have inline default `= true`** | Added in this session. SwiftData lightweight migration requires all new `Bool` fields to carry an inline default (rule 8). `GET /api/v1/templates` now only returns active templates; `pullReferenceData` deletes cached templates not in the server response so inactive ones never appear in pickers even offline. |
| 57 | **`upsertAreas` must set `newArea.facility = facility` at insert time** | SwiftData relationship wiring requires the inverse to be explicitly assigned. Without `newArea.facility = facility`, `LocalFacility.areas` is always empty and the area picker shows nothing. The `LocalFacility` object (from `facilityMap` or just inserted) is passed into `upsertAreas` as a parameter. Also re-wires on update: `if ex.facility == nil { ex.facility = facility }`. |
| 58 | **`ReadOnlyGridFormView` uses `rowView` (GeometryReader + ZStack), NOT a ZStack canvas or LazyVGrid** | ZStack canvas: gaps from unanswered rows because y-offsets are absolute. LazyVGrid: ignores `col` position, flows items sequentially. Correct approach: group fields by original `row` into `RowGroup`s, render each group as a `GeometryReader` that divides width by 12 to get `colW`, positions each field with `.offset(x: colW * (col-1))` and `.frame(width: colW * colSpan)`. `VStack(spacing: 3)` between rows. Row height fixed at 36pt (section headers 28pt). |
| 59 | **Read-only inspection detail: only answered fields are shown — filtering is 5-pass** | Pass 1: collect `answeredIds` (rating > 0, or non-empty value). Pass 2: collect `visibleLabelIds` (labels immediately before an answered field). Pass 3: collect `visibleSectionIds` (sections with at least one answered field after them). Pass 4: group all schema fields by original `row`. Pass 5: for each row group, emit only visible fields; skip rows with no visible content. |
| 60 | **`ReadOnlyGridFormView` rows advance by 1 regardless of original `rowSpan`** | The web renders every field with `grid-row: N / span 1`. The read-only view collapses all rowSpans to 1 — no field occupies more than one row of vertical space. |
| 61 | **`PhotoThumbnailView` owns `@State private var showLightbox`** | `ReadOnlyCellView.valueView` is a computed `@ViewBuilder` — it cannot hold `@State`. The `image` case delegates to `PhotoThumbnailView` (a separate struct) which holds its own sheet state. Thumbnail is 32×32pt; lightbox is a full-screen black sheet dismissed by tap. |
---
+3
View File
@@ -14,6 +14,7 @@ final class LocalTemplate {
var frequency: String
var formSchemaJSON: String
var lastSyncedAt: Date
var isActive: Bool = true // phase21 false templates excluded from picker
init(from summary: APITemplateSummary) {
self.serverId = summary.id
@@ -22,12 +23,14 @@ final class LocalTemplate {
self.frequency = summary.frequency
self.formSchemaJSON = "[]"
self.lastSyncedAt = Date()
self.isActive = summary.isActive
}
func updateSummary(from summary: APITemplateSummary) {
self.name = summary.name
self.templateDescription = summary.description
self.frequency = summary.frequency
self.isActive = summary.isActive
self.lastSyncedAt = Date()
}
+30 -5
View File
@@ -418,12 +418,16 @@ class SyncManager: ObservableObject {
}
for apiFacility in uniqueFacilities {
let localFacility: LocalFacility
if let existing = facilityMap[apiFacility.id] {
existing.update(from: apiFacility)
localFacility = existing
} else {
context.insert(LocalFacility(from: apiFacility))
let newFacility = LocalFacility(from: apiFacility)
context.insert(newFacility)
localFacility = newFacility
}
try await upsertAreas(for: apiFacility.id, context: context)
try await upsertAreas(for: apiFacility.id, facility: localFacility, context: context)
}
let existingTemplates = try context.fetch(FetchDescriptor<LocalTemplate>())
@@ -443,6 +447,17 @@ class SyncManager: ObservableObject {
)
}
// Delete any cached templates the server no longer returns.
// The server endpoint now only returns active templates, so any
// locally cached template not in the response was deactivated.
// Deleting ensures they never appear in the picker even offline.
let returnedTemplateIds = Set(templatesData.templates.map { $0.id })
for existing in existingTemplates {
if !returnedTemplateIds.contains(existing.serverId) {
context.delete(existing)
}
}
try context.save()
} catch APIError.notAuthenticated {
@@ -464,7 +479,7 @@ class SyncManager: ObservableObject {
// Private Helpers
private func upsertAreas(for facilityId: Int, context: ModelContext) async throws {
private func upsertAreas(for facilityId: Int, facility: LocalFacility, context: ModelContext) async throws {
let areasData: AreasResponseData = try await APIClient.shared.request(
"/api/v1/facilities/\(facilityId)/areas"
)
@@ -473,8 +488,18 @@ class SyncManager: ObservableObject {
let areaMap = Dictionary(existing.map { ($0.serverId, $0) },
uniquingKeysWith: { a, _ in a })
for apiArea in areasData.areas {
if let ex = areaMap[apiArea.id] { ex.update(from: apiArea) }
else { context.insert(LocalArea(from: apiArea)) }
if let ex = areaMap[apiArea.id] {
ex.update(from: apiArea)
// Re-wire relationship in case it was lost (e.g. cache clear)
if ex.facility == nil { ex.facility = facility }
} else {
let newArea = LocalArea(from: apiArea)
// Wire the inverse relationship so LocalFacility.areas is populated.
// Without this assignment SwiftData never links the area into the
// facility's areas array and selectedFacility?.areas returns [].
newArea.facility = facility
context.insert(newArea)
}
}
}
@@ -13,9 +13,15 @@ struct StartInspectionView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
@Query(sort: \LocalTemplate.name) private var allTemplates: [LocalTemplate]
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
/// Active templates only inactive templates excluded from the picker.
/// Filtered in Swift (not #Predicate) per CLAUDE.md rule 3.
private var templates: [LocalTemplate] {
allTemplates.filter { $0.isActive }
}
@State private var selectedTemplateId: Int?
@State private var selectedProjectId: Int?
@State private var selectedFacilityId: Int?
@@ -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
}
private var visibleRowGroups: [RowGroup] {
let skipTypes: Set<String> = ["label", "section",
"button_submit", "button_print", "button_email"]
// 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) }
}
// 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()
}
}
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)
// 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 }
}
}
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 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)
}
return CGFloat(maxRow) * cellH + CGFloat(max(maxRow - 1, 0)) * Self.rowGap
// 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))
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)
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))
}
.onPreferenceChange(ReadOnlyWidthKey.self) { if $0 > 0 { containerWidth = $0 } }
.frame(height: canvasHeight() + 2 * Self.cardPadding)
}
// 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 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)
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 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) ?? ""
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 = field["type"] as? String ?? "text"
let label = field["label"] as? String ?? ""
let ftype = f["type"] as? String ?? "text"
let label = f["label"] as? String ?? ""
ReadOnlyCellView(field: field, value: value, fieldType: ftype, label: label)
.frame(width: width, height: height, alignment: .topLeading)
.offset(x: xOff, y: yOff)
ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label)
.frame(width: colW * CGFloat(colSpan), alignment: .topLeading)
.offset(x: colW * CGFloat(col - 1))
}
}
}
.frame(height: rowHeight(group))
}
// 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)
}
}