Handle network connectivity a bit different

When the server is not reachable, we show a slightly different error message
and remove the option to automatically create an issue.
This commit is contained in:
Roland Geider
2025-05-08 14:48:10 +02:00
parent 5e22d69bb1
commit 7e0910b56b
12 changed files with 213 additions and 100 deletions

View File

@@ -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<Widget> 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<Widget> extractErrors(Map<String, dynamic>? errors) {
final List<Widget> 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;
}

View File

@@ -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",

View File

@@ -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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
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;

View File

@@ -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<AuthCard> {
);
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;
}
}

View File

@@ -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<HomeTabsScreen> with SingleTickerProvid
} on WgerHttpException catch (error) {
widget._logger.warning('Wger exception loading base data');
if (mounted) {
showHttpExceptionErrorDialog(error, context);
showHttpExceptionErrorDialog(error, context: context);
}
}

View File

@@ -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) {

View File

@@ -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<IngredientForm> {
);
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<PlanForm> {
_descriptionController.clear();
} on WgerHttpException catch (error) {
if (context.mounted) {
showHttpExceptionErrorDialog(error, context);
showHttpExceptionErrorDialog(error, context: context);
}
}
},

View File

@@ -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<LogPage> {
_isSaving = false;
} on WgerHttpException catch (error) {
if (mounted) {
showHttpExceptionErrorDialog(error, context);
showHttpExceptionErrorDialog(error, context: context);
}
_isSaving = false;
}

View File

@@ -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<SessionPage> {
}
} on WgerHttpException catch (error) {
if (mounted) {
showHttpExceptionErrorDialog(error, context);
showHttpExceptionErrorDialog(error, context: context);
}
}
},

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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<Text>().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<SizedBox>().toList();
expect(spacers.length, 2);
});
});
}