mirror of
https://github.com/wger-project/flutter.git
synced 2026-02-18 00:17:48 +01:00
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:
@@ -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
50
lib/helpers/logs.dart
Normal 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();
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () {
|
||||
|
||||
66
lib/widgets/core/log_overview.dart
Normal file
66
lib/widgets/core/log_overview.dart
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user