Jul 29 - Update codes to support iPhone
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 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) |
|
| 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`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user