Skip to content

Commit 4c34f91

Browse files
authored
Merge pull request #905 from wger-project/feature/better-logging
Save the application logs locally
2 parents 10f82e2 + 5a4d4c7 commit 4c34f91

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)