Skip to content

Commit 0741fd4

Browse files
committed
Better error handling
We can now distinguish between errors that should block the application, (hopefully) transient ones that only warrant a snackbar and others that are only logged.
1 parent 695727b commit 0741fd4

3 files changed

Lines changed: 150 additions & 69 deletions

File tree

lib/helpers/errors.dart

Lines changed: 100 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import 'package:flutter/foundation.dart';
2323
import 'package:flutter/material.dart';
2424
import 'package:flutter/services.dart';
2525
import 'package:flutter_html/flutter_html.dart';
26+
import 'package:http/http.dart' as http;
2627
import 'package:json_annotation/json_annotation.dart';
2728
import 'package:logging/logging.dart';
2829
import 'package:provider/provider.dart';
@@ -42,6 +43,18 @@ import 'logs.dart';
4243
/// modal dialogs on top of each other.
4344
bool _errorDialogVisible = false;
4445

46+
/// How an error should be surfaced to the user.
47+
enum ErrorSeverity {
48+
/// Logged only; not worth interrupting the user (e.g. layout overflows).
49+
cosmetic,
50+
51+
/// A brief, non-blocking snackbar (e.g. transient connectivity problems).
52+
transient,
53+
54+
/// A blocking error dialog.
55+
fatal,
56+
}
57+
4558
void showHttpExceptionErrorDialog(WgerHttpException exception, {BuildContext? context}) {
4659
final logger = Logger('showHttpExceptionErrorDialog');
4760

@@ -97,7 +110,7 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
97110
// of a widget's build method.
98111
final BuildContext? dialogContext = context ?? navigatorKey.currentContext;
99112

100-
final logger = Logger('showHttpExceptionErrorDialog');
113+
final logger = Logger('showGeneralErrorDialog');
101114

102115
if (dialogContext == null) {
103116
if (kDebugMode) {
@@ -114,30 +127,16 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
114127

115128
final i18n = AppLocalizations.of(dialogContext);
116129

117-
// If possible, determine the error title and message based on the error type.
130+
// If possible, determine the issue title and message based on the error type.
118131
// (Note that issue titles and error messages are not localized)
119-
bool allowReportIssue = true;
120132
String issueTitle = 'An error occurred';
121133
String issueErrorMessage = error.toString();
122-
String errorTitle = i18n.anErrorOccurred;
123-
String errorDescription = i18n.errorInfoDescription;
124-
var icon = Icons.error;
125-
126-
if (error is TimeoutException) {
127-
issueTitle = 'Network Timeout';
128-
issueErrorMessage =
129-
'The connection to the server timed out. Please check your '
130-
'internet connection and try again.';
131-
} else if (error is FlutterErrorDetails) {
134+
135+
if (error is FlutterErrorDetails) {
132136
issueTitle = 'Application Error';
133137
issueErrorMessage = error.exceptionAsString();
134138
} else if (error is MissingRequiredKeysException) {
135139
issueTitle = 'Missing Required Key';
136-
} else if (error is SocketException) {
137-
allowReportIssue = false;
138-
icon = Icons.signal_wifi_connected_no_internet_4_outlined;
139-
errorTitle = i18n.errorCouldNotConnectToServer;
140-
errorDescription = i18n.errorCouldNotConnectToServerDetails;
141140
}
142141

143142
final String fullStackTrace = stackTrace?.toString() ?? 'No stack trace available.';
@@ -152,16 +151,19 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
152151
spacing: 8,
153152
mainAxisAlignment: MainAxisAlignment.center,
154153
children: [
155-
Icon(icon, color: Theme.of(context).colorScheme.error),
154+
Icon(Icons.error, color: Theme.of(context).colorScheme.error),
156155
Expanded(
157-
child: Text(errorTitle, style: TextStyle(color: Theme.of(context).colorScheme.error)),
156+
child: Text(
157+
i18n.anErrorOccurred,
158+
style: TextStyle(color: Theme.of(context).colorScheme.error),
159+
),
158160
),
159161
],
160162
),
161163
content: SingleChildScrollView(
162164
child: ListBody(
163165
children: [
164-
Text(errorDescription),
166+
Text(i18n.errorInfoDescription),
165167
const SizedBox(height: 8),
166168
Text(i18n.errorInfoDescription2),
167169
const SizedBox(height: 10),
@@ -216,30 +218,29 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
216218
),
217219
),
218220
actions: [
219-
if (allowReportIssue)
220-
TextButton(
221-
child: const Text('Report issue'),
222-
onPressed: () async {
223-
final githubIssueUrl = buildGithubIssueUrl(
224-
issueTitle: issueTitle,
225-
issueErrorMessage: issueErrorMessage,
226-
stackTrace: fullStackTrace,
227-
applicationLogs: applicationLogs,
228-
);
229-
final Uri reportUri = Uri.parse(githubIssueUrl);
230-
231-
try {
232-
await launchUrl(reportUri, mode: LaunchMode.externalApplication);
233-
} catch (e) {
234-
if (kDebugMode) {
235-
logger.warning('Error launching URL: $e');
236-
}
237-
ScaffoldMessenger.of(
238-
context,
239-
).showSnackBar(SnackBar(content: Text('Error opening issue tracker: $e')));
221+
TextButton(
222+
child: const Text('Report issue'),
223+
onPressed: () async {
224+
final githubIssueUrl = buildGithubIssueUrl(
225+
issueTitle: issueTitle,
226+
issueErrorMessage: issueErrorMessage,
227+
stackTrace: fullStackTrace,
228+
applicationLogs: applicationLogs,
229+
);
230+
final Uri reportUri = Uri.parse(githubIssueUrl);
231+
232+
try {
233+
await launchUrl(reportUri, mode: LaunchMode.externalApplication);
234+
} catch (e) {
235+
if (kDebugMode) {
236+
logger.warning('Error launching URL: $e');
240237
}
241-
},
242-
),
238+
ScaffoldMessenger.of(
239+
context,
240+
).showSnackBar(SnackBar(content: Text('Error opening issue tracker: $e')));
241+
}
242+
},
243+
),
243244
FilledButton(
244245
child: Text(MaterialLocalizations.of(context).okButtonLabel),
245246
onPressed: () {
@@ -252,6 +253,61 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
252253
).whenComplete(() => _errorDialogVisible = false);
253254
}
254255

256+
/// Classifies [error] to decide how it should be surfaced.
257+
ErrorSeverity classifyError(Object? error) {
258+
// Flutter reports layout overflows as a plain FlutterError without a
259+
// dedicated type, so matching the message is the only option.
260+
final isLayoutOverflow = error is FlutterError && error.toString().contains('overflowed');
261+
262+
if (error is NetworkImageLoadException || isLayoutOverflow) {
263+
return ErrorSeverity.cosmetic;
264+
}
265+
if (error is SocketException || error is http.ClientException || error is TimeoutException) {
266+
return ErrorSeverity.transient;
267+
}
268+
return ErrorSeverity.fatal;
269+
}
270+
271+
/// Routes [error] to the appropriate UI based on its [ErrorSeverity].
272+
///
273+
/// The caller is responsible for logging the error beforehand.
274+
void handleError(Object? error, StackTrace? stackTrace) {
275+
switch (classifyError(error)) {
276+
case ErrorSeverity.cosmetic:
277+
break;
278+
case ErrorSeverity.transient:
279+
showTransientErrorSnackbar();
280+
case ErrorSeverity.fatal:
281+
if (error is WgerHttpException) {
282+
showHttpExceptionErrorDialog(error);
283+
} else {
284+
showGeneralErrorDialog(error, stackTrace);
285+
}
286+
}
287+
}
288+
289+
/// Shows a brief, non-blocking snackbar telling the user about a (hopefully)
290+
/// transient error such as network problems, etc.
291+
void showTransientErrorSnackbar() {
292+
final messenger = scaffoldMessengerKey.currentState;
293+
final context = navigatorKey.currentContext;
294+
295+
if (messenger == null || context == null) {
296+
if (kDebugMode) {
297+
Logger(
298+
'showNetworkErrorSnackbar',
299+
).warning('Could not show snackbar: no messenger or context available.');
300+
}
301+
return;
302+
}
303+
304+
messenger
305+
..clearSnackBars()
306+
..showSnackBar(
307+
SnackBar(content: Text(AppLocalizations.of(context).errorCouldNotConnectToServer)),
308+
);
309+
}
310+
255311
/// Builds the URL that opens a pre-filled GitHub bug report.
256312
///
257313
/// The error details are passed to GitHub as query parameters and since

lib/main.dart

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import 'package:flutter/material.dart';
2121
import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod;
2222
import 'package:logging/logging.dart';
2323
import 'package:provider/provider.dart';
24-
import 'package:wger/core/exceptions/http_exception.dart';
2524
import 'package:wger/core/locator.dart';
2625
import 'package:wger/helpers/errors.dart';
2726
import 'package:wger/helpers/locale.dart';
@@ -82,6 +81,7 @@ void _setupLogging() {
8281
}
8382

8483
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
84+
final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
8585

8686
void main() async {
8787
// Needs to be called before runApp
@@ -97,26 +97,14 @@ void main() async {
9797

9898
// SharedPreferences to SharedPreferencesAsync migration function
9999
await PreferenceHelper.instance.migrationSupportFunctionForSharedPreferences();
100-
// Catch errors from Flutter itself (widget build, layout, paint, etc.)
101-
//
102-
// NOTE: it seems this sometimes makes problems and even freezes the flutter
103-
// process when widgets overflow, so it is disabled in dev mode.
104-
if (!kDebugMode) {
105-
FlutterError.onError = (FlutterErrorDetails details) {
106-
final stack = details.stack ?? StackTrace.empty;
107-
logger.severe('Error caught by FlutterError.onError: ${details.exception}');
108-
109-
FlutterError.dumpErrorToConsole(details);
110100

111-
// Don't show the full error dialog for network image loading errors.
112-
if (details.exception is NetworkImageLoadException) {
113-
return;
114-
}
115-
116-
showGeneralErrorDialog(details.exception, stack);
117-
// throw details.exception;
118-
};
119-
}
101+
// Catch errors from Flutter itself (widget build, layout, paint, etc.)
102+
FlutterError.onError = (FlutterErrorDetails details) {
103+
final stack = details.stack ?? StackTrace.empty;
104+
logger.severe('Error caught by FlutterError.onError: ${details.exception}');
105+
FlutterError.dumpErrorToConsole(details);
106+
handleError(details.exception, stack);
107+
};
120108

121109
// Catch errors that happen outside of the Flutter framework (e.g., in async operations)
122110
PlatformDispatcher.instance.onError = (error, stack) {
@@ -131,11 +119,7 @@ void main() async {
131119
logger.severe('Error caught by PlatformDispatcher.instance.onError: $error');
132120
logger.severe('Stack trace: $stack');
133121

134-
if (error is WgerHttpException) {
135-
showHttpExceptionErrorDialog(error);
136-
} else {
137-
showGeneralErrorDialog(error, stack);
138-
}
122+
handleError(error, stack);
139123

140124
// Return true to indicate that the error has been handled.
141125
return true;
@@ -244,6 +228,7 @@ class MainApp extends StatelessWidget {
244228
return MaterialApp(
245229
title: 'wger',
246230
navigatorKey: navigatorKey,
231+
scaffoldMessengerKey: scaffoldMessengerKey,
247232
theme: wgerLightTheme,
248233
darkTheme: wgerDarkTheme,
249234
highContrastTheme: wgerLightThemeHc,

test/helpers/errors_test.dart

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1717
*/
1818

19+
import 'dart:async';
20+
import 'dart:io';
21+
22+
import 'package:flutter/material.dart';
1923
import 'package:flutter_test/flutter_test.dart';
24+
import 'package:http/http.dart' as http;
25+
import 'package:wger/core/exceptions/http_exception.dart';
2026
import 'package:wger/helpers/consts.dart';
2127
import 'package:wger/helpers/errors.dart';
2228

@@ -178,4 +184,38 @@ void main() {
178184
expect(description, contains('#79 SomeClass.someMethod'));
179185
});
180186
});
187+
188+
group('classifyError', () {
189+
test('Connectivity errors are transient', () {
190+
expect(
191+
classifyError(const SocketException('no route to host')),
192+
ErrorSeverity.transient,
193+
);
194+
expect(
195+
classifyError(http.ClientException('connection refused')),
196+
ErrorSeverity.transient,
197+
);
198+
expect(classifyError(TimeoutException('request timed out')), ErrorSeverity.transient);
199+
});
200+
201+
test('Network image and layout overflow errors are cosmetic', () {
202+
expect(
203+
classifyError(
204+
NetworkImageLoadException(statusCode: 404, uri: Uri.parse('https://x/y.png')),
205+
),
206+
ErrorSeverity.cosmetic,
207+
);
208+
expect(
209+
classifyError(FlutterError('A RenderFlex overflowed by 99 pixels on the right.')),
210+
ErrorSeverity.cosmetic,
211+
);
212+
});
213+
214+
test('Other errors are fatal', () {
215+
expect(classifyError(WgerHttpException.fromMap({'detail': 'invalid'})), ErrorSeverity.fatal);
216+
expect(classifyError(Exception('boom')), ErrorSeverity.fatal);
217+
expect(classifyError(StateError('bad state')), ErrorSeverity.fatal);
218+
expect(classifyError(null), ErrorSeverity.fatal);
219+
});
220+
});
181221
}

0 commit comments

Comments
 (0)