**Critical:** This project uses `PBXFileSystemSynchronizedRootGroup` (Xcode 16+ folder sync). Xcode **automatically compiles every `.swift` file in the folder tree**. There is no explicit file list. A stray duplicate (e.g. a model file accidentally placed in the wrong folder) will cause "Multiple commands produce" build errors. Always verify file locations after any copy/paste operation.
---
## 4. App Entry Point & Lifecycle
**`JanitorialQCApp.swift`** is `@main`. It:
### JanitorialQCApp.swift
`@main` struct. Responsibilities:
1. Creates the SwiftData `ModelContainer` for all seven model types.
2.On container success: sets`SyncManager.shared.modelContext`, calls `AuthManager.shared.restoreSession()`, starts `NWPathMonitor`, and triggers an immediate sync if online.
3. Registers the `com.jqc.sync``BGProcessingTask` identifier — this **must** match the `BGTaskSchedulerPermittedIdentifiers` array in `Info.plist`.
2.In the container success callback: sets **only**`SyncManager.shared.modelContext`. Nothing else — no async calls, no session restore.
3. Registers the `com.jqc.sync``BGProcessingTask` identifier.
**`ContentView.swift`** is the auth gate. When `AuthManager.isAuthenticated` is `false` it shows `LoginView`; when `true` it shows `DashboardView`.
**Critical:** The `modelContainer` callback runs on a background thread. Do NOT call `restoreSession()` or `startMonitoring()` from inside this callback. Doing so causes a race condition where `NWPathMonitor` fires `triggerSync()` before auth tokens are loaded, producing a 401 loop that leaves `isLoading` stuck at `true` and the app frozen on the splash screen.
**Background transitions:**`DashboardView` observes `.scenePhase` and calls `scheduleBackgroundSync()` every time the app moves to `.background`.
### ContentView.swift
Auth gate and **startup lifecycle owner**. The `.task {}` modifier owns the startup sequence:
**The order is mandatory.**`startMonitoring()` must not be called before `restoreSession()` completes because NWPathMonitor fires immediately on network availability, triggering `triggerSync()` before tokens are in Keychain.
### Background transitions
`DashboardView` observes `.scenePhase` and calls `scheduleBackgroundSync()` every time the app moves to `.background`.
---
@@ -140,7 +163,7 @@ JanitorialQC/
| `currentUserId/Username/Role/DisplayName` | User identity persisted to Keychain |
**Session restore flow (`restoreSession`):**
1. If no access token in Keychain → unauthenticated immediately.
1. If no access token in Keychain → `isAuthenticated = false` immediately (no network call).
2. Calls `GET /api/v1/auth/me` to validate the stored token.
3. On `notAuthenticated` error → clears Keychain, sets unauthenticated.
4. On any other error (network timeout, server 500) → restores user identity from Keychain and sets authenticated. This allows offline launch.
@@ -211,7 +234,7 @@ The app follows the **outbox pattern**:
The `isAuthenticated` guard is critical. `NWPathMonitor` fires immediately on connectivity, including during app startup before `restoreSession()` completes. Without this guard, `triggerSync()` runs with no valid token, hits a 401, attempts token refresh, fails with `notAuthenticated`, and the error propagates up through the `.task{}` startup chain — leaving `isLoading` stuck at `true`.
### triggerSync() — processing order
1.`processPhotoQueue` — upload all `PendingPhoto` with `uploadStatus == "pending"`. On success, updates `serverPath` on the photo and propagates it to the parent `LocalInspection.formData` (for image fields) or `LocalIssue.photoServerPath`.
2.`processInspectionQueue` — for each completed inspection with `syncStatus == "pending"`, submits only when all `pendingPhotos` are `"uploaded"` or `"failed"`. After sync, clears `followUpRequired` on the parent if this is a re-inspection.
3.`processIssueQueue` — for each issue with `syncStatus == "pending"`, **skips and marks `"failed"` if the parent inspection has `syncStatus == "failed"`** (prevents orphaned server records). Sends `inspection_id` from `inspection.serverId`.
4.`pullReferenceData` — fetches all facilities, their areas, and all templates (with full schema). Uses sequential `await` calls (not `async let`) to avoid Swift 6 actor-isolation warnings on `Decodable` structs.
1.`processPhotoQueue` — upload all `PendingPhoto` with `uploadStatus == "pending"`. On success, propagates `serverPath` to the parent `LocalInspection.formData` (image fields) or `LocalIssue.photoServerPath`. Uses fetch-all + filter in Swift — no `#Predicate`.
2.`processInspectionQueue` — submits completed inspections only when all `pendingPhotos` are settled. Clears `followUpRequired` on parent after sync.
3.`processIssueQueue` — guards against submitting when parent inspection `syncStatus == "failed"` (prevents orphaned server records). Uses fetch-all + filter in Swift.
4.`pullReferenceData` — fetches facilities, areas, and templates. Sequential `await` calls — not `async let`.
### Fetch pattern — CRITICAL for Xcode 26
**Never use `#Predicate` anywhere in `SyncManager`.** Under Xcode 26 with `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, `#Predicate` with a captured `String` variable inside a `@MainActor` async function causes `LocalInspection is ambiguous for type lookup` — a cascade of compiler errors.
- Each item increments `syncRetryCount` on every failure.
- At 5 retries: `syncStatus = "failed"`. The item stays in SwiftData but is never retried automatically.
-`syncError` is set to the last error for display; it is cleared at the start of each `triggerSync()` call.
### #Predicate string comparison rule
**Never use `#Predicate` with string literal comparisons across model type boundaries.** Fetch all + filter in Swift instead. This avoids a SwiftData macro type-inference bug that causes runtime crashes in some Xcode versions.
-`syncError` is cleared at the start of each `triggerSync()` call.
├── Sidebar (NavigationSplitView — no selection: binding)
@@ -377,7 +430,7 @@ Uses a 12-column absolute-position grid matching the web app's CSS grid exactly:
| `cellAspect` | 52/72 | Web editor `CELL_H/CELL_W` |
| `cardPadding` | 16 pt | Card inset |
Cell position is computed from `col`, `row`, `colSpan`, `rowSpan` attributes in the field schema. Container width is measured via a `PreferenceKey` pattern (zero-height overlay with `GeometryReader`) — this works correctly through`ScrollView` rotations and split-screen resizing.
Cell position is computed from `col`, `row`, `colSpan`, `rowSpan` attributes in the field schema. Container width is measured via a `PreferenceKey` pattern (zero-height overlay with `GeometryReader`) — works correctly through rotations and split-screen resizing.
### Supported field types
@@ -409,7 +462,7 @@ else if let n = field["id"] as? Int { fid = String(n) }
else{continue}
```
**Never use `Optional.map` on `field["id"]`** — it produces `"Optional(5)"` instead of `"5"`, causing all formData lookups to miss.
**Never use `Optional.map` on `field["id"]`** — it produces `"Optional(5)"` instead of `"5"`, causing all formData lookups to silently miss and all scores to return 0.
---
@@ -426,7 +479,7 @@ else { continue }
- If a photo was taken: creates a `PendingPhoto` with `entityType = "issue"`.
- Saves to SwiftData.
- Triggers `SyncManager.triggerSync()` if online.
5. Area picker is absent — facility is derived directly from the inspection context (mirrors web `flag_issue.html`).
5. Area picker is absent — facility is derived directly from the inspection context.
### Issue sync guard
@@ -455,7 +508,7 @@ Cleared at **three** points to ensure the badge disappears regardless of timing:
`clearParentFollowUpFlag()` resolution order:
1. Match by `parentLocalId` (UUID, always set if re-inspection was created in this session).
2. Fall back to `parentServerId` (set only after parent has synced).
3. Last resort: match by same `templateServerId + facilityServerId + followUpRequired == true` (for stale records created before `parentLocalId` was added). **This fallback is ambiguous when multiple inspections of the same template at the same facility are pending follow-up — document as a known edge case.**
3. Last resort: match by same `templateServerId + facilityServerId + followUpRequired == true`. **This fallback is ambiguous when multiple follow-ups are pending for the same template/facility combination.**
---
@@ -465,8 +518,6 @@ Cleared at **three** points to ensure the badge disappears regardless of timing:
Pagination: limit 30, offset-based. A "Load More" button appears when `inspections.count < total`. Pull-to-refresh resets to page 0.
`HistoryDetailView` shows follow-up badge, parent inspection link, score, and re-inspection option. The data is `APIInspectionSummary` — a server-side DTO, not a SwiftData model.
While a photo is pending upload, the form field value is set to `"local://<path>"`. Before `submitInspection` sends `formData` to the server, these values are replaced with `""` (empty string). A `local://` value that reaches the server would be stored as a malformed path.
While a photo is pending upload, the form field value is set to `"local://<path>"`. Before `submitInspection` sends `formData` to the server, these values are replaced with `""`. A `local://` value that reaches the server would be stored as a malformed path.
### Inspection submission gating
An inspection in `processInspectionQueue` is **not submitted** until all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"uploaded"` or `"failed"`. It simply `continue`s to the next cycle.
An inspection is **not submitted** until all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"failed"`. It simply `continue`s to the next sync cycle.
---
@@ -515,7 +566,7 @@ An inspection in `processInspectionQueue` is **not submitted** until all its `pe
Returns `nil` if no scoreable fields or all are unanswered.
**Field ID resolution:** Always use the explicit cast pattern (see §12 Field ID rule). `Optional.map` on `Any?` produces `"Optional(5)"`and causes all lookups to silently miss.
**Field ID resolution:** Always use the explicit cast pattern (see §12). `Optional.map` on `Any?` produces `"Optional(5)"`— all lookups miss, all scores return 0.
---
@@ -525,8 +576,9 @@ The app registers a `BGProcessingTask` with identifier `com.jqc.sync`.
**`Info.plist` requirement:** `BGTaskSchedulerPermittedIdentifiers` must contain `com.jqc.sync`. Without this entry, `BGTaskScheduler.shared.register` silently fails and background sync never fires.
**Note on `GENERATE_INFOPLIST_FILE`:** The project uses `GENERATE_INFOPLIST_FILE = YES`. Do NOT also have a physical `Info.plist` file on disk — having both causes "Multiple commands produce Info" build error. The file is generated at build time; there is no `Info.plist` in the source tree.
**Scheduling:**`scheduleBackgroundSync()` is called:
- On app init (via `registerBackgroundTasks`)
- Every time `scenePhase == .background`
- At the start of each background task handler (schedules the next run)
@@ -554,35 +606,85 @@ The app registers a `BGProcessingTask` with identifier `com.jqc.sync`.
|---|---|---|
| 1 | **`import Combine` required in files using `@Published`** | Swift 5.9+ does not auto-import Combine; `ObservableObject` without it causes build errors |
| 2 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17; use `@State var selectedTab: SidebarTab` with `Button` handlers |
| 3 | **`#Predicate` — fetch all + filter in Swift for string comparisons** | SwiftData macro type-inference bug with string literals across model type boundaries causes runtime crashes |
| 3 | **No `#Predicate` anywhere in `SyncManager`** | Under Xcode 26 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, `#Predicate` with a captured String variable causes `LocalInspection is ambiguous` compiler cascade. Use fetch-all + filter in Swift throughout |
| 4 | **Sequential `await` in `pullReferenceData()`** | `async let` causes Swift 6 actor-isolation warnings on `Decodable` structs; use sequential `await` calls |
| 5 | **PencilKit requires explicit framework linkage** | Add `PencilKit.framework` under Target → Frameworks, Libraries, and Embedded Content |
| 6 | **Free Apple ID provisioning expires every 7 days** | Rebuild with ⌘R while iPad is connected; SwiftData persists across reinstalls |
| 7 | **`kSecAttrAccessibleAfterFirstUnlock` for all Keychain items** | Tokens must be readable when the app is woken by BGTaskScheduler after reboot |
| 8 | **New `Bool` model fields require `= false` default** | SwiftData lightweight migration crashes on launch without a default value for new Bool properties |
| 9 | **Field IDs in formData are always String keys** | Server encodes them as Int in JSON; always cast via `as? String` then `as? Int → String(n)`. Never use `Optional.map` on `field["id"]` |
| 10 | **Strip `local://` paths from formData before `submitInspection`** | Failed photo uploads leave `"local://..."` in formData; JSONSerialization silently drops non-serialisable values, losing the field entirely on the server |
| 10 | **Strip `local://` paths from formData before `submitInspection`** | Failed photo uploads leave `"local://..."` in formData; JSONSerialization silently drops non-serialisable values |
| 11 | **Do not submit an issue when parent inspection `syncStatus == "failed"`** | Submitting with no `inspection_id` creates orphaned server records |
| 12 | **Photo-before-inspection ordering in sync** | `processPhotoQueue` must run before `processInspectionQueue`; server path must be in `formData` before the inspection is submitted |
| 13 | **`com.jqc.sync` BGTaskSchedulerPermittedIdentifiers must be in Info.plist** | BGTaskScheduler silently ignores unregistered identifiers |
| 12 | **Photo-before-inspection ordering in sync** | `processPhotoQueue` must run before `processInspectionQueue` |
| 13 | **`com.jqc.sync` must be in BGTaskSchedulerPermittedIdentifiers** | BGTaskScheduler silently ignores unregistered identifiers |
| 14 | **`refreshAccessToken()` uses a local JSONDecoder, not `self.decoder`** | Accessing the actor-isolated `self.decoder` from a non-isolated context triggers Swift 6 isolation errors |
| 15 | **`clearParentFollowUpFlag()` fallback-2 is ambiguous** | Matching by template+facility when `parentLocalId` and `parentServerId` are both nil may clear the wrong inspection if multiple follow-ups are pending for the same template/facility combination |
| 16 | **`SyncQueueEntry` model is registered but not actively written** | Included for future use; currently`syncStatus` on `LocalInspection` and `LocalIssue`serves as the outbox queue |
| 17 | **`Constants.baseURL` is the only server URL** | All endpoints are built as `Constants.baseURL + endpoint`. Update this one constant for environment changes |
| 18 | **Photo JPEG compression is 0.8** | Balances quality vs. upload size. Do not raise above 0.85 without testing against the server's 50 MB limit |
| 19 | **`clearCache()` in Settings never deletes inspections or issues** | Only `LocalFacility`, `LocalArea`, `LocalTemplate` are safe to purge — inspection and issue data is the inspector's primary work product |
| 20 | **`AuthManager.restoreSession()` falls back to Keychain on non-auth errors** | A server 500 or network timeout at launch allows offline operation but may expose stale role/identity data |
| 15 | **`clearParentFollowUpFlag()` fallback-2 is ambiguous** | Matching by template+facility is ambiguous when multiple follow-ups are pending for the same template/facility |
| 16 | **`SyncQueueEntry` model is registered but not actively written** | Included for future use; `syncStatus` on `LocalInspection` and `LocalIssue`is the active queue |
| 17 | **`Constants.baseURL` is the only server URL** | All endpoints are `Constants.baseURL + endpoint`. Update this one constant for environment changes |
| 18 | **Photo JPEG compression is 0.8** | Do not raise above 0.85 without testing against the server's 50 MB limit |
| 19 | **`clearCache()` in Settings never deletes inspections or issues** | Only `LocalFacility`, `LocalArea`, `LocalTemplate` are safe to purge |
| 20 | **`AuthManager.restoreSession()` falls back to Keychain on non-auth errors** | Allows offline launch but may expose stale role/identity data |
| 21 | **`startMonitoring()` must be called AFTER `restoreSession()` completes** | NWPathMonitor fires immediately on launch, triggering `triggerSync()` before tokens exist; the 401 loop leaves `isLoading` stuck |
| 22 | **`triggerSync()` guards on `AuthManager.shared.isAuthenticated`** | Prevents sync from running before auth is established — covers the NWPathMonitor race and any BGTask path |
| 23 | **Do NOT place model files in non-Model folders** | Xcode 26 folder sync compiles every `.swift` in the tree; a `LocalInspection.swift` in `Auth/` causes "Multiple commands produce LocalInspection" |
| 24 | **No physical `Info.plist` file when `GENERATE_INFOPLIST_FILE = YES`** | Having both causes "Multiple commands produce Info" build error |
| 25 | **Always parenthesise `try?` before `??`** | `try? context.fetch(...) ?? []` parses as `try? (fetch() ?? [])` — `fetch()` is non-optional so `??` is invalid inside `try?`; compiler infers `T = Any` and cascades into build errors. Write `(try? context.fetch(...)) ?? []` |
| 26 | **Delete Xcode's default `Item.swift` immediately after project creation** | Xcode generates `Item.swift` with `@Model class Item` when creating a new SwiftData project; it compiles silently via folder sync and conflicts with real models |
---
## 21. Change Philosophy
## 21. Xcode 26 Specific Issues
1.**Read the actual file before editing.** Never rely on earlier context — a prior edit invalidates it.
This project was created with **Xcode 26.4.1** (Apple's major 2026 release). Several compiler behaviours differ from Xcode 15/16 and require specific patterns.
### SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
Xcode 26 sets this build setting when creating new projects with "approachable concurrency" enabled. It makes every type and function implicitly `@MainActor`.
**Effect on SwiftData:** SwiftData's `@Model` macro generates `nonisolated` accessors internally. When `FetchDescriptor<LocalInspection>` is used inside a `@MainActor` async method, the compiler sees a conflict between `@MainActor LocalInspection` and `nonisolated PersistentModel` requirements. The error cascade is:
-`'LocalInspection' is ambiguous for type lookup in this context`
-`Generic parameter 'T' could not be inferred`
-`Type 'Any' cannot conform to 'PersistentModel'`
-`The compiler is unable to type-check this expression in reasonable time`
**Solutions applied in this codebase:**
1. All `context.fetch()` calls use fetch-all + filter in Swift — no `#Predicate` with captured variables.
2. All `try? context.fetch(...)` expressions are parenthesised before `??`.
3. Chained optional patterns `(try? fetch(...))?.filter { }` are split into two `let` statements.
4.`triggerSync()` guards on `isAuthenticated` to prevent 401 cascades during startup.
**Do NOT remove `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` from build settings** unless you fully audit every file for the resulting isolation changes. The patterns above are the correct workarounds.
Xcode 26 uses folder-sync mode instead of an explicit file list in `project.pbxproj`. **Every `.swift` file in the project folder is compiled automatically.** There is no file registry to check.
Consequences:
- Stray files (e.g. `Item.swift` from the project template, accidentally duplicated model files) compile silently and cause "Multiple commands produce" errors.
- Deleting a file from Finder is sufficient to remove it from the build — no need to remove it from the project navigator separately.
- When diagnosing "Multiple commands produce X", run: `find /path/to/project -name "*.swift" | xargs grep -l "class X"` to find all definitions.
### Derived Data corruption
Under Xcode 26, repeated failed builds accumulate corrupt intermediate files in derived data. After any "Multiple commands produce" error is resolved, delete derived data manually before rebuilding:
The hash is visible in every error message path. Do not use Product → Clean Build Folder alone — it does not remove all intermediate files.
---
## 22. Change Philosophy
1.**Read the actual file before editing.** Never rely on earlier context — a prior edit invalidates it. When a user pastes file content, that is the ground truth — not the local copy.
2.**Trace the full data path.** For any bug: view → SwiftData write → SyncManager → APIClient → server response. Identify the exact layer.
3.**Root cause, not symptom.** State the root cause explicitly before proposing a fix.
3.**Root cause, not symptom.** State the root cause explicitly before proposing a fix. Multiple failed attempts are always caused by treating symptoms.
4.**Smallest possible change.** Do not restructure, rename, or reformat surrounding code.
6.**SwiftData schema changes need defaults.** All new `Bool` fields: `= false`. All new optional fields: `= nil` or a safe default. Run on device and check for migration crash before shipping.
7.**Test offline and online.** Every feature must work without connectivity. Sync-related fixes must be verified by simulating airplane mode.
9.**Update this document**at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.
6.**SwiftData schema changes need defaults.** All new `Bool` fields: `= false`. Run on device and check for migration crash before shipping.
7.**Test offline and online.** Every sync-related fix must be verified in airplane mode.
8.**Verify file placement.**After delivering a file, confirm the user placed it at the correct path. Xcode 26 folder sync means a file in the wrong subfolder compiles as a duplicate.
9.**When errors persist unchanged across multiple fix attempts, the file is not being picked up.**Ask the user to paste the current file content before making further changes.
10.**Update this document** at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.