Merge pull request #813 from wger-project/feature/better-error-handling

Improve error handling and logging
This commit is contained in:
Roland Geider
2025-05-09 21:56:30 +02:00
committed by GitHub
27 changed files with 835 additions and 508 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(', ');
}
}

347
lib/helpers/errors.dart Normal file
View File

@@ -0,0 +1,347 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/main.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/providers/routines.dart';
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;
}
final errorList = formatErrors(extractErrors(exception.errors));
showDialog(
context: dialogContext,
builder: (ctx) => AlertDialog(
title: Text(AppLocalizations.of(ctx).anErrorOccurred),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [...errorList],
),
actions: [
TextButton(
child: Text(MaterialLocalizations.of(ctx).closeButtonLabel),
onPressed: () {
Navigator.of(ctx).pop();
},
),
],
),
);
}
void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext? context}) {
// 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;
final logger = Logger('showHttpExceptionErrorDialog');
if (dialogContext == null) {
if (kDebugMode) {
logger.warning('Error: Could not error show dialog because the context is null.');
}
return;
}
// 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();
if (error is TimeoutException) {
errorTitle = 'Network Timeout';
errorMessage =
'The connection to the server timed out. Please check your internet connection and try again.';
} else if (error is FlutterErrorDetails) {
errorTitle = 'Application Error';
errorMessage = error.exceptionAsString();
} else if (error is MissingRequiredKeysException) {
errorTitle = 'Missing Required Key';
} else if (error is SocketException) {
isNetworkError = true;
}
final String fullStackTrace = stackTrace?.toString() ?? 'No stack trace available.';
final i18n = AppLocalizations.of(dialogContext);
showDialog(
context: dialogContext,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
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(
isNetworkError ? i18n.errorCouldNotConnectToServer : i18n.anErrorOccurred,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
content: SingleChildScrollView(
child: ListBody(
children: [
Text(
isNetworkError
? i18n.errorCouldNotConnectToServerDetails
: i18n.errorInfoDescription,
),
const SizedBox(height: 8),
Text(i18n.errorInfoDescription2),
const SizedBox(height: 10),
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: Text(i18n.errorViewDetails),
children: [
Text(
errorMessage,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Container(
alignment: Alignment.topLeft,
padding: const EdgeInsets.symmetric(vertical: 8.0),
constraints: const BoxConstraints(maxHeight: 250),
child: SingleChildScrollView(
child: Text(
fullStackTrace,
style: TextStyle(fontSize: 12.0, color: Colors.grey[700]),
),
),
),
const SizedBox(height: 8),
TextButton.icon(
icon: const Icon(Icons.copy_all_outlined, size: 18),
label: Text(i18n.copyToClipboard),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
final String clipboardText =
'Error Title: $errorTitle\nError Message: $errorMessage\n\nStack Trace:\n$fullStackTrace';
Clipboard.setData(ClipboardData(text: clipboardText)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error details copied to clipboard!')),
);
}).catchError((copyError) {
if (kDebugMode) logger.fine('Error copying to clipboard: $copyError');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not copy details.')),
);
});
},
),
],
),
],
),
),
actions: [
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: Text(MaterialLocalizations.of(context).okButtonLabel),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
void showDeleteDialog(BuildContext context, String confirmDeleteName, Log log) async {
final res = await showDialog(
context: context,
builder: (BuildContext contextDialog) {
return AlertDialog(
content: Text(
AppLocalizations.of(context).confirmDelete(confirmDeleteName),
),
actions: [
TextButton(
key: const ValueKey('cancel-button'),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
onPressed: () => Navigator.of(contextDialog).pop(),
),
TextButton(
key: const ValueKey('delete-button'),
child: Text(
AppLocalizations.of(context).delete,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onPressed: () {
context.read<RoutinesProvider>().deleteLog(log.id!, log.routineId);
Navigator.of(contextDialog).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).successfullyDeleted,
textAlign: TextAlign.center,
),
),
);
},
),
],
);
},
);
return res;
}
class ApiError {
final String key;
late List<String> errorMessages = [];
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) {
// 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(error.key, style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
);
for (final message in error.errorMessages) {
errorList.add(Text(message, style: TextStyle(color: textColor)));
}
errorList.add(const SizedBox(height: 8));
}
return errorList;
}
class FormHttpErrorsWidget extends StatelessWidget {
final WgerHttpException exception;
const FormHttpErrorsWidget(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

@@ -1,149 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/providers/routines.dart';
void showErrorDialog(dynamic exception, BuildContext context) {
// log('showErrorDialog: ');
// log(exception.toString());
// log('=====================');
showDialog(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(AppLocalizations.of(context).anErrorOccurred),
content: SelectableText(exception.toString()),
actions: [
TextButton(
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
onPressed: () {
Navigator.of(ctx).pop();
},
),
],
),
);
}
void showHttpExceptionErrorDialog(
WgerHttpException exception,
BuildContext context,
) {
final logger = Logger('showHttpExceptionErrorDialog');
logger.fine(exception.toString());
logger.fine('-------------------');
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));
}
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(AppLocalizations.of(ctx).anErrorOccurred),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [...errorList],
),
actions: [
TextButton(
child: Text(MaterialLocalizations.of(ctx).closeButtonLabel),
onPressed: () {
Navigator.of(ctx).pop();
},
),
],
),
);
// 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 showDeleteDialog(BuildContext context, String confirmDeleteName, Log log) async {
final res = await showDialog(
context: context,
builder: (BuildContext contextDialog) {
return AlertDialog(
content: Text(
AppLocalizations.of(context).confirmDelete(confirmDeleteName),
),
actions: [
TextButton(
key: const ValueKey('cancel-button'),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
onPressed: () => Navigator.of(contextDialog).pop(),
),
TextButton(
key: const ValueKey('delete-button'),
child: Text(
AppLocalizations.of(context).delete,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onPressed: () {
context.read<RoutinesProvider>().deleteLog(log.id!, log.routineId);
Navigator.of(contextDialog).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).successfullyDeleted,
textAlign: TextAlign.center,
),
),
);
},
),
],
);
},
);
return res;
}

