From 5a4d4c7208a50332e92bcefce0b86cab78148b5a Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Sat, 6 Sep 2025 17:23:21 +0200 Subject: [PATCH] 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. --- lib/helpers/errors.dart | 100 ++++++++++++++++++------ lib/helpers/logs.dart | 50 ++++++++++++ lib/l10n/app_en.arb | 1 + lib/main.dart | 4 + lib/widgets/core/about.dart | 14 ++++ lib/widgets/core/log_overview.dart | 66 ++++++++++++++++ lib/widgets/routines/routines_list.dart | 14 ++-- test/helpers/logs_test.dart | 54 +++++++++++++ 8 files changed, 271 insertions(+), 32 deletions(-) create mode 100644 lib/helpers/logs.dart create mode 100644 lib/widgets/core/log_overview.dart create mode 100644 test/helpers/logs_test.dart diff --git a/lib/helpers/errors.dart b/lib/helpers/errors.dart index 4a4a22de..4c313bf4 100644 --- a/lib/helpers/errors.dart +++ b/lib/helpers/errors.dart @@ -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, diff --git a/lib/helpers/logs.dart b/lib/helpers/logs.dart new file mode 100644 index 00000000..38aea584 --- /dev/null +++ b/lib/helpers/logs.dart @@ -0,0 +1,50 @@ +/* + * This file is part of wger Workout Manager . + * 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 . + */ + +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 _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 get logs => List.unmodifiable(_logs); + + List get formattedLogs => _logs + .map((log) => + '${log.time.toIso8601String()} ${log.level.name} [${log.loggerName}] ${log.message}') + .toList(); + + void clear() => _logs.clear(); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 22c4abae..093a9447 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/main.dart b/lib/main.dart index 509c176d..216e881f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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, diff --git a/lib/widgets/core/about.dart b/lib/widgets/core/about.dart index ee98cbbc..9e430bcd 100644 --- a/lib/widgets/core/about.dart +++ b/lib/widgets/core/about.dart @@ -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: () { diff --git a/lib/widgets/core/log_overview.dart b/lib/widgets/core/log_overview.dart new file mode 100644 index 00000000..2e335ebb --- /dev/null +++ b/lib/widgets/core/log_overview.dart @@ -0,0 +1,66 @@ +/* + * This file is part of wger Workout Manager . + * 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 . + */ + +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; +} diff --git a/lib/widgets/routines/routines_list.dart b/lib/widgets/routines/routines_list.dart index 618f9a85..bede513d 100644 --- a/lib/widgets/routines/routines_list.dart +++ b/lib/widgets/routines/routines_list.dart @@ -56,13 +56,15 @@ class _RoutinesListState extends State { 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, diff --git a/test/helpers/logs_test.dart b/test/helpers/logs_test.dart new file mode 100644 index 00000000..92867e0c --- /dev/null +++ b/test/helpers/logs_test.dart @@ -0,0 +1,54 @@ +/* + * This file is part of wger Workout Manager . + * 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 . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:logging/logging.dart'; +import 'package:wger/helpers/logs.dart'; + +void main() { + group('log store test cases', () { + late InMemoryLogStore logStore; + + setUp(() { + logStore = InMemoryLogStore(); + logStore.clear(); + }); + + test('class returns a singleton', () { + final logStore1 = InMemoryLogStore(); + final logStore2 = InMemoryLogStore(); + expect(identical(logStore1, logStore2), true); + }); + + test('correctly adds LogRecords', () { + logStore.add(LogRecord(Level.INFO, 'this is a test', 'testLogger')); + + expect(logStore.logs.length, 1); + expect(logStore.formattedLogs.length, 1); + }); + + test('total number of logs is limited', () { + for (var i = 0; i < 600; i++) { + logStore.add(LogRecord(Level.INFO, 'this is log $i', 'testLogger')); + } + + expect(logStore.logs.length, 500); + expect(logStore.logs.first.message, 'this is log 100'); + }); + }); +}