diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index aac01ca..c61bf61 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -29,22 +29,9 @@ B37875952FA6358F0088F40B /* JanitorialQCUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JanitorialQCUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ -/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - B37875E02FA771440088F40B /* Exceptions for "JanitorialQC" folder in "JanitorialQC" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = B378757B2FA6358E0088F40B /* JanitorialQC */; - }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ - /* Begin PBXFileSystemSynchronizedRootGroup section */ B378757E2FA6358E0088F40B /* JanitorialQC */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - B37875E02FA771440088F40B /* Exceptions for "JanitorialQC" folder in "JanitorialQC" target */, - ); path = JanitorialQC; sourceTree = ""; }; @@ -408,22 +395,17 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = SB7DNYC9TY; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = JanitorialQC/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_NSCameraUsageDescription = "JQC uses the camera to capture photos of inspection findings."; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "JQC saves inspection photos to your photo library."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "JQC reads photos from your library to attach to inspection reports."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = ( @@ -443,7 +425,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -452,22 +434,17 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = SB7DNYC9TY; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = JanitorialQC/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_NSCameraUsageDescription = "JQC uses the camera to capture photos of inspection findings."; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "JQC saves inspection photos to your photo library."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "JQC reads photos from your library to attach to inspection reports."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = ( @@ -487,7 +464,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/JanitorialQC.xcodeproj/xcuserdata/nngo.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/JanitorialQC.xcodeproj/xcuserdata/nngo.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..96526d0 --- /dev/null +++ b/JanitorialQC.xcodeproj/xcuserdata/nngo.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/JanitorialQC/Auth/LocalInspection.swift b/JanitorialQC/Auth/LocalInspection.swift deleted file mode 100644 index 04c5e99..0000000 --- a/JanitorialQC/Auth/LocalInspection.swift +++ /dev/null @@ -1,185 +0,0 @@ -// Models/LocalInspection.swift -// ---------------------------- -// SwiftData model for locally stored inspections. -// Created immediately when the inspector starts a new inspection. -// Written entirely offline; synced to server when connectivity returns. - -import Foundation -import SwiftData - -@Model -final class LocalInspection { - - // ── Identity ────────────────────────────────────────────────────────── - /// UUID generated on device — stable local identity and idempotency key - @Attribute(.unique) var localId: String - /// Set by server after successful sync; nil until then - var serverId: Int? - - // ── Foreign keys (server IDs, from cached reference data) ───────────── - var templateServerId: Int - var facilityServerId: Int - var areaServerId: Int? - var inspectorUserId: Int - - // ── Inspection data ──────────────────────────────────────────────────── - /// "draft" | "completed" | "synced" | "sync_failed" - var status: String - /// JSON dict of { field_id: value } — same shape as server form_data - var formDataJSON: String - var inspectorNotes: String - var overallScore: Double? - var inspectionDate: Date - var completedAt: Date? - var createdAt: Date - var lastModifiedAt: Date - - // ── Sync ────────────────────────────────────────────────────────────── - var syncStatus: String // "pending" | "synced" | "failed" - var syncErrorMessage: String? - var syncRetryCount: Int - - // ── Follow-up / re-inspection (populated from server after sync) ─────── - /// Set by director/admin on the web app; signals this inspection needs a follow-up. - @Attribute var followUpRequired: Bool = false - /// Optional note explaining what the follow-up should address. - var followUpNote: String? - /// Server ID of the parent inspection this record is a re-inspection of. - var parentServerId: Int? - /// Local UUID of the parent LocalInspection — set at creation, always available - /// regardless of whether the parent has synced. Used to clear the parent's - /// followUpRequired badge without relying on parentServerId being non-nil. - var parentLocalId: String? - - // ── Relationships ────────────────────────────────────────────────────── - @Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto] - @Relationship(deleteRule: .cascade) var localIssues: [LocalIssue] - - init( - templateServerId: Int, - facilityServerId: Int, - areaServerId: Int?, - inspectorUserId: Int - ) { - self.localId = UUID().uuidString - self.serverId = nil - self.templateServerId = templateServerId - self.facilityServerId = facilityServerId - self.areaServerId = areaServerId - self.inspectorUserId = inspectorUserId - self.status = "draft" - self.formDataJSON = "{}" - self.inspectorNotes = "" - self.overallScore = nil - self.inspectionDate = Date() - self.completedAt = nil - self.createdAt = Date() - self.lastModifiedAt = Date() - self.syncStatus = "pending" - self.syncErrorMessage = nil - self.syncRetryCount = 0 - self.followUpRequired = false - self.followUpNote = nil - self.parentServerId = nil - self.parentLocalId = nil - self.pendingPhotos = [] - self.localIssues = [] - } - - // ── Form data helpers ────────────────────────────────────────────────── - - var formData: [String: Any] { - get { - guard let data = formDataJSON.data(using: .utf8), - let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { return [:] } - return dict - } - set { - if let data = try? JSONSerialization.data(withJSONObject: newValue), - let str = String(data: data, encoding: .utf8) { - formDataJSON = str - lastModifiedAt = Date() - } - } - } - - func setValue(_ value: Any, forFieldId fieldId: String) { - var current = formData - current[fieldId] = value - formData = current - } - - func getValue(forFieldId fieldId: String) -> Any? { - formData[fieldId] - } - - // ── Score calculation (mirrors Python _compute_score_from_form) ──────── - - func computeScore(fromSchema schema: [[String: Any]]) -> Double? { - let scoreable = schema.filter { - ["rating", "checkbox", "radio", "pass_fail"].contains($0["type"] as? String ?? "") - } - guard !scoreable.isEmpty else { return nil } - - var total = 0 - var earned = 0 - - for field in scoreable { - // Resolve field ID to String regardless of whether the JSON encoded - // it as a String or an Int. The web app stores IDs as integers in - // form_schema JSON (e.g. "id": 5); JSONSerialization decodes these - // as Int, not String. The formData dict is keyed by String (the - // ID is always stringified before storage), so the lookup key must - // also be a String. The previous guard-let with Optional.map was - // producing Optional("5") rather than "5", causing all lookups to - // miss when field IDs were integers. - 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 // field has no usable id — skip - } - - guard let ftype = field["type"] as? String else { continue } - - let val = formData[fid].map { "\($0)" } ?? "" - - switch ftype { - case "rating": - if let v = Int(val), v > 0 { - earned += v - total += 5 - } - case "checkbox": - total += 1 - if val == "true" { earned += 1 } - case "radio": - total += 1 - if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { - earned += 1 - } - case "pass_fail": - if val.isEmpty { continue } - total += 1 - if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { - earned += 1 - } - default: break - } - } - - guard total > 0 else { return nil } - return (Double(earned) / Double(total) * 100).rounded(toPlaces: 2) - } -} - -// Helper for rounding -extension Double { - func rounded(toPlaces places: Int) -> Double { - let divisor = pow(10.0, Double(places)) - return (self * divisor).rounded() / divisor - } -} diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index 3f86388..5dd70bd 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -2,7 +2,7 @@ > **Audience:** AI assistants and developers working on the JanitorialQC iPad app. > **Purpose:** Authoritative reference for architecture, conventions, constraints, and decisions. -> **Last reviewed:** May 2026 (Phase C complete — background sync, Issues view, Inspection History) +> **Last reviewed:** May 2026 (Hardening session — Xcode 26 build fixes, startup race condition resolved) > **Companion:** See the web backend's `CLAUDE.md` for API contract, server-side rules, and migration chain. --- @@ -29,7 +29,8 @@ 18. [Background Sync](#18-background-sync) 19. [Settings & Cache Management](#19-settings--cache-management) 20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules) -21. [Change Philosophy](#21-change-philosophy) +21. [Xcode 26 Specific Issues](#21-xcode-26-specific-issues) +22. [Change Philosophy](#22-change-philosophy) --- @@ -64,7 +65,7 @@ Core capabilities: | Signature capture | PencilKit (PKCanvasView) | | Background tasks | BGTaskScheduler / BGProcessingTask | | Min deployment | iOS 17.0 | -| Xcode | 15+ | +| Xcode | **26.4.1** (Xcode 26 beta — see §21 for critical constraints) | --- @@ -73,7 +74,7 @@ Core capabilities: ``` JanitorialQC/ ├── JanitorialQCApp.swift # @main — SwiftData container, BGTask registration -├── ContentView.swift # Auth gate: LoginView ↔ DashboardView +├── ContentView.swift # Auth gate + startup lifecycle (.task{}) │ ├── Auth/ │ ├── AuthManager.swift # @MainActor ObservableObject — login/logout/restore @@ -86,43 +87,65 @@ JanitorialQC/ ├── Sync/ │ └── SyncManager.swift # @MainActor ObservableObject — NWPathMonitor, outbox queue │ -├── Models/ # SwiftData @Model classes +├── Models/ # SwiftData @Model classes — NO other .swift files here │ ├── LocalFacility.swift │ ├── LocalArea.swift │ ├── LocalTemplate.swift -│ ├── LocalInspection.swift +│ ├── LocalInspection.swift # ← ONLY definition of LocalInspection — never duplicate │ ├── LocalIssue.swift │ ├── PendingPhoto.swift │ └── SyncQueueEntry.swift │ ├── Views/ │ ├── Auth/ -│ │ └── LoginView.swift +│ │ └── LoginView.swift # ← Auth/ folder must contain ONLY auth files — no models │ ├── Dashboard/ -│ │ ├── DashboardView.swift # NavigationSplitView sidebar + all embedded views -│ │ ├── StartInspectionView.swift # Template/facility/area picker, creates LocalInspection -│ │ ├── ExecuteInspectionView.swift # Grid form renderer, draft save, submit -│ │ ├── FlagIssueView.swift # Issue creation sheet -│ │ └── FormFieldView.swift # All field-type renderers + camera/library pickers +│ │ ├── DashboardView.swift +│ │ ├── StartInspectionView.swift +│ │ ├── ExecuteInspectionView.swift +│ │ ├── FlagIssueView.swift +│ │ └── FormFieldView.swift │ └── Inspection/ -│ └── InspectionHistoryView.swift # Server-side history, read-only +│ └── InspectionHistoryView.swift │ └── Utils/ - └── Constants.swift # baseURL, Keychain key strings + └── 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`** is `@main`. It: +### JanitorialQCApp.swift + +`@main` struct. Responsibilities: 1. Creates the SwiftData `ModelContainer` for all seven model types. -2. On container success: sets `SyncManager.shared.modelContext`, calls `AuthManager.shared.restoreSession()`, starts `NWPathMonitor`, and triggers an immediate sync if online. -3. Registers the `com.jqc.sync` `BGProcessingTask` identifier — this **must** match the `BGTaskSchedulerPermittedIdentifiers` array in `Info.plist`. +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. -**`ContentView.swift`** is the auth gate. When `AuthManager.isAuthenticated` is `false` it shows `LoginView`; when `true` it shows `DashboardView`. +**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. -**Background transitions:** `DashboardView` observes `.scenePhase` and calls `scheduleBackgroundSync()` every time the app moves to `.background`. +### ContentView.swift + +Auth gate and **startup lifecycle owner**. The `.task {}` modifier owns the startup sequence: + +```swift +.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`. --- @@ -140,7 +163,7 @@ JanitorialQC/ | `currentUserId/Username/Role/DisplayName` | User identity persisted to Keychain | **Session restore flow (`restoreSession`):** -1. If no access token in Keychain → unauthenticated immediately. +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. @@ -211,7 +234,7 @@ The app follows the **outbox pattern**: ``` Inspector action → SwiftData write (always succeeds immediately) ↓ - SyncQueueEntry / syncStatus = "pending" + syncStatus = "pending" ↓ NWPathMonitor detects connectivity ↓ @@ -240,22 +263,52 @@ The UI never blocks on network. Every screen is driven by local SwiftData querie | `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: + +```swift +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, updates `serverPath` on the photo and propagates it to the parent `LocalInspection.formData` (for image fields) or `LocalIssue.photoServerPath`. -2. `processInspectionQueue` — for each completed inspection with `syncStatus == "pending"`, submits only when all `pendingPhotos` are `"uploaded"` or `"failed"`. After sync, clears `followUpRequired` on the parent if this is a re-inspection. -3. `processIssueQueue` — for each issue with `syncStatus == "pending"`, **skips and marks `"failed"` if the parent inspection has `syncStatus == "failed"`** (prevents orphaned server records). Sends `inspection_id` from `inspection.serverId`. -4. `pullReferenceData` — fetches all facilities, their areas, and all templates (with full schema). Uses sequential `await` calls (not `async let`) to avoid Swift 6 actor-isolation warnings on `Decodable` structs. +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`. + +### 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:** +```swift +// WRONG — compiler cannot infer T in this chained expression +let x = (try? context.fetch(FetchDescriptor()))?.filter { ... } ?? [] + +// CORRECT — split into two statements +let all = (try? context.fetch(FetchDescriptor())) ?? [] +let x = all.filter { ... } +``` + +**Always parenthesise `try?` before `??`:** +```swift +// WRONG — operator precedence: try? binds looser than ?? +// parses as: try? (context.fetch(...) ?? []) — nonsensical, fetch() is not Optional +let all = try? context.fetch(FetchDescriptor()) ?? [] + +// CORRECT +let all = (try? context.fetch(FetchDescriptor())) ?? [] +``` ### Retry / failure - Each item increments `syncRetryCount` on every failure. - At 5 retries: `syncStatus = "failed"`. The item stays in SwiftData but is never retried automatically. -- `syncError` is set to the last error for display; it is cleared at the start of each `triggerSync()` call. - -### #Predicate string comparison rule - -**Never use `#Predicate` with string literal comparisons across model type boundaries.** Fetch all + filter in Swift instead. This avoids a SwiftData macro type-inference bug that causes runtime crashes in some Xcode versions. +- `syncError` is cleared at the start of each `triggerSync()` call. --- @@ -304,7 +357,7 @@ request(endpoint, method, body, retrying) async throws -> T ``` JanitorialQCApp -└── ContentView (auth gate) +└── ContentView (auth gate + startup lifecycle) ├── LoginView (unauthenticated) └── DashboardView (authenticated) ├── Sidebar (NavigationSplitView — no selection: binding) @@ -377,7 +430,7 @@ Uses a 12-column absolute-position grid matching the web app's CSS grid exactly: | `cellAspect` | 52/72 | Web editor `CELL_H/CELL_W` | | `cardPadding` | 16 pt | Card inset | -Cell position is computed from `col`, `row`, `colSpan`, `rowSpan` attributes in the field schema. Container width is measured via a `PreferenceKey` pattern (zero-height overlay with `GeometryReader`) — this works correctly through `ScrollView` rotations and split-screen resizing. +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 @@ -409,7 +462,7 @@ else if let n = field["id"] as? Int { fid = String(n) } else { continue } ``` -**Never use `Optional.map` on `field["id"]`** — it produces `"Optional(5)"` instead of `"5"`, causing all formData lookups to miss. +**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. --- @@ -426,7 +479,7 @@ else { continue } - If a photo was taken: creates a `PendingPhoto` with `entityType = "issue"`. - Saves to SwiftData. - Triggers `SyncManager.triggerSync()` if online. -5. Area picker is absent — facility is derived directly from the inspection context (mirrors web `flag_issue.html`). +5. Area picker is absent — facility is derived directly from the inspection context. ### Issue sync guard @@ -455,7 +508,7 @@ Cleared at **three** points to ensure the badge disappears regardless of timing: `clearParentFollowUpFlag()` resolution order: 1. Match by `parentLocalId` (UUID, always set if re-inspection was created in this session). 2. Fall back to `parentServerId` (set only after parent has synced). -3. Last resort: match by same `templateServerId + facilityServerId + followUpRequired == true` (for stale records created before `parentLocalId` was added). **This fallback is ambiguous when multiple inspections of the same template at the same facility are pending follow-up — document as a known edge case.** +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.** --- @@ -465,8 +518,6 @@ Cleared at **three** points to ensure the badge disappears regardless of timing: Pagination: limit 30, offset-based. A "Load More" button appears when `inspections.count < total`. Pull-to-refresh resets to page 0. -`HistoryDetailView` shows follow-up badge, parent inspection link, score, and re-inspection option. The data is `APIInspectionSummary` — a server-side DTO, not a SwiftData model. - --- ## 16. Photo Handling @@ -492,11 +543,11 @@ Uploaded (uploadStatus="uploaded", serverPath set) ### local:// sentinel -While a photo is pending upload, the form field value is set to `"local://"`. Before `submitInspection` sends `formData` to the server, these values are replaced with `""` (empty string). A `local://` value that reaches the server would be stored as a malformed path. +While a photo is pending upload, the form field value is set to `"local://"`. 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 in `processInspectionQueue` is **not submitted** until all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"uploaded"` or `"failed"`. It simply `continue`s to the next cycle. +An inspection is **not submitted** until all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"failed"`. It simply `continue`s to the next sync cycle. --- @@ -515,7 +566,7 @@ An inspection in `processInspectionQueue` is **not submitted** until all its `pe Returns `nil` if no scoreable fields or all are unanswered. -**Field ID resolution:** Always use the explicit cast pattern (see §12 Field ID rule). `Optional.map` on `Any?` produces `"Optional(5)"` and causes all lookups to silently miss. +**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. --- @@ -525,8 +576,9 @@ 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: -- On app init (via `registerBackgroundTasks`) - Every time `scenePhase == .background` - At the start of each background task handler (schedules the next run) @@ -554,35 +606,85 @@ The app registers a `BGProcessingTask` with identifier `com.jqc.sync`. |---|---|---| | 1 | **`import Combine` required in files using `@Published`** | Swift 5.9+ does not auto-import Combine; `ObservableObject` without it causes build errors | | 2 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17; use `@State var selectedTab: SidebarTab` with `Button` handlers | -| 3 | **`#Predicate` — fetch all + filter in Swift for string comparisons** | SwiftData macro type-inference bug with string literals across model type boundaries causes runtime crashes | +| 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, losing the field entirely on the server | +| 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`; server path must be in `formData` before the inspection is submitted | -| 13 | **`com.jqc.sync` BGTaskSchedulerPermittedIdentifiers must be in Info.plist** | BGTaskScheduler silently ignores unregistered identifiers | +| 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 when `parentLocalId` and `parentServerId` are both nil may clear the wrong inspection if multiple follow-ups are pending for the same template/facility combination | -| 16 | **`SyncQueueEntry` model is registered but not actively written** | Included for future use; currently `syncStatus` on `LocalInspection` and `LocalIssue` serves as the outbox queue | -| 17 | **`Constants.baseURL` is the only server URL** | All endpoints are built as `Constants.baseURL + endpoint`. Update this one constant for environment changes | -| 18 | **Photo JPEG compression is 0.8** | Balances quality vs. upload size. Do not raise above 0.85 without testing against the server's 50 MB limit | -| 19 | **`clearCache()` in Settings never deletes inspections or issues** | Only `LocalFacility`, `LocalArea`, `LocalTemplate` are safe to purge — inspection and issue data is the inspector's primary work product | -| 20 | **`AuthManager.restoreSession()` falls back to Keychain on non-auth errors** | A server 500 or network timeout at launch allows offline operation but may expose stale role/identity data | +| 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. Change Philosophy +## 21. Xcode 26 Specific Issues -1. **Read the actual file before editing.** Never rely on earlier context — a prior edit invalidates it. +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` 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. + +**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: + +```bash +rm -rf ~/Library/Developer/Xcode/DerivedData/- +``` + +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. +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`. All new optional fields: `= nil` or a safe default. Run on device and check for migration crash before shipping. -7. **Test offline and online.** Every feature must work without connectivity. Sync-related fixes must be verified by simulating airplane mode. -8. **Full file contents** for 1–3 file changes; deployment map for larger changesets. -9. **Update this document** at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision. +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. diff --git a/JanitorialQC/ContentView.swift b/JanitorialQC/ContentView.swift index 1c691a6..6888f42 100644 --- a/JanitorialQC/ContentView.swift +++ b/JanitorialQC/ContentView.swift @@ -1,16 +1,14 @@ // ContentView.swift -// ----------------- -// Root view. Shows LoginView or DashboardView based on auth state. import SwiftUI struct ContentView: View { @EnvironmentObject private var auth: AuthManager + @EnvironmentObject private var sync: SyncManager var body: some View { Group { if auth.isLoading && !auth.isAuthenticated { - // Splash / loading state on app launch VStack(spacing: 16) { Image(systemName: "checkmark.seal.fill") .font(.system(size: 64)) @@ -24,5 +22,17 @@ struct ContentView: View { } } .animation(.easeInOut(duration: 0.3), value: auth.isAuthenticated) + .task { + // 1. Restore session first — sets isAuthenticated and loads tokens. + await AuthManager.shared.restoreSession() + // 2. Only start network monitoring AFTER auth is resolved. + // startMonitoring() fires triggerSync() immediately on connectivity, + // so it must not run before tokens are in place. + SyncManager.shared.startMonitoring() + // 3. If already online and authenticated, do an initial sync. + if SyncManager.shared.isOnline && AuthManager.shared.isAuthenticated { + await SyncManager.shared.triggerSync() + } + } } } diff --git a/JanitorialQC/Info.plist b/JanitorialQC/Info.plist deleted file mode 100644 index e2e8562..0000000 --- a/JanitorialQC/Info.plist +++ /dev/null @@ -1,23 +0,0 @@ - - - - - BGTaskSchedulerPermittedIdentifiers - - com.jqc.sync - - Background Modes - - CFBundleDocumentTypes - - - LSHandlerRank - Default - - - UIBackgroundModes - - processing - - - diff --git a/JanitorialQC/Item.swift b/JanitorialQC/Item.swift deleted file mode 100644 index 6621b23..0000000 --- a/JanitorialQC/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// JanitorialQC -// -// Created by Nguyen Ngo on 5/2/26. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift index 43bb301..7a106fa 100644 --- a/JanitorialQC/JanitorialQCApp.swift +++ b/JanitorialQC/JanitorialQCApp.swift @@ -1,6 +1,6 @@ // JQCApp.swift // ------------ -// App entry point. Phase C adds BGTaskScheduler for background sync. +// App entry point. import SwiftUI import SwiftData @@ -23,11 +23,9 @@ struct JanitorialQCApp: App { .environmentObject(sync) } .modelContainer(for: [ - // Phase A LocalFacility.self, LocalArea.self, LocalTemplate.self, - // Phase B LocalInspection.self, LocalIssue.self, PendingPhoto.self, @@ -35,24 +33,17 @@ struct JanitorialQCApp: App { ], isUndoEnabled: false) { result in switch result { case .success(let container): - Task { @MainActor in - SyncManager.shared.modelContext = container.mainContext - await AuthManager.shared.restoreSession() - SyncManager.shared.startMonitoring() - if SyncManager.shared.isOnline { - await SyncManager.shared.triggerSync() - } - } + // Only set the model context here — do NOT await anything. + // Session restore and sync are triggered by ContentView.task{} + // which runs on the MainActor inside the SwiftUI lifecycle, + // guaranteeing isLoading changes are seen by the view immediately. + SyncManager.shared.modelContext = container.mainContext case .failure(let error): fatalError("SwiftData container failed: \(error)") } } } - // ── Background Tasks ────────────────────────────────────────────────── - - /// Register the background sync task with the OS. - /// The identifier must match BGTaskSchedulerPermittedIdentifiers in Info.plist. private func registerBackgroundTasks() { BGTaskScheduler.shared.register( forTaskWithIdentifier: "com.jqc.sync", @@ -67,18 +58,13 @@ struct JanitorialQCApp: App { } private func handleBackgroundSync(task: BGProcessingTask) { - // Schedule the next background run immediately scheduleBackgroundSync() - let syncTask = Task { await SyncManager.shared.triggerSync() } - - // If the OS needs to cancel early, cancel our work task.expirationHandler = { syncTask.cancel() } - Task { await syncTask.value task.setTaskCompleted(success: !syncTask.isCancelled) @@ -86,8 +72,6 @@ struct JanitorialQCApp: App { } } -/// Schedule a background processing task. -/// Call this from sceneDidEnterBackground or after each foreground sync. func scheduleBackgroundSync() { let request = BGProcessingTaskRequest(identifier: "com.jqc.sync") request.requiresNetworkConnectivity = true diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 39e74c7..392fb06 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -48,7 +48,9 @@ class SyncManager: ObservableObject { // ── Full Sync ───────────────────────────────────────────────────────── func triggerSync() async { - guard isOnline, let context = modelContext else { return } + // Do not sync unless authenticated — avoids 401 loops before + // restoreSession() completes on first launch. + guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return } isSyncing = true syncError = nil defer { isSyncing = false } @@ -116,8 +118,6 @@ class SyncManager: ObservableObject { // ── Outbox: Inspections ─────────────────────────────────────────────── private func processInspectionQueue(context: ModelContext) async { - // Fetch all and filter in Swift to avoid #Predicate compound - // string comparison issues across Xcode versions. guard let all = try? context.fetch(FetchDescriptor()) else { return } let pending = all .filter { $0.status == "completed" && $0.syncStatus == "pending" } @@ -135,10 +135,7 @@ class SyncManager: ObservableObject { inspection.syncStatus = "synced" inspection.status = "synced" - // ── Clear follow-up flag on parent ──────────────────────── - // Use parentLocalId (always set at creation) rather than - // parentServerId (nil until parent syncs) so the badge clears - // regardless of whether the parent has been synced yet. + // Clear follow-up flag on parent. if let parentLocalId = inspection.parentLocalId { let allInspections = try? context.fetch(FetchDescriptor()) if let parent = allInspections?.first(where: { $0.localId == parentLocalId }) { @@ -169,17 +166,16 @@ class SyncManager: ObservableObject { .filter { $0.syncStatus == "pending" } .sorted { $0.createdAt < $1.createdAt } - // Pre-fetch all LocalInspections once for the parent-failed guard below. + // Pre-fetch all inspections to check parent sync status. let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] for issue in pending { - // Guard: if the parent inspection permanently failed to sync, submitting - // this issue without an inspection_id would create an orphaned server - // record. Skip the issue and mark it failed so the inspector can see - // the error rather than losing the association silently. - if let parentInspection = allInspections.first(where: { - $0.localId == issue.inspectionLocalId - }), parentInspection.syncStatus == "failed" { + // Guard: if the parent inspection permanently failed to sync, + // submitting this issue without an inspection_id would create an + // orphaned server record. Mark it failed immediately instead. + let parentId = issue.inspectionLocalId + let parent = allInspections.first(where: { $0.localId == parentId }) + if parent?.syncStatus == "failed" { issue.syncStatus = "failed" issue.syncErrorMessage = "Parent inspection failed to sync — issue cannot be submitted." try? context.save() @@ -210,8 +206,6 @@ class SyncManager: ObservableObject { guard isOnline, let context = modelContext else { return } do { - // Sequential fetches avoid Swift 6 actor-isolation warnings - // on Decodable structs used across async boundaries. let facilitiesData: FacilitiesResponseData = try await APIClient.shared.request("/api/v1/facilities") let templatesData: TemplatesResponseData =