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:
@@ -5,10 +5,49 @@ struct AppMenuView: View {
|
||||
@EnvironmentObject var lanMonitor: LANMonitor
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showClearConfirm = false
|
||||
@State private var selectedErrorEntry: BackupHistoryEntry?
|
||||
|
||||
private var errorEntries: [BackupHistoryEntry] {
|
||||
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 {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
@@ -38,6 +77,19 @@ struct AppMenuView: View {
|
||||
.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()
|
||||
|
||||
statusBadge(
|
||||
label: lanMonitor.nasReachable == true ? "Reachable" : lanMonitor.nasReachable == false ? "Offline" : "Unknown",
|
||||
color: lanMonitor.nasReachable == true ? AppTheme.positive : lanMonitor.nasReachable == false ? AppTheme.destructive : AppTheme.inkQuaternary
|
||||
)
|
||||
statusBadge(label: nasBadgeLabel, color: nasBadgeColor)
|
||||
}
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
menuHairline
|
||||
|
||||
// Network row
|
||||
// Wi-Fi row — title is always "Wi-Fi"; subtitle shows SSID or connection type
|
||||
HStack(spacing: 12) {
|
||||
iconBadge("wifi", color: AppTheme.inkSecondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(lanMonitor.currentSSID ?? "No Wi-Fi")
|
||||
Text("Wi-Fi")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text(lanMonitor.isOnCellular ? "Cellular" : lanMonitor.isOnNetwork ? "Wi-Fi connected" : "Offline")
|
||||
Text(wifiSubtitle)
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
statusBadge(
|
||||
label: lanMonitor.isOnNetwork ? "Online" : "Offline",
|
||||
color: lanMonitor.isOnNetwork ? AppTheme.positive : AppTheme.inkQuaternary
|
||||
)
|
||||
statusBadge(label: wifiBadgeLabel, color: wifiBadgeColor)
|
||||
}
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 14)
|
||||
@@ -123,12 +169,20 @@ struct AppMenuView: View {
|
||||
|
||||
private var syncActivitySection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
menuSectionLabel("SYNC ACTIVITY")
|
||||
HStack {
|
||||
menuSectionLabel("SYNC ACTIVITY")
|
||||
Spacer()
|
||||
if !store.historyEntries.isEmpty {
|
||||
Button("Clear") { showClearConfirm = true }
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
let total = store.historyEntries.count
|
||||
let total = store.historyEntries.count
|
||||
let uploaded = store.historyEntries.reduce(0) { $0 + $1.uploadedCount }
|
||||
let errors = store.historyEntries.reduce(0) { $0 + $1.failedCount }
|
||||
let errors = store.historyEntries.reduce(0) { $0 + $1.failedCount }
|
||||
|
||||
activityStat(icon: "clock.arrow.circlepath", label: "Total backups", value: "\(total)")
|
||||
|
||||
@@ -220,23 +274,33 @@ struct AppMenuView: View {
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(errorEntries.prefix(3).enumerated()), id: \.element.id) { idx, entry in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
Button {
|
||||
selectedErrorEntry = entry
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundStyle(AppTheme.destructive)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.date, format: .dateTime.month(.abbreviated).day().year())
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text("\(entry.failedCount) file\(entry.failedCount == 1 ? "" : "s") failed")
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.destructive.opacity(0.8))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.date, format: .dateTime.month(.abbreviated).day().year())
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text("\(entry.failedCount) file\(entry.failedCount == 1 ? "" : "s") failed")
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.destructive.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
Spacer()
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 12)
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
|
||||
if idx < min(errorEntries.count, 3) - 1 {
|
||||
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
|
||||
|
||||
private var aboutSection: some View {
|
||||
|
||||
Reference in New Issue
Block a user