diff --git a/lib/helpers/ui.dart b/lib/helpers/errors.dart similarity index 73% rename from lib/helpers/ui.dart rename to lib/helpers/errors.dart index 8a61dc19..415dc48a 100644 --- a/lib/helpers/ui.dart +++ b/lib/helpers/errors.dart @@ -17,6 +17,7 @@ */ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -31,41 +32,27 @@ import 'package:wger/main.dart'; import 'package:wger/models/workouts/log.dart'; import 'package:wger/providers/routines.dart'; -void showHttpExceptionErrorDialog( - WgerHttpException exception, - BuildContext context, -) { +void showHttpExceptionErrorDialog(WgerHttpException exception, {BuildContext? context}) { final logger = Logger('showHttpExceptionErrorDialog'); + // Attempt to get the BuildContext from our global navigatorKey. + // This allows us to show a dialog even if the error occurs outside + // of a widget's build method. + final BuildContext? dialogContext = context ?? navigatorKey.currentContext; + + if (dialogContext == null) { + if (kDebugMode) { + logger.warning('Error: Could not error show http error dialog because the context is null.'); + } + return; + } + logger.fine(exception.toString()); - final List errorList = []; - - for (final key in exception.errors!.keys) { - // Error headers - // Ensure that the error heading first letter is capitalized. - final String errorHeaderMsg = key[0].toUpperCase() + key.substring(1, key.length); - - errorList.add( - Text( - errorHeaderMsg.replaceAll('_', ' '), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ); - - // Error messages - if (exception.errors![key] is String) { - errorList.add(Text(exception.errors![key])); - } else { - for (final value in exception.errors![key]) { - errorList.add(Text(value)); - } - } - errorList.add(const SizedBox(height: 8)); - } + final errorList = extractErrors(exception.errors); showDialog( - context: context, + context: dialogContext, builder: (ctx) => AlertDialog( title: Text(AppLocalizations.of(ctx).anErrorOccurred), content: Column( @@ -83,10 +70,6 @@ void showHttpExceptionErrorDialog( ], ), ); - - // This call serves no purpose The dialog above doesn't seem to show - // unless this dummy call is present - //showDialog(context: context, builder: (context) => Container()); } void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext? context}) { @@ -100,13 +83,12 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext if (dialogContext == null) { if (kDebugMode) { logger.warning('Error: Could not error show dialog because the context is null.'); - logger.warning('Original error: $error'); - logger.warning('Original stackTrace: $stackTrace'); } return; } - // Determine the error title and message based on the error type. + // If possible, determine the error title and message based on the error type. + bool isNetworkError = false; String errorTitle = 'An error occurred'; String errorMessage = error.toString(); @@ -118,7 +100,9 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext errorTitle = 'Application Error'; errorMessage = error.exceptionAsString(); } else if (error is MissingRequiredKeysException) { - errorTitle = 'Missing Required Key '; + errorTitle = 'Missing Required Key'; + } else if (error is SocketException) { + isNetworkError = true; } final String fullStackTrace = stackTrace?.toString() ?? 'No stack trace available.'; @@ -133,11 +117,14 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext title: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error, color: Theme.of(context).colorScheme.error), + Icon( + isNetworkError ? Icons.signal_wifi_connected_no_internet_4_outlined : Icons.error, + color: Theme.of(context).colorScheme.error, + ), const SizedBox(width: 8), Expanded( child: Text( - i18n.anErrorOccurred, + isNetworkError ? i18n.errorCouldNotConnectToServer : i18n.anErrorOccurred, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), @@ -146,7 +133,11 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext content: SingleChildScrollView( child: ListBody( children: [ - Text(i18n.errorInfoDescription), + Text( + isNetworkError + ? i18n.errorCouldNotConnectToServerDetails + : i18n.errorInfoDescription, + ), const SizedBox(height: 8), Text(i18n.errorInfoDescription2), const SizedBox(height: 10), @@ -198,36 +189,37 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext ), ), actions: [ - TextButton( - child: const Text('Report issue'), - onPressed: () async { - const githubRepoUrl = 'https://github.com/wger-project/flutter'; - final description = Uri.encodeComponent( - '## Description\n\n' - '[Please describe what you were doing when the error occurred.]\n\n' - '## Error details\n\n' - 'Error title: $errorTitle\n' - 'Error message: $errorMessage\n' - 'Stack trace:\n' - '```\n$stackTrace\n```', - ); - final githubIssueUrl = '$githubRepoUrl/issues/new?template=1_bug.yml' - '&title=$errorTitle' - '&description=$description'; - final Uri reportUri = Uri.parse(githubIssueUrl); - - try { - await launchUrl(reportUri, mode: LaunchMode.externalApplication); - } catch (e) { - if (kDebugMode) logger.warning('Error launching URL: $e'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error opening issue tracker: $e')), + if (!isNetworkError) + TextButton( + child: const Text('Report issue'), + onPressed: () async { + const githubRepoUrl = 'https://github.com/wger-project/flutter'; + final description = Uri.encodeComponent( + '## Description\n\n' + '[Please describe what you were doing when the error occurred.]\n\n' + '## Error details\n\n' + 'Error title: $errorTitle\n' + 'Error message: $errorMessage\n' + 'Stack trace:\n' + '```\n$stackTrace\n```', ); - } - }, - ), + final githubIssueUrl = '$githubRepoUrl/issues/new?template=1_bug.yml' + '&title=$errorTitle' + '&description=$description'; + final Uri reportUri = Uri.parse(githubIssueUrl); + + try { + await launchUrl(reportUri, mode: LaunchMode.externalApplication); + } catch (e) { + if (kDebugMode) logger.warning('Error launching URL: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error opening issue tracker: $e')), + ); + } + }, + ), FilledButton( - child: const Text('OK'), + child: Text(MaterialLocalizations.of(context).okButtonLabel), onPressed: () { Navigator.of(context).pop(); }, @@ -279,3 +271,36 @@ void showDeleteDialog(BuildContext context, String confirmDeleteName, Log log) a ); return res; } + +List extractErrors(Map? errors) { + final List errorList = []; + + if (errors == null) { + return errorList; + } + + for (final key in errors.keys) { + // Error headers + // Ensure that the error heading first letter is capitalized. + final String errorHeaderMsg = key[0].toUpperCase() + key.substring(1, key.length); + + errorList.add( + Text( + errorHeaderMsg.replaceAll('_', ' '), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ); + + // Error messages + if (errors[key] is String) { + errorList.add(Text(errors[key])); + } else { + for (final value in errors[key]) { + errorList.add(Text(value)); + } + } + errorList.add(const SizedBox(height: 8)); + } + + return errorList; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3acc19a9..ef1c1b95 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -303,8 +303,10 @@ "goalFiber": "Fiber goal", "anErrorOccurred": "An Error Occurred!", "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 as expected.", - "errorViewDetails": "View technical details", + "errorInfoDescription2": "You can continue using the app, but some features may not work.", + "errorViewDetails": "Technical details", + "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", "weight": "Weight", "min": "Min", diff --git a/lib/main.dart b/lib/main.dart index a2f1b27b..879047cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'package:wger/core/locator.dart'; +import 'package:wger/exceptions/http_exception.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/helpers/shared_preferences.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/add_exercise.dart'; @@ -60,7 +62,6 @@ import 'package:wger/theme/theme.dart'; import 'package:wger/widgets/core/about.dart'; import 'package:wger/widgets/core/settings.dart'; -import 'helpers/ui.dart'; import 'providers/auth.dart'; void _setupLogging() { @@ -73,14 +74,15 @@ void _setupLogging() { final GlobalKey navigatorKey = GlobalKey(); void main() async { + // Needs to be called before runApp + WidgetsFlutterBinding.ensureInitialized(); + + // Logger _setupLogging(); final logger = Logger('main'); //zx.setLogEnabled(kDebugMode); - // Needs to be called before runApp - WidgetsFlutterBinding.ensureInitialized(); - // Locator to initialize exerciseDB await ServiceLocator().configure(); @@ -89,11 +91,12 @@ void main() async { // Catch errors from Flutter itself (widget build, layout, paint, etc.) FlutterError.onError = (FlutterErrorDetails details) { + final stack = details.stack ?? StackTrace.empty; if (kDebugMode) { FlutterError.dumpErrorToConsole(details); } - showGeneralErrorDialog(details.exception, details.stack ?? StackTrace.empty); - // Zone.current.handleUncaughtError(details.exception, details.stack ?? StackTrace.empty); + + showGeneralErrorDialog(details.exception, stack); }; // Catch errors that happen outside of the Flutter framework (e.g., in async operations) @@ -102,7 +105,11 @@ void main() async { logger.warning('Caught error by PlatformDispatcher: $error'); logger.warning('Stack trace: $stack'); } - showGeneralErrorDialog(error, stack); + if (error is WgerHttpException) { + showHttpExceptionErrorDialog(error); + } else { + showGeneralErrorDialog(error, stack); + } // Return true to indicate that the error has been handled. return true; diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 218ed770..4229f950 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -22,7 +22,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:wger/exceptions/http_exception.dart'; import 'package:wger/helpers/consts.dart'; -import 'package:wger/helpers/ui.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/screens/update_app_screen.dart'; import 'package:wger/theme/theme.dart'; @@ -195,24 +195,22 @@ class _AuthCardState extends State { ); return; } - setState(() { _isLoading = false; }); } on WgerHttpException catch (error) { if (mounted) { - showHttpExceptionErrorDialog(error, context); + showHttpExceptionErrorDialog(error, context: context); } setState(() { _isLoading = false; }); - } catch (error, stackTrace) { - if (mounted) { - showGeneralErrorDialog(error, stackTrace, context: context); - } + rethrow; + } catch (error) { setState(() { _isLoading = false; }); + rethrow; } } diff --git a/lib/screens/home_tabs_screen.dart b/lib/screens/home_tabs_screen.dart index a1792fbe..8bbf4826 100644 --- a/lib/screens/home_tabs_screen.dart +++ b/lib/screens/home_tabs_screen.dart @@ -22,7 +22,7 @@ import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'package:rive/rive.dart'; import 'package:wger/exceptions/http_exception.dart'; -import 'package:wger/helpers/ui.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/auth.dart'; import 'package:wger/providers/body_weight.dart'; @@ -101,7 +101,7 @@ class _HomeTabsScreenState extends State with SingleTickerProvid } on WgerHttpException catch (error) { widget._logger.warning('Wger exception loading base data'); if (mounted) { - showHttpExceptionErrorDialog(error, context); + showHttpExceptionErrorDialog(error, context: context); } } diff --git a/lib/widgets/measurements/forms.dart b/lib/widgets/measurements/forms.dart index e77ec62b..2b8a05f6 100644 --- a/lib/widgets/measurements/forms.dart +++ b/lib/widgets/measurements/forms.dart @@ -19,8 +19,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:wger/exceptions/http_exception.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/helpers/json.dart'; -import 'package:wger/helpers/ui.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/measurements/measurement_category.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; @@ -123,7 +123,7 @@ class MeasurementCategoryForm extends StatelessWidget { ); } on WgerHttpException catch (error) { if (context.mounted) { - showHttpExceptionErrorDialog(error, context); + showHttpExceptionErrorDialog(error, context: context); } } if (context.mounted) { @@ -296,7 +296,7 @@ class MeasurementEntryForm extends StatelessWidget { ); } on WgerHttpException catch (error) { if (context.mounted) { - showHttpExceptionErrorDialog(error, context); + showHttpExceptionErrorDialog(error, context: context); } } if (context.mounted) { diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index 2a5ecca2..0d1ab082 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -20,8 +20,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:wger/exceptions/http_exception.dart'; import 'package:wger/helpers/consts.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/helpers/json.dart'; -import 'package:wger/helpers/ui.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/nutrition/ingredient.dart'; import 'package:wger/models/nutrition/log.dart'; @@ -106,7 +106,7 @@ class MealForm extends StatelessWidget { listen: false, ).editMeal(_meal); } on WgerHttpException catch (error) { - showHttpExceptionErrorDialog(error, context); + showHttpExceptionErrorDialog(error, context: context); } Navigator.of(context).pop(); }, @@ -411,7 +411,7 @@ class IngredientFormState extends State { ); widget.onSave(context, _mealItem, date); } on WgerHttpException catch (error) { - showHttpExceptionErrorDialog(error, context); + showHttpExceptionErrorDialog(error, context: context); } Navigator.of(context).pop(); }, @@ -690,7 +690,7 @@ class _PlanFormState extends State { _descriptionController.clear(); } on WgerHttpException catch (error) { if (context.mounted) { - showHttpExceptionErrorDialog(error, context); + showHttpExceptionErrorDialog(error, context: context); } } }, diff --git a/lib/widgets/routines/gym_mode/log_page.dart b/lib/widgets/routines/gym_mode/log_page.dart index 852a037d..b7752b55 100644 --- a/lib/widgets/routines/gym_mode/log_page.dart +++ b/lib/widgets/routines/gym_mode/log_page.dart @@ -20,8 +20,8 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart' as provider; import 'package:wger/exceptions/http_exception.dart'; import 'package:wger/helpers/consts.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/helpers/gym_mode.dart'; -import 'package:wger/helpers/ui.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/exercises/exercise.dart'; import 'package:wger/models/workouts/log.dart'; @@ -315,7 +315,7 @@ class _LogPageState extends State { _isSaving = false; } on WgerHttpException catch (error) { if (mounted) { - showHttpExceptionErrorDialog(error, context); + showHttpExceptionErrorDialog(error, context: context); } _isSaving = false; } diff --git a/lib/widgets/routines/gym_mode/session_page.dart b/lib/widgets/routines/gym_mode/session_page.dart index 956bf17f..8dde53f8 100644 --- a/lib/widgets/routines/gym_mode/session_page.dart +++ b/lib/widgets/routines/gym_mode/session_page.dart @@ -20,9 +20,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart' as provider; import 'package:wger/exceptions/http_exception.dart'; import 'package:wger/helpers/consts.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/helpers/json.dart'; import 'package:wger/helpers/misc.dart'; -import 'package:wger/helpers/ui.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/exercises/exercise.dart'; import 'package:wger/models/workouts/routine.dart'; @@ -237,7 +237,7 @@ class _SessionPageState extends State { } } on WgerHttpException catch (error) { if (mounted) { - showHttpExceptionErrorDialog(error, context); + showHttpExceptionErrorDialog(error, context: context); } } }, diff --git a/lib/widgets/routines/log.dart b/lib/widgets/routines/log.dart index 52bc3863..e6156d4b 100644 --- a/lib/widgets/routines/log.dart +++ b/lib/widgets/routines/log.dart @@ -19,8 +19,8 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:wger/helpers/colors.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/helpers/misc.dart'; -import 'package:wger/helpers/ui.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/workouts/log.dart'; import 'package:wger/models/workouts/routine.dart'; diff --git a/lib/widgets/weight/forms.dart b/lib/widgets/weight/forms.dart index d279a918..8b9a7b24 100644 --- a/lib/widgets/weight/forms.dart +++ b/lib/widgets/weight/forms.dart @@ -21,8 +21,8 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; import 'package:wger/exceptions/http_exception.dart'; import 'package:wger/helpers/consts.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/helpers/json.dart'; -import 'package:wger/helpers/ui.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/body_weight/weight_entry.dart'; import 'package:wger/providers/body_weight.dart'; @@ -179,7 +179,7 @@ class WeightForm extends StatelessWidget { : await provider.editEntry(_weightEntry); } on WgerHttpException catch (error) { if (context.mounted) { - showHttpExceptionErrorDialog(error, context); + showHttpExceptionErrorDialog(error, context: context); } } diff --git a/test/utils/errors_test.dart b/test/utils/errors_test.dart new file mode 100644 index 00000000..e618365b --- /dev/null +++ b/test/utils/errors_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/helpers/errors.dart'; + +void main() { + group('extractErrors', () { + testWidgets('Returns empty list when errors is null', (WidgetTester tester) async { + final result = extractErrors(null); + expect(result, isEmpty); + }); + + testWidgets('Returns empty list when errors is empty', (WidgetTester tester) async { + final result = extractErrors({}); + expect(result, isEmpty); + }); + + testWidgets('Processes string values correctly', (WidgetTester tester) async { + // Arrange + final errors = {'error': 'Something went wrong'}; + + // Act + final widgets = extractErrors(errors); + + // Assert + expect(widgets.length, 3, reason: 'Expected 3 widgets: header, message, and spacing'); + + final headerWidget = widgets[0] as Text; + expect(headerWidget.data, 'Error'); + + final messageWidget = widgets[1] as Text; + expect(messageWidget.data, 'Something went wrong'); + expect(widgets[2] is SizedBox, true); + }); + + testWidgets('Processes list values correctly', (WidgetTester tester) async { + // Arrange + final errors = { + 'validation_error': ['Error 1', 'Error 2'], + }; + + // Act + final widgets = extractErrors(errors); + + // Assert + expect(widgets.length, 4); + + final headerWidget = widgets[0] as Text; + expect(headerWidget.data, 'Validation error'); + + final messageWidget1 = widgets[1] as Text; + expect(messageWidget1.data, 'Error 1'); + + final messageWidget2 = widgets[2] as Text; + expect(messageWidget2.data, 'Error 2'); + }); + + testWidgets('Processes multiple error types correctly', (WidgetTester tester) async { + // Arrange + final errors = { + 'username': ['Username is too boring', 'Username is too short'], + 'password': 'Password does not match', + }; + + // Act + final widgets = extractErrors(errors); + + // Assert + expect(widgets.length, 7); + + final textWidgets = widgets.whereType().toList(); + expect(textWidgets.map((w) => w.data).contains('Username'), true); + expect(textWidgets.map((w) => w.data).contains('Password'), true); + expect(textWidgets.map((w) => w.data).contains('Username is too boring'), true); + expect(textWidgets.map((w) => w.data).contains('Username is too short'), true); + expect(textWidgets.map((w) => w.data).contains('Password does not match'), true); + + final spacers = widgets.whereType().toList(); + expect(spacers.length, 2); + }); + }); +}