diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index d102bce..1a22f1a 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -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 = ""; }; @@ -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; diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index fb5ab27..772d096 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -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 { diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index 6b5c3ce..2bf319e 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -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 A–E 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 phase’s 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 ~1–2 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. | --- diff --git a/JanitorialQC/Models/LocalTemplate.swift b/JanitorialQC/Models/LocalTemplate.swift index ca61fea..29b9f8a 100644 --- a/JanitorialQC/Models/LocalTemplate.swift +++ b/JanitorialQC/Models/LocalTemplate.swift @@ -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() } diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index b0fbc2f..9aa348f 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -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()) @@ -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) + } } } diff --git a/JanitorialQC/Views/Dashboard/StartInspectionView.swift b/JanitorialQC/Views/Dashboard/StartInspectionView.swift index e119c33..3e4c904 100644 --- a/JanitorialQC/Views/Dashboard/StartInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/StartInspectionView.swift @@ -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? diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index cb6b712..8fe8220 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -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 = ["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() + 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() + 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() + 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(@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) + } +}