import SwiftUI // MARK: - Debug Logger /// Singleton that captures log messages for the in-app debug console class DebugLogger: ObservableObject { static let shared = DebugLogger() struct LogEntry: Identifiable { let id = UUID() let timestamp: Date let category: String let message: String var formatted: String { let df = DateFormatter() df.dateFormat = "HH:mm:ss.SSS" return "[\(df.string(from: timestamp))] [\(category)] \(message)" } } @Published var entries: [LogEntry] = [] @Published var isEnabled: Bool { didSet { UserDefaults.standard.set(isEnabled, forKey: "debug_console_enabled") } } private let maxEntries = 500 private init() { isEnabled = UserDefaults.standard.bool(forKey: "debug_console_enabled") } func log(_ message: String, category: String = "General") { let entry = LogEntry(timestamp: Date(), category: category, message: message) DispatchQueue.main.async { self.entries.append(entry) if self.entries.count > self.maxEntries { self.entries.removeFirst(self.entries.count - self.maxEntries) } } // Also print to Xcode console print("[\(category)] \(message)") } func clear() { entries.removeAll() } } // MARK: - Debug Console View struct DebugConsoleView: View { @ObservedObject private var logger = DebugLogger.shared @State private var filterText = "" @State private var selectedCategory: String? = nil @State private var autoScroll = true private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) private var categories: [String] { Array(Set(logger.entries.map { $0.category })).sorted() } private var filteredEntries: [DebugLogger.LogEntry] { logger.entries.filter { entry in let matchesCategory = selectedCategory == nil || entry.category == selectedCategory let matchesText = filterText.isEmpty || entry.message.localizedCaseInsensitiveContains(filterText) || entry.category.localizedCaseInsensitiveContains(filterText) return matchesCategory && matchesText } } var body: some View { NavigationStack { VStack(spacing: 0) { // Filter bar HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundColor(.gray) .font(.system(size: 13)) TextField("Filter logs...", text: $filterText) .font(.system(size: 13)) .foregroundColor(.white) if !filterText.isEmpty { Button(action: { filterText = "" }) { Image(systemName: "xmark.circle.fill") .foregroundColor(.gray) .font(.system(size: 13)) } } } .padding(.horizontal, 12) .padding(.vertical, 8) .background(Color.white.opacity(0.08)) .cornerRadius(8) .padding(.horizontal, 12) .padding(.top, 8) // Category pills if !categories.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { categoryPill("All", isSelected: selectedCategory == nil) { selectedCategory = nil } ForEach(categories, id: \.self) { cat in categoryPill(cat, isSelected: selectedCategory == cat) { selectedCategory = cat } } } .padding(.horizontal, 12) } .padding(.vertical, 6) } Divider().background(Color.white.opacity(0.1)) // Log entries ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { ForEach(filteredEntries) { entry in logRow(entry) .id(entry.id) } } .padding(.horizontal, 12) .padding(.vertical, 4) } .onChange(of: logger.entries.count) { _, _ in if autoScroll, let last = filteredEntries.last { withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(last.id, anchor: .bottom) } } } } // Bottom bar HStack { Text("\(filteredEntries.count) entries") .font(.system(size: 11)) .foregroundColor(.gray) Spacer() Toggle("Auto-scroll", isOn: $autoScroll) .font(.system(size: 11)) .foregroundColor(.gray) .toggleStyle(.switch) .tint(accentPink) .fixedSize() } .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.black.opacity(0.3)) } .background(Color(white: 0.06)) .navigationTitle("Debug Console") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button(action: { logger.clear() }) { Text("Clear").font(.system(size: 14)) } .foregroundColor(.red) } ToolbarItem(placement: .navigationBarTrailing) { ShareLink(item: exportLogs()) { Image(systemName: "square.and.arrow.up") .font(.system(size: 14)) } .foregroundColor(accentPink) } } } } private func categoryPill(_ title: String, isSelected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(title) .font(.system(size: 11, weight: .medium)) .foregroundColor(isSelected ? .white : .gray) .padding(.horizontal, 10) .padding(.vertical, 5) .background(isSelected ? accentPink.opacity(0.6) : Color.white.opacity(0.08)) .cornerRadius(12) } } private func logRow(_ entry: DebugLogger.LogEntry) -> some View { let color: Color = { switch entry.category { case "Watch": return .cyan case "Audio": return .green case "FFT": return .orange case "Network": return .yellow case "Error": return .red case "Radio": return .purple default: return .gray } }() return VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(entry.category) .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(color) .padding(.horizontal, 4) .padding(.vertical, 1) .background(color.opacity(0.15)) .cornerRadius(3) let df = DateFormatter() let _ = df.dateFormat = "HH:mm:ss.SSS" Text(df.string(from: entry.timestamp)) .font(.system(size: 9, design: .monospaced)) .foregroundColor(.gray) } Text(entry.message) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.white.opacity(0.9)) .textSelection(.enabled) } .padding(.vertical, 4) } private func exportLogs() -> String { filteredEntries.map { $0.formatted }.joined(separator: "\n") } }