Merge branch 'main' of https://gitea.ngodanguyen.tech/nngo/JQC_iOS_App
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user