diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index e51bab4..3030da1 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -431,7 +431,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 1.8; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -474,7 +474,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7; + MARKETING_VERSION = 1.8; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index fd2b239..41491b7 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -62,7 +62,7 @@ Core capabilities: | Layer | Technology | |---|---| | Language | Swift 5.10+ | -| UI | SwiftUI (iPad-only, all four orientations) | +| UI | SwiftUI. iPad is the primary target; iPhone (compact width) is supported — see rules 76–77. All four orientations. `TARGETED_DEVICE_FAMILY = "1,2"`, so the app installs on iPhone and must stay usable there | | Local storage | SwiftData (iOS 17+ required) | | Networking | URLSession async/await | | Connectivity detection | NWPathMonitor (Network.framework) | @@ -359,6 +359,8 @@ JanitorialQCApp **`NavigationSplitView` constraint:** `init(selection:content:)` unavailable on iPadOS 17. Use `@State var selectedTab: SidebarTab` with `Button` handlers. **Never add a `selection:` binding.** +**Compact width takes a different tree entirely.** That Button-driven sidebar cannot navigate once the split view collapses (rule 76), so `DashboardView.body` branches on `horizontalSizeClass`: `regularBody` is the `NavigationSplitView` above, `compactBody` is a `NavigationStack` whose rows are `NavigationLink`s. Both share `sidebarRowLabel(_:tinted:)` and `detailRoot(for:)` — the latter returns destination content *without* a `NavigationStack` wrapper so the call site can supply one (iPad) or push it (iPhone). Add new destinations to `sidebarTabs` + both helpers, never to one body only. + **`+` button placement:** The new inspection `+` button lives on `MyInspectionsView` (not the sidebar) so it remains accessible when the sidebar is collapsed. The new issue `+` button lives on `IssuesListView`. --- @@ -702,6 +704,8 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record | 73 | **Both background-sync Info.plist keys must live in `Info.plist` itself — `INFOPLIST_KEY_*` cannot express them** | `BGTaskSchedulerPermittedIdentifiers` and `UIBackgroundModes` are both **arrays**. `INFOPLIST_KEY_*` build settings only merge Xcode's recognised key list and only as **strings**, so `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync` never produced a valid entry — it sat inert in the pbxproj while `BGTaskScheduler.submit()` failed with `.notPermitted` under a `try?`. Adding `UIBackgroundModes` then made App Store Connect check, and the upload was rejected with **error 90771**. Both keys now live in `JanitorialQC/Info.plist` as arrays and the build setting is deleted from both configurations. Do not reintroduce it: a build setting overwrites the file's value at merge time. Keep the identifier string in sync with `BGTaskScheduler.register` / `BGProcessingTaskRequest` in `JanitorialQCApp`. Verify a build before uploading: `plutil -p /Info.plist \| grep -A2 BGTask` must show an array. | | 74 | **Two different background mechanisms — do not confuse them** | `BGProcessingTask` (JanitorialQCApp) asks iOS to **wake us later**: opportunistic, typically charging + Wi-Fi + idle, not a heartbeat. `beginBackgroundTask` (`SyncManager.beginSyncBackgroundTask`) asks iOS **not to suspend us right now**: ~30 s, covers submit-then-lock. The expiration handler must end the assertion or iOS terminates the app, and it needs `MainActor.assumeIsolated` because the closure is nonisolated under `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. | | 75 | **A background launch has no ContentView and may have no ModelContainer** | `restoreSession()` runs from `ContentView.task{}` and the container comes from the `.modelContainer` scene modifier — neither happens on a cold BGTaskScheduler launch, so `triggerSync()`'s `isAuthenticated` / `modelContext` guards silently no-op. `handleBackgroundSync()` now restores the session itself and logs-and-returns when there is no context. Background sync therefore covers the *suspended-but-resident* case; cold relaunch needs the container hoisted out of the scene modifier. | +| 76 | **A collapsed `NavigationSplitView` shows only its sidebar — Button-driven rows navigate nowhere on iPhone** | On compact width the split view collapses to a stack rooted at the sidebar, and the `detail:` column is presented only when something *pushes* it. Rule 2 forbids a `selection:` binding, so the rows are plain `Button`s that mutate `@State` — and a state change alone cannot push the detail column. The app installs on iPhone (`TARGETED_DEVICE_FAMILY = "1,2"`), so every inspector on a phone got a list where tapping highlighted the row and opened nothing: Dashboard, Inspections, Issues, Settings were all unreachable. Verified in the simulator: setting `selectedTab` programmatically still rendered only the sidebar. `DashboardView` now branches on `horizontalSizeClass` and gives compact width a real `NavigationStack` with `NavigationLink` rows. Never "fix" this by adding a `selection:` binding — that breaks iPadOS 17 (rule 2). | +| 77 | **The 12-column form grid is unusable below ~600 pt — reflow to one field per line, don't shrink it** | `GridFormView` positions cells absolutely from `cellW = (W - 32 - 88) / 12`. At 375 pt (iPhone SE/6/7/8) that is a 21 pt column and a 15 pt row, so an ordinary 6x2 field renders ~167x35 pt — less than the label needs. Cells are deliberately unclipped (matching the web's `overflow: visible`), so the excess draws *on top of* the row below and the form becomes an unreadable pile of overlapping controls. Below `GridFormView.minGridWidth` (600 pt, keeping a column at >=40 pt) the view switches to `stackedLayout`: fields sorted by `(row, col)` (rule 62), one per line, full width, natural height. Widgets with no intrinsic height (`textarea`, `signature`, `table`, `image`) get floors from `stackedMinH` or they collapse to nothing. In the stacked branch the card must be a `.background` modifier, **not** a `ZStack` sibling — as a sibling the flexible `RoundedRectangle` competes with the `VStack` for the container's size and the card ends up shorter than its own content, cutting off the last fields. `ReadOnlyGridFormView` (history detail) has the same 12-column assumption and the same compact branch, keyed off `horizontalSizeClass`. | --- diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 9eb8d04..ef16b1d 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -34,6 +34,7 @@ struct DashboardView: View { @EnvironmentObject private var sync: SyncManager @Environment(\.modelContext) private var context @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var hSizeClass @Query( filter: #Predicate { $0.status != "synced" }, @@ -71,141 +72,26 @@ struct DashboardView: View { selectedTab = tab } + /// Sidebar order — single source of truth for both the regular-width + /// sidebar and the compact-width root list. + private let sidebarTabs: [SidebarTab] = [ + .dashboard, .myInspections, .issues, .facilities, + .pendingSync, .history, .notifications, .settings, + ] + var body: some View { - NavigationSplitView { - List { - // ── Dashboard ────────────────────────────────────────────── - Button { selectTab(.dashboard) } label: { - Label("Dashboard", systemImage: "chart.bar.xaxis") - .foregroundStyle(selectedTab == .dashboard ? .blue : .primary) - } - .listRowBackground(selectedTab == .dashboard ? Color.blue.opacity(0.1) : Color.clear) - - // ── My Inspections ───────────────────────────────────────── - Button { selectTab(.myInspections) } label: { - HStack { - Label("My Inspections", systemImage: "checklist") - .foregroundStyle(selectedTab == .myInspections ? .blue : .primary) - Spacer() - if !myInspections.isEmpty { - Text("\(myInspections.count)") - .font(.caption2) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Color.blue.opacity(0.15)) - .clipShape(Capsule()) - } - } - } - .listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear) - - // ── Issues (all roles) ───────────────────────────────────── - Button { selectTab(.issues) } label: { - Label("Issues", systemImage: "exclamationmark.triangle") - .foregroundStyle(selectedTab == .issues ? .blue : .primary) - } - .listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear) - - // ── Facilities ───────────────────────────────────────────── - Button { selectTab(.facilities) } label: { - Label("Facilities", systemImage: "building.2") - .foregroundStyle(selectedTab == .facilities ? .blue : .primary) - } - .listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear) - - // ── Pending Sync ─────────────────────────────────────────── - Button { selectTab(.pendingSync) } label: { - HStack { - Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") - .foregroundStyle(selectedTab == .pendingSync ? .blue : .primary) - Spacer() - if sync.pendingCount > 0 { - Text("\(sync.pendingCount)") - .font(.caption2) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Color.orange.opacity(0.2)) - .foregroundStyle(.orange) - .clipShape(Capsule()) - } - } - } - .listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear) - - // ── History (moved — sits between Pending Sync and Settings) - Button { selectTab(.history) } label: { - Label("History", systemImage: "clock.arrow.circlepath") - .foregroundStyle(selectedTab == .history ? .blue : .primary) - } - .listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear) - - // ── Notifications ───────────────────────────────────────── - Button { - selectTab(.notifications) - sync.markNotificationsViewed() - } label: { - HStack { - Label("Notifications", systemImage: "bell") - .foregroundStyle(selectedTab == .notifications ? .blue : .primary) - Spacer() - if sync.unreadNotificationCount > 0 { - Text("\(min(sync.unreadNotificationCount, 99))") - .font(.caption2) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Color.red.opacity(0.85)) - .foregroundStyle(.white) - .clipShape(Capsule()) - } - } - } - .listRowBackground(selectedTab == .notifications ? Color.blue.opacity(0.1) : Color.clear) - - // ── Settings ─────────────────────────────────────────────── - Button { selectTab(.settings) } label: { - Label("Settings", systemImage: "gear") - .foregroundStyle(selectedTab == .settings ? .blue : .primary) - } - .listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear) - } - .navigationTitle("JQC Inspector") - .listStyle(.sidebar) - .safeAreaInset(edge: .bottom) { syncStatusFooter } - - } detail: { - switch selectedTab { - case .dashboard: - NavigationStack { DashboardStatsView() } - case .myInspections: - NavigationStack(path: $inspectionsPath) { - MyInspectionsView() - .navigationDestination(for: LocalInspection.self) { inspection in - if inspection.status == "draft" { - ExecuteInspectionView(inspection: inspection) - } else { - CompletedInspectionView(inspection: inspection) - } - } - } - case .issues: - NavigationStack(path: $issuesPath) { - IssuesListView() - .navigationDestination(for: LocalIssue.self) { issue in - IssueDetailView(issue: issue) - } - } - case .facilities: - NavigationStack { FacilitiesListView() } - case .pendingSync: - NavigationStack { SyncStatusView() } - case .history: - NavigationStack(path: $historyPath) { - InspectionHistoryView() - .navigationDestination(for: APIInspectionSummary.self) { inspection in - HistoryDetailView(inspection: inspection) - } - } - case .notifications: - NavigationStack { NotificationsView() } - case .settings: - NavigationStack { SettingsView() } + Group { + // On compact width (iPhone) a NavigationSplitView collapses to show + // ONLY the sidebar: its `detail:` column is never presented, because + // nothing pushes it. The rows here are plain Buttons driving @State + // (rule 2 forbids a `selection:` binding), and a state change alone + // cannot push the detail column — so every destination was + // unreachable on iPhone. Compact width therefore gets a real + // NavigationStack whose rows are NavigationLinks. + if hSizeClass == .compact { + compactBody + } else { + regularBody } } .task { @@ -223,6 +109,181 @@ struct DashboardView: View { } } + // ── Regular width (iPad) — unchanged two-column split view ──────────── + + private var regularBody: some View { + NavigationSplitView { + List { + ForEach(sidebarTabs, id: \.self) { tab in + Button { + selectTab(tab) + if tab == .notifications { sync.markNotificationsViewed() } + } label: { + sidebarRowLabel(tab, tinted: selectedTab == tab) + } + .listRowBackground( + selectedTab == tab ? Color.blue.opacity(0.1) : Color.clear + ) + } + } + .navigationTitle("JQC Inspector") + .listStyle(.sidebar) + .safeAreaInset(edge: .bottom) { syncStatusFooter } + + } detail: { + switch selectedTab { + case .myInspections: + NavigationStack(path: $inspectionsPath) { detailRoot(for: .myInspections) } + case .issues: + NavigationStack(path: $issuesPath) { detailRoot(for: .issues) } + case .history: + NavigationStack(path: $historyPath) { detailRoot(for: .history) } + default: + NavigationStack { detailRoot(for: selectedTab) } + } + } + } + + // ── Compact width (iPhone) — push-based stack ───────────────────────── + // One NavigationStack whose root is the same destination list. Rows are + // NavigationLinks so tapping actually pushes. The per-tab paths used by + // the iPad split view are not needed here: this single stack owns the + // whole hierarchy, and the nested `.navigationDestination`s declared in + // detailRoot(for:) register against it. + + private var compactBody: some View { + NavigationStack { + List { + ForEach(sidebarTabs, id: \.self) { tab in + NavigationLink(value: tab) { + sidebarRowLabel(tab, tinted: false) + } + } + } + .navigationTitle("JQC Inspector") + .navigationDestination(for: SidebarTab.self) { detailRoot(for: $0) } + .safeAreaInset(edge: .bottom) { syncStatusFooter } + } + } + + // ── Shared row label ────────────────────────────────────────────────── + + @ViewBuilder + private func sidebarRowLabel(_ tab: SidebarTab, tinted: Bool) -> some View { + let tint: Color = tinted ? .blue : .primary + switch tab { + case .dashboard: + Label("Dashboard", systemImage: "chart.bar.xaxis") + .foregroundStyle(tint) + + case .myInspections: + HStack { + Label("My Inspections", systemImage: "checklist") + .foregroundStyle(tint) + Spacer() + if !myInspections.isEmpty { + Text("\(myInspections.count)") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.blue.opacity(0.15)) + .clipShape(Capsule()) + } + } + + case .issues: + Label("Issues", systemImage: "exclamationmark.triangle") + .foregroundStyle(tint) + + case .facilities: + Label("Facilities", systemImage: "building.2") + .foregroundStyle(tint) + + case .pendingSync: + HStack { + Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") + .foregroundStyle(tint) + Spacer() + if sync.pendingCount > 0 { + Text("\(sync.pendingCount)") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.orange.opacity(0.2)) + .foregroundStyle(.orange) + .clipShape(Capsule()) + } + } + + case .history: + Label("History", systemImage: "clock.arrow.circlepath") + .foregroundStyle(tint) + + case .notifications: + HStack { + Label("Notifications", systemImage: "bell") + .foregroundStyle(tint) + Spacer() + if sync.unreadNotificationCount > 0 { + Text("\(min(sync.unreadNotificationCount, 99))") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.red.opacity(0.85)) + .foregroundStyle(.white) + .clipShape(Capsule()) + } + } + + case .settings: + Label("Settings", systemImage: "gear") + .foregroundStyle(tint) + } + } + + // ── Shared destination root ─────────────────────────────────────────── + // The NavigationStack wrapper lives at the call site, so the same content + // serves as a split-view detail root (iPad) and a pushed view (iPhone). + + @ViewBuilder + private func detailRoot(for tab: SidebarTab) -> some View { + switch tab { + case .dashboard: + DashboardStatsView() + + case .myInspections: + MyInspectionsView() + .navigationDestination(for: LocalInspection.self) { inspection in + if inspection.status == "draft" { + ExecuteInspectionView(inspection: inspection) + } else { + CompletedInspectionView(inspection: inspection) + } + } + + case .issues: + IssuesListView() + .navigationDestination(for: LocalIssue.self) { issue in + IssueDetailView(issue: issue) + } + + case .facilities: + FacilitiesListView() + + case .pendingSync: + SyncStatusView() + + case .history: + InspectionHistoryView() + .navigationDestination(for: APIInspectionSummary.self) { inspection in + HistoryDetailView(inspection: inspection) + } + + case .notifications: + NotificationsView() + + case .settings: + SettingsView() + } + } + private var syncStatusFooter: some View { VStack(spacing: 0) { Divider() @@ -460,6 +521,9 @@ struct DashboardStatsView: View { .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) + // Tiles are 2-up, so on a phone each is ~170 pt wide and + // longer labels ("Open / In Progress") would truncate. + .minimumScaleFactor(0.75) } Text(value) .font(.system(size: 32, weight: .bold, design: .rounded)) diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 2d2dd7a..be25f0e 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -870,6 +870,25 @@ struct GridFormView: View { static let cellAspect: CGFloat = 52/72 // cellH / cellW — matches editor CELL_H/CELL_W static let cardPadding: CGFloat = 16 // card inset on all sides + // Below this container width the 12-column grid stops being usable: at + // 375 pt (iPhone SE/6/7/8) a column is only ~21 pt wide and a row ~15 pt + // tall, so a normal 6x2 field renders ~167x35 pt — the label alone eats + // most of it. Cells are absolutely positioned and deliberately unclipped + // (see body), so the overflow draws on top of the row beneath and the + // form becomes an unreadable pile. Under this width we reflow to one + // field per line instead. 600 pt keeps a column at >=40 pt. + static let minGridWidth: CGFloat = 600 + + // Heights for widgets that have no intrinsic size of their own. In the + // absolute grid these are driven by rowSpan; in the stacked layout there + // is no rowSpan to read, so they would otherwise collapse to nothing. + static let stackedMinH: [String: CGFloat] = [ + "textarea": 96, + "signature": 120, + "table": 120, + "image": 88, + ] + // Minimum cell height (points) per field type — ensures 44pt touch targets // on iPad even when the template author assigned a very short rowSpan. static let minCellH: [String: CGFloat] = [ @@ -894,54 +913,115 @@ struct GridFormView: View { @State private var containerWidth: CGFloat = 0 var body: some View { - ZStack(alignment: .topLeading) { - // ── Card background ──────────────────────────────────────────── - RoundedRectangle(cornerRadius: 12) - .fill(Color(.secondarySystemBackground)) + Group { + if containerWidth > 0 && isCompact { + // ── Compact: content drives the height ───────────────────── + // The card is a .background modifier rather than a ZStack + // sibling so it takes its size FROM the stack. As a ZStack + // sibling the flexible RoundedRectangle competes with the + // VStack for the container's size and the card ends up + // shorter than its own content, cutting off the last fields. + stackedLayout + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.secondarySystemBackground)) + ) + } else { + // ── Regular: absolute 12-column canvas ───────────────────── + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 12) + .fill(Color(.secondarySystemBackground)) - // ── Width probe — zero-size overlay, reports container width ─── - // Using a background Color.clear with a GeometryReader that sends - // its width via PreferenceKey is the idiomatic SwiftUI pattern that - // works correctly inside ScrollView on all iOS versions. - Color.clear - .frame(maxWidth: .infinity) - .frame(height: 0) - .background( - GeometryReader { geo in - Color.clear.preference( - key: WidthPreferenceKey.self, - value: geo.size.width - ) - } - ) + // Field overlays — only rendered after width is measured. + // containerWidth == 0 means the PreferenceKey has not fired + // yet (first layout pass). Skipping the overlay pass on the + // zero frame prevents fields from being positioned using a + // stale width and overflowing the modal on narrow sheet + // presentations (iPad 10th gen). + if containerWidth > 0 { + let cellW = computedCellW + let cellH = cellW * Self.cellAspect - // ── Field overlays — only rendered after width is measured ───── - // containerWidth == 0 means the PreferenceKey has not fired yet - // (first layout pass). Skipping the overlay pass on the zero frame - // prevents fields from being positioned using a stale width and - // overflowing the modal on narrow sheet presentations (iPad 10th gen). - if containerWidth > 0 { - 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) { - gridCell(field: field, cellW: cellW, cellH: cellH) + 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) { + gridCell(field: field, cellW: cellW, cellH: cellH) + } + } } } + // Height is derived from the same arithmetic as the cell + // offsets — the ScrollView measures this frame and can never + // be wrong. + .frame(height: containerWidth > 0 + ? canvasHeight() + 2 * Self.cardPadding + : 0) } } + // ── Width probe ─────────────────────────────────────────────────── + // Attached as a background so it reports the resolved container width + // without taking part in sizing the content itself. + .background( + GeometryReader { geo in + Color.clear.preference( + key: WidthPreferenceKey.self, + value: geo.size.width + ) + } + ) .onPreferenceChange(WidthPreferenceKey.self) { width in if width > 0 { containerWidth = width } } - // Height is always derived from the same arithmetic as cell offsets — - // the ScrollView measures this frame and can never be wrong. - // When containerWidth is 0, canvasHeight() still returns the correct - // value (it uses computedCellW which returns 0 when containerWidth is 0), - // so the card reserves space and avoids a layout jump. - .frame(height: containerWidth > 0 ? canvasHeight() + 2 * Self.cardPadding : 0) + } + + // ── Compact (narrow) layout ─────────────────────────────────────────── + // One field per line, full width, natural height. Fields are ordered by + // (row, col) because the form editor stores them in drag/creation order, + // not visual order — the same sort the PDF and read-only renderers use + // (rule 62). Nothing is absolutely positioned here, so nothing can + // overlap regardless of how narrow the screen gets. + + private var isCompact: Bool { containerWidth < Self.minGridWidth } + + private var orderedFields: [[String: Any]] { + schema + .filter { f in + let t = f["type"] as? String ?? "text" + return !["button_submit", "button_print", "button_email"].contains(t) + } + .sorted { + let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0 + if r0 != r1 { return r0 < r1 } + return ($0["col"] as? Int ?? 0) < ($1["col"] as? Int ?? 0) + } + } + + private var stackedLayout: some View { + let fields = orderedFields + return VStack(alignment: .leading, spacing: 14) { + ForEach(fields.indices, id: \.self) { idx in + let field = fields[idx] + let ftype = field["type"] as? String ?? "text" + let fid = fieldId(field) + + GridCellContentView( + field: field, + value: Binding( + get: { formValues[fid] ?? "" }, + set: { formValues[fid] = $0; onFieldChanged?() } + ), + onPhotoSelected: { path in onPhotoSelected?(path, field) } + ) + .frame( + maxWidth: .infinity, + minHeight: Self.stackedMinH[ftype] ?? 0, + alignment: .topLeading + ) + } + } + .padding(Self.cardPadding) + .frame(maxWidth: .infinity, alignment: .topLeading) } // ── Derived cell width from current containerWidth ──────────────────── diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index 0524470..b552a84 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -668,6 +668,8 @@ struct ReadOnlyGridFormView: View { let schema: [[String: Any]] let formValues: [String: String] + @Environment(\.horizontalSizeClass) private var hSizeClass + // ── Field visibility filtering ──────────────────────────────────────── // A row group: all visible fields that share the same original `row`. @@ -767,9 +769,13 @@ struct ReadOnlyGridFormView: View { .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } else { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: hSizeClass == .compact ? 10 : 3) { ForEach(visibleRowGroups.indices, id: \.self) { idx in - rowView(visibleRowGroups[idx]) + if hSizeClass == .compact { + stackedRowView(visibleRowGroups[idx]) + } else { + rowView(visibleRowGroups[idx]) + } } } .padding(12) @@ -782,6 +788,11 @@ struct ReadOnlyGridFormView: View { // 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. + // + // On a narrow screen the same 12-column division that works on iPad + // leaves each field a few dozen points wide inside a fixed 36 pt row, so + // labels and values collide. Below `minGridWidth` each field gets its own + // full-width line at its natural height instead. @ViewBuilder private func rowView(_ group: RowGroup) -> some View { GeometryReader { geo in @@ -808,6 +819,24 @@ struct ReadOnlyGridFormView: View { .frame(height: rowHeight(group)) } + /// Stacked equivalent of `rowView` for narrow screens: no proportional + /// widths, no fixed row height, so nothing can overlap. + @ViewBuilder + private func stackedRowView(_ group: RowGroup) -> some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(group.fields.indices, id: \.self) { i in + let f = group.fields[i] + 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 ?? "" + + ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + } + // 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" }