View File

@@ -302,7 +302,12 @@
"goalFat": "Fat goal",
"goalFiber": "Fiber goal",
"anErrorOccurred": "An Error Occurred!",
"@anErrorOccurred": {},
"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.",
"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",
"max": "Max",

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';
@@ -69,19 +71,55 @@ void _setupLogging() {
});
}
void main() async {
_setupLogging();
//zx.setLogEnabled(kDebugMode);
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);
// Locator to initialize exerciseDB
await ServiceLocator().configure();
// SharedPreferences to SharedPreferencesAsync migration function
await PreferenceHelper.instance.migrationSupportFunctionForSharedPreferences();
// 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);
}
// Don't show the full error dialog for network image loading errors.
if (details.exception is NetworkImageLoadException) {
return;
}
showGeneralErrorDialog(details.exception, stack);
};
// Catch errors that happen outside of the Flutter framework (e.g., in async operations)
PlatformDispatcher.instance.onError = (error, stack) {
if (kDebugMode) {
logger.warning('Caught error by PlatformDispatcher: $error');
logger.warning('Stack trace: $stack');
}
if (error is WgerHttpException) {
showHttpExceptionErrorDialog(error);
} else {
showGeneralErrorDialog(error, stack);
}
// Return true to indicate that the error has been handled.
return true;
};
// Application
runApp(const riverpod.ProviderScope(child: MainApp()));
}
@@ -90,18 +128,19 @@ class MainApp extends StatelessWidget {
const MainApp();
Widget _getHomeScreen(AuthProvider auth) {
if (auth.state == AuthState.loggedIn) {
return HomeTabsScreen();
} else if (auth.state == AuthState.updateRequired) {
return const UpdateAppScreen();
} else {
return FutureBuilder(
future: auth.tryAutoLogin(),
builder: (ctx, authResultSnapshot) =>
authResultSnapshot.connectionState == ConnectionState.waiting
? const SplashScreen()
: const AuthScreen(),
);
switch (auth.state) {
case AuthState.loggedIn:
return HomeTabsScreen();
case AuthState.updateRequired:
return const UpdateAppScreen();
default:
return FutureBuilder(
future: auth.tryAutoLogin(),
builder: (ctx, authResultSnapshot) =>
authResultSnapshot.connectionState == ConnectionState.waiting
? const SplashScreen()
: const AuthScreen(),
);
}
}
@@ -173,6 +212,7 @@ class MainApp extends StatelessWidget {
builder: (ctx, auth, _) => Consumer<UserProvider>(
builder: (ctx, user, _) => MaterialApp(
title: 'wger',
navigatorKey: navigatorKey,
theme: wgerLightTheme,
darkTheme: wgerDarkTheme,
highContrastTheme: wgerLightThemeHc,

View File

@@ -18,7 +18,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';

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';
@@ -41,7 +41,7 @@ class AuthScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final deviceSize = MediaQuery.of(context).size;
final deviceSize = MediaQuery.sizeOf(context);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: Stack(
@@ -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,24 +196,21 @@ class _AuthCardState extends State<AuthCard> {
);
return;
}
setState(() {
_isLoading = false;
});
if (context.mounted) {
setState(() {
_isLoading = false;
});
}
} on WgerHttpException catch (error) {
if (mounted) {
showHttpExceptionErrorDialog(error, context);
if (context.mounted) {
setState(() {
errorMessage = FormHttpErrorsWidget(error);
});
}
setState(() {
_isLoading = false;
});
} catch (error) {
} finally {
if (mounted) {
showErrorDialog(error, context);
setState(() => _isLoading = false);
}
setState(() {
_isLoading = false;
});
}
}
@@ -250,6 +248,7 @@ class _AuthCardState extends State<AuthCard> {
child: AutofillGroup(
child: Column(
children: [
errorMessage,
TextFormField(
key: const Key('inputUsername'),
decoration: InputDecoration(

View File

@@ -49,6 +49,7 @@ class HomeTabsScreen extends StatefulWidget {
class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProviderStateMixin {
late Future<void> _initialData;
bool _errorHandled = false;
int _selectedIndex = 0;
@override
@@ -87,45 +88,30 @@ 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(),
]);
} catch (e) {
widget._logger.warning('Exception loading base data');
widget._logger.warning(e.toString());
}
await Future.wait([
authProvider.setServerVersion(),
userProvider.fetchAndSetProfile(),
routinesProvider.fetchAndSetUnits(),
nutritionPlansProvider.fetchIngredientsFromCache(),
exercisesProvider.fetchAndSetInitialData(),
]);
// Plans, weight and gallery
widget._logger.info('Loading plans, weight, measurements and gallery');
try {
await Future.wait([
galleryProvider.fetchAndSetGallery(),
nutritionPlansProvider.fetchAndSetAllPlansSparse(),
routinesProvider.fetchAndSetAllRoutinesSparse(),
// routinesProvider.fetchAndSetAllRoutinesFull(),
weightProvider.fetchAndSetEntries(),
measurementProvider.fetchAndSetAllCategoriesAndEntries(),
]);
} catch (e) {
widget._logger.warning('Exception loading plans, weight, measurements and gallery');
widget._logger.info(e.toString());
}
widget._logger.info('Loading routines, weight, measurements and gallery');
await Future.wait([
galleryProvider.fetchAndSetGallery(),
nutritionPlansProvider.fetchAndSetAllPlansSparse(),
routinesProvider.fetchAndSetAllRoutinesSparse(),
// routinesProvider.fetchAndSetAllRoutinesFull(),
weightProvider.fetchAndSetEntries(),
measurementProvider.fetchAndSetAllCategoriesAndEntries(),
]);
// Current nutritional plan
widget._logger.info('Loading current nutritional plan');
try {
if (nutritionPlansProvider.currentPlan != null) {
final plan = nutritionPlansProvider.currentPlan!;
await nutritionPlansProvider.fetchAndSetPlanFull(plan.id!);
}
} catch (e) {
widget._logger.warning('Exception loading current nutritional plan');
widget._logger.warning(e.toString());
if (nutritionPlansProvider.currentPlan != null) {
final plan = nutritionPlansProvider.currentPlan!;
await nutritionPlansProvider.fetchAndSetPlanFull(plan.id!);
}
// Current routine
@@ -144,6 +130,28 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
return FutureBuilder<void>(
future: _initialData,
builder: (context, snapshot) {
if (snapshot.hasError) {
// Throw the original error with the original stack trace, otherwise
// the error will only point to these lines here
if (!_errorHandled) {
_errorHandled = true;
final error = snapshot.error;
final stackTrace = snapshot.stackTrace;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
if (error != null && stackTrace != null) {
throw Error.throwWithStackTrace(error, stackTrace);
}
throw error!;
}
});
}
// Note that we continue to show the app, even if there was an error.
// return const Scaffold(body: LoadingWidget());
}
if (snapshot.connectionState != ConnectionState.done) {
return const Scaffold(
body: LoadingWidget(),

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/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';
@@ -101,35 +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);
}
} catch (error) {
if (context.mounted) {
showErrorDialog(error, 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();
}
@@ -276,37 +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);
}
} catch (error) {
if (context.mounted) {
showErrorDialog(error, 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,10 +18,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/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';
@@ -89,27 +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);
} catch (error) {
showErrorDialog(error, 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();
},
),
@@ -401,22 +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);
} catch (error) {
showErrorDialog(error, 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();
},
),
@@ -668,39 +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);
Navigator.of(context).pop();
}
} catch (error) {
} else {
widget._plan = await Provider.of<NutritionPlansProvider>(
context,
listen: false,
).addPlan(widget._plan);
if (context.mounted) {
showErrorDialog(error, context);
Navigator.of(context).pushReplacementNamed(
NutritionalPlanScreen.routeName,
arguments: widget._plan,
);
}
}
// Saving was successful, reset the data
_descriptionController.clear();
},
),
],

