Files
Kisani/Features/Backup/BackupView.swift
Robin Kutesa d2190bce8a Fix backup loop, NAS Archive regression, and manifest validity check
- AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task
  and scenePhase triggers (navigation appear, rapid foreground cycles) are
  no-ops; start a 60s periodic recheck timer on first active call; stop timer
  and reset debounce on willResignActiveNotification so each new foreground
  session always gets an immediate fresh status check

- BackupView: remove the redundant lanMonitor.nasReachable onChange that was
  calling statusService.refresh() directly — coordinator already handles this
  via its Combine $nasReachable subscriber, avoiding a double reconcile that
  could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value

- BackupStatusService: when NAS connect fails, restore nasArchiveTotal from
  NASManifestCache before writing the offline snapshot — prevents the count
  from showing 0 when the NAS is temporarily unreachable but the cache is warm

- BackupStatusService: manifest validity check — if the decoded manifest has
  ≤ 1 entries (corrupt or newly created), scan the NAS directory and use the
  real file count as the display floor for nasArchiveTotal without adding
  orphan entries to the manifest (that was the 7421 regression)

- BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for
  the validity check path — keeps localIdentifier matching intact while
  correcting the displayed archive count

- SMBService: add os.log around connect/auth/listDirectory with actual error
  reason instead of always surfacing authenticationFailed; distinguish network
  errors (timeout, host unreachable) from auth errors in thrown BackupError

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 16:07:41 +03:00

742 lines
30 KiB
Swift

