35 KiB
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 (Hardening session — Xcode 26 build fixes, startup race condition resolved) Companion: See the web backend's
CLAUDE.mdfor API contract, server-side rules, and migration chain.
Table of Contents
- Project Overview
- Tech Stack
- Repository Layout
- App Entry Point & Lifecycle
- Authentication
- SwiftData Models
- Offline-First Architecture
- Sync Engine (SyncManager)
- API Client (APIClient)
- Navigation & View Hierarchy
- Inspection Workflow
- Form Field Rendering
- Issue Flagging Workflow
- Re-inspection Workflow
- Inspection History
- Photo Handling
- Score Calculation
- Background Sync
- Settings & Cache Management
- Known Constraints & Hard Rules
- Xcode 26 Specific Issues
- 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 | 26.4.1 (Xcode 26 beta — see §21 for critical constraints) |
3. Repository Layout
JanitorialQC/
├── JanitorialQCApp.swift # @main — SwiftData container, BGTask registration
├── ContentView.swift # Auth gate + startup lifecycle (.task{})
│
├── Auth/
│ ├── AuthManager.swift # @MainActor ObservableObject — login/logout/restore
│ └── KeychainHelper.swift # Security.framework wrapper (nonisolated)
│
├── API/
│ ├── APIClient.swift # actor — URLSession, JWT inject, 401-retry, photo upload
│ └── APIModels.swift # All Codable/Sendable response DTOs
│
├── Sync/
│ └── SyncManager.swift # @MainActor ObservableObject — NWPathMonitor, outbox queue
│
├── Models/ # SwiftData @Model classes — NO other .swift files here
│ ├── LocalFacility.swift
│ ├── LocalArea.swift
│ ├── LocalTemplate.swift
│ ├── LocalInspection.swift # ← ONLY definition of LocalInspection — never duplicate
│ ├── LocalIssue.swift
│ ├── PendingPhoto.swift
│ └── SyncQueueEntry.swift
│
├── Views/
│ ├── Auth/
│ │ └── LoginView.swift # ← Auth/ folder must contain ONLY auth files — no models
│ ├── Dashboard/
│ │ ├── DashboardView.swift
│ │ ├── StartInspectionView.swift
│ │ ├── ExecuteInspectionView.swift
│ │ ├── FlagIssueView.swift
│ │ └── FormFieldView.swift
│ └── Inspection/
│ └── InspectionHistoryView.swift
│
└── Utils/
└── Constants.swift
Critical: This project uses PBXFileSystemSynchronizedRootGroup (Xcode 16+ folder sync). Xcode automatically compiles every .swift file in the folder tree. There is no explicit file list. A stray duplicate (e.g. a model file accidentally placed in the wrong folder) will cause "Multiple commands produce" build errors. Always verify file locations after any copy/paste operation.
4. App Entry Point & Lifecycle
JanitorialQCApp.swift
@main struct. Responsibilities:
- Creates the SwiftData
ModelContainerfor all seven model types. - In the container success callback: sets only
SyncManager.shared.modelContext. Nothing else — no async calls, no session restore. - Registers the
com.jqc.syncBGProcessingTaskidentifier.
Critical: The modelContainer callback runs on a background thread. Do NOT call restoreSession() or startMonitoring() from inside this callback. Doing so causes a race condition where NWPathMonitor fires triggerSync() before auth tokens are loaded, producing a 401 loop that leaves isLoading stuck at true and the app frozen on the splash screen.
ContentView.swift
Auth gate and startup lifecycle owner. The .task {} modifier owns the startup sequence:
.task {
await AuthManager.shared.restoreSession() // 1. auth first
SyncManager.shared.startMonitoring() // 2. monitor after auth resolves
if SyncManager.shared.isOnline && AuthManager.shared.isAuthenticated {
await SyncManager.shared.triggerSync() // 3. sync only if authenticated
}
}
The order is mandatory. startMonitoring() must not be called before restoreSession() completes because NWPathMonitor fires immediately on network availability, triggering triggerSync() before tokens are in Keychain.
Background transitions
DashboardView observes .scenePhase and calls scheduleBackgroundSync() every time the app moves to .background.
5. Authentication
AuthManager
@MainActor class AuthManager: ObservableObject — singleton via AuthManager.shared.
| Property | Purpose |
|---|---|
isAuthenticated |
Master gate — drives ContentView routing |
isLoading |
Shows spinner during network calls |
errorMessage |
Shown inline on LoginView |
currentUserId/Username/Role/DisplayName |
User identity persisted to Keychain |
Session restore flow (restoreSession):
- If no access token in Keychain →
isAuthenticated = falseimmediately (no network call). - Calls
GET /api/v1/auth/meto validate the stored token. - On
notAuthenticatederror → clears Keychain, sets unauthenticated. - 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:
LocalFacility.self, LocalArea.self, LocalTemplate.self,
LocalInspection.self, LocalIssue.self, PendingPhoto.self, SyncQueueEntry.self
SwiftData lightweight migration: Adding a new Bool property to any model requires a default value (e.g. var followUpRequired: Bool = false) — without a default the app crashes on launch after the model change.
Model Reference
| Model | Role | Key fields |
|---|---|---|
LocalFacility |
Read-only cached facility reference | serverId, name, address, projectName, areas (cascade) |
LocalArea |
Read-only cached area reference | serverId, facilityServerId, name, areaType |
LocalTemplate |
Cached template + raw JSON schema | serverId, formSchemaJSON, formSchema (computed) |
LocalInspection |
Inspector-authored inspection record | localId (UUID, unique), serverId, status, syncStatus, formDataJSON, followUpRequired, parentLocalId, parentServerId |
LocalIssue |
Issue flagged during inspection | localId (UUID, unique), serverId, inspectionLocalId, facilityServerId, severity, syncStatus |
PendingPhoto |
Photo awaiting upload | localId, localFilePath, serverPath, uploadStatus, entityType, fieldId |
SyncQueueEntry |
Outbox entry (currently informational) | entityType, localId, syncStatus, payloadJSON |
LocalInspection Status Flow
"draft" → "completed" → "synced"
→ "failed" (after 5 retries)
status = inspector workflow state. syncStatus = server submission state ("pending" | "synced" | "failed"). They are separate fields.
formData Storage
LocalInspection.formData is a computed property that JSON-serialises to/from formDataJSON: String. Keys are always strings (field IDs stringified). Values are Any (String, Int, Bool, Array, Dict). Do not store UIImage or any non-JSON-serialisable type in formData.
7. Offline-First Architecture
The app follows the outbox pattern:
Inspector action → SwiftData write (always succeeds immediately)
↓
syncStatus = "pending"
↓
NWPathMonitor detects connectivity
↓
SyncManager.triggerSync()
1. Upload pending photos (processPhotoQueue)
2. Submit completed inspections (processInspectionQueue)
3. Submit pending issues (processIssueQueue)
4. Pull fresh reference data (pullReferenceData)
The UI never blocks on network. Every screen is driven by local SwiftData queries.
8. Sync Engine (SyncManager)
@MainActor class SyncManager: ObservableObject — singleton via SyncManager.shared.
Published state
| Property | Purpose |
|---|---|
isOnline |
True when NWPathMonitor reports .satisfied |
isSyncing |
True during active sync cycle |
lastSyncAt |
Date of last completed sync |
syncError |
Last error string (shown in Settings and Pending Sync views) |
pendingCount |
Count of unsynced inspections + issues (shown as badge) |
triggerSync() guard
triggerSync() guards on three conditions before doing any work:
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
processPhotoQueue— upload allPendingPhotowithuploadStatus == "pending". On success, propagatesserverPathto the parentLocalInspection.formData(image fields) orLocalIssue.photoServerPath. Uses fetch-all + filter in Swift — no#Predicate.processInspectionQueue— submits completed inspections only when allpendingPhotosare settled. ClearsfollowUpRequiredon parent after sync.processIssueQueue— guards against submitting when parent inspectionsyncStatus == "failed"(prevents orphaned server records). Uses fetch-all + filter in Swift.pullReferenceData— fetches facilities, areas, and templates. Sequentialawaitcalls — notasync let.
Fetch pattern — CRITICAL for Xcode 26
Never use #Predicate anywhere in SyncManager. Under Xcode 26 with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, #Predicate with a captured String variable inside a @MainActor async function causes LocalInspection is ambiguous for type lookup — a cascade of compiler errors.
Never use the chained optional pattern:
// WRONG — compiler cannot infer T in this chained expression
let x = (try? context.fetch(FetchDescriptor<LocalInspection>()))?.filter { ... } ?? []
// CORRECT — split into two statements
let all = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
let x = all.filter { ... }
Always parenthesise try? before ??:
// 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
syncRetryCounton every failure. - At 5 retries:
syncStatus = "failed". The item stays in SwiftData but is never retried automatically. syncErroris cleared at the start of eachtriggerSync()call.
9. API Client (APIClient)
actor APIClient — singleton via APIClient.shared. All methods are async throws.
Generic request pipeline
request(endpoint, method, body, retrying) async throws -> T
- Builds URL from
Constants.baseURL + endpoint. - Injects Bearer token from Keychain.
- Performs
URLSession.data(for:). - On HTTP 401 and
retrying == false: callsrefreshAccessToken()once, retries. If refresh fails →APIError.notAuthenticated. - Decodes via
_Envelope<T>(wrapsok: 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 + startup lifecycle)
├── LoginView (unauthenticated)
└── DashboardView (authenticated)
├── Sidebar (NavigationSplitView — no selection: binding)
│ ├── My Inspections → MyInspectionsView
│ ├── Issues → IssuesListView
│ ├── Facilities → FacilitiesListView
│ ├── Pending Sync → SyncStatusView
│ ├── History → InspectionHistoryView
│ └── Settings → SettingsView
└── + button → StartInspectionView (sheet)
└── ExecuteInspectionView (navigation push)
└── FlagIssueView (sheet)
NavigationSplitView constraint: init(selection:content:) is unavailable on iPadOS 17. Navigation is driven by @State var selectedTab: SidebarTab with manual Button handlers. Never add a selection: binding.
11. Inspection Workflow
1. Start (StartInspectionView)
- Inspector picks template (required), facility (required), area (optional).
- Tapping Start creates a
LocalInspectionin SwiftData immediately (status = "draft",syncStatus = "pending") and navigates toExecuteInspectionView. - 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
formSchemaas a 12-column CSS-grid-equivalent layout viaGridFormView. - Auto-saves every 30 seconds to SwiftData.
- Saves on
onDisappear. - "Flag an Issue" button opens
FlagIssueViewas a sheet. - "Save Draft" force-saves with a brief spinner feedback.
- "Submit Inspection" shows a confirmation alert, then:
- Persists final
formDatato SwiftData. - Computes
overallScoreviacomputeScore(fromSchema:). - Sets
status = "completed",syncStatus = "pending",completedAt = Date(). - Calls
clearParentFollowUpFlag()immediately (badge clears on device before sync). - Triggers
SyncManager.triggerSync()in the background if online. - Shows a success banner for 2.5 seconds then dismisses.
- Persists final
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
PendingPhotorecords, local photo files, and associatedLocalIssuerecords. - Only
status == "draft"inspections may be deleted.
Status badge map
status |
syncStatus |
Badge label | Badge colour |
|---|---|---|---|
draft |
any | Draft | Blue |
completed |
pending |
Pending Sync | Orange |
completed |
synced |
Completed | Green |
failed |
any | Sync Failed | Red |
12. Form Field Rendering
GridFormView (primary renderer in ExecuteInspectionView)
Uses a 12-column absolute-position grid matching the web app's CSS grid exactly:
| Constant | Value | Source |
|---|---|---|
totalColumns |
12 | Web editor COLS=12 |
cellGap |
8 pt | Web CSS col-gap: 8px |
rowGap |
4 pt | Web CSS row-gap: 4px |
cellAspect |
52/72 | Web editor CELL_H/CELL_W |
cardPadding |
16 pt | Card inset |
Cell position is computed from col, row, colSpan, rowSpan attributes in the field schema. Container width is measured via a PreferenceKey pattern (zero-height overlay with GeometryReader) — works correctly through rotations and split-screen resizing.
Supported field types
| Type | SwiftUI renderer |
|---|---|
text, email |
TextField |
textarea |
TextEditor |
number |
TextField + .decimalPad |
date |
DatePicker (date only) |
checkbox |
Toggle |
checkbox_group |
Custom multi-select buttons (CellCheckboxGroup) |
radio |
Custom radio buttons (CellRadioGroup) |
select |
Menu dropdown (CellSelect) |
rating |
Custom star row (CellRatingStars) — tap same star to clear |
pass_fail |
Capsule pill buttons (CellPassFail) — tap selected to deselect |
signature |
PKCanvasView (SignatureFieldView) — requires PencilKit framework |
image |
CompactImageFieldView (grid) / ImageFieldView (standalone) |
table |
TableFieldView — horizontal scroll, editable cells |
section, label |
Display-only Text |
Field ID rule (CRITICAL)
Form schema IDs from the server are integers in JSON (e.g. "id": 5). After JSONSerialization, they arrive as Int in [String: Any] dictionaries. formData keys are always String. Field IDs must be resolved via:
let fid: String
if let s = field["id"] as? String { fid = s }
else if let n = field["id"] as? Int { fid = String(n) }
else { continue }
Never use Optional.map on field["id"] — it produces "Optional(5)" instead of "5", causing all formData lookups to silently miss and all scores to return 0.
13. Issue Flagging Workflow
FlagIssueView is presented as a sheet from ExecuteInspectionView.
- Inspector selects severity (segmented control: low / medium / high / critical).
- Enters description (required).
- Optionally attaches a photo.
- Tapping Submit:
- Creates
LocalIssuewithfacilityServerIdfrom the parent inspection. - Appends the issue to
inspection.localIssues. - If a photo was taken: creates a
PendingPhotowithentityType = "issue". - Saves to SwiftData.
- Triggers
SyncManager.triggerSync()if online.
- Creates
- Area picker is absent — facility is derived directly from the inspection context.
Issue sync guard
If inspection.syncStatus == "failed" when processIssueQueue runs, the issue is immediately marked "failed" with message "Parent inspection failed to sync — issue cannot be submitted." This prevents orphaned server records with no inspection_id.
14. Re-inspection Workflow
Trigger
On CompletedInspectionView, if inspection.followUpRequired == true, an orange banner is shown with a Start Re-inspection button. This opens StartInspectionView with preFillTemplateId, preFillFacilityId, parentServerId, and parentLocalId pre-set.
Parent form pre-fill
StartInspectionView.startInspection() copies non-scoring fields from the parent's formData into the new inspection. Excluded field types: rating, pass_fail, image, signature — these must always be re-evaluated fresh.
followUpRequired clearing
Cleared at three points to ensure the badge disappears regardless of timing:
- Immediately on Submit in
ExecuteInspectionView.submitInspection()viaclearParentFollowUpFlag(). - After sync in
SyncManager.processInspectionQueueusingparentLocalId. - On the server in
api/inspections.pywhen the PATCH/POST arrives.
clearParentFollowUpFlag() resolution order:
- Match by
parentLocalId(UUID, always set if re-inspection was created in this session). - Fall back to
parentServerId(set only after parent has synced). - Last resort: match by same
templateServerId + facilityServerId + followUpRequired == true. This fallback is ambiguous when multiple follow-ups are pending for the same template/facility combination.
15. Inspection History
InspectionHistoryView fetches completed inspections from the server via GET /api/v1/inspections?status=completed. It is online-only — shows ContentUnavailableView with "Offline" when !sync.isOnline.
Pagination: limit 30, offset-based. A "Load More" button appears when inspections.count < total. Pull-to-refresh resets to page 0.
16. Photo Handling
Capture
Photos are taken via UIImagePickerController (camera) or PHPickerViewController (library, no permission required for iOS 16+).
Local storage
All photos are saved to: Documents/JQC/Photos/<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 "". A local:// value that reaches the server would be stored as a malformed path.
Inspection submission gating
An inspection is not submitted until all its pendingPhotos have uploadStatus == "uploaded" or "failed". It simply continues to the next sync 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). Optional.map on Any? produces "Optional(5)" — all lookups miss, all scores return 0.
18. Background Sync
The app registers a BGProcessingTask with identifier com.jqc.sync.
Info.plist requirement: BGTaskSchedulerPermittedIdentifiers must contain com.jqc.sync. Without this entry, BGTaskScheduler.shared.register silently fails and background sync never fires.
Note on GENERATE_INFOPLIST_FILE: The project uses GENERATE_INFOPLIST_FILE = YES. Do NOT also have a physical Info.plist file on disk — having both causes "Multiple commands produce Info" build error. The file is generated at build time; there is no Info.plist in the source tree.
Scheduling: scheduleBackgroundSync() is called:
- Every time
scenePhase == .background - At the start of each background task handler (schedules the next run)
Requirements: requiresNetworkConnectivity = true, requiresExternalPower = false.
Tokens: Keychain access policy kSecAttrAccessibleAfterFirstUnlock ensures tokens are available when the app is woken by BGTaskScheduler after device reboot.
19. Settings & Cache Management
SettingsView exposes:
- Account info (username, role) — read-only from
AuthManager. - Sync Now — triggers
SyncManager.triggerSync(); disabled when offline or already syncing. - Last sync timestamp.
- Clear Reference Cache — deletes all
LocalFacility,LocalArea, andLocalTemplaterecords. Never deletesLocalInspection,LocalIssue, orPendingPhoto. TriggerspullReferenceData()immediately if online. - Log Out — calls
AuthManager.logout(). - App version and server URL (from
Constants.baseURL).
20. Known Constraints & Hard Rules
| # | Rule | Rationale |
|---|---|---|
| 1 | import Combine required in files using @Published |
Swift 5.9+ does not auto-import Combine; ObservableObject without it causes build errors |
| 2 | No selection: binding on NavigationSplitView |
init(selection:content:) unavailable on iPadOS 17; use @State var selectedTab: SidebarTab with Button handlers |
| 3 | No #Predicate anywhere in SyncManager |
Under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, #Predicate with a captured String variable causes LocalInspection is ambiguous compiler cascade. Use fetch-all + filter in Swift throughout |
| 4 | Sequential await in pullReferenceData() |
async let causes Swift 6 actor-isolation warnings on Decodable structs; use sequential await calls |
| 5 | PencilKit requires explicit framework linkage | Add PencilKit.framework under Target → Frameworks, Libraries, and Embedded Content |
| 6 | Free Apple ID provisioning expires every 7 days | Rebuild with ⌘R while iPad is connected; SwiftData persists across reinstalls |
| 7 | kSecAttrAccessibleAfterFirstUnlock for all Keychain items |
Tokens must be readable when the app is woken by BGTaskScheduler after reboot |
| 8 | New Bool model fields require = false default |
SwiftData lightweight migration crashes on launch without a default value for new Bool properties |
| 9 | Field IDs in formData are always String keys | Server encodes them as Int in JSON; always cast via as? String then as? Int → String(n). Never use Optional.map on field["id"] |
| 10 | Strip local:// paths from formData before submitInspection |
Failed photo uploads leave "local://..." in formData; JSONSerialization silently drops non-serialisable values |
| 11 | Do not submit an issue when parent inspection syncStatus == "failed" |
Submitting with no inspection_id creates orphaned server records |
| 12 | Photo-before-inspection ordering in sync | processPhotoQueue must run before processInspectionQueue |
| 13 | com.jqc.sync must be in BGTaskSchedulerPermittedIdentifiers |
BGTaskScheduler silently ignores unregistered identifiers |
| 14 | refreshAccessToken() uses a local JSONDecoder, not self.decoder |
Accessing the actor-isolated self.decoder from a non-isolated context triggers Swift 6 isolation errors |
| 15 | clearParentFollowUpFlag() fallback-2 is ambiguous |
Matching by template+facility is ambiguous when multiple follow-ups are pending for the same template/facility |
| 16 | SyncQueueEntry model is registered but not actively written |
Included for future use; syncStatus on LocalInspection and LocalIssue is the active queue |
| 17 | Constants.baseURL is the only server URL |
All endpoints are Constants.baseURL + endpoint. Update this one constant for environment changes |
| 18 | Photo JPEG compression is 0.8 | Do not raise above 0.85 without testing against the server's 50 MB limit |
| 19 | clearCache() in Settings never deletes inspections or issues |
Only LocalFacility, LocalArea, LocalTemplate are safe to purge |
| 20 | AuthManager.restoreSession() falls back to Keychain on non-auth errors |
Allows offline launch but may expose stale role/identity data |
| 21 | startMonitoring() must be called AFTER restoreSession() completes |
NWPathMonitor fires immediately on launch, triggering triggerSync() before tokens exist; the 401 loop leaves isLoading stuck |
| 22 | triggerSync() guards on AuthManager.shared.isAuthenticated |
Prevents sync from running before auth is established — covers the NWPathMonitor race and any BGTask path |
| 23 | Do NOT place model files in non-Model folders | Xcode 26 folder sync compiles every .swift in the tree; a LocalInspection.swift in Auth/ causes "Multiple commands produce LocalInspection" |
| 24 | No physical Info.plist file when GENERATE_INFOPLIST_FILE = YES |
Having both causes "Multiple commands produce Info" build error |
| 25 | Always parenthesise try? before ?? |
try? context.fetch(...) ?? [] parses as try? (fetch() ?? []) — fetch() is non-optional so ?? is invalid inside try?; compiler infers T = Any and cascades into build errors. Write (try? context.fetch(...)) ?? [] |
| 26 | Delete Xcode's default Item.swift immediately after project creation |
Xcode generates Item.swift with @Model class Item when creating a new SwiftData project; it compiles silently via folder sync and conflicts with real models |
21. Xcode 26 Specific Issues
This project was created with Xcode 26.4.1 (Apple's major 2026 release). Several compiler behaviours differ from Xcode 15/16 and require specific patterns.
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
Xcode 26 sets this build setting when creating new projects with "approachable concurrency" enabled. It makes every type and function implicitly @MainActor.
Effect on SwiftData: SwiftData's @Model macro generates nonisolated accessors internally. When FetchDescriptor<LocalInspection> is used inside a @MainActor async method, the compiler sees a conflict between @MainActor LocalInspection and nonisolated PersistentModel requirements. The error cascade is:
'LocalInspection' is ambiguous for type lookup in this contextGeneric parameter 'T' could not be inferredType 'Any' cannot conform to 'PersistentModel'The compiler is unable to type-check this expression in reasonable time
Solutions applied in this codebase:
- All
context.fetch()calls use fetch-all + filter in Swift — no#Predicatewith captured variables. - All
try? context.fetch(...)expressions are parenthesised before??. - Chained optional patterns
(try? fetch(...))?.filter { }are split into twoletstatements. triggerSync()guards onisAuthenticatedto prevent 401 cascades during startup.
Do NOT remove SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor from build settings unless you fully audit every file for the resulting isolation changes. The patterns above are the correct workarounds.
PBXFileSystemSynchronizedRootGroup (Folder Sync)
Xcode 26 uses folder-sync mode instead of an explicit file list in project.pbxproj. Every .swift file in the project folder is compiled automatically. There is no file registry to check.
Consequences:
- Stray files (e.g.
Item.swiftfrom the project template, accidentally duplicated model files) compile silently and cause "Multiple commands produce" errors. - Deleting a file from Finder is sufficient to remove it from the build — no need to remove it from the project navigator separately.
- When diagnosing "Multiple commands produce X", run:
find /path/to/project -name "*.swift" | xargs grep -l "class X"to find all definitions.
Derived Data corruption
Under Xcode 26, repeated failed builds accumulate corrupt intermediate files in derived data. After any "Multiple commands produce" error is resolved, delete derived data manually before rebuilding:
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.
22. Change Philosophy
- 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.
- Trace the full data path. For any bug: view → SwiftData write → SyncManager → APIClient → server response. Identify the exact layer.
- Root cause, not symptom. State the root cause explicitly before proposing a fix. Multiple failed attempts are always caused by treating symptoms.
- Smallest possible change. Do not restructure, rename, or reformat surrounding code.
- Never remove functionality unless explicitly directed.
- SwiftData schema changes need defaults. All new
Boolfields:= false. Run on device and check for migration crash before shipping. - Test offline and online. Every sync-related fix must be verified in airplane mode.
- 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.
- 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.
- Update this document at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.