View File

@@ -25,7 +25,6 @@ import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/misc.dart';
import 'package:wger/helpers/platform.dart';
import 'package:wger/helpers/ui.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/ingredient_api.dart';
import 'package:wger/models/nutrition/ingredient.dart';
@@ -203,15 +202,10 @@ class _IngredientTypeaheadState extends State<IngredientTypeahead> {
key: const Key('scan-button'),
icon: const FaIcon(FontAwesomeIcons.barcode),
onPressed: () async {
try {
if (!widget.test!) {
barcode = await readerscan(context);
}
} catch (e) {
if (mounted) {
showErrorDialog(e, context);
}
if (!widget.test!) {
barcode = await readerscan(context);
}
if (!mounted) {
return;
}

View File

@@ -1,6 +1,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/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/day.dart';
import 'package:wger/providers/routines.dart';
@@ -56,6 +58,8 @@ class ReorderableDaysList extends StatefulWidget {
}
class _ReorderableDaysListState extends State<ReorderableDaysList> {
Widget errorMessage = const SizedBox.shrink();
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
@@ -63,6 +67,7 @@ class _ReorderableDaysListState extends State<ReorderableDaysList> {
return Column(
children: [
errorMessage,
ReorderableListView.builder(
buildDefaultDragHandles: false,
shrinkWrap: true,
@@ -120,7 +125,18 @@ class _ReorderableDaysListState extends State<ReorderableDaysList> {
}
});
provider.editDays(widget.days);
try {
provider.editDays(widget.days);
setState(() {
errorMessage = const SizedBox.shrink();
});
} on WgerHttpException catch (error) {
if (context.mounted) {
setState(() {
errorMessage = FormHttpErrorsWidget(error);
});
}
}
},
),
Card(
@@ -161,6 +177,8 @@ class DayFormWidget extends StatefulWidget {
}
class _DayFormWidgetState extends State<DayFormWidget> {
Widget errorMessage = const SizedBox.shrink();
final descriptionController = TextEditingController();
final nameController = TextEditingController();
late bool isRestDay;
@@ -193,6 +211,7 @@ class _DayFormWidgetState extends State<DayFormWidget> {
key: _form,
child: Column(
children: [
errorMessage,
Text(
widget.day.isRest ? i18n.restDay : widget.day.name,
style: Theme.of(context).textTheme.titleLarge,
@@ -292,25 +311,24 @@ class _DayFormWidgetState extends State<DayFormWidget> {
try {
await Provider.of<RoutinesProvider>(context, listen: false)
.editDay(widget.day);
} catch (error) {
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('An error occurred!'),
content: const Text('Something went wrong.'),
actions: [
TextButton(
child: const Text('Okay'),
onPressed: () {
Navigator.of(ctx).pop();
},
),
],
),
);
if (context.mounted) {
setState(() {
errorMessage = const SizedBox.shrink();
});
}
} on WgerHttpException catch (error) {
if (context.mounted) {
setState(() {
errorMessage = FormHttpErrorsWidget(error);
});
}
} finally {
if (mounted) {
setState(() {
isSaving = false;
});
}
}
setState(() => isSaving = false);
},
child:
isSaving ? const FormProgressIndicator() : Text(AppLocalizations.of(context).save),

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.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/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/routine.dart';
import 'package:wger/providers/routines.dart';
@@ -20,6 +22,7 @@ class RoutineForm extends StatefulWidget {
class _RoutineFormState extends State<RoutineForm> {
final _form = GlobalKey<FormState>();
Widget errorMessage = const SizedBox.shrink();
bool isSaving = false;
late bool fitInWeek;
@@ -50,6 +53,7 @@ class _RoutineFormState extends State<RoutineForm> {
final i18n = AppLocalizations.of(context);
final children = [
errorMessage,
TextFormField(
key: const Key('field-name'),
decoration: InputDecoration(labelText: i18n.name),
@@ -225,19 +229,22 @@ class _RoutineFormState extends State<RoutineForm> {
);
}
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${i18n.anErrorOccurred} $e')),
);
setState(() {
errorMessage = const SizedBox.shrink();
});
} on WgerHttpException catch (error) {
if (context.mounted) {
setState(() {
errorMessage = FormHttpErrorsWidget(error);
});
}
} finally {
if (mounted) {
setState(() => isSaving = false);
setState(() {
isSaving = false;
});
}
}
setState(() {
isSaving = false;
});
},
child: isSaving ? const FormProgressIndicator() : Text(AppLocalizations.of(context).save),
),

View File

@@ -21,8 +21,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/workouts/session.dart';
import 'package:wger/providers/routines.dart';
@@ -51,6 +51,7 @@ class SessionForm extends StatefulWidget {
}
class _SessionFormState extends State<SessionForm> {
Widget errorMessage = const SizedBox.shrink();
final _form = GlobalKey<FormState>();
final impressionController = TextEditingController();
@@ -90,6 +91,7 @@ class _SessionFormState extends State<SessionForm> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
errorMessage,
ToggleButtons(
renderBorder: false,
onPressed: (int index) {
@@ -128,6 +130,7 @@ class _SessionFormState extends State<SessionForm> {
children: [
Flexible(
child: TextFormField(
key: const ValueKey('time-start'),
decoration: InputDecoration(
labelText: AppLocalizations.of(context).timeStart,
errorMaxLines: 2,
@@ -170,6 +173,7 @@ class _SessionFormState extends State<SessionForm> {
const SizedBox(width: 10),
Flexible(
child: TextFormField(
key: const ValueKey('time-end'),
decoration: InputDecoration(
labelText: AppLocalizations.of(context).timeEnd,
),
@@ -219,16 +223,18 @@ class _SessionFormState extends State<SessionForm> {
await routinesProvider.editSession(widget._session);
}
setState(() {
errorMessage = const SizedBox.shrink();
});
if (context.mounted && widget._onSaved != null) {
widget._onSaved!();
}
} on WgerHttpException catch (error) {
if (context.mounted) {
showHttpExceptionErrorDialog(error, context);
}
} catch (error) {
if (context.mounted) {
showErrorDialog(error, context);
setState(() {
errorMessage = FormHttpErrorsWidget(error);
});
}
}
},

View File

@@ -18,7 +18,9 @@
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/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/day.dart';
@@ -89,6 +91,8 @@ class _SlotEntryFormState extends State<SlotEntryForm> {
final maxRestController = TextEditingController();
final rirController = TextEditingController();
Widget errorMessage = const SizedBox.shrink();
final _form = GlobalKey<FormState>();
var _edit = false;
@@ -154,6 +158,7 @@ class _SlotEntryFormState extends State<SlotEntryForm> {
key: _form,
child: Column(
children: [
errorMessage,
ListTile(
title: Text(
widget.entry.exerciseObj.getTranslation(languageCode).name,
@@ -177,9 +182,18 @@ class _SlotEntryFormState extends State<SlotEntryForm> {
? null
: () async {
setState(() => isDeleting = true);
await provider.deleteSlotEntry(widget.entry.id!, widget.routineId);
if (mounted) {
setState(() => isDeleting = false);
try {
await provider.deleteSlotEntry(widget.entry.id!, widget.routineId);
} on WgerHttpException catch (error) {
if (context.mounted) {
setState(() {
errorMessage = FormHttpErrorsWidget(error);
});
}
} finally {
if (mounted) {
setState(() => isDeleting = false);
}
}
},
),
@@ -385,11 +399,14 @@ class _SlotEntryFormState extends State<SlotEntryForm> {
await provider.editSlotEntry(widget.entry, widget.routineId);
if (mounted) {
setState(() => isSaving = false);
errorMessage = const SizedBox.shrink();
}
} on WgerHttpException catch (error) {
if (context.mounted) {
setState(() {
errorMessage = FormHttpErrorsWidget(error);
});
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${i18n.anErrorOccurred} $e')),
);
} finally {
if (mounted) {
setState(() => isSaving = false);
@@ -419,6 +436,7 @@ class SlotDetailWidget extends StatefulWidget {
class _SlotDetailWidgetState extends State<SlotDetailWidget> {
bool _showExerciseSearchBox = false;
Widget errorMessage = const SizedBox.shrink();
@override
Widget build(BuildContext context) {
@@ -427,6 +445,7 @@ class _SlotDetailWidgetState extends State<SlotDetailWidget> {
return Column(
children: [
errorMessage,
...widget.slot.entries.map((entry) => entry.hasProgressionRules
? ProgressionRulesInfoBox(entry.exerciseObj)
: SlotEntryForm(entry, widget.routineId, simpleMode: widget.simpleMode)),
@@ -442,7 +461,18 @@ class _SlotDetailWidgetState extends State<SlotDetailWidget> {
exercise: exercise,
);
await provider.addSlotEntry(entry, widget.routineId);
try {
await provider.addSlotEntry(entry, widget.routineId);
if (context.mounted) {
setState(() => errorMessage = const SizedBox.shrink());
}
} on WgerHttpException catch (error) {
if (context.mounted) {
setState(() {
errorMessage = FormHttpErrorsWidget(error);
});
}
}
},
),
if (widget.slot.entries.isNotEmpty)
@@ -473,6 +503,7 @@ class _SlotFormWidgetStateNg extends State<ReorderableSlotList> {
bool simpleMode = true;
bool isAddingSlot = false;
int? isDeletingSlot;
Widget errorMessage = const SizedBox.shrink();
@override
Widget build(BuildContext context) {
@@ -482,6 +513,7 @@ class _SlotFormWidgetStateNg extends State<ReorderableSlotList> {
return Column(
children: [
errorMessage,
if (!widget.day.isRest)
SwitchListTile(
value: simpleMode,
@@ -579,7 +611,18 @@ class _SlotFormWidgetStateNg extends State<ReorderableSlotList> {
widget.slots[i].order = i + 1;
}
provider.editSlots(widget.slots, widget.day.routineId);
try {
provider.editSlots(widget.slots, widget.day.routineId);
setState(() {
errorMessage = const SizedBox.shrink();
});
} on WgerHttpException catch (error) {
if (context.mounted) {
setState(() {
errorMessage = FormHttpErrorsWidget(error);
});
}
}
});
},
),

View File

@@ -1,58 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* wger Workout Manager is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/slot_entry.dart';
class WeightInputWidget extends StatelessWidget {
final _weightController = TextEditingController();
final SlotEntry _setting;
final bool _detailed;
WeightInputWidget(this._setting, this._detailed);
@override
Widget build(BuildContext context) {
return TextFormField(
decoration: InputDecoration(
labelText: _detailed ? AppLocalizations.of(context).weight : '',
errorMaxLines: 2,
),
controller: _weightController,
keyboardType: TextInputType.number,
validator: (value) {
try {
if (value != '') {
double.parse(value!);
}
} catch (error) {
return AppLocalizations.of(context).enterValidNumber;
}
return null;
},
onChanged: (newValue) {
if (newValue != '') {
try {
// _setting.weight = double.parse(newValue);
} catch (e) {}
}
},
);
}
}

View File

@@ -21,7 +21,6 @@ import 'package:provider/provider.dart' as provider;
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.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';
@@ -313,16 +312,10 @@ class _LogPageState extends State<LogPage> {
curve: DEFAULT_ANIMATION_CURVE,
);
_isSaving = false;
} on WgerHttpException catch (error) {
if (mounted) {
showHttpExceptionErrorDialog(error, context);
}
_isSaving = false;
} catch (error) {
if (mounted) {
showErrorDialog(error, context);
}
} on WgerHttpException {
_isSaving = false;
rethrow;
}
},
child:

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

@@ -19,10 +19,8 @@
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/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';
@@ -172,20 +170,11 @@ 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);
}
} catch (error) {
if (context.mounted) {
showErrorDialog(error, 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

@@ -41,13 +41,13 @@ void main() {
var plan1 = NutritionalPlan.empty();
var meal1 = Meal();
when(mockNutrition.editMeal(any)).thenAnswer((_) => Future.value(Meal()));
when(mockNutrition.addMeal(any, any)).thenAnswer((_) => Future.value(Meal()));
setUp(() {
plan1 = getNutritionalPlan();
meal1 = plan1.meals.first;
mockNutrition = MockNutritionPlansProvider();
when(mockNutrition.editMeal(any)).thenAnswer((_) => Future.value(Meal()));
when(mockNutrition.addMeal(any, any)).thenAnswer((_) => Future.value(Meal()));
});
Widget createFormScreen(Meal meal, {locale = 'en'}) {

View File

@@ -41,11 +41,11 @@ void main() {
);
final plan2 = NutritionalPlan.empty();
when(mockNutrition.editPlan(any)).thenAnswer((_) => Future.value(plan1));
when(mockNutrition.addPlan(any)).thenAnswer((_) => Future.value(plan2));
setUp(() {
mockNutrition = MockNutritionPlansProvider();
when(mockNutrition.editPlan(any)).thenAnswer((_) => Future.value(plan1));
when(mockNutrition.addPlan(any)).thenAnswer((_) => Future.value(plan1));
});
Widget createHomeScreen(NutritionalPlan plan, {locale = 'en'}) {
@@ -97,7 +97,7 @@ void main() {
// https://stackoverflow.com/questions/50704647/how-to-test-navigation-via-navigator-in-flutter
// Detail page
//await tester.pumpAndSettle();
// await tester.pumpAndSettle();
//expect(
// find.text(('New description')),
//findsOneWidget,
@@ -117,8 +117,8 @@ void main() {
verifyNever(mockNutrition.editPlan(any));
verify(mockNutrition.addPlan(any));
// Detail page
await tester.pumpAndSettle();
expect(find.text('New cool plan'), findsOneWidget, reason: 'Nutritional plan detail page');
// TODO: detail page
// await tester.pumpAndSettle();
// expect(find.text('New cool plan'), findsOneWidget, reason: 'Nutritional plan detail page');
});
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wger/helpers/errors.dart';
void main() {
group('extractErrors', () {
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 result = extractErrors(errors);
// Assert
expect(result.length, 1, reason: 'Expected 1 error');
expect(result[0].errorMessages.length, 1, reason: '1 error message');
expect(result[0].key, 'Error');
expect(result[0].errorMessages[0], 'Something went wrong');
});
testWidgets('Processes list values correctly', (WidgetTester tester) async {
// Arrange
final errors = {
'validation_error': ['Error 1', 'Error 2'],
};
// Act
final result = extractErrors(errors);
// Assert
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 {
// Arrange
final errors = {
'username': ['Username is too boring', 'Username is too short'],
'password': 'Password does not match',
};
// Act
final result = extractErrors(errors);
// Assert
expect(result.length, 2);
final error1 = result[0];
final error2 = result[1];
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');
expect(error2.key, 'Password');
expect(error2.errorMessages.length, 1);
expect(error2.errorMessages[0], 'Password does not match');
});
});
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/session.dart';
import 'package:wger/providers/routines.dart';
@@ -126,5 +127,20 @@ void main() {
expect(captured.notes, 'Updated notes');
expect(onSavedCalled, isTrue);
});
testWidgets('shows server side error messages', (WidgetTester tester) async {
// Arrange
await pumpSessionForm(tester);
when(mockRoutinesProvider.addSession(any, any)).thenThrow(WgerHttpException.fromMap({
'name': ['The name is not valid'],
}));
// Act
await tester.tap(find.byKey(const ValueKey('save-button')));
await tester.pumpAndSettle();
// Assert
expect(find.text('The name is not valid'), findsOneWidget, reason: 'Error message is shown');
});
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -25,6 +25,7 @@ import 'package:provider/provider.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/routine.dart';
import 'package:wger/models/workouts/session.dart';
import 'package:wger/providers/routines.dart';
import 'package:wger/widgets/routines/gym_mode/session_page.dart';
@@ -38,6 +39,10 @@ void main() {
setUp(() {
testRoutine = getTestRoutine();
when(mockRoutinesProvider.editSession(any)).thenAnswer(
(_) => Future.value(testRoutine.sessions[0].session),
);
});
Widget renderSessionPage({locale = 'en'}) {
@@ -73,12 +78,17 @@ void main() {
testWidgets('Test that data from session is loaded - null times', (WidgetTester tester) async {
testRoutine.sessions[0].session.timeStart = null;
testRoutine.sessions[0].session.timeEnd = null;
final timeNow = timeToString(TimeOfDay.now())!;
withClock(Clock.fixed(DateTime(2021, 5, 1)), () async {
await tester.pumpWidget(renderSessionPage());
expect(find.text('13:35'), findsOneWidget);
expect(find.text(timeNow), findsOneWidget);
final startTimeField = find.byKey(const ValueKey('time-start'));
expect(startTimeField, findsOneWidget);
expect(tester.widget<TextFormField>(startTimeField).controller!.text, '');
final endTimeField = find.byKey(const ValueKey('time-end'));
expect(endTimeField, findsOneWidget);
expect(tester.widget<TextFormField>(endTimeField).controller!.text, '');
});
});
@@ -97,7 +107,8 @@ void main() {
withClock(Clock.fixed(DateTime(2021, 5, 1)), () async {
await tester.pumpWidget(renderSessionPage());
await tester.tap(find.byKey(const ValueKey('save-button')));
final captured = verify(mockRoutinesProvider.editSession(captureAny)).captured.single;
final captured =
verify(mockRoutinesProvider.editSession(captureAny)).captured.single as WorkoutSession;
expect(captured.id, 1);
expect(captured.impression, 3);

View File

@@ -21,6 +21,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/routine.dart';
@@ -74,7 +75,7 @@ void main() {
);
}
testWidgets('Test the widgets on the workout form', (WidgetTester tester) async {
testWidgets('Test the widgets on the routine form', (WidgetTester tester) async {
await tester.pumpWidget(renderWidget(existingRoutine));
await tester.pumpAndSettle();
@@ -82,19 +83,19 @@ void main() {
expect(find.byType(ElevatedButton), findsOneWidget);
});
testWidgets('Test editing an existing workout', (WidgetTester tester) async {
testWidgets('Test editing an existing routine', (WidgetTester tester) async {
await tester.pumpWidget(renderWidget(existingRoutine));
await tester.pumpAndSettle();
expect(
find.text('test 1'),
findsOneWidget,
reason: 'Name of existing workout plan',
reason: 'Name of existing routine',
);
expect(
find.text('description 1'),
findsOneWidget,
reason: 'Description of existing workout plan',
reason: 'Description of existing routine',
);
await tester.enterText(find.byKey(const Key('field-name')), 'New description');
await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME)));
@@ -112,13 +113,28 @@ void main() {
//expect(find.text(('New description')), findsOneWidget, reason: 'Workout plan detail page');
});
testWidgets('Test creating a new workout - only name', (WidgetTester tester) async {
testWidgets('Test editing an existing routine - server error', (WidgetTester tester) async {
// Arrange
when(mockRoutinesProvider.editRoutine(any)).thenThrow(WgerHttpException.fromMap({
'name': ['The name is not valid'],
}));
// Act
await tester.pumpWidget(renderWidget(existingRoutine));
await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME)));
await tester.pump();
// Assert
expect(find.text('The name is not valid'), findsOneWidget, reason: 'Error message is shown');
});
testWidgets('Test creating a new routine - only name', (WidgetTester tester) async {
final editRoutine = Routine(
id: 2,
created: newRoutine.created,
start: DateTime(2024, 11, 1),
end: DateTime(2024, 12, 1),
name: 'New cool workout',
name: 'New cool routine',
);
when(mockRoutinesProvider.addRoutine(any)).thenAnswer((_) => Future.value(editRoutine));
@@ -127,7 +143,7 @@ void main() {
await tester.pumpWidget(renderWidget(newRoutine));
await tester.pumpAndSettle();
expect(find.text(''), findsNWidgets(2), reason: 'New workout has no name or description');
expect(find.text(''), findsNWidgets(2), reason: 'New routine has no name or description');
await tester.enterText(find.byKey(const Key('field-name')), editRoutine.name);
await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME)));
@@ -136,16 +152,16 @@ void main() {
// Detail page
await tester.pumpAndSettle();
expect(find.text('New cool workout'), findsWidgets, reason: 'Workout plan detail page');
expect(find.text('New cool routine'), findsWidgets, reason: 'routine detail page');
});
testWidgets('Test creating a new workout - name and description', (WidgetTester tester) async {
testWidgets('Test creating a new routine - name and description', (WidgetTester tester) async {
final editRoutine = Routine(
id: 2,
created: newRoutine.created,
start: DateTime(2024, 11, 1),
end: DateTime(2024, 12, 1),
name: 'My workout',
name: 'My routine',
description: 'Get yuuuge',
);
when(mockRoutinesProvider.addRoutine(any)).thenAnswer((_) => Future.value(editRoutine));
@@ -154,7 +170,7 @@ void main() {
await tester.pumpWidget(renderWidget(newRoutine));
await tester.pumpAndSettle();
expect(find.text(''), findsNWidgets(2), reason: 'New workout has no name or description');
expect(find.text(''), findsNWidgets(2), reason: 'New routine has no name or description');
await tester.enterText(find.byKey(const Key('field-name')), editRoutine.name);
await tester.enterText(find.byKey(const Key('field-description')), editRoutine.description);
await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME)));
@@ -164,6 +180,22 @@ void main() {
// Detail page
await tester.pumpAndSettle();
expect(find.text('My workout'), findsWidgets, reason: 'Workout plan detail page');
expect(find.text('My routine'), findsWidgets, reason: 'routine detail page');
});
testWidgets('Test creating a new routine - server error', (WidgetTester tester) async {
// Arrange
when(mockRoutinesProvider.addRoutine(any)).thenThrow(WgerHttpException.fromMap({
'name': ['The name is not valid'],
}));
// Act
await tester.pumpWidget(renderWidget(newRoutine));
await tester.enterText(find.byKey(const Key('field-name')), 'test 1234');
await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME)));
await tester.pump();
// Assert
expect(find.text('The name is not valid'), findsOneWidget, reason: 'Error message is shown');
});
}