Jul 29 - Update codes to support iPhone

This commit is contained in:
Nguyen Ngo
2026-07-29 15:51:08 -04:00
parent 9926739cb6
commit b7990cc9ba
5 changed files with 355 additions and 178 deletions
+2 -2
View File
@@ -431,7 +431,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.7; MARKETING_VERSION = 1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -474,7 +474,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.7; MARKETING_VERSION = 1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
+5 -1
View File
@@ -62,7 +62,7 @@ Core capabilities:
| Layer | Technology | | Layer | Technology |
|---|---| |---|---|
| Language | Swift 5.10+ | | 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 7677. 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) | | Local storage | SwiftData (iOS 17+ required) |
| Networking | URLSession async/await | | Networking | URLSession async/await |
| Connectivity detection | NWPathMonitor (Network.framework) | | 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.** **`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`. **`+` 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 <built .app>/Info.plist \| grep -A2 BGTask` must show an array. | | 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 <built .app>/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`. | | 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. | | 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`. |
--- ---
+197 -133
View File
@@ -34,6 +34,7 @@ struct DashboardView: View {
@EnvironmentObject private var sync: SyncManager @EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var hSizeClass
@Query( @Query(
filter: #Predicate<LocalInspection> { $0.status != "synced" }, filter: #Predicate<LocalInspection> { $0.status != "synced" },
@@ -71,141 +72,26 @@ struct DashboardView: View {
selectedTab = tab 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 { var body: some View {
NavigationSplitView { Group {
List { // On compact width (iPhone) a NavigationSplitView collapses to show
// Dashboard // ONLY the sidebar: its `detail:` column is never presented, because
Button { selectTab(.dashboard) } label: { // nothing pushes it. The rows here are plain Buttons driving @State
Label("Dashboard", systemImage: "chart.bar.xaxis") // (rule 2 forbids a `selection:` binding), and a state change alone
.foregroundStyle(selectedTab == .dashboard ? .blue : .primary) // cannot push the detail column so every destination was
} // unreachable on iPhone. Compact width therefore gets a real
.listRowBackground(selectedTab == .dashboard ? Color.blue.opacity(0.1) : Color.clear) // NavigationStack whose rows are NavigationLinks.
if hSizeClass == .compact {
// My Inspections compactBody
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 { } else {
CompletedInspectionView(inspection: inspection) regularBody
}
}
}
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() }
} }
} }
.task { .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 { private var syncStatusFooter: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Divider() Divider()
@@ -460,6 +521,9 @@ struct DashboardStatsView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .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) Text(value)
.font(.system(size: 32, weight: .bold, design: .rounded)) .font(.system(size: 32, weight: .bold, design: .rounded))
@@ -870,6 +870,25 @@ struct GridFormView: View {
static let cellAspect: CGFloat = 52/72 // cellH / cellW matches editor CELL_H/CELL_W static let cellAspect: CGFloat = 52/72 // cellH / cellW matches editor CELL_H/CELL_W
static let cardPadding: CGFloat = 16 // card inset on all sides 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 // Minimum cell height (points) per field type ensures 44pt touch targets
// on iPad even when the template author assigned a very short rowSpan. // on iPad even when the template author assigned a very short rowSpan.
static let minCellH: [String: CGFloat] = [ static let minCellH: [String: CGFloat] = [
@@ -894,32 +913,31 @@ struct GridFormView: View {
@State private var containerWidth: CGFloat = 0 @State private var containerWidth: CGFloat = 0
var body: some View { var body: some View {
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) { ZStack(alignment: .topLeading) {
// Card background
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground)) .fill(Color(.secondarySystemBackground))
// Width probe zero-size overlay, reports container width // Field overlays only rendered after width is measured.
// Using a background Color.clear with a GeometryReader that sends // containerWidth == 0 means the PreferenceKey has not fired
// its width via PreferenceKey is the idiomatic SwiftUI pattern that // yet (first layout pass). Skipping the overlay pass on the
// works correctly inside ScrollView on all iOS versions. // zero frame prevents fields from being positioned using a
Color.clear // stale width and overflowing the modal on narrow sheet
.frame(maxWidth: .infinity) // presentations (iPad 10th gen).
.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 { if containerWidth > 0 {
let cellW = computedCellW let cellW = computedCellW
let cellH = cellW * Self.cellAspect let cellH = cellW * Self.cellAspect
@@ -933,15 +951,77 @@ struct GridFormView: View {
} }
} }
} }
// 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 .onPreferenceChange(WidthPreferenceKey.self) { width in
if width > 0 { containerWidth = width } 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 // Compact (narrow) layout
// value (it uses computedCellW which returns 0 when containerWidth is 0), // One field per line, full width, natural height. Fields are ordered by
// so the card reserves space and avoids a layout jump. // (row, col) because the form editor stores them in drag/creation order,
.frame(height: containerWidth > 0 ? canvasHeight() + 2 * Self.cardPadding : 0) // 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 // Derived cell width from current containerWidth
@@ -668,6 +668,8 @@ struct ReadOnlyGridFormView: View {
let schema: [[String: Any]] let schema: [[String: Any]]
let formValues: [String: String] let formValues: [String: String]
@Environment(\.horizontalSizeClass) private var hSizeClass
// Field visibility filtering // Field visibility filtering
// A row group: all visible fields that share the same original `row`. // A row group: all visible fields that share the same original `row`.
@@ -767,11 +769,15 @@ struct ReadOnlyGridFormView: View {
.background(Color(.secondarySystemBackground)) .background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
} else { } else {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: hSizeClass == .compact ? 10 : 3) {
ForEach(visibleRowGroups.indices, id: \.self) { idx in ForEach(visibleRowGroups.indices, id: \.self) { idx in
if hSizeClass == .compact {
stackedRowView(visibleRowGroups[idx])
} else {
rowView(visibleRowGroups[idx]) rowView(visibleRowGroups[idx])
} }
} }
}
.padding(12) .padding(12)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground)) .background(Color(.secondarySystemBackground))
@@ -782,6 +788,11 @@ struct ReadOnlyGridFormView: View {
// Render one row as a GeometryReader-based HStack so each field // Render one row as a GeometryReader-based HStack so each field
// occupies exactly (colSpan/12) of the available width, and leading // occupies exactly (colSpan/12) of the available width, and leading
// space before col > 1 is filled with a transparent spacer. // 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 @ViewBuilder
private func rowView(_ group: RowGroup) -> some View { private func rowView(_ group: RowGroup) -> some View {
GeometryReader { geo in GeometryReader { geo in
@@ -808,6 +819,24 @@ struct ReadOnlyGridFormView: View {
.frame(height: rowHeight(group)) .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. // Row height: fixed 36pt for most fields; taller for section headers.
private func rowHeight(_ group: RowGroup) -> CGFloat { private func rowHeight(_ group: RowGroup) -> CGFloat {
let hasSection = group.fields.contains { ($0["type"] as? String) == "section" } let hasSection = group.fields.contains { ($0["type"] as? String) == "section" }