// 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/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('onSaved callback is called', (WidgetTester tester) async { final formKey = GlobalKey(); String? fieldValue; Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, child: TextFormField( onSaved: (String? value) { fieldValue = value; }, ), ), ), ), ), ), ); } await tester.pumpWidget(builder()); expect(fieldValue, isNull); Future checkText(String testValue) async { await tester.enterText(find.byType(TextFormField), testValue); formKey.currentState!.save(); // Pumping is unnecessary because callback happens regardless of frames. expect(fieldValue, equals(testValue)); } await checkText('Test'); await checkText(''); }); testWidgets('onChanged callback is called', (WidgetTester tester) async { String? fieldValue; Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( child: TextField( onChanged: (String value) { fieldValue = value; }, ), ), ), ), ), ), ); } await tester.pumpWidget(builder()); expect(fieldValue, isNull); Future checkText(String testValue) async { await tester.enterText(find.byType(TextField), testValue); // Pumping is unnecessary because callback happens regardless of frames. expect(fieldValue, equals(testValue)); } await checkText('Test'); await checkText(''); }); testWidgets('onReset callback is called', (WidgetTester tester) async { final formKey = GlobalKey(); var resetCalled = false; await tester.pumpWidget( MaterialApp( home: Form( key: formKey, child: FormField( builder: (_) => const SizedBox.shrink(), onReset: () { resetCalled = true; }, ), ), ), ); expect(resetCalled, isFalse); formKey.currentState!.reset(); await tester.pump(); expect(resetCalled, isTrue); }); testWidgets('Validator sets the error text only when validate is called', ( WidgetTester tester, ) async { final formKey = GlobalKey(); String? errorText(String? value) => '${value ?? ''}/error'; 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(validator: errorText), ), ), ), ), ), ); } // Start off not autovalidating. await tester.pumpWidget(builder(AutovalidateMode.disabled)); Future checkErrorText(String testValue) async { formKey.currentState!.reset(); await tester.pumpWidget(builder(AutovalidateMode.disabled)); await tester.enterText(find.byType(TextFormField), testValue); await tester.pump(); // We have to manually validate if we're not autovalidating. expect(find.text(errorText(testValue)!), findsNothing); formKey.currentState!.validate(); await tester.pump(); expect(find.text(errorText(testValue)!), findsOneWidget); // Try again with autovalidation. Should validate immediately. formKey.currentState!.reset(); await tester.pumpWidget(builder(AutovalidateMode.always)); await tester.enterText(find.byType(TextFormField), testValue); await tester.pump(); expect(find.text(errorText(testValue)!), findsOneWidget); } await checkErrorText('Test'); await checkErrorText(''); }); for (final test in <_PlatformAnnounceScenario>[ _PlatformAnnounceScenario( supportsAnnounce: false, testName: 'Should announce only the first error message when validate returns errors and announce = false', ), _PlatformAnnounceScenario( supportsAnnounce: true, testName: 'Should not announce error message when validate returns errors and announce = true', ), ]) { testWidgets(test.testName, (WidgetTester tester) async { final formKey = GlobalKey(); await tester.pumpWidget( MaterialApp( home: MediaQuery( data: MediaQueryData(supportsAnnounce: test.supportsAnnounce), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, child: Column( children: [ TextFormField(validator: (_) => 'First error message'), TextFormField(validator: (_) => 'Second error message'), ], ), ), ), ), ), ), ), ); formKey.currentState!.reset(); await tester.enterText(find.byType(TextFormField).first, ''); await tester.pump(); // Manually validate. expect(find.text('First error message'), findsNothing); expect(find.text('Second error message'), findsNothing); formKey.currentState!.validate(); await tester.pump(); expect(find.text('First error message'), findsOneWidget); expect(find.text('Second error message'), findsOneWidget); if (test.supportsAnnounce) { expect(tester.takeAnnouncements(), [ isAccessibilityAnnouncement( 'First error message', textDirection: TextDirection.ltr, assertiveness: Assertiveness.assertive, ), ]); } else { expect(tester.takeAnnouncements(), isEmpty); } }); } testWidgets('isValid returns true when a field is valid', (WidgetTester tester) async { final fieldKey1 = GlobalKey>(); final fieldKey2 = GlobalKey>(); const validString = 'Valid string'; String? validator(String? s) => s == validString ? null : 'Error text'; Widget builder() { return 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.always, ), TextFormField( key: fieldKey2, initialValue: validString, validator: validator, autovalidateMode: AutovalidateMode.always, ), ], ), ), ), ), ), ), ); } await tester.pumpWidget(builder()); expect(fieldKey1.currentState!.isValid, isTrue); expect(fieldKey2.currentState!.isValid, isTrue); }); testWidgets('isValid returns false when the field is invalid and does not change error display', ( WidgetTester tester, ) async { final fieldKey1 = GlobalKey>(); final fieldKey2 = GlobalKey>(); const validString = 'Valid string'; String? validator(String? s) => s == validString ? null : 'Error text'; Widget builder() { return 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: '', validator: validator, autovalidateMode: AutovalidateMode.disabled, ), ], ), ), ), ), ), ), ); } await tester.pumpWidget(builder()); expect(fieldKey1.currentState!.isValid, isTrue); expect(fieldKey2.currentState!.isValid, isFalse); expect(fieldKey2.currentState!.hasError, isFalse); }); testWidgets('validateGranularly returns a set containing all, and only, invalid fields', ( WidgetTester tester, ) async { final formKey = GlobalKey(); final validFieldsKey = UniqueKey(); final invalidFieldsKey = UniqueKey(); const validString = 'Valid string'; const invalidString = 'Invalid string'; String? validator(String? s) => s == validString ? null : 'Error text'; Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, child: ListView( children: [ TextFormField( key: validFieldsKey, initialValue: validString, validator: validator, autovalidateMode: AutovalidateMode.disabled, ), TextFormField( key: invalidFieldsKey, initialValue: invalidString, validator: validator, autovalidateMode: AutovalidateMode.disabled, ), TextFormField( key: invalidFieldsKey, initialValue: invalidString, validator: validator, autovalidateMode: AutovalidateMode.disabled, ), ], ), ), ), ), ), ), ); } await tester.pumpWidget(builder()); final Set> validationResult = formKey.currentState! .validateGranularly(); expect(validationResult.length, equals(2)); expect( validationResult .where((FormFieldState field) => field.widget.key == invalidFieldsKey) .length, equals(2), ); expect( validationResult .where((FormFieldState field) => field.widget.key == validFieldsKey) .length, equals(0), ); }); testWidgets('Should announce error text when validateGranularly is called', ( WidgetTester tester, ) async { final formKey = GlobalKey(); const validString = 'Valid string'; String? validator(String? s) => s == validString ? null : 'error'; Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(supportsAnnounce: true), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, child: ListView( children: [ TextFormField( initialValue: validString, validator: validator, autovalidateMode: AutovalidateMode.disabled, ), TextFormField( initialValue: '', validator: validator, autovalidateMode: AutovalidateMode.disabled, ), ], ), ), ), ), ), ), ); } await tester.pumpWidget(builder()); expect(find.text('error'), findsNothing); formKey.currentState!.validateGranularly(); await tester.pump(); expect(find.text('error'), findsOneWidget); expect(tester.takeAnnouncements(), [ isAccessibilityAnnouncement( 'error', textDirection: TextDirection.ltr, assertiveness: Assertiveness.assertive, ), ]); }); testWidgets('Multiple TextFormFields communicate', (WidgetTester tester) async { final formKey = GlobalKey(); final fieldKey = GlobalKey>(); // Input 2's validator depends on a input 1's value. String? errorText(String? input) => '${fieldKey.currentState!.value}/error'; Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, autovalidateMode: AutovalidateMode.always, child: ListView( children: [ TextFormField(key: fieldKey), TextFormField(validator: errorText), ], ), ), ), ), ), ), ); } await tester.pumpWidget(builder()); Future checkErrorText(String testValue) async { await tester.enterText(find.byType(TextFormField).first, testValue); await tester.pump(); // Check for a new Text widget with our error text. expect(find.text('$testValue/error'), findsOneWidget); return; } await checkErrorText('Test'); await checkErrorText(''); }); testWidgets('Provide initial value to input when no controller is specified', ( WidgetTester tester, ) async { const initialValue = 'hello'; final inputKey = GlobalKey>(); Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( child: TextFormField(key: inputKey, initialValue: 'hello'), ), ), ), ), ), ); } await tester.pumpWidget(builder()); await tester.showKeyboard(find.byType(TextFormField)); // initial value should be loaded into keyboard editing state expect(tester.testTextInput.editingState, isNotNull); expect(tester.testTextInput.editingState!['text'], equals(initialValue)); // initial value should also be visible in the raw input line final EditableTextState editableText = tester.state(find.byType(EditableText)); expect(editableText.widget.controller.text, equals(initialValue)); // sanity check, make sure we can still edit the text and everything updates expect(inputKey.currentState!.value, equals(initialValue)); await tester.enterText(find.byType(TextFormField), 'world'); await tester.pump(); expect(inputKey.currentState!.value, equals('world')); expect(editableText.widget.controller.text, equals('world')); }); testWidgets('Controller defines initial value', (WidgetTester tester) async { final controller = TextEditingController(text: 'hello'); addTearDown(controller.dispose); const initialValue = 'hello'; final inputKey = GlobalKey>(); Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( child: TextFormField(key: inputKey, controller: controller), ), ), ), ), ), ); } await tester.pumpWidget(builder()); await tester.showKeyboard(find.byType(TextFormField)); // initial value should be loaded into keyboard editing state expect(tester.testTextInput.editingState, isNotNull); expect(tester.testTextInput.editingState!['text'], equals(initialValue)); // initial value should also be visible in the raw input line final EditableTextState editableText = tester.state(find.byType(EditableText)); expect(editableText.widget.controller.text, equals(initialValue)); expect(controller.text, equals(initialValue)); // sanity check, make sure we can still edit the text and everything updates expect(inputKey.currentState!.value, equals(initialValue)); await tester.enterText(find.byType(TextFormField), 'world'); await tester.pump(); expect(inputKey.currentState!.value, equals('world')); expect(editableText.widget.controller.text, equals('world')); expect(controller.text, equals('world')); }); testWidgets('TextFormField resets to its initial value', (WidgetTester tester) async { final formKey = GlobalKey(); final inputKey = GlobalKey>(); final controller = TextEditingController(text: 'Plover'); addTearDown(controller.dispose); Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, child: TextFormField( key: inputKey, controller: controller, // initialValue is 'Plover' ), ), ), ), ), ), ); } await tester.pumpWidget(builder()); await tester.showKeyboard(find.byType(TextFormField)); final EditableTextState editableText = tester.state(find.byType(EditableText)); // overwrite initial value. controller.text = 'Xyzzy'; await tester.idle(); expect(editableText.widget.controller.text, equals('Xyzzy')); expect(inputKey.currentState!.value, equals('Xyzzy')); expect(controller.text, equals('Xyzzy')); // verify value resets to initialValue on reset. formKey.currentState!.reset(); await tester.idle(); expect(inputKey.currentState!.value, equals('Plover')); expect(editableText.widget.controller.text, equals('Plover')); expect(controller.text, equals('Plover')); }); testWidgets('TextEditingController updates to/from form field value', ( WidgetTester tester, ) async { final controller1 = TextEditingController(text: 'Foo'); addTearDown(controller1.dispose); final controller2 = TextEditingController(text: 'Bar'); addTearDown(controller2.dispose); final inputKey = GlobalKey>(); TextEditingController? currentController; late StateSetter setState; Widget builder() { return StatefulBuilder( builder: (BuildContext context, StateSetter setter) { setState = setter; return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( child: TextFormField(key: inputKey, controller: currentController), ), ), ), ), ), ); }, ); } await tester.pumpWidget(builder()); await tester.showKeyboard(find.byType(TextFormField)); // verify initially empty. expect(tester.testTextInput.editingState, isNotNull); expect(tester.testTextInput.editingState!['text'], isEmpty); final EditableTextState editableText = tester.state(find.byType(EditableText)); expect(editableText.widget.controller.text, isEmpty); // verify changing the controller from null to controller1 sets the value. setState(() { currentController = controller1; }); await tester.pump(); expect(editableText.widget.controller.text, equals('Foo')); expect(inputKey.currentState!.value, equals('Foo')); // verify changes to controller1 text are visible in text field and set in form value. controller1.text = 'Wobble'; await tester.idle(); expect(editableText.widget.controller.text, equals('Wobble')); expect(inputKey.currentState!.value, equals('Wobble')); // verify changes to the field text update the form value and controller1. await tester.enterText(find.byType(TextFormField), 'Wibble'); await tester.pump(); expect(inputKey.currentState!.value, equals('Wibble')); expect(editableText.widget.controller.text, equals('Wibble')); expect(controller1.text, equals('Wibble')); // verify that switching from controller1 to controller2 is handled. setState(() { currentController = controller2; }); await tester.pump(); expect(inputKey.currentState!.value, equals('Bar')); expect(editableText.widget.controller.text, equals('Bar')); expect(controller2.text, equals('Bar')); expect(controller1.text, equals('Wibble')); // verify changes to controller2 text are visible in text field and set in form value. controller2.text = 'Xyzzy'; await tester.idle(); expect(editableText.widget.controller.text, equals('Xyzzy')); expect(inputKey.currentState!.value, equals('Xyzzy')); expect(controller1.text, equals('Wibble')); // verify changes to controller1 text are not visible in text field or set in form value. controller1.text = 'Plugh'; await tester.idle(); expect(editableText.widget.controller.text, equals('Xyzzy')); expect(inputKey.currentState!.value, equals('Xyzzy')); expect(controller1.text, equals('Plugh')); // verify that switching from controller2 to null is handled. setState(() { currentController = null; }); await tester.pump(); expect(inputKey.currentState!.value, equals('Xyzzy')); expect(editableText.widget.controller.text, equals('Xyzzy')); expect(controller2.text, equals('Xyzzy')); expect(controller1.text, equals('Plugh')); // verify that changes to the field text update the form value but not the previous controllers. await tester.enterText(find.byType(TextFormField), 'Plover'); await tester.pump(); expect(inputKey.currentState!.value, equals('Plover')); expect(editableText.widget.controller.text, equals('Plover')); expect(controller1.text, equals('Plugh')); expect(controller2.text, equals('Xyzzy')); }); testWidgets('No crash when a TextFormField is removed from the tree', ( WidgetTester tester, ) async { final formKey = GlobalKey(); String? fieldValue; Widget builder(bool remove) { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, child: remove ? Container() : TextFormField( autofocus: true, onSaved: (String? value) { fieldValue = value; }, validator: (String? value) { return (value == null || value.isEmpty) ? null : 'yes'; }, ), ), ), ), ), ), ); } await tester.pumpWidget(builder(false)); expect(fieldValue, isNull); expect(formKey.currentState!.validate(), isTrue); await tester.enterText(find.byType(TextFormField), 'Test'); await tester.pumpWidget(builder(false)); // Form wasn't saved yet. expect(fieldValue, null); expect(formKey.currentState!.validate(), isFalse); formKey.currentState!.save(); // Now fieldValue is saved. expect(fieldValue, 'Test'); expect(formKey.currentState!.validate(), isFalse); // Now remove the field with an error. await tester.pumpWidget(builder(true)); // Reset the form. Should not crash. formKey.currentState!.reset(); formKey.currentState!.save(); expect(formKey.currentState!.validate(), isTrue); }); testWidgets( 'Does not auto-validate before value changes when autovalidateMode is set to onUserInteraction', (WidgetTester tester) async { late FormFieldState formFieldState; String? errorText(String? value) => '$value/error'; Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: FormField( initialValue: 'foo', autovalidateMode: AutovalidateMode.onUserInteraction, builder: (FormFieldState state) { formFieldState = state; return Container(); }, validator: errorText, ), ), ), ), ), ); } await tester.pumpWidget(builder()); // The form field has no error. expect(formFieldState.hasError, isFalse); // No error widget is visible. expect(find.text(errorText('foo')!), findsNothing); }, ); testWidgets( 'Does not auto-validate before value changes when autovalidateMode is set to onUserInteractionIfError', (WidgetTester tester) async { late FormFieldState formFieldState; String? errorText(String? value) => (value == null || value.isEmpty) ? 'Required' : null; Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: FormField( initialValue: 'foo', autovalidateMode: AutovalidateMode.onUserInteractionIfError, builder: (FormFieldState state) { formFieldState = state; return Container(); }, validator: errorText, ), ), ), ), ), ); } await tester.pumpWidget(builder()); // The form field has no error. expect(formFieldState.hasError, isFalse); // No "Required" error is visible. expect(find.text('Required'), findsNothing); }, ); testWidgets( 'Does not auto-validate before value changes even when initialValue is empty and autovalidateMode is set to onUserInteractionIfError', (WidgetTester tester) async { late FormFieldState formFieldState; String? errorText(String? value) => (value == null || value.isEmpty) ? 'Required' : null; Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: FormField( autovalidateMode: AutovalidateMode.onUserInteractionIfError, builder: (FormFieldState state) { formFieldState = state; return Column( mainAxisSize: MainAxisSize.min, children: [ Container(), if (state.errorText != null) Text(state.errorText!), ], ); }, validator: errorText, ), ), ), ), ), ); } await tester.pumpWidget(builder()); expect(formFieldState.hasError, isFalse); expect(find.text('Required'), findsNothing); expect(formFieldState.errorText, isNull); formFieldState.validate(); await tester.pump(); expect(formFieldState.hasError, isTrue); expect(find.text('Required'), findsOneWidget); }, ); testWidgets('auto-validate before value changes if autovalidateMode was set to always', ( WidgetTester tester, ) async { late FormFieldState formFieldState; String? errorText(String? value) => '$value/error'; Widget builder() { return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: FormField( initialValue: 'foo', autovalidateMode: AutovalidateMode.always, builder: (FormFieldState state) { formFieldState = state; return Container(); }, validator: errorText, ), ), ), ), ), ); } await tester.pumpWidget(builder()); expect(formFieldState.hasError, isTrue); }); testWidgets( 'Form auto-validates form fields only after one of them changes if autovalidateMode is onUserInteraction', (WidgetTester tester) async { const initialValue = 'foo'; String? errorText(String? value) => 'error/$value'; Widget builder() { return MaterialApp( home: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( children: [ TextFormField(initialValue: initialValue, validator: errorText), TextFormField(initialValue: initialValue, validator: errorText), TextFormField(initialValue: initialValue, validator: errorText), ], ), ), ), ), ), ); } // Makes sure the Form widget won't auto-validate the form fields // after rebuilds if there is not user interaction. await tester.pumpWidget(builder()); await tester.pumpWidget(builder()); // We expect no validation error text being shown. expect(find.text(errorText(initialValue)!), findsNothing); // Set a empty string into the first form field to // trigger the fields validators. await tester.enterText(find.byType(TextFormField).first, ''); await tester.pump(); // Now we expect the errors to be shown for the first Text Field and // for the next two form fields that have their contents unchanged. expect(find.text(errorText('')!), findsOneWidget); expect(find.text(errorText(initialValue)!), findsNWidgets(2)); }, ); testWidgets( 'Form auto-validates form fields even before any have changed if autovalidateMode is set to always', (WidgetTester tester) async { String? errorText(String? value) => 'error/$value'; Widget builder() { return MaterialApp( home: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( autovalidateMode: AutovalidateMode.always, child: TextFormField(validator: errorText), ), ), ), ), ); } // The issue only happens on the second build so we // need to rebuild the tree twice. await tester.pumpWidget(builder()); await tester.pumpWidget(builder()); // We expect validation error text being shown. expect(find.text(errorText('')!), findsOneWidget); }, ); testWidgets( 'Form.reset() resets form fields, and auto validation will only happen on the next user interaction if autovalidateMode is onUserInteraction', (WidgetTester tester) async { final formState = GlobalKey(); String? errorText(String? value) => '$value/error'; Widget builder() { return MaterialApp( theme: ThemeData(), home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Form( key: formState, autovalidateMode: AutovalidateMode.onUserInteraction, child: Material( child: TextFormField(initialValue: 'foo', validator: errorText), ), ), ), ), ), ); } await tester.pumpWidget(builder()); // No error text is visible yet. expect(find.text(errorText('foo')!), findsNothing); await tester.enterText(find.byType(TextFormField), 'bar'); await tester.pumpAndSettle(); await tester.pump(); expect(find.text(errorText('bar')!), findsOneWidget); // Resetting the form state should remove the error text. formState.currentState!.reset(); await tester.pump(); expect(find.text(errorText('bar')!), findsNothing); }, ); testWidgets( 'Form with AutovalidateMode.onUserInteractionIfError only revalidates when user interacts after an error exists', (WidgetTester tester) async { final formState = GlobalKey(); String? errorText(String? value) => (value == null || value.isEmpty) ? 'Required' : null; Widget builder() { return MaterialApp( theme: ThemeData(), home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Form( key: formState, autovalidateMode: AutovalidateMode.onUserInteractionIfError, child: Material( child: TextFormField(initialValue: 'foo', validator: errorText), ), ), ), ), ), ); } await tester.pumpWidget(builder()); // No error text is visible yet. (Initial valid state). expect(find.text('Required'), findsNothing); // User types valid input 'bar' → autovalidate is disabled → still no error. await tester.enterText(find.byType(TextFormField), 'bar'); await tester.pump(); expect(find.text('Required'), findsNothing); // Clear the input (invalid). await tester.enterText(find.byType(TextFormField), ''); await tester.pump(); // Manually submit form to show the initial error (AutovalidateMode is now active). formState.currentState!.validate(); expect(find.text('Required'), findsNothing); await tester.pump(); // Verify error is shown. expect(find.text('Required'), findsOneWidget); // Now user interacts again with valid text ('baz') → validation auto-runs and clears the error. await tester.enterText(find.byType(TextFormField), 'baz'); await tester.pump(); expect(find.text('Required'), findsNothing); // Check the behavior of a manual validate when the text is already valid. // This should *confirm* the error is cleared, not re-introduce it. formState.currentState!.validate(); await tester.pump(); expect(find.text('Required'), findsNothing); // Resetting should clear form (already cleared, but a safety check). await tester.enterText(find.byType(TextFormField), ''); formState.currentState!.reset(); await tester.pump(); expect(find.text('Required'), findsNothing); }, ); // Regression test for https://github.com/flutter/flutter/issues/63753. testWidgets('Validate form should return correct validation if the value is composing', ( WidgetTester tester, ) async { final formKey = GlobalKey(); String? fieldValue; final Widget widget = MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, child: TextFormField( maxLength: 5, maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, onSaved: (String? value) { fieldValue = value; }, validator: (String? value) => (value != null && value.length > 5) ? 'Exceeded' : null, ), ), ), ), ), ), ); await tester.pumpWidget(widget); final EditableTextState editableText = tester.state( find.byType(EditableText), ); editableText.updateEditingValue( const TextEditingValue(text: '123456', composing: TextRange(start: 2, end: 5)), ); expect(editableText.currentTextEditingValue.composing, const TextRange(start: 2, end: 5)); formKey.currentState!.save(); expect(fieldValue, '123456'); expect(formKey.currentState!.validate(), isFalse); }); testWidgets('hasInteractedByUser returns false when the input has not changed', ( WidgetTester tester, ) async { final fieldKey = GlobalKey>(); final Widget widget = MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material(child: TextFormField(key: fieldKey)), ), ), ), ); await tester.pumpWidget(widget); expect(fieldKey.currentState!.hasInteractedByUser, isFalse); }); testWidgets('hasInteractedByUser returns true after the input has changed', ( WidgetTester tester, ) async { final fieldKey = GlobalKey>(); final Widget widget = MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material(child: TextFormField(key: fieldKey)), ), ), ), ); await tester.pumpWidget(widget); // initially, the field has not been interacted with expect(fieldKey.currentState!.hasInteractedByUser, isFalse); // after entering text, the field has been interacted with await tester.enterText(find.byType(TextFormField), 'foo'); expect(fieldKey.currentState!.hasInteractedByUser, isTrue); }); testWidgets('hasInteractedByUser returns false after the field is reset', ( WidgetTester tester, ) async { final fieldKey = GlobalKey>(); final Widget widget = MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material(child: TextFormField(key: fieldKey)), ), ), ), ); await tester.pumpWidget(widget); // initially, the field has not been interacted with expect(fieldKey.currentState!.hasInteractedByUser, isFalse); // after entering text, the field has been interacted with await tester.enterText(find.byType(TextFormField), 'foo'); expect(fieldKey.currentState!.hasInteractedByUser, isTrue); // after resetting the field, it has not been interacted with again fieldKey.currentState!.reset(); expect(fieldKey.currentState!.hasInteractedByUser, isFalse); }); testWidgets('forceErrorText forces an error state when first init', (WidgetTester tester) async { const 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 formKey = GlobalKey(); const 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 formKey = GlobalKey(); const 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 formKey = GlobalKey(); const forceErrorText = 'Forcing error.'; const validatorErrorText = 'this error should not appear as we override it with forceErrorText'; var 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 formKey = GlobalKey(); var useValidator = false; late StateSetter setState; String? validator(String? value) { if (value == null || value.isEmpty) { return 'test_error'; } return null; } Widget builder() { return StatefulBuilder( builder: (BuildContext context, StateSetter setter) { setState = setter; return MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, child: TextFormField(validator: useValidator ? validator : null), ), ), ), ), ), ); }, ); } await tester.pumpWidget(builder()); // Start with no validator. await tester.enterText(find.byType(TextFormField), ''); await tester.pump(); formKey.currentState!.validate(); await tester.pump(); expect(find.text('test_error'), findsNothing); // Now use the validator. setState(() { useValidator = true; }); await tester.pump(); formKey.currentState!.validate(); await tester.pump(); expect(find.text('test_error'), findsOneWidget); // Remove the validator again and expect the error to disappear. setState(() { useValidator = false; }); await tester.pump(); formKey.currentState!.validate(); await tester.pump(); expect(find.text('test_error'), findsNothing); }); testWidgets('AutovalidateMode.onUnfocus', (WidgetTester tester) async { final formKey = GlobalKey(); String? errorText(String? value) => '$value/error'; Widget builder() { return MaterialApp( theme: ThemeData(), home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Form( key: formKey, autovalidateMode: AutovalidateMode.onUnfocus, child: Material( child: Column( children: [ TextFormField(initialValue: 'bar', validator: errorText), TextFormField(initialValue: 'bar', validator: errorText), ], ), ), ), ), ), ), ); } await tester.pumpWidget(builder()); // No error text is visible yet. expect(find.text(errorText('foo')!), findsNothing); // Enter text in the first TextFormField. await tester.enterText(find.byType(TextFormField).first, 'foo'); await tester.pumpAndSettle(); // No error text is visible yet. expect(find.text(errorText('foo')!), findsNothing); // Tap on the second TextFormField to trigger validation. // This should trigger validation for the first TextFormField as well. await tester.tap(find.byType(TextFormField).last); await tester.pumpAndSettle(); // Verify that the error text is displayed for the first TextFormField. expect(find.text(errorText('foo')!), findsOneWidget); expect(find.text(errorText('bar')!), findsNothing); // Tap on the first TextFormField to trigger validation. await tester.tap(find.byType(TextFormField).first); await tester.pumpAndSettle(); // Verify that the both error texts are displayed. expect(find.text(errorText('foo')!), findsOneWidget); expect(find.text(errorText('bar')!), findsOneWidget); }); testWidgets('Validate conflicting AutovalidateModes', (WidgetTester tester) async { final formKey = GlobalKey(); String? errorText(String? value) => '$value/error'; Widget builder() { return MaterialApp( theme: ThemeData(), home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Form( key: formKey, autovalidateMode: AutovalidateMode.onUnfocus, child: Material( child: Column( children: [ TextFormField( autovalidateMode: AutovalidateMode.always, initialValue: 'foo', validator: errorText, ), TextFormField( autovalidateMode: AutovalidateMode.disabled, initialValue: 'bar', validator: errorText, ), ], ), ), ), ), ), ), ); } await tester.pumpWidget(builder()); // Verify that the error text is displayed for the first TextFormField. expect(find.text(errorText('foo')!), findsOneWidget); // Enter text in the TextFormField. await tester.enterText(find.byType(TextFormField).first, 'foo'); await tester.pumpAndSettle(); // Click in the second TextFormField to trigger validation. await tester.tap(find.byType(TextFormField).last); await tester.pumpAndSettle(); // No error text is visible yet for the second TextFormField. expect(find.text(errorText('bar')!), findsNothing); // Now click in the first TextFormField to trigger validation for the second TextFormField. await tester.tap(find.byType(TextFormField).first); await tester.pumpAndSettle(); // Verify that the error text is displayed for the second TextFormField. expect(find.text(errorText('bar')!), findsOneWidget); }); testWidgets('FocusNode should move to next field when TextInputAction.next is received', ( WidgetTester tester, ) async { final formKey = GlobalKey(); final focusNode1 = FocusNode(); addTearDown(focusNode1.dispose); final focusNode2 = FocusNode(); addTearDown(focusNode2.dispose); final controller1 = TextEditingController(); addTearDown(controller1.dispose); final controller2 = TextEditingController(); addTearDown(controller2.dispose); final Widget widget = MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, child: Column( children: [ TextFormField( focusNode: focusNode1, controller: controller1, textInputAction: TextInputAction.next, ), TextFormField(focusNode: focusNode2, controller: controller2), ], ), ), ), ), ), ), ); await tester.pumpWidget(widget); await tester.showKeyboard(find.byType(TextFormField).first); await tester.testTextInput.receiveAction(TextInputAction.next); await tester.pumpAndSettle(); expect(focusNode2.hasFocus, isTrue); }); testWidgets('AutovalidateMode.always should validate on second build', ( WidgetTester tester, ) async { String errorText(String? value) => '$value/error'; await tester.pumpWidget( MaterialApp( theme: ThemeData(), home: Center( child: Form( autovalidateMode: AutovalidateMode.always, child: Material( child: Column( children: [ TextFormField(initialValue: 'foo', validator: errorText), TextFormField(initialValue: 'bar', validator: errorText), ], ), ), ), ), ), ); // The validation happens in a post frame callback, so the error // doesn't show up until the second frame. expect(find.text(errorText('foo')), findsNothing); expect(find.text(errorText('bar')), findsNothing); await tester.pump(); // The error shows up on the second frame. expect(find.text(errorText('foo')), findsOneWidget); expect(find.text(errorText('bar')), findsOneWidget); }); testWidgets('AutovalidateMode.onUnfocus should validate all fields manually with FormState', ( WidgetTester tester, ) async { final formKey = GlobalKey(); const fieldKey = Key('form field'); String errorText(String? value) => '$value/error'; await tester.pumpWidget( MaterialApp( home: Center( child: Form( key: formKey, autovalidateMode: AutovalidateMode.onUnfocus, child: Material( child: Column( children: [ TextFormField(key: fieldKey, initialValue: 'foo', validator: errorText), TextFormField(initialValue: 'bar', validator: errorText), ], ), ), ), ), ), ); // Focus on the first field. await tester.tap(find.byKey(fieldKey)); await tester.pump(); // Check no error messages are displayed initially. expect(find.text('foo/error'), findsNothing); expect(find.text('bar/error'), findsNothing); // Validate all fields manually using FormState. expect(formKey.currentState!.validate(), isFalse); await tester.pump(); // Check error messages are displayed. expect(find.text('foo/error'), findsOneWidget); expect(find.text('bar/error'), findsOneWidget); }); testWidgets('FormField adds validation result to the semantics of the child', ( WidgetTester tester, ) async { final formKey = GlobalKey(); String? errorText; Future pumpWidget() async { formKey.currentState?.reset(); await tester.pumpWidget( MaterialApp( home: MediaQuery( data: const MediaQueryData(), child: Directionality( textDirection: TextDirection.ltr, child: Center( child: Material( child: Form( key: formKey, autovalidateMode: AutovalidateMode.always, child: TextFormField(validator: (String? value) => errorText), ), ), ), ), ), ), ); await tester.enterText(find.byType(TextFormField), 'Hello'); await tester.pump(); } // Test valid case await pumpWidget(); expect( tester.getSemantics(find.byType(TextFormField).last), isSemantics( isTextField: true, isFocusable: true, validationResult: SemanticsValidationResult.valid, ), ); // Test invalid case errorText = 'Error'; await pumpWidget(); expect( tester.getSemantics(find.byType(TextFormField).last), isSemantics( isTextField: true, isFocusable: true, validationResult: SemanticsValidationResult.invalid, ), ); }); } class _PlatformAnnounceScenario { _PlatformAnnounceScenario({required this.supportsAnnounce, required this.testName}); final bool supportsAnnounce; final String testName; }