Aug 27 - Fixed inspection lost photos recovery
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user