Save the application logs

This allows us to show the logs to the user and also send them along with any
bug reports. This is a simple system that just keeps the last entries in memory
and nothing is stored permanently, but that's ok for our use case and can be
changed in the future if the need arises.
This commit is contained in:
Roland Geider
2025-09-06 17:23:21 +02:00
parent d1d6392ee1
commit 5a4d4c7208
8 changed files with 271 additions and 32 deletions

View File

@@ -33,6 +33,7 @@ import 'package:wger/models/workouts/log.dart';
import 'package:wger/providers/routines.dart';
import 'consts.dart';
import 'logs.dart';
void showHttpExceptionErrorDialog(WgerHttpException exception, {BuildContext? context}) {
final logger = Logger('showHttpExceptionErrorDialog');
@@ -115,6 +116,7 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
}
final String fullStackTrace = stackTrace?.toString() ?? 'No stack trace available.';
final applicationLogs = InMemoryLogStore().formattedLogs;
showDialog(
context: dialogContext,
@@ -163,32 +165,32 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
),
),
),
const SizedBox(height: 8),
TextButton.icon(
icon: const Icon(Icons.copy_all_outlined, size: 18),
label: Text(i18n.copyToClipboard),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
final String clipboardText = 'Error Title: $issueTitle\n'
'Error Message: $issueErrorMessage\n\n'
'Stack Trace:\n$fullStackTrace';
Clipboard.setData(ClipboardData(text: clipboardText)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error details copied to clipboard!')),
);
}).catchError((copyError) {
if (kDebugMode) {
logger.fine('Error copying to clipboard: $copyError');
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not copy details.')),
);
});
},
CopyToClipboardButton(
text: 'Error Title: $issueTitle\n'
'Error Message: $issueErrorMessage\n\n'
'Stack Trace:\n$fullStackTrace',
),
const SizedBox(height: 8),
Text(
i18n.applicationLogs,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Container(
alignment: Alignment.topLeft,
padding: const EdgeInsets.symmetric(vertical: 8.0),
constraints: const BoxConstraints(maxHeight: 250),
child: SingleChildScrollView(
child: Column(
children: [
...applicationLogs.map((entry) => Text(
entry,
style: TextStyle(fontSize: 12.0, color: Colors.grey[700]),
))
],
),
),
),
CopyToClipboardButton(text: applicationLogs.join('\n')),
],
),
],
@@ -199,6 +201,9 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
TextButton(
child: const Text('Report issue'),
onPressed: () async {
final logText = applicationLogs.isEmpty
? '-- No logs available --'
: applicationLogs.join('\n');
final description = Uri.encodeComponent(
'## Description\n\n'
'[Please describe what you were doing when the error occurred.]\n\n'
@@ -206,7 +211,9 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
'Error title: $issueTitle\n'
'Error message: $issueErrorMessage\n'
'Stack trace:\n'
'```\n$stackTrace\n```',
'```\n$stackTrace\n```\n\n'
'App logs (last ${applicationLogs.length} entries):\n'
'```\n$logText\n```',
);
final githubIssueUrl = '$GITHUB_ISSUES_BUG_URL'
'&title=$issueTitle'
@@ -237,6 +244,47 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
);
}
class CopyToClipboardButton extends StatelessWidget {
final logger = Logger('CopyToClipboardButton');
final String text;
CopyToClipboardButton({
required this.text,
super.key,
});
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
return TextButton.icon(
icon: const Icon(Icons.copy_all_outlined, size: 18),
label: Text(i18n.copyToClipboard),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
Clipboard.setData(ClipboardData(text: text)).then((_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Details copied to clipboard!')),
);
}
}).catchError((copyError) {
logger.warning('Error copying to clipboard: $copyError');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not copy details.')),
);
}
});
},
);
}
}
void showDeleteDialog(BuildContext context, String confirmDeleteName, Log log) async {
final res = await showDialog(
context: context,

50
lib/helpers/logs.dart Normal file
View File

@@ -0,0 +1,50 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) wger Team
*
* wger Workout Manager 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 <http://www.gnu.org/licenses/>.
*/
import 'package:logging/logging.dart';
/// Stores log entries in memory.
///
/// This means nothing is stored permanently anywhere and we loose everything
/// when the application closes, but that's ok for our use case and can be
/// changed in the future if the need arises.
class InMemoryLogStore {
static final InMemoryLogStore _instance = InMemoryLogStore._internal();
final List<LogRecord> _logs = [];
factory InMemoryLogStore() => _instance;
InMemoryLogStore._internal();
// Adds a new log entry, but keeps the total number of entries limited
void add(LogRecord record) {
if (_logs.length >= 500) {
_logs.removeAt(0);
}
_logs.add(record);
}
List<LogRecord> get logs => List.unmodifiable(_logs);
List<String> get formattedLogs => _logs
.map((log) =>
'${log.time.toIso8601String()} ${log.level.name} [${log.loggerName}] ${log.message}')
.toList();
void clear() => _logs.clear();
}

View File

@@ -330,6 +330,7 @@
"errorInfoDescription": "We're sorry, but something went wrong. You can help us fix this by reporting the issue on GitHub.",
"errorInfoDescription2": "You can continue using the app, but some features may not work.",
"errorViewDetails": "Technical details",
"applicationLogs": "Application logs",
"errorCouldNotConnectToServer": "Couldn't connect to server",
"errorCouldNotConnectToServerDetails": "The application could not connect to the server. Please check your internet connection or the server URL and try again. If the problem persists, contact the server administrator.",
"copyToClipboard": "Copy to clipboard",

View File

@@ -61,14 +61,17 @@ import 'package:wger/screens/update_app_screen.dart';
import 'package:wger/screens/weight_screen.dart';
import 'package:wger/theme/theme.dart';
import 'package:wger/widgets/core/about.dart';
import 'package:wger/widgets/core/log_overview.dart';
import 'package:wger/widgets/core/settings.dart';
import 'helpers/logs.dart';
import 'providers/auth.dart';
void _setupLogging() {
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
Logger.root.onRecord.listen((record) {
print('${record.level.name}: ${record.time} [${record.loggerName}] ${record.message}');
InMemoryLogStore().add(record);
});
}
@@ -247,6 +250,7 @@ class MainApp extends StatelessWidget {
AddExerciseScreen.routeName: (ctx) => const AddExerciseScreen(),
AboutPage.routeName: (ctx) => const AboutPage(),
SettingsPage.routeName: (ctx) => const SettingsPage(),
LogOverviewPage.routeName: (ctx) => const LogOverviewPage(),
ConfigurePlatesScreen.routeName: (ctx) => const ConfigurePlatesScreen(),
},
localizationsDelegates: AppLocalizations.localizationsDelegates,

View File

@@ -25,6 +25,8 @@ import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/auth.dart';
import 'package:wger/screens/add_exercise_screen.dart';
import 'log_overview.dart';
class AboutPage extends StatelessWidget {
static String routeName = '/AboutPage';
@@ -159,12 +161,14 @@ class AboutPage extends StatelessWidget {
_buildSectionHeader(context, i18n.aboutJoinCommunityTitle),
ListTile(
leading: const FaIcon(FontAwesomeIcons.discord),
trailing: const Icon(Icons.arrow_outward),
title: Text(i18n.aboutDiscordTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(DISCORD_URL, context),
),
ListTile(
leading: const FaIcon(FontAwesomeIcons.mastodon),
trailing: const Icon(Icons.arrow_outward),
title: Text(i18n.aboutMastodonTitle),
contentPadding: EdgeInsets.zero,
onTap: () => launchURL(MASTODON_URL, context),
@@ -175,6 +179,16 @@ class AboutPage extends StatelessWidget {
ListTile(
leading: const Icon(Icons.article),
trailing: const Icon(Icons.chevron_right),
title: Text(i18n.applicationLogs),
contentPadding: EdgeInsets.zero,
onTap: () {
Navigator.of(context).pushNamed(LogOverviewPage.routeName);
},
),
ListTile(
leading: const Icon(Icons.article),
trailing: const Icon(Icons.chevron_right),
title: const Text('View Licenses'),
contentPadding: EdgeInsets.zero,
onTap: () {

View File

@@ -0,0 +1,66 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) wger Team
*
* wger Workout Manager 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.
*
* wger Workout Manager 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:wger/helpers/logs.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
class LogOverviewPage extends StatelessWidget {
static String routeName = '/LogOverviewPage';
const LogOverviewPage({super.key});
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
final logs = InMemoryLogStore().logs.reversed.toList();
return Scaffold(
appBar: AppBar(title: Text(i18n.applicationLogs)),
body: logs.isEmpty
? const Center(child: Text('No logs available.'))
: ListView.builder(
itemCount: logs.length,
itemBuilder: (context, index) {
final log = logs[index];
return ListTile(
dense: true,
leading: Icon(_iconForLevel(log.level)),
title: Text('[${log.level.name}] ${log.message}'),
subtitle: Text('${log.loggerName}\n${log.time.toIso8601String()}'),
isThreeLine: true,
);
},
),
);
}
}
IconData _iconForLevel(Level level) {
if (level >= Level.SEVERE) {
return Icons.priority_high;
}
if (level >= Level.WARNING) {
return Icons.warning;
}
if (level >= Level.INFO) {
return Icons.info;
}
return Icons.bug_report;
}

View File

@@ -56,13 +56,15 @@ class _RoutinesListState extends State<RoutinesList> {
setState(() {
_loadingRoutine = currentRoutine.id;
});
await widget._routineProvider.fetchAndSetRoutineFull(currentRoutine.id!);
if (mounted) {
setState(() {
_loadingRoutine = null;
});
try {
await widget._routineProvider.fetchAndSetRoutineFull(currentRoutine.id!);
} finally {
if (mounted) {
setState(() => _loadingRoutine = null);
}
}
if (context.mounted) {
Navigator.of(context).pushNamed(
RoutineScreen.routeName,
arguments: currentRoutine.id,