06/18 Update Inspection details view layout
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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