05/15 Update: inspector issue view
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Void, Never>? // 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<LocalIssue>())) ?? []
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user