05/15 Update: inspector issue view

This commit is contained in:
Nguyen Ngo
2026-05-15 16:02:24 -04:00
parent 7320e88dec
commit 1b90c23b3e
3 changed files with 79 additions and 22 deletions
+22 -3
View File
@@ -16,6 +16,9 @@ struct JanitorialQCApp: App {
init() { init() {
registerBackgroundTasks() registerBackgroundTasks()
requestNotificationPermission() requestNotificationPermission()
// Set delegate so notifications display as banners when the app is in
// the foreground. Without this iOS silently drops them.
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
} }
var body: some Scene { var body: some Scene {
@@ -47,9 +50,6 @@ struct JanitorialQCApp: App {
} }
// Local notification permission // Local notification permission
// Request permission for local notifications (used by the polling poller
// to alert inspectors of new assignments and follow-up requests).
// Called at init iOS caches the grant; subsequent calls are no-ops.
private func requestNotificationPermission() { private func requestNotificationPermission() {
UNUserNotificationCenter.current().requestAuthorization( UNUserNotificationCenter.current().requestAuthorization(
@@ -87,6 +87,25 @@ struct JanitorialQCApp: App {
} }
} }
// Notification delegate
// Allows local notifications to appear as banners while the app is in the
// foreground. Without this delegate iOS discards them silently.
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
private override init() {}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void
) {
// Show banner + play sound even when the app is active in foreground.
completionHandler([.banner, .sound])
}
}
func scheduleBackgroundSync() { func scheduleBackgroundSync() {
let request = BGProcessingTaskRequest(identifier: "com.jqc.sync") let request = BGProcessingTaskRequest(identifier: "com.jqc.sync")
request.requiresNetworkConnectivity = true request.requiresNetworkConnectivity = true
+44 -16
View File
@@ -33,8 +33,8 @@ class SyncManager: ObservableObject {
// poll only retrieves newer records. Nil on first launch server returns // poll only retrieves newer records. Nil on first launch server returns
// last 50 unread. Reset to nil on logout. // last 50 unread. Reset to nil on logout.
private var lastNotificationFetch: Date? private var lastNotificationFetch: Date?
private var pollTimer: Timer? private var pollTask: Task<Void, Never>? // replaces Timer Task.sleep works correctly
private let pollInterval: TimeInterval = 60 // seconds private let pollInterval: UInt64 = 60_000_000_000 // 60 seconds in nanoseconds
static let shared = SyncManager() static let shared = SyncManager()
private init() {} private init() {}
@@ -51,36 +51,45 @@ class SyncManager: ObservableObject {
if wasOffline { if wasOffline {
await self.triggerSync() await self.triggerSync()
} }
self.startPollTimer() self.startPollTask()
} else { } else {
self.stopPollTimer() self.stopPollTask()
} }
} }
} }
monitor.start(queue: monitorQueue) monitor.start(queue: monitorQueue)
} }
// Notification poll timer // Notification poll task
// Timer.scheduledTimer requires RunLoop.main to be ticking. When called
// from inside a Swift Concurrency Task { @MainActor } the current RunLoop
// is NOT RunLoop.main the timer is added to a runloop that never runs,
// so it silently fires never. Task + Task.sleep has no such dependency.
private func startPollTimer() { private func startPollTask() {
guard pollTimer == nil else { return } // already running guard pollTask == nil else { return } // already running
pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in pollTask = Task { [weak self] in
Task { @MainActor [weak self] in while !Task.isCancelled {
guard let self, self.isOnline, AuthManager.shared.isAuthenticated else { return } try? await Task.sleep(nanoseconds: 60_000_000_000)
await self.pollNotifications() guard !Task.isCancelled else { break }
await MainActor.run { [weak self] in
guard let self, self.isOnline,
AuthManager.shared.isAuthenticated else { return }
Task { await self.pollNotifications() }
}
} }
} }
} }
private func stopPollTimer() { private func stopPollTask() {
pollTimer?.invalidate() pollTask?.cancel()
pollTimer = nil pollTask = nil
} }
/// Called on logout so the next login starts a clean fetch. /// Called on logout so the next login starts a clean fetch.
func resetNotificationPoller() { func resetNotificationPoller() {
lastNotificationFetch = nil lastNotificationFetch = nil
stopPollTimer() stopPollTask()
} }
// Notification polling // Notification polling
@@ -405,7 +414,8 @@ class SyncManager: ObservableObject {
guard isOnline, AuthManager.shared.isAuthenticated else { return } guard isOnline, AuthManager.shared.isAuthenticated else { return }
do { do {
let apiIssues = try await APIClient.shared.fetchAssignedIssues() let apiIssues = try await APIClient.shared.fetchAssignedIssues()
guard !apiIssues.isEmpty else { return } // Do not return early on empty deletion still needs to run
// to remove issues that were unassigned from this inspector.
// Build a map of existing LocalIssues by serverId for upsert // Build a map of existing LocalIssues by serverId for upsert
let allLocal = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? [] let allLocal = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
@@ -455,6 +465,24 @@ class SyncManager: ObservableObject {
context.insert(local) context.insert(local)
} }
} }
// Remove server-pulled records that are no longer in the response.
// This happens when an issue is reassigned to a different inspector
// the server stops returning it for this user, so the local copy must
// be deleted. Only remove records that were pulled from the server
// (syncStatus == "synced" AND serverId != nil AND inspectionLocalId == "").
// Device-created issues (inspectionLocalId != "") are never touched.
let returnedServerIds = Set(apiIssues.map { $0.id })
for local in allLocal {
guard let sid = local.serverId,
local.syncStatus == "synced",
local.inspectionLocalId == ""
else { continue }
if !returnedServerIds.contains(sid) {
context.delete(local)
}
}
try? context.save() try? context.save()
} catch APIError.notAuthenticated { } catch APIError.notAuthenticated {
@@ -125,9 +125,19 @@ struct StartInspectionView: View {
} }
.pickerStyle(.navigationLink) .pickerStyle(.navigationLink)
.onChange(of: selectedProjectId) { .onChange(of: selectedProjectId) {
// Reset downstream selections when contract changes // Reset downstream selections when the user changes contract.
selectedFacilityId = nil // Do NOT reset if the current facilityId already belongs to
selectedAreaId = nil // the newly selected contract this covers the pre-fill path
// where applyPreFill() sets both projectId and facilityId and
// the onChange fires before facilityId is applied, wiping it.
let facilityBelongsToContract = facilities.contains {
$0.serverId == selectedFacilityId &&
$0.projectId == selectedProjectId
}
if !facilityBelongsToContract {
selectedFacilityId = nil
selectedAreaId = nil
}
} }
} }
} }