@@ -23,6 +23,7 @@ import 'package:flutter/foundation.dart';
2323import 'package:flutter/material.dart' ;
2424import 'package:flutter/services.dart' ;
2525import 'package:flutter_html/flutter_html.dart' ;
26+ import 'package:http/http.dart' as http;
2627import 'package:json_annotation/json_annotation.dart' ;
2728import 'package:logging/logging.dart' ;
2829import 'package:provider/provider.dart' ;
@@ -42,6 +43,18 @@ import 'logs.dart';
4243/// modal dialogs on top of each other.
4344bool _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+
4558void 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
0 commit comments