diff --git a/app/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart b/app/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart
new file mode 100644
index 00000000..1006f1ba
--- /dev/null
+++ b/app/lib/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2024-2026 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';
+
+/// Shows a dialog asking the user to confirm adding a PWA to the home screen.
+///
+/// Returns true if the user confirms, false if cancelled.
+Future showPwaInstallConfirmDialog(BuildContext context, String name) {
+ return showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Add to Home Screen'),
+ content: Text('Add "$name" to your home screen?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(false),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.of(context).pop(true),
+ child: const Text('Add'),
+ ),
+ ],
+ ),
+ );
+}
diff --git a/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart b/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart
index e5bb33f6..13e96d58 100644
--- a/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart
+++ b/app/lib/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart
@@ -22,29 +22,14 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
+import 'package:weblibre/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart';
import 'package:weblibre/utils/ui_helper.dart';
Future showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
final manifest = ref.read(currentTabManifestProvider);
final name = manifest?.name ?? manifest?.shortName ?? 'this web app';
- final confirmed = await showDialog(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text('Add to Home Screen'),
- content: Text('Add "$name" to your home screen?'),
- actions: [
- TextButton(
- onPressed: () => Navigator.of(context).pop(false),
- child: const Text('Cancel'),
- ),
- FilledButton(
- onPressed: () => Navigator.of(context).pop(true),
- child: const Text('Add'),
- ),
- ],
- ),
- );
+ final confirmed = await showPwaInstallConfirmDialog(context, name);
if (confirmed == true) {
try {
diff --git a/app/lib/features/settings/presentation/dialogs/log_details_dialog.dart b/app/lib/features/settings/presentation/dialogs/log_details_dialog.dart
new file mode 100644
index 00000000..59c98eb5
--- /dev/null
+++ b/app/lib/features/settings/presentation/dialogs/log_details_dialog.dart
@@ -0,0 +1,211 @@
+/*
+ * Copyright (c) 2024-2026 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 .
+ */
+
+// ignore_for_file: deprecated_member_use
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:google_fonts/google_fonts.dart';
+import 'package:intl/intl.dart';
+import 'package:logger/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,
+ };
+}
+
+class LogDetailsDialog extends StatelessWidget {
+ const LogDetailsDialog({
+ required this.level,
+ required this.message,
+ this.error,
+ this.stackTrace,
+ required this.time,
+ super.key,
+ });
+
+ 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'),
+ ),
+ ],
+ );
+ }
+}
+
+Future showLogDetailsDialog(
+ BuildContext context, {
+ required Level level,
+ required String message,
+ String? error,
+ String? stackTrace,
+ required DateTime time,
+}) {
+ return showDialog(
+ context: context,
+ builder: (context) => LogDetailsDialog(
+ level: level,
+ message: message,
+ error: error,
+ stackTrace: stackTrace,
+ time: time,
+ ),
+ );
+}
diff --git a/app/lib/features/settings/presentation/screens/error_logs_screen.dart b/app/lib/features/settings/presentation/screens/error_logs_screen.dart
index 891eda6b..62c364c2 100644
--- a/app/lib/features/settings/presentation/screens/error_logs_screen.dart
+++ b/app/lib/features/settings/presentation/screens/error_logs_screen.dart
@@ -25,11 +25,11 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/features/settings/domain/providers/log_filter.dart';
+import 'package:weblibre/features/settings/presentation/dialogs/log_details_dialog.dart';
import 'package:weblibre/utils/ui_helper.dart';
IconData _levelIcon(Level level) {
@@ -203,15 +203,13 @@ class _LogEntryTile extends StatelessWidget {
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,
- ),
+ await showLogDetailsDialog(
+ context,
+ level: level,
+ message: message,
+ error: error,
+ stackTrace: stackTrace,
+ time: time,
);
},
child: ListTile(
@@ -241,133 +239,3 @@ class _LogEntryTile extends StatelessWidget {
);
}
}
-
-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'),
- ),
- ],
- );
- }
-}