Aug 27 - Fixed inspection lost photos recovery

This commit is contained in:
Nguyen Ngo
2026-08-27 10:42:01 -04:00
parent ca7c09f982
commit 308710538b
14 changed files with 794 additions and 114 deletions
+2 -2
View File
@@ -431,7 +431,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.11; MARKETING_VERSION = 1.12;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -474,7 +474,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.11; MARKETING_VERSION = 1.12;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
+10 -2
View File
@@ -177,7 +177,11 @@ actor APIClient {
retrying: Bool = false) async throws -> String { retrying: Bool = false) async throws -> String {
let url = try buildURL("/api/v1/photos/upload") let url = try buildURL("/api/v1/photos/upload")
guard let imageData = FileManager.default.contents(atPath: localPath) else { // Read via PhotoStore, not the raw path. Stored paths embed the app
// container UUID, which iOS reassigns on every app update the file
// survives, the path does not, and a raw read then fails forever on a
// photo that is sitting right there on disk (rule 91).
guard let imageData = PhotoStore.contents(at: localPath) else {
throw APIError.networkError("Could not read photo: \(localPath)") throw APIError.networkError("Could not read photo: \(localPath)")
} }
@@ -385,7 +389,11 @@ actor APIClient {
retrying: Bool = false) async throws -> String { retrying: Bool = false) async throws -> String {
let url = try buildURL("/api/v1/photos/upload") let url = try buildURL("/api/v1/photos/upload")
guard let imageData = FileManager.default.contents(atPath: localPath) else { // Read via PhotoStore, not the raw path. Stored paths embed the app
// container UUID, which iOS reassigns on every app update the file
// survives, the path does not, and a raw read then fails forever on a
// photo that is sitting right there on disk (rule 91).
guard let imageData = PhotoStore.contents(at: localPath) else {
throw APIError.networkError("Could not read photo: \(localPath)") throw APIError.networkError("Could not read photo: \(localPath)")
} }
+31
View File
@@ -181,6 +181,7 @@ Stored keys (all prefixed `com.jqc.`): `accessToken`, `refreshToken`, `userId`,
```swift ```swift
LocalFacility.self, LocalArea.self, LocalTemplate.self, LocalFacility.self, LocalArea.self, LocalTemplate.self,
LocalInspection.self, LocalIssue.self, LocalScheduledInspection.self, LocalInspection.self, LocalIssue.self, LocalScheduledInspection.self,
LocalFollowUpRequest.self, LocalNotification.self,
PendingPhoto.self, SyncQueueEntry.self PendingPhoto.self, SyncQueueEntry.self
``` ```
@@ -197,6 +198,7 @@ PendingPhoto.self, SyncQueueEntry.self
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) | | `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) |
| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed), `parentInspectionServerId` (`Int?`, phase45 — set when the schedule is a planned follow-up; becomes the run's `parentServerId`). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` | | `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed), `parentInspectionServerId` (`Int?`, phase45 — set when the schedule is a planned follow-up; becomes the run's `parentServerId`). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` |
| `LocalFollowUpRequest` | Read-only cached follow-up request raised on the web (July 2026) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63; this is the *flagged parent* inspection's id and the `parentServerId` the re-inspection links to), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `overallScore`, `inspectionDateString` (sort key), `followUpNote`, `note` (computed, trimmed/nil-ed), `inspectedOn` (computed, parses the `yyyy-MM-dd` prefix only — see the file comment), `fulfilledLocally` (`= false`, rule 71), `parentFormDataJSON` (`= "{}"`, the parent's answers cached for re-inspection prefill — rule 79), `parentFormData` (computed). Pulled by `pullFollowUpRequests()` | | `LocalFollowUpRequest` | Read-only cached follow-up request raised on the web (July 2026) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63; this is the *flagged parent* inspection's id and the `parentServerId` the re-inspection links to), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `overallScore`, `inspectionDateString` (sort key), `followUpNote`, `note` (computed, trimmed/nil-ed), `inspectedOn` (computed, parses the `yyyy-MM-dd` prefix only — see the file comment), `fulfilledLocally` (`= false`, rule 71), `parentFormDataJSON` (`= "{}"`, the parent's answers cached for re-inspection prefill — rule 79), `parentFormData` (computed). Pulled by `pullFollowUpRequests()` |
| `LocalNotification` | In-app notification inbox (Aug 2026) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `title`, `body`, `eventType` (nil pre-phase17), `issueId`, `createdAt`, `receivedAt`, `isRead` (`= false`), `readAt`, `readSyncPending` (`= false`). Upserted by `pollNotifications()`; the local store IS the inbox, because the API only returns unread — rule 92 |
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `uploadRetryCount` (`Int = 0`, rule 83 — the row stays `"pending"` until it hits 5), `entityType` (`"issue"` or `"inspection"`), `fieldId` | | `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `uploadRetryCount` (`Int = 0`, rule 83 — the row stays `"pending"` until it hits 5), `entityType` (`"issue"` or `"inspection"`), `fieldId` |
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` | | `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
@@ -340,6 +342,33 @@ install and an upgrade from a build without the marker are indistinguishable, an
"purge" would delete an in-progress draft belonging to the person signing in right then. "purge" would delete an in-progress draft belonging to the person signing in right then.
Every identity change after that first login is covered. Every identity change after that first login is covered.
### Notification inbox (Aug 2026)
Notifications are persisted as `LocalNotification` and the inbox reads that store,
not the poll response. **Why:** `GET /api/v1/notifications` is a poller, not an inbox —
it filters to `is_read = False` and never sends the flag, so the list was all-unread by
construction (every row looked identical, which is the defect this fixed) and a
notification became invisible the moment it was read.
- `pollNotifications()` **upserts** by `serverId` and never deletes. A row missing from a
response means nothing: it may have been read on the web, or just predate the cursor.
- A local banner fires **only for newly-inserted rows**. Previously every polled item was
delivered, so a cold launch (cursor nil → server returns the whole unread backlog)
re-banner'd all of it on every app start.
- `unreadNotificationCount` is **derived** by `refreshUnreadNotificationCount()`, not
tallied as items arrive — read state changes from both ends now.
- `markNotificationsViewed()` is **gone**. It zeroed the badge because the screen had been
opened, which cannot coexist with real read state (badge 0, every row still unread).
- Reading is explicit: tap a row, swipe, or **Mark All Read**. Local first
(`LocalNotification.markRead()` sets `readSyncPending`), then pushed by
`pushNotificationReadState()` — so it works offline and drains on reconnect.
- `pruneReadNotifications()` drops **read** rows older than 30 days. Unread rows are never
pruned at any age; nothing else deletes a row, so without this the store grows forever.
`NotificationDetailView` shows the full text and links to the referenced issue when that
issue is cached locally, and says so plainly when it is not — the issue may belong to
another inspector or simply not be pulled yet.
### Notification polling ### Notification polling
- `startPollTask()` creates a `Task` with `Task.sleep(nanoseconds: 60_000_000_000)` loop. - `startPollTask()` creates a `Task` with `Task.sleep(nanoseconds: 60_000_000_000)` loop.
@@ -854,6 +883,8 @@ unchanged. See §8, `purgeSessionScopedData()`.
| 88 | **Local data is scoped to a `(server, userId)` pair — purge on an identity CHANGE, never on logout** | Two defects, one cause. (a) Logout deliberately kept the cache so the same inspector could work offline after signing back in — correct — but nothing checked that the next sign-in *was* the same inspector. `pullAssignedIssues`' reconciliation only deletes rows with `inspectionLocalId == ""`, so device-authored synced issues survived indefinitely and a different inspector on the same iPad simply inherited them. (b) The server switch cleared `LocalIssue` alone, leaving `LocalInspection` rows carrying `facilityServerId`/`templateServerId` values that name different rows on the server being switched to — ready to be submitted against it. `SessionScope` (UserDefaults, **not** Keychain — it must outlive `KeychainHelper.clearAll()`) records the pair; `AuthManager.reconcileSessionScope()` compares on every `login()`/`restoreSession()` and calls `SyncManager.purgeSessionScopedData()` only on a mismatch, before `isAuthenticated` flips so no view ever renders the previous user's data. `LocalInspection` is the only model with an author (`inspectorUserId`), so it is the only one whose unsent rows can be handed back; `LocalIssue` has none, and submitting one under a different inspector's credentials would put a false name on a QC record. A nil marker adopts the existing data rather than purging — fresh install and pre-marker upgrade are indistinguishable, and guessing wrong would delete the signing-in user's own draft. | | 88 | **Local data is scoped to a `(server, userId)` pair — purge on an identity CHANGE, never on logout** | Two defects, one cause. (a) Logout deliberately kept the cache so the same inspector could work offline after signing back in — correct — but nothing checked that the next sign-in *was* the same inspector. `pullAssignedIssues`' reconciliation only deletes rows with `inspectionLocalId == ""`, so device-authored synced issues survived indefinitely and a different inspector on the same iPad simply inherited them. (b) The server switch cleared `LocalIssue` alone, leaving `LocalInspection` rows carrying `facilityServerId`/`templateServerId` values that name different rows on the server being switched to — ready to be submitted against it. `SessionScope` (UserDefaults, **not** Keychain — it must outlive `KeychainHelper.clearAll()`) records the pair; `AuthManager.reconcileSessionScope()` compares on every `login()`/`restoreSession()` and calls `SyncManager.purgeSessionScopedData()` only on a mismatch, before `isAuthenticated` flips so no view ever renders the previous user's data. `LocalInspection` is the only model with an author (`inspectorUserId`), so it is the only one whose unsent rows can be handed back; `LocalIssue` has none, and submitting one under a different inspector's credentials would put a false name on a QC record. A nil marker adopts the existing data rather than purging — fresh install and pre-marker upgrade are indistinguishable, and guessing wrong would delete the signing-in user's own draft. |
| 89 | **Never raise a second alert from inside the first one's button action** | Both alerts hang off the same view, so the new presentation is discarded while the first is still tearing down. `ExecuteInspectionView`'s Submit set `showNoGPSAlert = true` from inside the confirm alert's action, and the warning simply never appeared — tapping Submit without a GPS fix did *nothing at all*: no alert, no submission, no feedback. Park the intent in a `@State` flag and act on it from `onChange(of:)` when the first alert's binding flips false, with a short hop so the dismissal animation has finished. Applies to `.sheet`/`.confirmationDialog` chained onto one view too. | | 89 | **Never raise a second alert from inside the first one's button action** | Both alerts hang off the same view, so the new presentation is discarded while the first is still tearing down. `ExecuteInspectionView`'s Submit set `showNoGPSAlert = true` from inside the confirm alert's action, and the warning simply never appeared — tapping Submit without a GPS fix did *nothing at all*: no alert, no submission, no feedback. Park the intent in a `@State` flag and act on it from `onChange(of:)` when the first alert's binding flips false, with a short hop so the dismissal animation has finished. Applies to `.sheet`/`.confirmationDialog` chained onto one view too. |
| 90 | **`date` form fields are `"yyyy-MM-dd"`, and an unanswered one must render as unanswered** | Two defects in one widget, both in `CellDatePicker` and `DateFieldView`. (a) They stored `ISO8601DateFormatter().string(...)` — a full `2026-08-18T14:30:00Z` timestamp — into a field the web writes with `<input type="date">` and both the read-only grid and the PDF print verbatim. `FormDateFormat` (UTC + POSIX, `yyyy-MM-dd`) is now the single definition, and parses a leading date out of legacy timestamp values. (b) A `DatePicker` bound to an empty value still displays TODAY, so the field looked answered — but the setter only fires on a *change*, so selecting the already-shown date wrote nothing and `missingRequiredFields()` reported it missing with a date visible on screen. An explicit "Set date" affordance replaces the picker while the value is empty, plus an × to return to unanswered. | | 90 | **`date` form fields are `"yyyy-MM-dd"`, and an unanswered one must render as unanswered** | Two defects in one widget, both in `CellDatePicker` and `DateFieldView`. (a) They stored `ISO8601DateFormatter().string(...)` — a full `2026-08-18T14:30:00Z` timestamp — into a field the web writes with `<input type="date">` and both the read-only grid and the PDF print verbatim. `FormDateFormat` (UTC + POSIX, `yyyy-MM-dd`) is now the single definition, and parses a leading date out of legacy timestamp values. (b) A `DatePicker` bound to an empty value still displays TODAY, so the field looked answered — but the setter only fires on a *change*, so selecting the already-shown date wrote nothing and `missingRequiredFields()` reported it missing with a date visible on screen. An explicit "Set date" affordance replaces the picker while the value is empty, plus an × to return to unanswered. |
| 91 | **A stored photo path is only valid inside the container that wrote it — resolve by FILENAME, never trust the absolute path** | Photo paths are absolute and embed the app-container UUID (`/var/mobile/Containers/Data/Application/<UUID>/Documents/JQC/Photos/<file>.jpg`). iOS assigns a NEW container UUID on every app update, reinstall and restore: `Documents/` survives, every stored path dies. Nothing accounted for that, so `uploadPhoto`'s `FileManager.contents(atPath:)` returned nil and the upload could **never** succeed no matter how often it retried — the path named a container that no longer existed. Any photo still awaiting upload when the app updated was stranded permanently and its inspection submitted with the field blank. Confirmed in the field on inspection #887 (Aug 2026): nine photos, all `upload: pending`, **0 rows failed**, every file present on disk under the current container. `PhotoStore.resolve()` re-resolves by basename (filenames are per-save UUIDs, so a basename is unambiguous) and is now used by every reader, writer and deleter of a stored path. `processPhotoQueue` heals paths BEFORE the pending filter so a row already given up on is revived, and resets `uploadRetryCount` — earlier failures were about a path that no longer applies. **The retry budget added in rule 83 does not help here and never could: retrying an unresolvable path is futile.** Two corollaries: an unresolvable path now fails fast instead of burning five sync cycles, and `cleanupOrphanedPhotos` compares FILENAMES — comparing full paths meant that after an update every reference missed its own file and the sweep would have deleted exactly the photos that were still recoverable. |
| 92 | **The notification API is a POLLER, not an inbox — `LocalNotification` is the inbox** | `GET /api/v1/notifications` filters to `is_read = False` and never sends the flag (`app/api/notifications.py`), so the iPad list was all-unread by construction — every row rendered identically, with nothing to read or dismiss — and marking one read made it disappear rather than grey out. The fix is a local store the poll upserts into and never deletes from; a row's absence from a response carries no information. Read state is shared with the web: a tap or Mark All is a **deliberate user action**, so it pushes via `PATCH /notifications/mark-read` (previously dead code) and clears the web badge too. That does not contradict the long-standing rule against marking read on POLL — auto-marking would zero the user's web badge just because the iPad was switched on, and `pollNotifications()` still never does it. Writes are local-first with `readSyncPending`, drained by `pushNotificationReadState()`, so reading works in airplane mode. If a mobile inbox endpoint mirroring `routes/notifications.py::index` is ever added, this model becomes a cache of it rather than the source of truth. |
--- ---
+1
View File
@@ -102,6 +102,7 @@ struct JanitorialQCApp: App {
LocalIssue.self, LocalIssue.self,
LocalScheduledInspection.self, LocalScheduledInspection.self,
LocalFollowUpRequest.self, LocalFollowUpRequest.self,
LocalNotification.self,
PendingPhoto.self, PendingPhoto.self,
SyncQueueEntry.self, SyncQueueEntry.self,
], isUndoEnabled: false) { result in ], isUndoEnabled: false) { result in
+102
View File
@@ -0,0 +1,102 @@
// Models/LocalNotification.swift
// ------------------------------
// SwiftData model backing the in-app notification inbox.
//
// WHY THIS IS A LOCAL STORE, unlike every other server-backed list in the app.
// `GET /api/v1/notifications` is a POLLER, not an inbox: it filters to
// `is_read = False` and never sends the flag at all
// (app/api/notifications.py :: list_notifications). So the instant a
// notification is marked read the server stops returning it there is no
// response the iPad could render as "read", and before this every row in the
// list was unread by definition, which is why they all looked identical.
// Keeping our own copy is the only way to show read and unread side by side.
//
// The web already has a real inbox (routes/notifications.py :: index, with an
// all / unread / read filter and paging). If a mobile equivalent is ever added,
// this model should become a cache of it rather than the source of truth see
// rule 92.
//
// Scope: notifications are per-user, so this is purged on an identity change
// like everything else (rule 88).
import Foundation
import SwiftData
@Model
final class LocalNotification {
/// Server notification id stable identity, and the value
/// `PATCH /api/v1/notifications/mark-read` takes.
/// No inline default: a `.unique` key must not carry one (rule 63).
@Attribute(.unique) var serverId: Int
var title: String
var body: String
/// e.g. `issue_assigned`, `sla_alert`, `scheduled_inspection`. Nil for rows
/// created before the server's phase17 migration added the column.
var eventType: String?
/// Set when the notification refers to an issue drives "View Issue".
var issueId: Int?
/// Server `created_at`, parsed. Falls back to receipt time when the string
/// cannot be parsed so ordering never collapses to a single instant.
var createdAt: Date
/// When THIS device first saw it. Distinct from `createdAt`: a notification
/// raised while the iPad was offline arrives late but keeps its real time.
var receivedAt: Date
// Read state
// Set optimistically on tap / Mark All so the UI responds offline, then
// pushed to the server. Read state is shared with the web (rule 92).
var isRead: Bool = false
var readAt: Date?
/// True while this row's read state has not yet reached the server.
/// Drained by `SyncManager.pushNotificationReadState()`.
///
/// Non-optional with an inline default so SwiftData migrates lightweight
/// (rule 8).
var readSyncPending: Bool = false
init(from api: APINotification) {
self.serverId = api.id
self.title = api.title
self.body = api.body
self.eventType = api.eventType
self.issueId = api.issueId
self.createdAt = SyncManager.isoFormatter.date(from: api.createdAt) ?? Date()
self.receivedAt = Date()
self.isRead = false
self.readAt = nil
self.readSyncPending = false
}
/// Refresh the mutable text from a later poll.
///
/// Deliberately does NOT touch `isRead`. The endpoint only ever returns
/// UNREAD rows, so being returned again carries no information about read
/// state it usually just means our mark-read has not been pushed yet.
/// Clobbering it here would make a notification the inspector just opened
/// pop straight back to unread.
func update(from api: APINotification) {
self.title = api.title
self.body = api.body
self.eventType = api.eventType
self.issueId = api.issueId
if let parsed = SyncManager.isoFormatter.date(from: api.createdAt) {
self.createdAt = parsed
}
}
/// Mark read locally and queue the server push. Idempotent.
func markRead() {
guard !isRead else { return }
isRead = true
readAt = Date()
readSyncPending = true
}
}
+221 -42
View File
@@ -24,12 +24,14 @@ class SyncManager: ObservableObject {
@Published var pendingCount = 0 @Published var pendingCount = 0
/// Dashboard KPI stats fetched from the server. Nil until first successful fetch. /// Dashboard KPI stats fetched from the server. Nil until first successful fetch.
@Published var dashboardStats: APIDashboardStats? @Published var dashboardStats: APIDashboardStats?
/// Count of notifications received since last resetNotificationPoller(). /// Number of `LocalNotification` rows with `isRead == false`.
/// Incremented on each poll that returns new items; reset to 0 on logout. ///
/// Derived from the store rather than counted as items arrive: read state
/// is now real and can change from either end (a tap here, Mark All, or the
/// same account reading on the web), so an incrementing tally would drift.
/// Refreshed by `refreshUnreadNotificationCount()` after every poll and
/// every read action.
@Published var unreadNotificationCount = 0 @Published var unreadNotificationCount = 0
/// The most recent batch of notifications (up to 50) for the in-app inbox.
/// Replaced entirely on each successful poll; empty until first fetch.
@Published var recentNotifications: [APINotification] = []
// Dependencies // Dependencies
@@ -142,11 +144,17 @@ class SyncManager: ObservableObject {
} }
/// Called on logout so the next login starts a clean fetch. /// Called on logout so the next login starts a clean fetch.
///
/// Resets the CURSOR and stops the task; it does not touch the stored
/// inbox. The same inspector signing back in should still find their
/// notifications and their read state, exactly as they find their cached
/// facilities and issues a different inspector is handled by the identity
/// purge instead (rule 88). `unreadNotificationCount` is recomputed from
/// the store rather than zeroed, so the badge stays truthful.
func resetNotificationPoller() { func resetNotificationPoller() {
lastNotificationFetch = nil lastNotificationFetch = nil
unreadNotificationCount = 0
recentNotifications = []
stopPollTask() stopPollTask()
refreshUnreadNotificationCount()
} }
/// Called when the app enters the background (scenePhase == .background). /// Called when the app enters the background (scenePhase == .background).
@@ -164,10 +172,10 @@ class SyncManager: ObservableObject {
Task { await triggerSync() } Task { await triggerSync() }
} }
/// Call when the user opens the NotificationsView to clear the badge. // NOTE: `markNotificationsViewed()` is gone. It zeroed the badge merely
func markNotificationsViewed() { // because the inbox had been OPENED, which is incompatible with showing
unreadNotificationCount = 0 // real read state the badge would read 0 while every row still rendered
} // as unread. Reading is now an explicit act: tap a row, or Mark All Read.
// Notification polling // Notification polling
@@ -177,25 +185,39 @@ class SyncManager: ObservableObject {
let notifications = try await APIClient.shared.fetchNotifications(since: lastNotificationFetch) let notifications = try await APIClient.shared.fetchNotifications(since: lastNotificationFetch)
guard !notifications.isEmpty else { return } guard !notifications.isEmpty else { return }
// Deliver a local notification for each new item // Upsert into the local inbox. The endpoint only ever returns
for n in notifications { // UNREAD rows and never sends the flag, so a row disappearing from
deliverLocalNotification(n) // the response says nothing it may have been read on the web, or
// simply be older than the cursor. Rows are therefore never deleted
// here; `pruneReadNotifications()` handles retention instead.
if let context = modelContext {
let existing = (try? context.fetch(FetchDescriptor<LocalNotification>())) ?? []
var byServerId: [Int: LocalNotification] = [:]
for row in existing { byServerId[row.serverId] = row }
for api in notifications {
if let row = byServerId[api.id] {
row.update(from: api)
} else {
context.insert(LocalNotification(from: api))
// Banner ONLY for genuinely new rows. Previously every
// polled item was delivered, so a cold launch (cursor
// nil the server returns all unread) re-banner'd the
// inspector's whole backlog on every app start.
deliverLocalNotification(api)
}
}
try? context.save()
refreshUnreadNotificationCount()
} }
// Update in-app inbox state. // Advance the cursor so the next poll only fetches newer items.
// Prepend new notifications and cap at 50 avoids allocating two //
// arrays and concatenating them on every poll (the old pattern // Still no implicit mark-read: the server's read state changes only
// `notifications + recentNotifications.prefix(50 - count)` always // on a deliberate user action a tap or Mark All, which route
// created a new array even when notifications.count >= 50). // through markNotificationRead/markAllNotificationsRead. Marking on
recentNotifications.insert(contentsOf: notifications, at: 0) // poll would zero the user's WEB badge simply because the iPad was
if recentNotifications.count > 50 { recentNotifications = Array(recentNotifications.prefix(50)) } // switched on (rule 92).
unreadNotificationCount += notifications.count
// Update the cursor to the newest notification's timestamp so the
// next poll only fetches newer items do NOT mark notifications as
// read on the server. Read state is a deliberate user action managed
// via the web app; marking read here would cause the web badge count
// to always show zero when the iPad has polled before the user checks.
let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) } let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) }
if let newest = dates.max() { if let newest = dates.max() {
lastNotificationFetch = newest lastNotificationFetch = newest
@@ -208,6 +230,86 @@ class SyncManager: ObservableObject {
} }
} }
// Notification read state
/// Recount unread rows and publish. Cheap: one fetch, no relationships.
func refreshUnreadNotificationCount() {
guard let context = modelContext else { return }
let all = (try? context.fetch(FetchDescriptor<LocalNotification>())) ?? []
unreadNotificationCount = all.filter { !$0.isRead }.count
}
/// Mark one notification read optimistically local, then pushed.
///
/// Local first so the inbox responds instantly and works offline; the
/// server call is best-effort and `readSyncPending` keeps the debt until it
/// lands (the same shape as every other write in this app).
func markNotificationRead(_ notification: LocalNotification) async {
guard !notification.isRead else { return }
notification.markRead()
try? modelContext?.save()
refreshUnreadNotificationCount()
await pushNotificationReadState()
}
/// Mark every unread notification read.
func markAllNotificationsRead() async {
guard let context = modelContext else { return }
let all = (try? context.fetch(FetchDescriptor<LocalNotification>())) ?? []
let unread = all.filter { !$0.isRead }
guard !unread.isEmpty else { return }
for row in unread { row.markRead() }
try? context.save()
refreshUnreadNotificationCount()
await pushNotificationReadState()
}
/// Push any locally-read notifications the server does not know about yet.
///
/// Runs on every sync as well as immediately after a read action, so a
/// notification opened in airplane mode still clears the web badge once the
/// iPad reconnects. `markNotificationsRead` was dead code before this.
func pushNotificationReadState() async {
guard isOnline, AuthManager.shared.isAuthenticated,
let context = modelContext
else { return }
let all = (try? context.fetch(FetchDescriptor<LocalNotification>())) ?? []
let pending = all.filter { $0.readSyncPending }
guard !pending.isEmpty else { return }
do {
try await APIClient.shared.markNotificationsRead(ids: pending.map { $0.serverId })
for row in pending { row.readSyncPending = false }
try? context.save()
} catch {
// Left pending retried on the next sync. The row already reads as
// read locally, which is what the inspector asked for.
}
}
/// Drop read notifications older than the retention window.
///
/// Needed because nothing else ever deletes a row: the poll endpoint cannot
/// tell us a notification is gone (it only returns unread), so without this
/// the inbox would grow without bound. Unread rows are never pruned however
/// old an unread alert is outstanding work.
private static let notificationRetention: TimeInterval = 30 * 24 * 3600 // 30 days
private func pruneReadNotifications(context: ModelContext) {
let cutoff = Date().addingTimeInterval(-Self.notificationRetention)
let all = (try? context.fetch(FetchDescriptor<LocalNotification>())) ?? []
var removed = 0
for row in all where row.isRead && !row.readSyncPending && row.createdAt < cutoff {
context.delete(row)
removed += 1
}
if removed > 0 {
try? context.save()
print("[JQC] Sync | pruneReadNotifications | removed \(removed) row(s)")
}
}
// Local notification delivery // Local notification delivery
private func deliverLocalNotification(_ n: APINotification) { private func deliverLocalNotification(_ n: APINotification) {
@@ -284,6 +386,9 @@ class SyncManager: ObservableObject {
// for the 60-second timer ensures the inspector sees assignments // for the 60-second timer ensures the inspector sees assignments
// and follow-up requests as soon as the app goes online. // and follow-up requests as soon as the app goes online.
await pollNotifications() await pollNotifications()
// Drain read state marked while offline, then trim the inbox.
await pushNotificationReadState()
pruneReadNotifications(context: context)
// Fetch dashboard KPIs best-effort, non-fatal on failure. // Fetch dashboard KPIs best-effort, non-fatal on failure.
await fetchDashboardStats() await fetchDashboardStats()
@@ -311,6 +416,41 @@ class SyncManager: ObservableObject {
// string literals against PendingPhoto.uploadStatus reliably // string literals against PendingPhoto.uploadStatus reliably
// when the predicate type is inferred across model boundaries. // when the predicate type is inferred across model boundaries.
guard let allPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) else { return } guard let allPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) else { return }
// Heal stale container paths FIRST
//
// Stored paths are absolute and embed the app-container UUID, which iOS
// reassigns on every app update, reinstall and restore. The files
// survive in Documents/; the paths do not. `uploadPhoto` then fails on
// `FileManager.contents(atPath:)` and can NEVER succeed however often it
// is retried, because the path names a container that no longer exists.
//
// This is what stranded inspection #887's nine photos: reported as
// `upload: pending`, 0 rows failed, every file present on disk under the
// CURRENT container. Retrying was futile; re-resolving is all that was
// ever needed. See Utils/PhotoStore.swift and rule 91.
//
// Runs before the "pending" filter on purpose, so a row already given up
// on is REVIVED rather than left stranded otherwise a single app
// update permanently costs an inspection its evidence.
var healed = 0
for photo in allPhotos where photo.uploadStatus != "uploaded" {
guard let live = PhotoStore.resolve(photo.localFilePath),
live != photo.localFilePath
else { continue }
photo.localFilePath = live
// Previous failures were about a path that no longer applies, so
// they are not evidence about this one reset the retry budget.
photo.uploadRetryCount = 0
photo.lastUploadError = nil
if photo.uploadStatus == "failed" { photo.uploadStatus = "pending" }
healed += 1
}
if healed > 0 {
try? context.save()
print("[JQC] Sync | processPhotoQueue | re-resolved \(healed) stale photo path(s)")
}
let pending = allPhotos let pending = allPhotos
.filter { $0.uploadStatus == "pending" } .filter { $0.uploadStatus == "pending" }
.sorted { $0.createdAt < $1.createdAt } .sorted { $0.createdAt < $1.createdAt }
@@ -351,6 +491,18 @@ class SyncManager: ObservableObject {
var uploadedPaths: [String: String] = [:] // localFilePath -> serverPath var uploadedPaths: [String: String] = [:] // localFilePath -> serverPath
for photo in toUpload { for photo in toUpload {
// Paths were healed above, so an unresolvable one here means the
// file is genuinely gone from every container. Fail fast rather
// than burning five attempts and five sync cycles on it.
guard PhotoStore.resolve(photo.localFilePath) != nil else {
photo.uploadStatus = "failed"
photo.uploadRetryCount = Self.maxPhotoUploadAttempts
photo.lastUploadError = "File no longer on this device: "
+ PhotoStore.filename(of: photo.localFilePath)
try? context.save()
continue
}
do { do {
// Capture metadata was recorded at the shutter, not now the // Capture metadata was recorded at the shutter, not now the
// sync may run hours after an offline capture, and the server // sync may run hours after an offline capture, and the server
@@ -546,15 +698,31 @@ class SyncManager: ObservableObject {
.filter { $0.status == "completed" && $0.syncStatus == "pending" } .filter { $0.status == "completed" && $0.syncStatus == "pending" }
.sorted { $0.createdAt < $1.createdAt } .sorted { $0.createdAt < $1.createdAt }
// Photos are matched by entityLocalId rather than navigated to via the
// relationship see the photosReady guard below for why.
let allPhotos = (try? context.fetch(FetchDescriptor<PendingPhoto>())) ?? []
for inspection in pending { for inspection in pending {
// "failed" is only reachable after maxPhotoUploadAttempts, so this // "failed" is only reachable after maxPhotoUploadAttempts, so this
// now means "uploaded, or genuinely unrecoverable" rather than // means "uploaded, or genuinely unrecoverable" rather than
// "uploaded, or hit one network error". A still-retrying photo // "uploaded, or hit one network error". A still-retrying photo
// keeps its row "pending" and holds the inspection back which is // keeps its row "pending" and holds the inspection back which is
// the point: submitting first is what blanked the field for good, // the point: submitting first is what blanked the field for good,
// since APIClient.submitInspection rewrites a surviving local:// // since APIClient.submitInspection rewrites a surviving local://
// value to "" and the inspection is then marked synced forever. // value to "" and the inspection is then marked synced forever.
let photosReady = inspection.pendingPhotos.allSatisfy { //
// Matched by entityLocalId, NOT via `inspection.pendingPhotos`.
// That relationship declares no explicit inverse (rule 37, which
// LocalIssue follows and PendingPhoto does not), so it is not a
// trustworthy source of truth here and an EMPTY array makes
// `allSatisfy` vacuously true, which silently converts this guard
// into no guard at all and submits the inspection with every photo
// still pending. processPhotoQueue and processIssueQueue already
// query globally; this is now consistent with them.
let ownPhotos = allPhotos.filter {
$0.entityType == "inspection" && $0.entityLocalId == inspection.localId
}
let photosReady = ownPhotos.allSatisfy {
$0.uploadStatus == "uploaded" || $0.uploadStatus == "failed" $0.uploadStatus == "uploaded" || $0.uploadStatus == "failed"
} }
guard photosReady else { continue } guard photosReady else { continue }
@@ -854,14 +1022,22 @@ class SyncManager: ObservableObject {
guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return } guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return }
UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey) UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey)
// Phase 1: collect referenced paths on @MainActor (SwiftData fetches) // Phase 1: collect referenced FILENAMES on @MainActor
// These are fast in-memory operations always runs on the main actor. // Filenames, not paths. Stored paths are absolute and embed the app
var referencedPaths = Set<String>() // container UUID, which iOS changes on every app update so after an
// update every reference would fail to match its own file on disk and
// this sweep would delete the lot, including photos still awaiting
// upload. Filenames are per-save UUIDs and survive the move (PhotoStore).
var referencedNames = Set<String>()
func reference(_ storedPath: String) {
let name = PhotoStore.filename(of: storedPath)
if !name.isEmpty { referencedNames.insert(name) }
}
// PendingPhoto not yet uploaded // PendingPhoto not yet uploaded
if let pendingPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) { if let pendingPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) {
for p in pendingPhotos where p.uploadStatus != "uploaded" { for p in pendingPhotos where p.uploadStatus != "uploaded" {
referencedPaths.insert(p.localFilePath) reference(p.localFilePath)
} }
} }
// LocalInspection EVERY field still holding the local:// sentinel, // LocalInspection EVERY field still holding the local:// sentinel,
@@ -877,7 +1053,7 @@ class SyncManager: ObservableObject {
for insp in inspections { for insp in inspections {
for val in insp.formData.values { for val in insp.formData.values {
if let s = val as? String, s.hasPrefix("local://") { if let s = val as? String, s.hasPrefix("local://") {
referencedPaths.insert(String(s.dropFirst("local://".count))) reference(String(s.dropFirst("local://".count)))
} }
} }
} }
@@ -888,14 +1064,14 @@ class SyncManager: ObservableObject {
// does not have. // does not have.
if let issues = try? context.fetch(FetchDescriptor<LocalIssue>()) { if let issues = try? context.fetch(FetchDescriptor<LocalIssue>()) {
for issue in issues { for issue in issues {
for path in issue.photoLocalPaths { referencedPaths.insert(path) } for path in issue.photoLocalPaths { reference(path) }
} }
} }
// Phase 2: FileManager enumeration + deletion on a background thread // Phase 2: FileManager enumeration + deletion on a background thread
// Directory enumeration and file removal are I/O-bound and can stutter // Directory enumeration and file removal are I/O-bound and can stutter
// the main thread when JQCPhotos/ contains hundreds of files. Dispatching // the main thread when JQCPhotos/ contains hundreds of files. Dispatching
// here is safe because `referencedPaths` is a value type (Set<String>) // here is safe because `referencedNames` is a value type (Set<String>)
// captured by copy no shared mutable state crosses the boundary. // captured by copy no shared mutable state crosses the boundary.
let minAge = Self.cleanupMinFileAge let minAge = Self.cleanupMinFileAge
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
@@ -921,7 +1097,8 @@ class SyncManager: ObservableObject {
let cutoff = Date().addingTimeInterval(-minAge) let cutoff = Date().addingTimeInterval(-minAge)
var deletedCount = 0 var deletedCount = 0
for fileURL in diskFiles { for fileURL in diskFiles {
if referencedPaths.contains(fileURL.path) { continue } // Matched by filename see the Phase 1 comment.
if referencedNames.contains(fileURL.lastPathComponent) { continue }
// Age floor never touch a file young enough to belong to a // Age floor never touch a file young enough to belong to a
// capture flow that has not yet written its record. // capture flow that has not yet written its record.
let modified = (try? fileURL.resourceValues( let modified = (try? fileURL.resourceValues(
@@ -1278,6 +1455,9 @@ class SyncManager: ObservableObject {
deleteAll(LocalTemplate.self, from: context) deleteAll(LocalTemplate.self, from: context)
deleteAll(LocalScheduledInspection.self, from: context) deleteAll(LocalScheduledInspection.self, from: context)
deleteAll(LocalFollowUpRequest.self, from: context) deleteAll(LocalFollowUpRequest.self, from: context)
// Notifications are addressed to one user the most personal thing in
// the store, and the clearest thing another inspector must never see.
deleteAll(LocalNotification.self, from: context)
// Issues // Issues
// All of them, unconditionally. LocalIssue carries no author field, so // All of them, unconditionally. LocalIssue carries no author field, so
@@ -1302,7 +1482,7 @@ class SyncManager: ObservableObject {
// Remove the JPEGs too cleanupOrphanedPhotos would otherwise // Remove the JPEGs too cleanupOrphanedPhotos would otherwise
// wait out its 7-day age floor holding another user's evidence. // wait out its 7-day age floor holding another user's evidence.
for photo in insp.pendingPhotos { for photo in insp.pendingPhotos {
try? FileManager.default.removeItem(atPath: photo.localFilePath) PhotoStore.remove(at: photo.localFilePath)
} }
context.delete(insp) // cascades pendingPhotos + localIssues context.delete(insp) // cascades pendingPhotos + localIssues
} }
@@ -1317,7 +1497,7 @@ class SyncManager: ObservableObject {
let stillOwned = photo.entityType == "inspection" let stillOwned = photo.entityType == "inspection"
&& keptInspectionIds.contains(photo.entityLocalId) && keptInspectionIds.contains(photo.entityLocalId)
if !stillOwned { if !stillOwned {
try? FileManager.default.removeItem(atPath: photo.localFilePath) PhotoStore.remove(at: photo.localFilePath)
context.delete(photo) context.delete(photo)
} }
} }
@@ -1331,7 +1511,6 @@ class SyncManager: ObservableObject {
dashboardStats = nil dashboardStats = nil
lastNotificationFetch = nil lastNotificationFetch = nil
unreadNotificationCount = 0 unreadNotificationCount = 0
recentNotifications = []
syncError = nil syncError = nil
lastSyncAt = nil lastSyncAt = nil
updatePendingCount(context: context) updatePendingCount(context: context)
@@ -184,7 +184,8 @@ enum InspectionPDFGenerator {
group.addTask { group.addTask {
if val.hasPrefix("local://") { if val.hasPrefix("local://") {
let path = String(val.dropFirst("local://".count)) let path = String(val.dropFirst("local://".count))
guard let raw = UIImage(contentsOfFile: path) else { return (fid, nil) } guard let live = PhotoStore.resolve(path),
let raw = UIImage(contentsOfFile: live) else { return (fid, nil) }
return (fid, compress(raw)) return (fid, compress(raw))
} else if val.hasPrefix("uploads/") { } else if val.hasPrefix("uploads/") {
guard let url = URL(string: "\(ServerConfig.current)/static/\(val)") guard let url = URL(string: "\(ServerConfig.current)/static/\(val)")
+1 -1
View File
@@ -157,7 +157,7 @@ enum IssuePDFGenerator {
return results return results
} }
} else if !localPaths.isEmpty { } else if !localPaths.isEmpty {
raw = localPaths.compactMap { UIImage(contentsOfFile: $0) } raw = localPaths.compactMap { PhotoStore.resolve($0).flatMap(UIImage.init(contentsOfFile:)) }
} }
return raw.compactMap { compress($0) } return raw.compactMap { compress($0) }
+91
View File
@@ -0,0 +1,91 @@
// Utils/PhotoStore.swift
// ----------------------
// Resolves a stored photo path against the CURRENT app container.
//
// The bug this exists to fix
// Every photo path in the database is ABSOLUTE and embeds the app-container
// UUID:
//
// /var/mobile/Containers/Data/Application/<CONTAINER-UUID>/Documents/JQC/Photos/<file>.jpg
//
// iOS assigns a NEW container UUID on every app update, reinstall and restore.
// The Documents directory survives the files are all still there but every
// stored path is instantly dead.
//
// Nothing accounted for that. `uploadPhoto` does
// `FileManager.default.contents(atPath:)`, which returns nil, so the upload
// throws "Could not read photo" and can NEVER succeed no matter how often it is
// retried: the path names a container that no longer exists. Any photo still
// awaiting upload when the app updates is therefore stranded permanently, and
// its inspection is submitted with the field blank.
//
// That is what happened to inspection #887 (9 photos, Aug 2026): the diagnostic
// reported all nine as `upload: pending` with 0 failed rows and
// "Stored path stale (container changed) file found by name, recoverable".
//
// Why basename lookup is safe
// Filenames are `UUID().uuidString + ".jpg"`, generated per save at every call
// site, so a basename identifies a file unambiguously. This is the same
// resolution PhotoDiagnosticView already performs to report recoverability
// it just was not wired into the code paths that actually read the files.
import Foundation
nonisolated enum PhotoStore {
/// Sub-directories of Documents/ the app has ever written photos to.
/// `JQCPhotos` is not written by any current code path but is checked so a
/// file left by an older build is still found.
private static let subdirectories = ["JQC/Photos", "JQC/ResultPhotos", "JQCPhotos"]
/// Documents/ in the CURRENT container. Recomputed per call caching it
/// across an app update would reintroduce the very staleness this fixes.
private static var documentsDirectory: URL? {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
}
/// Absolute URL for a photo directory in the current container.
static func directory(_ subdirectory: String = "JQC/Photos") -> URL? {
documentsDirectory?.appendingPathComponent(subdirectory, isDirectory: true)
}
/// The live absolute path for a stored photo path, or nil if the file is
/// genuinely gone.
///
/// Returns `storedPath` unchanged when it still resolves (the common case,
/// costing one `fileExists` check). Otherwise re-resolves by filename under
/// the current container.
static func resolve(_ storedPath: String) -> String? {
guard !storedPath.isEmpty else { return nil }
let fm = FileManager.default
if fm.fileExists(atPath: storedPath) { return storedPath }
let name = URL(fileURLWithPath: storedPath).lastPathComponent
guard !name.isEmpty else { return nil }
for sub in subdirectories {
guard let candidate = directory(sub)?.appendingPathComponent(name) else { continue }
if fm.fileExists(atPath: candidate.path) { return candidate.path }
}
return nil
}
/// Read a photo's bytes, healing a stale container path first.
static func contents(at storedPath: String) -> Data? {
guard let live = resolve(storedPath) else { return nil }
return FileManager.default.contents(atPath: live)
}
/// Delete a photo, whichever container its path was written in.
@discardableResult
static func remove(at storedPath: String) -> Bool {
guard let live = resolve(storedPath) else { return false }
return (try? FileManager.default.removeItem(atPath: live)) != nil
}
/// Filename component, which is the only stable part of a stored path.
/// Use this never the full path to compare a database reference against
/// a file on disk (see `SyncManager.cleanupOrphanedPhotos`).
static func filename(of storedPath: String) -> String {
URL(fileURLWithPath: storedPath).lastPathComponent
}
}
@@ -130,7 +130,9 @@ struct DashboardView: View {
ForEach(sidebarTabs, id: \.self) { tab in ForEach(sidebarTabs, id: \.self) { tab in
Button { Button {
selectTab(tab) selectTab(tab)
if tab == .notifications { sync.markNotificationsViewed() } // No longer zeroes the badge on tap: opening the inbox
// is not reading it. The count now tracks genuinely
// unread rows and clears as they are read (rule 92).
} label: { } label: {
sidebarRowLabel(tab, tinted: selectedTab == tab) sidebarRowLabel(tab, tinted: selectedTab == tab)
} }
@@ -567,7 +567,7 @@ struct IssueDetailView: View {
if !issue.photoLocalPaths.isEmpty { if !issue.photoLocalPaths.isEmpty {
Section("Photos (\(issue.photoLocalPaths.count))") { Section("Photos (\(issue.photoLocalPaths.count))") {
ForEach(issue.photoLocalPaths, id: \.self) { path in ForEach(issue.photoLocalPaths, id: \.self) { path in
if let img = UIImage(contentsOfFile: path) { if let live = PhotoStore.resolve(path), let img = UIImage(contentsOfFile: live) {
Image(uiImage: img) Image(uiImage: img)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
@@ -173,13 +173,13 @@ struct MyInspectionsView: View {
private func deleteDraft(_ inspection: LocalInspection) { private func deleteDraft(_ inspection: LocalInspection) {
// Delete associated pending photos from disk and SwiftData // Delete associated pending photos from disk and SwiftData
for photo in inspection.pendingPhotos { for photo in inspection.pendingPhotos {
try? FileManager.default.removeItem(atPath: photo.localFilePath) PhotoStore.remove(at: photo.localFilePath)
context.delete(photo) context.delete(photo)
} }
// Delete associated local issues // Delete associated local issues
for issue in inspection.localIssues { for issue in inspection.localIssues {
for path in issue.photoLocalPaths { for path in issue.photoLocalPaths {
try? FileManager.default.removeItem(atPath: path) PhotoStore.remove(at: path)
} }
context.delete(issue) context.delete(issue)
} }
@@ -1,90 +1,355 @@
// Views/Dashboard/NotificationsView.swift // Views/Dashboard/NotificationsView.swift
// ---------------------------------------
// In-app notification inbox, backed by LocalNotification (see that file for why
// the inbox is stored locally rather than re-read from the server each time).
//
// Every row used to look identical because the poll endpoint only returns
// UNREAD notifications the list was, by construction, all-unread with nothing
// to distinguish. Now read state is real: unread rows carry a dot and a bold
// title, read rows are muted, and reading is an explicit act (tap a row, or
// Mark All Read) rather than a side effect of opening the screen.
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import MessageUI import MessageUI
// MARK: - Notifications Inbox // MARK: - Inbox
// Shows the most recent notifications fetched during polling.
// Notifications are already marked read on the server by pollNotifications().
struct NotificationsView: View { struct NotificationsView: View {
@EnvironmentObject private var sync: SyncManager @EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context
// Sorted newest-first. Filtered in Swift, not in the @Query predicate
// (CLAUDE.md rule 3).
@Query(sort: \LocalNotification.createdAt, order: .reverse)
private var allNotifications: [LocalNotification]
enum Filter: String, CaseIterable, Identifiable {
case all, unread, read
var id: String { rawValue }
var label: String {
switch self {
case .all: return "All"
case .unread: return "Unread"
case .read: return "Read"
}
}
}
@State private var filter: Filter = .all
@State private var isMarkingAll = false
private var unreadCount: Int { allNotifications.filter { !$0.isRead }.count }
private var visible: [LocalNotification] {
switch filter {
case .all: return allNotifications
case .unread: return allNotifications.filter { !$0.isRead }
case .read: return allNotifications.filter { $0.isRead }
}
}
var body: some View { var body: some View {
Group { Group {
if sync.recentNotifications.isEmpty { if allNotifications.isEmpty {
if !sync.isOnline { emptyState
ContentUnavailableView(
"Offline",
systemImage: "wifi.slash",
description: Text("Notifications are delivered when you go online.")
)
} else {
ContentUnavailableView(
"No Notifications",
systemImage: "bell.slash",
description: Text("You\'re all caught up.")
)
}
} else { } else {
List(sync.recentNotifications) { notif in VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 6) { // OUTSIDE the List, so it survives a filter that matches
HStack(alignment: .top) { // nothing. As a list row it vanished with the rows
Image(systemName: iconName(for: notif.eventType)) // selecting "Read" with nothing read left no way back.
.foregroundStyle(iconColor(for: notif.eventType)) Picker("Show", selection: $filter) {
.frame(width: 24) ForEach(Filter.allCases) { f in
VStack(alignment: .leading, spacing: 2) { Text(f.label).tag(f)
Text(notif.title) }
.font(.callout.bold()) }
.lineLimit(2) .pickerStyle(.segmented)
Text(notif.body) .padding(.horizontal, 16)
.font(.caption) .padding(.vertical, 8)
.foregroundStyle(.secondary)
.lineLimit(3) if visible.isEmpty {
} ContentUnavailableView(
} filter == .unread ? "All Caught Up" : "Nothing Read Yet",
if let date = SyncManager.isoFormatter.date(from: notif.createdAt) { systemImage: filter == .unread ? "checkmark.circle" : "envelope.open",
Text(date.formatted(.relative(presentation: .named))) description: Text(filter == .unread
.font(.caption2) ? "You have no unread notifications."
.foregroundStyle(.tertiary) : "Notifications you open will appear here.")
)
Spacer(minLength: 0)
} else {
List {
ForEach(visible) { notif in
NavigationLink(value: notif) {
NotificationRow(notification: notif)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if !notif.isRead {
Button {
Task { await sync.markNotificationRead(notif) }
} label: {
Label("Read", systemImage: "envelope.open")
}
.tint(.blue)
}
}
}
} }
} }
.padding(.vertical, 4)
} }
} }
} }
.navigationTitle("Notifications") // On the ALWAYS-PRESENT Group, never inside the List same hazard as
// rule 68. Opening a notification marks it read, which removes it from
// the "Unread" filter; if that was the last row, the List is replaced by
// an empty state and a destination declared inside it would be torn
// down, popping the detail view out from under the inspector as they
// read it.
.navigationDestination(for: LocalNotification.self) { notif in
NotificationDetailView(notification: notif)
}
.navigationTitle(unreadCount > 0 ? "Notifications (\(unreadCount))" : "Notifications")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.onAppear { .toolbar {
sync.markNotificationsViewed() if unreadCount > 0 {
ToolbarItem(placement: .primaryAction) {
Button {
Task {
isMarkingAll = true
await sync.markAllNotificationsRead()
isMarkingAll = false
}
} label: {
if isMarkingAll {
ProgressView()
} else {
Label("Mark All Read", systemImage: "envelope.open")
}
}
.labelStyle(.titleAndIcon) // rule 72
.disabled(isMarkingAll)
}
}
} }
.refreshable { .refreshable {
await sync.pollNotifications() await sync.pollNotifications()
sync.markNotificationsViewed()
} }
// Keep the sidebar badge honest if read state changed elsewhere (a
// swipe, the detail view, or a push that landed while this was open).
.onAppear { sync.refreshUnreadNotificationCount() }
} }
private func iconName(for eventType: String?) -> String { @ViewBuilder
switch eventType { private var emptyState: some View {
case "inspection_completed": return "checkmark.circle.fill" if !sync.isOnline {
case "issue_flagged": return "exclamationmark.triangle.fill" ContentUnavailableView(
case "issue_resolved": return "checkmark.seal.fill" "Offline",
case "sla_alert": return "clock.badge.exclamationmark" systemImage: "wifi.slash",
case "follow_up_required": return "exclamationmark.arrow.circlepath" description: Text("Notifications are delivered when you go online.")
default: return "bell.fill" )
} } else {
} ContentUnavailableView(
"No Notifications",
private func iconColor(for eventType: String?) -> Color { systemImage: "bell.slash",
switch eventType { description: Text("You're all caught up.")
case "inspection_completed": return .green )
case "issue_flagged": return .orange
case "issue_resolved": return .green
case "sla_alert": return .red
case "follow_up_required": return .orange
default: return .blue
} }
} }
} }
// MARK: - Row
struct NotificationRow: View {
let notification: LocalNotification
var body: some View {
HStack(alignment: .top, spacing: 10) {
// Unread marker. A filled dot rather than colour alone, so the
// distinction survives greyscale and colour-blind vision.
Circle()
.fill(notification.isRead ? Color.clear : Color.blue)
.frame(width: 8, height: 8)
.padding(.top, 6)
Image(systemName: NotificationStyle.icon(for: notification.eventType))
.foregroundStyle(notification.isRead
? Color.secondary
: NotificationStyle.color(for: notification.eventType))
.frame(width: 24)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 3) {
Text(notification.title)
.font(notification.isRead ? .callout : .callout.bold())
.foregroundStyle(notification.isRead ? .secondary : .primary)
.lineLimit(2)
Text(notification.body)
.font(.caption)
.foregroundStyle(notification.isRead ? Color(.tertiaryLabel) : .secondary)
.lineLimit(2)
Text(notification.createdAt.formatted(.relative(presentation: .named)))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
}
// MARK: - Detail
/// Full text of one notification, plus a route to whatever it refers to.
///
/// Opening this marks the notification read the standard inbox contract, and
/// the reason a tap is treated as a deliberate read action that also clears the
/// user's web badge (rule 92).
struct NotificationDetailView: View {
let notification: LocalNotification
@EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context
/// The issue this notification refers to, when it refers to one AND that
/// issue is cached on this device. Absent is normal, not an error: the
/// issue may belong to another inspector, or simply not be pulled yet.
private var linkedIssue: LocalIssue? {
guard let issueId = notification.issueId else { return nil }
// Fetch-all + filter in Swift (rule 3), `try?` parenthesised (rule 25).
let all = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
return all.first { $0.serverId == issueId }
}
var body: some View {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: NotificationStyle.icon(for: notification.eventType))
.foregroundStyle(NotificationStyle.color(for: notification.eventType))
Text(NotificationStyle.label(for: notification.eventType))
.font(.caption.bold())
.foregroundStyle(.secondary)
}
Text(notification.title)
.font(.headline)
Text(notification.body)
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
}
Section("Received") {
LabeledContent("Sent",
value: notification.createdAt.formatted(date: .long, time: .shortened))
if notification.isRead, let readAt = notification.readAt {
LabeledContent("Read",
value: readAt.formatted(date: .long, time: .shortened))
}
}
if let issueId = notification.issueId {
Section("Related") {
if let issue = linkedIssue {
NavigationLink(value: issue) {
Label("View Issue #\(issueId)", systemImage: "exclamationmark.triangle")
}
} else {
// Honest dead end rather than a link that goes nowhere.
Label("Issue #\(issueId) is not on this device yet.",
systemImage: "arrow.down.circle")
.font(.callout)
.foregroundStyle(.secondary)
Text("It will appear under Issues after the next sync, "
+ "if it is assigned to you.")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
if !notification.isRead {
Section {
Button {
Task { await sync.markNotificationRead(notification) }
} label: {
Label("Mark as Read", systemImage: "envelope.open")
}
}
}
}
.navigationTitle("Notification")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: LocalIssue.self) { issue in
IssueDetailView(issue: issue)
}
.task {
// Opening IS reading the standard inbox contract.
// Re-fires when returning from the issue detail, which is harmless:
// markNotificationRead() no-ops once isRead is true.
await sync.markNotificationRead(notification)
}
}
}
// MARK: - Event styling
/// Icon, colour and human label per server `event_type`.
///
/// Keys mirror the constants in `app/models/notification.py`; an unknown or nil
/// type (rows predating the server's phase17 migration) falls back to a
/// neutral bell rather than being hidden.
nonisolated enum NotificationStyle {
static func icon(for eventType: String?) -> String {
switch eventType {
case "issue_assigned": return "person.crop.circle.badge.exclamationmark"
case "issue_status": return "arrow.triangle.2.circlepath"
case "issue_comment": return "text.bubble"
case "issue_flagged": return "exclamationmark.triangle.fill"
case "issue_follow_update": return "bell.badge"
case "inspection_completed": return "checkmark.circle.fill"
case "sla_alert": return "clock.badge.exclamationmark"
case "score_alert": return "chart.line.downtrend.xyaxis"
case "scheduled_inspection": return "calendar.badge.clock"
case "followup_requested": return "exclamationmark.arrow.circlepath"
case "admin_broadcast": return "megaphone"
default: return "bell.fill"
}
}
static func color(for eventType: String?) -> Color {
switch eventType {
case "issue_assigned": return .blue
case "issue_status": return .blue
case "issue_comment": return .teal
case "issue_flagged": return .orange
case "issue_follow_update": return .blue
case "inspection_completed": return .green
case "sla_alert": return .red
case "score_alert": return .red
case "scheduled_inspection": return .indigo
case "followup_requested": return .orange
case "admin_broadcast": return .purple
default: return .blue
}
}
static func label(for eventType: String?) -> String {
switch eventType {
case "issue_assigned": return "ISSUE ASSIGNED"
case "issue_status": return "ISSUE STATUS"
case "issue_comment": return "NEW COMMENT"
case "issue_flagged": return "ISSUE FLAGGED"
case "issue_follow_update": return "FOLLOWED ISSUE"
case "inspection_completed": return "INSPECTION COMPLETED"
case "sla_alert": return "SLA ALERT"
case "score_alert": return "SCORE ALERT"
case "scheduled_inspection": return "SCHEDULED INSPECTION"
case "followup_requested": return "FOLLOW-UP REQUESTED"
case "admin_broadcast": return "ANNOUNCEMENT"
default: return "NOTIFICATION"
}
}
}
@@ -1179,7 +1179,7 @@ struct PhotoThumbnailView: View {
Group { Group {
if value.hasPrefix("local://") { if value.hasPrefix("local://") {
let path = String(value.dropFirst("local://".count)) let path = String(value.dropFirst("local://".count))
if let img = UIImage(contentsOfFile: path) { if let live = PhotoStore.resolve(path), let img = UIImage(contentsOfFile: live) {
thumbnailButton { thumbnailButton {
Image(uiImage: img) Image(uiImage: img)
.resizable().scaledToFill() .resizable().scaledToFill()