mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
add forceErrorText to FormField & TextFormField. (#132903)
Introducing the `forceErrorText` property to both `FormField` and `TextFormField`. With this addition, we gain the capability to trigger an error state and provide an error message without invoking the `validator` method. While the idea of making the `Validator` method work asynchronously may be appealing, it could introduce significant complexity to our current form field implementation. Additionally, this approach might not be suitable for all developers, as discussed by @justinmc in this [comment](https://github.com/flutter/flutter/issues/56414#issuecomment-624960263). This PR try to address this issue by adding `forceErrorText` property allowing us to force the error to the `FormField` or `TextFormField` at our own base making it possible to preform some async operations without the need for any hacks while keep the ability to check for errors if we call `formKey.currentState!.validate()`. Here is an example: <details> <summary>Code Example</summary> ```dart import 'package:flutter/material.dart'; void main() { runApp( const MaterialApp(home: MyHomePage()), ); } class MyHomePage extends StatefulWidget { const MyHomePage({ super.key, }); @override State<MyHomePage> createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { final key = GlobalKey<FormState>(); String? forcedErrorText; Future<void> handleValidation() async { // simulate some async work.. await Future.delayed(const Duration(seconds: 3)); setState(() { forcedErrorText = 'this username is not available.'; }); // wait for build to run and then check. // // this is not required though, as the error would already be showing. WidgetsBinding.instance.addPostFrameCallback((_) { print(key.currentState!.validate()); }); } @override Widget build(BuildContext context) { print('build'); return Scaffold( floatingActionButton: FloatingActionButton(onPressed: handleValidation), body: Form( key: key, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 30), child: TextFormField( forceErrorText: forcedErrorText, ), ), ], ), ), ), ); } } ``` </details> Related to #9688 & #56414. Happy to hear your thoughts on this.
This commit is contained in:
parent
6c06abbb55
commit
f54dfcd27d
138
examples/api/lib/material/text_form_field/text_form_field.2.dart
Normal file
138
examples/api/lib/material/text_form_field/text_form_field.2.dart
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
|
// found in the LICENSE file.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Flutter code sample for [TextFormField].
|
||||||
|
|
||||||
|
const Duration kFakeHttpRequestDuration = Duration(seconds: 3);
|
||||||
|
|
||||||
|
void main() => runApp(const TextFormFieldExampleApp());
|
||||||
|
|
||||||
|
class TextFormFieldExampleApp extends StatelessWidget {
|
||||||
|
const TextFormFieldExampleApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const MaterialApp(
|
||||||
|
home: TextFormFieldExample(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextFormFieldExample extends StatefulWidget {
|
||||||
|
const TextFormFieldExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TextFormFieldExample> createState() => _TextFormFieldExampleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TextFormFieldExampleState extends State<TextFormFieldExample> {
|
||||||
|
final TextEditingController controller = TextEditingController();
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
String? forceErrorText;
|
||||||
|
bool isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? validator(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'This field is required';
|
||||||
|
}
|
||||||
|
if (value.length != value.replaceAll(' ', '').length) {
|
||||||
|
return 'Username must not contain any spaces';
|
||||||
|
}
|
||||||
|
if (int.tryParse(value[0]) != null) {
|
||||||
|
return 'Username must not start with a number';
|
||||||
|
}
|
||||||
|
if (value.length <= 2) {
|
||||||
|
return 'Username should be at least 3 characters long';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onChanged(String value) {
|
||||||
|
// Nullify forceErrorText if the input changed.
|
||||||
|
if (forceErrorText != null) {
|
||||||
|
setState(() {
|
||||||
|
forceErrorText = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onSave() async {
|
||||||
|
// Providing a default value in case this was called on the
|
||||||
|
// first frame, the [fromKey.currentState] will be null.
|
||||||
|
final bool isValid = formKey.currentState?.validate() ?? false;
|
||||||
|
if (!isValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => isLoading = true);
|
||||||
|
final String? errorText = await validateUsernameFromServer(controller.text);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
setState(() => isLoading = false);
|
||||||
|
|
||||||
|
if (errorText != null) {
|
||||||
|
setState(() {
|
||||||
|
forceErrorText = errorText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||||
|
child: Center(
|
||||||
|
child: Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
TextFormField(
|
||||||
|
forceErrorText: forceErrorText,
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Please write a username',
|
||||||
|
),
|
||||||
|
validator: validator,
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 40.0),
|
||||||
|
if (isLoading)
|
||||||
|
const CircularProgressIndicator()
|
||||||
|
else
|
||||||
|
TextButton(
|
||||||
|
onPressed: onSave,
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> validateUsernameFromServer(String username) async {
|
||||||
|
final Set<String> takenUsernames = <String>{'jack', 'alex'};
|
||||||
|
|
||||||
|
await Future<void>.delayed(kFakeHttpRequestDuration);
|
||||||
|
|
||||||
|
final bool isValid = !takenUsernames.contains(username);
|
||||||
|
if (isValid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Username $username is already taken';
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
|
// found in the LICENSE file.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_api_samples/material/text_form_field/text_form_field.2.dart' as example;
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TextFormFieldExample2 Widget Tests', () {
|
||||||
|
testWidgets('Input validation handles empty, incorrect, and short usernames', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const example.TextFormFieldExampleApp());
|
||||||
|
final Finder textFormField = find.byType(TextFormField);
|
||||||
|
final Finder saveButton = find.byType(TextButton);
|
||||||
|
|
||||||
|
await tester.enterText(textFormField, '');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('This field is required'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(textFormField, 'jo hn');
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Username must not contain any spaces'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(textFormField, 'jo');
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Username should be at least 3 characters long'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(textFormField, '1jo');
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Username must not start with a number'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Async validation feedback is handled correctly', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const example.TextFormFieldExampleApp());
|
||||||
|
final Finder textFormField = find.byType(TextFormField);
|
||||||
|
final Finder saveButton = find.byType(TextButton);
|
||||||
|
|
||||||
|
// Simulate entering a username already taken.
|
||||||
|
await tester.enterText(textFormField, 'jack');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Username jack is already taken'), findsNothing);
|
||||||
|
await tester.pump(example.kFakeHttpRequestDuration);
|
||||||
|
expect(find.text('Username jack is already taken'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(textFormField, 'alex');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Username alex is already taken'), findsNothing);
|
||||||
|
await tester.pump(example.kFakeHttpRequestDuration);
|
||||||
|
expect(find.text('Username alex is already taken'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(textFormField, 'jack');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Username jack is already taken'), findsNothing);
|
||||||
|
await tester.pump(example.kFakeHttpRequestDuration);
|
||||||
|
expect(find.text('Username jack is already taken'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Loading spinner displays correctly when saving', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const example.TextFormFieldExampleApp());
|
||||||
|
final Finder textFormField = find.byType(TextFormField);
|
||||||
|
final Finder saveButton = find.byType(TextButton);
|
||||||
|
await tester.enterText(textFormField, 'alexander');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
await tester.pump(example.kFakeHttpRequestDuration);
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
@ -81,6 +81,13 @@ export 'package:flutter/services.dart' show SmartDashesType, SmartQuotesType;
|
|||||||
/// ** See code in examples/api/lib/material/text_form_field/text_form_field.1.dart **
|
/// ** See code in examples/api/lib/material/text_form_field/text_form_field.1.dart **
|
||||||
/// {@end-tool}
|
/// {@end-tool}
|
||||||
///
|
///
|
||||||
|
/// {@tool dartpad}
|
||||||
|
/// This example shows how to force an error text to the field after making
|
||||||
|
/// an asynchronous call.
|
||||||
|
///
|
||||||
|
/// ** See code in examples/api/lib/material/text_form_field/text_form_field.2.dart **
|
||||||
|
/// {@end-tool}
|
||||||
|
///
|
||||||
/// See also:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * <https://material.io/design/components/text-fields.html>
|
/// * <https://material.io/design/components/text-fields.html>
|
||||||
@ -105,6 +112,7 @@ class TextFormField extends FormField<String> {
|
|||||||
this.controller,
|
this.controller,
|
||||||
String? initialValue,
|
String? initialValue,
|
||||||
FocusNode? focusNode,
|
FocusNode? focusNode,
|
||||||
|
super.forceErrorText,
|
||||||
InputDecoration? decoration = const InputDecoration(),
|
InputDecoration? decoration = const InputDecoration(),
|
||||||
TextInputType? keyboardType,
|
TextInputType? keyboardType,
|
||||||
TextCapitalization textCapitalization = TextCapitalization.none,
|
TextCapitalization textCapitalization = TextCapitalization.none,
|
||||||
|
@ -234,8 +234,7 @@ class FormState extends State<Form> {
|
|||||||
void _fieldDidChange() {
|
void _fieldDidChange() {
|
||||||
widget.onChanged?.call();
|
widget.onChanged?.call();
|
||||||
|
|
||||||
_hasInteractedByUser = _fields
|
_hasInteractedByUser = _fields.any((FormFieldState<dynamic> field) => field._hasInteractedByUser.value);
|
||||||
.any((FormFieldState<dynamic> field) => field._hasInteractedByUser.value);
|
|
||||||
_forceRebuild();
|
_forceRebuild();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,7 +336,6 @@ class FormState extends State<Form> {
|
|||||||
return _validate();
|
return _validate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Validates every [FormField] that is a descendant of this [Form], and
|
/// Validates every [FormField] that is a descendant of this [Form], and
|
||||||
/// returns a [Set] of [FormFieldState] of the invalid field(s) only, if any.
|
/// returns a [Set] of [FormFieldState] of the invalid field(s) only, if any.
|
||||||
///
|
///
|
||||||
@ -393,8 +391,8 @@ class _FormScope extends InheritedWidget {
|
|||||||
required super.child,
|
required super.child,
|
||||||
required FormState formState,
|
required FormState formState,
|
||||||
required int generation,
|
required int generation,
|
||||||
}) : _formState = formState,
|
}) : _formState = formState,
|
||||||
_generation = generation;
|
_generation = generation;
|
||||||
|
|
||||||
final FormState _formState;
|
final FormState _formState;
|
||||||
|
|
||||||
@ -454,6 +452,7 @@ class FormField<T> extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.builder,
|
required this.builder,
|
||||||
this.onSaved,
|
this.onSaved,
|
||||||
|
this.forceErrorText,
|
||||||
this.validator,
|
this.validator,
|
||||||
this.initialValue,
|
this.initialValue,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
@ -465,6 +464,24 @@ class FormField<T> extends StatefulWidget {
|
|||||||
/// [FormState.save].
|
/// [FormState.save].
|
||||||
final FormFieldSetter<T>? onSaved;
|
final FormFieldSetter<T>? onSaved;
|
||||||
|
|
||||||
|
/// An optional property that forces the [FormFieldState] into an error state
|
||||||
|
/// by directly setting the [FormFieldState.errorText] property without
|
||||||
|
/// running the validator function.
|
||||||
|
///
|
||||||
|
/// When the [forceErrorText] property is provided, the [FormFieldState.errorText]
|
||||||
|
/// will be set to the provided value, causing the form field to be considered
|
||||||
|
/// invalid and to display the error message specified.
|
||||||
|
///
|
||||||
|
/// When [validator] is provided, [forceErrorText] will override any error that it
|
||||||
|
/// returns. [validator] will not be called unless [forceErrorText] is null.
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [InputDecoration.errorText], which is used to display error messages in the text
|
||||||
|
/// field's decoration without effecting the field's state. When [forceErrorText] is
|
||||||
|
/// not null, it will override [InputDecoration.errorText] value.
|
||||||
|
final String? forceErrorText;
|
||||||
|
|
||||||
/// An optional method that validates an input. Returns an error string to
|
/// An optional method that validates an input. Returns an error string to
|
||||||
/// display if the input is invalid, or null otherwise.
|
/// display if the input is invalid, or null otherwise.
|
||||||
///
|
///
|
||||||
@ -533,7 +550,9 @@ class FormField<T> extends StatefulWidget {
|
|||||||
/// for use in constructing the form field's widget.
|
/// for use in constructing the form field's widget.
|
||||||
class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
|
class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
|
||||||
late T? _value = widget.initialValue;
|
late T? _value = widget.initialValue;
|
||||||
final RestorableStringN _errorText = RestorableStringN(null);
|
// Marking it as late, so it can be registered
|
||||||
|
// with the value provided by [forceErrorText].
|
||||||
|
late final RestorableStringN _errorText;
|
||||||
final RestorableBool _hasInteractedByUser = RestorableBool(false);
|
final RestorableBool _hasInteractedByUser = RestorableBool(false);
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
|
||||||
@ -541,8 +560,12 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
|
|||||||
T? get value => _value;
|
T? get value => _value;
|
||||||
|
|
||||||
/// The current validation error returned by the [FormField.validator]
|
/// The current validation error returned by the [FormField.validator]
|
||||||
/// callback, or null if no errors have been triggered. This only updates when
|
/// callback, or the manually provided error message using the
|
||||||
/// [validate] is called.
|
/// [FormField.forceErrorText] property.
|
||||||
|
///
|
||||||
|
/// This property is automatically updated when [validate] is called and the
|
||||||
|
/// [FormField.validator] callback is invoked, or If [FormField.forceErrorText] is set
|
||||||
|
/// directly to a non-null value.
|
||||||
String? get errorText => _errorText.value;
|
String? get errorText => _errorText.value;
|
||||||
|
|
||||||
/// True if this field has any validation errors.
|
/// True if this field has any validation errors.
|
||||||
@ -562,7 +585,9 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
|
|||||||
/// See also:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [validate], which may update [errorText] and [hasError].
|
/// * [validate], which may update [errorText] and [hasError].
|
||||||
bool get isValid => widget.validator?.call(_value) == null;
|
///
|
||||||
|
/// * [FormField.forceErrorText], which also may update [errorText] and [hasError].
|
||||||
|
bool get isValid => widget.forceErrorText == null && widget.validator?.call(_value) == null;
|
||||||
|
|
||||||
/// Calls the [FormField]'s onSaved method with the current value.
|
/// Calls the [FormField]'s onSaved method with the current value.
|
||||||
void save() {
|
void save() {
|
||||||
@ -579,9 +604,10 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
|
|||||||
Form.maybeOf(context)?._fieldDidChange();
|
Form.maybeOf(context)?._fieldDidChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calls [FormField.validator] to set the [errorText]. Returns true if there
|
/// Calls [FormField.validator] to set the [errorText] only if [FormField.forceErrorText] is null.
|
||||||
/// were no errors.
|
/// When [FormField.forceErrorText] is not null, [FormField.validator] will not be called.
|
||||||
///
|
///
|
||||||
|
/// Returns true if there were no errors.
|
||||||
/// See also:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [isValid], which passively gets the validity without setting
|
/// * [isValid], which passively gets the validity without setting
|
||||||
@ -594,6 +620,11 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _validate() {
|
void _validate() {
|
||||||
|
if (widget.forceErrorText != null) {
|
||||||
|
_errorText.value = widget.forceErrorText;
|
||||||
|
// Skip validating if error is forced.
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (widget.validator != null) {
|
if (widget.validator != null) {
|
||||||
_errorText.value = widget.validator!(_value);
|
_errorText.value = widget.validator!(_value);
|
||||||
} else {
|
} else {
|
||||||
@ -643,6 +674,20 @@ class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
|
|||||||
super.deactivate();
|
super.deactivate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_errorText = RestorableStringN(widget.forceErrorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(FormField<T> oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.forceErrorText != oldWidget.forceErrorText) {
|
||||||
|
_errorText.value = widget.forceErrorText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_errorText.dispose();
|
_errorText.dispose();
|
||||||
|
@ -1714,4 +1714,84 @@ void main() {
|
|||||||
expect(stateKey.currentState!.value,'initialValue');
|
expect(stateKey.currentState!.value,'initialValue');
|
||||||
expect(value, 'initialValue');
|
expect(value, 'initialValue');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'isValid returns false when forceErrorText is set and will change error display',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final GlobalKey<FormFieldState<String>> fieldKey1 = GlobalKey<FormFieldState<String>>();
|
||||||
|
final GlobalKey<FormFieldState<String>> fieldKey2 = GlobalKey<FormFieldState<String>>();
|
||||||
|
const String forceErrorText = 'Forcing error.';
|
||||||
|
const String validString = 'Valid string';
|
||||||
|
String? validator(String? s) => s == validString ? null : 'Error text';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Center(
|
||||||
|
child: Material(
|
||||||
|
child: Form(
|
||||||
|
child: ListView(
|
||||||
|
children: <Widget>[
|
||||||
|
TextFormField(
|
||||||
|
key: fieldKey1,
|
||||||
|
initialValue: validString,
|
||||||
|
validator: validator,
|
||||||
|
autovalidateMode: AutovalidateMode.disabled,
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
key: fieldKey2,
|
||||||
|
initialValue: '',
|
||||||
|
forceErrorText: forceErrorText,
|
||||||
|
validator: validator,
|
||||||
|
autovalidateMode: AutovalidateMode.disabled,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fieldKey1.currentState!.isValid, isTrue);
|
||||||
|
expect(fieldKey1.currentState!.hasError, isFalse);
|
||||||
|
expect(fieldKey2.currentState!.isValid, isFalse);
|
||||||
|
expect(fieldKey2.currentState!.hasError, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('forceErrorText will override InputDecoration.error when both are provided', (WidgetTester tester) async {
|
||||||
|
const String forceErrorText = 'Forcing error';
|
||||||
|
const String decorationErrorText = 'Decoration';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Center(
|
||||||
|
child: Material(
|
||||||
|
child: Form(
|
||||||
|
child: TextFormField(
|
||||||
|
forceErrorText: forceErrorText,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
errorText: decorationErrorText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text(forceErrorText), findsOne);
|
||||||
|
expect(find.text(decorationErrorText), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
@ -1060,8 +1060,162 @@ void main() {
|
|||||||
expect(fieldKey.currentState!.hasInteractedByUser, isFalse);
|
expect(fieldKey.currentState!.hasInteractedByUser, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Validator is nullified and error text behaves accordingly',
|
testWidgets('forceErrorText forces an error state when first init', (WidgetTester tester) async {
|
||||||
(WidgetTester tester) async {
|
const String forceErrorText = 'Forcing error.';
|
||||||
|
|
||||||
|
Widget builder(AutovalidateMode autovalidateMode) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Center(
|
||||||
|
child: Material(
|
||||||
|
child: Form(
|
||||||
|
autovalidateMode: autovalidateMode,
|
||||||
|
child: TextFormField(
|
||||||
|
forceErrorText: forceErrorText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.pumpWidget(builder(AutovalidateMode.disabled));
|
||||||
|
expect(find.text(forceErrorText), findsOne);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Validate returns false when forceErrorText is non-null even when validator returns a null value',
|
||||||
|
(WidgetTester tester) async {
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
const String forceErrorText = 'Forcing error';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Center(
|
||||||
|
child: Material(
|
||||||
|
child: Form(
|
||||||
|
key: formKey,
|
||||||
|
child: TextFormField(
|
||||||
|
forceErrorText: forceErrorText,
|
||||||
|
validator: (String? value) => null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text(forceErrorText), findsOne);
|
||||||
|
final bool isValid = formKey.currentState!.validate();
|
||||||
|
expect(isValid, isFalse);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text(forceErrorText), findsOne);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('forceErrorText forces an error state only after setting it to a non-null value', (WidgetTester tester) async {
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
const String errorText = 'Forcing Error Text';
|
||||||
|
Widget builder(AutovalidateMode autovalidateMode, String? forceErrorText) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Center(
|
||||||
|
child: Material(
|
||||||
|
child: Form(
|
||||||
|
key: formKey,
|
||||||
|
autovalidateMode: autovalidateMode,
|
||||||
|
child: TextFormField(
|
||||||
|
forceErrorText: forceErrorText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tester.pumpWidget(builder(AutovalidateMode.disabled, null));
|
||||||
|
final bool isValid = formKey.currentState!.validate();
|
||||||
|
expect(isValid, true);
|
||||||
|
expect(find.text(errorText), findsNothing);
|
||||||
|
await tester.pumpWidget(builder(AutovalidateMode.disabled, errorText));
|
||||||
|
expect(find.text(errorText), findsOne);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Validator will not be called if forceErrorText is provided', (WidgetTester tester) async {
|
||||||
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
|
const String forceErrorText = 'Forcing error.';
|
||||||
|
const String validatorErrorText = 'this error should not appear as we override it with forceErrorText';
|
||||||
|
bool didCallValidator = false;
|
||||||
|
|
||||||
|
Widget builder(AutovalidateMode autovalidateMode) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Center(
|
||||||
|
child: Material(
|
||||||
|
child: Form(
|
||||||
|
key: formKey,
|
||||||
|
autovalidateMode: autovalidateMode,
|
||||||
|
child: TextFormField(
|
||||||
|
forceErrorText: forceErrorText,
|
||||||
|
validator: (String? value) {
|
||||||
|
didCallValidator = true;
|
||||||
|
return validatorErrorText;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start off not autovalidating.
|
||||||
|
await tester.pumpWidget(builder(AutovalidateMode.disabled));
|
||||||
|
expect(find.text(forceErrorText), findsOne);
|
||||||
|
expect(find.text(validatorErrorText), findsNothing);
|
||||||
|
|
||||||
|
formKey.currentState!.reset();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text(forceErrorText), findsNothing);
|
||||||
|
expect(find.text(validatorErrorText), findsNothing);
|
||||||
|
|
||||||
|
// We have to manually validate if we're not autovalidating.
|
||||||
|
formKey.currentState!.validate();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(didCallValidator, isFalse);
|
||||||
|
expect(find.text(forceErrorText), findsOne);
|
||||||
|
expect(find.text(validatorErrorText), findsNothing);
|
||||||
|
|
||||||
|
// Try again with autovalidation. Should validate immediately.
|
||||||
|
await tester.pumpWidget(builder(AutovalidateMode.always));
|
||||||
|
|
||||||
|
expect(didCallValidator, isFalse);
|
||||||
|
expect(find.text(forceErrorText), findsOne);
|
||||||
|
expect(find.text(validatorErrorText), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Validator is nullified and error text behaves accordingly', (WidgetTester tester) async {
|
||||||
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
bool useValidator = false;
|
bool useValidator = false;
|
||||||
late StateSetter setState;
|
late StateSetter setState;
|
||||||
|
Loading…
Reference in New Issue
Block a user