Skip to content

Commit 5a4d4c7

Browse files
committed
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.
1 parent d1d6392 commit 5a4d4c7

File tree

8 files changed

+269
-30
lines changed

8 files changed

+269
-30
lines changed

lib/helpers/errors.dart

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import 'package:wger/models/workouts/log.dart';
3333
import 'package:wger/providers/routines.dart';
3434

3535
import 'consts.dart';
36+
import 'logs.dart';
3637

3738
void showHttpExceptionErrorDialog(WgerHttpException exception, {BuildContext? context}) {
3839
final logger = Logger('showHttpExceptionErrorDialog');
@@ -115,6 +116,7 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
115116
}
116117

117118
final String fullStackTrace = stackTrace?.toString() ?? 'No stack trace available.';
119+
final applicationLogs = InMemoryLogStore().formattedLogs;
118120

119121
showDialog(
120122
context: dialogContext,
@@ -163,32 +165,32 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
163165
),
164166
),
165167
),
168+
CopyToClipboardButton(
169+
text: 'Error Title: $issueTitle\n'
170+
'Error Message: $issueErrorMessage\n\n'
171+
'Stack Trace:\n$fullStackTrace',
172+
),
166173
const SizedBox(height: 8),
167-
TextButton.icon(
168-
icon: const Icon(Icons.copy_all_outlined, size: 18),
169-
label: Text(i18n.copyToClipboard),
170-
style: TextButton.styleFrom(
171-
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
172-
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
174+
Text(
175+
i18n.applicationLogs,
176+
style: const TextStyle(fontWeight: FontWeight.bold),
177+
),
178+
Container(
179+
alignment: Alignment.topLeft,
180+
padding: const EdgeInsets.symmetric(vertical: 8.0),
181+
constraints: const BoxConstraints(maxHeight: 250),
182+
child: SingleChildScrollView(
183+
child: Column(
184+
children: [
185+
...applicationLogs.map((entry) => Text(
186+
entry,
187+
style: TextStyle(fontSize: 12.0, color: Colors.grey[700]),
188+
))
189+
],
190+
),
173191
),
174-
onPressed: () {
175-
final String clipboardText = 'Error Title: $issueTitle\n'
176-
'Error Message: $issueErrorMessage\n\n'
177-
'Stack Trace:\n$fullStackTrace';
178-
Clipboard.setData(ClipboardData(text: clipboardText)).then((_) {
179-
ScaffoldMessenger.of(context).showSnackBar(
180-
const SnackBar(content: Text('Error details copied to clipboard!')),
181-
);
182-
}).catchError((copyError) {
183-
if (kDebugMode) {
184-
logger.fine('Error copying to clipboard: $copyError');
185-
}
186-
ScaffoldMessenger.of(context).showSnackBar(
187-
const SnackBar(content: Text('Could not copy details.')),
188-
);
189-
});
190-
},
191192
),
193+
CopyToClipboardButton(text: applicationLogs.join('\n')),
192194
],
193195
),
194196
],
@@ -199,14 +201,19 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
199201
TextButton(
200202
child: const Text('Report issue'),
201203
onPressed: () async {
204+
final logText = applicationLogs.isEmpty
205+
? '-- No logs available --'
206+
: applicationLogs.join('\n');
202207
final description = Uri.encodeComponent(
203208
'## Description\n\n'
204209
'[Please describe what you were doing when the error occurred.]\n\n'
205210
'## Error details\n\n'
206211
'Error title: $issueTitle\n'
207212
'Error message: $issueErrorMessage\n'
208213
'Stack trace:\n'
209-
'```\n$stackTrace\n```',
214+
'```\n$stackTrace\n```\n\n'
215+
'App logs (last ${applicationLogs.length} entries):\n'
216+
'```\n$logText\n```',
210217
);
211218
final githubIssueUrl = '$GITHUB_ISSUES_BUG_URL'
212219
'&title=$issueTitle'
@@ -237,6 +244,47 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
237244
);
238245
}
239246

