06/23 Fix Medium//Low-impact issues
This commit is contained in:
@@ -118,6 +118,21 @@ class SyncManager: ObservableObject {
|
||||
stopPollTask()
|
||||
}
|
||||
|
||||
/// Called when the app enters the background (scenePhase == .background).
|
||||
/// Stops the poll loop so it doesn't accumulate suspended sleep cycles.
|
||||
func suspendPolling() {
|
||||
stopPollTask()
|
||||
}
|
||||
|
||||
/// Called when the app returns to the foreground (scenePhase == .active).
|
||||
/// Restarts the poll loop and immediately syncs so stale data is refreshed
|
||||
/// without waiting up to 60s for the next scheduled tick.
|
||||
func resumePolling() {
|
||||
guard isOnline, AuthManager.shared.isAuthenticated else { return }
|
||||
startPollTask()
|
||||
Task { await triggerSync() }
|
||||
}
|
||||
|
||||
/// Call when the user opens the NotificationsView to clear the badge.
|
||||
func markNotificationsViewed() {
|
||||
unreadNotificationCount = 0
|
||||
@@ -136,8 +151,13 @@ class SyncManager: ObservableObject {
|
||||
deliverLocalNotification(n)
|
||||
}
|
||||
|
||||
// Update in-app inbox state
|
||||
recentNotifications = notifications + recentNotifications.prefix(50 - notifications.count)
|
||||
// Update in-app inbox state.
|
||||
// Prepend new notifications and cap at 50 — avoids allocating two
|
||||
// arrays and concatenating them on every poll (the old pattern
|
||||
// `notifications + recentNotifications.prefix(50 - count)` always
|
||||
// created a new array even when notifications.count >= 50).
|
||||
recentNotifications.insert(contentsOf: notifications, at: 0)
|
||||
if recentNotifications.count > 50 { recentNotifications = Array(recentNotifications.prefix(50)) }
|
||||
unreadNotificationCount += notifications.count
|
||||
|
||||
// Update the cursor to the newest notification's timestamp so the
|
||||
@@ -493,13 +513,18 @@ class SyncManager: ObservableObject {
|
||||
)
|
||||
|
||||
for apiSummary in templatesData.templates {
|
||||
let summaryChanged: Bool
|
||||
if let existing = templateMap[apiSummary.id] {
|
||||
existing.updateSummary(from: apiSummary)
|
||||
summaryChanged = existing.updateSummary(from: apiSummary)
|
||||
} else {
|
||||
context.insert(LocalTemplate(from: apiSummary))
|
||||
summaryChanged = true // new template — must fetch schema
|
||||
}
|
||||
try await upsertTemplateSchema(
|
||||
id: apiSummary.id, context: context, templateMap: templateMap
|
||||
id: apiSummary.id,
|
||||
context: context,
|
||||
templateMap: templateMap,
|
||||
summaryChanged: summaryChanged
|
||||
)
|
||||
}
|
||||
|
||||
@@ -543,14 +568,8 @@ class SyncManager: ObservableObject {
|
||||
guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return }
|
||||
UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey)
|
||||
|
||||
let fm = FileManager.default
|
||||
guard let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||
let photosDir = docsDir.appendingPathComponent("JQCPhotos")
|
||||
guard let diskFiles = try? fm.contentsOfDirectory(
|
||||
at: photosDir, includingPropertiesForKeys: nil
|
||||
) else { return }
|
||||
|
||||
// Collect all local paths that are still in use.
|
||||
// ── Phase 1: collect referenced paths on @MainActor (SwiftData fetches) ──
|
||||
// These are fast in-memory operations — always runs on the main actor.
|
||||
var referencedPaths = Set<String>()
|
||||
|
||||
// PendingPhoto — not yet uploaded
|
||||
@@ -576,17 +595,29 @@ class SyncManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete any disk file not in referencedPaths
|
||||
var deletedCount = 0
|
||||
for fileURL in diskFiles {
|
||||
let path = fileURL.path
|
||||
if !referencedPaths.contains(path) {
|
||||
try? fm.removeItem(at: fileURL)
|
||||
deletedCount += 1
|
||||
// ── Phase 2: FileManager enumeration + deletion on a background thread ──
|
||||
// Directory enumeration and file removal are I/O-bound and can stutter
|
||||
// the main thread when JQCPhotos/ contains hundreds of files. Dispatching
|
||||
// here is safe because `referencedPaths` is a value type (Set<String>)
|
||||
// captured by copy — no shared mutable state crosses the boundary.
|
||||
Task.detached(priority: .utility) {
|
||||
let fm = FileManager.default
|
||||
guard let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||
let photosDir = docsDir.appendingPathComponent("JQCPhotos")
|
||||
guard let diskFiles = try? fm.contentsOfDirectory(
|
||||
at: photosDir, includingPropertiesForKeys: nil
|
||||
) else { return }
|
||||
|
||||
var deletedCount = 0
|
||||
for fileURL in diskFiles {
|
||||
if !referencedPaths.contains(fileURL.path) {
|
||||
try? fm.removeItem(at: fileURL)
|
||||
deletedCount += 1
|
||||
}
|
||||
}
|
||||
if deletedCount > 0 {
|
||||
print("[JQC] Sync | cleanupOrphanedPhotos | removed \(deletedCount) file(s)")
|
||||
}
|
||||
}
|
||||
if deletedCount > 0 {
|
||||
print("[JQC] Sync | cleanupOrphanedPhotos | removed \(deletedCount) file(s)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,18 +662,33 @@ class SyncManager: ObservableObject {
|
||||
private func upsertTemplateSchema(
|
||||
id: Int,
|
||||
context: ModelContext,
|
||||
templateMap: [Int: LocalTemplate]
|
||||
templateMap: [Int: LocalTemplate],
|
||||
summaryChanged: Bool
|
||||
) async throws {
|
||||
let existing = templateMap[id] ?? (try? context.fetch(FetchDescriptor<LocalTemplate>()))?
|
||||
.first { $0.serverId == id }
|
||||
|
||||
// Skip the detail API call if:
|
||||
// • The schema was already fetched (schemaFetchedAt is non-nil)
|
||||
// • No summary fields changed this sync pass (name, frequency, isActive)
|
||||
// • The local schema is non-empty (not a first-run blank)
|
||||
//
|
||||
// This reduces N sequential GET /api/v1/templates/{id} calls to zero
|
||||
// on a typical sync where templates haven't changed — the common case.
|
||||
// The schema is always re-fetched when any summary field changes,
|
||||
// when schemaFetchedAt is nil (new template or first launch), or
|
||||
// when the local schema is empty ("[]").
|
||||
if let existing,
|
||||
existing.schemaFetchedAt != nil,
|
||||
existing.formSchemaJSON != "[]",
|
||||
!summaryChanged {
|
||||
return // schema is current — no network call needed
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
existing?.updateSchema(from: detailData.template)
|
||||
}
|
||||
|
||||
// ── Pull server-assigned issues ───────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user