BackupEngine: after all retries fail, call fileExists() before recording a failure. If the file is on NAS (uploaded earlier but fileExists silently threw on the pre-check), count it as skipped and add it to manifestEntries — this ensures writeManifest includes every safe asset and prevents alreadySafe from dropping to 1 after the post-backup reconcile. BackupView: remove the immediate refreshAndWait from onChange(.failed). That call raced with refreshAfterBackup's writeManifest, cancelled the manifest- correct refresh, then reconciled against a stale 1-entry NAS manifest. Auto-resolve (.failed → .completed when needBackup == 0) is now owned by the coordinator so it runs after writeManifest finishes. AutoBackupCoordinator: add backupTriggerArmed flag (set by onActive/ handleNASReachable, cleared by handleReconciliationComplete). Post-backup reconciles from refreshAfterBackup are no longer able to re-trigger auto- backup — only an explicit app-open or LAN-join can arm the trigger. Also added auto-resolve: calls engine.resolveSuccess() when job is .failed but needBackup == 0, which fires the .completed green-flash path in BackupView. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
765 lines
32 KiB
Swift
765 lines
32 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)
|
|
}
|
|
}
|
|
// .failed auto-resolve is owned by AutoBackupCoordinator.handleReconciliationComplete.
|
|
// It waits for refreshAfterBackup's writeManifest to finish before reconciling, then
|
|
// calls engine.resolveSuccess() when needBackup == 0, which re-triggers .completed above.
|
|
// Calling refreshAndWait here would race with writeManifest and read a stale NAS manifest.
|
|
}
|
|
}
|
|
|
|
// 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).
|
|
// Live NAS Archive: grows by 1 with each confirmed upload this session.
|
|
// Skipped files (already on NAS) are NOT added — if nasArchiveTotal came from
|
|
// a directory scan they're already counted; if from manifest they weren't tracked
|
|
// but they're present on disk regardless.
|
|
private var nasArchiveDisplayCount: Int {
|
|
let base = statusService.snapshot.nasArchiveTotal
|
|
if engine.job.status.isActive {
|
|
return base + engine.job.uploadedFiles
|
|
}
|
|
return base
|
|
}
|
|
|
|
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 {
|
|
// Uploaded = newly confirmed on NAS this session.
|
|
// Skipped = fileExists returned true — already on NAS but wasn't in manifest.
|
|
// Both are now confirmed safe; count them both optimistically.
|
|
return min(base + engine.job.uploadedFiles + engine.job.skippedFiles, total)
|
|
}
|
|
return min(base, total)
|
|
}
|
|
|
|
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(
|
|
"\(nasArchiveDisplayCount)",
|
|
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(statusService.refreshError ?? "Unreachable")
|
|
.font(.system(size: 9, weight: .regular))
|
|
.foregroundStyle(AppTheme.destructive.opacity(0.7))
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.trailing)
|
|
} 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 {
|
|
// Coordinator is running a status check — block the start button so
|
|
// a backup cannot be triggered before reconciliation completes.
|
|
if coordinator.phase == .checking {
|
|
HStack(spacing: 8) {
|
|
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
|
|
Text("Checking status…")
|
|
.font(AppTheme.body())
|
|
.foregroundStyle(AppTheme.inkSecondary)
|
|
}
|
|
.frame(height: 50)
|
|
} else {
|
|
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)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.82), value: coordinator.phase == .checking)
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|