05/08 Add claude.md
This commit is contained in:
@@ -0,0 +1,588 @@
|
|||||||
|
# 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 1–3 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.
|
||||||
Reference in New Issue
Block a user