From b573ed564507c4079ebac11e26971f2f81502028 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 21 Jan 2026 13:48:10 +0100 Subject: [PATCH] create dedicated log screen --- app/lib/core/logger.dart | 6 +- app/lib/core/routing/routes.dart | 1 + app/lib/core/routing/routes.g.dart | 25 ++ app/lib/core/routing/routes.settings.dart | 8 + .../screens/advanced_settings.dart | 20 +- .../screens/error_logs_screen.dart | 363 ++++++++++++++++++ 6 files changed, 407 insertions(+), 16 deletions(-) create mode 100644 app/lib/features/settings/presentation/screens/error_logs_screen.dart diff --git a/app/lib/core/logger.dart b/app/lib/core/logger.dart index 4ff9988b..1602d8d0 100644 --- a/app/lib/core/logger.dart +++ b/app/lib/core/logger.dart @@ -31,4 +31,8 @@ final loggerMemory = MemoryOutput( bufferSize: 255, secondOutput: ConsoleOutput(), ); -final logger = Logger(filter: _DebugLogFilter(), output: loggerMemory); +final logger = Logger( + filter: _DebugLogFilter(), + printer: PrettyPrinter(dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart), + output: loggerMemory, +); diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index f5bce488..258e61b2 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -57,6 +57,7 @@ import 'package:weblibre/features/settings/presentation/screens/advanced_setting import 'package:weblibre/features/settings/presentation/screens/appearance_display_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/bang_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/doh_settings.dart'; +import 'package:weblibre/features/settings/presentation/screens/error_logs_screen.dart'; import 'package:weblibre/features/settings/presentation/screens/fingerprint_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/fingerprinting_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/locale_settings.dart'; diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index a0627f96..349dfbbf 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -151,6 +151,11 @@ RouteBase get $settingsRoute => GoRouteData.$route( name: 'TrackingProtectionExceptionsRoute', factory: $TrackingProtectionExceptionsRoute._fromState, ), + GoRouteData.$route( + path: 'error_logs', + name: 'ErrorLogsRoute', + factory: $ErrorLogsRoute._fromState, + ), ], ); @@ -473,6 +478,26 @@ mixin $TrackingProtectionExceptionsRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $ErrorLogsRoute on GoRouteData { + static ErrorLogsRoute _fromState(GoRouterState state) => ErrorLogsRoute(); + + @override + String get location => GoRouteData.$location('/settings/error_logs'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + RouteBase get $browserRoute => GoRouteData.$route( path: '/browser', name: 'BrowserRoute', diff --git a/app/lib/core/routing/routes.settings.dart b/app/lib/core/routing/routes.settings.dart index da7429f5..f49d95ee 100644 --- a/app/lib/core/routing/routes.settings.dart +++ b/app/lib/core/routing/routes.settings.dart @@ -75,6 +75,7 @@ part of 'routes.dart'; name: 'TrackingProtectionExceptionsRoute', path: 'tracking_protection_exceptions', ), + TypedGoRoute(name: 'ErrorLogsRoute', path: 'error_logs'), ], ) class SettingsRoute extends GoRouteData with $SettingsRoute { @@ -194,3 +195,10 @@ class TrackingProtectionExceptionsRoute extends GoRouteData return const TrackingProtectionExceptionsScreen(); } } + +class ErrorLogsRoute extends GoRouteData with $ErrorLogsRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const ErrorLogsScreen(); + } +} diff --git a/app/lib/features/settings/presentation/screens/advanced_settings.dart b/app/lib/features/settings/presentation/screens/advanced_settings.dart index b32de5e5..c108bd42 100644 --- a/app/lib/features/settings/presentation/screens/advanced_settings.dart +++ b/app/lib/features/settings/presentation/screens/advanced_settings.dart @@ -27,8 +27,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/providers/app_state.dart'; +import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/dialogs/user_agent_restart_dialog.dart'; import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart'; @@ -272,7 +272,7 @@ class _ErrorLogsTile extends StatelessWidget { Widget build(BuildContext context) { return CustomListTile( title: 'Error Logs', - subtitle: 'Copy logs for issue reporting', + subtitle: 'View and copy logs for issue reporting', prefix: Padding( padding: const EdgeInsets.only(right: 16.0), child: Icon( @@ -283,20 +283,10 @@ class _ErrorLogsTile extends StatelessWidget { ), suffix: FilledButton.icon( onPressed: () async { - await Clipboard.setData( - ClipboardData( - text: loggerMemory.buffer - .map((e) => e.lines.join('\n')) - .join('\n\n'), - ), - ); - - if (context.mounted) { - showInfoMessage(context, 'Logs copied'); - } + await ErrorLogsRoute().push(context); }, - icon: const Icon(Icons.copy), - label: const Text('Copy'), + icon: const Icon(Icons.open_in_new), + label: const Text('View'), ), ); } diff --git a/app/lib/features/settings/presentation/screens/error_logs_screen.dart b/app/lib/features/settings/presentation/screens/error_logs_screen.dart new file mode 100644 index 00000000..4ab4f3b2 --- /dev/null +++ b/app/lib/features/settings/presentation/screens/error_logs_screen.dart @@ -0,0 +1,363 @@ +// ignore_for_file: deprecated_member_use + +/* + * Copyright (c) 2024-2025 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; +import 'package:logger/logger.dart'; +import 'package:timeago/timeago.dart' as timeago; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +IconData _levelIcon(Level level) { + return switch (level) { + Level.trace => Icons.blur_circular, + Level.debug => Icons.bug_report, + Level.info => Icons.info, + Level.warning => Icons.warning, + Level.error => Icons.error, + Level.fatal => Icons.dangerous, + Level.all => Icons.notes, + Level.verbose => Icons.chat_bubble_outline, + Level.wtf => Icons.question_mark, + Level.nothing => Icons.close, + Level.off => Icons.offline_bolt, + }; +} + +Color _levelColor(Level level) { + return switch (level) { + Level.trace => Colors.grey, + Level.debug => Colors.blue, + Level.info => Colors.cyan, + Level.warning => Colors.orange, + Level.error => Colors.red, + Level.fatal => Colors.purple, + Level.all => Colors.grey, + Level.verbose => Colors.teal, + Level.wtf => Colors.brown, + Level.nothing => Colors.grey, + Level.off => Colors.grey, + }; +} + +Color _levelBackgroundColor(Level level, BuildContext context) { + return switch (level) { + Level.trace => Colors.grey.withValues(alpha: 0.1), + Level.debug => Colors.blue.withValues(alpha: 0.05), + Level.info => Colors.cyan.withValues(alpha: 0.05), + Level.warning => Colors.orange.withValues(alpha: 0.1), + Level.error => Colors.red.withValues(alpha: 0.1), + Level.fatal => Colors.purple.withValues(alpha: 0.1), + Level.all => Colors.grey.withValues(alpha: 0.1), + Level.verbose => Colors.teal.withValues(alpha: 0.05), + Level.wtf => Colors.brown.withValues(alpha: 0.1), + Level.nothing => Colors.grey.withValues(alpha: 0.05), + Level.off => Colors.grey.withValues(alpha: 0.05), + }; +} + +class ErrorLogsScreen extends HookWidget { + const ErrorLogsScreen({super.key}); + + String _logsText() { + final logs = loggerMemory.buffer; + return logs.map((e) => e.lines.join('\n')).join('\n\n'); + } + + Future _copyToClipboard(BuildContext context) async { + await Clipboard.setData(ClipboardData(text: _logsText())); + if (context.mounted) { + showInfoMessage(context, 'Logs copied'); + } + } + + @override + Widget build(BuildContext context) { + final minLogLevel = useState(Level.all); + final allLogs = useMemoized(() => loggerMemory.buffer.toList()); + + final sortedLogs = useMemoized( + () => allLogs.reversed + .where((e) => e.level.value >= minLogLevel.value.value) + .toList(), + [allLogs, minLogLevel.value], + ); + + return Scaffold( + appBar: AppBar( + title: const Text('Error Logs'), + actions: [ + MenuAnchor( + builder: (context, controller, childAnchor) { + return TextButton.icon( + onPressed: controller.open, + icon: const Icon(Icons.filter_list), + label: Text(minLogLevel.value.name.toUpperCase()), + ); + }, + menuChildren: [ + const Divider(height: 0), + _buildLevelFilterMenuItem(context, minLogLevel, Level.trace), + _buildLevelFilterMenuItem(context, minLogLevel, Level.debug), + _buildLevelFilterMenuItem(context, minLogLevel, Level.info), + _buildLevelFilterMenuItem(context, minLogLevel, Level.warning), + _buildLevelFilterMenuItem(context, minLogLevel, Level.error), + _buildLevelFilterMenuItem(context, minLogLevel, Level.fatal), + ], + ), + IconButton( + onPressed: () => _copyToClipboard(context), + icon: const Icon(Icons.copy), + tooltip: 'Copy logs', + ), + ], + ), + body: SafeArea( + child: sortedLogs.isEmpty + ? const Center(child: Text('No logs available')) + : ListView.builder( + padding: const EdgeInsets.all(8.0), + itemCount: sortedLogs.length, + itemBuilder: (context, index) { + final logEntry = sortedLogs[index]; + return _LogEntryTile(event: logEntry); + }, + ), + ), + ); + } + + Widget _buildLevelFilterMenuItem( + BuildContext context, + ValueNotifier minLogLevel, + Level level, + ) { + final isSelected = level.value == minLogLevel.value.value; + final levelColor = _levelColor(level); + + return CheckboxMenuButton( + trailingIcon: Icon(_levelIcon(level), color: levelColor), + value: isSelected, + onChanged: (value) { + if (value == true) { + minLogLevel.value = level; + } + }, + child: Text(level.name.toUpperCase()), + ); + } +} + +class _LogEntryTile extends StatelessWidget { + const _LogEntryTile({required this.event}); + + final OutputEvent event; + + @override + Widget build(BuildContext context) { + final logEvent = event.origin; + + final level = logEvent.level; + final message = logEvent.message?.toString() ?? ''; + final error = logEvent.error?.toString(); + final stackTrace = logEvent.stackTrace?.toString(); + final time = logEvent.time; + + final iconData = _levelIcon(level); + final iconColor = _levelColor(level); + final backgroundColor = _levelBackgroundColor(level, context); + + return Card( + color: backgroundColor, + margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: InkWell( + onTap: () async { + await showDialog( + context: context, + builder: (context) => _LogDetailsDialog( + level: level, + message: message, + error: error, + stackTrace: stackTrace, + time: time, + ), + ); + }, + child: ListTile( + leading: Icon(iconData, color: iconColor, size: 24), + title: Text( + message, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.robotoMono(fontSize: 12), + ), + subtitle: _buildTime(time, context), + ), + ), + ); + } + + Widget? _buildTime(DateTime? time, BuildContext context) { + if (time == null) return null; + + return Text( + timeago.format(time), + style: TextStyle( + fontSize: 10, + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } +} + +class _LogDetailsDialog extends StatelessWidget { + const _LogDetailsDialog({ + required this.level, + required this.message, + this.error, + this.stackTrace, + required this.time, + }); + + final Level level; + final String message; + final String? error; + final String? stackTrace; + final DateTime time; + + String _formatTime(DateTime time) { + return DateFormat('HH:mm:ss.SSS').format(time); + } + + Future _copyEntryToClipboard(BuildContext context) async { + final text = StringBuffer(); + + text.writeln( + '[${_formatTime(time)}] ${level.name.toUpperCase()}: $message', + ); + if (error != null) { + text.writeln('Error: $error'); + } + if (stackTrace != null) { + text.writeln('Stack Trace:'); + text.writeln(stackTrace); + } + + await Clipboard.setData(ClipboardData(text: text.toString())); + + if (context.mounted) { + showInfoMessage(context, 'Entry copied'); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Row( + children: [ + Icon(_levelIcon(level), color: _levelColor(level)), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(level.name.toUpperCase()), + Text( + _formatTime(time), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (message.isNotEmpty) ...[ + Text( + 'Message:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 4), + SelectableText( + message, + style: GoogleFonts.robotoMono(fontSize: 12), + ), + const SizedBox(height: 16), + ], + if (error != null) ...[ + Text( + 'Error:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: _levelColor(level), + ), + ), + const SizedBox(height: 4), + SelectableText( + error!, + style: GoogleFonts.robotoMono(fontSize: 11), + ), + const SizedBox(height: 16), + ], + if (stackTrace != null) ...[ + Text( + 'Stack Trace:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 4), + SelectableText( + stackTrace!, + style: GoogleFonts.robotoMono(fontSize: 9), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + TextButton.icon( + onPressed: () => _copyEntryToClipboard(context), + icon: const Icon(Icons.copy), + label: const Text('Copy'), + ), + ], + ); + } +}