mirror of
https://github.com/wger-project/flutter.git
synced 2026-02-18 00:17:48 +01:00
Merge pull request #813 from wger-project/feature/better-error-handling
Improve error handling and logging
This commit is contained in:
@@ -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
347
lib/helpers/errors.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'}) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
66
test/utils/errors_test.dart
Normal file
66
test/utils/errors_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user