Refactor WgerHttpException

We can now use a widget to show any errors returned by WgerHttpException. This has
to be added on a form-by-form basis, otherwise, the general error handling shows
the results in a modal dialog
This commit is contained in:
Roland Geider
2025-05-08 22:06:48 +02:00
parent 7e0910b56b
commit 013721ba71
11 changed files with 213 additions and 232 deletions

View File

@@ -19,7 +19,7 @@
import 'dart:convert';
class WgerHttpException implements Exception {
Map<String, dynamic>? errors;
Map<String, dynamic> errors = {};
/// Custom http exception.
/// Expects the response body of the REST call and will try to parse it to
@@ -37,8 +37,12 @@ class WgerHttpException implements Exception {
}
}
WgerHttpException.fromMap(Map<String, dynamic> map) {
errors = map;
}
@override
String toString() {
return errors!.values.toList().join(', ');
return errors.values.toList().join(', ');
}
}

View File

@@ -47,9 +47,7 @@ void showHttpExceptionErrorDialog(WgerHttpException exception, {BuildContext? co
return;
}
logger.fine(exception.toString());
final errorList = extractErrors(exception.errors);
final errorList = formatErrors(extractErrors(exception.errors));
showDialog(
context: dialogContext,
@@ -272,35 +270,78 @@ void showDeleteDialog(BuildContext context, String confirmDeleteName, Log log) a
return res;
}
List<Widget> extractErrors(Map<String, dynamic>? errors) {
final List<Widget> errorList = [];
class ApiError {
final String key;
late List<String> errorMessages = [];
if (errors == null) {
return errorList;
ApiError({required this.key, this.errorMessages = const []});
@override
String toString() {
return 'ApiError(key: $key, errorMessage: $errorMessages)';
}
}
/// Extracts error messages from the server response
List<ApiError> extractErrors(Map<String, dynamic> errors) {
final List<ApiError> 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);
// Header
var header = key[0].toUpperCase() + key.substring(1, key.length);
header = header.replaceAll('_', ' ');
final error = ApiError(key: header);
final messages = errors[key];
// Messages
if (messages is String) {
error.errorMessages = List.of(error.errorMessages)..add(messages);
} else {
error.errorMessages = [...error.errorMessages, ...messages];
}
errorList.add(error);
}
return errorList;
}
/// Processes the error messages from the server and returns a list of widgets
List<Widget> formatErrors(List<ApiError> errors, {Color? color}) {
final textColor = color ?? Colors.black;
final List<Widget> errorList = [];
for (final error in errors) {
errorList.add(
Text(
errorHeaderMsg.replaceAll('_', ' '),
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(error.key, style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
);
// Error messages
if (errors[key] is String) {
errorList.add(Text(errors[key]));
} else {
for (final value in errors[key]) {
errorList.add(Text(value));
}
for (final message in error.errorMessages) {
errorList.add(Text(message, style: TextStyle(color: textColor)));
}
errorList.add(const SizedBox(height: 8));
}
return errorList;
}
class FormErrorsWidget extends StatelessWidget {
final WgerHttpException exception;
const FormErrorsWidget(this.exception, {super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error),
...formatErrors(
extractErrors(exception.errors),
color: Theme.of(context).colorScheme.error,
),
],
);
}
}

View File

@@ -105,6 +105,7 @@ class AuthCard extends StatefulWidget {
class _AuthCardState extends State<AuthCard> {
bool isObscure = true;
bool confirmIsObscure = true;
Widget errorMessage = const SizedBox.shrink();
final GlobalKey<FormState> _formKey = GlobalKey();
AuthMode _authMode = AuthMode.Login;
@@ -195,21 +196,25 @@ class _AuthCardState extends State<AuthCard> {
);
return;
}
setState(() {
_isLoading = false;
});
} on WgerHttpException catch (error) {
if (mounted) {
showHttpExceptionErrorDialog(error, context: context);
if (context.mounted) {
setState(() {
_isLoading = false;
});
}
} on WgerHttpException catch (error) {
if (context.mounted) {
setState(() {
_isLoading = false;
errorMessage = FormErrorsWidget(error);
});
}
setState(() {
_isLoading = false;
});
rethrow;
} catch (error) {
setState(() {
_isLoading = false;
});
if (context.mounted) {
setState(() {
_isLoading = false;
});
}
rethrow;
}
}
@@ -248,6 +253,7 @@ class _AuthCardState extends State<AuthCard> {
child: AutofillGroup(
child: Column(
children: [
errorMessage,
TextFormField(
key: const Key('inputUsername'),
decoration: InputDecoration(

View File

@@ -21,8 +21,6 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
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/errors.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/auth.dart';
import 'package:wger/providers/body_weight.dart';
@@ -90,20 +88,13 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
// Base data
widget._logger.info('Loading base data');
try {
await Future.wait([
authProvider.setServerVersion(),
userProvider.fetchAndSetProfile(),
routinesProvider.fetchAndSetUnits(),
nutritionPlansProvider.fetchIngredientsFromCache(),
exercisesProvider.fetchAndSetInitialData(),
]);
} on WgerHttpException catch (error) {
widget._logger.warning('Wger exception loading base data');
if (mounted) {
showHttpExceptionErrorDialog(error, context: context);
}
}
await Future.wait([
authProvider.setServerVersion(),
userProvider.fetchAndSetProfile(),
routinesProvider.fetchAndSetUnits(),
nutritionPlansProvider.fetchIngredientsFromCache(),
exercisesProvider.fetchAndSetInitialData(),
]);
// Plans, weight and gallery
widget._logger.info('Loading routines, weight, measurements and gallery');

View File

@@ -18,8 +18,6 @@
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/l10n/generated/app_localizations.dart';
import 'package:wger/models/measurements/measurement_category.dart';
@@ -101,31 +99,26 @@ class MeasurementCategoryForm extends StatelessWidget {
_form.currentState!.save();
// Save the entry on the server
try {
categoryData['id'] == null
? await Provider.of<MeasurementProvider>(
context,
listen: false,
).addCategory(
MeasurementCategory(
id: categoryData['id'],
name: categoryData['name'],
unit: categoryData['unit'],
),
)
: await Provider.of<MeasurementProvider>(
context,
listen: false,
).editCategory(
categoryData['id'],
categoryData['name'],
categoryData['unit'],
);
} on WgerHttpException catch (error) {
if (context.mounted) {
showHttpExceptionErrorDialog(error, context: context);
}
}
categoryData['id'] == null
? await Provider.of<MeasurementProvider>(
context,
listen: false,
).addCategory(
MeasurementCategory(
id: categoryData['id'],
name: categoryData['name'],
unit: categoryData['unit'],
),
)
: await Provider.of<MeasurementProvider>(
context,
listen: false,
).editCategory(
categoryData['id'],
categoryData['name'],
categoryData['unit'],
);
if (context.mounted) {
Navigator.of(context).pop();
}
@@ -272,33 +265,28 @@ class MeasurementEntryForm extends StatelessWidget {
_form.currentState!.save();
// Save the entry on the server
try {
_entryData['id'] == null
? await Provider.of<MeasurementProvider>(
context,
listen: false,
).addEntry(MeasurementEntry(
id: _entryData['id'],
category: _entryData['category'],
date: _entryData['date'],
value: _entryData['value'],
notes: _entryData['notes'],
))
: await Provider.of<MeasurementProvider>(
context,
listen: false,
).editEntry(
_entryData['id'],
_entryData['category'],
_entryData['value'],
_entryData['notes'],
_entryData['date'],
);
} on WgerHttpException catch (error) {
if (context.mounted) {
showHttpExceptionErrorDialog(error, context: context);
}
}
_entryData['id'] == null
? await Provider.of<MeasurementProvider>(
context,
listen: false,
).addEntry(MeasurementEntry(
id: _entryData['id'],
category: _entryData['category'],
date: _entryData['date'],
value: _entryData['value'],
notes: _entryData['notes'],
))
: await Provider.of<MeasurementProvider>(
context,
listen: false,
).editEntry(
_entryData['id'],
_entryData['category'],
_entryData['value'],
_entryData['notes'],
_entryData['date'],
);
if (context.mounted) {
Navigator.of(context).pop();
}

View File

@@ -18,9 +18,7 @@
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/l10n/generated/app_localizations.dart';
import 'package:wger/models/nutrition/ingredient.dart';
@@ -89,25 +87,22 @@ class MealForm extends StatelessWidget {
ElevatedButton(
key: const Key(SUBMIT_BUTTON_KEY_NAME),
child: Text(AppLocalizations.of(context).save),
onPressed: () async {
onPressed: () {
if (!_form.currentState!.validate()) {
return;
}
_form.currentState!.save();
try {
_meal.id == null
? Provider.of<NutritionPlansProvider>(
context,
listen: false,
).addMeal(_meal, _planId)
: Provider.of<NutritionPlansProvider>(
context,
listen: false,
).editMeal(_meal);
} on WgerHttpException catch (error) {
showHttpExceptionErrorDialog(error, context: context);
}
_meal.id == null
? Provider.of<NutritionPlansProvider>(
context,
listen: false,
).addMeal(_meal, _planId)
: Provider.of<NutritionPlansProvider>(
context,
listen: false,
).editMeal(_meal);
Navigator.of(context).pop();
},
),
@@ -399,20 +394,17 @@ class IngredientFormState extends State<IngredientForm> {
_form.currentState!.save();
_mealItem.ingredientId = int.parse(_ingredientIdController.text);
try {
var date = DateTime.parse(_dateController.text);
final tod = stringToTime(_timeController.text);
date = DateTime(
date.year,
date.month,
date.day,
tod.hour,
tod.minute,
);
widget.onSave(context, _mealItem, date);
} on WgerHttpException catch (error) {
showHttpExceptionErrorDialog(error, context: context);
}
var date = DateTime.parse(_dateController.text);
final tod = stringToTime(_timeController.text);
date = DateTime(
date.year,
date.month,
date.day,
tod.hour,
tod.minute,
);
widget.onSave(context, _mealItem, date);
Navigator.of(context).pop();
},
),
@@ -664,35 +656,29 @@ class _PlanFormState extends State<PlanForm> {
_form.currentState!.save();
// Save to DB
try {
if (widget._plan.id != null) {
await Provider.of<NutritionPlansProvider>(
context,
listen: false,
).editPlan(widget._plan);
if (context.mounted) {
Navigator.of(context).pop();
}
} else {
widget._plan = await Provider.of<NutritionPlansProvider>(
context,
listen: false,
).addPlan(widget._plan);
if (context.mounted) {
Navigator.of(context).pushReplacementNamed(
NutritionalPlanScreen.routeName,
arguments: widget._plan,
);
}
}
// Saving was successful, reset the data
_descriptionController.clear();
} on WgerHttpException catch (error) {
if (widget._plan.id != null) {
await Provider.of<NutritionPlansProvider>(
context,
listen: false,
).editPlan(widget._plan);
if (context.mounted) {
showHttpExceptionErrorDialog(error, context: context);
Navigator.of(context).pop();
}
} else {
widget._plan = await Provider.of<NutritionPlansProvider>(
context,
listen: false,
).addPlan(widget._plan);
if (context.mounted) {
Navigator.of(context).pushReplacementNamed(
NutritionalPlanScreen.routeName,
arguments: widget._plan,
);
}
}
// Saving was successful, reset the data
_descriptionController.clear();
},
),
],

View File

@@ -20,7 +20,6 @@ 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/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
@@ -313,11 +312,10 @@ class _LogPageState extends State<LogPage> {
curve: DEFAULT_ANIMATION_CURVE,
);
_isSaving = false;
} on WgerHttpException catch (error) {
if (mounted) {
showHttpExceptionErrorDialog(error, context: context);
}
} on WgerHttpException {
_isSaving = false;
rethrow;
}
},
child:

View File

@@ -18,9 +18,7 @@
import 'package:clock/clock.dart';
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/l10n/generated/app_localizations.dart';
@@ -225,20 +223,14 @@ class _SessionPageState extends State<SessionPage> {
_form.currentState!.save();
// Save the entry on the server
try {
if (widget._session.id == null) {
await routinesProvider.addSession(widget._session, widget._routine.id!);
} else {
await routinesProvider.editSession(widget._session);
}
if (widget._session.id == null) {
await routinesProvider.addSession(widget._session, widget._routine.id!);
} else {
await routinesProvider.editSession(widget._session);
}
if (mounted) {
Navigator.of(context).pop();
}
} on WgerHttpException catch (error) {
if (mounted) {
showHttpExceptionErrorDialog(error, context: context);
}
if (mounted) {
Navigator.of(context).pop();
}
},
),

View File

@@ -19,9 +19,7 @@
import 'package:flutter/material.dart';
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/l10n/generated/app_localizations.dart';
import 'package:wger/models/body_weight/weight_entry.dart';
@@ -172,16 +170,10 @@ class WeightForm extends StatelessWidget {
_form.currentState!.save();
// Save the entry on the server
try {
final provider = Provider.of<BodyWeightProvider>(context, listen: false);
_weightEntry.id == null
? await provider.addEntry(_weightEntry)
: await provider.editEntry(_weightEntry);
} on WgerHttpException catch (error) {
if (context.mounted) {
showHttpExceptionErrorDialog(error, context: context);
}
}
final provider = Provider.of<BodyWeightProvider>(context, listen: false);
_weightEntry.id == null
? await provider.addEntry(_weightEntry)
: await provider.editEntry(_weightEntry);
if (context.mounted) {
Navigator.of(context).pop();

View File

@@ -146,7 +146,7 @@ void main() {
));
});
testWidgets('Login - wront username & password', (WidgetTester tester) async {
testWidgets('Login - wrong username & password', (WidgetTester tester) async {
// Arrange
await tester.binding.setSurfaceSize(const Size(1080, 1920));
tester.view.devicePixelRatio = 1.0;
@@ -168,7 +168,6 @@ void main() {
await tester.pumpAndSettle();
// Assert
expect(find.textContaining('An Error Occurred'), findsOne);
expect(find.textContaining('Non field errors'), findsOne);
expect(find.textContaining('Username or password unknown'), findsOne);
verify(mockClient.post(
@@ -259,7 +258,6 @@ void main() {
await tester.pumpAndSettle();
// Assert
expect(find.textContaining('An Error Occurred'), findsOne);
expect(find.textContaining('This password is too common'), findsOne);
expect(find.textContaining('This password is entirely numeric'), findsOne);
expect(find.textContaining('This field must be unique'), findsOne);

View File

@@ -1,14 +1,8 @@
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);
@@ -19,17 +13,14 @@ void main() {
final errors = {'error': 'Something went wrong'};
// Act
final widgets = extractErrors(errors);
final result = extractErrors(errors);
// Assert
expect(widgets.length, 3, reason: 'Expected 3 widgets: header, message, and spacing');
expect(result.length, 1, reason: 'Expected 1 error');
expect(result[0].errorMessages.length, 1, reason: '1 error message');
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);
expect(result[0].key, 'Error');
expect(result[0].errorMessages[0], 'Something went wrong');
});
testWidgets('Processes list values correctly', (WidgetTester tester) async {
@@ -39,19 +30,12 @@ void main() {
};
// Act
final widgets = extractErrors(errors);
final result = 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');
expect(result[0].key, 'Validation error');
expect(result[0].errorMessages[0], 'Error 1');
expect(result[0].errorMessages[1], 'Error 2');
});
testWidgets('Processes multiple error types correctly', (WidgetTester tester) async {
@@ -62,20 +46,21 @@ void main() {
};
// Act
final widgets = extractErrors(errors);
final result = extractErrors(errors);
// Assert
expect(widgets.length, 7);
expect(result.length, 2);
final error1 = result[0];
final error2 = result[1];
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);
expect(error1.key, 'Username');
expect(error1.errorMessages.length, 2);
expect(error1.errorMessages[0], 'Username is too boring');
expect(error1.errorMessages[1], 'Username is too short');
final spacers = widgets.whereType<SizedBox>().toList();
expect(spacers.length, 2);
expect(error2.key, 'Password');
expect(error2.errorMessages.length, 1);
expect(error2.errorMessages[0], 'Password does not match');
});
});
}