Files
JQC_iOS_App/JanitorialQC/CLAUDE.md
T
2026-05-15 16:50:25 -04:00

41 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 (Phase C complete — notification polling, assigned-issue sync, issue photo display, FlagIssueView confirmation banner, re-inspection pre-fill fix) Companion: See the web backend's CLAUDE.md for API contract, server-side rules, and migration chain.


Table of Contents

  1. Project Overview
  2. Tech Stack
  3. Repository Layout
  4. App Entry Point & Lifecycle
  5. Authentication
  6. SwiftData Models
  7. Offline-First Architecture
  8. Sync Engine (SyncManager)
  9. API Client (APIClient)
  10. Navigation & View Hierarchy
  11. Inspection Workflow
  12. Form Field Rendering
  13. Issue Flagging Workflow
  14. Re-inspection Workflow
  15. Inspection History
  16. Photo Handling
  17. Score Calculation
  18. Background Sync
  19. Settings & Cache Management
  20. Known Constraints & Hard Rules
  21. Xcode 26 Specific Issues
  22. 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; confirmation banner shown on submit
  • Re-inspection — linked follow-up inspections with parent-form pre-fill (template, contract, facility all pre-filled)
  • 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 issues pulled from server + device-created issues; stale unassigned records removed on sync
  • Issue detail — shows local photos and server-hosted photos via AsyncImage
  • 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

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:

  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

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):

  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

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)
         5. Pull + reconcile assigned issues (pullAssignedIssues)
         6. Poll server notifications (pollNotifications)

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

  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.
  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.
  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.
  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.

Notification polling

  • startPollTask() creates a Task with a Task.sleep(nanoseconds: 60_000_000_000) loop. Called from startMonitoring() whenever connectivity is .satisfied.
  • stopPollTask() cancels the task. Called when connectivity drops.
  • resetNotificationPoller() cancels task + clears lastNotificationFetch. 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.currentRunLoop.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].

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 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)

actor APIClient — singleton via APIClient.shared. All methods are async throws.

Generic request pipeline

request(endpoint, method, body, retrying) async throws -> T
  1. Builds URL from Constants.baseURL + endpoint.
  2. Injects Bearer token from Keychain.
  3. Performs URLSession.data(for:).
  4. On HTTP 401 and retrying == false: calls refreshAccessToken() once, retries. If refresh fails → APIError.notAuthenticated.
  5. Decodes via _Envelope<T> (wraps ok: Bool, data: T?, error: String?).

Key methods

Method Endpoint Notes
request<T> Any Generic GET/POST
post<T> Any POST convenience
uploadPhoto POST /api/v1/photos/upload Multipart form-data, manual boundary
submitInspection POST /api/v1/inspections Sanitises local:// photo paths before send
submitIssue POST /api/v1/issues Sends inspection_id only if inspection.serverId is non-nil
fetchInspectionHistory GET /api/v1/inspections Paginated, returns InspectionHistoryResponseData
fetchAssignedIssues GET /api/v1/issues Returns issues assigned to current user
fetchIssueDetail GET /api/v1/issues/<id> Fetches current status for detail view
updateIssueStatus PATCH /api/v1/issues/<id>/status Inspector updates status on assigned issues
fetchNotifications GET /api/v1/notifications Accepts optional since: Date; returns [APINotification]
markNotificationsRead PATCH /api/v1/notifications/mark-read Marks list of IDs read on server

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 LocalInspection in SwiftData immediately (status = "draft", syncStatus = "pending") and navigates to ExecuteInspectionView.
  • For re-inspections: template and facility are pre-filled; non-scoring fields from the parent are copied into formData (rating, pass_fail, image, signature fields are always blank).

