diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 3e9a547b771..e52b44cd937 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -591,6 +591,7 @@ class CupertinoTextField extends StatefulWidget { final GestureTapCallback onTap; /// {@macro flutter.widgets.editableText.autofillHints} + /// {@macro flutter.services.autofill.autofillHints} final Iterable autofillHints; @override diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 112c9cd6f4d..fff7fee6492 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -728,6 +728,7 @@ class TextField extends StatefulWidget { final ScrollController scrollController; /// {@macro flutter.widgets.editableText.autofillHints} + /// {@macro flutter.services.autofill.autofillHints} final Iterable autofillHints; /// Indicates whether [debugCheckHasMaterialLocalizations] can be called diff --git a/packages/flutter/lib/src/services/autofill.dart b/packages/flutter/lib/src/services/autofill.dart index bffa7e87117..96b058df4a6 100644 --- a/packages/flutter/lib/src/services/autofill.dart +++ b/packages/flutter/lib/src/services/autofill.dart @@ -7,9 +7,9 @@ import 'text_input.dart'; /// A collection of commonly used autofill hint strings on different platforms. /// -/// Each hint may not be supported on every platform, and may get translated to -/// different strings on different platforms. Please refer to their documentation -/// for what each value corresponds to on different platforms. +/// Each hint is pre-defined on at least one supported platform. See their +/// documentation for their availability on each platform, and the platform +/// values each autofill hint corresponds to. class AutofillHints { AutofillHints._(); @@ -350,7 +350,7 @@ class AutofillHints { /// * Otherwise, the hint string will be used as-is. static const String nickname = 'nickname'; - /// The input field expects a single-factor SMS login code. + /// The input field expects a SMS one-time code. /// /// This hint will be translated to the below values on different platforms: /// @@ -649,9 +649,9 @@ class AutofillConfiguration { /// {@template flutter.services.autofill.autofillHints} /// For the best results, hint strings need to be understood by the platform's /// autofill service. The common values of hint strings can be found in - /// [AutofillHints], as well as the platforms that understand each of them. + /// [AutofillHints], as well as their availability on different platforms. /// - /// If an autofillable input field needs to use a custom hint that translate to + /// If an autofillable input field needs to use a custom hint that translates to /// different strings on different platforms, the easiest way to achieve that /// is to return different hint strings based on the value of /// [defaultTargetPlatform]. @@ -668,6 +668,12 @@ class AutofillConfiguration { /// * On web, only the first hint is accounted for and will be translated to /// an "autocomplete" string. /// + /// Providing an autofill hint that is predefined on the platform does not + /// automatically grant the input field eligibility for autofill. Ultimately, + /// it comes down to the autofill service currently in charge to determine + /// whether an input field is suitable for autofill and what the autofill + /// candidates are. + /// /// See also: /// /// * [AutofillHints], a list of autofill hint strings that is predefined on at diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 6eb96b47bea..e7080237471 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -157,14 +157,33 @@ class TextInputType { /// Requests a keyboard with ready access to both letters and numbers. static const TextInputType visiblePassword = TextInputType._(7); + /// Optimized for a person's name. + /// + /// On iOS, requests the + /// [UIKeyboardType.namePhonePad](https://developer.apple.com/documentation/uikit/uikeyboardtype/namephonepad) + /// keyboard, a keyboard optimized for entering a person’s name or phone number. + /// Does not support auto-capitalization. + /// + /// On Android, requests a keyboard optimized for + /// [TYPE_TEXT_VARIATION_PERSON_NAME](https://developer.android.com/reference/android/text/InputType#TYPE_TEXT_VARIATION_PERSON_NAME). + static const TextInputType name = TextInputType._(8); + + /// Optimized for postal mailing addresses. + /// + /// On iOS, requests the default keyboard. + /// + /// On Android, requests a keyboard optimized for + /// [TYPE_TEXT_VARIATION_POSTAL_ADDRESS](https://developer.android.com/reference/android/text/InputType#TYPE_TEXT_VARIATION_POSTAL_ADDRESS). + static const TextInputType streetAddress = TextInputType._(9); + /// All possible enum values. static const List values = [ - text, multiline, number, phone, datetime, emailAddress, url, visiblePassword, + text, multiline, number, phone, datetime, emailAddress, url, visiblePassword, name, streetAddress, ]; // Corresponding string name for each of the [values]. static const List _names = [ - 'text', 'multiline', 'number', 'phone', 'datetime', 'emailAddress', 'url', 'visiblePassword', + 'text', 'multiline', 'number', 'phone', 'datetime', 'emailAddress', 'url', 'visiblePassword', 'name', 'address', ]; // Enum value name, this is what enum.toString() would normally return. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 96afec890bc..1c94d153aa3 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -338,9 +338,10 @@ class EditableText extends StatefulWidget { /// the number of lines. By default, it is one, meaning this is a single-line /// text field. [maxLines] must be null or greater than zero. /// - /// If [keyboardType] is not set or is null, it will default to - /// [TextInputType.text] unless [maxLines] is greater than one, when it will - /// default to [TextInputType.multiline]. + /// If [keyboardType] is not set or is null, its value will be inferred from + /// [autofillHints], if [autofillHints] is not empty. Otherwise it defaults to + /// [TextInputType.text] if [maxLines] is exactly one, and + /// [TextInputType.multiline] if [maxLines] is null or greater than one. /// /// The text cursor is not shown if [showCursor] is false or if [showCursor] /// is null (the default) and [readOnly] is true. @@ -452,7 +453,7 @@ class EditableText extends StatefulWidget { assert(dragStartBehavior != null), assert(toolbarOptions != null), _strutStyle = strutStyle, - keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + keyboardType = keyboardType ?? _inferKeyboardType(autofillHints: autofillHints, maxLines: maxLines), inputFormatters = maxLines == 1 ? [ BlacklistingTextInputFormatter.singleLineFormatter, @@ -1120,10 +1121,162 @@ class EditableText extends StatefulWidget { /// The minimum platform SDK version that supports Autofill is API level 26 /// for Android, and iOS 10.0 for iOS. /// - /// {@macro flutter.services.autofill.autofillHints} + /// ### iOS-specific Concerns: + /// + /// To provide the best user experience and ensure your app fully supports + /// password autofill on iOS, follow these steps: + /// + /// * Set up your iOS app's + /// [associated domains](https://developer.apple.com/documentation/safariservices/supporting_associated_domains_in_your_app). + /// * Some autofill hints only work with specific [keyboardType]s. For example, + /// [AutofillHints.name] requires [TextInputType.name] and [AutofillHints.email] + /// works only with [TextInputType.email]. Make sure the input field has a + /// compatible [keyboardType]. Empirically, [TextInputType.name] works well + /// with many autofill hints that are predefined on iOS. /// {@endtemplate} + /// {@macro flutter.services.autofill.autofillHints} final Iterable autofillHints; + // Infer the keyboard type of an `EditableText` if it's not specified. + static TextInputType _inferKeyboardType({ + @required Iterable autofillHints, + @required int maxLines, + }) { + if (autofillHints?.isEmpty ?? true) { + return maxLines == 1 ? TextInputType.text : TextInputType.multiline; + } + + TextInputType returnValue; + final String effectiveHint = autofillHints.first; + + // On iOS oftentimes specifying a text content type is not enough to qualify + // the input field for autofill. The keyboard type also needs to be compatible + // with the content type. To get autofill to work by default on EditableText, + // the keyboard type inference on iOS is done differently from other platforms. + // + // The entries with "autofill not working" comments are the iOS text content + // types that should work with the specified keyboard type but won't trigger + // (even within a native app). Tested on iOS 13.5. + if (!kIsWeb) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + const Map iOSKeyboardType = { + AutofillHints.addressCity : TextInputType.name, + AutofillHints.addressCityAndState : TextInputType.name, // Autofill not working. + AutofillHints.addressState : TextInputType.name, + AutofillHints.countryName : TextInputType.name, + AutofillHints.creditCardNumber : TextInputType.number, // Couldn't test. + AutofillHints.email : TextInputType.emailAddress, + AutofillHints.familyName : TextInputType.name, + AutofillHints.fullStreetAddress : TextInputType.name, + AutofillHints.givenName : TextInputType.name, + AutofillHints.jobTitle : TextInputType.name, // Autofill not working. + AutofillHints.location : TextInputType.name, // Autofill not working. + AutofillHints.middleName : TextInputType.name, // Autofill not working. + AutofillHints.name : TextInputType.name, + AutofillHints.namePrefix : TextInputType.name, // Autofill not working. + AutofillHints.nameSuffix : TextInputType.name, // Autofill not working. + AutofillHints.newPassword : TextInputType.text, + AutofillHints.newUsername : TextInputType.text, + AutofillHints.nickname : TextInputType.name, // Autofill not working. + AutofillHints.oneTimeCode : TextInputType.number, + AutofillHints.organizationName : TextInputType.text, // Autofill not working. + AutofillHints.password : TextInputType.text, + AutofillHints.postalCode : TextInputType.name, + AutofillHints.streetAddressLine1 : TextInputType.name, + AutofillHints.streetAddressLine2 : TextInputType.name, // Autofill not working. + AutofillHints.sublocality : TextInputType.name, // Autofill not working. + AutofillHints.telephoneNumber : TextInputType.name, + AutofillHints.url : TextInputType.url, // Autofill not working. + AutofillHints.username : TextInputType.text, + }; + + returnValue = iOSKeyboardType[effectiveHint]; + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + } + } + + if (returnValue != null || maxLines != 1) + return returnValue ?? TextInputType.multiline; + + const Map inferKeyboardType = { + AutofillHints.addressCity : TextInputType.streetAddress, + AutofillHints.addressCityAndState : TextInputType.streetAddress, + AutofillHints.addressState : TextInputType.streetAddress, + AutofillHints.birthday : TextInputType.datetime, + AutofillHints.birthdayDay : TextInputType.datetime, + AutofillHints.birthdayMonth : TextInputType.datetime, + AutofillHints.birthdayYear : TextInputType.datetime, + AutofillHints.countryCode : TextInputType.number, + AutofillHints.countryName : TextInputType.text, + AutofillHints.creditCardExpirationDate : TextInputType.datetime, + AutofillHints.creditCardExpirationDay : TextInputType.datetime, + AutofillHints.creditCardExpirationMonth : TextInputType.datetime, + AutofillHints.creditCardExpirationYear : TextInputType.datetime, + AutofillHints.creditCardFamilyName : TextInputType.name, + AutofillHints.creditCardGivenName : TextInputType.name, + AutofillHints.creditCardMiddleName : TextInputType.name, + AutofillHints.creditCardName : TextInputType.name, + AutofillHints.creditCardNumber : TextInputType.number, + AutofillHints.creditCardSecurityCode : TextInputType.number, + AutofillHints.creditCardType : TextInputType.text, + AutofillHints.email : TextInputType.emailAddress, + AutofillHints.familyName : TextInputType.name, + AutofillHints.fullStreetAddress : TextInputType.streetAddress, + AutofillHints.gender : TextInputType.text, + AutofillHints.givenName : TextInputType.name, + AutofillHints.impp : TextInputType.url, + AutofillHints.jobTitle : TextInputType.text, + AutofillHints.language : TextInputType.text, + AutofillHints.location : TextInputType.streetAddress, + AutofillHints.middleInitial : TextInputType.name, + AutofillHints.middleName : TextInputType.name, + AutofillHints.name : TextInputType.name, + AutofillHints.namePrefix : TextInputType.name, + AutofillHints.nameSuffix : TextInputType.name, + AutofillHints.newPassword : TextInputType.text, + AutofillHints.newUsername : TextInputType.text, + AutofillHints.nickname : TextInputType.text, + AutofillHints.oneTimeCode : TextInputType.text, + AutofillHints.organizationName : TextInputType.text, + AutofillHints.password : TextInputType.text, + AutofillHints.photo : TextInputType.text, + AutofillHints.postalAddress : TextInputType.streetAddress, + AutofillHints.postalAddressExtended : TextInputType.streetAddress, + AutofillHints.postalAddressExtendedPostalCode : TextInputType.number, + AutofillHints.postalCode : TextInputType.number, + AutofillHints.streetAddressLevel1 : TextInputType.streetAddress, + AutofillHints.streetAddressLevel2 : TextInputType.streetAddress, + AutofillHints.streetAddressLevel3 : TextInputType.streetAddress, + AutofillHints.streetAddressLevel4 : TextInputType.streetAddress, + AutofillHints.streetAddressLine1 : TextInputType.streetAddress, + AutofillHints.streetAddressLine2 : TextInputType.streetAddress, + AutofillHints.streetAddressLine3 : TextInputType.streetAddress, + AutofillHints.sublocality : TextInputType.streetAddress, + AutofillHints.telephoneNumber : TextInputType.phone, + AutofillHints.telephoneNumberAreaCode : TextInputType.phone, + AutofillHints.telephoneNumberCountryCode : TextInputType.phone, + AutofillHints.telephoneNumberDevice : TextInputType.phone, + AutofillHints.telephoneNumberExtension : TextInputType.phone, + AutofillHints.telephoneNumberLocal : TextInputType.phone, + AutofillHints.telephoneNumberLocalPrefix : TextInputType.phone, + AutofillHints.telephoneNumberLocalSuffix : TextInputType.phone, + AutofillHints.telephoneNumberNational : TextInputType.phone, + AutofillHints.transactionAmount : TextInputType.numberWithOptions(decimal: true), + AutofillHints.transactionCurrency : TextInputType.text, + AutofillHints.url : TextInputType.url, + AutofillHints.username : TextInputType.text, + }; + + return inferKeyboardType[effectiveHint] ?? TextInputType.text; + } + @override EditableTextState createState() => EditableTextState(); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index c781ce46b24..dc9f0ef2d68 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -267,6 +267,134 @@ void main() { ); }); + group('Infer keyboardType from autofillHints', () { + testWidgets('infer keyboard types from autofillHints: ios', + (WidgetTester tester) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + autofillHints: const [AutofillHints.streetAddressLine1], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = 'test'; + await tester.idle(); + expect(tester.testTextInput.editingState['text'], equals('test')); + expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.name')); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('infer keyboard types from autofillHints: non-ios', + (WidgetTester tester) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + autofillHints: const [AutofillHints.streetAddressLine1], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = 'test'; + await tester.idle(); + expect(tester.testTextInput.editingState['text'], equals('test')); + expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.address')); + }); + + testWidgets('inferred keyboard types can be overridden: ios', + (WidgetTester tester) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + keyboardType: TextInputType.text, + autofillHints: const [AutofillHints.streetAddressLine1], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = 'test'; + await tester.idle(); + expect(tester.testTextInput.editingState['text'], equals('test')); + expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.text')); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('inferred keyboard types can be overridden: non-ios', + (WidgetTester tester) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + controller: controller, + backgroundCursorColor: Colors.grey, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + keyboardType: TextInputType.text, + autofillHints: const [AutofillHints.streetAddressLine1], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = 'test'; + await tester.idle(); + expect(tester.testTextInput.editingState['text'], equals('test')); + expect(tester.testTextInput.setClientArgs['inputType']['name'], equals('TextInputType.text')); + }); + }); + testWidgets('multiline keyboard is requested when set explicitly', (WidgetTester tester) async { await tester.pumpWidget( MediaQuery(