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

@@ -109,7 +109,7 @@ struct BackupView: View {
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
.task {
vm.loadPhotoCount(filter: store.backupFilter)
await loadNASCount()
await loadNASAndCompare()
}
}
@@ -167,9 +167,9 @@ struct BackupView: View {
VStack(spacing: 4) {
ringCenterContent
.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(
DragGesture(minimumDistance: 20)
@@ -184,27 +184,32 @@ struct BackupView: View {
.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
private var ringCenterContent: some View {
switch ringPage {
case 0: // Progress / status
VStack(spacing: 4) {
Text(percentText)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
ringStatusLabel
}
case 0:
ringMainView
case 1: // Pending
case 1: // Not backed up
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))
.foregroundStyle(AppTheme.ink)
.contentTransition(.numericText())
.monospacedDigit()
Text("pending")
Text("not backed up")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
@@ -221,66 +226,107 @@ struct BackupView: View {
.foregroundStyle(AppTheme.inkTertiary)
}
default: // Completed
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("uploaded")
Text("backed up")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
}
// Page 0 content changes based on backup state
@ViewBuilder
private var ringStatusLabel: some View {
private var ringMainView: some View {
switch engine.job.status {
case .running:
VStack(spacing: 2) {
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
case .idle, .cancelled:
if vm.totalPhotoCount > 0 && vm.notBackedUpCount == 0 {
// 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())
.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 .preparing:
Text("Preparing…")
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
case .completed:
Text("Done")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.positive)
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:
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)
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 dots
// MARK: Page indicator
private var pageIndicator: some View {
HStack(spacing: 5) {
ForEach(0..<ringPageCount, id: \.self) { i in
Circle()
.fill(i == ringPage ? AppTheme.ink : AppTheme.inkQuaternary)
.frame(width: i == ringPage ? 5 : 4, height: i == ringPage ? 5 : 4)
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: ringPage)
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)
}
}
}
@@ -291,7 +337,7 @@ struct BackupView: View {
case .failed: return AppTheme.destructive
case .running, .paused,
.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
// 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 {
VStack(spacing: 8) {
VStack(spacing: 7) {
HStack(spacing: 0) {
compactStat(
nasFileCount.map { "\($0)" } ?? "",
label: "in NAS",
label: "In NAS",
color: AppTheme.interactive
)
thinDivider
compactStat(
"\(vm.totalPhotoCount)",
label: "on iPhone",
label: "On iPhone",
color: AppTheme.inkSecondary
)
thinDivider
compactStat(
"\(engine.job.uploadedFiles)",
label: "uploaded",
"\(notBackedUpDisplayCount)",
label: "Not Backed Up",
color: AppTheme.inkSecondary
)
thinDivider
compactStat(
"\(alreadySafeDisplayCount)",
label: "Already Safe",
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)
.background(AppTheme.surfaceSunken)
.frame(height: 48)
.background(AppTheme.surfaceSunken.opacity(0.85))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
pageIndicator
@@ -335,26 +405,27 @@ struct BackupView: View {
}
private func compactStat(_ value: String, label: String, color: Color) -> some View {
VStack(spacing: 3) {
VStack(spacing: 2) {
Text(value)
.font(.system(size: 16, weight: .semibold))
.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, 8)
.padding(.vertical, 6)
}
private var thinDivider: some View {
Rectangle()
.fill(AppTheme.separator.opacity(0.7))
.frame(width: 0.5, height: 22)
.fill(AppTheme.separator.opacity(0.5))
.frame(width: 0.5, height: 18)
}
private func loadNASCount() async {
private func loadNASAndCompare() async {
guard let conn = store.savedConnection else { return }
do {
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
@@ -362,9 +433,11 @@ struct BackupView: View {
username: conn.username, password: conn.password)
let items = try await s.listDirectory(at: conn.remotePath)
nasFileCount = items.filter { !$0.isDirectory }.count
vm.compareWithNAS(nasItems: items)
s.disconnect()
} catch {
nasFileCount = nil
// On failure: notBackedUpCount stays at totalPhotoCount (conservative show all as pending)
}
}