Fix AppMenuView Connection Status UX and add smart sync dashboard

- Wi-Fi row: title always "Wi-Fi", subtitle shows SSID or Cellular/Not connected;
  badge is Connected (green) / Cellular (amber) / Offline (muted) — no more
  contradictory "No Wi-Fi" title with "Wi-Fi connected" subtitle
- NAS badge: Reachable now amber (TCP ping only, not auth-confirmed), Offline red,
  Unknown muted — removes misleading green on unverified NAS connectivity
- Error rows are now tappable; chevron affordance + ScaleButtonStyle; opens detail
  sheet with date, counts, duration, NAS host, trigger type, and suggested fix steps
- Sync Activity section: Clear button with confirmation alert (warns about error count
  if errors exist), calls store.clearHistory()
- BackupViewModel: compareWithNAS() computes accurate pre-scan alreadySafeCount /
  notBackedUpCount by matching lowercased filenames against NAS directory listing
- BackupView: ring shows state-based content (idle: not-backed-up count, running: %,
  completed: checkmark), pill-style page indicator, display counts update live during
  backup run, loadNASAndCompare() called on appear

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-17 11:54:43 +03:00
parent 59bdad803f
commit a077964d75
3 changed files with 350 additions and 92 deletions

View File

@@ -5,10 +5,49 @@ struct AppMenuView: View {
@EnvironmentObject var lanMonitor: LANMonitor @EnvironmentObject var lanMonitor: LANMonitor
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showClearConfirm = false
@State private var selectedErrorEntry: BackupHistoryEntry?
private var errorEntries: [BackupHistoryEntry] { private var errorEntries: [BackupHistoryEntry] {
store.historyEntries.filter { $0.failedCount > 0 } store.historyEntries.filter { $0.failedCount > 0 }
} }
// Wi-Fi state helpers
private var wifiSubtitle: String {
if lanMonitor.isOnCellular { return "Cellular only" }
if lanMonitor.isOnNetwork { return lanMonitor.currentSSID ?? "Connected" }
return "Not connected"
}
private var wifiBadgeLabel: String {
if lanMonitor.isOnCellular { return "Cellular" }
if lanMonitor.isOnNetwork { return "Connected" }
return "Offline"
}
private var wifiBadgeColor: Color {
if lanMonitor.isOnCellular { return Color(red: 1.0, green: 0.58, blue: 0.0) }
if lanMonitor.isOnNetwork { return AppTheme.positive }
return AppTheme.inkQuaternary
}
// NAS badge TCP ping only, never green (not auth-confirmed)
private var nasBadgeLabel: String {
switch lanMonitor.nasReachable {
case true: return "Reachable"
case false: return "Offline"
default: return "Unknown"
}
}
private var nasBadgeColor: Color {
switch lanMonitor.nasReachable {
case true: return Color(red: 1.0, green: 0.58, blue: 0.0)
case false: return AppTheme.destructive
default: return AppTheme.inkQuaternary
}
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
@@ -38,6 +77,19 @@ struct AppMenuView: View {
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
} }
} }
.alert("Clear History", isPresented: $showClearConfirm) {
Button("Clear", role: .destructive) { store.clearHistory() }
Button("Cancel", role: .cancel) {}
} message: {
if errorEntries.isEmpty {
Text("All \(store.historyEntries.count) backup records will be removed.")
} else {
Text("All \(store.historyEntries.count) backup records will be removed, including \(errorEntries.count) error\(errorEntries.count == 1 ? "" : "s").")
}
}
.sheet(item: $selectedErrorEntry) { entry in
errorDetailSheet(entry: entry)
}
} }
} }
@@ -65,35 +117,29 @@ struct AppMenuView: View {
Spacer() Spacer()
statusBadge( statusBadge(label: nasBadgeLabel, color: nasBadgeColor)
label: lanMonitor.nasReachable == true ? "Reachable" : lanMonitor.nasReachable == false ? "Offline" : "Unknown",
color: lanMonitor.nasReachable == true ? AppTheme.positive : lanMonitor.nasReachable == false ? AppTheme.destructive : AppTheme.inkQuaternary
)
} }
.padding(.horizontal, AppTheme.cardPad) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 14) .padding(.vertical, 14)
menuHairline menuHairline
// Network row // Wi-Fi row title is always "Wi-Fi"; subtitle shows SSID or connection type
HStack(spacing: 12) { HStack(spacing: 12) {
iconBadge("wifi", color: AppTheme.inkSecondary) iconBadge("wifi", color: AppTheme.inkSecondary)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(lanMonitor.currentSSID ?? "No Wi-Fi") Text("Wi-Fi")
.font(AppTheme.body()) .font(AppTheme.body())
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
Text(lanMonitor.isOnCellular ? "Cellular" : lanMonitor.isOnNetwork ? "Wi-Fi connected" : "Offline") Text(wifiSubtitle)
.font(AppTheme.micro()) .font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
} }
Spacer() Spacer()
statusBadge( statusBadge(label: wifiBadgeLabel, color: wifiBadgeColor)
label: lanMonitor.isOnNetwork ? "Online" : "Offline",
color: lanMonitor.isOnNetwork ? AppTheme.positive : AppTheme.inkQuaternary
)
} }
.padding(.horizontal, AppTheme.cardPad) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 14) .padding(.vertical, 14)
@@ -123,7 +169,15 @@ struct AppMenuView: View {
private var syncActivitySection: some View { private var syncActivitySection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack {
menuSectionLabel("SYNC ACTIVITY") menuSectionLabel("SYNC ACTIVITY")
Spacer()
if !store.historyEntries.isEmpty {
Button("Clear") { showClearConfirm = true }
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppTheme.inkTertiary)
}
}
VStack(spacing: 0) { VStack(spacing: 0) {
let total = store.historyEntries.count let total = store.historyEntries.count
@@ -220,6 +274,9 @@ struct AppMenuView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(errorEntries.prefix(3).enumerated()), id: \.element.id) { idx, entry in ForEach(Array(errorEntries.prefix(3).enumerated()), id: \.element.id) { idx, entry in
Button {
selectedErrorEntry = entry
} label: {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 12, weight: .regular)) .font(.system(size: 12, weight: .regular))
@@ -233,10 +290,17 @@ struct AppMenuView: View {
.font(AppTheme.micro()) .font(AppTheme.micro())
.foregroundStyle(AppTheme.destructive.opacity(0.8)) .foregroundStyle(AppTheme.destructive.opacity(0.8))
} }
Spacer() Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
} }
.padding(.horizontal, AppTheme.cardPad) .padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12) .padding(.vertical, 12)
}
.buttonStyle(ScaleButtonStyle())
if idx < min(errorEntries.count, 3) - 1 { if idx < min(errorEntries.count, 3) - 1 {
menuHairline menuHairline
@@ -249,6 +313,100 @@ struct AppMenuView: View {
} }
} }
// MARK: Error Detail Sheet
@ViewBuilder
private func errorDetailSheet(entry: BackupHistoryEntry) -> some View {
NavigationStack {
ZStack {
AppTheme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: AppTheme.sectionGap) {
VStack(spacing: 0) {
detailRow(label: "Date", value: entry.date.formatted(.dateTime.month(.wide).day().year().hour().minute()))
menuHairline
detailRow(label: "Failed", value: "\(entry.failedCount) file\(entry.failedCount == 1 ? "" : "s")", valueColor: AppTheme.destructive)
menuHairline
detailRow(label: "Uploaded", value: "\(entry.uploadedCount) file\(entry.uploadedCount == 1 ? "" : "s")")
menuHairline
detailRow(label: "Skipped", value: "\(entry.skippedCount) file\(entry.skippedCount == 1 ? "" : "s")")
menuHairline
detailRow(label: "Duration", value: durationString(entry.durationSeconds))
menuHairline
detailRow(label: "NAS", value: entry.nasHost)
menuHairline
detailRow(label: "Triggered by", value: entry.triggeredByLAN ? "LAN (automatic)" : "Manual")
}
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
VStack(alignment: .leading, spacing: 8) {
menuSectionLabel("SUGGESTED FIX")
VStack(alignment: .leading, spacing: 10) {
fixRow(icon: "network", text: "Ensure the device is on the same network as the NAS.")
fixRow(icon: "internaldrive", text: "Check that the NAS has enough available storage space.")
fixRow(icon: "arrow.clockwise", text: "Retry the backup — transient network errors often resolve automatically.")
}
.padding(AppTheme.cardPad)
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
}
.padding(.horizontal, AppTheme.hPad)
.padding(.top, 8)
.padding(.bottom, 48)
}
}
.navigationTitle("Error Detail")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { selectedErrorEntry = nil }
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppTheme.ink)
}
}
}
}
private func detailRow(label: String, value: String, valueColor: Color = AppTheme.ink) -> some View {
HStack {
Text(label)
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkTertiary)
Spacer()
Text(value)
.font(AppTheme.body())
.foregroundStyle(valueColor)
.multilineTextAlignment(.trailing)
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 13)
}
private func fixRow(icon: String, text: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: icon)
.font(.system(size: 12, weight: .regular))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 16, height: 16)
.padding(.top, 1)
Text(text)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func durationString(_ seconds: Double) -> String {
if seconds < 60 { return "\(Int(seconds))s" }
let m = Int(seconds) / 60
let s = Int(seconds) % 60
return s == 0 ? "\(m)m" : "\(m)m \(s)s"
}
// MARK: About // MARK: About
private var aboutSection: some View { private var aboutSection: some View {

View File

@@ -109,7 +109,7 @@ struct BackupView: View {
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized) .animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
.task { .task {
vm.loadPhotoCount(filter: store.backupFilter) vm.loadPhotoCount(filter: store.backupFilter)
await loadNASCount() await loadNASAndCompare()
} }
} }
@@ -167,9 +167,9 @@ struct BackupView: View {
VStack(spacing: 4) { VStack(spacing: 4) {
ringCenterContent ringCenterContent
.transition(.opacity.combined(with: .scale(scale: 0.95))) .transition(.opacity.combined(with: .scale(scale: 0.95)))
.id(ringPage) .id(ringContentID)
} }
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringPage) .animation(.spring(response: 0.3, dampingFraction: 0.85), value: ringContentID)
} }
.gesture( .gesture(
DragGesture(minimumDistance: 20) DragGesture(minimumDistance: 20)
@@ -184,27 +184,32 @@ struct BackupView: View {
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress) .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 {
switch engine.job.status {
case .idle, .cancelled: return "\(ringPage)_idle"
case .preparing: return "\(ringPage)_preparing"
case .running: return "\(ringPage)_running"
case .paused: return "\(ringPage)_paused"
case .completed: return "\(ringPage)_done"
case .failed: return "\(ringPage)_failed"
}
}
@ViewBuilder @ViewBuilder
private var ringCenterContent: some View { private var ringCenterContent: some View {
switch ringPage { switch ringPage {
case 0: // Progress / status case 0:
VStack(spacing: 4) { ringMainView
Text(percentText)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
ringStatusLabel
}
case 1: // Pending case 1: // Not backed up
VStack(spacing: 4) { VStack(spacing: 4) {
Text("\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))") Text("\(notBackedUpDisplayCount)")
.font(.system(size: 44, weight: .semibold, design: .rounded)) .font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink) .foregroundStyle(AppTheme.ink)
.contentTransition(.numericText()) .contentTransition(.numericText())
.monospacedDigit() .monospacedDigit()
Text("pending") Text("not backed up")
.font(AppTheme.caption()) .font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
} }
@@ -221,66 +226,107 @@ struct BackupView: View {
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
} }
default: // Completed default: // Backed up
VStack(spacing: 4) { VStack(spacing: 4) {
Text("\(engine.job.uploadedFiles)") Text("\(engine.job.uploadedFiles)")
.font(.system(size: 44, weight: .semibold, design: .rounded)) .font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.positive) .foregroundStyle(AppTheme.positive)
.contentTransition(.numericText()) .contentTransition(.numericText())
.monospacedDigit() .monospacedDigit()
Text("uploaded") Text("backed up")
.font(AppTheme.caption()) .font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
} }
} }
} }
// Page 0 content changes based on backup state
@ViewBuilder @ViewBuilder
private var ringStatusLabel: some View { private var ringMainView: some View {
switch engine.job.status { switch engine.job.status {
case .running: case .idle, .cancelled:
VStack(spacing: 2) { if vm.totalPhotoCount > 0 && vm.notBackedUpCount == 0 {
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)") // All phone photos already exist in NAS
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("\(vm.notBackedUpCount)")
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text(vm.totalPhotoCount == 0 ? "No photos found" : "not backed up")
.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("\(engine.job.uploadedFiles) of \(engine.job.totalFiles) backed up")
.font(AppTheme.caption()) .font(AppTheme.caption())
.foregroundStyle(AppTheme.inkSecondary) .foregroundStyle(AppTheme.inkSecondary)
if let eta = engine.job.eta { if let eta = engine.job.eta {
Text("~\(etaString(eta)) remaining") Text("~\(etaString(eta)) remaining")
.font(.system(size: 11, weight: .regular)) .font(.system(size: 11, weight: .regular))
.foregroundStyle(AppTheme.inkTertiary) .foregroundStyle(AppTheme.inkTertiary)
} .padding(.top, 1)
}
case .preparing:
Text("Preparing…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
case .completed:
Text("Done")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.positive)
case .failed:
Text("Failed")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.destructive)
case .cancelled:
Text("Cancelled")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
default:
Text(vm.totalPhotoCount > 0 ? "\(vm.totalPhotoCount) photos ready" : "Ready")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
} }
} }
// MARK: Page indicator dots 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 { private var pageIndicator: some View {
HStack(spacing: 5) { HStack(spacing: 5) {
ForEach(0..<ringPageCount, id: \.self) { i in ForEach(0..<ringPageCount, id: \.self) { i in
Circle() Capsule(style: .continuous)
.fill(i == ringPage ? AppTheme.ink : AppTheme.inkQuaternary) .fill(i == ringPage ? AppTheme.interactive : AppTheme.inkQuaternary)
.frame(width: i == ringPage ? 5 : 4, height: i == ringPage ? 5 : 4) .frame(width: i == ringPage ? 18 : 5, height: 5)
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: ringPage) .animation(.spring(response: 0.3, dampingFraction: 0.75), value: ringPage)
} }
} }
} }
@@ -291,7 +337,7 @@ struct BackupView: View {
case .failed: return AppTheme.destructive case .failed: return AppTheme.destructive
case .running, .paused, case .running, .paused,
.preparing: return Color(red: 1.0, green: 0.58, blue: 0.0) .preparing: return Color(red: 1.0, green: 0.58, blue: 0.0)
default: return AppTheme.inkTertiary.opacity(0.35) default: return AppTheme.interactive.opacity(0.28)
} }
} }
@@ -299,35 +345,59 @@ struct BackupView: View {
// MARK: Compact stats strip // MARK: Compact stats strip
// Photos from the phone set that are NOT yet in the NAS.
// Idle: derived from NAS comparison scan.
// Active: live remaining from engine job counters.
private var notBackedUpDisplayCount: Int {
switch engine.job.status {
case .running, .paused, .preparing:
return max(0, engine.job.totalFiles - engine.job.uploadedFiles
- engine.job.skippedFiles - engine.job.failedFiles)
case .completed: return 0
default: return vm.notBackedUpCount
}
}
// Photos from the phone set that ARE already safe in the NAS.
// Grows during backup as uploads + engine-skipped files are confirmed present.
private var alreadySafeDisplayCount: Int {
switch engine.job.status {
case .running, .paused, .preparing, .completed:
return vm.alreadySafeCount + engine.job.uploadedFiles + engine.job.skippedFiles
default:
return vm.alreadySafeCount
}
}
private var statsStrip: some View { private var statsStrip: some View {
VStack(spacing: 8) { VStack(spacing: 7) {
HStack(spacing: 0) { HStack(spacing: 0) {
compactStat( compactStat(
nasFileCount.map { "\($0)" } ?? "", nasFileCount.map { "\($0)" } ?? "",
label: "in NAS", label: "In NAS",
color: AppTheme.interactive color: AppTheme.interactive
) )
thinDivider thinDivider
compactStat( compactStat(
"\(vm.totalPhotoCount)", "\(vm.totalPhotoCount)",
label: "on iPhone", label: "On iPhone",
color: AppTheme.inkSecondary color: AppTheme.inkSecondary
) )
thinDivider thinDivider
compactStat( compactStat(
"\(engine.job.uploadedFiles)", "\(notBackedUpDisplayCount)",
label: "uploaded", label: "Not Backed Up",
color: AppTheme.inkSecondary
)
thinDivider
compactStat(
"\(alreadySafeDisplayCount)",
label: "Already Safe",
color: AppTheme.positive color: AppTheme.positive
) )
thinDivider
compactStat(
"\(max(0, engine.job.totalFiles - engine.job.uploadedFiles - engine.job.skippedFiles - engine.job.failedFiles))",
label: "left",
color: AppTheme.inkSecondary
)
} }
.frame(height: 56) .frame(height: 48)
.background(AppTheme.surfaceSunken) .background(AppTheme.surfaceSunken.opacity(0.85))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
pageIndicator pageIndicator
@@ -335,26 +405,27 @@ struct BackupView: View {
} }
private func compactStat(_ value: String, label: String, color: Color) -> some View { private func compactStat(_ value: String, label: String, color: Color) -> some View {
VStack(spacing: 3) { VStack(spacing: 2) {
Text(value) Text(value)
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(color) .foregroundStyle(color)
.monospacedDigit() .monospacedDigit()
Text(label) Text(label)
.font(.system(size: 9, weight: .regular)) .font(.system(size: 9, weight: .regular))
.foregroundStyle(AppTheme.inkQuaternary) .foregroundStyle(AppTheme.inkQuaternary)
.tracking(0.2)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 8) .padding(.vertical, 6)
} }
private var thinDivider: some View { private var thinDivider: some View {
Rectangle() Rectangle()
.fill(AppTheme.separator.opacity(0.7)) .fill(AppTheme.separator.opacity(0.5))
.frame(width: 0.5, height: 22) .frame(width: 0.5, height: 18)
} }
private func loadNASCount() async { private func loadNASAndCompare() async {
guard let conn = store.savedConnection else { return } guard let conn = store.savedConnection else { return }
do { do {
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
@@ -362,9 +433,11 @@ struct BackupView: View {
username: conn.username, password: conn.password) username: conn.username, password: conn.password)
let items = try await s.listDirectory(at: conn.remotePath) let items = try await s.listDirectory(at: conn.remotePath)
nasFileCount = items.filter { !$0.isDirectory }.count nasFileCount = items.filter { !$0.isDirectory }.count
vm.compareWithNAS(nasItems: items)
s.disconnect() s.disconnect()
} catch { } catch {
nasFileCount = nil nasFileCount = nil
// On failure: notBackedUpCount stays at totalPhotoCount (conservative show all as pending)
} }
} }

View File

@@ -4,13 +4,40 @@ import Photos
@MainActor @MainActor
final class BackupViewModel: ObservableObject { final class BackupViewModel: ObservableObject {
@Published var totalPhotoCount: Int = 0 @Published var totalPhotoCount: Int = 0
@Published var alreadySafeCount: Int = 0
@Published var notBackedUpCount: Int = 0
@Published var photosAuthStatus: PHAuthorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) @Published var photosAuthStatus: PHAuthorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
private let photoService = PhotoLibraryService() private let photoService = PhotoLibraryService()
private var fetchedAssets: [PhotoAsset] = []
func loadPhotoCount(filter: BackupFilter) { func loadPhotoCount(filter: BackupFilter) {
let assets = photoService.fetchAssets(filter: filter) let assets = photoService.fetchAssets(filter: filter)
fetchedAssets = assets
totalPhotoCount = assets.count totalPhotoCount = assets.count
// Conservative initial state until NAS scan completes
alreadySafeCount = 0
notBackedUpCount = assets.count
}
// Called after NAS directory listing loads.
// Matches by lowercased filename same heuristic BackupEngine uses for skip checks.
// This gives an accurate pre-scan "Already Safe / Not Backed Up" split before
// the user ever taps Start Backup.
func compareWithNAS(nasItems: [NASItem]) {
let nasFilenames = Set(
nasItems
.filter { !$0.isDirectory }
.map { $0.name.lowercased() }
)
var safe = 0
for asset in fetchedAssets {
if nasFilenames.contains(asset.filename.lowercased()) {
safe += 1
}
}
alreadySafeCount = safe
notBackedUpCount = totalPhotoCount - safe
} }
func requestPhotosAccess() async { func requestPhotosAccess() async {