perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign

Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
  Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
  Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
  O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).

Fast path reconcile:
  When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
  entirely and returns in microseconds. Falls through to full NAS path only when stale.

AutoBackupCoordinator.onActive():
  Changed force: true → force: false so the 30-second debounce prevents expensive rescans
  on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.

BackupQueueItem + queue tracking:
  BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
  speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
  always has data to show even after restart.

NotificationService:
  Centralised local notification sender replacing the inline UNMutableNotificationContent
  blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
  identifiers so same-type notifications replace each other.

SyncView redesign:
  - Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
  - No live NAS directory listing (was expensive NAS connection on every tab open)
  - Shows engine.queueItems grouped into Active / Failed / Completed sections
  - Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
  - Idle state shows archive count from statusService snapshot

GalleryViewModel:
  NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
  is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.

AppDelegate:
  Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 23:28:29 +03:00
parent 025326f2b2
commit 1afcbaff96
11 changed files with 786 additions and 325 deletions

View File

@@ -0,0 +1,65 @@
import UserNotifications
import Foundation
/// Centralised local-notification sender.
///
/// Uses notification identifiers as categories posting the same id replaces
/// any previous notification in that category, preventing stale banners from
/// stacking up.
enum NotificationService {
static func notifyBackupStarted(count: Int) {
guard count > 0 else { return }
post(
id: "kisani.backup.started",
title: "Backup started",
body: "Kisani is backing up \(count) new \(count == 1 ? "item" : "items")."
)
}
static func notifyBackupCompleted(uploaded: Int, total: Int) {
post(
id: "kisani.backup.completed",
title: "Backup complete",
body: total > 0
? "All \(total) photos are safe."
: "\(uploaded) \(uploaded == 1 ? "item" : "items") backed up."
)
}
static func notifyBackupPaused(pendingCount: Int) {
post(
id: "kisani.backup.paused",
title: "Backup paused",
body: pendingCount > 0
? "Kisani will continue \(pendingCount) \(pendingCount == 1 ? "item" : "items") when your NAS is reachable."
: "Kisani will continue when your NAS is reachable."
)
}
static func notifyBackupFailed(count: Int) {
post(
id: "kisani.backup.failed",
title: "Backup failed",
body: "\(count) \(count == 1 ? "item" : "items") could not be backed up. Tap to review."
)
}
static func notifyNASOffline() {
post(
id: "kisani.nas.offline",
title: "NAS unreachable",
body: "Kisani will resume backup when your NAS is back online."
)
}
// MARK: Private
private static func post(id: String, title: String, body: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(identifier: id, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request) { _ in }
}
}