05/02 Phase B
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
// Sync/SyncManager.swift
|
||||
// ----------------------
|
||||
// Manages connectivity monitoring, reference data sync (Phase A),
|
||||
// and the outbox queue for offline inspection/issue submission (Phase B).
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
class SyncManager: ObservableObject {
|
||||
|
||||
// ── Published State ───────────────────────────────────────────────────
|
||||
|
||||
@Published var isOnline = false
|
||||
@Published var isSyncing = false
|
||||
@Published var lastSyncAt: Date?
|
||||
@Published var syncError: String?
|
||||
@Published var pendingCount = 0
|
||||
|
||||
// ── Dependencies ──────────────────────────────────────────────────────
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let monitorQueue = DispatchQueue(label: "com.jqc.networkmonitor")
|
||||
var modelContext: ModelContext?
|
||||
|
||||
static let shared = SyncManager()
|
||||
private init() {}
|
||||
|
||||
// ── Start Monitoring ──────────────────────────────────────────────────
|
||||
|
||||
func startMonitoring() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let wasOffline = !self.isOnline
|
||||
self.isOnline = path.status == .satisfied
|
||||
if wasOffline && self.isOnline {
|
||||
await self.triggerSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: monitorQueue)
|
||||
}
|
||||
|
||||
// ── Full Sync ─────────────────────────────────────────────────────────
|
||||
|
||||
func triggerSync() async {
|
||||
guard isOnline, let context = modelContext else { return }
|
||||
isSyncing = true
|
||||
syncError = nil
|
||||
defer { isSyncing = false }
|
||||
|
||||
await processPhotoQueue(context: context)
|
||||
await processInspectionQueue(context: context)
|
||||
await processIssueQueue(context: context)
|
||||
await pullReferenceData()
|
||||
|
||||
updatePendingCount(context: context)
|
||||
lastSyncAt = Date()
|
||||
}
|
||||
|
||||
// ── Outbox: Photos ────────────────────────────────────────────────────
|
||||
|
||||
private func processPhotoQueue(context: ModelContext) async {
|
||||
// Fetch all then filter in Swift — #Predicate cannot reference
|
||||
// string literals against PendingPhoto.uploadStatus reliably
|
||||
// when the predicate type is inferred across model boundaries.
|
||||
guard let allPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) else { return }
|
||||
let pending = allPhotos
|
||||
.filter { $0.uploadStatus == "pending" }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
|
||||
for photo in pending {
|
||||
do {
|
||||
let serverPath = try await APIClient.shared.uploadPhoto(
|
||||
localPath: photo.localFilePath,
|
||||
entityType: photo.entityType
|
||||
)
|
||||
photo.serverPath = serverPath
|
||||
photo.uploadStatus = "uploaded"
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Update parent issue photo path
|
||||
if photo.entityType == "issue" {
|
||||
let entityId = photo.entityLocalId
|
||||
let issues = try? context.fetch(
|
||||
FetchDescriptor<LocalIssue>(
|
||||
predicate: #Predicate { $0.localId == entityId }
|
||||
)
|
||||
)
|
||||
issues?.first?.photoServerPath = serverPath
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
|
||||
} catch {
|
||||
photo.uploadStatus = "failed"
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Outbox: Inspections ───────────────────────────────────────────────
|
||||
|
||||
private func processInspectionQueue(context: ModelContext) async {
|
||||
// Fetch all and filter in Swift to avoid #Predicate compound
|
||||
// string comparison issues across Xcode versions.
|
||||
guard let all = try? context.fetch(FetchDescriptor<LocalInspection>()) else { return }
|
||||
let pending = all
|
||||
.filter { $0.status == "completed" && $0.syncStatus == "pending" }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
|
||||
for inspection in pending {
|
||||
let photosReady = inspection.pendingPhotos.allSatisfy {
|
||||
$0.uploadStatus == "uploaded" || $0.uploadStatus == "failed"
|
||||
}
|
||||
guard photosReady else { continue }
|
||||
|
||||
do {
|
||||
let inspectionId = try await APIClient.shared.submitInspection(inspection)
|
||||
inspection.serverId = inspectionId
|
||||
inspection.syncStatus = "synced"
|
||||
inspection.status = "synced"
|
||||
try? context.save()
|
||||
|
||||
} catch {
|
||||
inspection.syncRetryCount += 1
|
||||
inspection.syncErrorMessage = error.localizedDescription
|
||||
if inspection.syncRetryCount >= 5 {
|
||||
inspection.syncStatus = "failed"
|
||||
}
|
||||
syncError = "Failed to sync inspection: \(error.localizedDescription)"
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Outbox: Issues ────────────────────────────────────────────────────
|
||||
|
||||
private func processIssueQueue(context: ModelContext) async {
|
||||
guard let all = try? context.fetch(FetchDescriptor<LocalIssue>()) else { return }
|
||||
let pending = all
|
||||
.filter { $0.syncStatus == "pending" }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
|
||||
for issue in pending {
|
||||
do {
|
||||
let issueId = try await APIClient.shared.submitIssue(issue)
|
||||
issue.serverId = issueId
|
||||
issue.syncStatus = "synced"
|
||||
try? context.save()
|
||||
|
||||
} catch {
|
||||
issue.syncRetryCount += 1
|
||||
issue.syncErrorMessage = error.localizedDescription
|
||||
if issue.syncRetryCount >= 5 {
|
||||
issue.syncStatus = "failed"
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reference Data ────────────────────────────────────────────────────
|
||||
|
||||
func pullReferenceData() async {
|
||||
guard isOnline, let context = modelContext else { return }
|
||||
|
||||
do {
|
||||
// Sequential fetches avoid Swift 6 actor-isolation warnings
|
||||
// on Decodable structs used across async boundaries.
|
||||
let facilitiesData: FacilitiesResponseData =
|
||||
try await APIClient.shared.request("/api/v1/facilities")
|
||||
let templatesData: TemplatesResponseData =
|
||||
try await APIClient.shared.request("/api/v1/templates")
|
||||
|
||||
let existingFacilities = try context.fetch(FetchDescriptor<LocalFacility>())
|
||||
let facilityMap = Dictionary(
|
||||
existingFacilities.map { ($0.serverId, $0) },
|
||||
uniquingKeysWith: { a, _ in a }
|
||||
)
|
||||
|
||||
for apiFacility in facilitiesData.facilities {
|
||||
if let existing = facilityMap[apiFacility.id] {
|
||||
existing.update(from: apiFacility)
|
||||
} else {
|
||||
context.insert(LocalFacility(from: apiFacility))
|
||||
}
|
||||
try await upsertAreas(for: apiFacility.id, context: context)
|
||||
}
|
||||
|
||||
let existingTemplates = try context.fetch(FetchDescriptor<LocalTemplate>())
|
||||
let templateMap = Dictionary(
|
||||
existingTemplates.map { ($0.serverId, $0) },
|
||||
uniquingKeysWith: { a, _ in a }
|
||||
)
|
||||
|
||||
for apiSummary in templatesData.templates {
|
||||
if let existing = templateMap[apiSummary.id] {
|
||||
existing.updateSummary(from: apiSummary)
|
||||
} else {
|
||||
context.insert(LocalTemplate(from: apiSummary))
|
||||
}
|
||||
try await upsertTemplateSchema(
|
||||
id: apiSummary.id, context: context, templateMap: templateMap
|
||||
)
|
||||
}
|
||||
|
||||
try context.save()
|
||||
|
||||
} catch APIError.notAuthenticated {
|
||||
syncError = "Session expired. Please log in again."
|
||||
} catch {
|
||||
syncError = "Sync failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pending Count ─────────────────────────────────────────────────────
|
||||
|
||||
func updatePendingCount(context: ModelContext) {
|
||||
let inspCount = (try? context.fetch(FetchDescriptor<LocalInspection>()))?
|
||||
.filter { $0.syncStatus == "pending" }.count ?? 0
|
||||
let issueCount = (try? context.fetch(FetchDescriptor<LocalIssue>()))?
|
||||
.filter { $0.syncStatus == "pending" }.count ?? 0
|
||||
pendingCount = inspCount + issueCount
|
||||
}
|
||||
|
||||
// ── Private Helpers ───────────────────────────────────────────────────
|
||||
|
||||
private func upsertAreas(for facilityId: Int, context: ModelContext) async throws {
|
||||
let areasData: AreasResponseData = try await APIClient.shared.request(
|
||||
"/api/v1/facilities/\(facilityId)/areas"
|
||||
)
|
||||
let existing = (try? context.fetch(FetchDescriptor<LocalArea>()))?
|
||||
.filter { $0.facilityServerId == facilityId } ?? []
|
||||
let areaMap = Dictionary(existing.map { ($0.serverId, $0) },
|
||||
uniquingKeysWith: { a, _ in a })
|
||||
for apiArea in areasData.areas {
|
||||
if let ex = areaMap[apiArea.id] { ex.update(from: apiArea) }
|
||||
else { context.insert(LocalArea(from: apiArea)) }
|
||||
}
|
||||
}
|
||||
|
||||
private func upsertTemplateSchema(
|
||||
id: Int,
|
||||
context: ModelContext,
|
||||
templateMap: [Int: LocalTemplate]
|
||||
) async throws {
|
||||
let detailData: TemplateDetailResponseData = try await APIClient.shared.request(
|
||||
"/api/v1/templates/\(id)"
|
||||
)
|
||||
if let existing = templateMap[id] {
|
||||
existing.updateSchema(from: detailData.template)
|
||||
} else {
|
||||
(try? context.fetch(FetchDescriptor<LocalTemplate>()))?
|
||||
.first { $0.serverId == id }?
|
||||
.updateSchema(from: detailData.template)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user