- Redesign logging configuration UI with cleaner list-based navigation - Replace segmented controls with detailed selection screens for better UX - Add in-app debug log viewer with filtering and search capabilities - Implement opt-in logging toggle to reduce device performance impact - Add log storage system with 1000 entry limit - Enable log export via share sheet - Show warning banner when logging is disabled
445 lines
15 KiB
Swift
445 lines
15 KiB
Swift
//
|
|
// DebugLogViewer.swift
|
|
// readeck
|
|
//
|
|
// Created by Ilyas Hallak on 01.11.25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct DebugLogViewer: View {
|
|
@State private var entries: [LogEntry] = []
|
|
@State private var selectedLevel: LogLevel?
|
|
@State private var selectedCategory: LogCategory?
|
|
@State private var searchText = ""
|
|
@State private var showShareSheet = false
|
|
@State private var exportText = ""
|
|
@State private var autoScroll = true
|
|
@State private var showFilters = false
|
|
@StateObject private var logConfig = LogConfiguration.shared
|
|
|
|
private let logger = Logger.ui
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Logging Disabled Warning
|
|
if !logConfig.isLoggingEnabled {
|
|
loggingDisabledBanner
|
|
}
|
|
|
|
// Filter Bar
|
|
if showFilters {
|
|
filterBar
|
|
}
|
|
|
|
// Log List
|
|
if filteredEntries.isEmpty {
|
|
emptyState
|
|
} else {
|
|
ScrollViewReader { proxy in
|
|
List {
|
|
ForEach(filteredEntries) { entry in
|
|
LogEntryRow(entry: entry)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.onChange(of: entries.count) { oldValue, newValue in
|
|
if autoScroll, let lastEntry = filteredEntries.last {
|
|
withAnimation {
|
|
proxy.scrollTo(lastEntry.id, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Debug Logs")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
Menu {
|
|
Button {
|
|
showFilters.toggle()
|
|
} label: {
|
|
Label(
|
|
showFilters ? "Hide Filters" : "Show Filters",
|
|
systemImage: "line.3.horizontal.decrease.circle"
|
|
)
|
|
}
|
|
|
|
Button {
|
|
autoScroll.toggle()
|
|
} label: {
|
|
Label(
|
|
autoScroll ? "Disable Auto-Scroll" : "Enable Auto-Scroll",
|
|
systemImage: autoScroll ? "arrow.down.circle.fill" : "arrow.down.circle"
|
|
)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button {
|
|
Task {
|
|
await refreshLogs()
|
|
}
|
|
} label: {
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
}
|
|
|
|
Button {
|
|
Task {
|
|
await exportLogs()
|
|
}
|
|
} label: {
|
|
Label("Export Logs", systemImage: "square.and.arrow.up")
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button(role: .destructive) {
|
|
Task {
|
|
await clearLogs()
|
|
}
|
|
} label: {
|
|
Label("Clear All Logs", systemImage: "trash")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
}
|
|
.searchable(text: $searchText, prompt: "Search logs")
|
|
.task {
|
|
await refreshLogs()
|
|
}
|
|
.sheet(isPresented: $showShareSheet) {
|
|
ActivityView(activityItems: [exportText])
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var filterBar: some View {
|
|
VStack(spacing: 8) {
|
|
HStack {
|
|
Text("Filters")
|
|
.font(.headline)
|
|
Spacer()
|
|
}
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
// Level Filter
|
|
Menu {
|
|
Button("All Levels") {
|
|
selectedLevel = nil
|
|
}
|
|
Divider()
|
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
|
Button {
|
|
selectedLevel = level
|
|
} label: {
|
|
HStack {
|
|
Text(levelName(for: level))
|
|
if selectedLevel == level {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "slider.horizontal.3")
|
|
Text(selectedLevel != nil ? levelName(for: selectedLevel!) : "Level")
|
|
Image(systemName: "chevron.down")
|
|
.font(.caption)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(selectedLevel != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
|
.foregroundColor(selectedLevel != nil ? .accentColor : .primary)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
// Category Filter
|
|
Menu {
|
|
Button("All Categories") {
|
|
selectedCategory = nil
|
|
}
|
|
Divider()
|
|
ForEach(LogCategory.allCases, id: \.self) { category in
|
|
Button {
|
|
selectedCategory = category
|
|
} label: {
|
|
HStack {
|
|
Text(category.rawValue)
|
|
if selectedCategory == category {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "tag")
|
|
Text(selectedCategory?.rawValue ?? "Category")
|
|
Image(systemName: "chevron.down")
|
|
.font(.caption)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(selectedCategory != nil ? Color.accentColor.opacity(0.2) : Color(.systemGray5))
|
|
.foregroundColor(selectedCategory != nil ? .accentColor : .primary)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
// Clear Filters
|
|
if selectedLevel != nil || selectedCategory != nil {
|
|
Button {
|
|
selectedLevel = nil
|
|
selectedCategory = nil
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "xmark.circle.fill")
|
|
Text("Clear")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color(.systemGray5))
|
|
.foregroundColor(.secondary)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var loggingDisabledBanner: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.orange)
|
|
.font(.title3)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Logging Disabled")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
Text("Enable logging in settings to capture new logs")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
logConfig.isLoggingEnabled = true
|
|
} label: {
|
|
Text("Enable")
|
|
.font(.subheadline)
|
|
.fontWeight(.semibold)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color.orange)
|
|
.foregroundColor(.white)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color.orange.opacity(0.1))
|
|
.cornerRadius(12)
|
|
.padding(.horizontal)
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var emptyState: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "doc.text.magnifyingglass")
|
|
.font(.system(size: 60))
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("No Logs Found")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
if !searchText.isEmpty || selectedLevel != nil || selectedCategory != nil {
|
|
Text("Try adjusting your filters or search criteria")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Button {
|
|
searchText = ""
|
|
selectedLevel = nil
|
|
selectedCategory = nil
|
|
} label: {
|
|
Text("Clear Filters")
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(Color.accentColor)
|
|
.foregroundColor(.white)
|
|
.clipShape(Capsule())
|
|
}
|
|
} else {
|
|
Text("Logs will appear here as they are generated")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding()
|
|
}
|
|
|
|
private var filteredEntries: [LogEntry] {
|
|
var filtered = entries
|
|
|
|
if let level = selectedLevel {
|
|
filtered = filtered.filter { $0.level == level }
|
|
}
|
|
|
|
if let category = selectedCategory {
|
|
filtered = filtered.filter { $0.category == category }
|
|
}
|
|
|
|
if !searchText.isEmpty {
|
|
filtered = filtered.filter {
|
|
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
|
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
|
$0.function.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
private func refreshLogs() async {
|
|
entries = await LogStore.shared.getEntries()
|
|
}
|
|
|
|
private func clearLogs() async {
|
|
await LogStore.shared.clear()
|
|
await refreshLogs()
|
|
logger.info("Cleared all debug logs")
|
|
}
|
|
|
|
private func exportLogs() async {
|
|
exportText = await LogStore.shared.exportAsText()
|
|
showShareSheet = true
|
|
logger.info("Exported debug logs")
|
|
}
|
|
|
|
private func levelName(for level: LogLevel) -> String {
|
|
switch level.rawValue {
|
|
case 0: return "Debug"
|
|
case 1: return "Info"
|
|
case 2: return "Notice"
|
|
case 3: return "Warning"
|
|
case 4: return "Error"
|
|
case 5: return "Critical"
|
|
default: return "Unknown"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Log Entry Row
|
|
|
|
struct LogEntryRow: View {
|
|
let entry: LogEntry
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
// Level Badge
|
|
Text(levelName(for: entry.level))
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(levelColor(for: entry.level).opacity(0.2))
|
|
.foregroundColor(levelColor(for: entry.level))
|
|
.clipShape(Capsule())
|
|
|
|
// Category
|
|
Text(entry.category.rawValue)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
|
|
// Timestamp
|
|
Text(entry.formattedTimestamp)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
|
|
// Message
|
|
Text(entry.message)
|
|
.font(.subheadline)
|
|
.foregroundColor(.primary)
|
|
|
|
// Source Location
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "doc.text")
|
|
.font(.caption2)
|
|
Text("\(entry.fileName):\(entry.line)")
|
|
.font(.caption)
|
|
Text("•")
|
|
.font(.caption)
|
|
Text(entry.function)
|
|
.font(.caption)
|
|
}
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func levelName(for level: LogLevel) -> String {
|
|
switch level.rawValue {
|
|
case 0: return "DEBUG"
|
|
case 1: return "INFO"
|
|
case 2: return "NOTICE"
|
|
case 3: return "WARN"
|
|
case 4: return "ERROR"
|
|
case 5: return "CRITICAL"
|
|
default: return "UNKNOWN"
|
|
}
|
|
}
|
|
|
|
private func levelColor(for level: LogLevel) -> Color {
|
|
switch level.rawValue {
|
|
case 0: return .blue
|
|
case 1: return .green
|
|
case 2: return .cyan
|
|
case 3: return .orange
|
|
case 4: return .red
|
|
case 5: return .purple
|
|
default: return .gray
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Activity View (for Share Sheet)
|
|
|
|
struct ActivityView: UIViewControllerRepresentable {
|
|
let activityItems: [Any]
|
|
|
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
DebugLogViewer()
|
|
}
|
|
}
|