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'); + }); + }); +}