diff --git a/Features/Backup/AppMenuView.swift b/Features/Backup/AppMenuView.swift index e8f1548..3ef7de1 100644 --- a/Features/Backup/AppMenuView.swift +++ b/Features/Backup/AppMenuView.swift @@ -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 { diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift index 97e86c5..149266a 100644 --- a/Features/Backup/BackupView.swift +++ b/Features/Backup/BackupView.swift @@ -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.. 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) } } diff --git a/Features/Backup/BackupViewModel.swift b/Features/Backup/BackupViewModel.swift index 0219353..0a20d5c 100644 --- a/Features/Backup/BackupViewModel.swift +++ b/Features/Backup/BackupViewModel.swift @@ -4,13 +4,40 @@ import Photos @MainActor final class BackupViewModel: ObservableObject { @Published var totalPhotoCount: Int = 0 + @Published var alreadySafeCount: Int = 0 + @Published var notBackedUpCount: Int = 0 @Published var photosAuthStatus: PHAuthorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) private let photoService = PhotoLibraryService() + private var fetchedAssets: [PhotoAsset] = [] func loadPhotoCount(filter: BackupFilter) { let assets = photoService.fetchAssets(filter: filter) + fetchedAssets = assets 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 {