05/21 Update claude.md
This commit is contained in:
@@ -171,7 +171,7 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2640;
|
LastSwiftUpdateCheck = 2640;
|
||||||
LastUpgradeCheck = 2640;
|
LastUpgradeCheck = 2650;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
B378757B2FA6358E0088F40B = {
|
B378757B2FA6358E0088F40B = {
|
||||||
CreatedOnToolsVersion = 26.4.1;
|
CreatedOnToolsVersion = 26.4.1;
|
||||||
@@ -328,6 +328,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
@@ -385,6 +386,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
|
|||||||
+313
-374
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Audience:** AI assistants and developers working on the JanitorialQC iPad app.
|
> **Audience:** AI assistants and developers working on the JanitorialQC iPad app.
|
||||||
> **Purpose:** Authoritative reference for architecture, conventions, constraints, and decisions.
|
> **Purpose:** Authoritative reference for architecture, conventions, constraints, and decisions.
|
||||||
> **Last reviewed:** May 2026 (Phase 18 complete — static DateFormatter, uploadPhoto retry guard, explicit LocalIssue relationship inverse, APIUser.fullName/displayName, build fixes: SyncManager.shared restored, _RefreshEnvelope nonisolated init)
|
> **Last reviewed:** May 2026 (Phase 19 complete — server selection, standalone issue creation, multi-photo evidence, facility deduplication, RetryablePhotoView, photo URL /static/ prefix)
|
||||||
> **Companion:** See the web backend's `CLAUDE.md` for API contract, server-side rules, and migration chain.
|
> **Companion:** See the web backend's `CLAUDE.md` for API contract, server-side rules, and migration chain.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -22,15 +22,17 @@
|
|||||||
11. [Inspection Workflow](#11-inspection-workflow)
|
11. [Inspection Workflow](#11-inspection-workflow)
|
||||||
12. [Form Field Rendering](#12-form-field-rendering)
|
12. [Form Field Rendering](#12-form-field-rendering)
|
||||||
13. [Issue Flagging Workflow](#13-issue-flagging-workflow)
|
13. [Issue Flagging Workflow](#13-issue-flagging-workflow)
|
||||||
14. [Re-inspection Workflow](#14-re-inspection-workflow)
|
14. [Standalone Issue Creation](#14-standalone-issue-creation)
|
||||||
15. [Inspection History](#15-inspection-history)
|
15. [Re-inspection Workflow](#15-re-inspection-workflow)
|
||||||
16. [Photo Handling](#16-photo-handling)
|
16. [Inspection History](#16-inspection-history)
|
||||||
17. [Score Calculation](#17-score-calculation)
|
17. [Photo Handling](#17-photo-handling)
|
||||||
18. [Background Sync](#18-background-sync)
|
18. [Score Calculation](#18-score-calculation)
|
||||||
19. [Settings & Cache Management](#19-settings--cache-management)
|
19. [Background Sync](#19-background-sync)
|
||||||
20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules)
|
20. [Settings & Cache Management](#20-settings--cache-management)
|
||||||
21. [Xcode 26 Specific Issues](#21-xcode-26-specific-issues)
|
21. [Server Selection](#21-server-selection)
|
||||||
22. [Change Philosophy](#22-change-philosophy)
|
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,16 +42,18 @@
|
|||||||
|
|
||||||
Core capabilities:
|
Core capabilities:
|
||||||
- **Login/logout** via JWT against the JQC web backend API
|
- **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
|
- **Reference data sync** — facilities, areas, and inspection templates pulled from server
|
||||||
- **Inspection execution** — dynamic form rendering driven by server-side template schemas
|
- **Inspection execution** — dynamic form rendering driven by server-side template schemas
|
||||||
- **Issue flagging** — severity-tagged issues with optional photos, attached to inspections; confirmation banner shown on submit
|
- **Issue flagging** — severity-tagged issues with optional photos, attached to inspections
|
||||||
- **Re-inspection** — linked follow-up inspections with parent-form pre-fill (template, contract, facility all pre-filled)
|
- **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
|
- **Outbox queue** — completed inspections and issues submitted to server automatically when online
|
||||||
- **Inspection history** — server-side read-only history for completed inspections
|
- **Inspection history** — server-side read-only history for completed inspections
|
||||||
- **Issues list** — assigned issues pulled from server + device-created issues; stale unassigned records removed on sync
|
- **Issues list** — assigned/reported issues pulled from server + device-created issues
|
||||||
- **Issue detail** — shows local photos and server-hosted photos via `AsyncImage`
|
- **Issue detail** — shows local photos (pending) or server photos (synced) via `RetryablePhotoView`
|
||||||
- **Facilities browser** — read-only view of synced facilities and their areas
|
- **Facilities browser** — read-only view of synced facilities and their areas
|
||||||
- **Push notifications** — local notifications for issue assignments and follow-up requests via 60-second polling
|
- **Push notifications** — local notifications via 60-second polling
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -66,8 +70,9 @@ Core capabilities:
|
|||||||
| Photo capture | UIImagePickerController (camera), PHPickerViewController (library) |
|
| Photo capture | UIImagePickerController (camera), PHPickerViewController (library) |
|
||||||
| Signature capture | PencilKit (PKCanvasView) |
|
| Signature capture | PencilKit (PKCanvasView) |
|
||||||
| Background tasks | BGTaskScheduler / BGProcessingTask |
|
| Background tasks | BGTaskScheduler / BGProcessingTask |
|
||||||
|
| Server config | UserDefaults (not Keychain — not a secret) |
|
||||||
| Min deployment | iOS 17.0 |
|
| Min deployment | iOS 17.0 |
|
||||||
| Xcode | **26.4.1** (Xcode 26 beta — see §21 for critical constraints) |
|
| Xcode | **26.4.1** (Xcode 26 beta — see §23 for critical constraints) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,37 +89,46 @@ JanitorialQC/
|
|||||||
│
|
│
|
||||||
├── API/
|
├── API/
|
||||||
│ ├── APIClient.swift # actor — URLSession, JWT inject, 401-retry, photo upload
|
│ ├── APIClient.swift # actor — URLSession, JWT inject, 401-retry, photo upload
|
||||||
|
│ │ # updateIssuePhotos() — PATCH /issues/<id>/photos
|
||||||
│ └── APIModels.swift # All Codable/Sendable response DTOs
|
│ └── APIModels.swift # All Codable/Sendable response DTOs
|
||||||
|
│ # APIAssignedIssue has photoPath + mobilePhotoPaths + resultPhotos
|
||||||
│
|
│
|
||||||
├── Sync/
|
├── Sync/
|
||||||
│ └── SyncManager.swift # @MainActor ObservableObject — NWPathMonitor, outbox queue
|
│ └── 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
|
├── Models/ # SwiftData @Model classes — NO other .swift files here
|
||||||
│ ├── LocalFacility.swift
|
│ ├── LocalFacility.swift # @Attribute(.unique) serverId; projectId/projectName for contracts
|
||||||
│ ├── LocalArea.swift
|
│ ├── LocalArea.swift
|
||||||
│ ├── LocalTemplate.swift
|
│ ├── LocalTemplate.swift
|
||||||
│ ├── LocalInspection.swift # ← ONLY definition of LocalInspection — never duplicate
|
│ ├── LocalInspection.swift
|
||||||
│ ├── LocalIssue.swift
|
│ ├── LocalIssue.swift # photoLocalPathsJSON + photoServerPathsJSON (both JSON-encoded)
|
||||||
│ ├── PendingPhoto.swift
|
│ ├── PendingPhoto.swift
|
||||||
│ └── SyncQueueEntry.swift
|
│ └── SyncQueueEntry.swift
|
||||||
│
|
│
|
||||||
├── Views/
|
├── Views/
|
||||||
│ ├── Auth/
|
│ ├── Auth/
|
||||||
│ │ └── LoginView.swift # ← Auth/ folder must contain ONLY auth files — no models
|
│ │ └── LoginView.swift # Server picker (segmented) above login form
|
||||||
│ ├── Dashboard/
|
│ ├── Dashboard/
|
||||||
│ │ ├── DashboardView.swift
|
│ │ ├── DashboardView.swift # Sidebar + all detail views
|
||||||
│ │ ├── StartInspectionView.swift
|
│ │ │ # 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
|
│ │ ├── ExecuteInspectionView.swift
|
||||||
│ │ ├── FlagIssueView.swift
|
│ │ ├── FlagIssueView.swift
|
||||||
│ │ └── FormFieldView.swift
|
│ │ └── FormFieldView.swift
|
||||||
│ └── Inspection/
|
│ └── Inspection/
|
||||||
│ └── InspectionHistoryView.swift
|
│ └── InspectionHistoryView.swift # Uses RetryablePhotoView for server photos
|
||||||
│
|
│
|
||||||
└── Utils/
|
└── Utils/
|
||||||
└── Constants.swift
|
└── Constants.swift # ServerConfig (UserDefaults), ServerOption enum, Keychain keys
|
||||||
|
# Constants.baseURL is GONE — use ServerConfig.current everywhere
|
||||||
```
|
```
|
||||||
|
|
||||||
**Critical:** This project uses `PBXFileSystemSynchronizedRootGroup` (Xcode 16+ folder sync). Xcode **automatically compiles every `.swift` file in the folder tree**. There is no explicit file list. A stray duplicate (e.g. a model file accidentally placed in the wrong folder) will cause "Multiple commands produce" build errors. Always verify file locations after any copy/paste operation.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,16 +136,11 @@ JanitorialQC/
|
|||||||
|
|
||||||
### JanitorialQCApp.swift
|
### JanitorialQCApp.swift
|
||||||
|
|
||||||
`@main` struct. Responsibilities:
|
`@main` struct. Sets **only** `SyncManager.shared.modelContext` in the container callback. No async calls from there.
|
||||||
1. Creates the SwiftData `ModelContainer` for all seven model types.
|
|
||||||
2. In the container success callback: sets **only** `SyncManager.shared.modelContext`. Nothing else — no async calls, no session restore.
|
|
||||||
3. Registers the `com.jqc.sync` `BGProcessingTask` identifier.
|
|
||||||
|
|
||||||
**Critical:** The `modelContainer` callback runs on a background thread. Do NOT call `restoreSession()` or `startMonitoring()` from inside this callback. Doing so causes a race condition where `NWPathMonitor` fires `triggerSync()` before auth tokens are loaded, producing a 401 loop that leaves `isLoading` stuck at `true` and the app frozen on the splash screen.
|
|
||||||
|
|
||||||
### ContentView.swift
|
### ContentView.swift
|
||||||
|
|
||||||
Auth gate and **startup lifecycle owner**. The `.task {}` modifier owns the startup sequence:
|
Startup sequence in `.task {}`:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
.task {
|
.task {
|
||||||
@@ -143,11 +152,7 @@ Auth gate and **startup lifecycle owner**. The `.task {}` modifier owns the star
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**The order is mandatory.** `startMonitoring()` must not be called before `restoreSession()` completes because NWPathMonitor fires immediately on network availability, triggering `triggerSync()` before tokens are in Keychain.
|
**The order is mandatory.** `startMonitoring()` must not be called before `restoreSession()` completes — NWPathMonitor fires immediately, triggering `triggerSync()` before tokens exist.
|
||||||
|
|
||||||
### Background transitions
|
|
||||||
|
|
||||||
`DashboardView` observes `.scenePhase` and calls `scheduleBackgroundSync()` every time the app moves to `.background`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -157,35 +162,15 @@ Auth gate and **startup lifecycle owner**. The `.task {}` modifier owns the star
|
|||||||
|
|
||||||
`@MainActor class AuthManager: ObservableObject` — singleton via `AuthManager.shared`.
|
`@MainActor class AuthManager: ObservableObject` — singleton via `AuthManager.shared`.
|
||||||
|
|
||||||
| Property | Purpose |
|
**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).
|
||||||
|---|---|
|
|
||||||
| `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`):**
|
**Logout:** Calls `POST /api/v1/auth/logout` best-effort, clears Keychain, sets `isAuthenticated = false`.
|
||||||
1. If no access token in Keychain → `isAuthenticated = false` immediately (no network call).
|
|
||||||
2. Calls `GET /api/v1/auth/me` to validate the stored token.
|
|
||||||
3. On `notAuthenticated` error → clears Keychain, sets unauthenticated.
|
|
||||||
4. On any other error (network timeout, server 500) → restores user identity from Keychain and sets authenticated. This allows offline launch.
|
|
||||||
|
|
||||||
**Logout flow:** Calls `POST /api/v1/auth/logout` with the refresh token (best-effort), then clears all Keychain keys and sets `isAuthenticated = false`.
|
|
||||||
|
|
||||||
### KeychainHelper
|
### KeychainHelper
|
||||||
|
|
||||||
`nonisolated` static methods wrapping `Security.framework`. All keys use `kSecAttrAccessibleAfterFirstUnlock` so tokens are readable for background sync after device reboot.
|
`nonisolated` static methods. All keys use `kSecAttrAccessibleAfterFirstUnlock`.
|
||||||
|
|
||||||
Stored keys (all prefixed `com.jqc.`):
|
Stored keys (all prefixed `com.jqc.`): `accessToken`, `refreshToken`, `userId`, `userRole`, `username`, `displayName`.
|
||||||
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -193,26 +178,24 @@ Stored keys (all prefixed `com.jqc.`):
|
|||||||
|
|
||||||
### Model Container Registration
|
### Model Container Registration
|
||||||
|
|
||||||
All models are registered in `JanitorialQCApp` in this order:
|
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
LocalFacility.self, LocalArea.self, LocalTemplate.self,
|
LocalFacility.self, LocalArea.self, LocalTemplate.self,
|
||||||
LocalInspection.self, LocalIssue.self, PendingPhoto.self, SyncQueueEntry.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.
|
**SwiftData lightweight migration:** New `Bool` fields require `= false` default — without it the app crashes on launch.
|
||||||
|
|
||||||
### Model Reference
|
### Model Reference
|
||||||
|
|
||||||
| Model | Role | Key fields |
|
| Model | Role | Key fields |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `LocalFacility` | Read-only cached facility reference | `serverId`, `name`, `address`, `projectName`, `areas` (cascade) |
|
| `LocalFacility` | Read-only cached facility reference | `serverId` (`@Attribute(.unique)`), `name`, `projectId`, `projectName`, `areas` (cascade) |
|
||||||
| `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` |
|
| `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` |
|
||||||
| `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) |
|
| `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` |
|
| `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` |
|
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON` |
|
||||||
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType`, `fieldId` |
|
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
||||||
| `SyncQueueEntry` | Outbox entry (currently informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
|
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
|
||||||
|
|
||||||
### LocalInspection Status Flow
|
### LocalInspection Status Flow
|
||||||
|
|
||||||
@@ -221,18 +204,12 @@ LocalInspection.self, LocalIssue.self, PendingPhoto.self, SyncQueueEntry.self
|
|||||||
→ "failed" (after 5 retries)
|
→ "failed" (after 5 retries)
|
||||||
```
|
```
|
||||||
|
|
||||||
`status` = inspector workflow state. `syncStatus` = server submission state (`"pending"` | `"synced"` | `"failed"`). They are separate fields.
|
`status` = inspector workflow state. `syncStatus` = server submission state (`"pending"` | `"synced"` | `"failed"`).
|
||||||
|
|
||||||
### 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
|
## 7. Offline-First Architecture
|
||||||
|
|
||||||
The app follows the **outbox pattern**:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Inspector action → SwiftData write (always succeeds immediately)
|
Inspector action → SwiftData write (always succeeds immediately)
|
||||||
↓
|
↓
|
||||||
@@ -249,127 +226,97 @@ Inspector action → SwiftData write (always succeeds immediately)
|
|||||||
6. Poll server notifications (pollNotifications)
|
6. Poll server notifications (pollNotifications)
|
||||||
```
|
```
|
||||||
|
|
||||||
The UI never blocks on network. Every screen is driven by local SwiftData queries.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Sync Engine (SyncManager)
|
## 8. Sync Engine (SyncManager)
|
||||||
|
|
||||||
`@MainActor class SyncManager: ObservableObject` — singleton via `SyncManager.shared`.
|
`@MainActor class SyncManager: ObservableObject` — singleton via `SyncManager.shared`.
|
||||||
|
|
||||||
### Published state
|
|
||||||
|
|
||||||
| Property | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `isOnline` | True when `NWPathMonitor` reports `.satisfied` |
|
|
||||||
| `isSyncing` | True during active sync cycle |
|
|
||||||
| `lastSyncAt` | Date of last completed sync |
|
|
||||||
| `syncError` | Last error string (shown in Settings and Pending Sync views) |
|
|
||||||
| `pendingCount` | Count of unsynced inspections + issues (shown as badge) |
|
|
||||||
|
|
||||||
### triggerSync() guard
|
### triggerSync() guard
|
||||||
|
|
||||||
`triggerSync()` guards on **three conditions** before doing any work:
|
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return }
|
guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return }
|
||||||
```
|
```
|
||||||
|
|
||||||
The `isAuthenticated` guard is critical. `NWPathMonitor` fires immediately on connectivity, including during app startup before `restoreSession()` completes. Without this guard, `triggerSync()` runs with no valid token, hits a 401, attempts token refresh, fails with `notAuthenticated`, and the error propagates up through the `.task{}` startup chain — leaving `isLoading` stuck at `true`.
|
|
||||||
|
|
||||||
### triggerSync() — processing order
|
### triggerSync() — processing order
|
||||||
|
|
||||||
1. `processPhotoQueue` — upload all `PendingPhoto` with `uploadStatus == "pending"`. On success, propagates `serverPath` to the parent `LocalInspection.formData` (image fields) or `LocalIssue.photoServerPath`. Uses fetch-all + filter in Swift — no `#Predicate`.
|
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 only when all `pendingPhotos` are settled. Clears `followUpRequired` on parent after sync.
|
|
||||||
3. `processIssueQueue` — guards against submitting when parent inspection `syncStatus == "failed"` (prevents orphaned server records). Uses fetch-all + filter in Swift.
|
2. **`processInspectionQueue`** — submits completed inspections when all `pendingPhotos` are settled.
|
||||||
4. `pullReferenceData` — fetches facilities, areas, and templates. Sequential `await` calls — not `async let`.
|
|
||||||
5. `pullAssignedIssues` — fetches `GET /api/v1/issues` (scoped to current user on server). Upserts into SwiftData keyed by `serverId`. **Deletion pass** removes local records with `syncStatus == "synced"` + `inspectionLocalId == ""` whose `serverId` is absent from the response — handles reassignment away from this inspector. Empty response is not short-circuited so full unassignment is handled.
|
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())`).
|
||||||
6. `pollNotifications` — fetches `GET /api/v1/notifications?since=<last_fetch>`. Delivers each item as a local `UNUserNotificationCenter` banner. Updates cursor timestamp. Marks fetched IDs read on server.
|
|
||||||
|
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
|
### Notification polling
|
||||||
|
|
||||||
- `startPollTask()` creates a `Task` with a `Task.sleep(nanoseconds: 60_000_000_000)` loop. Called from `startMonitoring()` whenever connectivity is `.satisfied`.
|
- `startPollTask()` creates a `Task` with `Task.sleep(nanoseconds: 60_000_000_000)` loop.
|
||||||
- `stopPollTask()` cancels the task. Called when connectivity drops.
|
- **`Timer.scheduledTimer` is banned** — inside `Task { @MainActor }`, `RunLoop.current ≠ RunLoop.main`; timer fires never.
|
||||||
- `resetNotificationPoller()` cancels task + clears `lastNotificationFetch`. Call on logout.
|
- `resetNotificationPoller()` cancels task + clears cursor. Call on logout.
|
||||||
- **`Timer.scheduledTimer` is banned for periodic work in SyncManager.** Timer requires `RunLoop.main` to be ticking; inside a Swift Concurrency `Task { @MainActor }` block `RunLoop.current` ≠ `RunLoop.main` — the timer fires silently never. Always use `Task.sleep`.
|
|
||||||
- **`UNUserNotificationCenterDelegate` is required for foreground delivery.** Without it, iOS silently drops local notifications when the app is active. `NotificationDelegate.shared` is set as `UNUserNotificationCenter.current().delegate` in `JanitorialQCApp.init()`. Its `willPresent` returns `[.banner, .sound]`.
|
|
||||||
- **`Self.isoFormatter` for all date parsing.** `SyncManager.isoFormatter` is a `nonisolated static let DateFormatter` with `locale = Locale(identifier: "en_US_POSIX")` and `dateFormat = "yyyy-MM-dd'T'HH:mm:ss"`. Used by both `pollNotifications()` and `pullAssignedIssues()`. **Never allocate a `DateFormatter` per call or per loop iteration** — it is expensive. The `en_US_POSIX` locale is mandatory for fixed-format parsing; without it, the system locale can reinterpret the format string unpredictably.
|
|
||||||
|
|
||||||
### Fetch pattern — CRITICAL for Xcode 26
|
### Fetch pattern — CRITICAL for Xcode 26
|
||||||
|
|
||||||
**Never use `#Predicate` anywhere in `SyncManager`.** Under Xcode 26 with `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, `#Predicate` with a captured `String` variable inside a `@MainActor` async function causes `LocalInspection is ambiguous for type lookup` — a cascade of compiler errors.
|
**Never use `#Predicate` in `SyncManager`.** Use fetch-all + filter in Swift. Always parenthesise `try?` before `??`:
|
||||||
|
|
||||||
**Never use the chained optional pattern:**
|
|
||||||
```swift
|
```swift
|
||||||
// WRONG — compiler cannot infer T in this chained expression
|
// CORRECT
|
||||||
let x = (try? context.fetch(FetchDescriptor<LocalInspection>()))?.filter { ... } ?? []
|
let all = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||||
|
|
||||||
// CORRECT — split into two statements
|
|
||||||
let all = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
|
||||||
let x = all.filter { ... }
|
let x = all.filter { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Always parenthesise `try?` before `??`:**
|
|
||||||
```swift
|
|
||||||
// WRONG — operator precedence: try? binds looser than ??
|
|
||||||
// parses as: try? (context.fetch(...) ?? []) — nonsensical, fetch() is not Optional
|
|
||||||
let all = try? context.fetch(FetchDescriptor<LocalInspection>()) ?? []
|
|
||||||
|
|
||||||
// CORRECT
|
|
||||||
let all = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
|
||||||
```
|
|
||||||
|
|
||||||
### Retry / failure
|
|
||||||
|
|
||||||
- Each item increments `syncRetryCount` on every failure.
|
|
||||||
- At 5 retries: `syncStatus = "failed"`. The item stays in SwiftData but is never retried automatically.
|
|
||||||
- `syncError` is cleared at the start of each `triggerSync()` call.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. API Client (APIClient)
|
## 9. API Client (APIClient)
|
||||||
|
|
||||||
`actor APIClient` — singleton via `APIClient.shared`. All methods are `async throws`.
|
`actor APIClient` — singleton via `APIClient.shared`. All methods are `async throws`.
|
||||||
|
|
||||||
### Generic request pipeline
|
All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.baseURL` no longer exists.**
|
||||||
|
|
||||||
```swift
|
### JSON decoding
|
||||||
request(endpoint, method, body, retrying) async throws -> T
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Builds URL from `Constants.baseURL + endpoint`.
|
`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.).
|
||||||
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
|
### Key methods
|
||||||
|
|
||||||
| Method | Endpoint | Notes |
|
| Method | Endpoint | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `request<T>` | Any | Generic GET/POST; `retrying: Bool` prevents double-refresh loop |
|
| `request<T>` | Any | Generic; 401 auto-refresh once |
|
||||||
| `post<T>` | Any | POST convenience |
|
| `post<T>` | Any | POST convenience |
|
||||||
| `uploadPhoto` | `POST /api/v1/photos/upload` | Multipart form-data, manual boundary; `retrying: Bool = false` matches `request()` retry pattern |
|
| `uploadPhoto` | `POST /api/v1/photos/upload` | Multipart form-data; `entity_type="issue"` → `uploads/issue_photos/` |
|
||||||
| `submitInspection` | `POST /api/v1/inspections` | Sanitises `local://` photo paths before send |
|
| `submitInspection` | `POST /api/v1/inspections` | Sanitises `local://` paths |
|
||||||
| `submitIssue` | `POST /api/v1/issues` | Sends `inspection_id` only if `inspection.serverId` is non-nil |
|
| `submitIssue` | `POST /api/v1/issues` | Sends `photo_path` = first server photo only |
|
||||||
| `fetchInspectionHistory` | `GET /api/v1/inspections` | Paginated, returns `InspectionHistoryResponseData` |
|
| `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 |
|
| `fetchAssignedIssues` | `GET /api/v1/issues` | Returns issues assigned to OR reported by current user |
|
||||||
| `fetchIssueDetail` | `GET /api/v1/issues/<id>` | Fetches current status for detail view |
|
| `fetchIssueDetail` | `GET /api/v1/issues/<id>` | Fetches current status |
|
||||||
| `updateIssueStatus` | `PATCH /api/v1/issues/<id>/status` | Inspector updates status on assigned/reported issues |
|
| `updateIssueStatus` | `PATCH /api/v1/issues/<id>/status` | Inspector updates status |
|
||||||
| `fetchNotifications` | `GET /api/v1/notifications` | Accepts optional `since: Date`; returns `[APINotification]` |
|
| `fetchNotifications` | `GET /api/v1/notifications` | Optional `since: Date` cursor |
|
||||||
| `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks list of IDs read on server |
|
| `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks IDs read on server |
|
||||||
|
|
||||||
### Token refresh
|
### `APIAssignedIssue` fields
|
||||||
|
|
||||||
`refreshAccessToken()` uses a **local** `JSONDecoder` (not `self.decoder`) to avoid Swift 6 actor-isolation errors. It stores both new tokens in Keychain before returning.
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
### JSON decoding
|
`SyncManager.pullAssignedIssues` merges `photoPath + mobilePhotoPaths` into `photoServerPaths`. `resultPhotos` is decoded but intentionally excluded from `photoServerPaths` — resolution photos are web-only.
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -379,20 +326,21 @@ request(endpoint, method, body, retrying) async throws -> T
|
|||||||
JanitorialQCApp
|
JanitorialQCApp
|
||||||
└── ContentView (auth gate + startup lifecycle)
|
└── ContentView (auth gate + startup lifecycle)
|
||||||
├── LoginView (unauthenticated)
|
├── LoginView (unauthenticated)
|
||||||
|
│ └── Server picker (segmented: jqc Primary / jqc1 Secondary)
|
||||||
└── DashboardView (authenticated)
|
└── DashboardView (authenticated)
|
||||||
├── Sidebar (NavigationSplitView — no selection: binding)
|
├── Sidebar (NavigationSplitView — no selection: binding)
|
||||||
│ ├── My Inspections → MyInspectionsView
|
│ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar]
|
||||||
│ ├── Issues → IssuesListView
|
│ ├── Issues → IssuesListView [+ button here → StandaloneIssueView]
|
||||||
│ ├── Facilities → FacilitiesListView
|
│ ├── Facilities → FacilitiesListView
|
||||||
│ ├── Pending Sync → SyncStatusView
|
│ ├── Pending Sync → SyncStatusView
|
||||||
│ ├── History → InspectionHistoryView
|
│ ├── History → InspectionHistoryView
|
||||||
│ └── Settings → SettingsView
|
│ └── Settings → SettingsView [server picker + logout alert]
|
||||||
└── + button → StartInspectionView (sheet)
|
└── (sidebar has NO + button)
|
||||||
└── 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.**
|
**`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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -400,80 +348,35 @@ JanitorialQCApp
|
|||||||
|
|
||||||
### 1. Start (`StartInspectionView`)
|
### 1. Start (`StartInspectionView`)
|
||||||
|
|
||||||
- Inspector picks template (required), facility (required), area (optional).
|
Contract → Facility cascade pickers (same as `StandaloneIssueView`). `onChange(of: selectedProjectId)` guards against resetting pre-filled facility — checks `facilityBelongsToContract` before clearing `selectedFacilityId`.
|
||||||
- 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`)
|
### 2. Execute (`ExecuteInspectionView`)
|
||||||
|
|
||||||
- Renders the template's `formSchema` as a 12-column CSS-grid-equivalent layout via `GridFormView`.
|
- Renders 12-column grid form. Auto-saves every 30 seconds.
|
||||||
- Auto-saves every 30 seconds to SwiftData.
|
- Submit: computes score, sets `status = "completed"`, `syncStatus = "pending"`, calls `clearParentFollowUpFlag()`, triggers sync.
|
||||||
- Saves on `onDisappear`.
|
- Shows success banner 2.5 seconds then dismisses.
|
||||||
- "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
|
### 3. Draft management
|
||||||
|
|
||||||
- Drafts appear in **My Inspections** with a blue "Draft" badge.
|
Swipe-left delete (confirmation required). Deletes draft + `PendingPhoto` records + local photo files + associated `LocalIssue` records. Only `status == "draft"` inspections may be deleted.
|
||||||
- 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
|
## 12. Form Field Rendering
|
||||||
|
|
||||||
### GridFormView (primary renderer in ExecuteInspectionView)
|
### GridFormView — 12-column absolute-position grid
|
||||||
|
|
||||||
Uses a 12-column absolute-position grid matching the web app's CSS grid exactly:
|
| Constant | Value |
|
||||||
|
|
||||||
| Constant | Value | Source |
|
|
||||||
|---|---|---|
|
|
||||||
| `totalColumns` | 12 | Web editor `COLS=12` |
|
|
||||||
| `cellGap` | 8 pt | Web CSS `col-gap: 8px` |
|
|
||||||
| `rowGap` | 4 pt | Web CSS `row-gap: 4px` |
|
|
||||||
| `cellAspect` | 52/72 | Web editor `CELL_H/CELL_W` |
|
|
||||||
| `cardPadding` | 16 pt | Card inset |
|
|
||||||
|
|
||||||
Cell position is computed from `col`, `row`, `colSpan`, `rowSpan` attributes in the field schema. Container width is measured via a `PreferenceKey` pattern (zero-height overlay with `GeometryReader`) — works correctly through rotations and split-screen resizing.
|
|
||||||
|
|
||||||
### Supported field types
|
|
||||||
|
|
||||||
| Type | SwiftUI renderer |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `text`, `email` | `TextField` |
|
| `totalColumns` | 12 |
|
||||||
| `textarea` | `TextEditor` |
|
| `cellGap` | 8 pt |
|
||||||
| `number` | `TextField` + `.decimalPad` |
|
| `rowGap` | 4 pt |
|
||||||
| `date` | `DatePicker` (date only) |
|
| `cellAspect` | 52/72 |
|
||||||
| `checkbox` | `Toggle` |
|
| `cardPadding` | 16 pt |
|
||||||
| `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)
|
### 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:
|
Form schema IDs arrive as `Int` in `[String: Any]`. Always cast:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
let fid: String
|
let fid: String
|
||||||
@@ -482,78 +385,60 @@ else if let n = field["id"] as? Int { fid = String(n) }
|
|||||||
else { continue }
|
else { continue }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Never use `Optional.map` on `field["id"]`** — it produces `"Optional(5)"` instead of `"5"`, causing all formData lookups to silently miss and all scores to return 0.
|
**Never use `Optional.map` on `field["id"]`** — produces `"Optional(5)"` instead of `"5"`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Issue Flagging Workflow
|
## 13. Issue Flagging Workflow
|
||||||
|
|
||||||
`FlagIssueView` is presented as a sheet from `ExecuteInspectionView`.
|
`FlagIssueView` — presented as sheet from `ExecuteInspectionView`. Sets `inspectionLocalId = inspection.localId` and appends to `inspection.localIssues`. Creates `PendingPhoto` with `entityType = "issue"`.
|
||||||
|
|
||||||
1. Inspector selects severity (segmented control: low / medium / high / critical).
|
**Issue sync guard:** If parent `inspection.syncStatus == "failed"`, issue is immediately marked `"failed"` — prevents orphaned server records.
|
||||||
2. Enters description (required).
|
|
||||||
3. Optionally attaches a photo.
|
|
||||||
4. Tapping Submit:
|
|
||||||
- Creates `LocalIssue` with `facilityServerId` from the parent inspection.
|
|
||||||
- Appends the issue to `inspection.localIssues`.
|
|
||||||
- If a photo was taken: creates a `PendingPhoto` with `entityType = "issue"`.
|
|
||||||
- Saves to SwiftData.
|
|
||||||
- Shows a green **"Issue Logged"** confirmation banner (2 seconds) then dismisses.
|
|
||||||
- Triggers `SyncManager.triggerSync()` if online.
|
|
||||||
5. Area picker is absent — facility is derived directly from the inspection context.
|
|
||||||
|
|
||||||
### Issue sync guard
|
|
||||||
|
|
||||||
If `inspection.syncStatus == "failed"` when `processIssueQueue` runs, the issue is immediately marked `"failed"` with message `"Parent inspection failed to sync — issue cannot be submitted."` This prevents orphaned server records with no `inspection_id`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Re-inspection Workflow
|
## 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
|
### 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.
|
`CompletedInspectionView` shows orange banner with **Start Re-inspection** when `followUpRequired == true`. Opens `StartInspectionView` with `preFillTemplateId`, `preFillFacilityId`, `parentServerId`, `parentLocalId`.
|
||||||
|
|
||||||
### Parent form pre-fill
|
### followUpRequired clearing — three points
|
||||||
|
|
||||||
`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.
|
1. Immediately on Submit in `ExecuteInspectionView.submitInspection()` via `clearParentFollowUpFlag()`.
|
||||||
|
2. After sync in `SyncManager.processInspectionQueue`.
|
||||||
### Picker pre-fill (template, contract, facility)
|
3. On the server when PATCH/POST arrives.
|
||||||
|
|
||||||
`applyPreFill()` sets `selectedTemplateId`, `selectedProjectId`, and `selectedFacilityId` on `.onAppear`. **The `.onChange(of: selectedProjectId)` handler guards against resetting `selectedFacilityId`** when the facility already belongs to the newly selected contract — this prevents the onChange from wiping the pre-filled facility before it renders. Without this guard, `selectedProjectId` is set first, `.onChange` fires, and `selectedFacilityId` is reset to `nil` before it can be applied.
|
|
||||||
|
|
||||||
### followUpRequired clearing
|
|
||||||
|
|
||||||
Cleared at **three** points to ensure the badge disappears regardless of timing:
|
|
||||||
|
|
||||||
1. **Immediately on Submit** in `ExecuteInspectionView.submitInspection()` via `clearParentFollowUpFlag()`.
|
|
||||||
2. **After sync** in `SyncManager.processInspectionQueue` using `parentLocalId`.
|
|
||||||
3. **On the server** in `api/inspections.py` when the PATCH/POST arrives.
|
|
||||||
|
|
||||||
`clearParentFollowUpFlag()` resolution order:
|
|
||||||
1. Match by `parentLocalId` (UUID, always set if re-inspection was created in this session).
|
|
||||||
2. Fall back to `parentServerId` (set only after parent has synced).
|
|
||||||
3. Last resort: match by same `templateServerId + facilityServerId + followUpRequired == true`. **This fallback is ambiguous when multiple follow-ups are pending for the same template/facility combination.**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. Inspection History
|
## 16. 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`.
|
`InspectionHistoryView` — online-only. Fetches `GET /api/v1/inspections?status=completed`. Pagination: limit 30, offset-based. Uses `RetryablePhotoView` for server photos.
|
||||||
|
|
||||||
Pagination: limit 30, offset-based. A "Load More" button appears when `inspections.count < total`. Pull-to-refresh resets to page 0.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. Photo Handling
|
## 17. Photo Handling
|
||||||
|
|
||||||
### Capture
|
### Capture
|
||||||
|
|
||||||
Photos are taken via `UIImagePickerController` (camera) or `PHPickerViewController` (library, no permission required for iOS 16+).
|
`UIImagePickerController` (camera) or `PHPickerViewController` (library, no permission required iOS 16+).
|
||||||
|
|
||||||
### Local storage
|
### Local storage
|
||||||
|
|
||||||
All photos are saved to: `Documents/JQC/Photos/<UUID>.jpg` at JPEG quality 0.8.
|
`Documents/JQC/Photos/<UUID>.jpg` at JPEG quality 0.8.
|
||||||
|
|
||||||
### PendingPhoto lifecycle
|
### PendingPhoto lifecycle
|
||||||
|
|
||||||
@@ -562,172 +447,226 @@ Created (uploadStatus="pending")
|
|||||||
↓ SyncManager.processPhotoQueue()
|
↓ SyncManager.processPhotoQueue()
|
||||||
Uploaded (uploadStatus="uploaded", serverPath set)
|
Uploaded (uploadStatus="uploaded", serverPath set)
|
||||||
↓ Parent record updated
|
↓ Parent record updated
|
||||||
For inspection image fields: LocalInspection.formData[fieldId] = serverPath
|
Inspection image fields: LocalInspection.formData[fieldId] = serverPath
|
||||||
For issues: LocalIssue.photoServerPath = 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
|
### 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 `""`. A `local://` value that reaches the server would be stored as a malformed path.
|
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.
|
||||||
|
|
||||||
### Server-hosted photos in IssueDetailView
|
|
||||||
|
|
||||||
`IssueDetailView` shows two photo sections:
|
|
||||||
1. **Local photos** — `photoLocalPaths` rendered via `UIImage(contentsOfFile:)`. Present for device-created issues where photos were captured on-device.
|
|
||||||
2. **Server photos** — `photoServerPaths` rendered via `AsyncImage(url: Constants.baseURL + "/" + relativePath)`. Present for issues pulled from the server (`pullAssignedIssues` stores `photo_path` + `result_photos` from the API response into `photoServerPaths`).
|
|
||||||
|
|
||||||
Both sections are shown independently; an issue can have entries in either or both.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 17. Score Calculation
|
## 18. Score Calculation
|
||||||
|
|
||||||
`LocalInspection.computeScore(fromSchema:)` mirrors Python's `_compute_score_from_form()` exactly.
|
`LocalInspection.computeScore(fromSchema:)` mirrors Python's `_compute_score_from_form()`.
|
||||||
|
|
||||||
**Scoreable field types:** `rating`, `checkbox`, `radio`, `pass_fail`.
|
**Scoreable:** `rating`, `checkbox`, `radio`, `pass_fail`.
|
||||||
|
|
||||||
| Type | Rule |
|
| Type | Rule |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `rating` | Value `0` = unanswered → excluded. Each answered rating contributes `value / 5` of a possible 1.0 |
|
| `rating` | `0` = unanswered → excluded. Each answered rating: `value / 5` of 1.0 |
|
||||||
| `checkbox` | `"true"` = pass, anything else = fail |
|
| `checkbox` | `"true"` = pass |
|
||||||
| `radio` | Pass keywords: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant` (case-insensitive) |
|
| `radio` | Pass: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant` (case-insensitive) |
|
||||||
| `pass_fail` | Same pass keywords as radio. Empty string = unanswered → excluded |
|
| `pass_fail` | Same keywords. Empty = unanswered → excluded |
|
||||||
|
|
||||||
Returns `nil` if no scoreable fields or all are unanswered.
|
Returns `nil` if no scoreable fields or all unanswered.
|
||||||
|
|
||||||
**Field ID resolution:** Always use the explicit cast pattern (see §12). `Optional.map` on `Any?` produces `"Optional(5)"` — all lookups miss, all scores return 0.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 18. Background Sync
|
## 19. Background Sync
|
||||||
|
|
||||||
The app registers a `BGProcessingTask` with identifier `com.jqc.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**.
|
||||||
|
|
||||||
**`Info.plist` requirement:** `BGTaskSchedulerPermittedIdentifiers` must contain `com.jqc.sync`. Without this entry, `BGTaskScheduler.shared.register` silently fails and background sync never fires.
|
Requirements: `requiresNetworkConnectivity = true`, `requiresExternalPower = false`.
|
||||||
|
|
||||||
**Note on `GENERATE_INFOPLIST_FILE`:** The project uses `GENERATE_INFOPLIST_FILE = YES`. Do NOT also have a physical `Info.plist` file on disk — having both causes "Multiple commands produce Info" build error. The file is generated at build time; there is no `Info.plist` in the source tree.
|
|
||||||
|
|
||||||
**Scheduling:** `scheduleBackgroundSync()` is called:
|
|
||||||
- Every time `scenePhase == .background`
|
|
||||||
- At the start of each background task handler (schedules the next run)
|
|
||||||
|
|
||||||
**Requirements:** `requiresNetworkConnectivity = true`, `requiresExternalPower = false`.
|
|
||||||
|
|
||||||
**Tokens:** Keychain access policy `kSecAttrAccessibleAfterFirstUnlock` ensures tokens are available when the app is woken by BGTaskScheduler after device reboot.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 19. Settings & Cache Management
|
## 20. Settings & Cache Management
|
||||||
|
|
||||||
`SettingsView` exposes:
|
`SettingsView` exposes:
|
||||||
- Account info (username, role) — read-only from `AuthManager`.
|
- **Account info** — username, role (read-only).
|
||||||
- **Sync Now** — triggers `SyncManager.triggerSync()`; disabled when offline or already syncing.
|
- **Sync Now** — triggers `triggerSync()`; disabled when offline or syncing.
|
||||||
- Last sync timestamp.
|
- **Clear Reference Cache** — deletes `LocalFacility`, `LocalArea`, `LocalTemplate` only. Never touches `LocalInspection`, `LocalIssue`, `PendingPhoto`. Triggers `pullReferenceData()` if online.
|
||||||
- **Clear Reference Cache** — deletes all `LocalFacility`, `LocalArea`, and `LocalTemplate` records. **Never deletes `LocalInspection`, `LocalIssue`, or `PendingPhoto`.** Triggers `pullReferenceData()` immediately if online.
|
- **Server picker** — see §21.
|
||||||
- **Log Out** — calls `AuthManager.logout()`.
|
- **Log Out** — calls `clearServerPulledData()` + `resetNotificationPoller()` + `auth.logout()`.
|
||||||
- App version and server URL (from `Constants.baseURL`).
|
- App version + current server URL (from `ServerConfig.current`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 20. Known Constraints & Hard Rules
|
## 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 |
|
| # | Rule | Rationale |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | **`import Combine` required in files using `@Published`** | Swift 5.9+ does not auto-import Combine; `ObservableObject` without it causes build errors |
|
| 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; use `@State var selectedTab: SidebarTab` with `Button` handlers |
|
| 2 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17 |
|
||||||
| 3 | **No `#Predicate` anywhere in `SyncManager`** | Under Xcode 26 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, `#Predicate` with a captured String variable causes `LocalInspection is ambiguous` compiler cascade. Use fetch-all + filter in Swift throughout |
|
| 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` structs; use sequential `await` calls |
|
| 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, Libraries, and Embedded Content |
|
| 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; SwiftData persists across reinstalls |
|
| 6 | **Free Apple ID provisioning expires every 7 days** | Rebuild with ⌘R while iPad is connected |
|
||||||
| 7 | **`kSecAttrAccessibleAfterFirstUnlock` for all Keychain items** | Tokens must be readable when the app is woken by BGTaskScheduler after reboot |
|
| 7 | **`kSecAttrAccessibleAfterFirstUnlock` for all Keychain items** | Tokens readable after reboot for BGTaskScheduler |
|
||||||
| 8 | **New `Bool` model fields require `= false` default** | SwiftData lightweight migration crashes on launch without a default value for new Bool properties |
|
| 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 them as Int in JSON; always cast via `as? String` then `as? Int → String(n)`. Never use `Optional.map` on `field["id"]` |
|
| 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`** | Failed photo uploads leave `"local://..."` in formData; JSONSerialization silently drops non-serialisable values |
|
| 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"`** | Submitting with no `inspection_id` creates orphaned server records |
|
| 11 | **Do not submit an issue when parent inspection `syncStatus == "failed"`** | Creates orphaned server records |
|
||||||
| 12 | **Photo-before-inspection ordering in sync** | `processPhotoQueue` must run before `processInspectionQueue` |
|
| 12 | **Photo-before-inspection ordering in sync** | `processPhotoQueue` runs before `processInspectionQueue` |
|
||||||
| 13 | **`com.jqc.sync` must be in BGTaskSchedulerPermittedIdentifiers** | BGTaskScheduler silently ignores unregistered identifiers |
|
| 13 | **`com.jqc.sync` must be in BGTaskSchedulerPermittedIdentifiers** | Silently ignored if absent |
|
||||||
| 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 |
|
| 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 is ambiguous when multiple follow-ups are pending for the same template/facility |
|
| 15 | **`clearParentFollowUpFlag()` fallback-2 is ambiguous** | Matching by template+facility ambiguous with multiple follow-ups |
|
||||||
| 16 | **`SyncQueueEntry` model is registered but not actively written** | Included for future use; `syncStatus` on `LocalInspection` and `LocalIssue` is the active queue |
|
| 16 | **`SyncQueueEntry` model registered but not actively written** | Future use; `syncStatus` on models is the active queue |
|
||||||
| 17 | **`Constants.baseURL` is the only server URL** | All endpoints are `Constants.baseURL + endpoint`. Update this one constant for environment changes |
|
| 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 against the server's 50 MB limit |
|
| 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 |
|
| 19 | **`clearCache()` in Settings never deletes inspections or issues** | Only `LocalFacility`, `LocalArea`, `LocalTemplate` are safe to purge |
|
||||||
| 20 | **`AuthManager.restoreSession()` falls back to Keychain on non-auth errors** | Allows offline launch but may expose stale role/identity data |
|
| 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 on launch, triggering `triggerSync()` before tokens exist; the 401 loop leaves `isLoading` stuck |
|
| 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 from running before auth is established — covers the NWPathMonitor race and any BGTask path |
|
| 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` in the tree; a `LocalInspection.swift` in `Auth/` causes "Multiple commands produce LocalInspection" |
|
| 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` file when `GENERATE_INFOPLIST_FILE = YES`** | Having both causes "Multiple commands produce Info" build error |
|
| 24 | **No physical `Info.plist` when `GENERATE_INFOPLIST_FILE = YES`** | "Multiple commands produce Info" build error |
|
||||||
| 25 | **Always parenthesise `try?` before `??`** | `try? context.fetch(...) ?? []` parses as `try? (fetch() ?? [])` — `fetch()` is non-optional so `??` is invalid inside `try?`; compiler infers `T = Any` and cascades into build errors. Write `(try? context.fetch(...)) ?? []` |
|
| 25 | **Always parenthesise `try?` before `??`** | `try? fetch(...) ?? []` parses as `try? (fetch() ?? [])` — nonsensical |
|
||||||
| 26 | **Delete Xcode's default `Item.swift` immediately after project creation** | Xcode generates `Item.swift` with `@Model class Item` when creating a new SwiftData project; it compiles silently via folder sync and conflicts with real models |
|
| 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** | Inside `Task { @MainActor }`, `RunLoop.current` ≠ `RunLoop.main`; the timer is added to a runloop that never ticks and fires silently never. Use `Task.sleep` instead |
|
| 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 a delegate returning `[.banner, .sound]` from `willPresent`, iOS silently drops local notifications while the app is active. `NotificationDelegate.shared` is set in `JanitorialQCApp.init()` |
|
| 28 | **`UNUserNotificationCenterDelegate` must be set for foreground notifications** | Without it, iOS drops local notifications while app is active |
|
||||||
| 29 | **All `Decodable & Sendable` response structs need `nonisolated init(from:)`** | Under `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, synthesised Decodable inits inherit `@MainActor` isolation, conflicting with the `Sendable` constraint on `_Envelope<T>`; every API model struct must declare `nonisolated init(from decoder: any Decoder)` explicitly |
|
| 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 the server returns zero issues (all unassigned), the deletion pass must still run to remove stale local records; do not `guard !apiIssues.isEmpty else { return }` before the deletion loop |
|
| 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 identified by `syncStatus == "synced"` + `inspectionLocalId == ""`** | These are the only records safe to delete during reconciliation. Device-created issues have `inspectionLocalId != ""` and must never be deleted by `pullAssignedIssues` |
|
| 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 against resetting pre-filled facility** | Check `facilityBelongsToContract` before clearing `selectedFacilityId`; the onChange fires during `applyPreFill()` before `selectedFacilityId` is applied, wiping it if unchecked |
|
| 32 | **`StartInspectionView.onChange(of: selectedProjectId)` guards pre-filled facility** | Check `facilityBelongsToContract` before clearing `selectedFacilityId` |
|
||||||
| 33 | **Issue photos are stored in `photoServerPaths` after `pullAssignedIssues`** | `photo_path` and `result_photos` from `_issue_payload` are merged into a single `[String]` and stored in `local.photoServerPaths` on insert/update |
|
| 33 | **`photoServerPaths` = `photo_path` + `mobile_photo_paths` only** | `result_photos` (resolution photos) is intentionally excluded from `photoServerPaths` on iPad |
|
||||||
| 34 | **`IssueDetailView` shows both `photoLocalPaths` and `photoServerPaths`** | Local paths use `UIImage(contentsOfFile:)`; server paths use `AsyncImage` with `Constants.baseURL` prefix. Both sections are independent |
|
| 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. The static `nonisolated` formatter with `en_US_POSIX` locale handles all `yyyy-MM-dd'T'HH:mm:ss` parsing. Adding a new `DateFormatter` anywhere in SyncManager is wrong. |
|
| 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** | Matches `request()` pattern. Without it, a 401 on the retry triggers a second token refresh instead of throwing `notAuthenticated`. |
|
| 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)` — without it SwiftData infers the inverse implicitly, which can produce migration warnings and incorrect cascade behaviour under some Xcode 26 versions. `deleteRule` is `.nullify` not `.cascade` because cascade is already declared on `LocalInspection.localIssues`. |
|
| 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, falls back to `username`** | Server `_user_payload` sends `full_name`; iOS decodes as `fullName: String` (empty string when unset). `displayName` computed property: `fullName.isEmpty ? username : fullName`. Mirrors `User.display_name` server-side. |
|
| 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 }`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 21. Xcode 26 Specific Issues
|
## 23. Xcode 26 Specific Issues
|
||||||
|
|
||||||
This project was created with **Xcode 26.4.1** (Apple's major 2026 release). Several compiler behaviours differ from Xcode 15/16 and require specific patterns.
|
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
|
### SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
|
||||||
|
|
||||||
Xcode 26 sets this build setting when creating new projects with "approachable concurrency" enabled. It makes every type and function implicitly `@MainActor`.
|
Every type and function is implicitly `@MainActor`. Effects on SwiftData `FetchDescriptor` and `#Predicate`: see §8 and rule 3.
|
||||||
|
|
||||||
**Effect on SwiftData:** SwiftData's `@Model` macro generates `nonisolated` accessors internally. When `FetchDescriptor<LocalInspection>` is used inside a `@MainActor` async method, the compiler sees a conflict between `@MainActor LocalInspection` and `nonisolated PersistentModel` requirements. The error cascade is:
|
**Solutions applied:**
|
||||||
- `'LocalInspection' is ambiguous for type lookup in this context`
|
1. Fetch-all + filter in Swift — no `#Predicate` with captured variables.
|
||||||
- `Generic parameter 'T' could not be inferred`
|
2. All `try? context.fetch(...)` parenthesised before `??`.
|
||||||
- `Type 'Any' cannot conform to 'PersistentModel'`
|
3. Chained optional patterns split into two `let` statements.
|
||||||
- `The compiler is unable to type-check this expression in reasonable time`
|
4. All API model structs declare `nonisolated init(from decoder: any Decoder)` explicitly.
|
||||||
|
|
||||||
**Solutions applied in this codebase:**
|
**Do NOT remove `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` from build settings** unless you audit every file for the resulting isolation changes.
|
||||||
1. All `context.fetch()` calls use fetch-all + filter in Swift — no `#Predicate` with captured variables.
|
|
||||||
2. All `try? context.fetch(...)` expressions are parenthesised before `??`.
|
|
||||||
3. Chained optional patterns `(try? fetch(...))?.filter { }` are split into two `let` statements.
|
|
||||||
4. `triggerSync()` guards on `isAuthenticated` to prevent 401 cascades during startup.
|
|
||||||
5. Notification polling uses `Task.sleep` not `Timer.scheduledTimer` (RunLoop dependency).
|
|
||||||
6. All API model structs declare `nonisolated init(from decoder: any Decoder)` explicitly — including file-scope `private` structs like `_RefreshEnvelope` in `APIClient.swift`. A `private` struct defined in a file containing an `actor` or `@MainActor` type can have its `Decodable` conformance tainted with `@MainActor`, producing "cannot be used in actor-isolated context" errors in Swift 6 mode. The fix is always an explicit `nonisolated init(from:)` with a matching `CodingKeys` enum.
|
|
||||||
|
|
||||||
**Do NOT remove `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` from build settings** unless you fully audit every file for the resulting isolation changes. The patterns above are the correct workarounds.
|
|
||||||
|
|
||||||
### PBXFileSystemSynchronizedRootGroup (Folder Sync)
|
### PBXFileSystemSynchronizedRootGroup (Folder Sync)
|
||||||
|
|
||||||
Xcode 26 uses folder-sync mode instead of an explicit file list in `project.pbxproj`. **Every `.swift` file in the project folder is compiled automatically.** There is no file registry to check.
|
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.
|
||||||
|
|
||||||
Consequences:
|
|
||||||
- Stray files (e.g. `Item.swift` from the project template, accidentally duplicated model files) compile silently and cause "Multiple commands produce" errors.
|
|
||||||
- Deleting a file from Finder is sufficient to remove it from the build — no need to remove it from the project navigator separately.
|
|
||||||
- When diagnosing "Multiple commands produce X", run: `find /path/to/project -name "*.swift" | xargs grep -l "class X"` to find all definitions.
|
|
||||||
|
|
||||||
### Derived Data corruption
|
### Derived Data corruption
|
||||||
|
|
||||||
Under Xcode 26, repeated failed builds accumulate corrupt intermediate files in derived data. After any "Multiple commands produce" error is resolved, delete derived data manually before rebuilding:
|
After "Multiple commands produce" errors: delete derived data manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf ~/Library/Developer/Xcode/DerivedData/<ProjectName>-<hash>
|
rm -rf ~/Library/Developer/Xcode/DerivedData/<ProjectName>-<hash>
|
||||||
```
|
```
|
||||||
|
|
||||||
The hash is visible in every error message path. Do not use Product → Clean Build Folder alone — it does not remove all intermediate files.
|
Do not rely on Product → Clean Build Folder alone.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 22. Change Philosophy
|
## 24. Change Philosophy
|
||||||
|
|
||||||
1. **Read the actual file before editing.** Never rely on earlier context — a prior edit invalidates it. When a user pastes file content, that is the ground truth — not the local copy.
|
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.
|
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. Multiple failed attempts are always caused by treating symptoms.
|
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.
|
4. **Smallest possible change.** Do not restructure, rename, or reformat surrounding code.
|
||||||
5. **Never remove functionality** unless explicitly directed.
|
5. **Never remove functionality** unless explicitly directed.
|
||||||
6. **SwiftData schema changes need defaults.** All new `Bool` fields: `= false`. Run on device and check for migration crash before shipping.
|
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.
|
7. **Test offline and online.** Every sync-related fix must be verified in airplane mode.
|
||||||
8. **Verify file placement.** After delivering a file, confirm the user placed it at the correct path. Xcode 26 folder sync means a file in the wrong subfolder compiles as a duplicate.
|
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 before making further changes.
|
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.
|
10. **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