import SwiftUI import UIKit // MARK: - Log Level enum LogLevel: String, CaseIterable { case error = "ERROR" case warning = "WARN" case info = "INFO" case debug = "DEBUG" var color: Color { switch self { case .error: return .red case .warning: return .yellow case .info: return .cyan case .debug: return .gray } } var icon: String { switch self { case .error: return "exclamationmark.circle.fill" case .warning: return "exclamationmark.triangle.fill" case .info: return "info.circle.fill" case .debug: return "ant.fill" } } } // MARK: - Debug Logger class DebugLogger: ObservableObject { static let shared = DebugLogger() struct LogEntry: Identifiable { let id = UUID() let timestamp: Date let category: String let message: String let level: LogLevel var isMarker: Bool = false // background/foreground separator var formatted: String { let df = DateFormatter(); df.dateFormat = "HH:mm:ss.SSS" return "[\(df.string(from: timestamp))] [\(level.rawValue)] [\(category)] \(message)" } } @Published var entries: [LogEntry] = [] @Published var isPiP: Bool = false @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") // Auto-insert app state markers NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in self?.insertMarker("── Background ──") } NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in self?.insertMarker("── Foreground ──") } } func log(_ message: String, category: String = "General", level: LogLevel = .debug) { guard isEnabled else { return } let entry = LogEntry(timestamp: Date(), category: category, message: message, level: level) DispatchQueue.main.async { self.entries.append(entry) if self.entries.count > self.maxEntries { self.entries.removeFirst(self.entries.count - self.maxEntries) } } print("[\(level.rawValue)][\(category)] \(message)") } // Convenience level shortcuts func error(_ message: String, category: String = "General") { log(message, category: category, level: .error) } func warn(_ message: String, category: String = "General") { log(message, category: category, level: .warning) } func info(_ message: String, category: String = "General") { log(message, category: category, level: .info) } private func insertMarker(_ text: String) { guard isEnabled else { return } var e = LogEntry(timestamp: Date(), category: "System", message: text, level: .info) e.isMarker = true entries.append(e) } 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 @State private var paused = false // Group toggles — persisted @AppStorage("dbg_show_iphone") private var showIPhone = true @AppStorage("dbg_show_watch") private var showWatch = true @AppStorage("dbg_show_companion") private var showCompanion = true @AppStorage("dbg_show_audio") private var showAudio = true // Level toggles @State private var showError = true @State private var showWarning = true @State private var showInfo = true @State private var showDebug = true // Snapshot for pause mode @State private var pausedSnapshot: [DebugLogger.LogEntry] = [] private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) private let watchCategories: Set = ["Watch", "WC", "WatchSync"] private let companionCategories: Set = ["Companion", "SmartDJ", "Crossfade", "Sync"] private let audioCategories: Set = ["Audio", "FFT", "Radio", "Prefetch"] private var activeEntries: [DebugLogger.LogEntry] { paused ? pausedSnapshot : logger.entries } private var categories: [String] { Array(Set(activeEntries.map { $0.category })).sorted() } private var filteredEntries: [DebugLogger.LogEntry] { activeEntries.filter { entry in // Group filter let isWatch = watchCategories.contains(entry.category) let isCompanion = companionCategories.contains(entry.category) let isAudio = audioCategories.contains(entry.category) let isIPhone = !isWatch && !isCompanion && !isAudio guard (isIPhone && showIPhone) || (isWatch && showWatch) || (isCompanion && showCompanion) || (isAudio && showAudio) || entry.isMarker else { return false } // Level filter let level = entry.level guard (level == .error && showError) || (level == .warning && showWarning) || (level == .info && showInfo) || (level == .debug && showDebug) || entry.isMarker else { return false } // Category pill if let cat = selectedCategory, entry.category != cat { return false } // Text search if !filterText.isEmpty { return entry.message.localizedCaseInsensitiveContains(filterText) || entry.category.localizedCaseInsensitiveContains(filterText) } return true } } var body: some View { NavigationStack { VStack(spacing: 0) { // ── Search bar ─────────────────────────────────────────────── HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundColor(.gray).font(.system(size: 13)) TextField("Filter logs...", text: $filterText) .font(.system(size: 13)).foregroundColor(.white) .keyboardDoneButton() 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) // ── Group toggles ───────────────────────────────────────────── ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { groupToggle("iPhone", icon: "iphone", isOn: $showIPhone) groupToggle("Watch", icon: "applewatch", isOn: $showWatch) groupToggle("Companion", icon: "server.rack", isOn: $showCompanion) groupToggle("Audio", icon: "waveform", isOn: $showAudio) Divider().frame(height: 16).background(Color.white.opacity(0.2)) levelToggle(.error, isOn: $showError) levelToggle(.warning, isOn: $showWarning) levelToggle(.info, isOn: $showInfo) levelToggle(.debug, isOn: $showDebug) } .padding(.horizontal, 12) } .padding(.vertical, 4) // ── 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, 4) } Divider().background(Color.white.opacity(0.1)) // ── Log entries ─────────────────────────────────────────────── ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { ForEach(Array(filteredEntries.enumerated()), id: \.element.id) { idx, entry in let prev = idx > 0 ? filteredEntries[idx - 1] : nil logRow(entry, prev: prev) .id(entry.id) } } .padding(.horizontal, 12).padding(.vertical, 4) } .onChange(of: logger.entries.count) { _, _ in guard !paused, autoScroll, let last = filteredEntries.last else { return } withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(last.id, anchor: .bottom) } } } // ── Bottom bar ──────────────────────────────────────────────── HStack(spacing: 12) { Text("\(filteredEntries.count) / \(logger.entries.count)") .font(.system(size: 11)).foregroundColor(.gray) Spacer() // Pause / Resume Button(action: togglePause) { HStack(spacing: 4) { Image(systemName: paused ? "play.fill" : "pause.fill").font(.system(size: 10)) Text(paused ? "Resume" : "Pause").font(.system(size: 11)) } .foregroundColor(paused ? accentPink : .gray) .padding(.horizontal, 8).padding(.vertical, 4) .background(paused ? accentPink.opacity(0.15) : Color.white.opacity(0.06)) .cornerRadius(8) } Toggle("", isOn: $autoScroll) .labelsHidden() .toggleStyle(.switch) .tint(accentPink) .fixedSize() Text("Scroll").font(.system(size: 11)).foregroundColor(.gray) } .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) } ToolbarItemGroup(placement: .navigationBarTrailing) { // PiP toggle → uses existing MainTabView floating window Button(action: { logger.isPiP = true }) { Image(systemName: "pip.enter").font(.system(size: 14)) }.foregroundColor(accentPink) ShareLink(item: exportLogs()) { Image(systemName: "square.and.arrow.up").font(.system(size: 14)) }.foregroundColor(accentPink) } } } } // MARK: - Helpers private func togglePause() { if paused { paused = false pausedSnapshot = [] } else { pausedSnapshot = logger.entries paused = true } } 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 groupToggle(_ title: String, icon: String, isOn: Binding) -> some View { Button(action: { isOn.wrappedValue.toggle() }) { HStack(spacing: 4) { Image(systemName: icon).font(.system(size: 10)) Text(title).font(.system(size: 10, weight: .medium)) } .foregroundColor(isOn.wrappedValue ? .white : .gray.opacity(0.4)) .padding(.horizontal, 8).padding(.vertical, 4) .background(isOn.wrappedValue ? accentPink.opacity(0.4) : Color.white.opacity(0.05)) .cornerRadius(10) } } private func levelToggle(_ level: LogLevel, isOn: Binding) -> some View { Button(action: { isOn.wrappedValue.toggle() }) { Text(level.rawValue) .font(.system(size: 9, weight: .bold)) .foregroundColor(isOn.wrappedValue ? level.color : .gray.opacity(0.3)) .padding(.horizontal, 6).padding(.vertical, 4) .background(isOn.wrappedValue ? level.color.opacity(0.15) : Color.white.opacity(0.04)) .cornerRadius(8) } } private func logRow(_ entry: DebugLogger.LogEntry, prev: DebugLogger.LogEntry?) -> some View { // Marker (background/foreground separator) if entry.isMarker { return AnyView( Text(entry.message) .font(.system(size: 10, weight: .semibold, design: .monospaced)) .foregroundColor(.white.opacity(0.25)) .frame(maxWidth: .infinity) .padding(.vertical, 6) ) } // Delta time since previous entry let delta: String? = { guard let p = prev, !p.isMarker else { return nil } let d = entry.timestamp.timeIntervalSince(p.timestamp) if d < 0.001 { return nil } return d < 1 ? String(format: "+%.0fms", d * 1000) : String(format: "+%.1fs", d) }() let catColor: Color = { switch entry.category { case "Watch", "WC", "WatchSync": return .cyan case "Audio": return .green case "FFT": return .orange case "Network", "Radio": return .yellow case "Error": return .red case "Companion", "SmartDJ": return .purple default: return .gray } }() return AnyView( VStack(alignment: .leading, spacing: 2) { HStack(spacing: 5) { // Level indicator Circle() .fill(entry.level.color) .frame(width: 5, height: 5) .opacity(entry.level == .debug ? 0.4 : 1.0) // Category badge Text(entry.category) .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundColor(catColor) .padding(.horizontal, 4).padding(.vertical, 1) .background(catColor.opacity(0.12)) .cornerRadius(3) // Timestamp let df = DateFormatter(); let _ = df.dateFormat = "HH:mm:ss.SSS" Text(df.string(from: entry.timestamp)) .font(.system(size: 9, design: .monospaced)) .foregroundColor(.gray.opacity(0.6)) // Delta if let d = delta { Text(d) .font(.system(size: 9, design: .monospaced)) .foregroundColor(.white.opacity(0.25)) } } Text(entry.message) .font(.system(size: 11, design: .monospaced)) .foregroundColor(entry.level == .error ? .red.opacity(0.9) : entry.level == .warning ? .yellow.opacity(0.9) : .white.opacity(0.85)) .textSelection(.enabled) } .padding(.vertical, 4) ) } private func exportLogs() -> String { filteredEntries.map { $0.formatted }.joined(separator: "\n") } }