import SwiftUI
import Photos
struct BackupView: View {
@EnvironmentObject var engine: BackupEngine
@EnvironmentObject var store: ConnectionStore
@EnvironmentObject var lanMonitor: LANMonitor
@EnvironmentObject var statusService: BackupStatusService
@EnvironmentObject var coordinator: AutoBackupCoordinator
@StateObject private var vm = BackupViewModel()
@Environment(\.scenePhase) private var scenePhase
@State private var showMenu = false
@State private var showDestinationPicker = false
@State private var pickerPath: String = "/"
@State private var ringPage = 0
@State private var ctaFlashGreen = false
private let ringPageCount = 4
// Counts are the source of truth for final display state.
// If job ended in .failed but reconciliation shows needBackup == 0, treat as .completed.
private var resolvedStatus: BackupStatus {
if case .failed = engine.job.status,
!statusService.isRefreshing,
statusService.snapshot.phoneTotal > 0,
statusService.snapshot.needBackup == 0 {
return .completed
}
return engine.job.status
}
var body: some View {
ZStack(alignment: .topTrailing) {
AppTheme.background.ignoresSafeArea()
// Scrollable content with pull-to-refresh
GeometryReader { geo in
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
// Brand
VStack(spacing: 10) {
Text("kisani.")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(AppTheme.ink)
.kerning(2)
KisaniLogoMark(size: 66)
}
.frame(maxWidth: .infinity)
.padding(.top, 16)
Spacer(minLength: 24).frame(maxHeight: 48)
// Ring
ringHero
.padding(.bottom, 44)
// Stats
statsStrip
.padding(.horizontal, AppTheme.hPad)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
Spacer(minLength: 20).frame(maxHeight: 48)
// Storage card
nasStatusRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
// Destination card
destinationRow
.padding(.horizontal, AppTheme.hPad)
Spacer(minLength: 20).frame(maxHeight: 32)
// Photos access nudge
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
photosAccessRow
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 8)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// CTA
actionButton
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 24)
Spacer(minLength: 0)
}
.frame(minHeight: geo.size.height)
}
.refreshable {
await statusService.refreshAndWait(force: true)
if statusService.snapshot.needBackup == 0 {
engine.resolveSuccess()
}
// handleReconciliationComplete() fires via Combine and will
// start auto-backup if needed no explicit call required here.
}
}
// Menu button floats above scroll content
Button { showMenu = true } label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 14, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.accessibilityLabel("Activity menu")
.padding(.top, 4)
.padding(.trailing, AppTheme.hPad - 4)
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $showMenu) {
AppMenuView()
.environmentObject(store)
.environmentObject(lanMonitor)
.environmentObject(engine)
}
.sheet(isPresented: $showDestinationPicker, onDismiss: {
guard pickerPath != "/", var conn = store.savedConnection else { return }
conn.remotePath = pickerPath
store.savedConnection = conn
}) {
if let conn = store.savedConnection {
BrowseView(connection: conn, selectedPath: $pickerPath, isPickerMode: true, initialPath: pickerPath)
}
}
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed)
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
.task {
// Kick off reconciliation and auto-backup on first appear.
coordinator.onActive()
}
.onChange(of: scenePhase) { phase in
// Re-check every time the app returns to the foreground.
if phase == .active { coordinator.onActive() }
}
.onChange(of: store.savedConnection?.remotePath) { _ in
statusService.refresh(force: true)
}
.onChange(of: engine.job.status) { status in
if status == .completed {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
Task {
try? await Task.sleep(nanoseconds: 2_500_000_000)
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
try? await Task.sleep(nanoseconds: 500_000_000)
statusService.refresh(force: true)
}
} else if case .failed = status {
// Reconcile if all files landed on NAS despite the failure, auto-resolve to .completed
Task {
await statusService.refreshAndWait(force: true)
if statusService.snapshot.needBackup == 0 {
engine.resolveSuccess()
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
try? await Task.sleep(nanoseconds: 2_500_000_000)
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
}
}
}
}
}
// MARK: Logo mark (drawn in code no asset dependency)
// MARK: Ring hero
private var ringHero: some View {
ZStack {
ProgressRing(
progress: engine.job.progress,
lineWidth: 8,
size: 206,
color: ringColor,
backgroundColor: AppTheme.inkQuaternary.opacity(0.6)
)
.shadow(
color: ringColor.opacity(engine.job.status.isActive ? 0.16 : 0),
radius: 22, x: 0, y: 0
)
.animation(.easeInOut(duration: 0.5), value: engine.job.status.isActive)
.accessibilityLabel("Backup progress")
.accessibilityValue("\(Int(engine.job.progress * 100)) percent")
VStack(spacing: 4) {
ringCenterContent
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.id(ringContentID)
}
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringContentID)
}
.gesture(
DragGesture(minimumDistance: 20)
.onEnded { value in
if value.translation.width < 0 {
ringPage = (ringPage + 1) % ringPageCount
} else {
ringPage = (ringPage - 1 + ringPageCount) % ringPageCount
}
}
)
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
}
// ID that changes on both page swipe and status transition drives the ring animation
private var ringContentID: String {
let enginePart: String
switch resolvedStatus {
case .idle, .cancelled: enginePart = "idle"
case .preparing: enginePart = "prep"
case .running: enginePart = "run"
case .paused: enginePart = "pause"
case .completed: enginePart = "done"
case .failed: enginePart = "fail"
}
// Include coordinator phase so ring animates on checking/disconnected transitions
let coordSuffix: String
switch coordinator.phase {
case .checking: coordSuffix = "_chk"
case .disconnected: coordSuffix = "_disc"
case .autoBackingUp: coordSuffix = "_auto"
case .idle: coordSuffix = ""
}
return "\(ringPage)_\(enginePart)\(coordSuffix)"
}
@ViewBuilder
private var ringCenterContent: some View {
switch ringPage {
case 0:
// Engine activity always takes priority.
// Coordinator states are shown only when the engine is idle.
if engine.job.status.isActive {
ringMainView
} else if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 {
disconnectedRingView
} else if coordinator.phase == .checking {
checkingRingView
} else {
ringMainView
}
case 1: // Need backup
VStack(spacing: 4) {
Text("\(notBackedUpDisplayCount)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text("need backup")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
case 2: // Failed
VStack(spacing: 4) {
Text("\(engine.job.failedFiles)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text("failed")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
default: // Backed up
VStack(spacing: 4) {
Text("\(engine.job.uploadedFiles)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.positive)
.contentTransition(.numericText())
.monospacedDigit()
Text("backed up")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
// Page 0 content changes based on backup state
@ViewBuilder
private var ringMainView: some View {
switch resolvedStatus {
case .idle, .cancelled:
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
VStack(spacing: 8) {
Image(systemName: "checkmark")
.font(.system(size: 30, weight: .medium))
.foregroundStyle(AppTheme.positive)
Text("All photos safe")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
} else {
VStack(spacing: 6) {
Text("\(statusService.snapshot.needBackup)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text(statusService.snapshot.phoneTotal == 0 ? "No photos found" : "need backup")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
case .preparing:
VStack(spacing: 10) {
ProgressView()
.scaleEffect(0.85)
.tint(AppTheme.inkTertiary)
Text("Scanning library…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
case .running, .paused:
VStack(spacing: 5) {
Text(percentText)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text("Backing up \(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
if let eta = engine.job.eta {
Text("~\(etaString(eta)) remaining")
.font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary)
.padding(.top, 1)
}
}
case .completed:
VStack(spacing: 8) {
Image(systemName: "checkmark")
.font(.system(size: 30, weight: .medium))
.foregroundStyle(AppTheme.positive)
Text("\(engine.job.uploadedFiles) photos backed up")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
case .failed:
VStack(spacing: 8) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 30, weight: .light))
.foregroundStyle(AppTheme.destructive)
Text("Backup failed")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.destructive.opacity(0.8))
}
}
}
// MARK: Page indicator
private var pageIndicator: some View {
HStack(spacing: 5) {
ForEach(0..<ringPageCount, id: \.self) { i in
Capsule(style: .continuous)
.fill(i == ringPage ? AppTheme.interactive : AppTheme.inkQuaternary)
.frame(width: i == ringPage ? 18 : 5, height: 5)
.animation(.spring(response: 0.3, dampingFraction: 0.75), value: ringPage)
}
}
}
// MARK: Coordinator ring states (shown when engine is idle)
@ViewBuilder
private var checkingRingView: some View {
VStack(spacing: 10) {
ProgressView()
.scaleEffect(0.85)
.tint(AppTheme.inkTertiary)
Text("Checking…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
@ViewBuilder
private var disconnectedRingView: some View {
VStack(spacing: 8) {
Image(systemName: "wifi.slash")
.font(.system(size: 26, weight: .light))
.foregroundStyle(Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.75))
Text("Waiting for NAS")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
if coordinator.pendingAtDisconnect > 0 {
Text("\(coordinator.pendingAtDisconnect) queued")
.font(AppTheme.micro(11))
.foregroundStyle(AppTheme.inkTertiary)
.contentTransition(.numericText())
}
}
}
private var ringColor: Color {
if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
if case .failed = resolvedStatus { return AppTheme.destructive }
// Disconnected with pending items: dim orange pulse
if coordinator.phase == .disconnected, statusService.snapshot.needBackup > 0 {
return Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.35)
}
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
return AppTheme.positive
}
return AppTheme.inkQuaternary.opacity(0.55)
}
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
// MARK: Compact stats strip
// Invariant: alreadySafe + needBackup == phoneTotal always.
// During active backup show optimistic count (only newly uploaded, not skipped
// skipped files are already counted inside snapshot.alreadySafe from the last reconcile).
private var alreadySafeDisplayCount: Int {
let total = statusService.snapshot.phoneTotal
guard total > 0 else { return 0 }
let base = statusService.snapshot.alreadySafe
if engine.job.status.isActive {
return min(base + engine.job.uploadedFiles, total)
}
return min(base, total) // enforce invariant at all times
}
private var notBackedUpDisplayCount: Int {
let total = statusService.snapshot.phoneTotal
guard total > 0 else { return 0 }
if engine.job.status.isActive {
return max(0, total - alreadySafeDisplayCount)
}
return statusService.snapshot.needBackup
}
private var statsStrip: some View {
VStack(spacing: 7) {
HStack(spacing: 0) {
compactStat(
"\(statusService.snapshot.nasArchiveTotal)",
label: "NAS Archive",
color: AppTheme.interactive
)
thinDivider
compactStat(
"\(statusService.snapshot.phoneTotal)",
label: "On iPhone",
color: AppTheme.inkSecondary
)
thinDivider
compactStat(
"\(notBackedUpDisplayCount)",
label: "Need Backup",
color: AppTheme.inkSecondary
)
thinDivider
compactStat(
"\(alreadySafeDisplayCount)",
label: "Already Safe",
color: AppTheme.positive
)
}
.frame(height: 48)
.background(AppTheme.surfaceSunken.opacity(0.85))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
pageIndicator
// Subtle refresh status
HStack(spacing: 5) {
if statusService.isRefreshing {
ProgressView()
.scaleEffect(0.5)
.tint(AppTheme.inkQuaternary)
Text("Checking…")
.font(.system(size: 10, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
} else if let checked = statusService.snapshot.lastCheckedAt {
Image(systemName: statusService.snapshot.connectionState == .offline
? "wifi.slash" : "checkmark.circle")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(statusService.snapshot.connectionState == .offline
? .orange : AppTheme.inkQuaternary)
Text(checked, style: .relative)
.font(.system(size: 10, weight: .regular))
.foregroundColor(AppTheme.inkQuaternary)
+ Text(" ago")
.font(.system(size: 10, weight: .regular))
.foregroundColor(AppTheme.inkQuaternary)
}
Spacer()
Button {
statusService.refresh(force: true)
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
}
}
.frame(height: 16)
}
}
private func compactStat(_ value: String, label: String, color: Color) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(color)
.monospacedDigit()
Text(label)
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary)
.tracking(0.2)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
private var thinDivider: some View {
Rectangle()
.fill(AppTheme.separator.opacity(0.5))
.frame(width: 0.5, height: 18)
}
// MARK: Destination row
private var destinationRow: some View {
Button(action: {
pickerPath = store.savedConnection?.remotePath ?? "/"
showDestinationPicker = true
}) {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 34, height: 34)
Image(systemName: "folder.fill")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
let path = store.savedConnection?.remotePath ?? "/"
let folderName = path == "/" ? "Not set" : (path as NSString).lastPathComponent
VStack(alignment: .leading, spacing: 1) {
Text(folderName)
.font(AppTheme.headline())
.foregroundStyle(path == "/" ? AppTheme.inkTertiary : AppTheme.ink)
Text("Backup folder")
.font(AppTheme.micro(10))
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
.disabled(store.savedConnection == nil)
}
private var nasStatusDotColor: Color {
switch engine.job.status {
case .running, .preparing, .paused:
return Color(red: 1.0, green: 0.58, blue: 0.0)
default:
switch lanMonitor.nasReachable {
case true: return AppTheme.positive
case false: return AppTheme.destructive
case nil: return AppTheme.inkQuaternary
}
}
}
private var nasStatusLabel: String {
switch engine.job.status {
case .running: return "Backing up"
case .preparing: return "Preparing"
case .paused: return "Paused"
default:
return lanMonitor.nasReachable == false ? "Offline" : "Ready"
}
}
// MARK: NAS status row
private var nasStatusRow: some View {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
.frame(width: 34, height: 34)
Image(systemName: "externaldrive")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
}
if let conn = store.savedConnection {
let friendlyName = conn.displayName == conn.host ? "Home Server" : conn.displayName
VStack(alignment: .leading, spacing: 1) {
Text(friendlyName)
.font(AppTheme.headline())
.foregroundStyle(AppTheme.ink)
Text("\(conn.nasProtocol.rawValue) · \(conn.host)")
.font(AppTheme.micro(10))
.foregroundStyle(AppTheme.inkTertiary)
}
} else {
Text("No storage connected")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
HStack(spacing: 4) {
Circle()
.fill(nasStatusDotColor)
.frame(width: 6, height: 6)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusDotColor == AppTheme.positive)
Text(nasStatusLabel)
.font(AppTheme.micro())
.foregroundStyle(nasStatusDotColor)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: nasStatusLabel)
}
if lanMonitor.nasReachable == false {
Text("Unreachable")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.destructive.opacity(0.7))
} else if lanMonitor.nasReachable == true && !engine.job.status.isActive {
Text("Connected")
.font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.positive.opacity(0.7))
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
}
// MARK: Photos access nudge
private var photosAccessRow: some View {
Button(action: { Task { await vm.requestPhotosAccess() } }) {
HStack(spacing: 10) {
Image(systemName: "photo.on.rectangle")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.destructive)
.frame(width: 20)
Text("Grant photo library access to enable backup")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(AppTheme.destructive.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
}
// MARK: Action button
private var actionButton: some View {
Group {
switch resolvedStatus {
case .idle, .completed, .failed, .cancelled:
Button(action: startBackup) {
Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
}
.buttonStyle(PrimaryButtonStyle(
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
))
.accessibilityHint("Starts backing up your photos to the NAS")
case .running:
Button { engine.pause() } label: { Text("Pause") }
.buttonStyle(GhostButtonStyle())
.accessibilityLabel("Pause backup")
case .paused:
HStack(spacing: 10) {
Button { engine.resume() } label: { Text("Resume") }
.buttonStyle(PrimaryButtonStyle())
.accessibilityLabel("Resume backup")
Button { engine.cancel() } label: { Text("Cancel") }
.buttonStyle(GhostButtonStyle())
.frame(width: 90)
.accessibilityLabel("Cancel backup")
}
case .preparing:
HStack(spacing: 8) {
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
Text("Preparing…")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
}
.frame(height: 50)
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running)
}
private func startBackup() {
guard let conn = store.savedConnection else { return }
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
}
private func etaString(_ seconds: TimeInterval) -> String {
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
}
}