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

@@ -3,43 +3,175 @@ import SwiftUI
struct SyncView: View {
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@State private var files: [NASItem] = []
@State private var isLoading = false
@State private var error: String? = nil
@State private var appeared = false
@EnvironmentObject var engine: BackupEngine
@EnvironmentObject var statusService: BackupStatusService
private var connection: NASConnection? { store.savedConnection }
private var queueItems: [BackupQueueItem] { engine.queueItems }
private var uploadingItems: [BackupQueueItem] { queueItems.filter { $0.status == .uploading } }
private var queuedItems: [BackupQueueItem] { queueItems.filter { $0.status == .queued } }
private var completedItems: [BackupQueueItem] { queueItems.filter { $0.status == .uploaded || $0.status == .skipped } }
private var failedItems: [BackupQueueItem] { queueItems.filter { $0.status == .failed } }
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
if connection == nil {
noConnectionState
} else if isLoading && files.isEmpty {
loadingState
} else if let err = error {
errorState(err)
} else if files.isEmpty && !isLoading {
emptyState
} else {
fileList
contentList
}
}
.navigationTitle("Sync")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
nasStatusChip
}
// MARK: Main content
private var contentList: some View {
ScrollView {
LazyVStack(spacing: 0, pinnedViews: []) {
nasHeader
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 14)
Divider()
.padding(.horizontal, AppTheme.hPad)
if queueItems.isEmpty {
idleState
.padding(.top, 48)
} else {
queueContent
}
freeUpSpaceSection
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 32)
.padding(.bottom, 48)
}
}
.task { await loadFiles() }
.refreshable { await loadFiles() }
.refreshable {
// Pull-to-refresh: show latest queue from engine (already live)
}
}
// MARK: NAS header (no pill, no border)
private var nasHeader: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
if let conn = connection {
Text(conn.host)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppTheme.ink)
Text(conn.remotePath)
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
.lineLimit(1)
Text(conn.nasProtocol == .smb ? "SMB" : "SFTP")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
}
Spacer()
HStack(spacing: 5) {
Circle()
.fill(nasColor)
.frame(width: 6, height: 6)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable)
Text(nasLabel)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(nasColor)
}
}
}
private var nasColor: Color {
switch lanMonitor.nasReachable {
case true: AppTheme.positive
case false: AppTheme.destructive.opacity(0.8)
case nil: AppTheme.inkTertiary
}
}
private var nasLabel: String {
switch lanMonitor.nasReachable {
case true: "Online"
case false: "Offline"
case nil: "Checking…"
}
}
// MARK: Queue content
@ViewBuilder
private var queueContent: some View {
// Active / uploading
if !uploadingItems.isEmpty || !queuedItems.isEmpty {
sectionHeader("ACTIVE", count: uploadingItems.count + queuedItems.count)
ForEach(uploadingItems + queuedItems) { item in
QueueFileRow(item: item)
Divider().padding(.leading, 58)
}
}
// Failed
if !failedItems.isEmpty {
sectionHeader("FAILED", count: failedItems.count)
ForEach(failedItems) { item in
QueueFileRow(item: item)
Divider().padding(.leading, 58)
}
}
// Completed
if !completedItems.isEmpty {
sectionHeader("COMPLETED", count: completedItems.count)
ForEach(completedItems.prefix(30)) { item in
QueueFileRow(item: item)
Divider().padding(.leading, 58)
}
}
}
private func sectionHeader(_ title: String, count: Int) -> some View {
HStack {
Text(title)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(AppTheme.inkTertiary)
.kerning(0.8)
Spacer()
Text("\(count)")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 20)
.padding(.bottom, 6)
}
// MARK: States
private var idleState: some View {
VStack(spacing: 14) {
Image(systemName: statusService.snapshot.alreadySafe > 0 ? "checkmark.circle" : "arrow.up.doc")
.font(.system(size: 32, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary)
VStack(spacing: 5) {
Text(statusService.snapshot.alreadySafe > 0 ? "All caught up" : "No backup run yet")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
if statusService.snapshot.nasArchiveTotal > 0 {
Text("\(statusService.snapshot.nasArchiveTotal) files on NAS")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
}
private var noConnectionState: some View {
VStack(spacing: 16) {
Image(systemName: "externaldrive.badge.xmark")
@@ -49,184 +181,53 @@ struct SyncView: View {
Text("No NAS connected")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
Text("Connect to a NAS to see synced files")
Text("Connect to a NAS to see sync activity")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
private var loadingState: some View {
VStack(spacing: 12) {
ProgressView().tint(AppTheme.inkTertiary)
Text("Loading files…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
private func errorState(_ message: String) -> some View {
VStack(spacing: 20) {
ZStack {
Circle().fill(AppTheme.surfaceSunken).frame(width: 64, height: 64)
Image(systemName: "wifi.exclamationmark")
.font(.system(size: 24, weight: .light))
.foregroundStyle(AppTheme.inkSecondary)
}
VStack(spacing: 6) {
Text("Couldn't load files")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(AppTheme.ink)
Text(message)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
Button(action: { Task { await loadFiles() } }) {
HStack(spacing: 5) {
Image(systemName: "arrow.clockwise").font(.system(size: 12, weight: .medium))
Text("Retry").font(.system(size: 13, weight: .medium))
}
.foregroundStyle(AppTheme.ink)
.padding(.horizontal, 16).padding(.vertical, 8)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
}
}
private var emptyState: some View {
VStack(spacing: 14) {
Image(systemName: "checkmark.circle")
.font(.system(size: 32, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary)
VStack(spacing: 5) {
Text("Nothing backed up yet")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
if let conn = connection {
Text(conn.remotePath)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
}
// MARK: File list
private var fileList: some View {
ScrollView {
LazyVStack(spacing: 0) {
// Header strip
headerStrip
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 14)
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.horizontal, AppTheme.hPad)
ForEach(Array(files.enumerated()), id: \.element.id) { idx, file in
SyncFileRow(item: file)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 6)
.animation(
.spring(response: 0.38, dampingFraction: 0.82)
.delay(Double(min(idx, 12)) * 0.03),
value: appeared
)
if idx < files.count - 1 {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, 60)
}
}
// Free Up Space section
freeUpSpaceSection
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 28)
.padding(.bottom, 48)
}
}
.onAppear { appeared = true }
}
private var headerStrip: some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("\(files.count) files backed up")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppTheme.ink)
if let conn = connection {
Text(conn.remotePath)
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
.lineLimit(1)
}
}
Spacer()
HStack(spacing: 5) {
Circle()
.fill(reachableColor)
.frame(width: 6, height: 6)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable)
Text(reachableLabel)
.font(AppTheme.micro())
.foregroundStyle(reachableColor)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable)
}
}
}
// MARK: Free Up Space
private var freeUpSpaceSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("FREE UP SPACE")
Text("FREE UP SPACE")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(AppTheme.inkTertiary)
.kerning(0.8)
.padding(.horizontal, 2)
VStack(spacing: 0) {
// Explanation
HStack(spacing: 12) {
Image(systemName: "iphone.and.arrow.forward")
.font(.system(size: 14, weight: .regular))
.font(.system(size: 14))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text("Delete local copies after backup")
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Text("Photos and videos already on your NAS")
Text("Files already on your NAS")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
.padding(AppTheme.cardPad)
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, AppTheme.cardPad)
Divider().padding(.leading, AppTheme.cardPad)
// Retention picker
HStack {
Text("Delete after")
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
Spacer()
Menu {
Button("Never (keep all)") { store.localRetentionDays = 0 }
Button("After 60 days") { store.localRetentionDays = 60 }
Button("After 90 days") { store.localRetentionDays = 90 }
Button("After 1 year") { store.localRetentionDays = 365 }
Button("Never") { store.localRetentionDays = 0 }
Button("60 days") { store.localRetentionDays = 60 }
Button("90 days") { store.localRetentionDays = 90 }
Button("1 year") { store.localRetentionDays = 365 }
} label: {
HStack(spacing: 4) {
Text(retentionLabel)
@@ -238,172 +239,162 @@ struct SyncView: View {
}
}
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
if store.localRetentionDays > 0 {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
.padding(.leading, AppTheme.cardPad)
HStack(spacing: 8) {
Image(systemName: "info.circle")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
Text("Files will be removed from this device \(retentionLabel.lowercased()) they were successfully backed up. They remain on your NAS.")
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 10)
}
.padding(AppTheme.cardPad)
}
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.localRetentionDays > 0)
}
}
private var retentionLabel: String {
switch store.localRetentionDays {
case 0: return "Never"
case 60: return "60 days"
case 90: return "90 days"
case 365: return "1 year"
default: return "\(store.localRetentionDays) days"
case 0: "Never"
case 60: "60 days"
case 90: "90 days"
case 365: "1 year"
default: "\(store.localRetentionDays) days"
}
}
private var reachableColor: Color {
switch lanMonitor.nasReachable {
case true: return AppTheme.positive
case false: return AppTheme.destructive.opacity(0.7)
case nil: return AppTheme.inkQuaternary
}
}
private var reachableLabel: String {
switch lanMonitor.nasReachable {
case true: return "Online"
case false: return "Offline"
case nil: return "Checking…"
}
}
// MARK: Toolbar chip
private var nasStatusChip: some View {
HStack(spacing: 5) {
Circle()
.fill(lanMonitor.nasReachable == true ? AppTheme.positive : AppTheme.inkQuaternary)
.frame(width: 5, height: 5)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.nasReachable)
Text(connection?.host ?? "NAS")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
}
// MARK: Data
private func loadFiles() async {
guard let conn = connection else { return }
isLoading = true
error = nil
do {
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
try await s.connect(to: conn.host, port: conn.port,
username: conn.username, password: conn.password)
files = try await s.listDirectory(at: conn.remotePath)
.filter { !$0.isDirectory }
.sorted { ($0.modifiedDate ?? .distantPast) > ($1.modifiedDate ?? .distantPast) }
s.disconnect()
} catch let e as BackupError {
self.error = e.errorDescription
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
// MARK: File Row
// MARK: Per-file row
struct SyncFileRow: View {
let item: NASItem
struct QueueFileRow: View {
let item: BackupQueueItem
private var ext: String { (item.name as NSString).pathExtension.lowercased() }
private var ext: String { (item.filename as NSString).pathExtension.lowercased() }
private var fileIcon: String {
switch ext {
case "jpg", "jpeg", "png", "heic", "heif", "gif", "webp": return "photo"
case "mp4", "mov", "m4v", "avi": return "play.rectangle"
case "raw", "dng", "cr2", "nef", "arw": return "camera.aperture"
case "pdf": return "doc.richtext"
default: return "doc"
case "jpg","jpeg","png","heic","heif","gif","webp": "photo"
case "mp4","mov","m4v","avi": "play.rectangle"
case "raw","dng","cr2","nef","arw": "camera.aperture"
case "pdf": "doc.richtext"
default: "doc"
}
}
private var iconColor: Color {
switch ext {
case "jpg", "jpeg", "png", "heic", "heif", "gif", "webp": return AppTheme.interactive
case "mp4", "mov", "m4v", "avi": return AppTheme.destructive
case "raw", "dng", "cr2", "nef", "arw": return AppTheme.inkSecondary
default: return AppTheme.inkTertiary
case "jpg","jpeg","png","heic","heif","gif","webp": AppTheme.interactive
case "mp4","mov","m4v","avi": AppTheme.destructive
case "raw","dng","cr2","nef","arw": AppTheme.inkSecondary
default: AppTheme.inkTertiary
}
}
var body: some View {
HStack(spacing: 12) {
// File type icon
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(iconColor.opacity(0.1))
.frame(width: 38, height: 38)
Image(systemName: fileIcon)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(iconColor)
VStack(spacing: 0) {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(iconColor.opacity(0.1))
.frame(width: 38, height: 38)
Image(systemName: fileIcon)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(iconColor)
}
VStack(alignment: .leading, spacing: 3) {
Text(item.filename)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(AppTheme.ink)
.lineLimit(1)
statusRow
}
Spacer()
statusBadge
}
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 10)
// Name + meta
VStack(alignment: .leading, spacing: 3) {
Text(item.name)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.ink)
.lineLimit(1)
HStack(spacing: 6) {
if let date = item.modifiedDate {
Text(date, format: .dateTime.month(.abbreviated).day().year())
.foregroundStyle(AppTheme.inkTertiary)
// Progress bar for uploading
if item.status == .uploading && item.totalBytes > 0 {
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle()
.fill(AppTheme.surfaceSunken)
.frame(height: 2)
Rectangle()
.fill(AppTheme.interactive)
.frame(width: geo.size.width * CGFloat(item.progress), height: 2)
.animation(.linear(duration: 0.1), value: item.progress)
}
if item.size > 0 {
Text("·").foregroundStyle(AppTheme.inkQuaternary)
Text(formatBytes(item.size))
.foregroundStyle(AppTheme.inkTertiary)
}
.frame(height: 2)
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 8)
}
}
}
@ViewBuilder
private var statusRow: some View {
switch item.status {
case .uploading:
if item.totalBytes > 0 {
HStack(spacing: 4) {
Text("\(formatBytes(item.bytesUploaded)) / \(formatBytes(item.totalBytes))")
if item.speedBytesPerSec > 0 {
Text("·")
Text("\(formatBytes(Int64(item.speedBytesPerSec)))/s")
}
}
.font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
} else {
Text("Uploading…")
.font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
// Synced badge
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(AppTheme.positive.opacity(0.7))
case .queued:
Text("Queued")
.font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary)
case .uploaded, .skipped:
if let date = item.completedAt {
Text(date, format: .dateTime.month(.abbreviated).day().hour().minute())
.font(.system(size: 11)).foregroundStyle(AppTheme.inkTertiary)
}
case .failed:
Text(item.error?.message ?? "Upload failed")
.font(.system(size: 11))
.foregroundStyle(AppTheme.destructive.opacity(0.85))
.lineLimit(1)
}
}
@ViewBuilder
private var statusBadge: some View {
switch item.status {
case .uploading:
ProgressView()
.scaleEffect(0.7)
.tint(AppTheme.interactive)
case .queued:
Image(systemName: "clock")
.font(.system(size: 13))
.foregroundStyle(AppTheme.inkQuaternary)
case .uploaded:
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(AppTheme.positive.opacity(0.8))
case .skipped:
Image(systemName: "arrow.forward.circle")
.font(.system(size: 15))
.foregroundStyle(AppTheme.inkTertiary)
case .failed:
Image(systemName: "xmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(AppTheme.destructive.opacity(0.8))
}
.padding(.horizontal, AppTheme.hPad)
.padding(.vertical, 11)
}
private func formatBytes(_ bytes: Int64) -> String {
let gb = Double(bytes) / 1_073_741_824
if gb >= 1 { return String(format: "%.1f GB", gb) }
let mb = Double(bytes) / 1_048_576
if mb >= 1 { return String(format: "%.0f MB", mb) }
return String(format: "%.0f KB", Double(bytes) / 1024)
if mb < 1 { return "\(max(0, bytes / 1024)) KB" }
if mb < 1000 { return String(format: "%.1f MB", mb) }
return String(format: "%.1f GB", mb / 1024)
}
}