Files
yattee/Yattee/Views/Settings/LogViewerView.swift
2026-02-08 18:33:56 +01:00

346 lines
11 KiB
Swift

//
// LogViewerView.swift
// Yattee
//
// In-app log viewer for debugging.
//
import SwiftUI
struct LogViewerView: View {
@State private var loggingService = LoggingService.shared
@State private var showingFilters = false
@State private var showingExportSheet = false
@State private var selectedEntry: LogEntry?
#if os(tvOS)
@State private var logExportServer = LogExportHTTPServer()
#endif
var body: some View {
VStack(spacing: 0) {
// Search bar
searchBar
// Log list
logList
}
.navigationTitle(String(localized: "settings.advanced.logs.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
showingFilters = true
} label: {
Label(String(localized: "settings.advanced.logs.filter"), systemImage: "line.3.horizontal.decrease.circle")
}
Button {
showingExportSheet = true
} label: {
Label(String(localized: "settings.advanced.logs.export"), systemImage: "square.and.arrow.up")
}
Divider()
Button(role: .destructive) {
loggingService.clearLogs()
} label: {
Label(String(localized: "settings.advanced.logs.clear"), systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $showingFilters) {
LogFiltersSheet(loggingService: loggingService)
}
#if os(tvOS)
.sheet(isPresented: $showingExportSheet) {
LogExportOverlayView(server: logExportServer)
}
#else
.sheet(isPresented: $showingExportSheet) {
ShareSheet(items: [loggingService.exportLogs()])
}
#endif
.sheet(item: $selectedEntry) { entry in
LogEntryDetailView(entry: entry)
}
}
private var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(String(localized: "settings.advanced.logs.search"), text: $loggingService.searchText)
.textFieldStyle(.plain)
if !loggingService.searchText.isEmpty {
Button {
loggingService.searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(10)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding()
}
private var logList: some View {
Group {
if loggingService.filteredEntries.isEmpty {
ContentUnavailableView {
Label(String(localized: "settings.advanced.logs.empty"), systemImage: "doc.text")
} description: {
if loggingService.isEnabled {
Text(String(localized: "settings.advanced.logs.empty.description"))
} else {
Text(String(localized: "settings.advanced.logs.disabled"))
}
}
} else {
List(loggingService.filteredEntries) { entry in
LogEntryRow(entry: entry)
.contentShape(Rectangle())
.onTapGesture {
selectedEntry = entry
}
}
.listStyle(.plain)
}
}
}
}
// MARK: - Log Entry Row
private struct LogEntryRow: View {
let entry: LogEntry
var body: some View {
HStack(alignment: .top, spacing: 10) {
// Level indicator
Circle()
.fill(levelColor)
.frame(width: 8, height: 8)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 4) {
// Header: timestamp and category
HStack {
Text(entry.formattedTimestamp)
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
Text(entry.category.rawValue)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.quaternary)
.clipShape(Capsule())
}
// Message
Text(entry.message)
.font(.subheadline)
.lineLimit(2)
// Details preview
if let details = entry.details {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
}
.padding(.vertical, 4)
}
private var levelColor: Color {
switch entry.level {
case .debug: return .gray
case .info: return .blue
case .warning: return .orange
case .error: return .red
}
}
}
// MARK: - Log Entry Detail View
private struct LogEntryDetailView: View {
let entry: LogEntry
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section(String(localized: "settings.advanced.logs.detail.info")) {
LabeledContent(String(localized: "settings.advanced.logs.detail.timestamp")) {
Text(entry.timestamp.formatted(date: .abbreviated, time: .standard))
}
LabeledContent(String(localized: "settings.advanced.logs.detail.level")) {
HStack {
Image(systemName: entry.level.icon)
Text(entry.level.rawValue.capitalized)
}
.foregroundStyle(levelColor)
}
LabeledContent(String(localized: "settings.advanced.logs.detail.category")) {
HStack {
Image(systemName: entry.category.icon)
Text(entry.category.rawValue)
}
}
}
Section(String(localized: "settings.advanced.logs.detail.message")) {
Text(entry.message)
.font(.body)
#if !os(tvOS)
.textSelection(.enabled)
#endif
}
if let details = entry.details {
Section(String(localized: "settings.advanced.logs.detail.details")) {
Text(details)
.font(.caption)
.fontDesign(.monospaced)
#if !os(tvOS)
.textSelection(.enabled)
#endif
}
}
}
.navigationTitle(String(localized: "settings.advanced.logs.detail.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .cancel) {
dismiss()
} label: {
Label("Close", systemImage: "xmark")
.labelStyle(.iconOnly)
}
}
}
}
.presentationDetents([.medium, .large])
}
private var levelColor: Color {
switch entry.level {
case .debug: return .gray
case .info: return .blue
case .warning: return .orange
case .error: return .red
}
}
}
// MARK: - Log Filters Sheet
private struct LogFiltersSheet: View {
@Bindable var loggingService: LoggingService
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section(String(localized: "settings.advanced.logs.filter.categories")) {
ForEach(LogCategory.allCases, id: \.self) { category in
Toggle(isOn: binding(for: category)) {
Label(category.rawValue, systemImage: category.icon)
}
}
}
Section(String(localized: "settings.advanced.logs.filter.levels")) {
ForEach(LogLevel.allCases, id: \.self) { level in
Toggle(isOn: binding(for: level)) {
Label(level.rawValue.capitalized, systemImage: level.icon)
}
}
}
Section {
Button(String(localized: "settings.advanced.logs.filter.reset")) {
loggingService.resetFilters()
}
}
}
.navigationTitle(String(localized: "settings.advanced.logs.filter.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .cancel) {
dismiss()
} label: {
Label("Close", systemImage: "xmark")
.labelStyle(.iconOnly)
}
}
}
}
.presentationDetents([.medium])
}
private func binding(for category: LogCategory) -> Binding<Bool> {
Binding(
get: { loggingService.selectedCategories.contains(category) },
set: { isSelected in
if isSelected {
loggingService.selectedCategories.insert(category)
} else {
loggingService.selectedCategories.remove(category)
}
}
)
}
private func binding(for level: LogLevel) -> Binding<Bool> {
Binding(
get: { loggingService.selectedLevels.contains(level) },
set: { isSelected in
if isSelected {
loggingService.selectedLevels.insert(level)
} else {
loggingService.selectedLevels.remove(level)
}
}
)
}
}
// MARK: - Preview
#Preview {
NavigationStack {
LogViewerView()
}
}