# CLAUDE.md — JQC iOS App Developer Reference > **Audience:** AI assistants and developers working on the JanitorialQC iPad app. > **Purpose:** Authoritative reference for architecture, conventions, constraints, and decisions. > **Last reviewed:** May 2026 (Phase 18 complete — static DateFormatter, uploadPhoto retry guard, explicit LocalIssue relationship inverse, APIUser.fullName/displayName, build fixes: SyncManager.shared restored, _RefreshEnvelope nonisolated init) > **Companion:** See the web backend's `CLAUDE.md` for API contract, server-side rules, and migration chain. --- ## Table of Contents 1. [Project Overview](#1-project-overview) 2. [Tech Stack](#2-tech-stack) 3. [Repository Layout](#3-repository-layout) 4. [App Entry Point & Lifecycle](#4-app-entry-point--lifecycle) 5. [Authentication](#5-authentication) 6. [SwiftData Models](#6-swiftdata-models) 7. [Offline-First Architecture](#7-offline-first-architecture) 8. [Sync Engine (SyncManager)](#8-sync-engine-syncmanager) 9. [API Client (APIClient)](#9-api-client-apiclient) 10. [Navigation & View Hierarchy](#10-navigation--view-hierarchy) 11. [Inspection Workflow](#11-inspection-workflow) 12. [Form Field Rendering](#12-form-field-rendering) 13. [Issue Flagging Workflow](#13-issue-flagging-workflow) 14. [Re-inspection Workflow](#14-re-inspection-workflow) 15. [Inspection History](#15-inspection-history) 16. [Photo Handling](#16-photo-handling) 17. [Score Calculation](#17-score-calculation) 18. [Background Sync](#18-background-sync) 19. [Settings & Cache Management](#19-settings--cache-management) 20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules) 21. [Xcode 26 Specific Issues](#21-xcode-26-specific-issues) 22. [Change Philosophy](#22-change-philosophy) --- ## 1. Project Overview **JanitorialQC Inspector** is a native iPadOS app used by inspectors to conduct facility quality-control inspections. Its defining characteristic is **offline-first operation**: every action writes to local SwiftData storage first; the server is a secondary destination reached asynchronously when connectivity allows. Core capabilities: - **Login/logout** via JWT against the JQC web backend API - **Reference data sync** — facilities, areas, and inspection templates pulled from server - **Inspection execution** — dynamic form rendering driven by server-side template schemas - **Issue flagging** — severity-tagged issues with optional photos, attached to inspections; confirmation banner shown on submit - **Re-inspection** — linked follow-up inspections with parent-form pre-fill (template, contract, facility all pre-filled) - **Outbox queue** — completed inspections and issues submitted to server automatically when online - **Inspection history** — server-side read-only history for completed inspections - **Issues list** — assigned issues pulled from server + device-created issues; stale unassigned records removed on sync - **Issue detail** — shows local photos and server-hosted photos via `AsyncImage` - **Facilities browser** — read-only view of synced facilities and their areas - **Push notifications** — local notifications for issue assignments and follow-up requests via 60-second polling --- ## 2. Tech Stack | Layer | Technology | |---|---| | Language | Swift 5.10+ | | UI | SwiftUI (iPad-only, all four orientations) | | Local storage | SwiftData (iOS 17+ required) | | Networking | URLSession async/await | | Connectivity detection | NWPathMonitor (Network.framework) | | Token storage | iOS Keychain (Security.framework) | | Photo capture | UIImagePickerController (camera), PHPickerViewController (library) | | Signature capture | PencilKit (PKCanvasView) | | Background tasks | BGTaskScheduler / BGProcessingTask | | Min deployment | iOS 17.0 | | Xcode | **26.4.1** (Xcode 26 beta — see §21 for critical constraints) | --- ## 3. Repository Layout ``` JanitorialQC/ ├── JanitorialQCApp.swift # @main — SwiftData container, BGTask registration ├── ContentView.swift # Auth gate + startup lifecycle (.task{}) │ ├── Auth/ │ ├── AuthManager.swift # @MainActor ObservableObject — login/logout/restore │ └── KeychainHelper.swift # Security.framework wrapper (nonisolated) │ ├── API/ │ ├── APIClient.swift # actor — URLSession, JWT inject, 401-retry, photo upload │ └── APIModels.swift # All Codable/Sendable response DTOs │ ├── Sync/ │ └── SyncManager.swift # @MainActor ObservableObject — NWPathMonitor, outbox queue │ ├── Models/ # SwiftData @Model classes — NO other .swift files here │ ├── LocalFacility.swift │ ├── LocalArea.swift │ ├── LocalTemplate.swift │ ├── LocalInspection.swift # ← ONLY definition of LocalInspection — never duplicate │ ├── LocalIssue.swift │ ├── PendingPhoto.swift │ └── SyncQueueEntry.swift │ ├── Views/ │ ├── Auth/ │ │ └── LoginView.swift # ← Auth/ folder must contain ONLY auth files — no models │ ├── Dashboard/ │ │ ├── DashboardView.swift │ │ ├── StartInspectionView.swift │ │ ├── ExecuteInspectionView.swift │ │ ├── FlagIssueView.swift │ │ └── FormFieldView.swift │ └── Inspection/ │ └── InspectionHistoryView.swift │ └── Utils/ └── Constants.swift ``` **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 `@main` struct. Responsibilities: 1. Creates the SwiftData `ModelContainer` for all seven model types. 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. **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. ### ContentView.swift Auth gate and **startup lifecycle owner**. The `.task {}` modifier owns the startup sequence: ```swift .task { await AuthManager.shared.restoreSession() // 1. auth first SyncManager.shared.startMonitoring() // 2. monitor after auth resolves if SyncManager.shared.isOnline && AuthManager.shared.isAuthenticated { await SyncManager.shared.triggerSync() // 3. sync only if authenticated } } ``` **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`. --- ## 5. Authentication ### AuthManager `@MainActor class AuthManager: ObservableObject` — singleton via `AuthManager.shared`. | Property | Purpose | |---|---| | `isAuthenticated` | Master gate — drives ContentView routing | | `isLoading` | Shows spinner during network calls | | `errorMessage` | Shown inline on LoginView | | `currentUserId/Username/Role/DisplayName` | User identity persisted to Keychain | **Session restore flow (`restoreSession`):** 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. **Logout flow:** Calls `POST /api/v1/auth/logout` with the refresh token (best-effort), then clears all Keychain keys and sets `isAuthenticated = false`. ### KeychainHelper `nonisolated` static methods wrapping `Security.framework`. All keys use `kSecAttrAccessibleAfterFirstUnlock` so tokens are readable for background sync after device reboot. Stored keys (all prefixed `com.jqc.`): | Key constant | Value stored | |---|---| | `accessToken` | JWT Bearer token | | `refreshToken` | Opaque 64-char hex refresh token | | `userId` | User ID as string | | `userRole` | Role string (e.g. `inspector`) | | `username` | Login username | | `displayName` | Display name | --- ## 6. SwiftData Models ### Model Container Registration All models are registered in `JanitorialQCApp` in this order: ```swift LocalFacility.self, LocalArea.self, LocalTemplate.self, LocalInspection.self, LocalIssue.self, PendingPhoto.self, SyncQueueEntry.self ``` **SwiftData lightweight migration:** Adding a new `Bool` property to any model **requires a default value** (e.g. `var followUpRequired: Bool = false`) — without a default the app crashes on launch after the model change. ### Model Reference | Model | Role | Key fields | |---|---|---| | `LocalFacility` | Read-only cached facility reference | `serverId`, `name`, `address`, `projectName`, `areas` (cascade) | | `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` | | `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) | | `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId` | | `LocalIssue` | Issue flagged during inspection | `localId` (UUID, unique), `serverId`, `inspectionLocalId`, `facilityServerId`, `severity`, `syncStatus` | | `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType`, `fieldId` | | `SyncQueueEntry` | Outbox entry (currently informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` | ### LocalInspection Status Flow ``` "draft" → "completed" → "synced" → "failed" (after 5 retries) ``` `status` = inspector workflow state. `syncStatus` = server submission state (`"pending"` | `"synced"` | `"failed"`). They are separate fields. ### formData Storage `LocalInspection.formData` is a computed property that JSON-serialises to/from `formDataJSON: String`. Keys are always **strings** (field IDs stringified). Values are `Any` (String, Int, Bool, Array, Dict). Do not store `UIImage` or any non-JSON-serialisable type in `formData`. --- ## 7. Offline-First Architecture The app follows the **outbox pattern**: ``` Inspector action → SwiftData write (always succeeds immediately) ↓ syncStatus = "pending" ↓ NWPathMonitor detects connectivity ↓ SyncManager.triggerSync() 1. Upload pending photos (processPhotoQueue) 2. Submit completed inspections (processInspectionQueue) 3. Submit pending issues (processIssueQueue) 4. Pull fresh reference data (pullReferenceData) 5. Pull + reconcile assigned issues (pullAssignedIssues) 6. Poll server notifications (pollNotifications) ``` The UI never blocks on network. Every screen is driven by local SwiftData queries. --- ## 8. Sync Engine (SyncManager) `@MainActor class SyncManager: ObservableObject` — singleton via `SyncManager.shared`. ### Published state | Property | Purpose | |---|---| | `isOnline` | True when `NWPathMonitor` reports `.satisfied` | | `isSyncing` | True during active sync cycle | | `lastSyncAt` | Date of last completed sync | | `syncError` | Last error string (shown in Settings and Pending Sync views) | | `pendingCount` | Count of unsynced inspections + issues (shown as badge) | ### triggerSync() guard `triggerSync()` guards on **three conditions** before doing any work: ```swift guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return } ``` 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, 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`. 5. `pullAssignedIssues` — fetches `GET /api/v1/issues` (scoped to current user on server). Upserts into SwiftData keyed by `serverId`. **Deletion pass** removes local records with `syncStatus == "synced"` + `inspectionLocalId == ""` whose `serverId` is absent from the response — handles reassignment away from this inspector. Empty response is not short-circuited so full unassignment is handled. 6. `pollNotifications` — fetches `GET /api/v1/notifications?since=`. Delivers each item as a local `UNUserNotificationCenter` banner. Updates cursor timestamp. Marks fetched IDs read on server. ### Notification polling - `startPollTask()` creates a `Task` with a `Task.sleep(nanoseconds: 60_000_000_000)` loop. Called from `startMonitoring()` whenever connectivity is `.satisfied`. - `stopPollTask()` cancels the task. Called when connectivity drops. - `resetNotificationPoller()` cancels task + clears `lastNotificationFetch`. Call on logout. - **`Timer.scheduledTimer` is banned for periodic work in SyncManager.** Timer requires `RunLoop.main` to be ticking; inside a Swift Concurrency `Task { @MainActor }` block `RunLoop.current` ≠ `RunLoop.main` — the timer fires silently never. Always use `Task.sleep`. - **`UNUserNotificationCenterDelegate` is required for foreground delivery.** Without it, iOS silently drops local notifications when the app is active. `NotificationDelegate.shared` is set as `UNUserNotificationCenter.current().delegate` in `JanitorialQCApp.init()`. Its `willPresent` returns `[.banner, .sound]`. - **`Self.isoFormatter` for all date parsing.** `SyncManager.isoFormatter` is a `nonisolated static let DateFormatter` with `locale = Locale(identifier: "en_US_POSIX")` and `dateFormat = "yyyy-MM-dd'T'HH:mm:ss"`. Used by both `pollNotifications()` and `pullAssignedIssues()`. **Never allocate a `DateFormatter` per call or per loop iteration** — it is expensive. The `en_US_POSIX` locale is mandatory for fixed-format parsing; without it, the system locale can reinterpret the format string unpredictably. ### 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. **Never use the chained optional pattern:** ```swift // WRONG — compiler cannot infer T in this chained expression let x = (try? context.fetch(FetchDescriptor()))?.filter { ... } ?? [] // CORRECT — split into two statements let all = (try? context.fetch(FetchDescriptor())) ?? [] let x = all.filter { ... } ``` **Always parenthesise `try?` before `??`:** ```swift // WRONG — operator precedence: try? binds looser than ?? // parses as: try? (context.fetch(...) ?? []) — nonsensical, fetch() is not Optional let all = try? context.fetch(FetchDescriptor()) ?? [] // CORRECT let all = (try? context.fetch(FetchDescriptor())) ?? [] ``` ### Retry / failure - Each item increments `syncRetryCount` on every failure. - At 5 retries: `syncStatus = "failed"`. The item stays in SwiftData but is never retried automatically. - `syncError` is cleared at the start of each `triggerSync()` call. --- ## 9. API Client (APIClient) `actor APIClient` — singleton via `APIClient.shared`. All methods are `async throws`. ### Generic request pipeline ```swift request(endpoint, method, body, retrying) async throws -> T ``` 1. Builds URL from `Constants.baseURL + endpoint`. 2. Injects Bearer token from Keychain. 3. Performs `URLSession.data(for:)`. 4. On HTTP 401 and `retrying == false`: calls `refreshAccessToken()` once, retries. If refresh fails → `APIError.notAuthenticated`. 5. Decodes via `_Envelope` (wraps `ok: Bool`, `data: T?`, `error: String?`). ### Key methods | Method | Endpoint | Notes | |---|---|---| | `request` | Any | Generic GET/POST; `retrying: Bool` prevents double-refresh loop | | `post` | Any | POST convenience | | `uploadPhoto` | `POST /api/v1/photos/upload` | Multipart form-data, manual boundary; `retrying: Bool = false` matches `request()` retry pattern | | `submitInspection` | `POST /api/v1/inspections` | Sanitises `local://` photo paths before send | | `submitIssue` | `POST /api/v1/issues` | Sends `inspection_id` only if `inspection.serverId` is non-nil | | `fetchInspectionHistory` | `GET /api/v1/inspections` | Paginated, returns `InspectionHistoryResponseData` | | `fetchAssignedIssues` | `GET /api/v1/issues` | Returns issues assigned to OR reported by current user | | `fetchIssueDetail` | `GET /api/v1/issues/` | Fetches current status for detail view | | `updateIssueStatus` | `PATCH /api/v1/issues//status` | Inspector updates status on assigned/reported issues | | `fetchNotifications` | `GET /api/v1/notifications` | Accepts optional `since: Date`; returns `[APINotification]` | | `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks list of IDs read on server | ### Token refresh `refreshAccessToken()` uses a **local** `JSONDecoder` (not `self.decoder`) to avoid Swift 6 actor-isolation errors. It stores both new tokens in Keychain before returning. ### JSON decoding `decoder.keyDecodingStrategy = .convertFromSnakeCase` — all snake_case server fields map to camelCase Swift properties automatically. ### `JSONValue` / `AnyDecodable` `AnyDecodable` is a `typealias` for `JSONValue`, a typed enum replacing `Any` to achieve full `Sendable` conformance. Use `.anyValue` to bridge to `Any` where legacy code expects it. --- ## 10. Navigation & View Hierarchy ``` JanitorialQCApp └── ContentView (auth gate + startup lifecycle) ├── LoginView (unauthenticated) └── DashboardView (authenticated) ├── Sidebar (NavigationSplitView — no selection: binding) │ ├── My Inspections → MyInspectionsView │ ├── Issues → IssuesListView │ ├── Facilities → FacilitiesListView │ ├── Pending Sync → SyncStatusView │ ├── History → InspectionHistoryView │ └── Settings → SettingsView └── + button → StartInspectionView (sheet) └── ExecuteInspectionView (navigation push) └── FlagIssueView (sheet) ``` **`NavigationSplitView` constraint:** `init(selection:content:)` is unavailable on iPadOS 17. Navigation is driven by `@State var selectedTab: SidebarTab` with manual `Button` handlers. **Never add a `selection:` binding.** --- ## 11. Inspection Workflow ### 1. Start (`StartInspectionView`) - Inspector picks template (required), facility (required), area (optional). - Tapping Start creates a `LocalInspection` in SwiftData immediately (`status = "draft"`, `syncStatus = "pending"`) and navigates to `ExecuteInspectionView`. - For re-inspections: template and facility are pre-filled; non-scoring fields from the parent are copied into `formData` (rating, pass_fail, image, signature fields are always blank). ### 2. Execute (`ExecuteInspectionView`) - Renders the template's `formSchema` as a 12-column CSS-grid-equivalent layout via `GridFormView`. - Auto-saves every 30 seconds to SwiftData. - Saves on `onDisappear`. - "Flag an Issue" button opens `FlagIssueView` as a sheet. - "Save Draft" force-saves with a brief spinner feedback. - "Submit Inspection" shows a confirmation alert, then: 1. Persists final `formData` to SwiftData. 2. Computes `overallScore` via `computeScore(fromSchema:)`. 3. Sets `status = "completed"`, `syncStatus = "pending"`, `completedAt = Date()`. 4. Calls `clearParentFollowUpFlag()` immediately (badge clears on device before sync). 5. Triggers `SyncManager.triggerSync()` in the background if online. 6. Shows a success banner for 2.5 seconds then dismisses. ### 3. Draft management - Drafts appear in **My Inspections** with a blue "Draft" badge. - Swipe-left on a draft reveals a Delete action (confirmation required). Deletion removes the draft, all its `PendingPhoto` records, local photo files, and associated `LocalIssue` records. - Only `status == "draft"` inspections may be deleted. ### Status badge map | `status` | `syncStatus` | Badge label | Badge colour | |---|---|---|---| | `draft` | any | Draft | Blue | | `completed` | `pending` | Pending Sync | Orange | | `completed` | `synced` | Completed | Green | | `failed` | any | Sync Failed | Red | --- ## 12. Form Field Rendering ### GridFormView (primary renderer in ExecuteInspectionView) Uses a 12-column absolute-position grid matching the web app's CSS grid exactly: | Constant | Value | Source | |---|---|---| | `totalColumns` | 12 | Web editor `COLS=12` | | `cellGap` | 8 pt | Web CSS `col-gap: 8px` | | `rowGap` | 4 pt | Web CSS `row-gap: 4px` | | `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`) — works correctly through rotations and split-screen resizing. ### Supported field types | Type | SwiftUI renderer | |---|---| | `text`, `email` | `TextField` | | `textarea` | `TextEditor` | | `number` | `TextField` + `.decimalPad` | | `date` | `DatePicker` (date only) | | `checkbox` | `Toggle` | | `checkbox_group` | Custom multi-select buttons (`CellCheckboxGroup`) | | `radio` | Custom radio buttons (`CellRadioGroup`) | | `select` | `Menu` dropdown (`CellSelect`) | | `rating` | Custom star row (`CellRatingStars`) — tap same star to clear | | `pass_fail` | Capsule pill buttons (`CellPassFail`) — tap selected to deselect | | `signature` | `PKCanvasView` (`SignatureFieldView`) — requires PencilKit framework | | `image` | `CompactImageFieldView` (grid) / `ImageFieldView` (standalone) | | `table` | `TableFieldView` — horizontal scroll, editable cells | | `section`, `label` | Display-only `Text` | ### Field ID rule (CRITICAL) Form schema IDs from the server are **integers** in JSON (e.g. `"id": 5`). After `JSONSerialization`, they arrive as `Int` in `[String: Any]` dictionaries. `formData` keys are always `String`. Field IDs must be resolved via: ```swift let fid: String if let s = field["id"] as? String { fid = s } 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 silently miss and all scores to return 0. --- ## 13. Issue Flagging Workflow `FlagIssueView` is presented as a sheet from `ExecuteInspectionView`. 1. Inspector selects severity (segmented control: low / medium / high / critical). 2. Enters description (required). 3. Optionally attaches a photo. 4. Tapping Submit: - Creates `LocalIssue` with `facilityServerId` from the parent inspection. - Appends the issue to `inspection.localIssues`. - If a photo was taken: creates a `PendingPhoto` with `entityType = "issue"`. - Saves to SwiftData. - Shows a green **"Issue Logged"** confirmation banner (2 seconds) then dismisses. - Triggers `SyncManager.triggerSync()` if online. 5. Area picker is absent — facility is derived directly from the inspection context. ### Issue sync guard If `inspection.syncStatus == "failed"` when `processIssueQueue` runs, the issue is immediately marked `"failed"` with message `"Parent inspection failed to sync — issue cannot be submitted."` This prevents orphaned server records with no `inspection_id`. --- ## 14. Re-inspection Workflow ### Trigger On `CompletedInspectionView`, if `inspection.followUpRequired == true`, an orange banner is shown with a **Start Re-inspection** button. This opens `StartInspectionView` with `preFillTemplateId`, `preFillFacilityId`, `parentServerId`, and `parentLocalId` pre-set. ### Parent form pre-fill `StartInspectionView.startInspection()` copies non-scoring fields from the parent's `formData` into the new inspection. Excluded field types: `rating`, `pass_fail`, `image`, `signature` — these must always be re-evaluated fresh. ### Picker pre-fill (template, contract, facility) `applyPreFill()` sets `selectedTemplateId`, `selectedProjectId`, and `selectedFacilityId` on `.onAppear`. **The `.onChange(of: selectedProjectId)` handler guards against resetting `selectedFacilityId`** when the facility already belongs to the newly selected contract — this prevents the onChange from wiping the pre-filled facility before it renders. Without this guard, `selectedProjectId` is set first, `.onChange` fires, and `selectedFacilityId` is reset to `nil` before it can be applied. ### followUpRequired clearing Cleared at **three** points to ensure the badge disappears regardless of timing: 1. **Immediately on Submit** in `ExecuteInspectionView.submitInspection()` via `clearParentFollowUpFlag()`. 2. **After sync** in `SyncManager.processInspectionQueue` using `parentLocalId`. 3. **On the server** in `api/inspections.py` when the PATCH/POST arrives. `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`. **This fallback is ambiguous when multiple follow-ups are pending for the same template/facility combination.** --- ## 15. Inspection History `InspectionHistoryView` fetches completed inspections from the server via `GET /api/v1/inspections?status=completed`. It is **online-only** — shows `ContentUnavailableView` with "Offline" when `!sync.isOnline`. Pagination: limit 30, offset-based. A "Load More" button appears when `inspections.count < total`. Pull-to-refresh resets to page 0. --- ## 16. Photo Handling ### Capture Photos are taken via `UIImagePickerController` (camera) or `PHPickerViewController` (library, no permission required for iOS 16+). ### Local storage All photos are saved to: `Documents/JQC/Photos/.jpg` at JPEG quality 0.8. ### PendingPhoto lifecycle ``` Created (uploadStatus="pending") ↓ SyncManager.processPhotoQueue() Uploaded (uploadStatus="uploaded", serverPath set) ↓ Parent record updated For inspection image fields: LocalInspection.formData[fieldId] = serverPath For issues: LocalIssue.photoServerPath = serverPath ``` ### local:// sentinel While a photo is pending upload, the form field value is set to `"local://"`. 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. ### Server-hosted photos in IssueDetailView `IssueDetailView` shows two photo sections: 1. **Local photos** — `photoLocalPaths` rendered via `UIImage(contentsOfFile:)`. Present for device-created issues where photos were captured on-device. 2. **Server photos** — `photoServerPaths` rendered via `AsyncImage(url: Constants.baseURL + "/" + relativePath)`. Present for issues pulled from the server (`pullAssignedIssues` stores `photo_path` + `result_photos` from the API response into `photoServerPaths`). Both sections are shown independently; an issue can have entries in either or both. --- ## 17. Score Calculation `LocalInspection.computeScore(fromSchema:)` mirrors Python's `_compute_score_from_form()` exactly. **Scoreable field types:** `rating`, `checkbox`, `radio`, `pass_fail`. | Type | Rule | |---|---| | `rating` | Value `0` = unanswered → excluded. Each answered rating contributes `value / 5` of a possible 1.0 | | `checkbox` | `"true"` = pass, anything else = fail | | `radio` | Pass keywords: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant` (case-insensitive) | | `pass_fail` | Same pass keywords as radio. Empty string = unanswered → excluded | Returns `nil` if no scoreable fields or all are unanswered. **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. --- ## 18. Background Sync 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: - Every time `scenePhase == .background` - At the start of each background task handler (schedules the next run) **Requirements:** `requiresNetworkConnectivity = true`, `requiresExternalPower = false`. **Tokens:** Keychain access policy `kSecAttrAccessibleAfterFirstUnlock` ensures tokens are available when the app is woken by BGTaskScheduler after device reboot. --- ## 19. Settings & Cache Management `SettingsView` exposes: - Account info (username, role) — read-only from `AuthManager`. - **Sync Now** — triggers `SyncManager.triggerSync()`; disabled when offline or already syncing. - Last sync timestamp. - **Clear Reference Cache** — deletes all `LocalFacility`, `LocalArea`, and `LocalTemplate` records. **Never deletes `LocalInspection`, `LocalIssue`, or `PendingPhoto`.** Triggers `pullReferenceData()` immediately if online. - **Log Out** — calls `AuthManager.logout()`. - App version and server URL (from `Constants.baseURL`). --- ## 20. Known Constraints & Hard Rules | # | Rule | Rationale | |---|---|---| | 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 | **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 | | 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` | | 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 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 | | 27 | **`Timer.scheduledTimer` must NOT be used for periodic work in SyncManager** | Inside `Task { @MainActor }`, `RunLoop.current` ≠ `RunLoop.main`; the timer is added to a runloop that never ticks and fires silently never. Use `Task.sleep` instead | | 28 | **`UNUserNotificationCenterDelegate` must be set for foreground notifications** | Without a delegate returning `[.banner, .sound]` from `willPresent`, iOS silently drops local notifications while the app is active. `NotificationDelegate.shared` is set in `JanitorialQCApp.init()` | | 29 | **All `Decodable & Sendable` response structs need `nonisolated init(from:)`** | Under `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, synthesised Decodable inits inherit `@MainActor` isolation, conflicting with the `Sendable` constraint on `_Envelope`; every API model struct must declare `nonisolated init(from decoder: any Decoder)` explicitly | | 30 | **`pullAssignedIssues` deletion pass must NOT be short-circuited on empty response** | If the server returns zero issues (all unassigned), the deletion pass must still run to remove stale local records; do not `guard !apiIssues.isEmpty else { return }` before the deletion loop | | 31 | **Server-pulled issues identified by `syncStatus == "synced"` + `inspectionLocalId == ""`** | These are the only records safe to delete during reconciliation. Device-created issues have `inspectionLocalId != ""` and must never be deleted by `pullAssignedIssues` | | 32 | **`StartInspectionView.onChange(of: selectedProjectId)` guards against resetting pre-filled facility** | Check `facilityBelongsToContract` before clearing `selectedFacilityId`; the onChange fires during `applyPreFill()` before `selectedFacilityId` is applied, wiping it if unchecked | | 33 | **Issue photos are stored in `photoServerPaths` after `pullAssignedIssues`** | `photo_path` and `result_photos` from `_issue_payload` are merged into a single `[String]` and stored in `local.photoServerPaths` on insert/update | | 34 | **`IssueDetailView` shows both `photoLocalPaths` and `photoServerPaths`** | Local paths use `UIImage(contentsOfFile:)`; server paths use `AsyncImage` with `Constants.baseURL` prefix. Both sections are independent | | 35 | **`SyncManager.isoFormatter` is the only date formatter — never allocate per-call** | `DateFormatter` init is expensive. The static `nonisolated` formatter with `en_US_POSIX` locale handles all `yyyy-MM-dd'T'HH:mm:ss` parsing. Adding a new `DateFormatter` anywhere in SyncManager is wrong. | | 36 | **`uploadPhoto(retrying:)` — pass `retrying: true` on recursive retry** | Matches `request()` pattern. Without it, a 401 on the retry triggers a second token refresh instead of throwing `notAuthenticated`. | | 37 | **`LocalIssue.inspection` must declare explicit `@Relationship` inverse** | `@Relationship(deleteRule: .nullify, inverse: \LocalInspection.localIssues)` — without it SwiftData infers the inverse implicitly, which can produce migration warnings and incorrect cascade behaviour under some Xcode 26 versions. `deleteRule` is `.nullify` not `.cascade` because cascade is already declared on `LocalInspection.localIssues`. | | 38 | **`APIUser.displayName` uses `fullName` when non-empty, falls back to `username`** | Server `_user_payload` sends `full_name`; iOS decodes as `fullName: String` (empty string when unset). `displayName` computed property: `fullName.isEmpty ? username : fullName`. Mirrors `User.display_name` server-side. | --- ## 21. Xcode 26 Specific Issues 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` 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. 5. Notification polling uses `Task.sleep` not `Timer.scheduledTimer` (RunLoop dependency). 6. All API model structs declare `nonisolated init(from decoder: any Decoder)` explicitly — including file-scope `private` structs like `_RefreshEnvelope` in `APIClient.swift`. A `private` struct defined in a file containing an `actor` or `@MainActor` type can have its `Decodable` conformance tainted with `@MainActor`, producing "cannot be used in actor-isolated context" errors in Swift 6 mode. The fix is always an explicit `nonisolated init(from:)` with a matching `CodingKeys` enum. **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. ### PBXFileSystemSynchronizedRootGroup (Folder Sync) 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: ```bash rm -rf ~/Library/Developer/Xcode/DerivedData/- ``` 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. Multiple failed attempts are always caused by treating symptoms. 4. **Smallest possible change.** Do not restructure, rename, or reformat surrounding code. 5. **Never remove functionality** unless explicitly directed. 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.