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