diff --git a/lib/exceptions/http_exception.dart b/lib/exceptions/http_exception.dart index ca38f0a2..06cd2915 100644 --- a/lib/exceptions/http_exception.dart +++ b/lib/exceptions/http_exception.dart @@ -19,7 +19,7 @@ import 'dart:convert'; class WgerHttpException implements Exception { - Map? errors; + Map 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 map) { + errors = map; + } + @override String toString() { - return errors!.values.toList().join(', '); + return errors.values.toList().join(', '); } } diff --git a/lib/helpers/errors.dart b/lib/helpers/errors.dart new file mode 100644 index 00000000..5397d0d2 --- /dev/null +++ b/lib/helpers/errors.dart @@ -0,0 +1,347 @@ +/* + * This file is part of wger Workout Manager . + * 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 . + */ + +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().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 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 extractErrors(Map errors) { + final List 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 formatErrors(List errors, {Color? color}) { + final textColor = color ?? Colors.black; + + final List 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, + ), + ], + ); + } +} diff --git a/lib/helpers/ui.dart b/lib/helpers/ui.dart deleted file mode 100644 index ff4b73fe..00000000 --- a/lib/helpers/ui.dart +++ /dev/null @@ -1,149 +0,0 @@ -/* - * This file is part of wger Workout Manager . - * 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 . - */ - -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 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().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; -} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a5335021..ef1c1b95 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/main.dart b/lib/main.dart index 71e7b25c..dd2577f3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'package:wger/core/locator.dart'; +import 'package:wger/exceptions/http_exception.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/helpers/shared_preferences.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/add_exercise.dart'; @@ -69,19 +71,55 @@ void _setupLogging() { }); } -void main() async { - _setupLogging(); - //zx.setLogEnabled(kDebugMode); +final GlobalKey navigatorKey = GlobalKey(); +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( builder: (ctx, user, _) => MaterialApp( title: 'wger', + navigatorKey: navigatorKey, theme: wgerLightTheme, darkTheme: wgerDarkTheme, highContrastTheme: wgerLightThemeHc, diff --git a/lib/providers/routines.dart b/lib/providers/routines.dart index 04b161ff..1c6cb436 100644 --- a/lib/providers/routines.dart +++ b/lib/providers/routines.dart @@ -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'; diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index c94ce93f..1095b2e1 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -22,7 +22,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:wger/exceptions/http_exception.dart'; import 'package:wger/helpers/consts.dart'; -import 'package:wger/helpers/ui.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/screens/update_app_screen.dart'; import 'package:wger/theme/theme.dart'; @@ -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 { bool isObscure = true; bool confirmIsObscure = true; + Widget errorMessage = const SizedBox.shrink(); final GlobalKey _formKey = GlobalKey(); AuthMode _authMode = AuthMode.Login; @@ -195,24 +196,21 @@ class _AuthCardState extends State { ); 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 { child: AutofillGroup( child: Column( children: [ + errorMessage, TextFormField( key: const Key('inputUsername'), decoration: InputDecoration( diff --git a/lib/screens/home_tabs_screen.dart b/lib/screens/home_tabs_screen.dart index 1ba51559..3fea4cee 100644 --- a/lib/screens/home_tabs_screen.dart +++ b/lib/screens/home_tabs_screen.dart @@ -49,6 +49,7 @@ class HomeTabsScreen extends StatefulWidget { class _HomeTabsScreenState extends State with SingleTickerProviderStateMixin { late Future _initialData; + bool _errorHandled = false; int _selectedIndex = 0; @override @@ -87,45 +88,30 @@ class _HomeTabsScreenState extends State 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 with SingleTickerProvid return FutureBuilder( 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(), diff --git a/lib/widgets/measurements/forms.dart b/lib/widgets/measurements/forms.dart index 8ab405d6..b0039824 100644 --- a/lib/widgets/measurements/forms.dart +++ b/lib/widgets/measurements/forms.dart @@ -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( - context, - listen: false, - ).addCategory( - MeasurementCategory( - id: categoryData['id'], - name: categoryData['name'], - unit: categoryData['unit'], - ), - ) - : await Provider.of( - 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( + context, + listen: false, + ).addCategory( + MeasurementCategory( + id: categoryData['id'], + name: categoryData['name'], + unit: categoryData['unit'], + ), + ) + : await Provider.of( + 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( - context, - listen: false, - ).addEntry(MeasurementEntry( - id: _entryData['id'], - category: _entryData['category'], - date: _entryData['date'], - value: _entryData['value'], - notes: _entryData['notes'], - )) - : await Provider.of( - 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( + context, + listen: false, + ).addEntry(MeasurementEntry( + id: _entryData['id'], + category: _entryData['category'], + date: _entryData['date'], + value: _entryData['value'], + notes: _entryData['notes'], + )) + : await Provider.of( + context, + listen: false, + ).editEntry( + _entryData['id'], + _entryData['category'], + _entryData['value'], + _entryData['notes'], + _entryData['date'], + ); + if (context.mounted) { Navigator.of(context).pop(); } diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index 60d32967..ad5b7aa4 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -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( - context, - listen: false, - ).addMeal(_meal, _planId) - : Provider.of( - context, - listen: false, - ).editMeal(_meal); - } on WgerHttpException catch (error) { - showHttpExceptionErrorDialog(error, context); - } catch (error) { - showErrorDialog(error, context); - } + _meal.id == null + ? Provider.of( + context, + listen: false, + ).addMeal(_meal, _planId) + : Provider.of( + context, + listen: false, + ).editMeal(_meal); + Navigator.of(context).pop(); }, ), @@ -401,22 +394,17 @@ class IngredientFormState extends State { _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 { _form.currentState!.save(); // Save to DB - try { - if (widget._plan.id != null) { - await Provider.of( - context, - listen: false, - ).editPlan(widget._plan); - if (context.mounted) { - Navigator.of(context).pop(); - } - } else { - widget._plan = await Provider.of( - 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( + context, + listen: false, + ).editPlan(widget._plan); if (context.mounted) { - showHttpExceptionErrorDialog(error, context); + Navigator.of(context).pop(); } - } catch (error) { + } else { + widget._plan = await Provider.of( + 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(); }, ), ], diff --git a/lib/widgets/nutrition/widgets.dart b/lib/widgets/nutrition/widgets.dart index 0cfd7340..730c9787 100644 --- a/lib/widgets/nutrition/widgets.dart +++ b/lib/widgets/nutrition/widgets.dart @@ -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 { 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; } diff --git a/lib/widgets/routines/forms/day.dart b/lib/widgets/routines/forms/day.dart index a09a101f..d5a6fc38 100644 --- a/lib/widgets/routines/forms/day.dart +++ b/lib/widgets/routines/forms/day.dart @@ -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 { + Widget errorMessage = const SizedBox.shrink(); + @override Widget build(BuildContext context) { final i18n = AppLocalizations.of(context); @@ -63,6 +67,7 @@ class _ReorderableDaysListState extends State { return Column( children: [ + errorMessage, ReorderableListView.builder( buildDefaultDragHandles: false, shrinkWrap: true, @@ -120,7 +125,18 @@ class _ReorderableDaysListState extends State { } }); - 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 { + Widget errorMessage = const SizedBox.shrink(); + final descriptionController = TextEditingController(); final nameController = TextEditingController(); late bool isRestDay; @@ -193,6 +211,7 @@ class _DayFormWidgetState extends State { 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 { try { await Provider.of(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), diff --git a/lib/widgets/routines/forms/routine.dart b/lib/widgets/routines/forms/routine.dart index f39e803f..d8bb5bd1 100644 --- a/lib/widgets/routines/forms/routine.dart +++ b/lib/widgets/routines/forms/routine.dart @@ -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 { final _form = GlobalKey(); + Widget errorMessage = const SizedBox.shrink(); bool isSaving = false; late bool fitInWeek; @@ -50,6 +53,7 @@ class _RoutineFormState extends State { 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 { ); } } - } 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), ), diff --git a/lib/widgets/routines/forms/session.dart b/lib/widgets/routines/forms/session.dart index 79d0006e..4bb6d6cd 100644 --- a/lib/widgets/routines/forms/session.dart +++ b/lib/widgets/routines/forms/session.dart @@ -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 { + Widget errorMessage = const SizedBox.shrink(); final _form = GlobalKey(); final impressionController = TextEditingController(); @@ -90,6 +91,7 @@ class _SessionFormState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + errorMessage, ToggleButtons( renderBorder: false, onPressed: (int index) { @@ -128,6 +130,7 @@ class _SessionFormState extends State { 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 { 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 { 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); + }); } } }, diff --git a/lib/widgets/routines/forms/slot.dart b/lib/widgets/routines/forms/slot.dart index 9425b189..043ca71c 100644 --- a/lib/widgets/routines/forms/slot.dart +++ b/lib/widgets/routines/forms/slot.dart @@ -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 { final maxRestController = TextEditingController(); final rirController = TextEditingController(); + Widget errorMessage = const SizedBox.shrink(); + final _form = GlobalKey(); var _edit = false; @@ -154,6 +158,7 @@ class _SlotEntryFormState extends State { key: _form, child: Column( children: [ + errorMessage, ListTile( title: Text( widget.entry.exerciseObj.getTranslation(languageCode).name, @@ -177,9 +182,18 @@ class _SlotEntryFormState extends State { ? 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 { 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 { bool _showExerciseSearchBox = false; + Widget errorMessage = const SizedBox.shrink(); @override Widget build(BuildContext context) { @@ -427,6 +445,7 @@ class _SlotDetailWidgetState extends State { 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 { 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 { 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 { return Column( children: [ + errorMessage, if (!widget.day.isRest) SwitchListTile( value: simpleMode, @@ -579,7 +611,18 @@ class _SlotFormWidgetStateNg extends State { 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); + }); + } + } }); }, ), diff --git a/lib/widgets/routines/forms/weight.dart b/lib/widgets/routines/forms/weight.dart deleted file mode 100644 index e918da5d..00000000 --- a/lib/widgets/routines/forms/weight.dart +++ /dev/null @@ -1,58 +0,0 @@ -/* - * This file is part of wger Workout Manager . - * 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 . - */ - -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) {} - } - }, - ); - } -} diff --git a/lib/widgets/routines/gym_mode/log_page.dart b/lib/widgets/routines/gym_mode/log_page.dart index 9e2d9cce..17ace6e7 100644 --- a/lib/widgets/routines/gym_mode/log_page.dart +++ b/lib/widgets/routines/gym_mode/log_page.dart @@ -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 { 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: diff --git a/lib/widgets/routines/log.dart b/lib/widgets/routines/log.dart index f4318ccf..3028341f 100644 --- a/lib/widgets/routines/log.dart +++ b/lib/widgets/routines/log.dart @@ -19,8 +19,8 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:wger/helpers/colors.dart'; +import 'package:wger/helpers/errors.dart'; import 'package:wger/helpers/misc.dart'; -import 'package:wger/helpers/ui.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/workouts/log.dart'; import 'package:wger/models/workouts/routine.dart'; diff --git a/lib/widgets/weight/forms.dart b/lib/widgets/weight/forms.dart index 11b21430..73c7c6fe 100644 --- a/lib/widgets/weight/forms.dart +++ b/lib/widgets/weight/forms.dart @@ -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(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(context, listen: false); + _weightEntry.id == null + ? await provider.addEntry(_weightEntry) + : await provider.editEntry(_weightEntry); + if (context.mounted) { Navigator.of(context).pop(); } diff --git a/test/auth/auth_screen_test.dart b/test/auth/auth_screen_test.dart index b030e7cc..17967417 100644 --- a/test/auth/auth_screen_test.dart +++ b/test/auth/auth_screen_test.dart @@ -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); diff --git a/test/nutrition/nutritional_meal_form_test.dart b/test/nutrition/nutritional_meal_form_test.dart index 6f914bb2..82daa7d8 100644 --- a/test/nutrition/nutritional_meal_form_test.dart +++ b/test/nutrition/nutritional_meal_form_test.dart @@ -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'}) { diff --git a/test/nutrition/nutritional_plan_form_test.dart b/test/nutrition/nutritional_plan_form_test.dart index 47fae1ff..5c56634d 100644 --- a/test/nutrition/nutritional_plan_form_test.dart +++ b/test/nutrition/nutritional_plan_form_test.dart @@ -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'); }); } diff --git a/test/utils/errors_test.dart b/test/utils/errors_test.dart new file mode 100644 index 00000000..975e6f61 --- /dev/null +++ b/test/utils/errors_test.dart @@ -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'); + }); + }); +} diff --git a/test/workout/forms/session_form_test.dart b/test/workout/forms/session_form_test.dart index d10af65e..01a90524 100644 --- a/test/workout/forms/session_form_test.dart +++ b/test/workout/forms/session_form_test.dart @@ -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'); + }); }); } diff --git a/test/workout/goldens/routine_logs_screen_detail.png b/test/workout/goldens/routine_logs_screen_detail.png index df048921..eb48d474 100644 Binary files a/test/workout/goldens/routine_logs_screen_detail.png and b/test/workout/goldens/routine_logs_screen_detail.png differ diff --git a/test/workout/gym_mode_session_screen_test.dart b/test/workout/gym_mode_session_screen_test.dart index 3b36bee2..bb5c77f4 100644 --- a/test/workout/gym_mode_session_screen_test.dart +++ b/test/workout/gym_mode_session_screen_test.dart @@ -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(startTimeField).controller!.text, ''); + + final endTimeField = find.byKey(const ValueKey('time-end')); + expect(endTimeField, findsOneWidget); + expect(tester.widget(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); diff --git a/test/workout/routine_form_test.dart b/test/workout/routine_form_test.dart index 214eeb42..c52c5cd8 100644 --- a/test/workout/routine_form_test.dart +++ b/test/workout/routine_form_test.dart @@ -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'); }); }