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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user