import SwiftUI struct AppMenuView: View { @EnvironmentObject var store: ConnectionStore @EnvironmentObject var lanMonitor: LANMonitor @Environment(\.dismiss) private var dismiss private var errorEntries: [BackupHistoryEntry] { store.historyEntries.filter { $0.failedCount > 0 } } var body: some View { NavigationStack { ZStack { AppTheme.background.ignoresSafeArea() ScrollView { VStack(spacing: AppTheme.sectionGap) { connectionStatusSection syncActivitySection historySection if !errorEntries.isEmpty { errorsSection } } .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) } } } } // 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("externaldrive", 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: 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(.vertical, 14) menuHairline // Network row HStack(spacing: 12) { iconBadge("wifi", color: AppTheme.inkSecondary) VStack(alignment: .leading, spacing: 2) { Text(lanMonitor.currentSSID ?? "No Wi-Fi") .font(AppTheme.body()) .foregroundStyle(AppTheme.ink) Text(lanMonitor.isOnCellular ? "Cellular" : lanMonitor.isOnNetwork ? "Wi-Fi connected" : "Offline") .font(AppTheme.micro()) .foregroundStyle(AppTheme.inkTertiary) } Spacer() statusBadge( label: lanMonitor.isOnNetwork ? "Online" : "Offline", color: lanMonitor.isOnNetwork ? AppTheme.positive : 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: — Sync Activity private var syncActivitySection: some View { VStack(alignment: .leading, spacing: 8) { menuSectionLabel("SYNC ACTIVITY") 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 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() } .padding(.horizontal, AppTheme.cardPad) .padding(.vertical, 12) 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: — 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) } }