Files
JQC_iOS_App/JanitorialQC/CLAUDE.md
T
2026-05-08 13:55:37 -04:00

589 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 C complete — background sync, Issues view, Inspection History)
> **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. [Change Philosophy](#21-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
- **Re-inspection** — linked follow-up inspections with parent-form pre-fill
- **Outbox queue** — completed inspections and issues submitted to server automatically when online
- **Inspection history** — server-side read-only history for completed inspections
- **Issues list** — local read-only list of all issues flagged on this device
- **Facilities browser** — read-only view of synced facilities and their areas
---
## 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 | 15+ |
---
## 3. Repository Layout
```
JanitorialQC/
├── JanitorialQCApp.swift # @main — SwiftData container, BGTask registration
├── ContentView.swift # Auth gate: LoginView ↔ DashboardView
├── 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
│ ├── LocalFacility.swift
│ ├── LocalArea.swift
│ ├── LocalTemplate.swift
│ ├── LocalInspection.swift
│ ├── LocalIssue.swift
│ ├── PendingPhoto.swift
│ └── SyncQueueEntry.swift
├── Views/
│ ├── Auth/
│ │ └── LoginView.swift
│ ├── Dashboard/
│ │ ├── DashboardView.swift # NavigationSplitView sidebar + all embedded views
│ │ ├── StartInspectionView.swift # Template/facility/area picker, creates LocalInspection
│ │ ├── ExecuteInspectionView.swift # Grid form renderer, draft save, submit
│ │ ├── FlagIssueView.swift # Issue creation sheet
│ │ └── FormFieldView.swift # All field-type renderers + camera/library pickers
│ └── Inspection/
│ └── InspectionHistoryView.swift # Server-side history, read-only
└── Utils/
└── Constants.swift # baseURL, Keychain key strings
```
---
## 4. App Entry Point & Lifecycle
**`JanitorialQCApp.swift`** is `@main`. It:
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`.
**`ContentView.swift`** is the auth gate. When `AuthManager.isAuthenticated` is `false` it shows `LoginView`; when `true` it shows `DashboardView`.
**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 → unauthenticated immediately.
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)
SyncQueueEntry / 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)
```
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() — 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.
### 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 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.
---
## 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<T>` (wraps `ok: Bool`, `data: T?`, `error: String?`).
### Key methods
| Method | Endpoint | Notes |
|---|---|---|
| `request<T>` | Any | Generic GET/POST |
| `post<T>` | Any | POST convenience |
| `uploadPhoto` | `POST /api/v1/photos/upload` | Multipart form-data, manual boundary |
| `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` |
### 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)
├── 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`) — this works correctly through `ScrollView` 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 miss.
---
## 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.
- Triggers `SyncManager.triggerSync()` if online.
5. Area picker is absent — facility is derived directly from the inspection context (mirrors web `flag_issue.html`).
### 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.
### 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` (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.**
---
## 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.
`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.
---
## 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/<UUID>.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://<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.
### 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.
---
## 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 Field ID rule). `Optional.map` on `Any?` produces `"Optional(5)"` and causes all lookups to silently miss.
---
## 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.
**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)
**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 | **`#Predicate` — fetch all + filter in Swift for string comparisons** | SwiftData macro type-inference bug with string literals across model type boundaries causes runtime crashes |
| 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 |
| 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 |
| 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 |
---
## 21. Change Philosophy
1. **Read the actual file before editing.** Never rely on earlier context — a prior edit invalidates it.
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.
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`. 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.
8. **Full file contents** for 13 file changes; deployment map for larger changesets.
9. **Update this document** at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.