05/15 Update: show issue's images, push notifications
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
// Sync/SyncManager.swift
|
||||
// ----------------------
|
||||
// Manages connectivity monitoring, reference data sync (Phase A),
|
||||
// and the outbox queue for offline inspection/issue submission (Phase B).
|
||||
// the outbox queue for offline inspection/issue submission (Phase B),
|
||||
// and notification polling (Phase C).
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
class SyncManager: ObservableObject {
|
||||
@@ -26,6 +28,14 @@ class SyncManager: ObservableObject {
|
||||
private let monitorQueue = DispatchQueue(label: "com.jqc.networkmonitor")
|
||||
var modelContext: ModelContext?
|
||||
|
||||
// ── Notification polling state ────────────────────────────────────────
|
||||
// Tracks the timestamp of the most recently fetched notification so each
|
||||
// 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
|
||||
|
||||
static let shared = SyncManager()
|
||||
private init() {}
|
||||
|
||||
@@ -37,14 +47,98 @@ class SyncManager: ObservableObject {
|
||||
guard let self else { return }
|
||||
let wasOffline = !self.isOnline
|
||||
self.isOnline = path.status == .satisfied
|
||||
if wasOffline && self.isOnline {
|
||||
await self.triggerSync()
|
||||
if self.isOnline {
|
||||
if wasOffline {
|
||||
await self.triggerSync()
|
||||
}
|
||||
self.startPollTimer()
|
||||
} else {
|
||||
self.stopPollTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: monitorQueue)
|
||||
}
|
||||
|
||||
// ── Notification poll timer ───────────────────────────────────────────
|
||||
|
||||
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 stopPollTimer() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
/// Called on logout so the next login starts a clean fetch.
|
||||
func resetNotificationPoller() {
|
||||
lastNotificationFetch = nil
|
||||
stopPollTimer()
|
||||
}
|
||||
|
||||
// ── Notification polling ──────────────────────────────────────────────
|
||||
|
||||
func pollNotifications() async {
|
||||
guard isOnline, AuthManager.shared.isAuthenticated else { return }
|
||||
do {
|
||||
let notifications = try await APIClient.shared.fetchNotifications(since: lastNotificationFetch)
|
||||
guard !notifications.isEmpty else { return }
|
||||
|
||||
// Deliver a local notification for each new item
|
||||
for n in notifications {
|
||||
deliverLocalNotification(n)
|
||||
}
|
||||
|
||||
// Update the cursor to the newest notification's timestamp
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
let dates = notifications.compactMap { fmt.date(from: $0.createdAt) }
|
||||
if let newest = dates.max() {
|
||||
lastNotificationFetch = newest
|
||||
}
|
||||
|
||||
// Mark all fetched notifications as read on the server
|
||||
let ids = notifications.map(\.id)
|
||||
try await APIClient.shared.markNotificationsRead(ids: ids)
|
||||
|
||||
} catch APIError.notAuthenticated {
|
||||
// Token expired and refresh failed — let AuthManager handle it
|
||||
} catch {
|
||||
// Network errors are silent; next poll will retry
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local notification delivery ───────────────────────────────────────
|
||||
|
||||
private func deliverLocalNotification(_ n: APINotification) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = n.title
|
||||
content.body = n.body
|
||||
content.sound = .default
|
||||
|
||||
// Use the server notification ID as the identifier so duplicate
|
||||
// deliveries (if the same record is fetched twice) replace rather
|
||||
// than stack.
|
||||
let identifier = "jqc-notif-\(n.id)"
|
||||
let request = UNNotificationRequest(
|
||||
identifier: identifier,
|
||||
content: content,
|
||||
trigger: nil // nil = deliver immediately
|
||||
)
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error {
|
||||
print("[JQC] Local notification delivery failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full Sync ─────────────────────────────────────────────────────────
|
||||
|
||||
func triggerSync() async {
|
||||
@@ -59,6 +153,12 @@ class SyncManager: ObservableObject {
|
||||
await processInspectionQueue(context: context)
|
||||
await processIssueQueue(context: context)
|
||||
await pullReferenceData()
|
||||
await pullAssignedIssues(context: context)
|
||||
|
||||
// Poll notifications immediately on every sync rather than waiting
|
||||
// for the 60-second timer — ensures the inspector sees assignments
|
||||
// and follow-up requests as soon as the app goes online.
|
||||
await pollNotifications()
|
||||
|
||||
updatePendingCount(context: context)
|
||||
lastSyncAt = Date()
|
||||
@@ -87,12 +187,11 @@ class SyncManager: ObservableObject {
|
||||
// Update parent inspection form field value
|
||||
if photo.entityType == "inspection", let fieldId = photo.fieldId {
|
||||
let entityId = photo.entityLocalId
|
||||
let inspections = try? context.fetch(
|
||||
FetchDescriptor<LocalInspection>(
|
||||
predicate: #Predicate { $0.localId == entityId }
|
||||
)
|
||||
)
|
||||
inspections?.first?.setValue(serverPath, forFieldId: fieldId)
|
||||
// Fetch-all + filter in Swift — #Predicate with a captured String
|
||||
// variable causes "LocalInspection is ambiguous" under Xcode 26
|
||||
// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (CLAUDE.md rule 3).
|
||||
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||
allInspections.first(where: { $0.localId == entityId })?.setValue(serverPath, forFieldId: fieldId)
|
||||
}
|
||||
|
||||
// Update parent issue photo paths array
|
||||
@@ -294,4 +393,74 @@ class SyncManager: ObservableObject {
|
||||
.updateSchema(from: detailData.template)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pull server-assigned issues ───────────────────────────────────────
|
||||
// Fetches issues assigned to the current user on the server and upserts
|
||||
// them into SwiftData so IssuesListView shows them alongside device-created issues.
|
||||
// Keyed by serverId — existing records are updated in-place, new ones inserted.
|
||||
// These records carry syncStatus = "synced" and a generated localId so they
|
||||
// are never re-submitted to the server by processIssueQueue.
|
||||
|
||||
func pullAssignedIssues(context: ModelContext) async {
|
||||
guard isOnline, AuthManager.shared.isAuthenticated else { return }
|
||||
do {
|
||||
let apiIssues = try await APIClient.shared.fetchAssignedIssues()
|
||||
guard !apiIssues.isEmpty else { return }
|
||||
|
||||
// Build a map of existing LocalIssues by serverId for upsert
|
||||
let allLocal = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||
var serverIdMap: [Int: LocalIssue] = [:]
|
||||
for local in allLocal {
|
||||
if let sid = local.serverId { serverIdMap[sid] = local }
|
||||
}
|
||||
|
||||
let isoFmt = ISO8601DateFormatter()
|
||||
|
||||
for api in apiIssues {
|
||||
if let existing = serverIdMap[api.id] {
|
||||
// Update mutable fields on existing record
|
||||
existing.issueStatus = api.status
|
||||
existing.severity = api.severity
|
||||
existing.issueDescription = api.description
|
||||
if let fid = api.facilityId { existing.facilityServerId = fid }
|
||||
// Refresh photos in case they were added after first pull
|
||||
var serverPaths: [String] = []
|
||||
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
|
||||
serverPaths.append(contentsOf: api.resultPhotos)
|
||||
existing.photoServerPaths = serverPaths
|
||||
} else {
|
||||
// Insert new server-pulled issue
|
||||
let local = LocalIssue(
|
||||
inspectionLocalId: "",
|
||||
facilityServerId: api.facilityId ?? 0,
|
||||
severity: api.severity,
|
||||
description: api.description
|
||||
)
|
||||
local.serverId = api.id
|
||||
local.issueStatus = api.status
|
||||
local.syncStatus = "synced" // never re-submit
|
||||
// Store server photos so IssueDetailView can show them
|
||||
var serverPaths: [String] = []
|
||||
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
|
||||
serverPaths.append(contentsOf: api.resultPhotos)
|
||||
local.photoServerPaths = serverPaths
|
||||
if let ts = api.reportedAt,
|
||||
let date = isoFmt.date(from: ts) ?? {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
return f.date(from: ts)
|
||||
}() {
|
||||
local.createdAt = date
|
||||
}
|
||||
context.insert(local)
|
||||
}
|
||||
}
|
||||
try? context.save()
|
||||
|
||||
} catch APIError.notAuthenticated {
|
||||
// Let AuthManager handle session expiry
|
||||
} catch {
|
||||
// Non-fatal — IssuesListView still shows device-created issues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user