247+
class CopyToClipboardButton extends StatelessWidget {
248+
final logger = Logger('CopyToClipboardButton');
249+
final String text;
250+
251+
CopyToClipboardButton({
252+
required this.text,
253+
super.key,
254+
});
255+
256+
@override
257+
Widget build(BuildContext context) {
258+
final i18n = AppLocalizations.of(context);
259+
260+
return TextButton.icon(
261+
icon: const Icon(Icons.copy_all_outlined, size: 18),
262+
label: Text(i18n.copyToClipboard),
263+
style: TextButton.styleFrom(
264+
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
265+
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
266+
),
267+
onPressed: () {
268+
Clipboard.setData(ClipboardData(text: text)).then((_) {
269+
if (context.mounted) {
270+
ScaffoldMessenger.of(context).showSnackBar(
271+
const SnackBar(content: Text('Details copied to clipboard!')),
272+
);
273+
}
274+
}).catchError((copyError) {
275+
logger.warning('Error copying to clipboard: $copyError');
276+
277+
if (context.mounted) {
278+
ScaffoldMessenger.of(context).showSnackBar(
279+
const SnackBar(content: Text('Could not copy details.')),
280+
);
281+
}
282+
});
283+
},
284+
);
285+
}
286+
}
287+
240288
void showDeleteDialog(BuildContext context, String confirmDeleteName, Log log) async {
241289
final res = await showDialog(
242290
context: context,

lib/helpers/logs.dart

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* This file is part of wger Workout Manager <https://github.com/wger-project>.
3+
* Copyright (C) wger Team
4+
*
5+
* wger Workout Manager is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
import 'package:logging/logging.dart';
20+
21+
/// Stores log entries in memory.
22+
///
23+
/// This means nothing is stored permanently anywhere and we loose everything
24+
/// when the application closes, but that's ok for our use case and can be
25+
/// changed in the future if the need arises.
26+
class InMemoryLogStore {
27+
static final InMemoryLogStore _instance = InMemoryLogStore._internal();
28+
final List<LogRecord> _logs = [];
29+
30+
factory InMemoryLogStore() => _instance;
31+
32+
InMemoryLogStore._internal();
33+
34+
// Adds a new log entry, but keeps the total number of entries limited
35+
void add(LogRecord record) {
36+
if (_logs.length >= 500) {
37+
_logs.removeAt(0);
38+
}
39+
_logs.add(record);
40+
}
41+
42+
List<LogRecord> get logs => List.unmodifiable(_logs);
43+
44+
List<String> get formattedLogs => _logs
45+
.map((log) =>
46+
'${log.time.toIso8601String()} ${log.level.name} [${log.loggerName}] ${log.message}')
47+
.toList();
48+
49+
void clear() => _logs.clear();
50+
}

lib/l10n/app_en.arb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@
330330
"errorInfoDescription": "We're sorry, but something went wrong. You can help us fix this by reporting the issue on GitHub.",
331331
"errorInfoDescription2": "You can continue using the app, but some features may not work.",
332332
"errorViewDetails": "Technical details",
333+
"applicationLogs": "Application logs",
333334
"errorCouldNotConnectToServer": "Couldn't connect to server",
334335
"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.",
335336
"copyToClipboard": "Copy to clipboard",

lib/main.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,17 @@ import 'package:wger/screens/update_app_screen.dart';
6161
import 'package:wger/screens/weight_screen.dart';
6262
import 'package:wger/theme/theme.dart';
6363
import 'package:wger/widgets/core/about.dart';
64+
import 'package:wger/widgets/core/log_overview.dart';
6465
import 'package:wger/widgets/core/settings.dart';
6566

67+
import 'helpers/logs.dart';
6668
import 'providers/auth.dart';
6769

6870
void _setupLogging() {
6971
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
7072
Logger.root.onRecord.listen((record) {
7173
print('${record.level.name}: ${record.time} [${record.loggerName}] ${record.message}');
74+
InMemoryLogStore().add(record);
7275
});
7376
}
7477

@@ -247,6 +250,7 @@ class MainApp extends StatelessWidget {
247250
AddExerciseScreen.routeName: (ctx) => const AddExerciseScreen(),
248251
AboutPage.routeName: (ctx) => const AboutPage(),
249252
SettingsPage.routeName: (ctx) => const SettingsPage(),
253+
LogOverviewPage.routeName: (ctx) => const LogOverviewPage(),
250254
ConfigurePlatesScreen.routeName: (ctx) => const ConfigurePlatesScreen(),
251255
},
252256
localizationsDelegates: AppLocalizations.localizationsDelegates,

lib/widgets/core/about.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import 'package:wger/l10n/generated/app_localizations.dart';
2525
import 'package:wger/providers/auth.dart';
2626
import 'package:wger/screens/add_exercise_screen.dart';
2727

28+
import 'log_overview.dart';
29+
2830
class AboutPage extends StatelessWidget {
2931
static String routeName = '/AboutPage';
3032

@@ -159,12 +161,14 @@ class AboutPage extends StatelessWidget {
159161
_buildSectionHeader(context, i18n.aboutJoinCommunityTitle),
160162
ListTile(
161163
leading: const FaIcon(FontAwesomeIcons.discord),
164+
trailing: const Icon(Icons.arrow_outward),
162165
title: Text(i18n.aboutDiscordTitle),
163166
contentPadding: EdgeInsets.zero,
164167
onTap: () => launchURL(DISCORD_URL, context),
165168
),
166169
ListTile(
167170
leading: const FaIcon(FontAwesomeIcons.mastodon),
171+
trailing: const Icon(Icons.arrow_outward),
168172
title: Text(i18n.aboutMastodonTitle),
169173
contentPadding: EdgeInsets.zero,
170174
onTap: () => launchURL(MASTODON_URL, context),
@@ -175,6 +179,16 @@ class AboutPage extends StatelessWidget {
175179

176180
ListTile(
177181
leading: const Icon(Icons.article),
182+
trailing: const Icon(Icons.chevron_right),
183+
title: Text(i18n.applicationLogs),
184+
contentPadding: EdgeInsets.zero,
185+
onTap: () {
186+
Navigator.of(context).pushNamed(LogOverviewPage.routeName);
187+
},
188+
),
189+
ListTile(
190+
leading: const Icon(Icons.article),
191+
trailing: const Icon(Icons.chevron_right),
178192
title: const Text('View Licenses'),
179193
contentPadding: EdgeInsets.zero,
180194
onTap: () {

lib/widgets/core/log_overview.dart

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* This file is part of wger Workout Manager <https://github.com/wger-project>.
3+
* Copyright (C) wger Team
4+
*
5+
* wger Workout Manager is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* wger Workout Manager is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
import 'package:flutter/material.dart';
20+
import 'package:logging/logging.dart';
21+
import 'package:wger/helpers/logs.dart';
22+
import 'package:wger/l10n/generated/app_localizations.dart';
23+
24+
class LogOverviewPage extends StatelessWidget {
25+
static String routeName = '/LogOverviewPage';
26+
27+
const LogOverviewPage({super.key});
28+
29+
@override
30+
Widget build(BuildContext context) {
31+
final i18n = AppLocalizations.of(context);
32+
final logs = InMemoryLogStore().logs.reversed.toList();
33+
34+
return Scaffold(
35+
appBar: AppBar(title: Text(i18n.applicationLogs)),
36+
body: logs.isEmpty
37+
? const Center(child: Text('No logs available.'))
38+
: ListView.builder(
39+
itemCount: logs.length,
40+
itemBuilder: (context, index) {
41+
final log = logs[index];
42+
return ListTile(
43+
dense: true,
44+
leading: Icon(_iconForLevel(log.level)),
45+
title: Text('[${log.level.name}] ${log.message}'),
46+
subtitle: Text('${log.loggerName}\n${log.time.toIso8601String()}'),
47+
isThreeLine: true,
48+
);
49+
},
50+
),
51+
);
52+
}
53+
}
54+
55+
IconData _iconForLevel(Level level) {
56+
if (level >= Level.SEVERE) {
57+
return Icons.priority_high;
58+
}
59+
if (level >= Level.WARNING) {
60+
return Icons.warning;
61+
}
62+
if (level >= Level.INFO) {
63+
return Icons.info;
64+
}
65+
return Icons.bug_report;
66+
}

lib/widgets/routines/routines_list.dart

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ class _RoutinesListState extends State<RoutinesList> {
5656
setState(() {
5757
_loadingRoutine = currentRoutine.id;
5858
});
59-
await widget._routineProvider.fetchAndSetRoutineFull(currentRoutine.id!);
60-
61-
if (mounted) {
62-
setState(() {
63-
_loadingRoutine = null;
64-
});
59+
try {
60+
await widget._routineProvider.fetchAndSetRoutineFull(currentRoutine.id!);
61+
} finally {
62+
if (mounted) {
63+
setState(() => _loadingRoutine = null);
64+
}
65+
}
6566

67+
if (context.mounted) {
6668
Navigator.of(context).pushNamed(
6769
RoutineScreen.routeName,
6870
arguments: currentRoutine.id,

0 commit comments

Comments
 (0)