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