This commit is contained in:
2026-08-17 16:08:05 -04:00
21 changed files with 1978 additions and 232 deletions
+100
View File
@@ -10,6 +10,7 @@ import SwiftData
import SwiftUI
import Combine
import UserNotifications
import UIKit // beginBackgroundTask see beginSyncBackgroundTask()
@MainActor
class SyncManager: ObservableObject {
@@ -110,6 +111,36 @@ class SyncManager: ObservableObject {
pollTask = nil
}
// Background task assertion
// Keeps the process alive across a suspend so an in-flight sync can finish.
// Distinct from the BGProcessingTask in JanitorialQCApp: that one asks iOS
// to WAKE us later, this one asks it not to suspend us right now.
private var syncBackgroundTaskId: UIBackgroundTaskIdentifier = .invalid
private func beginSyncBackgroundTask() {
// triggerSync() is re-entrancy guarded, but assert defensively anyway:
// beginning a second assertion would leak the first identifier.
guard syncBackgroundTaskId == .invalid else { return }
syncBackgroundTaskId = UIApplication.shared.beginBackgroundTask(
withName: "JQC.syncDrain"
) {
// Called on the main thread when the grace period runs out. It MUST
// end the assertion or iOS terminates the app. assumeIsolated is
// required because the handler is a nonisolated closure under
// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
MainActor.assumeIsolated {
SyncManager.shared.endSyncBackgroundTask()
}
}
}
private func endSyncBackgroundTask() {
guard syncBackgroundTaskId != .invalid else { return }
UIApplication.shared.endBackgroundTask(syncBackgroundTaskId)
syncBackgroundTaskId = .invalid
}
/// Called on logout so the next login starts a clean fetch.
func resetNotificationPoller() {
lastNotificationFetch = nil
@@ -232,12 +263,22 @@ class SyncManager: ObservableObject {
syncError = nil
defer { isSyncing = false }
// Ask iOS to keep the process alive long enough to finish the drain.
// The case this covers: an inspector taps Submit and immediately locks
// the iPad or swipes to another app. Without an assertion the process
// suspends mid-upload and the work waits for the next launch.
// Roughly 30 s of grace; the expiration handler ends it cleanly so iOS
// never force-kills us. Harmless in the foreground it simply ends.
beginSyncBackgroundTask()
defer { endSyncBackgroundTask() }
await processPhotoQueue(context: context)
await processInspectionQueue(context: context)
await processIssueQueue(context: context)
await pullReferenceData()
await pullAssignedIssues(context: context)
await pullScheduledInspections(context: context)
await pullFollowUpRequests(context: context)
// Poll notifications immediately on every sync rather than waiting
// for the 60-second timer ensures the inspector sees assignments
@@ -925,6 +966,65 @@ class SyncManager: ObservableObject {
}
}
// Follow-up Requests
// Read-only pull of inspections a director flagged for follow-up, for the
// Dashboard and My Inspections "Follow-up Requested" section. Upsert by
// serverId, then delete rows the server no longer returns (the follow-up was
// fulfilled by a linked re-inspection, or the director cleared the flag).
// Best-effort never blocks the pipeline.
func pullFollowUpRequests(context: ModelContext) async {
guard isOnline, AuthManager.shared.isAuthenticated else { return }
do {
let apiRows = try await APIClient.shared.fetchFollowUpRequests()
// Fetch-all + filter/map in Swift no #Predicate (CLAUDE.md rule 3).
let allLocal = (try? context.fetch(FetchDescriptor<LocalFollowUpRequest>())) ?? []
var byServerId: [Int: LocalFollowUpRequest] = [:]
for row in allLocal { byServerId[row.serverId] = row }
for api in apiRows {
if let existing = byServerId[api.id] {
existing.update(from: api)
} else {
context.insert(LocalFollowUpRequest(from: api))
}
}
// Delete rows the server no longer returns.
let returnedIds = Set(apiRows.map { $0.id })
for row in allLocal where !returnedIds.contains(row.serverId) {
context.delete(row)
}
// Keep the local copy of the flagged inspection in step, so the
// follow-up badge in My Inspections / history detail agrees with the
// card without waiting for the inspector to open that detail view
// (which was previously the only thing that wrote these fields).
var noteByServerId: [Int: String] = [:]
for api in apiRows {
if let note = api.followUpNote { noteByServerId[api.id] = note }
}
for local in (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? [] {
guard let sid = local.serverId else { continue }
if returnedIds.contains(sid) {
local.followUpRequired = true
local.followUpNote = noteByServerId[sid]
} else if local.followUpRequired {
local.followUpRequired = false
local.followUpNote = nil
}
}
try? context.save()
} catch APIError.notAuthenticated {
// Let AuthManager handle session expiry
} catch {
// Non-fatal stale follow-up rows stay visible until next pull
}
}
// Dashboard Stats
// Best-effort fetch a network failure silently leaves dashboardStats nil
// so the UI falls back to a placeholder card. Never blocks the sync pipeline.