diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..b1ec2d2 Binary files /dev/null and b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Contents.json b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..a0146d3 100644 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index 5dd70bd..c06d21d 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 (Hardening session — Xcode 26 build fixes, startup race condition resolved) +> **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. --- @@ -42,12 +42,14 @@ Core capabilities: - **Login/logout** via JWT against the JQC web backend API - **Reference data sync** — facilities, areas, and inspection templates pulled from server - **Inspection execution** — dynamic form rendering driven by server-side template schemas -- **Issue flagging** — severity-tagged issues with optional photos, attached to inspections -- **Re-inspection** — linked follow-up inspections with parent-form pre-fill +- **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** — local read-only list of all issues flagged on this device +- **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 --- @@ -243,6 +245,8 @@ Inspector action → SwiftData write (always succeeds immediately) 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. @@ -279,6 +283,16 @@ The `isAuthenticated` guard is critical. `NWPathMonitor` fires immediately on co 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=`. 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.current` ≠ `RunLoop.main` — the timer fires silently never. Always use `Task.sleep`. +- **`UNUserNotificationCenterDelegate` is required for foreground delivery.** Without it, iOS silently drops local notifications when the app is active. `NotificationDelegate.shared` is set as `UNUserNotificationCenter.current().delegate` in `JanitorialQCApp.init()`. Its `willPresent` returns `[.banner, .sound]`. ### Fetch pattern — CRITICAL for Xcode 26 @@ -338,6 +352,11 @@ request(endpoint, method, body, retrying) async throws -> T | `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/` | Fetches current status for detail view | +| `updateIssueStatus` | `PATCH /api/v1/issues//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 @@ -478,6 +497,7 @@ else { continue } - 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. @@ -497,6 +517,10 @@ On `CompletedInspectionView`, if `inspection.followUpRequired == true`, an orang `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: @@ -545,9 +569,13 @@ Uploaded (uploadStatus="uploaded", serverPath set) 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 +### Server-hosted photos in IssueDetailView -An inspection is **not submitted** until all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"failed"`. It simply `continue`s to the next sync cycle. +`IssueDetailView` shows two photo sections: +1. **Local photos** — `photoLocalPaths` rendered via `UIImage(contentsOfFile:)`. Present for device-created issues where photos were captured on-device. +2. **Server photos** — `photoServerPaths` rendered via `AsyncImage(url: Constants.baseURL + "/" + relativePath)`. Present for issues pulled from the server (`pullAssignedIssues` stores `photo_path` + `result_photos` from the API response into `photoServerPaths`). + +Both sections are shown independently; an issue can have entries in either or both. --- @@ -630,6 +658,14 @@ The app registers a `BGProcessingTask` with identifier `com.jqc.sync`. | 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.current` ≠ `RunLoop.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`; 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 | --- @@ -652,6 +688,8 @@ Xcode 26 sets this build setting when creating new projects with "approachable c 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.