From e31f708961bc05925822aed366abdc6a62e7eda4 Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Mon, 20 Apr 2020 14:45:02 -0700 Subject: [PATCH] Autofill Part 1 (#52126) --- packages/flutter/lib/services.dart | 1 + .../flutter/lib/src/cupertino/text_field.dart | 5 + .../flutter/lib/src/material/text_field.dart | 5 + .../flutter/lib/src/services/autofill.dart | 811 ++++++++++++++++++ .../flutter/lib/src/services/text_input.dart | 60 +- .../flutter/lib/src/widgets/autofill.dart | 227 +++++ .../lib/src/widgets/editable_text.dart | 103 ++- packages/flutter/lib/widgets.dart | 1 + .../flutter/test/services/autofill_test.dart | 238 +++++ .../test/services/text_input_test.dart | 3 + .../test/widgets/autofill_group_test.dart | 166 ++++ .../test/widgets/editable_text_test.dart | 15 +- 12 files changed, 1605 insertions(+), 30 deletions(-) create mode 100644 packages/flutter/lib/src/services/autofill.dart create mode 100644 packages/flutter/lib/src/widgets/autofill.dart create mode 100644 packages/flutter/test/services/autofill_test.dart create mode 100644 packages/flutter/test/widgets/autofill_group_test.dart diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart index d52c58c1862..8199457f4f3 100644 --- a/packages/flutter/lib/services.dart +++ b/packages/flutter/lib/services.dart @@ -11,6 +11,7 @@ library services; export 'src/services/asset_bundle.dart'; +export 'src/services/autofill.dart'; export 'src/services/binary_messenger.dart'; export 'src/services/binding.dart'; export 'src/services/clipboard.dart'; diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index c021b72e2b8..a00410fd8fa 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -268,6 +268,7 @@ class CupertinoTextField extends StatefulWidget { this.onTap, this.scrollController, this.scrollPhysics, + this.autofillHints, }) : assert(textAlign != null), assert(readOnly != null), assert(autofocus != null), @@ -579,6 +580,9 @@ class CupertinoTextField extends StatefulWidget { /// {@macro flutter.material.textfield.onTap} final GestureTapCallback onTap; + /// {@macro flutter.widgets.editableText.autofillHints} + final Iterable autofillHints; + @override _CupertinoTextFieldState createState() => _CupertinoTextFieldState(); @@ -950,6 +954,7 @@ class _CupertinoTextFieldState extends State with AutomaticK scrollController: widget.scrollController, scrollPhysics: widget.scrollPhysics, enableInteractiveSelection: widget.enableInteractiveSelection, + autofillHints: widget.autofillHints, ), ), ); diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index cb72f583733..62a6fe3d0c9 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -346,6 +346,7 @@ class TextField extends StatefulWidget { this.buildCounter, this.scrollController, this.scrollPhysics, + this.autofillHints, }) : assert(textAlign != null), assert(readOnly != null), assert(autofocus != null), @@ -710,6 +711,9 @@ class TextField extends StatefulWidget { /// {@macro flutter.widgets.editableText.scrollController} final ScrollController scrollController; + /// {@macro flutter.widgets.editableText.autofillHints} + final Iterable autofillHints; + @override _TextFieldState createState() => _TextFieldState(); @@ -1049,6 +1053,7 @@ class _TextFieldState extends State implements TextSelectionGestureDe dragStartBehavior: widget.dragStartBehavior, scrollController: widget.scrollController, scrollPhysics: widget.scrollPhysics, + autofillHints: widget.autofillHints, autocorrectionTextRectColor: autocorrectionTextRectColor, ), ); diff --git a/packages/flutter/lib/src/services/autofill.dart b/packages/flutter/lib/src/services/autofill.dart new file mode 100644 index 00000000000..bffa7e87117 --- /dev/null +++ b/packages/flutter/lib/src/services/autofill.dart @@ -0,0 +1,811 @@ +// 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/foundation.dart'; +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. +class AutofillHints { + AutofillHints._(); + + /// The input field expects an address locality (city/town). + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_POSTAL_ADDRESS_LOCALITY](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_POSTAL_ADDRESS_LOCALITY). + /// * iOS: [addressCity](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * Otherwise, the hint string will be used as-is. + static const String addressCity = 'addressCity'; + + /// The input field expects a city name combined with a state name. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * iOS: [addressCityAndState](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * Otherwise, the hint string will be used as-is. + static const String addressCityAndState = 'addressCityAndState'; + + /// The input field expects a region/state. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_POSTAL_ADDRESS_REGION](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_POSTAL_ADDRESS_REGION). + /// * iOS: [addressState](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * Otherwise, the hint string will be used as-is. + static const String addressState = 'addressState'; + + /// The input field expects a person's full birth date. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_BIRTH_DATE_FULL](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_BIRTH_DATE_FULL). + /// * web: ["bday"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String birthday = 'birthday'; + + /// The input field expects a person's birth day(of the month). + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_BIRTH_DATE_DAY](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_BIRTH_DATE_DAY). + /// * web: ["bday-day"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String birthdayDay = 'birthdayDay'; + + /// The input field expects a person's birth month. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_BIRTH_DATE_MONTH](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_BIRTH_DATE_MONTH). + /// * web: ["bday-month"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String birthdayMonth = 'birthdayMonth'; + + /// The input field expects a person's birth year. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_BIRTH_DATE_YEAR](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_BIRTH_DATE_YEAR). + /// * web: ["bday-year"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String birthdayYear = 'birthdayYear'; + + /// The input field expects an + /// [ISO 3166-1-alpha-2](https://www.iso.org/standard/63545.html) country code. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["country"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String countryCode = 'countryCode'; + + /// The input field expects a country name. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_POSTAL_ADDRESS_COUNTRY](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_POSTAL_ADDRESS_COUNTRY). + /// * iOS: [countryName](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["country-name"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String countryName = 'countryName'; + + /// The input field expects a credit card expiration date. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_CREDIT_CARD_NUMBER](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_CREDIT_CARD_NUMBER). + /// * web: ["cc-exp"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardExpirationDate = 'creditCardExpirationDate'; + + /// The input field expects a credit card expiration day. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DAY](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DAY). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardExpirationDay = 'creditCardExpirationDay'; + + /// The input field expects a credit card expiration month. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH). + /// * web: ["cc-exp-month"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardExpirationMonth = 'creditCardExpirationMonth'; + + /// The input field expects a credit card expiration year. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR). + /// * web: ["cc-exp-year"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardExpirationYear = 'creditCardExpirationYear'; + + /// The input field expects the holder's last/family name as given on a credit + /// card. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["cc-family-name"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardFamilyName = 'creditCardFamilyName'; + + /// The input field expects the holder's first/given name as given on a credit + /// card. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["cc-given-name"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardGivenName = 'creditCardGivenName'; + + /// The input field expects the holder's middle name as given on a credit + /// card. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["cc-additional-name"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardMiddleName = 'creditCardMiddleName'; + + /// The input field expects the holder's full name as given on a credit card. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["cc-name"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardName = 'creditCardName'; + + /// The input field expects a credit card number. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_CREDIT_CARD_NUMBER](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_CREDIT_CARD_NUMBER). + /// * iOS: [creditCardNumber](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["cc-number"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardNumber = 'creditCardNumber'; + + /// The input field expects a credit card security code. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE). + /// * web: ["cc-csc"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardSecurityCode = 'creditCardSecurityCode'; + + /// The input field expects the type of a credit card, for example "Visa". + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["cc-type"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String creditCardType = 'creditCardType'; + + /// The input field expects an email address. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_EMAIL_ADDRESS](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_EMAIL_ADDRESS). + /// * iOS: [emailAddress](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["email"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String email = 'email'; + + /// The input field expects a person's last/family name. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PERSON_NAME_FAMILY](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PERSON_NAME_FAMILY). + /// * iOS: [familyName](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["family-name"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String familyName = 'familyName'; + + /// The input field expects a street address that fully identifies a location. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_POSTAL_ADDRESS_STREET_ADDRESS](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_POSTAL_ADDRESS_STREET_ADDRESS). + /// * iOS: [fullStreetAddress](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["street-address"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String fullStreetAddress = 'fullStreetAddress'; + + /// The input field expects a gender. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_GENDER](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_GENDER). + /// * web: ["sex"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String gender = 'gender'; + + /// The input field expects a person's first/given name. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PERSON_NAME_GIVEN](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PERSON_NAME_GIVEN). + /// * iOS: [givenName](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["given-name"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String givenName = 'givenName'; + + /// The input field expects a URL representing an instant messaging protocol + /// endpoint. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["impp"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String impp = 'impp'; + + /// The input field expects a job title. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * iOS: [jobTitle](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["organization-title"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String jobTitle = 'jobTitle'; + + /// The input field expects the preferred language of the user. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["language"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String language = 'language'; + + /// The input field expects a location, such as a point of interest, an + /// address,or another way to identify a location. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * iOS: [location](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * Otherwise, the hint string will be used as-is. + static const String location = 'location'; + + /// The input field expects a person's middle initial. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PERSON_NAME_MIDDLE_INITIAL](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PERSON_NAME_MIDDLE_INITIAL). + /// * Otherwise, the hint string will be used as-is. + static const String middleInitial = 'middleInitial'; + + /// The input field expects a person's middle name. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PERSON_NAME_MIDDLE](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PERSON_NAME_MIDDLE). + /// * iOS: [middleName](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["additional-name"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String middleName = 'middleName'; + + /// The input field expects a person's full name. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PERSON_NAME](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PERSON_NAME). + /// * iOS: [name](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["name"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String name = 'name'; + + /// The input field expects a person's name prefix or title, such as "Dr.". + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PERSON_NAME_PREFIX](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PERSON_NAME_PREFIX). + /// * iOS: [namePrefix](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["honorific-prefix"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String namePrefix = 'namePrefix'; + + /// The input field expects a person's name suffix, such as "Jr.". + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PERSON_NAME_SUFFIX](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PERSON_NAME_SUFFIX). + /// * iOS: [nameSuffix](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["honorific-suffix"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String nameSuffix = 'nameSuffix'; + + /// The input field expects a newly created password for save/update. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_NEW_PASSWORD](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_NEW_PASSWORD). + /// * iOS: [newPassword](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["new-password"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String newPassword = 'newPassword'; + + /// The input field expects a newly created username for save/update. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_NEW_USERNAME](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_NEW_USERNAME). + /// * Otherwise, the hint string will be used as-is. + static const String newUsername = 'newUsername'; + + /// The input field expects a nickname. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * iOS: [nickname](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["nickname"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String nickname = 'nickname'; + + /// The input field expects a single-factor SMS login code. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_SMS_OTP](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_SMS_OTP). + /// * iOS: [oneTimeCode](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["one-time-code"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String oneTimeCode = 'oneTimeCode'; + + /// The input field expects an organization name corresponding to the person, + /// address, or contact information in the other fields associated with this + /// field. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * iOS: [organizationName](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["organization"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String organizationName = 'organizationName'; + + /// The input field expects a password. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PASSWORD](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PASSWORD). + /// * iOS: [password](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["current-password"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String password = 'password'; + + /// The input field expects a photograph, icon, or other image corresponding + /// to the company, person, address, or contact information in the other + /// fields associated with this field. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["photo"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String photo = 'photo'; + + /// The input field expects a postal address. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_POSTAL_ADDRESS](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_POSTAL_ADDRESS). + /// * Otherwise, the hint string will be used as-is. + static const String postalAddress = 'postalAddress'; + + /// The input field expects an auxiliary address details. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_ADDRESS](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_ADDRESS). + /// * Otherwise, the hint string will be used as-is. + static const String postalAddressExtended = 'postalAddressExtended'; + + /// The input field expects an extended ZIP/POSTAL code. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_POSTAL_CODE](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_POSTAL_CODE). + /// * Otherwise, the hint string will be used as-is. + static const String postalAddressExtendedPostalCode = 'postalAddressExtendedPostalCode'; + + /// The input field expects a postal code. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_POSTAL_CODE](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_POSTAL_CODE). + /// * iOS: [postalCode](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["postal-code"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String postalCode = 'postalCode'; + + /// The first administrative level in the address. This is typically the + /// province in which the address is located. In the United States, this would + /// be the state. In Switzerland, the canton. In the United Kingdom, the post + /// town. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["address-level1"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String streetAddressLevel1 = 'streetAddressLevel1'; + + /// The second administrative level, in addresses with at least two of them. + /// In countries with two administrative levels, this would typically be the + /// city, town, village, or other locality in which the address is located. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["address-level2"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String streetAddressLevel2 = 'streetAddressLevel2'; + + /// The third administrative level, in addresses with at least three + /// administrative levels. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["address-level3"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String streetAddressLevel3 = 'streetAddressLevel3'; + + /// The finest-grained administrative level, in addresses which have four + /// levels. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["address-level4"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String streetAddressLevel4 = 'streetAddressLevel4'; + + /// The input field expects the first line of a street address. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * iOS: [streetAddressLine1](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["address-line1"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String streetAddressLine1 = 'streetAddressLine1'; + + /// The input field expects the second line of a street address. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * iOS: [streetAddressLine2](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["address-line2"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String streetAddressLine2 = 'streetAddressLine2'; + + /// The input field expects the third line of a street address. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["address-line3"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String streetAddressLine3 = 'streetAddressLine3'; + + /// The input field expects a sublocality. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * iOS: [sublocality](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * Otherwise, the hint string will be used as-is. + static const String sublocality = 'sublocality'; + + /// The input field expects a telephone number. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PHONE_NUMBER](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PHONE_NUMBER). + /// * iOS: [telephoneNumber](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["tel"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String telephoneNumber = 'telephoneNumber'; + + /// The input field expects a phone number's area code, with a country + /// -internal prefix applied if applicable. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["tel-area-code"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String telephoneNumberAreaCode = 'telephoneNumberAreaCode'; + + /// The input field expects a phone number's country code. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PHONE_COUNTRY_CODE](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PHONE_COUNTRY_CODE). + /// * web: ["tel-country-code"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String telephoneNumberCountryCode = 'telephoneNumberCountryCode'; + + /// The input field expects the current device's phone number, usually for + /// Sign Up / OTP flows. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PHONE_NUMBER_DEVICE](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PHONE_NUMBER_DEVICE). + /// * Otherwise, the hint string will be used as-is. + static const String telephoneNumberDevice = 'telephoneNumberDevice'; + + /// The input field expects a phone number's internal extension code. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["tel-extension"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String telephoneNumberExtension = 'telephoneNumberExtension'; + + /// The input field expects a phone number without the country code and area + /// code components. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["tel-local"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String telephoneNumberLocal = 'telephoneNumberLocal'; + + /// The input field expects the first part of the component of the telephone + /// number that follows the area code, when that component is split into two + /// components. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["tel-local-prefix"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String telephoneNumberLocalPrefix = 'telephoneNumberLocalPrefix'; + + /// The input field expects the second part of the component of the telephone + /// number that follows the area code, when that component is split into two + /// components. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["tel-local-suffix"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String telephoneNumberLocalSuffix = 'telephoneNumberLocalSuffix'; + + /// The input field expects a phone number without country code. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_PHONE_NATIONAL](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_PHONE_NATIONAL). + /// * web: ["tel-national"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String telephoneNumberNational = 'telephoneNumberNational'; + + /// The amount that the user would like for the transaction (e.g. when + /// entering a bid or sale price). + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["transaction-amount"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String transactionAmount = 'transactionAmount'; + + /// The currency that the user would prefer the transaction to use, in [ISO + /// 4217 currency code](https://www.iso.org/iso-4217-currency-codes.html). + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * web: ["transaction-currency"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String transactionCurrency = 'transactionCurrency'; + + /// The input field expects a URL. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * iOS: [URL](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["url"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String url = 'url'; + + /// The input field expects a username or an account name. + /// + /// This hint will be translated to the below values on different platforms: + /// + /// * Android: [AUTOFILL_HINT_NEW_USERNAME](https://developer.android.com/reference/androidx/autofill/HintConstants#AUTOFILL_HINT_NEW_USERNAME). + /// * iOS: [username](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// * web: ["username"](https://www.w3.org/TR/html52/sec-forms.html#autofilling-form-controls-the-autocomplete-attribute). + /// * Otherwise, the hint string will be used as-is. + static const String username = 'username'; +} + +/// A collection of autofill related information that represents an [AutofillClient]. +/// +/// Typically used in [TextInputConfiguration.autofillConfiguration]. +@immutable +class AutofillConfiguration { + /// Creates autofill related configuration information that can be sent to the + /// platform. + const AutofillConfiguration({ + @required this.uniqueIdentifier, + @required this.autofillHints, + this.currentEditingValue, + }) : assert(uniqueIdentifier != null), + assert(autofillHints != null); + + /// A string that uniquely identifies the current [AutofillClient]. + /// + /// The identifier needs to be unique within the [AutofillScope] for the + /// [AutofillClient] to receive the correct autofill value. + /// + /// Must not be null. + final String uniqueIdentifier; + + /// A list of strings that helps the autofill service identify the type of the + /// [AutofillClient]. + /// + /// Must not be null or empty. + /// + /// {@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. + /// + /// If an autofillable input field needs to use a custom hint that translate to + /// different strings on different platforms, the easiest way to achieve that + /// is to return different hint strings based on the value of + /// [defaultTargetPlatform]. + /// + /// Each hint in the list, if not ignored, will be translated to the platform's + /// autofill hint type understood by its autofill services: + /// + /// * On iOS, only the first hint in the list is accounted for. The hint will + /// be translated to a + /// [UITextContentType](https://developer.apple.com/documentation/uikit/uitextcontenttype). + /// + /// * On Android, all hints in the list are translated to Android hint strings. + /// + /// * On web, only the first hint is accounted for and will be translated to + /// an "autocomplete" string. + /// + /// See also: + /// + /// * [AutofillHints], a list of autofill hint strings that is predefined on at + /// least one platform. + /// + /// * [UITextContentType](https://developer.apple.com/documentation/uikit/uitextcontenttype), + /// the iOS equivalent. + /// + /// * Android [autofillHints](https://developer.android.com/reference/android/view/View#setAutofillHints(java.lang.String...)), + /// the Android equivalent. + /// + /// * The [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute, + /// the web equivalent. + /// {@endtemplate} + final List autofillHints; + + /// The current [TextEditingValue] of the [AutofillClient]. + final TextEditingValue currentEditingValue; + + /// Returns a representation of this object as a JSON object. + Map toJson() { + assert(autofillHints.isNotEmpty); + return { + 'uniqueIdentifier': uniqueIdentifier, + 'hints': autofillHints, + 'editingValue': currentEditingValue.toJSON(), + }; + } +} + +/// An object that represents an autofillable input field in the autofill workflow. +/// +/// An [AutofillClient] provides autofill-related information of the input field +/// it represents to the platform, and consumes autofill inputs from the platform. +abstract class AutofillClient { + /// The unique identifier of this [AutofillClient]. + /// + /// Must not be null; + String get autofillId; + + /// The [TextInputConfiguration] that describes this [AutofillClient]. + /// + /// In order to participate in autofill, its + /// [TextInputConfiguration.autofillConfiguration] must not be null. + TextInputConfiguration get textInputConfiguration; + + /// Requests this [AutofillClient] update its [TextEditingState] to the given + /// state. + void updateEditingValue(TextEditingValue newEditingValue); +} + +/// An ordered group within which [AutofillClient]s are logically connected. +/// +/// {@template flutter.services.autofill.AutofillScope} +/// [AutofillClient]s within the same [AutofillScope] are isolated from other +/// input fields during autofill. That is, when an autofillable [TextInputClient] +/// gains focus, only the [AutofillClient]s within the same [AutofillScope] will +/// be visible to the autofill service, in the same order as they appear in +/// [autofillClients]. +/// +/// [AutofillScope] also allows [TextInput] to redirect autofill values from the +/// platform to the [AutofillClient] with the given identifier, by calling +/// [getAutofillClient]. +/// +/// An [AutofillClient] that's not tied to any [AutofillScope] will only +/// participate in autofill if the autofill is directly triggered by its own +/// [TextInputClient]. +/// {@endtemplate} +abstract class AutofillScope { + /// Gets the [AutofillScope] associated with the given [autofillId], in + /// this [AutofillScope]. + /// + /// Returns null if there's no matching [AutofillClient]. + AutofillClient getAutofillClient(String autofillId); + + /// The collection of [AutofillClient]s currently tied to this [AutofillScope]. + /// + /// Every [AutofillClient] in this list must have autofill enabled (i.e. its + /// [AutofillClient.textInputConfiguration] must have a non-null + /// [AutofillConfiguration].) + Iterable get autofillClients; + + /// Allows a [TextInputClient] to attach to this scope. This method should be + /// called in lieu of [TextInput.attach], when the [TextInputClient] wishes to + /// participate in autofill. + TextInputConnection attach(TextInputClient trigger, TextInputConfiguration configuration); +} + +@immutable +class _AutofillScopeTextInputConfiguration extends TextInputConfiguration { + _AutofillScopeTextInputConfiguration({ + @required this.allConfigurations, + @required TextInputConfiguration currentClientConfiguration, + }) : assert(allConfigurations != null), + assert(currentClientConfiguration != null), + super(inputType: currentClientConfiguration.inputType, + obscureText: currentClientConfiguration.obscureText, + autocorrect: currentClientConfiguration.autocorrect, + smartDashesType: currentClientConfiguration.smartDashesType, + smartQuotesType: currentClientConfiguration.smartQuotesType, + enableSuggestions: currentClientConfiguration.enableSuggestions, + inputAction: currentClientConfiguration.inputAction, + textCapitalization: currentClientConfiguration.textCapitalization, + keyboardAppearance: currentClientConfiguration.keyboardAppearance, + actionLabel: currentClientConfiguration.actionLabel, + autofillConfiguration: currentClientConfiguration.autofillConfiguration, + ); + + final Iterable allConfigurations; + + @override + Map toJson() { + final Map result = super.toJson(); + result['fields'] = allConfigurations + .map((TextInputConfiguration configuration) => configuration.toJson()) + .toList(growable: false); + return result; + } +} + +/// A partial implementation of [AutofillScope]. +/// +/// The mixin provides a default implementation for [AutofillScope.attach]. +mixin AutofillScopeMixin implements AutofillScope { + @override + TextInputConnection attach(TextInputClient trigger, TextInputConfiguration configuration) { + assert(trigger != null); + assert( + !autofillClients.any((AutofillClient client) => client.textInputConfiguration.autofillConfiguration == null), + 'Every client in AutofillScope.autofillClients must enable autofill', + ); + return TextInput.attach( + trigger, + _AutofillScopeTextInputConfiguration( + allConfigurations: autofillClients + .map((AutofillClient client) => client.textInputConfiguration), + currentClientConfiguration: configuration, + ), + ); + } +} diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 35bd2f400f4..2b7ff2ce563 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -16,6 +16,7 @@ import 'dart:ui' show import 'package:flutter/foundation.dart'; import 'package:vector_math/vector_math_64.dart' show Matrix4; +import 'autofill.dart'; import 'message_codec.dart'; import 'platform_channel.dart'; import 'system_channels.dart'; @@ -441,6 +442,7 @@ class TextInputConfiguration { this.inputAction = TextInputAction.done, this.keyboardAppearance = Brightness.light, this.textCapitalization = TextCapitalization.none, + this.autofillConfiguration, }) : assert(inputType != null), assert(obscureText != null), smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), @@ -464,6 +466,14 @@ class TextInputConfiguration { /// Defaults to true. final bool autocorrect; + /// The configuration to use for autofill. + /// + /// Defaults to null, in which case no autofill information will be provided + /// to the platform. This will prevent the corresponding input field from + /// participating in autofills triggered by other fields. Additionally, on + /// Android and web, setting [autofillConfiguration] to null disables autofill. + final AutofillConfiguration autofillConfiguration; + /// {@template flutter.services.textInput.smartDashesType} /// Whether to allow the platform to automatically format dashes. /// @@ -565,6 +575,7 @@ class TextInputConfiguration { 'inputAction': inputAction.toString(), 'textCapitalization': textCapitalization.toString(), 'keyboardAppearance': keyboardAppearance.toString(), + if (autofillConfiguration != null) 'autofill': autofillConfiguration.toJson(), }; } } @@ -745,6 +756,21 @@ abstract class TextInputClient { /// const constructors so that they can be used in const expressions. const TextInputClient(); + /// The current state of the [TextEditingValue] held by this client. + TextEditingValue get currentTextEditingValue; + + /// The [AutofillScope] this [TextInputClient] belongs to, if any. + /// + /// It should return null if this [TextInputClient] does not need autofill + /// support. For a [TextInputClient] that supports autofill, returning null + /// causes it to participate in autofill alone. + /// + /// See also: + /// + /// * [AutofillGroup], a widget that creates an [AutofillScope] for its + /// descendent autofillable [TextInputClient]s. + AutofillScope get currentAutofillScope; + /// Requests that this client update its editing state to the given value. void updateEditingValue(TextEditingValue value); @@ -754,9 +780,6 @@ abstract class TextInputClient { /// Updates the floating cursor position and state. void updateFloatingCursor(RawFloatingCursorPoint point); - /// The current state of the [TextEditingValue] held by this client. - TextEditingValue get currentTextEditingValue; - /// Requests that this client display a prompt rectangle for the given text range, /// to indicate the range of text that will be changed by a pending autocorrection. /// @@ -809,6 +832,17 @@ class TextInputConnection { TextInput._instance._show(); } + /// Requests the platform autofill UI to appear. + /// + /// The call has no effect unless the currently attached client supports + /// autofill, and the platform has a standalone autofill UI (for example, this + /// call has no effect on iOS since its autofill UI is part of the software + /// keyboard). + void requestAutofill() { + assert(attached); + TextInput._instance._requestAutofill(); + } + /// Requests that the text input control change its internal state to match the given state. void setEditingState(TextEditingValue value) { assert(attached); @@ -1065,6 +1099,22 @@ class TextInput { } final List args = methodCall.arguments as List; + + if (method == 'TextInputClient.updateEditingStateWithTag') { + final TextInputClient client = _currentConnection._client; + assert(client != null); + final AutofillScope scope = client.currentAutofillScope; + final Map editingValue = args[1] as Map; + for (final String tag in editingValue.keys) { + final TextEditingValue textEditingValue = TextEditingValue.fromJSON( + editingValue[tag] as Map, + ); + scope?.getAutofillClient(tag)?.updateEditingValue(textEditingValue); + } + + return; + } + final int client = args[0] as int; // The incoming message was for a different client. if (client != _currentConnection._id) @@ -1128,6 +1178,10 @@ class TextInput { _channel.invokeMethod('TextInput.show'); } + void _requestAutofill() { + _channel.invokeMethod('TextInput.requestAutofill'); + } + void _setEditableSizeAndTransform(Map args) { _channel.invokeMethod( 'TextInput.setEditableSizeAndTransform', diff --git a/packages/flutter/lib/src/widgets/autofill.dart b/packages/flutter/lib/src/widgets/autofill.dart new file mode 100644 index 00000000000..e86198eb0e0 --- /dev/null +++ b/packages/flutter/lib/src/widgets/autofill.dart @@ -0,0 +1,227 @@ +// 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/services.dart'; +import 'framework.dart'; + +export 'package:flutter/services.dart' show AutofillHints; + +/// An [AutofillScope] widget that groups [AutofillClient]s together. +/// +/// [AutofillClient]s within the same [AutofillScope] must be built together, and +/// they be will be autofilled together. +/// +/// {@macro flutter.services.autofill.AutofillScope} +/// +/// The [AutofillGroup] widget only knows about [AutofillClient]s registered to +/// it using the [AutofillGroupState.register] API. Typically, [AutofillGroup] +/// will not pick up [AutofillClient]s that are not mounted, for example, an +/// [AutofillClient] within a [Scrollable] that has never been scrolled into the +/// viewport. To workaround this problem, ensure clients in the same [AutofillGroup] +/// are built together: +/// +/// {@tool dartpad --template=stateful_widget_material} +/// +/// An example form with autofillable fields grouped into different `AutofillGroup`s. +/// +/// ```dart +/// bool isSameAddress = true; +/// final TextEditingController shippingAddress1 = TextEditingController(); +/// final TextEditingController shippingAddress2 = TextEditingController(); +/// final TextEditingController billingAddress1 = TextEditingController(); +/// final TextEditingController billingAddress2 = TextEditingController(); +/// +/// final TextEditingController creditCardNumber = TextEditingController(); +/// final TextEditingController creditCardSecurityCode = TextEditingController(); +/// +/// final TextEditingController phoneNumber = TextEditingController(); +/// +/// @override +/// Widget build(BuildContext context) { +/// return ListView( +/// children: [ +/// const Text('Shipping address'), +/// // The address fields are grouped together as some platforms are capable +/// // of autofilling all these fields in one go. +/// AutofillGroup( +/// child: Column( +/// children: [ +/// TextField( +/// controller: shippingAddress1, +/// autofillHints: [AutofillHints.streetAddressLine1], +/// ), +/// TextField( +/// controller: shippingAddress2, +/// autofillHints: [AutofillHints.streetAddressLine2], +/// ), +/// ], +/// ), +/// ), +/// const Text('Billing address'), +/// Checkbox( +/// value: isSameAddress, +/// onChanged: (bool newValue) { +/// setState(() { isSameAddress = newValue; }); +/// }, +/// ), +/// // Again the address fields are grouped together for the same reason. +/// if (!isSameAddress) AutofillGroup( +/// child: Column( +/// children: [ +/// TextField( +/// controller: billingAddress1, +/// autofillHints: [AutofillHints.streetAddressLine1], +/// ), +/// TextField( +/// controller: billingAddress2, +/// autofillHints: [AutofillHints.streetAddressLine2], +/// ), +/// ], +/// ), +/// ), +/// const Text('Credit Card Information'), +/// // The credit card number and the security code are grouped together as +/// // some platforms are capable of autofilling both fields. +/// AutofillGroup( +/// child: Column( +/// children: [ +/// TextField( +/// controller: creditCardNumber, +/// autofillHints: [AutofillHints.creditCardNumber], +/// ), +/// TextField( +/// controller: creditCardSecurityCode, +/// autofillHints: [AutofillHints.creditCardSecurityCode], +/// ), +/// ], +/// ), +/// ), +/// const Text('Contact Phone Number'), +/// // The phone number field can still be autofilled despite lacking an +/// // `AutofillScope`. +/// TextField( +/// controller: phoneNumber, +/// autofillHints: [AutofillHints.telephoneNumber], +/// ), +/// ], +/// ); +/// } +/// ``` +/// {@end-tool} +class AutofillGroup extends StatefulWidget { + /// Creates a scope for autofillable input fields. + /// + /// The [child] argument must not be null. + const AutofillGroup({ + Key key, + @required this.child, + }) : assert(child != null), + super(key: key); + + /// Returns the closest [AutofillGroupState] which encloses the given context. + /// + /// {@macro flutter.widgets.autofill.AutofillGroupState} + /// + /// See also: + /// + /// * [EditableTextState], where this method is used to retrive the closest + /// [AutofillGroupState]. + static AutofillGroupState of(BuildContext context) { + final _AutofillScope scope = context.dependOnInheritedWidgetOfExactType<_AutofillScope>(); + return scope?._scope; + } + + /// {@macro flutter.widgets.child} + final Widget child; + + @override + AutofillGroupState createState() => AutofillGroupState(); +} + +/// State associated with an [AutofillGroup] widget. +/// +/// {@template flutter.widgets.autofill.AutofillGroupState} +/// An [AutofillGroupState] can be used to register an [AutofillClient] when it +/// enters this [AutofillGroup] (for example, when an [EditableText] is mounted or +/// reparented onto the [AutofillGroup]'s subtree), and unregister an +/// [AutofillClient] when it exits (for example, when an [EditableText] gets +/// unmounted or reparented out of the [AutofillGroup]'s subtree). +/// +/// The [AutofillGroupState] class also provides an [attach] method that can be +/// called by [TextInputClient]s that support autofill, instead of +/// [TextInputClient.attach], to create a [TextInputConnection] to interact with +/// the platform's text input system. +/// {@endtemplate} +/// +/// Typically obtained using [AutofillGroup.of]. +class AutofillGroupState extends State with AutofillScopeMixin { + final Map _clients = {}; + + @override + AutofillClient getAutofillClient(String tag) => _clients[tag]; + + @override + Iterable get autofillClients { + return _clients.values + .where((AutofillClient client) => client?.textInputConfiguration?.autofillConfiguration != null); + } + + /// Adds the [AutofillClient] to this [AutofillGroup]. + /// + /// Typically, this is called by [TextInputClient]s that support autofill (for + /// example, [EditableTextState]) in [State.didChangeDependencies], when the + /// input field should be registered to a new [AutofillGroup]. + /// + /// See also: + /// + /// * [EditableTextState.didChangeDependencies], where this method is called + /// to update the current [AutofillScope] when needed. + void register(AutofillClient client) { + assert(client != null); + _clients.putIfAbsent(client.autofillId, () => client); + } + + /// Removes an [AutofillClient] with the given [autofillId] from this + /// [AutofillGroup]. + /// + /// Typically, this should be called by autofillable [TextInputClient]s in + /// [State.dispose] and [State.didChangeDependencies], when the input field + /// needs to be removed from the [AutofillGroup] it is currently registered to. + /// + /// See also: + /// + /// * [EditableTextState.didChangeDependencies], where this method is called + /// to unregister from the previous [AutofillScope]. + /// * [EditableTextState.dispose], where this method is called to unregister + /// from the current [AutofillScope] when the widget is about to be removed + /// from the tree. + void unregister(String autofillId) { + assert(autofillId != null && _clients.containsKey(autofillId)); + _clients.remove(autofillId); + } + + @override + Widget build(BuildContext context) { + return _AutofillScope( + autofillScopeState: this, + child: widget.child, + ); + } +} + +class _AutofillScope extends InheritedWidget { + const _AutofillScope({ + Key key, + Widget child, + AutofillGroupState autofillScopeState, + }) : _scope = autofillScopeState, + super(key: key, child: child); + + final AutofillGroupState _scope; + + AutofillGroup get client => _scope.widget; + + @override + bool updateShouldNotify(_AutofillScope old) => _scope != old._scope; +} diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index e1ebab433b4..a1792bb436e 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -7,12 +7,13 @@ import 'dart:math' as math; import 'dart:ui' as ui hide TextStyle; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/painting.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'autofill.dart'; import 'automatic_keep_alive.dart'; import 'basic.dart'; import 'binding.dart'; @@ -29,8 +30,8 @@ import 'scrollable.dart'; import 'text_selection.dart'; import 'ticker_provider.dart'; -export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType, SmartQuotesType, SmartDashesType; export 'package:flutter/rendering.dart' show SelectionChangedCause; +export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType, SmartQuotesType, SmartDashesType; /// Signature for the callback that reports when the user changes the selection /// (including the cursor location). @@ -409,6 +410,7 @@ class EditableText extends StatefulWidget { paste: true, selectAll: true, ), + this.autofillHints, }) : assert(controller != null), assert(focusNode != null), assert(obscureText != null), @@ -1079,6 +1081,23 @@ class EditableText extends StatefulWidget { /// {@macro flutter.rendering.editable.selectionEnabled} bool get selectionEnabled => enableInteractiveSelection; + /// {@template flutter.widgets.editableText.autofillHints} + /// A list of strings that helps the autofill service identify the type of this + /// text input. + /// + /// When set to null or empty, the text input will not send any autofill related + /// information to the platform. As a result, it will not participate in + /// autofills triggered by a different [AutofillClient], even if they're in the + /// same [AutofillScope]. Additionally, on Android and web, setting this to null + /// or empty will disable autofill for this text field. + /// + /// 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} + /// {@endtemplate} + final Iterable autofillHints; + @override EditableTextState createState() => EditableTextState(); @@ -1104,11 +1123,12 @@ class EditableText extends StatefulWidget { properties.add(DiagnosticsProperty('keyboardType', keyboardType, defaultValue: null)); properties.add(DiagnosticsProperty('scrollController', scrollController, defaultValue: null)); properties.add(DiagnosticsProperty('scrollPhysics', scrollPhysics, defaultValue: null)); + properties.add(DiagnosticsProperty>('autofillHints', autofillHints, defaultValue: null)); } } /// State for a [EditableText]. -class EditableTextState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin implements TextInputClient, TextSelectionDelegate { +class EditableTextState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin implements TextSelectionDelegate, TextInputClient, AutofillClient { Timer _cursorTimer; bool _targetCursorVisibility = false; final ValueNotifier _cursorVisibilityNotifier = ValueNotifier(true); @@ -1128,6 +1148,10 @@ class EditableTextState extends State with AutomaticKeepAliveClien bool _didAutoFocus = false; FocusAttachment _focusAttachment; + AutofillGroupState _currentAutofillScope; + @override + AutofillScope get currentAutofillScope => _currentAutofillScope; + // This value is an eyeball estimation of the time it takes for the iOS cursor // to ease in and out. static const Duration _fadeDuration = Duration(milliseconds: 250); @@ -1175,6 +1199,14 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void didChangeDependencies() { super.didChangeDependencies(); + + final AutofillGroupState newAutofillGroup = AutofillGroup.of(context); + if (currentAutofillScope != newAutofillGroup) { + _currentAutofillScope?.unregister(autofillId); + _currentAutofillScope = newAutofillGroup; + newAutofillGroup?.register(this); + } + if (!_didAutoFocus && widget.autofocus) { _didAutoFocus = true; SchedulerBinding.instance.addPostFrameCallback((_) { @@ -1210,6 +1242,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (oldWidget.readOnly && _hasFocus) _openInputConnection(); } + if (widget.style != oldWidget.style) { final TextStyle style = widget.style; // The _textInputConnection will pick up the new style when it attaches in @@ -1228,6 +1261,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void dispose() { + _currentAutofillScope?.unregister(autofillId); widget.controller.removeListener(_didChangeTextEditingValue); _cursorBlinkOpacityController.removeListener(_onCursorColorTick); _floatingCursorResetController.removeListener(_onFloatingCursorResetTick); @@ -1278,10 +1312,12 @@ class EditableTextState extends State with AutomaticKeepAliveClien _formatAndSetValue(value); - // To keep the cursor from blinking while typing, we want to restart the - // cursor timer every time a new character is typed. - _stopCursorTimer(resetCharTicks: false); - _startCursorTimer(); + if (_hasInputConnection) { + // To keep the cursor from blinking while typing, we want to restart the + // cursor timer every time a new character is typed. + _stopCursorTimer(resetCharTicks: false); + _startCursorTimer(); + } } @override @@ -1465,26 +1501,16 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (!_hasInputConnection) { final TextEditingValue localValue = _value; _lastFormattedUnmodifiedTextEditingValue = localValue; - _textInputConnection = TextInput.attach( - this, - TextInputConfiguration( - inputType: widget.keyboardType, - obscureText: widget.obscureText, - autocorrect: widget.autocorrect, - smartDashesType: widget.smartDashesType ?? (widget.obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), - smartQuotesType: widget.smartQuotesType ?? (widget.obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), - enableSuggestions: widget.enableSuggestions, - inputAction: widget.textInputAction ?? (widget.keyboardType == TextInputType.multiline - ? TextInputAction.newline - : TextInputAction.done - ), - textCapitalization: widget.textCapitalization, - keyboardAppearance: widget.keyboardAppearance, - ), - ); - _textInputConnection.show(); + _textInputConnection = (widget.autofillHints?.isNotEmpty ?? false) && currentAutofillScope != null + ? currentAutofillScope.attach(this, textInputConfiguration) + : TextInput.attach(this, textInputConfiguration); + _textInputConnection.show(); _updateSizeAndTransform(); + // Request autofill AFTER the size and the transform have been sent to the + // platform side. + _textInputConnection.requestAutofill(); + final TextStyle style = widget.style; _textInputConnection ..setStyle( @@ -1904,6 +1930,33 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } + @override + String get autofillId => 'EditableText-$hashCode'; + + @override + TextInputConfiguration get textInputConfiguration { + final bool isAutofillEnabled = widget.autofillHints?.isNotEmpty ?? false; + return TextInputConfiguration( + inputType: widget.keyboardType, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType ?? (widget.obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType: widget.smartQuotesType ?? (widget.obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + enableSuggestions: widget.enableSuggestions, + inputAction: widget.textInputAction ?? (widget.keyboardType == TextInputType.multiline + ? TextInputAction.newline + : TextInputAction.done + ), + textCapitalization: widget.textCapitalization, + keyboardAppearance: widget.keyboardAppearance, + autofillConfiguration: !isAutofillEnabled ? null : AutofillConfiguration( + uniqueIdentifier: autofillId, + autofillHints: widget.autofillHints.toList(growable: false), + currentEditingValue: currentTextEditingValue, + ), + ); + } + // null if no promptRect should be shown. TextRange _currentPromptRectRange; diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 2db238b921b..38b3b5f07b4 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -22,6 +22,7 @@ export 'src/widgets/animated_switcher.dart'; export 'src/widgets/annotated_region.dart'; export 'src/widgets/app.dart'; export 'src/widgets/async.dart'; +export 'src/widgets/autofill.dart'; export 'src/widgets/automatic_keep_alive.dart'; export 'src/widgets/banner.dart'; export 'src/widgets/basic.dart'; diff --git a/packages/flutter/test/services/autofill_test.dart b/packages/flutter/test/services/autofill_test.dart new file mode 100644 index 00000000000..e170ab05a2a --- /dev/null +++ b/packages/flutter/test/services/autofill_test.dart @@ -0,0 +1,238 @@ +// 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 'dart:convert' show utf8; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('TextInput message channels', () { + FakeTextChannel fakeTextChannel; + FakeAutofillScope scope; + + setUp(() { + fakeTextChannel = FakeTextChannel((MethodCall call) async {}); + TextInput.setChannel(fakeTextChannel); + scope ??= FakeAutofillScope(); + scope.clients.clear(); + }); + + tearDown(() { + TextInputConnection.debugResetId(); + TextInput.setChannel(SystemChannels.textInput); + }); + + test('mandatory fields are mandatory', () async { + AutofillConfiguration config; + try { + config = AutofillConfiguration( + uniqueIdentifier: null, + autofillHints: const ['test'], + ); + } catch (e) { + expect(e.toString(), contains('uniqueIdentifier != null')); + } + + expect(config, isNull); + + try { + config = AutofillConfiguration( + uniqueIdentifier: 'id', + autofillHints: null, + ); + } catch (e) { + expect(e.toString(), contains('autofillHints != null')); + } + + expect(config, isNull); + }); + + test('throws if the hint list is empty', () async { + Map json; + try { + const AutofillConfiguration config = AutofillConfiguration( + uniqueIdentifier: 'id', + autofillHints: [], + ); + + json = config.toJson(); + } catch (e) { + expect(e.toString(), contains('isNotEmpty')); + } + + expect(json, isNull); + }); + + test( + 'AutofillClients send the correct configuration to the platform' + 'and responds to updateEditingStateWithTag method correctly', + () async { + final FakeAutofillClient client1 = FakeAutofillClient(const TextEditingValue(text: 'test1')); + final FakeAutofillClient client2 = FakeAutofillClient(const TextEditingValue(text: 'test2')); + + client1.textInputConfiguration = TextInputConfiguration( + autofillConfiguration: AutofillConfiguration( + uniqueIdentifier: client1.autofillId, + autofillHints: const ['client1'], + currentEditingValue: client1.currentTextEditingValue, + ), + ); + + client2.textInputConfiguration = TextInputConfiguration( + autofillConfiguration: AutofillConfiguration( + uniqueIdentifier: client2.autofillId, + autofillHints: const ['client2'], + currentEditingValue: client2.currentTextEditingValue, + ), + ); + + scope.register(client1); + scope.register(client2); + client1.currentAutofillScope = scope; + client2.currentAutofillScope = scope; + + scope.attach(client1, client1.textInputConfiguration); + + final Map expectedConfiguration = client1.textInputConfiguration.toJson(); + expectedConfiguration['fields'] = >[ + client1.textInputConfiguration.toJson(), + client2.textInputConfiguration.toJson(), + ]; + + fakeTextChannel.validateOutgoingMethodCalls([ + MethodCall('TextInput.setClient', [1, expectedConfiguration]), + ]); + + const TextEditingValue text2 = TextEditingValue(text: 'Text 2'); + fakeTextChannel.incoming(MethodCall( + 'TextInputClient.updateEditingStateWithTag', + [0, { client2.autofillId : text2.toJSON() }], + )); + + expect(client2.currentTextEditingValue, text2); + }); + }); +} + +class FakeAutofillClient implements TextInputClient, AutofillClient { + FakeAutofillClient(this.currentTextEditingValue); + + @override + String get autofillId => hashCode.toString(); + + @override + TextInputConfiguration textInputConfiguration; + + @override + void updateEditingValue(TextEditingValue newEditingValue) { + currentTextEditingValue = newEditingValue; + latestMethodCall = 'updateEditingValue'; + } + + @override + AutofillScope currentAutofillScope; + + String latestMethodCall = ''; + + @override + TextEditingValue currentTextEditingValue; + + @override + void performAction(TextInputAction action) { + latestMethodCall = 'performAction'; + } + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + latestMethodCall = 'updateFloatingCursor'; + } + + @override + void connectionClosed() { + latestMethodCall = 'connectionClosed'; + } + + @override + void showAutocorrectionPromptRect(int start, int end) { + latestMethodCall = 'showAutocorrectionPromptRect'; + } +} + +class FakeAutofillScope with AutofillScopeMixin implements AutofillScope { + final Map clients = {}; + + @override + Iterable get autofillClients => clients.values; + + @override + AutofillClient getAutofillClient(String autofillId) => clients[autofillId]; + + void register(AutofillClient client) { + clients.putIfAbsent(client.autofillId, () => client); + } +} + +class FakeTextChannel implements MethodChannel { + FakeTextChannel(this.outgoing) : assert(outgoing != null); + + Future Function(MethodCall) outgoing; + Future Function(MethodCall) incoming; + + List outgoingCalls = []; + + @override + BinaryMessenger get binaryMessenger => throw UnimplementedError(); + + @override + MethodCodec get codec => const JSONMethodCodec(); + + @override + Future> invokeListMethod(String method, [dynamic arguments]) => throw UnimplementedError(); + + @override + Future> invokeMapMethod(String method, [dynamic arguments]) => throw UnimplementedError(); + + @override + Future invokeMethod(String method, [dynamic arguments]) async { + final MethodCall call = MethodCall(method, arguments); + outgoingCalls.add(call); + return await outgoing(call) as T; + } + + @override + String get name => 'flutter/textinput'; + + @override + void setMethodCallHandler(Future Function(MethodCall call) handler) { + incoming = handler; + } + + @override + void setMockMethodCallHandler(Future Function(MethodCall call) handler) => throw UnimplementedError(); + + void validateOutgoingMethodCalls(List calls) { + expect(outgoingCalls.length, calls.length); + bool hasError = false; + for (int i = 0; i < calls.length; i++) { + final ByteData outgoingData = codec.encodeMethodCall(outgoingCalls[i]); + final ByteData expectedData = codec.encodeMethodCall(calls[i]); + final String outgoingString = utf8.decode(outgoingData.buffer.asUint8List()); + final String expectedString = utf8.decode(expectedData.buffer.asUint8List()); + + if (outgoingString != expectedString) { + print( + 'Index $i did not match:\n' + ' actual: ${outgoingCalls[i]}\n' + ' expected: ${calls[i]}'); + hasError = true; + } + } + if (hasError) { + fail('Calls did not match.'); + } + } +} diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index 380b909fcf7..cb6020f6dbb 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -200,6 +200,9 @@ class FakeTextInputClient implements TextInputClient { @override TextEditingValue currentTextEditingValue; + @override + AutofillScope get currentAutofillScope => null; + @override void performAction(TextInputAction action) { latestMethodCall = 'performAction'; diff --git a/packages/flutter/test/widgets/autofill_group_test.dart b/packages/flutter/test/widgets/autofill_group_test.dart new file mode 100644 index 00000000000..282edc52888 --- /dev/null +++ b/packages/flutter/test/widgets/autofill_group_test.dart @@ -0,0 +1,166 @@ +// 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_test/flutter_test.dart'; + +void main() { + testWidgets('AutofillGroup has the right clients', (WidgetTester tester) async { + const Key outerKey = Key('outer'); + const Key innerKey = Key('inner'); + + const TextField client1 = TextField(autofillHints: ['1']); + const TextField client2 = TextField(autofillHints: ['2']); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AutofillGroup( + key: outerKey, + child: Column(children: [ + client1, + AutofillGroup( + key: innerKey, + child: Column(children: const [client2, TextField()]), + ), + ]), + ), + ), + ), + ); + + final AutofillGroupState innerState = tester.state(find.byKey(innerKey)); + final AutofillGroupState outerState = tester.state(find.byKey(outerKey)); + + final EditableTextState clientState1 = tester.state( + find.descendant(of: find.byWidget(client1), matching: find.byType(EditableText)), + ); + final EditableTextState clientState2 = tester.state( + find.descendant(of: find.byWidget(client2), matching: find.byType(EditableText)), + ); + + expect(outerState.autofillClients, [clientState1]); + expect(innerState.autofillClients, [clientState2]); + }); + + testWidgets('new clients can be added & removed to a scope', (WidgetTester tester) async { + const Key scopeKey = Key('scope'); + + final List hints = []; + + const TextField client1 = TextField(autofillHints: ['1']); + final TextField client2 = TextField(autofillHints: hints); + + StateSetter setState; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AutofillGroup( + key: scopeKey, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return Column(children: [client1, client2]); + }, + ), + ), + ), + ), + ); + + final AutofillGroupState scopeState = tester.state(find.byKey(scopeKey)); + + final EditableTextState clientState1 = tester.state( + find.descendant(of: find.byWidget(client1), matching: find.byType(EditableText)), + ); + final EditableTextState clientState2 = tester.state( + find.descendant(of: find.byWidget(client2), matching: find.byType(EditableText)), + ); + + expect(scopeState.autofillClients, [clientState1]); + + // Add to scope. + setState(() { hints.add('2'); }); + + await tester.pump(); + + expect(scopeState.autofillClients.length, 2); + expect(scopeState.autofillClients, contains(clientState1)); + expect(scopeState.autofillClients, contains(clientState2)); + + // Remove from scope again. + setState(() { hints.clear(); }); + + await tester.pump(); + + expect(scopeState.autofillClients, [clientState1]); + }); + + testWidgets('AutofillGroup has the right clients after reparenting', (WidgetTester tester) async { + const Key outerKey = Key('outer'); + const Key innerKey = Key('inner'); + final GlobalKey keyClient3 = GlobalKey(); + + const TextField client1 = TextField(autofillHints: ['1']); + const TextField client2 = TextField(autofillHints: ['2']); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AutofillGroup( + key: outerKey, + child: Column(children: [ + client1, + AutofillGroup( + key: innerKey, + child: Column(children: [ + client2, + TextField(key: keyClient3, autofillHints: const ['3']), + ]), + ), + ]), + ), + ), + ), + ); + + final AutofillGroupState innerState = tester.state(find.byKey(innerKey)); + final AutofillGroupState outerState = tester.state(find.byKey(outerKey)); + + final EditableTextState clientState1 = tester.state( + find.descendant(of: find.byWidget(client1), matching: find.byType(EditableText)), + ); + final EditableTextState clientState2 = tester.state( + find.descendant(of: find.byWidget(client2), matching: find.byType(EditableText)), + ); + + final EditableTextState clientState3 = tester.state( + find.descendant(of: find.byKey(keyClient3), matching: find.byType(EditableText)), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AutofillGroup( + key: outerKey, + child: Column(children: [ + client1, + TextField(key: keyClient3, autofillHints: const ['3']), + AutofillGroup( + key: innerKey, + child: Column(children: const [client2]), + ), + ]), + ), + ), + ), + ); + + expect(outerState.autofillClients.length, 2); + expect(outerState.autofillClients, contains(clientState1)); + expect(outerState.autofillClients, contains(clientState3)); + expect(innerState.autofillClients, [clientState2]); + }); +} diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index aeacc0e78a3..69457bbf799 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -4116,8 +4116,17 @@ void main() { await tester.showKeyboard(find.byType(EditableText)); // TextInput.show should be before TextInput.setEditingState - final List logOrder = ['TextInput.setClient', 'TextInput.show', 'TextInput.setEditableSizeAndTransform', 'TextInput.setStyle', 'TextInput.setEditingState', 'TextInput.setEditingState', 'TextInput.show']; - expect(tester.testTextInput.log.length, 7); + final List logOrder = [ + 'TextInput.setClient', + 'TextInput.show', + 'TextInput.setEditableSizeAndTransform', + 'TextInput.requestAutofill', + 'TextInput.setStyle', + 'TextInput.setEditingState', + 'TextInput.setEditingState', + 'TextInput.show', + ]; + expect(tester.testTextInput.log.length, 8); int index = 0; for (final MethodCall m in tester.testTextInput.log) { expect(m.method, logOrder[index]); @@ -4156,6 +4165,7 @@ void main() { 'TextInput.setClient', 'TextInput.show', 'TextInput.setEditableSizeAndTransform', + 'TextInput.requestAutofill', 'TextInput.setStyle', 'TextInput.setEditingState', 'TextInput.setEditingState', @@ -4203,6 +4213,7 @@ void main() { 'TextInput.setClient', 'TextInput.show', 'TextInput.setEditableSizeAndTransform', + 'TextInput.requestAutofill', 'TextInput.setStyle', 'TextInput.setEditingState', 'TextInput.setEditingState',