736 lines
51 KiB
Markdown
736 lines
51 KiB
Markdown
# 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:** June 2026 (Phase 19 complete — server selection, standalone issue creation, multi-photo evidence, facility deduplication, RetryablePhotoView, photo URL /static/ prefix + Phase 25 GPS capture at submit time + active-template filter + area relationship fix + read-only inspection detail view)
|
||
> **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. [Standalone Issue Creation](#14-standalone-issue-creation)
|
||
15. [Re-inspection Workflow](#15-re-inspection-workflow)
|
||
16. [Inspection History](#16-inspection-history)
|
||
17. [Photo Handling](#17-photo-handling)
|
||
18. [Score Calculation](#18-score-calculation)
|
||
19. [Background Sync](#19-background-sync)
|
||
20. [Settings & Cache Management](#20-settings--cache-management)
|
||
21. [Server Selection](#21-server-selection)
|
||
22. [Known Constraints & Hard Rules](#22-known-constraints--hard-rules)
|
||
23. [Xcode 26 Specific Issues](#23-xcode-26-specific-issues)
|
||
24. [Change Philosophy](#24-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
|
||
- **Server selection** — inspector can choose between jqc (primary) and jqc1 (secondary) server at login or in Settings
|
||
- **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
|
||
- **Standalone issue creation** — inspector can create issues directly from the Issues page without an active inspection, with contract → facility cascade picker
|
||
- **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** — assigned/reported issues pulled from server + device-created issues
|
||
- **Issue detail** — shows local photos (pending) or server photos (synced) via `RetryablePhotoView`
|
||
- **Facilities browser** — read-only view of synced facilities and their areas
|
||
- **Push notifications** — local notifications 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 |
|
||
| Server config | UserDefaults (not Keychain — not a secret) |
|
||
| Min deployment | iOS 17.0 |
|
||
| Xcode | **26.4.1** (Xcode 26 beta — see §23 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
|
||
│ │ # updateIssuePhotos() — PATCH /issues/<id>/photos
|
||
│ └── APIModels.swift # All Codable/Sendable response DTOs
|
||
│ # APIAssignedIssue has photoPath + mobilePhotoPaths + resultPhotos
|
||
│
|
||
├── Sync/
|
||
│ └── SyncManager.swift # @MainActor ObservableObject — NWPathMonitor, outbox queue
|
||
│ # pullReferenceData() deduplicates facilities by serverId
|
||
│ # pullAssignedIssues() reads photo_path + mobile_photo_paths only
|
||
│
|
||
├── Models/ # SwiftData @Model classes — NO other .swift files here
|
||
│ ├── LocalFacility.swift # @Attribute(.unique) serverId; projectId/projectName for contracts
|
||
│ ├── LocalArea.swift
|
||
│ ├── LocalTemplate.swift
|
||
│ ├── LocalInspection.swift
|
||
│ ├── LocalIssue.swift # photoLocalPathsJSON + photoServerPathsJSON (both JSON-encoded)
|
||
│ ├── PendingPhoto.swift
|
||
│ └── SyncQueueEntry.swift
|
||
│
|
||
├── Views/
|
||
│ ├── Auth/
|
||
│ │ └── LoginView.swift # Server picker (segmented) above login form
|
||
│ ├── Dashboard/
|
||
│ │ ├── DashboardView.swift # Sidebar + all detail views
|
||
│ │ │ # MyInspectionsView owns + button (not sidebar)
|
||
│ │ │ # IssuesListView owns + button → StandaloneIssueView
|
||
│ │ │ # RetryablePhotoView — AsyncImage with tap-to-retry
|
||
│ │ │ # IssueDetailView — pending shows local, synced shows server
|
||
│ │ ├── StartInspectionView.swift # Contract → Facility → Area cascade pickers
|
||
│ │ ├── ExecuteInspectionView.swift
|
||
│ │ ├── FlagIssueView.swift
|
||
│ │ └── FormFieldView.swift
|
||
│ └── Inspection/
|
||
│ └── InspectionHistoryView.swift # Uses RetryablePhotoView for server photos
|
||
│
|
||
└── Utils/
|
||
└── Constants.swift # ServerConfig (UserDefaults), ServerOption enum, Keychain keys
|
||
# Constants.baseURL is GONE — use ServerConfig.current everywhere
|
||
```
|
||
|
||
**Critical:** This project uses `PBXFileSystemSynchronizedRootGroup` (Xcode 26 folder sync). Xcode **automatically compiles every `.swift` file in the folder tree**. There is no explicit file list. A stray duplicate causes "Multiple commands produce" build errors.
|
||
|
||
---
|
||
|
||
## 4. App Entry Point & Lifecycle
|
||
|
||
### JanitorialQCApp.swift
|
||
|
||
`@main` struct. Sets **only** `SyncManager.shared.modelContext` in the container callback. No async calls from there.
|
||
|
||
### ContentView.swift
|
||
|
||
Startup sequence in `.task {}`:
|
||
|
||
```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 — NWPathMonitor fires immediately, triggering `triggerSync()` before tokens exist.
|
||
|
||
---
|
||
|
||
## 5. Authentication
|
||
|
||
### AuthManager
|
||
|
||
`@MainActor class AuthManager: ObservableObject` — singleton via `AuthManager.shared`.
|
||
|
||
**Session restore:** Validates stored token via `GET /api/v1/auth/me`. On `notAuthenticated` → clears Keychain. On other errors (network) → restores from Keychain and sets authenticated (offline launch).
|
||
|
||
**Logout:** Calls `POST /api/v1/auth/logout` best-effort, clears Keychain, sets `isAuthenticated = false`.
|
||
|
||
### KeychainHelper
|
||
|
||
`nonisolated` static methods. All keys use `kSecAttrAccessibleAfterFirstUnlock`.
|
||
|
||
Stored keys (all prefixed `com.jqc.`): `accessToken`, `refreshToken`, `userId`, `userRole`, `username`, `displayName`.
|
||
|
||
---
|
||
|
||
## 6. SwiftData Models
|
||
|
||
### Model Container Registration
|
||
|
||
```swift
|
||
LocalFacility.self, LocalArea.self, LocalTemplate.self,
|
||
LocalInspection.self, LocalIssue.self, LocalScheduledInspection.self,
|
||
PendingPhoto.self, SyncQueueEntry.self
|
||
```
|
||
|
||
**SwiftData lightweight migration:** New `Bool` fields require `= false` default — without it the app crashes on launch. New non-optional model properties need inline defaults too; new **optional** properties (e.g. the `LocalIssue` handler fields) are nil-safe. **Exception:** a `@Attribute(.unique)` key must have NO default (rule 63).
|
||
|
||
### Model Reference
|
||
|
||
| Model | Role | Key fields |
|
||
|---|---|---|
|
||
| `LocalFacility` | Read-only cached facility reference | `serverId` (`@Attribute(.unique)`), `name`, `projectId`, `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`, `scheduledInspectionServerId` (Int?, inline default — links the submission to the schedule it fulfils), `submitLatitude` (Double?), `submitLongitude` (Double?) |
|
||
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) |
|
||
| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` |
|
||
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
||
| `SyncQueueEntry` | Outbox entry (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"`).
|
||
|
||
---
|
||
|
||
## 7. Offline-First Architecture
|
||
|
||
```
|
||
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)
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Sync Engine (SyncManager)
|
||
|
||
`@MainActor class SyncManager: ObservableObject` — singleton via `SyncManager.shared`.
|
||
|
||
### triggerSync() guard
|
||
|
||
```swift
|
||
guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return }
|
||
```
|
||
|
||
### triggerSync() — processing order
|
||
|
||
1. **`processPhotoQueue`** — uploads all `PendingPhoto` with `uploadStatus == "pending"`. On success: propagates `serverPath` to `LocalInspection.formData[fieldId]` (inspection image fields) or appends to `LocalIssue.photoServerPaths` (issue photos). Fetch-all + filter in Swift — no `#Predicate`.
|
||
|
||
2. **`processInspectionQueue`** — submits completed inspections when all `pendingPhotos` are settled.
|
||
|
||
3. **`processIssueQueue`** — guards against submitting when parent inspection `syncStatus == "failed"`. After successful submit: sets `syncStatus = "synced"`, **clears `photoLocalPaths = []`** (prevents duplicate photo sections in `IssueDetailView`), then calls `updateIssuePhotos(issueId:resultPhotos:)` for any extra photos beyond the first (`Array(photoServerPaths.dropFirst())`).
|
||
|
||
4. **`pullReferenceData`** — fetches facilities, areas, templates. **Deduplicates facility response by `id` using `seenFacilityIds = Set<Int>()`** before upserting — prevents duplicate buildings in pickers when server returns same facility ID multiple times.
|
||
|
||
5. **`pullAssignedIssues`** — fetches `GET /api/v1/issues`. Merges `api.photoPath` + `api.mobilePhotoPaths` into `photoServerPaths`. **Does NOT include `api.resultPhotos`** — resolution photos are web-only. Deletion pass runs always (not short-circuited on empty response).
|
||
|
||
6. **`pollNotifications`** — fetches new notifications since `lastNotificationFetch` cursor.
|
||
|
||
### Server-pulled issue identification
|
||
|
||
Records inserted by `pullAssignedIssues` are identified by: `syncStatus == "synced"` AND `inspectionLocalId == ""`. These are the only records safe to delete during reconciliation.
|
||
|
||
### clearServerPulledData() — on logout / server switch
|
||
|
||
Deletes every `LocalIssue` where `serverId != nil`. This covers:
|
||
- Server-pulled assigned issues (`inspectionLocalId == ""`, `syncStatus == "synced"`)
|
||
- Inspector-created issues that already synced (`inspectionLocalId != ""`, `serverId != nil`)
|
||
|
||
Preserves only truly pending device-created issues (`serverId == nil`, `syncStatus == "pending"`).
|
||
|
||
### Notification polling
|
||
|
||
- `startPollTask()` creates a `Task` with `Task.sleep(nanoseconds: 60_000_000_000)` loop.
|
||
- **`Timer.scheduledTimer` is banned** — inside `Task { @MainActor }`, `RunLoop.current ≠ RunLoop.main`; timer fires never.
|
||
- `resetNotificationPoller()` cancels task + clears cursor. Call on logout.
|
||
|
||
### Fetch pattern — CRITICAL for Xcode 26
|
||
|
||
**Never use `#Predicate` in `SyncManager`.** Use fetch-all + filter in Swift. Always parenthesise `try?` before `??`:
|
||
|
||
```swift
|
||
// CORRECT
|
||
let all = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||
let x = all.filter { ... }
|
||
```
|
||
|
||
---
|
||
|
||
## 9. API Client (APIClient)
|
||
|
||
`actor APIClient` — singleton via `APIClient.shared`. All methods are `async throws`.
|
||
|
||
All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.baseURL` no longer exists.**
|
||
|
||
### JSON decoding
|
||
|
||
`decoder.keyDecodingStrategy = .convertFromSnakeCase` — snake_case server fields map to camelCase automatically. Server POST body keys are snake_case (`photo_path`, `result_photos`, `facility_id`, etc.).
|
||
|
||
### Key methods
|
||
|
||
| Method | Endpoint | Notes |
|
||
|---|---|---|
|
||
| `request<T>` | Any | Generic; 401 auto-refresh once |
|
||
| `post<T>` | Any | POST convenience |
|
||
| `uploadPhoto` | `POST /api/v1/photos/upload` | Multipart form-data; `entity_type="issue"` → `uploads/issue_photos/` |
|
||
| `submitInspection` | `POST /api/v1/inspections` | Sanitises `local://` paths |
|
||
| `submitIssue` | `POST /api/v1/issues` | Sends `photo_path` = first server photo only |
|
||
| `updateIssuePhotos` | `PATCH /api/v1/issues/<id>/photos` | Sends `{ "result_photos": [extra paths] }`; stored server-side in `mobile_photo_paths` |
|
||
| `fetchAssignedIssues` | `GET /api/v1/issues` | Returns issues assigned to OR reported by current user |
|
||
| `fetchIssueDetail` | `GET /api/v1/issues/<id>` | Fetches current status |
|
||
| `updateIssueStatus` | `PATCH /api/v1/issues/<id>/status` | Inspector updates status |
|
||
| `fetchNotifications` | `GET /api/v1/notifications` | Optional `since: Date` cursor |
|
||
| `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks IDs read on server |
|
||
| `fetchScheduledInspections` | `GET /api/v1/scheduled-inspections` | Active scheduled assignments; inspector-scoped server-side. Pulled into `LocalScheduledInspection` |
|
||
| `updateIssueHandler` | `PATCH /api/v1/issues/<id>/handler` | Sets "Handled By". Body `["handler_type": …]` + optional snake_case detail keys (rule 65). Inspector-writable (server scopes by facility) |
|
||
|
||
### `APIAssignedIssue` fields
|
||
|
||
```swift
|
||
let photoPath: String? // primary evidence photo
|
||
let mobilePhotoPaths: [String] // extra evidence photos from iPad (mobile_photo_paths)
|
||
let resultPhotos: [String] // resolution photos — NOT stored in photoServerPaths on iPad
|
||
// Phase A:
|
||
let resultNotes: String? // resolution notes from web staff
|
||
let verifiedAt: String? // ISO 8601 — when fix was verified
|
||
let verificationNote: String? // verifier note
|
||
let reportedByName: String? // reporter display_name
|
||
// Phase E:
|
||
let areaName: String? // area the issue was flagged in
|
||
let assignedToName: String? // assigned user display_name
|
||
// Handler ("Handled By", July 2026):
|
||
let handlerType: String? // "internal" | "facility" | "vendor"
|
||
let handlerLabel: String? // human-readable label
|
||
let facilityHandlerName: String?, facilityHandlerContact: String?, facilityHandlerNotes: String?
|
||
let vendorName: String?, vendorContact: String?, vendorNotes: String?
|
||
```
|
||
|
||
`SyncManager.pullAssignedIssues` merges `photoPath + mobilePhotoPaths` into `photoServerPaths`. `resultPhotos` is decoded but intentionally excluded from `photoServerPaths` — resolution photos are web-only. All Phase A/E and handler optional fields are persisted to `LocalIssue` on both insert and update paths; `refreshStatusFromServer()` also refreshes the handler fields from `APIIssueDetail` (skipped while the inspector is mid-edit). The same handler fields exist on `APIIssueDetail`.
|
||
|
||
---
|
||
|
||
## 10. Navigation & View Hierarchy
|
||
|
||
```
|
||
JanitorialQCApp
|
||
└── ContentView (auth gate + startup lifecycle)
|
||
├── LoginView (unauthenticated)
|
||
│ └── Server picker (segmented: jqc Primary / jqc1 Secondary)
|
||
└── DashboardView (authenticated)
|
||
├── Sidebar (NavigationSplitView — no selection: binding)
|
||
│ ├── Dashboard → DashboardStatsView [default landing — KPI tiles]
|
||
│ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar]
|
||
│ ├── Issues → IssuesListView [+ button → StandaloneIssueView; resolved excluded]
|
||
│ ├── Facilities → FacilitiesListView
|
||
│ ├── Pending Sync → SyncStatusView
|
||
│ ├── History → InspectionHistoryView
|
||
│ ├── Notifications → NotificationsView [red badge when unreadCount > 0]
|
||
│ └── Settings → SettingsView [server picker + logout alert]
|
||
└── (sidebar has NO + button)
|
||
```
|
||
|
||
**`NavigationSplitView` constraint:** `init(selection:content:)` unavailable on iPadOS 17. Use `@State var selectedTab: SidebarTab` with `Button` handlers. **Never add a `selection:` binding.**
|
||
|
||
**`+` button placement:** The new inspection `+` button lives on `MyInspectionsView` (not the sidebar) so it remains accessible when the sidebar is collapsed. The new issue `+` button lives on `IssuesListView`.
|
||
|
||
---
|
||
|
||
## 11. Inspection Workflow
|
||
|
||
### 1. Start (`StartInspectionView`)
|
||
|
||
Contract → Facility cascade pickers (same as `StandaloneIssueView`). `onChange(of: selectedProjectId)` guards against resetting pre-filled facility — checks `facilityBelongsToContract` before clearing `selectedFacilityId`.
|
||
|
||
### 2. Execute (`ExecuteInspectionView`)
|
||
|
||
- Renders 12-column grid form. Auto-saves every 30 seconds.
|
||
- Submit: computes score, sets `status = "completed"`, `syncStatus = "pending"`, calls `clearParentFollowUpFlag()`, triggers sync.
|
||
- Shows success banner 2.5 seconds then dismisses.
|
||
|
||
**GPS capture (Phase 25):** `InspectionLocationManager` (thin `CLLocationManager` wrapper defined at the bottom of `ExecuteInspectionView.swift`) begins acquiring a fix the moment the submit confirm dialog appears. On successful fix, `inspection.submitLatitude` and `inspection.submitLongitude` are set before the inspection is marked completed. GPS is best-effort — nil on permission denial or location failure; submission still proceeds normally.
|
||
|
||
`APIClient.submitInspection` sends `submit_latitude` / `submit_longitude` only when non-nil. The `PATCH` endpoint does not accept GPS fields — creation-time (POST) capture only. The server displays a Google Maps embed in `inspections/view.html` for admin/director when both fields are present.
|
||
|
||
### Scheduled inspections (phase36, July 2026)
|
||
|
||
`ScheduledInspectionsView.swift` holds `ScheduledRow` + `ScheduledInspectionsCard`. The card renders on the Dashboard (`DashboardStatsView`); `MyInspectionsView` renders its own inline "Scheduled" `List` section reusing `ScheduledRow`. Both query `LocalScheduledInspection` (sorted by `dueDateString`), self-hide when empty, and tap-to-Start opens `StartInspectionView(preFillTemplateId:preFillFacilityId:preFillScheduleId:)`. Data is pulled read-only by `pullScheduledInspections()` (see rules 63–64 for the model + cover pitfalls hit while building it).
|
||
|
||
**Fulfilling the schedule (July 2026 fix).** `preFillScheduleId` is the schedule's `serverId`; `startInspection()` copies it onto `LocalInspection.scheduledInspectionServerId`, and `submitInspection()` sends it as **`scheduled_inspection_id`**. The server then fulfils the schedule (one-time → deactivated, recurring → rolled forward) in the same commit as the inspection.
|
||
|
||
Without it — the original bug — the schedule was never fulfilled: the banner stayed on the inspector's Dashboard and My Inspections, it stayed on the web dashboard for admin/director, and the web inspection list showed no "Scheduled" badge. **Both** Start call sites must pass `preFillScheduleId` (`ScheduledInspectionsCard` and the `MyInspectionsView` inline section); re-inspection launches correctly leave it nil.
|
||
|
||
No SyncManager change was needed: `pullScheduledInspections()` already runs after `processInspectionQueue()` in the same `triggerSync()` pass and deletes rows the server no longer returns, so the section clears on the same sync that submits the inspection.
|
||
|
||
### Inspection-start presentation (July 2026)
|
||
|
||
All start flows are full-screen for consistency (rule 66): draft-resume (dashboard) → `.fullScreenCover` → `ExecuteInspectionView(isModallyPresented: true)` with a leading `Close`; scheduled / new (`+`) / re-inspection → `.fullScreenCover` → `StartInspectionView` (its own Cancel). Pushed presentations (My Inspections row → `ExecuteInspectionView`) keep `isModallyPresented = false` and rely on the nav back button.
|
||
|
||
### "Handled By" (issue handler, phase35 → mobile July 2026)
|
||
|
||
`IssueDetailView` (`IssuesView.swift`) shows a "Handled By" section: current handler label + detail, and — for admin/director/PM **and the assigned inspector** — an inline editor (segmented internal/facility/vendor + name/contact/notes) that PATCHes via `updateIssueHandler` and mirrors the result onto `LocalIssue`. The inspector-writable path is a deliberate divergence from the web form (web CLAUDE.md rule 78).
|
||
|
||
### 3. Draft management
|
||
|
||
Swipe-left delete (confirmation required). Deletes draft + `PendingPhoto` records + local photo files + associated `LocalIssue` records. Only `status == "draft"` inspections may be deleted.
|
||
|
||
---
|
||
|
||
## 12. Form Field Rendering
|
||
|
||
### GridFormView — 12-column absolute-position grid
|
||
|
||
| Constant | Value |
|
||
|---|---|
|
||
| `totalColumns` | 12 |
|
||
| `cellGap` | 8 pt |
|
||
| `rowGap` | 4 pt |
|
||
| `cellAspect` | 52/72 |
|
||
| `cardPadding` | 16 pt |
|
||
|
||
### Field ID rule (CRITICAL)
|
||
|
||
Form schema IDs arrive as `Int` in `[String: Any]`. Always cast:
|
||
|
||
```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"]`** — produces `"Optional(5)"` instead of `"5"`.
|
||
|
||
---
|
||
|
||
## 13. Issue Flagging Workflow
|
||
|
||
`FlagIssueView` — presented as sheet from `ExecuteInspectionView`. Sets `inspectionLocalId = inspection.localId`, `areaServerId = inspection.areaServerId`, and appends to `inspection.localIssues`. Creates `PendingPhoto` with `entityType = "issue"`. `areaServerId` is sent as `area_id` in `submitIssue()`.
|
||
|
||
**Issue sync guard:** If parent `inspection.syncStatus == "failed"`, issue is immediately marked `"failed"` — prevents orphaned server records.
|
||
|
||
---
|
||
|
||
## 14. Standalone Issue Creation
|
||
|
||
`StandaloneIssueView` — presented as sheet from `IssuesListView` via `+` button.
|
||
|
||
- **Contract picker** → `Picker` with `.navigationLink` style. `onChange` resets `selectedFacilityId` unless the current facility already belongs to the new contract.
|
||
- **Facility picker** — gated: only appears after a contract is selected. Shows only facilities for the selected `projectId`.
|
||
- Severity segmented picker + description + up to 5 photos (camera + library).
|
||
- On submit: creates `LocalIssue` with `inspectionLocalId = ""` (same as server-pulled issues). `processIssueQueue` picks it up and submits — the parent guard (`parent?.syncStatus == "failed"`) evaluates to `false` for `""` `inspectionLocalId` so it proceeds normally.
|
||
|
||
**Contract/facility data source:** `@Query(sort: \LocalFacility.name)` — same cached reference data as `StartInspectionView`. `contracts` computed var deduplicates by `projectId` using `var seen = Set<Int>()`. `filteredFacilities` deduplicates by `serverId` using the same pattern.
|
||
|
||
---
|
||
|
||
## 15. Re-inspection Workflow
|
||
|
||
### Trigger
|
||
|
||
`CompletedInspectionView` shows orange banner with **Start Re-inspection** when `followUpRequired == true`. Opens `StartInspectionView` with `preFillTemplateId`, `preFillFacilityId`, `parentServerId`, `parentLocalId`.
|
||
|
||
### followUpRequired clearing — three points
|
||
|
||
1. Immediately on Submit in `ExecuteInspectionView.submitInspection()` via `clearParentFollowUpFlag()`.
|
||
2. After sync in `SyncManager.processInspectionQueue`.
|
||
3. On the server when PATCH/POST arrives.
|
||
|
||
---
|
||
|
||
## 16. Inspection History
|
||
|
||
`InspectionHistoryView` — online-only. Fetches `GET /api/v1/inspections?status=completed`. Pagination: limit 30, offset-based. Uses `RetryablePhotoView` for server photos.
|
||
|
||
---
|
||
|
||
## 17. Photo Handling
|
||
|
||
### Capture
|
||
|
||
`UIImagePickerController` (camera) or `PHPickerViewController` (library, no permission required iOS 16+).
|
||
|
||
### Local storage
|
||
|
||
`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
|
||
Inspection image fields: LocalInspection.formData[fieldId] = serverPath
|
||
Issues: LocalIssue.photoServerPaths.append(serverPath)
|
||
```
|
||
|
||
### Multi-photo issue submission sequence
|
||
|
||
```
|
||
1. processPhotoQueue: uploads all N photos → appends each serverPath to issue.photoServerPaths
|
||
2. processIssueQueue: submitIssue(issue) → sends photo_path = photoServerPaths[0]
|
||
issue.photoLocalPaths = [] (clear local paths — prevents duplicate sections)
|
||
updateIssuePhotos(issueId, photoServerPaths.dropFirst())
|
||
→ PATCH /issues/<id>/photos with extras
|
||
```
|
||
|
||
### Photo display in IssueDetailView
|
||
|
||
Gated on `syncStatus`:
|
||
|
||
- **`syncStatus != "synced"` (pending/failed):** Shows `photoLocalPaths` section only — local files from disk via `UIImage(contentsOfFile:)`. `photoServerPaths` may be partially populated from mid-sync uploads; hiding it prevents a mix of working and broken images.
|
||
- **`syncStatus == "synced"`:** Shows `photoServerPaths` section only via `RetryablePhotoView`. `photoLocalPaths` has been cleared by `processIssueQueue`.
|
||
|
||
### Server photo URL construction
|
||
|
||
All server photo URLs: `ServerConfig.current + "/static/" + relativePath`
|
||
|
||
The server stores photos under `app/static/uploads/` and serves them via Flask's static handler at `/static/`. The `/static/` prefix is **required** — omitting it returns 404.
|
||
|
||
### RetryablePhotoView
|
||
|
||
`struct RetryablePhotoView: View` defined in `DashboardView.swift`, used by both `IssueDetailView` and `InspectionHistoryView`.
|
||
|
||
- Holds `@State private var reloadToken = UUID()`.
|
||
- `AsyncImage` carries `.id(reloadToken)` — toggling the UUID forces SwiftUI to destroy and recreate the `AsyncImage`, triggering a fresh network request.
|
||
- On `.failure`: shows "Photo unavailable" label + **Retry** button that sets `reloadToken = UUID()`.
|
||
- `AsyncImage` has no built-in retry. Once it enters `.failure` (transient network error, SSL hiccup) it stays there for the view's lifetime. `RetryablePhotoView` is the fix.
|
||
- Uses `transaction: Transaction(animation: .easeIn)` for fade-in on success.
|
||
|
||
### SF Symbol availability
|
||
|
||
Only use symbols available on iOS 17+. Known unavailable symbols:
|
||
- `photo.slash` — **BANNED**, does not exist on all iOS 17 devices. Use `exclamationmark.triangle`.
|
||
- `photo.badge.exclamationmark` — introduced iOS 16 but not universally available. Use `exclamationmark.triangle`.
|
||
|
||
### local:// sentinel
|
||
|
||
While a photo is pending upload, the inspection form field value is `"local://<path>"`. Before `submitInspection` sends `formData`, these are replaced with `""`. A `local://` value reaching the server is a malformed path.
|
||
|
||
---
|
||
|
||
## 18. Score Calculation
|
||
|
||
`LocalInspection.computeScore(fromSchema:)` mirrors Python's `_compute_score_from_form()`.
|
||
|
||
**Scoreable:** `rating`, `checkbox`, `radio`, `pass_fail`.
|
||
|
||
| Type | Rule |
|
||
|---|---|
|
||
| `rating` | `0` = unanswered → excluded. Each answered rating: `value / 5` of 1.0 |
|
||
| `checkbox` | `"true"` = pass |
|
||
| `radio` | Pass: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant` (case-insensitive) |
|
||
| `pass_fail` | Same keywords. Empty = unanswered → excluded |
|
||
|
||
Returns `nil` if no scoreable fields or all unanswered.
|
||
|
||
---
|
||
|
||
## 19. Background Sync
|
||
|
||
Registers `BGProcessingTask` with identifier `com.jqc.sync`. `Info.plist` requirement: `BGTaskSchedulerPermittedIdentifiers` must contain `com.jqc.sync`. Project uses `GENERATE_INFOPLIST_FILE = YES` — **no physical `Info.plist` in source tree**.
|
||
|
||
Requirements: `requiresNetworkConnectivity = true`, `requiresExternalPower = false`.
|
||
|
||
---
|
||
|
||
## 20. Settings & Cache Management
|
||
|
||
`SettingsView` exposes:
|
||
- **Account info** — username, role (read-only).
|
||
- **Sync Now** — triggers `triggerSync()`; disabled when offline or syncing.
|
||
- **Clear Reference Cache** — deletes `LocalFacility`, `LocalArea`, `LocalTemplate` only. Never touches `LocalInspection`, `LocalIssue`, `PendingPhoto`. Triggers `pullReferenceData()` if online.
|
||
- **Server picker** — see §21.
|
||
- **Log Out** — calls `clearServerPulledData()` + `resetNotificationPoller()` + `auth.logout()`.
|
||
- App version + current server URL (from `ServerConfig.current`).
|
||
|
||
---
|
||
|
||
## 21. Server Selection
|
||
|
||
### Architecture
|
||
|
||
Server is selected at runtime from two options. Selection persists to `UserDefaults` (not Keychain — not a secret).
|
||
|
||
```swift
|
||
enum ServerOption: String, CaseIterable, Sendable {
|
||
case primary = "https://jqc.ltservicesinc.com" // default
|
||
case secondary = "https://jqc1.ltservicesinc.com"
|
||
}
|
||
|
||
enum ServerConfig {
|
||
static var current: String { /* reads UserDefaults */ }
|
||
@MainActor static func select(_ option: ServerOption)
|
||
@MainActor static var selectedOption: ServerOption
|
||
}
|
||
```
|
||
|
||
**`Constants.baseURL` no longer exists.** All code that previously read `Constants.baseURL` now reads `ServerConfig.current`.
|
||
|
||
### Login page
|
||
|
||
Segmented picker above the credential fields. `onChange` calls `ServerConfig.select(newValue)` immediately. The very next `Sign In` tap hits the selected server — no restart required when changing at login.
|
||
|
||
### Settings page
|
||
|
||
Segmented picker in a "Server" section. `onChange` snaps the picker back to the current saved server, stores intent in `pendingServer`, and shows `Alert("Switch Server?")`.
|
||
|
||
**Alert actions:**
|
||
- **Switch & Log Out (destructive):** `ServerConfig.select(chosen)` → `clearServerPulledData()` → `resetNotificationPoller()` → `auth.logout()`.
|
||
- **Cancel:** clears `pendingServer`, picker stays on original.
|
||
|
||
**Why logout is required on server switch:** `serverId` values are server-specific. A `LocalIssue` with `serverId = 48` from `jqc.ltservicesinc.com` has no meaning on `jqc1.ltservicesinc.com`. Keeping stale records causes "Issue not found" errors on every status fetch/update.
|
||
|
||
### clearServerPulledData() boundary
|
||
|
||
Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` records (pending, never synced). This is the correct boundary — not `syncStatus == "synced" && inspectionLocalId == ""` (the old incorrect filter that missed inspector-created synced issues).
|
||
|
||
---
|
||
|
||
## 22. Known Constraints & Hard Rules
|
||
|
||
| # | Rule | Rationale |
|
||
|---|---|---|
|
||
| 1 | **`import Combine` required in files using `@Published`** | Swift 5.9+ does not auto-import Combine |
|
||
| 2 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17 |
|
||
| 3 | **No `#Predicate` anywhere in `SyncManager`** | Xcode 26 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` causes `LocalInspection is ambiguous` cascade |
|
||
| 4 | **Sequential `await` in `pullReferenceData()`** | `async let` causes Swift 6 actor-isolation warnings on `Decodable` |
|
||
| 5 | **PencilKit requires explicit framework linkage** | Add `PencilKit.framework` under Target → Frameworks |
|
||
| 6 | **Free Apple ID provisioning expires every 7 days** | Rebuild with ⌘R while iPad is connected |
|
||
| 7 | **`kSecAttrAccessibleAfterFirstUnlock` for all Keychain items** | Tokens readable after reboot for BGTaskScheduler |
|
||
| 8 | **New `Bool` model fields require `= false` default** | SwiftData lightweight migration crashes without default |
|
||
| 9 | **Field IDs in formData are always String keys** | Server encodes as Int; cast via `as? String` then `as? Int → String(n)`. Never `Optional.map` |
|
||
| 10 | **Strip `local://` paths from formData before `submitInspection`** | JSONSerialization silently drops non-serialisable values |
|
||
| 11 | **Do not submit an issue when parent inspection `syncStatus == "failed"`** | Creates orphaned server records |
|
||
| 12 | **Photo-before-inspection ordering in sync** | `processPhotoQueue` runs before `processInspectionQueue` |
|
||
| 13 | **`com.jqc.sync` must be in BGTaskSchedulerPermittedIdentifiers** | Silently ignored if absent |
|
||
| 14 | **`refreshAccessToken()` uses a local JSONDecoder, not `self.decoder`** | Actor-isolation error in Swift 6 |
|
||
| 15 | **`clearParentFollowUpFlag()` fallback-2 is ambiguous** | Matching by template+facility ambiguous with multiple follow-ups |
|
||
| 16 | **`SyncQueueEntry` model registered but not actively written** | Future use; `syncStatus` on models is the active queue |
|
||
| 17 | **All server URLs read from `ServerConfig.current`** | `Constants.baseURL` no longer exists. Every endpoint: `ServerConfig.current + path`. Never hardcode a server URL. |
|
||
| 18 | **Photo JPEG compression is 0.8** | Do not raise above 0.85 without testing 50 MB server 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 |
|
||
| 21 | **`startMonitoring()` must be called AFTER `restoreSession()` completes** | NWPathMonitor fires immediately; 401 loop leaves `isLoading` stuck |
|
||
| 22 | **`triggerSync()` guards on `AuthManager.shared.isAuthenticated`** | Prevents sync before auth established |
|
||
| 23 | **Do NOT place model files in non-Model folders** | Xcode 26 folder sync compiles every `.swift`; duplicate causes build error |
|
||
| 24 | **No physical `Info.plist` when `GENERATE_INFOPLIST_FILE = YES`** | "Multiple commands produce Info" build error |
|
||
| 25 | **Always parenthesise `try?` before `??`** | `try? fetch(...) ?? []` parses as `try? (fetch() ?? [])` — nonsensical |
|
||
| 26 | **Delete Xcode's default `Item.swift` immediately after project creation** | Compiles silently via folder sync, conflicts with real models |
|
||
| 27 | **`Timer.scheduledTimer` must NOT be used for periodic work in SyncManager** | `RunLoop.current ≠ RunLoop.main` inside `Task { @MainActor }`; timer fires never |
|
||
| 28 | **`UNUserNotificationCenterDelegate` must be set for foreground notifications** | Without it, iOS drops local notifications while app is active |
|
||
| 29 | **All `Decodable & Sendable` structs need `nonisolated init(from:)`** | `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` taints synthesised Decodable inits |
|
||
| 30 | **`pullAssignedIssues` deletion pass must NOT be short-circuited on empty response** | If server returns zero issues, deletion pass must still run |
|
||
| 31 | **Server-pulled issues: `syncStatus == "synced"` + `inspectionLocalId == ""`** | Only these are safe to delete during reconciliation. Standalone device-created issues have `inspectionLocalId == ""` too but `serverId == nil` until synced — they survive. |
|
||
| 32 | **`StartInspectionView.onChange(of: selectedProjectId)` guards pre-filled facility** | Check `facilityBelongsToContract` before clearing `selectedFacilityId` |
|
||
| 33 | **`photoServerPaths` = `photo_path` + `mobile_photo_paths` only** | `result_photos` (resolution photos) is intentionally excluded from `photoServerPaths` on iPad |
|
||
| 34 | **`IssueDetailView` photo display is gated on `syncStatus`** | Pending: show `photoLocalPaths` only. Synced: show `photoServerPaths` only. Never both simultaneously — mixed state causes "Photo unavailable" for mid-upload server paths |
|
||
| 35 | **`SyncManager.isoFormatter` is the only date formatter — never allocate per-call** | `DateFormatter` init is expensive; `nonisolated static let` with `en_US_POSIX` locale |
|
||
| 36 | **`uploadPhoto(retrying:)` — pass `retrying: true` on recursive retry** | Prevents double token refresh on 401 during retry |
|
||
| 37 | **`LocalIssue.inspection` must declare explicit `@Relationship` inverse** | `@Relationship(deleteRule: .nullify, inverse: \LocalInspection.localIssues)` prevents implicit inverse ambiguity |
|
||
| 38 | **`APIUser.displayName` uses `fullName` when non-empty** | `fullName.isEmpty ? username : fullName` — mirrors `User.display_name` server-side |
|
||
| 39 | **Server photo URLs include `/static/` prefix** | Server stores at `app/static/uploads/`; Flask serves at `/static/uploads/`. URL = `ServerConfig.current + "/static/" + relativePath`. Missing `/static/` returns 404. |
|
||
| 40 | **Use `RetryablePhotoView` for all server photo loads** | `AsyncImage` has no retry — once in `.failure` it stays there for the view's lifetime. `RetryablePhotoView` allows tap-to-retry by toggling `.id(reloadToken)`. |
|
||
| 41 | **Only use SF Symbols available on iOS 17** | `photo.slash` and `photo.badge.exclamationmark` are absent on some devices. Use `exclamationmark.triangle` for all photo-error states. |
|
||
| 42 | **`clearServerPulledData()` boundary is `serverId != nil`** | Old boundary `syncStatus == "synced" && inspectionLocalId == ""` missed inspector-created synced issues, leaving stale serverIds that caused "Issue not found" after server switch. |
|
||
| 43 | **`processIssueQueue` clears `photoLocalPaths` after successful submit** | Prevents `IssueDetailView` from rendering a duplicate "local photos" section alongside the server photos section for synced issues. |
|
||
| 44 | **`StandaloneIssueView` uses `inspectionLocalId = ""`** | Same pattern as server-pulled issues. `processIssueQueue`'s parent-inspection guard evaluates `parent?.syncStatus == "failed"` → `false` for `""`, so standalone issues submit normally. |
|
||
| 45 | **Facility lists deduplicate by `serverId` at both storage and display layers** | Storage: `pullReferenceData()` deduplicates server response before upsert. Display: `filteredFacilities` in both `StartInspectionView` and `StandaloneIssueView` uses `filter { seen.insert($0.serverId).inserted }`. |
|
||
| 46 | **`LocalIssue` Phase A–E fields all use nil-default optionals** | `String?`, `Date?` optionals default to nil. SwiftData lightweight migration supports nil-default optional properties without a migration plan. |
|
||
| 47 | **`IssueDetailView` comments block must be inside `List { }`** | `.navigationTitle` and `.task` must chain on the `List` view. Placing the `if sync.isOnline { }` comments block outside `List` causes `Instance member ‘navigationTitle’ cannot be used on type ‘View’` build error. |
|
||
| 48 | **`IssuesListView` filters resolved in Swift, not via `#Predicate`** | `@Query` returns `allIssues`; computed `var issues` filters `$0.issueStatus != "resolved"`. `#Predicate` with string literals on `LocalIssue` is unreliable under Xcode 26 `SWIFT_DEFAULT_ACTOR_ISOLATION` (rule 3). |
|
||
| 49 | **`SyncManager.fetchDashboardStats()` is best-effort — never blocks pipeline** | Called last in `triggerSync()`. Failure leaves `dashboardStats` nil; UI shows placeholder. Never propagates errors. |
|
||
| 50 | **`NotificationsView.markNotificationsViewed()` on both `.onAppear` and sidebar tap** | Sidebar tap calls `sync.markNotificationsViewed()` inline to clear the badge immediately without waiting for navigation. |
|
||
| 51 | **`FlagIssueView.submitIssue()` must copy `inspection.areaServerId` to `issue.areaServerId`** | Without this, the server cannot link the issue to the correct area. `APIClient.submitIssue` sends `area_id` only when `issue.areaServerId` is non-nil. |
|
||
| 52 | **`LocalIssue.swift` zip delivery must include all Phase A–E fields** | When packaging, copy from the working-tree file and verify every field with `grep` before zipping. Partial field sets cause `SyncManager` build failures. The recurring hotfix pattern traces to this: each phase patched the working tree but the prior phase’s file was not in working tree. Fix: always `cp` back immediately after creating a phase file. |
|
||
| 53 | **`submitLatitude`/`submitLongitude` on `LocalInspection` are `Double?` optionals** | SwiftData lightweight migration supports nil-default optionals without a migration plan. `APIClient.submitInspection` sends them only when non-nil via `if let lat = inspection.submitLatitude`. Never make them non-optional — GPS is best-effort and must not block submission on permission denial or hardware failure. |
|
||
| 54 | **`InspectionLocationManager.startUpdating()` called when confirm dialog appears, not at view load** | Starting too early wastes battery. The confirm dialog provides a natural ~1–2 second window before the user taps Confirm, giving the manager time to acquire a fix. GPS captured into `LocalInspection` immediately before marking `status = "completed"`. |
|
||
| 55 | **`StartInspectionView` must filter templates in Swift, not via `@Query` predicate** | `@Query(sort: \LocalTemplate.name)` fetches all into `allTemplates`; computed `var templates` filters `{ $0.isActive }`. `#Predicate` with `isActive` is unreliable under Xcode 26 rule 3. |
|
||
| 56 | **`LocalTemplate.isActive` must have inline default `= true`** | Added in this session. SwiftData lightweight migration requires all new `Bool` fields to carry an inline default (rule 8). `GET /api/v1/templates` now only returns active templates; `pullReferenceData` deletes cached templates not in the server response so inactive ones never appear in pickers even offline. |
|
||
| 57 | **`upsertAreas` must set `newArea.facility = facility` at insert time** | SwiftData relationship wiring requires the inverse to be explicitly assigned. Without `newArea.facility = facility`, `LocalFacility.areas` is always empty and the area picker shows nothing. The `LocalFacility` object (from `facilityMap` or just inserted) is passed into `upsertAreas` as a parameter. Also re-wires on update: `if ex.facility == nil { ex.facility = facility }`. |
|
||
| 58 | **`ReadOnlyGridFormView` uses `rowView` (GeometryReader + ZStack), NOT a ZStack canvas or LazyVGrid** | ZStack canvas: gaps from unanswered rows because y-offsets are absolute. LazyVGrid: ignores `col` position, flows items sequentially. Correct approach: group fields by original `row` into `RowGroup`s, render each group as a `GeometryReader` that divides width by 12 to get `colW`, positions each field with `.offset(x: colW * (col-1))` and `.frame(width: colW * colSpan)`. `VStack(spacing: 3)` between rows. Row height fixed at 36pt (section headers 28pt). |
|
||
| 59 | **Read-only inspection detail: only answered fields are shown — filtering is 5-pass** | Pass 1: collect `answeredIds` (rating > 0, or non-empty value). Pass 2: collect `visibleLabelIds` (labels immediately before an answered field). Pass 3: collect `visibleSectionIds` (sections with at least one answered field after them). Pass 4: group all schema fields by original `row`. Pass 5: for each row group, emit only visible fields; skip rows with no visible content. |
|
||
| 60 | **`ReadOnlyGridFormView` rows advance by 1 regardless of original `rowSpan`** | The web renders every field with `grid-row: N / span 1`. The read-only view collapses all rowSpans to 1 — no field occupies more than one row of vertical space. |
|
||
| 62 | **Sort `schema` by `(row, col)` before grouping fields into row buckets** | The form editor stores fields in creation/drag order, NOT row-numeric order. Section fields have their own row numbers but may appear anywhere in the JSON array. Any code that groups fields by `row` and attaches section headers must first sort by `(f["row"], f["col"])` — exactly like the web's `sorted(key=lambda f: (f['row'], f['col']))`. Without this, sections attach to the wrong rows and appear displaced or missing. Sites that use absolute `(col, row)` pixel offsets (e.g. `GridFormView` ZStack, `canvasHeight()`) are unaffected — sort order only matters when grouping by row for sequential rendering. |
|
||
|
||
| 63 | **`@Attribute(.unique)` must NOT carry an inline default value** | A `.unique` key with a default (`@Attribute(.unique) var serverId: Int = 0`) stops the `@Model` macro from emitting a clean `PersistentModel` conformance. Symptom is misleading: the `.modelContainer(for: [ … ])` array literal fails to type-check and Xcode reports **"Cannot find '<OtherModel>' in scope" on the *other* schema elements**, not the offending one. Declare the unique key with no default (`@Attribute(.unique) var serverId: Int`) and set it in `init`, exactly like `LocalFacility`/`LocalArea`. (Bit us adding `LocalScheduledInspection`, July 2026.) |
|
||
| 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack` root instead. The dashboard scheduled card puts its cover on the VStack; `MyInspectionsView` puts the scheduled "Start" cover on the `List`. |
|
||
| 65 | **`CodingKeys` stay plain camelCase (decoder uses `.convertFromSnakeCase`); request-body keys are raw snake_case** | The shared `JSONDecoder` sets `keyDecodingStrategy = .convertFromSnakeCase`, converting JSON `facility_handler_name` → `facilityHandlerName` **before** matching — so `CodingKeys` must be bare camelCase; adding an explicit `= "facility_handler_name"` raw value double-converts and breaks decode. Conversely PATCH/POST bodies are `[String: Any]` encoded with `JSONSerialization` (no key strategy), so body keys must be the literal snake_case the server reads (`"handler_type"`, `"vendor_name"`, …). |
|
||
| 66 | **Modal (fullScreenCover root) views need an explicit Close/Cancel; pushed views get the nav back button for free** | `ExecuteInspectionView` takes `isModallyPresented` and shows a leading `Close` only when true (draft-resume from the dashboard is the root of its `NavigationStack`, no back button). All inspection-start flows now use `.fullScreenCover` for a consistent full-screen form: draft-resume → `ExecuteInspectionView(isModallyPresented: true)`; scheduled/new/re-inspection → `StartInspectionView` (its own `.cancellationAction` Cancel). `.onDisappear`/auto-save preserves work, so Close is always safe. |
|
||
| 67 | **Render server photos through `ServerConfig.mediaURL(absolute:path:)`, never by hand-building `current + "/static/" + path`** | After the R2 migration the server returns absolute display URLs (presigned R2, or absolute-static on the local backend): `photo_urls`/`result_photo_urls` on issues (`APIAssignedIssue`/`APIIssueDetail` → `LocalIssue.photoServerUrls`/`resultPhotoServerUrls`, parallel to the path arrays), and `form_media` `{fieldId: url}` on inspection detail (`APIInspectionSummary.mediaURLByPath`, injected into the read-only grid via the `\.mediaURLByPath` environment for `PhotoThumbnailView`). The resolver prefers the absolute URL and falls back to `/static/` for older servers. Presigned URLs expire (24h) — always render from the freshest pull/detail fetch; don't persist a URL and reuse it days later. |
|
||
|
||
---
|
||
|
||
## 23. Xcode 26 Specific Issues
|
||
|
||
This project was created with **Xcode 26.4.1**. See previous entries in §22 (rules 3, 4, 23, 25, 29) for the specific workarounds.
|
||
|
||
### SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
|
||
|
||
Every type and function is implicitly `@MainActor`. Effects on SwiftData `FetchDescriptor` and `#Predicate`: see §8 and rule 3.
|
||
|
||
**Solutions applied:**
|
||
1. Fetch-all + filter in Swift — no `#Predicate` with captured variables.
|
||
2. All `try? context.fetch(...)` parenthesised before `??`.
|
||
3. Chained optional patterns split into two `let` statements.
|
||
4. All API model structs declare `nonisolated init(from decoder: any Decoder)` explicitly.
|
||
|
||
**Do NOT remove `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` from build settings** unless you audit every file for the resulting isolation changes.
|
||
|
||
### PBXFileSystemSynchronizedRootGroup (Folder Sync)
|
||
|
||
Every `.swift` file in the project folder is compiled automatically. Stray files cause "Multiple commands produce" errors. Deleting from Finder is sufficient — no project navigator change needed.
|
||
|
||
### Derived Data corruption
|
||
|
||
After "Multiple commands produce" errors: delete derived data manually:
|
||
|
||
```bash
|
||
rm -rf ~/Library/Developer/Xcode/DerivedData/<ProjectName>-<hash>
|
||
```
|
||
|
||
Do not rely on Product → Clean Build Folder alone.
|
||
|
||
---
|
||
|
||
## 24. 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.** 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`.
|
||
7. **Test offline and online.** Every sync-related fix must be verified in airplane mode.
|
||
8. **Verify file placement.** 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.
|
||
10. **Update this document** at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.
|