From 7338db5fab8ff3fc6ff6dc5ca4a82f944176aaa2 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sat, 1 Nov 2025 13:54:40 +0100 Subject: [PATCH] Improve debug logging system - 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 --- readeck/UI/Settings/DebugLogViewer.swift | 444 ++++++++++++++++++ .../Settings/LoggingConfigurationView.swift | 261 ++++++---- .../UI/Settings/SettingsContainerView.swift | 11 +- readeck/Utils/LogStore.swift | 145 ++++++ readeck/Utils/Logger.swift | 51 +- 5 files changed, 820 insertions(+), 92 deletions(-) create mode 100644 readeck/UI/Settings/DebugLogViewer.swift create mode 100644 readeck/Utils/LogStore.swift diff --git a/readeck/UI/Settings/DebugLogViewer.swift b/readeck/UI/Settings/DebugLogViewer.swift new file mode 100644 index 0000000..fea7136 --- /dev/null +++ b/readeck/UI/Settings/DebugLogViewer.swift @@ -0,0 +1,444 @@ +// +// 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() + } +} diff --git a/readeck/UI/Settings/LoggingConfigurationView.swift b/readeck/UI/Settings/LoggingConfigurationView.swift index d9896e4..3688fff 100644 --- a/readeck/UI/Settings/LoggingConfigurationView.swift +++ b/readeck/UI/Settings/LoggingConfigurationView.swift @@ -5,97 +5,83 @@ // Created by Ilyas Hallak on 16.08.25. // - - import SwiftUI import os struct LoggingConfigurationView: View { @StateObject private var logConfig = LogConfiguration.shared private let logger = Logger.ui - + var body: some View { - NavigationView { - Form { - Section(header: Text("Global Settings")) { - VStack(alignment: .leading, spacing: 8) { - Text("Global Minimum Level") - .font(.headline) - - Picker("Global Level", selection: $logConfig.globalMinLevel) { - ForEach(LogLevel.allCases, id: \.self) { level in - HStack { - Text(level.emoji) - Text(level.rawValue == 0 ? "Debug" : - level.rawValue == 1 ? "Info" : - level.rawValue == 2 ? "Notice" : - level.rawValue == 3 ? "Warning" : - level.rawValue == 4 ? "Error" : "Critical") - } - .tag(level) - } + List { + Section { + Toggle("Enable Logging", isOn: $logConfig.isLoggingEnabled) + .tint(.green) + } header: { + Text("Logging Status") + } footer: { + Text("Enable logging to capture debug messages. When disabled, no logs are recorded to reduce device performance impact.") + } + + if logConfig.isLoggingEnabled { + Section { + NavigationLink { + GlobalLogLevelView(logConfig: logConfig) + } label: { + HStack { + Label("Global Log Level", systemImage: "slider.horizontal.3") + Spacer() + Text(levelName(for: logConfig.globalMinLevel)) + .foregroundColor(.secondary) } - .pickerStyle(SegmentedPickerStyle()) - - Text("Logs below this level will be filtered out globally") - .font(.caption) - .foregroundColor(.secondary) } - + Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs) Toggle("Show Timestamps", isOn: $logConfig.showTimestamps) Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation) - } - - Section(header: Text("Category-specific Levels")) { - ForEach(LogCategory.allCases, id: \.self) { category in - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(category.rawValue) - .font(.headline) - Spacer() - Text(levelName(for: logConfig.getLevel(for: category))) - .font(.caption) - .foregroundColor(.secondary) - } - - Picker("Level for \(category.rawValue)", selection: Binding( - get: { logConfig.getLevel(for: category) }, - set: { logConfig.setLevel($0, for: category) } - )) { - ForEach(LogLevel.allCases, id: \.self) { level in - HStack { - Text(level.emoji) - Text(levelName(for: level)) - } - .tag(level) - } - } - .pickerStyle(SegmentedPickerStyle()) - } - .padding(.vertical, 4) - } - } - - Section(header: Text("Reset")) { - Button("Reset to Defaults") { - resetToDefaults() - } - .foregroundColor(.orange) - } - - Section(footer: Text("Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).")) { - EmptyView() + } header: { + Text("Global Settings") + } footer: { + Text("Logs below the global level will be filtered out globally") + } + } + + if logConfig.isLoggingEnabled { + Section { + ForEach(LogCategory.allCases, id: \.self) { category in + NavigationLink { + CategoryLogLevelView(category: category, logConfig: logConfig) + } label: { + HStack { + Text(category.rawValue) + Spacer() + Text(levelName(for: logConfig.getLevel(for: category))) + .foregroundColor(.secondary) + } + } + } + } header: { + Text("Category-specific Levels") + } footer: { + Text("Configure log levels for each category individually") + } + } + + Section { + Button(role: .destructive) { + resetToDefaults() + } label: { + Label("Reset to Defaults", systemImage: "arrow.counterclockwise") } } - .navigationTitle("Logging Configuration") - .navigationBarTitleDisplayMode(.inline) } + .navigationTitle("Logging Configuration") + .navigationBarTitleDisplayMode(.inline) .onAppear { logger.debug("Opened logging configuration view") } } - + private func levelName(for level: LogLevel) -> String { switch level.rawValue { case 0: return "Debug" @@ -107,25 +93,140 @@ struct LoggingConfigurationView: View { default: return "Unknown" } } - + private func resetToDefaults() { logger.info("Resetting logging configuration to defaults") - - // Reset all category levels (this will use globalMinLevel as fallback) + for category in LogCategory.allCases { logConfig.setLevel(.debug, for: category) } - - // Reset global settings + logConfig.globalMinLevel = .debug logConfig.showPerformanceLogs = true logConfig.showTimestamps = true logConfig.includeSourceLocation = true - + logger.info("Logging configuration reset to defaults") } } -#Preview { - LoggingConfigurationView() +// MARK: - Global Log Level View + +struct GlobalLogLevelView: View { + @ObservedObject var logConfig: LogConfiguration + + var body: some View { + List { + ForEach(LogLevel.allCases, id: \.self) { level in + Button { + logConfig.globalMinLevel = level + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(levelName(for: level)) + .foregroundColor(.primary) + Text(levelDescription(for: level)) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if logConfig.globalMinLevel == level { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + } + .navigationTitle("Global Log Level") + .navigationBarTitleDisplayMode(.inline) + } + + 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" + } + } + + private func levelDescription(for level: LogLevel) -> String { + switch level.rawValue { + case 0: return "Show all logs including debug information" + case 1: return "Show informational messages and above" + case 2: return "Show notable events and above" + case 3: return "Show warnings and errors only" + case 4: return "Show errors and critical issues only" + case 5: return "Show only critical issues" + default: return "" + } + } +} + +// MARK: - Category Log Level View + +struct CategoryLogLevelView: View { + let category: LogCategory + @ObservedObject var logConfig: LogConfiguration + + var body: some View { + List { + ForEach(LogLevel.allCases, id: \.self) { level in + Button { + logConfig.setLevel(level, for: category) + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(levelName(for: level)) + .foregroundColor(.primary) + Text(levelDescription(for: level)) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if logConfig.getLevel(for: category) == level { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + } + .navigationTitle("\(category.rawValue) Logs") + .navigationBarTitleDisplayMode(.inline) + } + + 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" + } + } + + private func levelDescription(for level: LogLevel) -> String { + switch level.rawValue { + case 0: return "Show all logs including debug information" + case 1: return "Show informational messages and above" + case 2: return "Show notable events and above" + case 3: return "Show warnings and errors only" + case 4: return "Show errors and critical issues only" + case 5: return "Show only critical issues" + default: return "" + } + } +} + +#Preview { + NavigationStack { + LoggingConfigurationView() + } } diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift index 039db50..01425ea 100644 --- a/readeck/UI/Settings/SettingsContainerView.swift +++ b/readeck/UI/Settings/SettingsContainerView.swift @@ -46,8 +46,17 @@ struct SettingsContainerView: View { private var debugSettingsSection: some View { Section { SettingsRowNavigationLink( - icon: "doc.text.magnifyingglass", + icon: "list.bullet.rectangle", iconColor: .blue, + title: "Debug Logs", + subtitle: "View all debug messages" + ) { + DebugLogViewer() + } + + SettingsRowNavigationLink( + icon: "slider.horizontal.3", + iconColor: .purple, title: "Logging Configuration", subtitle: "Configure log levels and categories" ) { diff --git a/readeck/Utils/LogStore.swift b/readeck/Utils/LogStore.swift new file mode 100644 index 0000000..d3e85b6 --- /dev/null +++ b/readeck/Utils/LogStore.swift @@ -0,0 +1,145 @@ +// +// LogStore.swift +// readeck +// +// Created by Ilyas Hallak on 01.11.25. +// + +import Foundation + +// MARK: - Log Entry + +struct LogEntry: Identifiable, Codable { + let id: UUID + let timestamp: Date + let level: LogLevel + let category: LogCategory + let message: String + let file: String + let function: String + let line: Int + + var fileName: String { + URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "") + } + + var formattedTimestamp: String { + DateFormatter.logTimestamp.string(from: timestamp) + } + + init( + id: UUID = UUID(), + timestamp: Date = Date(), + level: LogLevel, + category: LogCategory, + message: String, + file: String, + function: String, + line: Int + ) { + self.id = id + self.timestamp = timestamp + self.level = level + self.category = category + self.message = message + self.file = file + self.function = function + self.line = line + } +} + +// MARK: - Log Store + +actor LogStore { + static let shared = LogStore() + + private var entries: [LogEntry] = [] + private let maxEntries: Int + + private init(maxEntries: Int = 1000) { + self.maxEntries = maxEntries + } + + func addEntry(_ entry: LogEntry) { + entries.append(entry) + + // Keep only the most recent entries + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + } + + func getEntries() -> [LogEntry] { + return entries + } + + func getEntries( + level: LogLevel? = nil, + category: LogCategory? = nil, + searchText: String? = nil + ) -> [LogEntry] { + var filtered = entries + + if let level = level { + filtered = filtered.filter { $0.level == level } + } + + if let category = category { + filtered = filtered.filter { $0.category == category } + } + + if let searchText = searchText, !searchText.isEmpty { + filtered = filtered.filter { + $0.message.localizedCaseInsensitiveContains(searchText) || + $0.fileName.localizedCaseInsensitiveContains(searchText) || + $0.function.localizedCaseInsensitiveContains(searchText) + } + } + + return filtered + } + + func clear() { + entries.removeAll() + } + + func exportAsText() -> String { + var text = "Readeck Debug Logs\n" + text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n" + text += "Total Entries: \(entries.count)\n" + text += String(repeating: "=", count: 80) + "\n\n" + + for entry in entries { + text += "[\(entry.formattedTimestamp)] " + text += "[\(entry.level.emoji) \(levelName(for: entry.level))] " + text += "[\(entry.category.rawValue)] " + text += "\(entry.fileName):\(entry.line) " + text += "\(entry.function)\n" + text += " \(entry.message)\n\n" + } + + return text + } + + 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: - DateFormatter Extension + +extension DateFormatter { + static let exportTimestamp: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() +} diff --git a/readeck/Utils/Logger.swift b/readeck/Utils/Logger.swift index cc8f6c1..9f1feb1 100644 --- a/readeck/Utils/Logger.swift +++ b/readeck/Utils/Logger.swift @@ -10,14 +10,14 @@ import os // MARK: - Log Configuration -enum LogLevel: Int, CaseIterable { +enum LogLevel: Int, CaseIterable, Codable { case debug = 0 case info = 1 case notice = 2 case warning = 3 case error = 4 case critical = 5 - + var emoji: String { switch self { case .debug: return "🔍" @@ -30,7 +30,7 @@ enum LogLevel: Int, CaseIterable { } } -enum LogCategory: String, CaseIterable { +enum LogCategory: String, CaseIterable, Codable { case network = "Network" case ui = "UI" case data = "Data" @@ -43,13 +43,14 @@ enum LogCategory: String, CaseIterable { class LogConfiguration: ObservableObject { static let shared = LogConfiguration() - + @Published private var categoryLevels: [LogCategory: LogLevel] = [:] @Published var globalMinLevel: LogLevel = .debug @Published var showPerformanceLogs = true @Published var showTimestamps = true @Published var includeSourceLocation = true - + @Published var isLoggingEnabled = false + private init() { loadConfiguration() } @@ -64,6 +65,7 @@ class LogConfiguration: ObservableObject { } func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool { + guard isLoggingEnabled else { return false } let categoryLevel = getLevel(for: category) return level.rawValue >= categoryLevel.rawValue } @@ -84,6 +86,7 @@ class LogConfiguration: ObservableObject { showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance") showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps") includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation") + isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled") } private func saveConfiguration() { @@ -96,6 +99,7 @@ class LogConfiguration: ObservableObject { UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance") UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps") UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation") + UserDefaults.standard.set(isLoggingEnabled, forKey: "LogIsEnabled") } } @@ -110,41 +114,66 @@ struct Logger { } // MARK: - Log Levels - + func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { guard config.shouldLog(.debug, for: category) else { return } let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line) logger.debug("\(formattedMessage)") + storeLog(message: message, level: .debug, file: file, function: function, line: line) } - + func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { guard config.shouldLog(.info, for: category) else { return } let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line) logger.info("\(formattedMessage)") + storeLog(message: message, level: .info, file: file, function: function, line: line) } - + func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { guard config.shouldLog(.notice, for: category) else { return } let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line) logger.notice("\(formattedMessage)") + storeLog(message: message, level: .notice, file: file, function: function, line: line) } - + func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { guard config.shouldLog(.warning, for: category) else { return } let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line) logger.warning("\(formattedMessage)") + storeLog(message: message, level: .warning, file: file, function: function, line: line) } - + func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { guard config.shouldLog(.error, for: category) else { return } let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line) logger.error("\(formattedMessage)") + storeLog(message: message, level: .error, file: file, function: function, line: line) } - + func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { guard config.shouldLog(.critical, for: category) else { return } let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line) logger.critical("\(formattedMessage)") + storeLog(message: message, level: .critical, file: file, function: function, line: line) + } + + // MARK: - Store Log + + private func storeLog(message: String, level: LogLevel, file: String, function: String, line: Int) { + #if DEBUG + guard config.isLoggingEnabled else { return } + let entry = LogEntry( + level: level, + category: category, + message: message, + file: file, + function: function, + line: line + ) + Task { + await LogStore.shared.addEntry(entry) + } + #endif } // MARK: - Convenience Methods