diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift index 691a729..648ab09 100644 --- a/JanitorialQC/JanitorialQCApp.swift +++ b/JanitorialQC/JanitorialQCApp.swift @@ -16,6 +16,9 @@ struct JanitorialQCApp: App { init() { registerBackgroundTasks() 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 { @@ -47,9 +50,6 @@ struct JanitorialQCApp: App { } // ── 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() { 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() { let request = BGProcessingTaskRequest(identifier: "com.jqc.sync") request.requiresNetworkConnectivity = true diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 4d78404..5b9a8f6 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -33,8 +33,8 @@ class SyncManager: ObservableObject { // poll only retrieves newer records. Nil on first launch → server returns // last 50 unread. Reset to nil on logout. private var lastNotificationFetch: Date? - private var pollTimer: Timer? - private let pollInterval: TimeInterval = 60 // seconds + private var pollTask: Task? // replaces Timer — Task.sleep works correctly + private let pollInterval: UInt64 = 60_000_000_000 // 60 seconds in nanoseconds static let shared = SyncManager() private init() {} @@ -51,36 +51,45 @@ class SyncManager: ObservableObject { if wasOffline { await self.triggerSync() } - self.startPollTimer() + self.startPollTask() } else { - self.stopPollTimer() + self.stopPollTask() } } } 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() { - guard pollTimer == nil else { return } // already running - pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self, self.isOnline, AuthManager.shared.isAuthenticated else { return } - await self.pollNotifications() + private func startPollTask() { + guard pollTask == nil else { return } // already running + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 60_000_000_000) + 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() { - pollTimer?.invalidate() - pollTimer = nil + private func stopPollTask() { + pollTask?.cancel() + pollTask = nil } /// Called on logout so the next login starts a clean fetch. func resetNotificationPoller() { lastNotificationFetch = nil - stopPollTimer() + stopPollTask() } // ── Notification polling ────────────────────────────────────────────── @@ -405,7 +414,8 @@ class SyncManager: ObservableObject { guard isOnline, AuthManager.shared.isAuthenticated else { return } do { 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 let allLocal = (try? context.fetch(FetchDescriptor())) ?? [] @@ -455,6 +465,24 @@ class SyncManager: ObservableObject { 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() } catch APIError.notAuthenticated { diff --git a/JanitorialQC/Views/Dashboard/StartInspectionView.swift b/JanitorialQC/Views/Dashboard/StartInspectionView.swift index 4d37ea8..ce7bf45 100644 --- a/JanitorialQC/Views/Dashboard/StartInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/StartInspectionView.swift @@ -125,9 +125,19 @@ struct StartInspectionView: View { } .pickerStyle(.navigationLink) .onChange(of: selectedProjectId) { - // Reset downstream selections when contract changes - selectedFacilityId = nil - selectedAreaId = nil + // Reset downstream selections when the user changes contract. + // Do NOT reset if the current facilityId already belongs to + // 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 + } } } }