Files
Kisani/Core/Models/BackupJob.swift
Robin Kutesa 819f2b949e Fix backup state logic, ring colors, Already Safe invariant, and dedup
BackupJob: fix finish() — failedCount > 0 now correctly sets .failed
instead of .completed in both branches.

BackupEngine: replace filename-only fileExists check with a 3-layer
dedup strategy. For each asset: (1) check ManifestIndex by localIdentifier
O(1); (2) filename fallback for bootstrap manifests; (3) NAS fileExists
for files not yet in manifest. Never overwrite an existing NAS file.
Manifest index is built once after connect and held in a local let for
the loop — Data+BackupManifest are released immediately after.

BackupView — ring color:
  active (running/preparing/paused) → orange always
  failed → destructive
  needBackup == 0 && phoneTotal > 0 → green
  default → neutral gray
  Ring is no longer permanently green after .completed.

BackupView — CTA button:
  Default color is always black (AppTheme.ink).
  On .completed: briefly flashes green for 2.5 s then smoothly
  reverts to black. ctaFlashGreen state drives the color.

BackupView — Already Safe invariant:
  alreadySafeDisplayCount = min(base + uploadedFiles, phoneTotal)
  during active backup; min(base, phoneTotal) otherwise.
  Adding skippedFiles was removed — they are already counted in
  snapshot.alreadySafe from the last reconcile.
  notBackedUpDisplayCount is derived from alreadySafeDisplayCount to
  keep the invariant alreadySafe + needBackup == phoneTotal.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 15:30:52 +03:00

93 lines
2.8 KiB
Swift

import Foundation
import Combine
enum BackupStatus: Equatable {
case idle
case preparing
case running
case paused
case completed
case failed(String)
case cancelled
var isActive: Bool {
switch self {
case .running, .paused, .preparing: return true
default: return false
}
}
}
@MainActor
final class BackupJob: ObservableObject {
@Published private(set) var status: BackupStatus = .idle
@Published private(set) var totalFiles: Int = 0
@Published private(set) var uploadedFiles: Int = 0
@Published private(set) var skippedFiles: Int = 0
@Published private(set) var failedFiles: Int = 0
@Published private(set) var currentFileName: String = ""
@Published private(set) var currentFileSizeBytes: Int64 = 0
@Published private(set) var bytesTransferred: Int64 = 0
@Published private(set) var totalBytes: Int64 = 0
@Published private(set) var speedBytesPerSecond: Double = 0
@Published private(set) var startDate: Date?
@Published private(set) var endDate: Date?
var progress: Double {
guard totalFiles > 0 else { return 0 }
return min(1.0, Double(uploadedFiles + skippedFiles + failedFiles) / Double(totalFiles))
}
var eta: TimeInterval? {
guard speedBytesPerSecond > 0, totalBytes > bytesTransferred else { return nil }
return Double(totalBytes - bytesTransferred) / speedBytesPerSecond
}
func prepare() { status = .preparing }
func start(totalFiles: Int, totalBytes: Int64) {
self.totalFiles = totalFiles
self.totalBytes = totalBytes
self.uploadedFiles = 0
self.skippedFiles = 0
self.failedFiles = 0
self.bytesTransferred = 0
self.startDate = Date()
self.endDate = nil
self.status = .running
}
func updateProgress(fileName: String, fileSize: Int64, bytesTransferred: Int64, speed: Double) {
self.currentFileName = fileName
self.currentFileSizeBytes = fileSize
self.bytesTransferred = bytesTransferred
self.speedBytesPerSecond = speed
}
func fileCompleted(skipped: Bool = false) {
if skipped { skippedFiles += 1 } else { uploadedFiles += 1 }
}
func fileFailed() { failedFiles += 1 }
func finish(result: BackupResult) {
endDate = Date()
status = result.failedCount == 0
? .completed
: .failed("Completed with \(result.failedCount) error\(result.failedCount == 1 ? "" : "s")")
}
func fail(error: BackupError) {
endDate = Date()
status = .failed(error.errorDescription ?? error.localizedDescription)
}
func cancel() {
endDate = Date()
status = .cancelled
}
func pause() { if case .running = status { status = .paused } }
func resume() { if case .paused = status { status = .running } }
}