import SwiftUI struct AppMenuView: View { @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor @EnvironmentObject var engine: BackupEngine @Environment(\.dismiss) private var dismiss @State private var showClearSheet = false @State private var selectedErrorEntry: BackupHistoryEntry? private var errorEntries: [BackupHistoryEntry] { store.historyEntries.filter { $0.failedCount > 0 } } private var lastSuccessfulEntry: BackupHistoryEntry? { store.historyEntries.first { $0.failedCount == 0 } } // MARK: — Wi-Fi 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 .orange } if lanMonitor.isOnNetwork { return AppTheme.positive } return .orange } // MARK: — NAS helpers private var nasBadgeLabel: String { switch lanMonitor.nasReachable { case true: return "Connected" case false: return "Offline" default: return "Unknown" } } private var nasBadgeColor: Color { switch lanMonitor.nasReachable { case true: return AppTheme.positive case false: return .orange default: return AppTheme.inkQuaternary } } // MARK: — Session helpers private var sessionColor: Color { switch engine.job.status { case .running: return AppTheme.interactive case .paused: return .orange default: return .orange } } private var sessionTitle: String { switch engine.job.status { case .preparing: return "Scanning library…" case .running: return "Uploading" case .paused: return "Paused" default: return "Active" } } private var sessionBadgeLabel: String { switch engine.job.status { case .preparing: return "Scanning" case .running: return "Uploading" case .paused: return "Paused" default: return "Active" } } // MARK: — Body var body: some View { NavigationStack { ZStack { AppTheme.background.ignoresSafeArea() ScrollView { VStack(spacing: AppTheme.sectionGap) { connectionStatusSection if engine.job.status.isActive { currentSessionSection } if !errorEntries.isEmpty { errorsSection } lastBackupSection syncActivitySection historySection aboutSection } .padding(.horizontal, AppTheme.hPad) .padding(.top, 8) .padding(.bottom, 48) } } .navigationTitle("Activity") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } .font(.system(size: 15, weight: .medium)) .foregroundStyle(AppTheme.ink) } } .confirmationDialog("Clear Activity", isPresented: $showClearSheet, titleVisibility: .visible) { Button("Clear Completed Logs") { store.clearCompletedHistory() } if !errorEntries.isEmpty { Button("Clear Errors") { store.clearErrorHistory() } Button("Clear Everything", role: .destructive) { store.clearHistory() } } Button("Cancel", role: .cancel) {} } .sheet(item: $selectedErrorEntry) { entry in errorDetailSheet(entry: entry) } } } // MARK: — Connection Status private var connectionStatusSection: some View { VStack(alignment: .leading, spacing: 8) { menuSectionLabel("CONNECTION STATUS") VStack(spacing: 0) { // NAS / destination row HStack(spacing: 12) { iconBadge("server.rack", color: AppTheme.inkSecondary) VStack(alignment: .leading, spacing: 2) { Text(store.savedConnection?.displayName ?? "No NAS connected") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) if let conn = store.savedConnection { Text("\(conn.nasProtocol.rawValue.uppercased()) · \(conn.host)") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) if conn.remotePath != "/" { Text(conn.remotePath) .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkQuaternary) .lineLimit(1) .truncationMode(.middle) } } } Spacer() statusBadge(label: nasBadgeLabel, color: nasBadgeColor) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) menuHairline // Wi-Fi row HStack(spacing: 12) { iconBadge("wifi", color: AppTheme.inkSecondary) VStack(alignment: .leading, spacing: 2) { Text("Wi-Fi") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Text(wifiSubtitle) .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() statusBadge(label: wifiBadgeLabel, color: wifiBadgeColor) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) menuHairline // Cellular row HStack(spacing: 12) { iconBadge("antenna.radiowaves.left.and.right", color: lanMonitor.isOnCellular ? Color(red: 1.0, green: 0.58, blue: 0.0) : AppTheme.inkSecondary) VStack(alignment: .leading, spacing: 2) { Text("Cellular") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Text(lanMonitor.isOnCellular ? "Active" : "Not in use") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() statusBadge( label: lanMonitor.isOnCellular ? "Active" : "Off", color: lanMonitor.isOnCellular ? Color(red: 1.0, green: 0.58, blue: 0.0) : AppTheme.inkQuaternary ) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) if lanMonitor.isTailscaleActive { menuHairline HStack(spacing: 12) { iconBadge("network.badge.shield.half.filled", color: AppTheme.interactive) Text("Tailscale active") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() statusBadge(label: "VPN", color: AppTheme.interactive) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) } } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } // MARK: — Current Session private var currentSessionSection: some View { VStack(alignment: .leading, spacing: 8) { menuSectionLabel("CURRENT SESSION") VStack(spacing: 0) { HStack(spacing: 12) { iconBadge( engine.job.status == .running ? "arrow.up.circle" : engine.job.status == .paused ? "pause.circle" : "arrow.triangle.2.circlepath", color: sessionColor ) VStack(alignment: .leading, spacing: 2) { Text(sessionTitle) .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) if engine.job.status == .running, !engine.job.currentFileName.isEmpty { Text(engine.job.currentFileName) .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) .lineLimit(1) .truncationMode(.middle) } } Spacer() statusBadge(label: sessionBadgeLabel, color: sessionColor) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) if engine.job.totalFiles > 0 { menuHairline VStack(alignment: .leading, spacing: 8) { HStack { Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)") .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkSecondary) Spacer() if engine.job.speedBytesPerSecond > 1000 { Text(speedString(engine.job.speedBytesPerSecond)) .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } } GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3, style: .continuous) .fill(AppTheme.inkQuaternary.opacity(0.35)) .frame(height: 4) RoundedRectangle(cornerRadius: 3, style: .continuous) .fill(sessionColor) .frame(width: max(0, geo.size.width * engine.job.progress), height: 4) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress) } } .frame(height: 4) if let eta = engine.job.eta, eta > 5 { Text("About \(etaString(eta)) remaining") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 12) } } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } // MARK: — Last Successful Backup private var lastBackupSection: some View { VStack(alignment: .leading, spacing: 8) { menuSectionLabel("LAST SUCCESSFUL BACKUP") if let entry = lastSuccessfulEntry { VStack(spacing: 0) { HStack(spacing: 12) { iconBadge("checkmark.circle", color: AppTheme.positive) VStack(alignment: .leading, spacing: 2) { Text(entry.date.formatted(.dateTime.month(.abbreviated).day().hour().minute())) .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) let totalSafe = entry.uploadedCount + entry.skippedCount Text("\(totalSafe) photo\(totalSafe == 1 ? "" : "s") safe") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() statusBadge(label: "Completed", color: AppTheme.positive) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } else { emptyCard(icon: "checkmark.circle", text: "No successful backups yet") } } } // MARK: — Errors private var errorsSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { menuSectionLabel("ERRORS") Spacer() if !errorEntries.isEmpty { Button { store.clearErrorHistory() } label: { Label("Clear Errors", systemImage: "trash") .font(.system(size: 11, weight: .medium)) .foregroundStyle(AppTheme.destructive) } } } VStack(spacing: 0) { ForEach(Array(errorEntries.prefix(3).enumerated()), id: \.element.id) { idx, entry in 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.formatted(.dateTime.month(.abbreviated).day().hour().minute())) .font(AppTheme.caption()) .foregroundStyle(AppTheme.ink) Text("\(entry.failedCount) file\(entry.failedCount == 1 ? "" : "s") failed · \(entry.uploadedCount) uploaded") .font(AppTheme.micro()) .foregroundStyle(AppTheme.destructive.opacity(0.8)) } Spacer() Image(systemName: "chevron.right") .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 12) } .buttonStyle(ScaleButtonStyle()) if idx < min(errorEntries.count, 3) - 1 { menuHairline } } } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } // MARK: — Sync Activity private var syncActivitySection: some View { VStack(alignment: .leading, spacing: 8) { HStack { menuSectionLabel("SYNC ACTIVITY") Spacer() if !store.historyEntries.isEmpty { Button { showClearSheet = true } label: { Text("Clear ▾") .font(.system(size: 12, weight: .medium)) .foregroundStyle(AppTheme.inkTertiary) } } } VStack(spacing: 0) { let total = store.historyEntries.count let uploaded = store.historyEntries.reduce(0) { $0 + $1.uploadedCount } let safe = store.historyEntries.reduce(0) { $0 + $1.skippedCount } let errors = store.historyEntries.reduce(0) { $0 + $1.failedCount } activityStat(icon: "clock.arrow.circlepath", label: "Backups completed", value: "\(total)") menuHairline activityStat(icon: "arrow.up.doc", label: "New uploads", value: "\(uploaded)") menuHairline activityStat(icon: "checkmark", label: "Already safe", value: "\(safe)") if errors > 0 { menuHairline activityStat(icon: "exclamationmark.triangle", label: "Errors", value: "\(errors)", valueColor: AppTheme.destructive) } } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } // MARK: — History private var historySection: some View { VStack(alignment: .leading, spacing: 8) { menuSectionLabel("HISTORY") if store.historyEntries.isEmpty { emptyCard(icon: "clock.arrow.circlepath", text: "No backups yet") } else { VStack(spacing: 0) { ForEach(Array(store.historyEntries.prefix(5).enumerated()), id: \.element.id) { idx, entry in compactHistoryRow(entry) if idx < min(store.historyEntries.count, 5) - 1 { menuHairline } } if store.historyEntries.count > 5 { menuHairline Text("+ \(store.historyEntries.count - 5) more") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkQuaternary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 10) } } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } } private func compactHistoryRow(_ entry: BackupHistoryEntry) -> some View { HStack(spacing: 10) { Circle() .fill(entry.failedCount > 0 ? AppTheme.destructive : AppTheme.positive) .frame(width: 5, height: 5) VStack(alignment: .leading, spacing: 2) { Text(entry.date.formatted(.dateTime.month(.abbreviated).day().hour().minute())) .font(AppTheme.caption()) .foregroundStyle(AppTheme.ink) historySubtitle(entry) } Spacer() if entry.triggeredByLAN { Text("AUTO") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(AppTheme.inkSecondary) .padding(.horizontal, 5) .padding(.vertical, 2) .overlay( RoundedRectangle(cornerRadius: 3, style: .continuous) .stroke(AppTheme.inkQuaternary, lineWidth: 0.75) ) } } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 12) } @ViewBuilder private func historySubtitle(_ entry: BackupHistoryEntry) -> some View { if entry.failedCount > 0 { Text("\(entry.uploadedCount) uploaded · \(entry.failedCount) failed") .font(AppTheme.micro()) .foregroundStyle(AppTheme.destructive.opacity(0.8)) } else if entry.uploadedCount == 0 && entry.skippedCount > 0 { Text("\(entry.skippedCount) already backed up") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } else if entry.skippedCount > 0 { Text("\(entry.uploadedCount) new · \(entry.skippedCount) already safe") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } else { Text("\(entry.uploadedCount) photo\(entry.uploadedCount == 1 ? "" : "s") backed up") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } } // MARK: — Error Detail Sheet @ViewBuilder private func errorDetailSheet(entry: BackupHistoryEntry) -> some View { NavigationStack { ZStack { AppTheme.background.ignoresSafeArea() ScrollView { VStack(spacing: AppTheme.sectionGap) { // Summary card 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: "Already safe", 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) // Suggested fix 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 — already-uploaded photos are skipped automatically.") } .padding(AppTheme.cardPad) .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } // Retry button Button { selectedErrorEntry = nil retryBackup() } label: { Label("Retry Failed Uploads", systemImage: "arrow.clockwise") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 50) .background( lanMonitor.nasReachable == true ? AppTheme.interactive : AppTheme.inkQuaternary ) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } .disabled(lanMonitor.nasReachable != true || engine.job.status.isActive) .buttonStyle(ScaleButtonStyle()) if lanMonitor.nasReachable != true { Text("NAS is not reachable — connect to the same network first.") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) .frame(maxWidth: .infinity, alignment: .center) .padding(.top, -4) } } .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) } } } } // MARK: — About private var aboutSection: some View { VStack(alignment: .leading, spacing: 8) { menuSectionLabel("ABOUT") VStack(spacing: 0) { HStack(spacing: 12) { Text("kisani.") .font(.system(size: 13, weight: .medium, design: .monospaced)) .foregroundStyle(AppTheme.ink) .kerning(0.5) Spacer() Text("v1.0") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) menuHairline Link(destination: URL(string: "mailto:apps@provoc.ug")!) { HStack(spacing: 12) { Image(systemName: "envelope") .font(.system(size: 13, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) Text("Support") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() Text("apps@provoc.ug") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) Image(systemName: "arrow.up.right") .font(.system(size: 10, weight: .medium)) .foregroundStyle(AppTheme.inkQuaternary) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) } } .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } // MARK: — Actions private func retryBackup() { guard let conn = store.savedConnection, !engine.job.status.isActive else { return } dismiss() Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) } } // MARK: — Helpers private func menuSectionLabel(_ text: String) -> some View { Text(text) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(AppTheme.inkQuaternary) .kerning(0.6) } private var menuHairline: some View { Rectangle() .fill(AppTheme.separator) .frame(height: 0.5) .padding(.leading, AppTheme.cardPad) } private func iconBadge(_ name: String, color: Color) -> some View { ZStack { Circle() .fill(AppTheme.surfaceSunken) .frame(width: 32, height: 32) Image(systemName: name) .font(.system(size: 13, weight: .medium)) .foregroundStyle(color) } } private func statusBadge(label: String, color: Color) -> some View { HStack(spacing: 4) { Circle().fill(color).frame(width: 5, height: 5) Text(label) .font(.system(size: 11, weight: .medium)) .foregroundStyle(color) } .padding(.horizontal, 8) .padding(.vertical, 4) .background(color.opacity(0.08)) .clipShape(Capsule(style: .continuous)) } private func activityStat(icon: String, label: String, value: String, valueColor: Color = AppTheme.ink) -> some View { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 13, weight: .regular)) .foregroundStyle(AppTheme.inkSecondary) .frame(width: 20) Text(label) .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Spacer() Text(value) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(valueColor) .monospacedDigit() } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 13) } private func emptyCard(icon: String, text: String) -> some View { HStack(spacing: 10) { Image(systemName: icon) .font(.system(size: 13, weight: .light)) .foregroundStyle(AppTheme.inkQuaternary) Text(text) .font(AppTheme.caption()) .foregroundStyle(AppTheme.inkTertiary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 16) .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } 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 speedString(_ bytesPerSecond: Double) -> String { if bytesPerSecond >= 1_048_576 { return String(format: "%.1f MB/s", bytesPerSecond / 1_048_576) } return String(format: "%.0f KB/s", bytesPerSecond / 1_024) } private func etaString(_ seconds: TimeInterval) -> String { let mins = Int(seconds) / 60 let secs = Int(seconds) % 60 if mins >= 60 { return "\(mins / 60)h \(mins % 60)m" } if mins > 0 { return "\(mins) min" } return "\(secs)s" } private func durationString(_ seconds: Double) -> String { let m = Int(seconds) / 60 let s = Int(seconds) % 60 if seconds < 60 { return "\(Int(seconds))s" } return s == 0 ? "\(m)m" : "\(m)m \(s)s" } }