2. Execute (ExecuteInspectionView)

  • Renders the template's formSchema as a 12-column CSS-grid-equivalent layout via GridFormView.
  • Auto-saves every 30 seconds to SwiftData.
  • Saves on onDisappear.
  • "Flag an Issue" button opens FlagIssueView as a sheet.
  • "Save Draft" force-saves with a brief spinner feedback.
  • "Submit Inspection" shows a confirmation alert, then:
    1. Persists final formData to SwiftData.
    2. Computes overallScore via computeScore(fromSchema:).
    3. Sets status = "completed", syncStatus = "pending", completedAt = Date().
    4. Calls clearParentFollowUpFlag() immediately (badge clears on device before sync).
    5. Triggers SyncManager.triggerSync() in the background if online.
    6. Shows a success banner for 2.5 seconds then dismisses.

3. Draft management

  • Drafts appear in My Inspections with a blue "Draft" badge.
  • Swipe-left on a draft reveals a Delete action (confirmation required). Deletion removes the draft, all its PendingPhoto records, local photo files, and associated LocalIssue records.
  • Only status == "draft" inspections may be deleted.

Status badge map

status syncStatus Badge label Badge colour
draft any Draft Blue
completed pending Pending Sync Orange
completed synced Completed Green
failed any Sync Failed Red

12. Form Field Rendering

GridFormView (primary renderer in ExecuteInspectionView)

Uses a 12-column absolute-position grid matching the web app's CSS grid exactly:

Constant Value Source
totalColumns 12 Web editor COLS=12
cellGap 8 pt Web CSS col-gap: 8px
rowGap 4 pt Web CSS row-gap: 4px
cellAspect 52/72 Web editor CELL_H/CELL_W
cardPadding 16 pt Card inset

Cell position is computed from col, row, colSpan, rowSpan attributes in the field schema. Container width is measured via a PreferenceKey pattern (zero-height overlay with GeometryReader) — 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.

  1. Inspector selects severity (segmented control: low / medium / high / critical).
  2. Enters description (required).
  3. Optionally attaches a photo.
  4. Tapping Submit:
    • Creates LocalIssue with facilityServerId from the parent inspection.
    • Appends the issue to inspection.localIssues.
    • If a photo was taken: creates a PendingPhoto with entityType = "issue".
    • Saves to SwiftData.
    • 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

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.

Picker pre-fill (template, contract, facility)

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

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.

Server-hosted photos in IssueDetailView

IssueDetailView shows two photo sections:

  1. Local photosphotoLocalPaths rendered via UIImage(contentsOfFile:). Present for device-created issues where photos were captured on-device.
  2. Server photosphotoServerPaths 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

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, and LocalTemplate records. Never deletes LocalInspection, LocalIssue, or PendingPhoto. Triggers pullReferenceData() immediately if online.
  • Log Out — calls AuthManager.logout().
  • App version and server URL (from Constants.baseURL).

20. Known Constraints & Hard Rules

# Rule Rationale
1 import Combine required in files using @Published Swift 5.9+ does not auto-import Combine; ObservableObject without it causes build errors
2 No selection: binding on NavigationSplitView init(selection:content:) unavailable on iPadOS 17; use @State var selectedTab: SidebarTab with Button handlers
3 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
27 Timer.scheduledTimer must NOT be used for periodic work in SyncManager Inside Task { @MainActor }, RunLoop.currentRunLoop.main; the timer is added to a runloop that never ticks and fires silently never. Use Task.sleep instead
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()
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
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
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
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
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
34 IssueDetailView shows both photoLocalPaths and photoServerPaths Local paths use UIImage(contentsOfFile:); server paths use AsyncImage with Constants.baseURL prefix. Both sections are independent

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 context
  • Generic parameter 'T' could not be inferred
  • Type 'Any' cannot conform to 'PersistentModel'
  • The compiler is unable to type-check this expression in reasonable time

Solutions applied in this codebase:

  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.

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.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

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

  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.
  2. Trace the full data path. For any bug: view → SwiftData write → SyncManager → APIClient → server response. Identify the exact layer.
  3. Root cause, not symptom. State the root cause explicitly before proposing a fix. Multiple failed attempts are always caused by treating symptoms.
  4. Smallest possible change. Do not restructure, rename, or reformat surrounding code.
  5. Never remove functionality unless explicitly directed.
  6. SwiftData schema changes need defaults. All new Bool fields: = false. Run on device and check for migration crash before shipping.
  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.
  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.
  10. Update this document at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.