import SwiftUI struct AppMenuView: View { @EnvironmentObject var store: ConnectionStore @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 — green when reachable, orange when offline, muted when unknown 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 Color(red: 1.0, green: 0.58, blue: 0.0) default: return AppTheme.inkQuaternary } } var body: some View { NavigationStack { ZStack { AppTheme.background.ignoresSafeArea() ScrollView { VStack(spacing: AppTheme.sectionGap) { connectionStatusSection syncActivitySection historySection if !errorEntries.isEmpty { errorsSection } 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) } } .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) } } } // MARK: — Connection Status private var connectionStatusSection: some View { VStack(alignment: .leading, spacing: 8) { menuSectionLabel("CONNECTION STATUS") VStack(spacing: 0) { // NAS 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) · \(conn.host)") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } } Spacer() statusBadge(label: nasBadgeLabel, color: nasBadgeColor) } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 14) menuHairline // 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("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) 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: — Sync Activity private var syncActivitySection: some View { VStack(alignment: .leading, spacing: 8) { 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 uploaded = store.historyEntries.reduce(0) { $0 + $1.uploadedCount } let errors = store.historyEntries.reduce(0) { $0 + $1.failedCount } activityStat(icon: "clock.arrow.circlepath", label: "Total backups", value: "\(total)") menuHairline activityStat(icon: "arrow.up.doc", label: "Files uploaded", value: "\(uploaded)") if errors > 0 { menuHairline activityStat(icon: "exclamationmark.triangle", label: "Total 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, format: .dateTime.month(.abbreviated).day().year().hour().minute()) .font(AppTheme.caption()) .foregroundStyle(AppTheme.ink) Text("\(entry.uploadedCount) uploaded · \(entry.skippedCount) skipped") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } 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) } // MARK: — Errors private var errorsSection: some View { VStack(alignment: .leading, spacing: 8) { menuSectionLabel("ERRORS") 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, 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) } .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: — 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 { VStack(alignment: .leading, spacing: 8) { menuSectionLabel("ABOUT") VStack(spacing: 0) { HStack(spacing: 12) { Image("ugreen_logo") .resizable() .scaledToFit() .frame(width: 28, height: 28) .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) 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: — 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) } }