diff --git a/examples/api/lib/material/text_form_field/text_form_field.2.dart b/examples/api/lib/material/text_form_field/text_form_field.2.dart new file mode 100644 index 00000000000..cb587ee59aa --- /dev/null +++ b/examples/api/lib/material/text_form_field/text_form_field.2.dart @@ -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 createState() => _TextFormFieldExampleState(); +} + +class _TextFormFieldExampleState extends State { + final TextEditingController controller = TextEditingController(); + final GlobalKey formKey = GlobalKey(); + 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 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: [ + 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 validateUsernameFromServer(String username) async { + final Set takenUsernames = {'jack', 'alex'}; + + await Future.delayed(kFakeHttpRequestDuration); + + final bool isValid = !takenUsernames.contains(username); + if (isValid) { + return null; + } + + return 'Username $username is already taken'; +} diff --git a/examples/api/test/material/text_form_field/text_form_field.2_test.dart b/examples/api/test/material/text_form_field/text_form_field.2_test.dart new file mode 100644 index 00000000000..6aa4209a521 --- /dev/null +++ b/examples/api/test/material/text_form_field/text_form_field.2_test.dart @@ -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); + }); + }); +} diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart index 20b37473635..519e71edbfe 100644 --- a/packages/flutter/lib/src/material/text_form_field.dart +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -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 ** /// {@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: /// /// * @@ -105,6 +112,7 @@ class TextFormField extends FormField { this.controller, String? initialValue, FocusNode? focusNode, + super.forceErrorText, InputDecoration? decoration = const InputDecoration(), TextInputType? keyboardType, TextCapitalization textCapitalization = TextCapitalization.none, diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart index b0b322e8f39..d1dc369b3d5 100644 --- a/packages/flutter/lib/src/widgets/form.dart +++ b/packages/flutter/lib/src/widgets/form.dart @@ -234,8 +234,7 @@ class FormState extends State
{ void _fieldDidChange() { widget.onChanged?.call(); - _hasInteractedByUser = _fields - .any((FormFieldState field) => field._hasInteractedByUser.value); + _hasInteractedByUser = _fields.any((FormFieldState field) => field._hasInteractedByUser.value); _forceRebuild(); } @@ -337,7 +336,6 @@ class FormState extends State { return _validate(); } - /// Validates every [FormField] that is a descendant of this [Form], and /// 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 FormState formState, required int generation, - }) : _formState = formState, - _generation = generation; + }) : _formState = formState, + _generation = generation; final FormState _formState; @@ -454,6 +452,7 @@ class FormField extends StatefulWidget { super.key, required this.builder, this.onSaved, + this.forceErrorText, this.validator, this.initialValue, this.enabled = true, @@ -465,6 +464,24 @@ class FormField extends StatefulWidget { /// [FormState.save]. final FormFieldSetter? 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 /// display if the input is invalid, or null otherwise. /// @@ -533,7 +550,9 @@ class FormField extends StatefulWidget { /// for use in constructing the form field's widget. class FormFieldState extends State> with RestorationMixin { 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 FocusNode _focusNode = FocusNode(); @@ -541,8 +560,12 @@ class FormFieldState extends State> with RestorationMixin { T? get value => _value; /// The current validation error returned by the [FormField.validator] - /// callback, or null if no errors have been triggered. This only updates when - /// [validate] is called. + /// callback, or the manually provided error message using the + /// [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; /// True if this field has any validation errors. @@ -562,7 +585,9 @@ class FormFieldState extends State> with RestorationMixin { /// See also: /// /// * [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. void save() { @@ -579,9 +604,10 @@ class FormFieldState extends State> with RestorationMixin { Form.maybeOf(context)?._fieldDidChange(); } - /// Calls [FormField.validator] to set the [errorText]. Returns true if there - /// were no errors. + /// Calls [FormField.validator] to set the [errorText] only if [FormField.forceErrorText] is null. + /// When [FormField.forceErrorText] is not null, [FormField.validator] will not be called. /// + /// Returns true if there were no errors. /// See also: /// /// * [isValid], which passively gets the validity without setting @@ -594,6 +620,11 @@ class FormFieldState extends State> with RestorationMixin { } void _validate() { + if (widget.forceErrorText != null) { + _errorText.value = widget.forceErrorText; + // Skip validating if error is forced. + return; + } if (widget.validator != null) { _errorText.value = widget.validator!(_value); } else { @@ -643,6 +674,20 @@ class FormFieldState extends State> with RestorationMixin { super.deactivate(); } + @override + void initState() { + super.initState(); + _errorText = RestorableStringN(widget.forceErrorText); + } + + @override + void didUpdateWidget(FormField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.forceErrorText != oldWidget.forceErrorText) { + _errorText.value = widget.forceErrorText; + } + } + @override void dispose() { _errorText.dispose(); diff --git a/packages/flutter/test/material/text_form_field_test.dart b/packages/flutter/test/material/text_form_field_test.dart index a0401cc3eb6..b09adec3f80 100644 --- a/packages/flutter/test/material/text_form_field_test.dart +++ b/packages/flutter/test/material/text_form_field_test.dart @@ -1714,4 +1714,84 @@ void main() { expect(stateKey.currentState!.value,'initialValue'); expect(value, 'initialValue'); }); + + testWidgets( + 'isValid returns false when forceErrorText is set and will change error display', + (WidgetTester tester) async { + final GlobalKey> fieldKey1 = GlobalKey>(); + final GlobalKey> fieldKey2 = GlobalKey>(); + 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: [ + 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); + }); } diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart index 262d0a72089..9785d7c28d2 100644 --- a/packages/flutter/test/widgets/form_test.dart +++ b/packages/flutter/test/widgets/form_test.dart @@ -1060,8 +1060,162 @@ void main() { expect(fieldKey.currentState!.hasInteractedByUser, isFalse); }); - testWidgets('Validator is nullified and error text behaves accordingly', - (WidgetTester tester) async { + testWidgets('forceErrorText forces an error state when first init', (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 formKey = GlobalKey(); + 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 formKey = GlobalKey(); + 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 formKey = GlobalKey(); + 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 formKey = GlobalKey(); bool useValidator = false; late StateSetter setState;