From 23d0f316e148976fe9a3d79b760afaa3ee076c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Rodem?= Date: Mon, 3 Nov 2025 19:41:00 +0000 Subject: [PATCH 1/5] feat: Add AuthenticatorTextFieldController and integrate with form fields - Introduced AuthenticatorTextFieldController for programmatic control over text fields. - Updated mixins (AuthenticatorTextField, AuthenticatorUsernameField) to utilize the new controller. - Enhanced various form fields (ConfirmSignIn, ConfirmSignUp, EmailSetup, PhoneNumber, ResetPassword, SignIn, SignUp, VerifyUser) to accept and manage TextEditingController. - Added tests to ensure synchronization between form fields and their respective controllers. --- .../lib/amplify_authenticator.dart | 1 + .../authenticator_text_field_controller.dart | 16 +++ .../src/mixins/authenticator_text_field.dart | 87 +++++++++++++- .../mixins/authenticator_username_field.dart | 110 +++++++++++++++++- .../confirm_sign_in_form_field.dart | 39 +++++++ .../confirm_sign_up_form_field.dart | 16 +++ .../form_fields/email_setup_form_field.dart | 8 ++ .../form_fields/phone_number_field.dart | 8 ++ .../reset_password_form_field.dart | 12 ++ .../form_fields/sign_in_form_field.dart | 24 +++- .../form_fields/sign_up_form_field.dart | 51 +++++++- .../form_fields/verify_user_form_field.dart | 8 ++ .../test/form_field_controller_test.dart | 83 +++++++++++++ 13 files changed, 457 insertions(+), 6 deletions(-) create mode 100644 packages/authenticator/amplify_authenticator/lib/src/controllers/authenticator_text_field_controller.dart create mode 100644 packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart diff --git a/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart b/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart index 3f85c897ef..55afb09d1d 100644 --- a/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart +++ b/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart @@ -94,6 +94,7 @@ export 'src/widgets/form_field.dart' TotpSetupFormField, VerifyUserFormField; export 'src/widgets/social/social_button.dart'; +export 'src/controllers/authenticator_text_field_controller.dart'; /// {@template amplify_authenticator.authenticator} /// # Amplify Authenticator diff --git a/packages/authenticator/amplify_authenticator/lib/src/controllers/authenticator_text_field_controller.dart b/packages/authenticator/amplify_authenticator/lib/src/controllers/authenticator_text_field_controller.dart new file mode 100644 index 0000000000..b6f71096f0 --- /dev/null +++ b/packages/authenticator/amplify_authenticator/lib/src/controllers/authenticator_text_field_controller.dart @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:flutter/widgets.dart'; + +/// Controller for text driven Authenticator form fields. +/// +/// Wraps Flutter's [TextEditingController] so developers can opt in to +/// programmatic control over prebuilt Authenticator fields while keeping +/// identical semantics to a regular controller. +class AuthenticatorTextFieldController extends TextEditingController { + AuthenticatorTextFieldController({super.text}); + + AuthenticatorTextFieldController.fromValue(TextEditingValue value) + : super.fromValue(value); +} diff --git a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart index b3c3b9861d..2501acbd5c 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart @@ -11,23 +11,108 @@ mixin AuthenticatorTextField< T extends AuthenticatorFormField > on AuthenticatorFormFieldState { + TextEditingController? _effectiveController; + bool _isApplyingControllerText = false; + String? _lastSyncedControllerValue; + + @protected + TextEditingController? get textController => null; + + void _maybeUpdateEffectiveController() { + final controller = textController; + if (identical(controller, _effectiveController)) { + return; + } + _effectiveController?.removeListener(_handleControllerChanged); + _effectiveController = controller; + _lastSyncedControllerValue = null; + if (_effectiveController != null) { + _effectiveController!.addListener(_handleControllerChanged); + } + } + + void _handleControllerChanged() { + final controller = _effectiveController; + if (controller == null || _isApplyingControllerText) { + return; + } + final text = controller.text; + if (text == _lastSyncedControllerValue) { + return; + } + _lastSyncedControllerValue = text; + onChanged(text); + } + + void _syncControllerText({bool force = false}) { + final controller = _effectiveController; + if (controller == null) { + return; + } + final target = initialValue ?? ''; + if (!force && controller.text == target) { + _lastSyncedControllerValue = controller.text; + return; + } + _isApplyingControllerText = true; + controller.value = controller.value.copyWith( + text: target, + selection: TextSelection.collapsed(offset: target.length), + composing: TextRange.empty, + ); + _lastSyncedControllerValue = target; + _isApplyingControllerText = false; + } + + @override + void initState() { + super.initState(); + _maybeUpdateEffectiveController(); + _syncControllerText(force: true); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _maybeUpdateEffectiveController(); + _syncControllerText(); + } + + @override + void didUpdateWidget(covariant T oldWidget) { + super.didUpdateWidget(oldWidget); + _maybeUpdateEffectiveController(); + _syncControllerText(force: true); + } + + @override + void dispose() { + _effectiveController?.removeListener(_handleControllerChanged); + _effectiveController = null; + super.dispose(); + } + @override Widget buildFormField(BuildContext context) { final inputResolver = stringResolver.inputs; final hintText = widget.hintText == null ? widget.hintTextKey?.resolve(context, inputResolver) : widget.hintText!; + _maybeUpdateEffectiveController(); return ValueListenableBuilder( valueListenable: AuthenticatorFormState.of( context, ).obscureTextToggleValue, builder: (BuildContext context, bool toggleObscureText, Widget? _) { final obscureText = this.obscureText && toggleObscureText; + _syncControllerText(); return TextFormField( style: enabled ? null : TextStyle(color: Theme.of(context).disabledColor), - initialValue: initialValue, + controller: _effectiveController, + initialValue: + _effectiveController == null ? initialValue : null, enabled: enabled, validator: widget.validatorOverride ?? validator, onChanged: onChanged, diff --git a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart index 0db8988714..03edde678f 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart @@ -15,6 +15,108 @@ mixin AuthenticatorUsernameField< T extends AuthenticatorFormField > on AuthenticatorFormFieldState { + TextEditingController? _controller; + UsernameType? _controllerUsernameType; + bool _applyingControllerText = false; + String? _lastSyncedText; + + @protected + TextEditingController? get textController => null; + + void _updateController() { + final controller = textController; + final type = selectedUsernameType; + final shouldListen = type != UsernameType.phoneNumber; + + if (identical(controller, _controller) && type == _controllerUsernameType) { + if (!shouldListen && _controller != null) { + _controller!.removeListener(_handleControllerChanged); + } + return; + } + + if (_controller != null) { + _controller!.removeListener(_handleControllerChanged); + } + + _controller = controller; + _controllerUsernameType = type; + _lastSyncedText = null; + + if (_controller != null && shouldListen) { + _controller!.addListener(_handleControllerChanged); + } + } + + void _handleControllerChanged() { + final controller = _controller; + if (controller == null || _applyingControllerText) { + return; + } + + final text = controller.text; + if (text == _lastSyncedText) { + return; + } + + _lastSyncedText = text; + _applyingControllerText = true; + try { + onChanged(UsernameInput(type: selectedUsernameType, username: text)); + } finally { + _applyingControllerText = false; + } + } + + void _syncControllerText({bool force = false}) { + if (_controller == null || selectedUsernameType == UsernameType.phoneNumber) { + return; + } + + final target = initialValue?.username ?? ''; + if (!force && _controller!.text == target) { + _lastSyncedText = _controller!.text; + return; + } + + _applyingControllerText = true; + _controller!.value = _controller!.value.copyWith( + text: target, + selection: TextSelection.collapsed(offset: target.length), + composing: TextRange.empty, + ); + _lastSyncedText = target; + _applyingControllerText = false; + } + + @override + void initState() { + super.initState(); + _updateController(); + _syncControllerText(force: true); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateController(); + _syncControllerText(); + } + + @override + void didUpdateWidget(covariant T oldWidget) { + super.didUpdateWidget(oldWidget); + _updateController(); + _syncControllerText(force: true); + } + + @override + void dispose() { + _controller?.removeListener(_handleControllerChanged); + _controller = null; + super.dispose(); + } + @override UsernameInput? get initialValue { return UsernameInput(type: selectedUsernameType, username: state.username); @@ -220,11 +322,17 @@ mixin AuthenticatorUsernameField< errorMaxLines: errorMaxLines, initialValue: state.username, autofillHints: autofillHints, + controller: textController, ); } + + _updateController(); + _syncControllerText(); + return TextFormField( style: enabled ? null : TextStyle(color: Theme.of(context).disabledColor), - initialValue: initialValue?.username, + controller: _controller, + initialValue: _controller == null ? initialValue?.username : null, enabled: enabled, validator: validator, onChanged: onChanged, diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart index 8b24a36ace..72b2a48f82 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart @@ -31,6 +31,7 @@ abstract class ConfirmSignInFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyNewPasswordConfirmSignInFormField, titleKey: InputResolverKey.passwordTitle, @@ -38,6 +39,7 @@ abstract class ConfirmSignInFormField field: ConfirmSignInField.newPassword, validator: validator, autofillHints: autofillHints, + controller: controller, ); /// Creates a new password component. @@ -45,6 +47,7 @@ abstract class ConfirmSignInFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyConfirmNewPasswordConfirmSignInFormField, titleKey: InputResolverKey.passwordConfirmationTitle, @@ -52,6 +55,7 @@ abstract class ConfirmSignInFormField field: ConfirmSignInField.confirmNewPassword, validator: validator, autofillHints: autofillHints, + controller: controller, ); /// Creates an auth answer component. @@ -61,6 +65,7 @@ abstract class ConfirmSignInFormField String? hintText, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyCustomChallengeConfirmSignInFormField, title: title, @@ -72,6 +77,7 @@ abstract class ConfirmSignInFormField field: ConfirmSignInField.customChallenge, validator: validator, autofillHints: autofillHints, + controller: controller, ); /// Creates an mfa preference selection component. @@ -93,6 +99,7 @@ abstract class ConfirmSignInFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyCodeConfirmSignInFormField, titleKey: InputResolverKey.verificationCodeTitle, @@ -100,6 +107,7 @@ abstract class ConfirmSignInFormField field: ConfirmSignInField.code, validator: validator, autofillHints: autofillHints, + controller: controller, ); /// Creates an address component. @@ -108,6 +116,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyAddressConfirmSignInFormField, titleKey: InputResolverKey.addressTitle, @@ -116,6 +125,7 @@ abstract class ConfirmSignInFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a birthdate component. @@ -140,6 +150,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyEmailConfirmSignInFormField, titleKey: InputResolverKey.emailTitle, @@ -148,6 +159,7 @@ abstract class ConfirmSignInFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a familyName component. @@ -156,6 +168,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyFamilyNameConfirmSignInFormField, titleKey: InputResolverKey.familyNameTitle, @@ -164,6 +177,7 @@ abstract class ConfirmSignInFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a gender component. @@ -172,6 +186,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyGenderConfirmSignInFormField, titleKey: InputResolverKey.genderTitle, @@ -180,6 +195,7 @@ abstract class ConfirmSignInFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a givenName component. @@ -188,6 +204,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyGivenNameConfirmSignInFormField, titleKey: InputResolverKey.givenNameTitle, @@ -196,6 +213,7 @@ abstract class ConfirmSignInFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a middleName component. @@ -204,6 +222,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyMiddleNameConfirmSignInFormField, titleKey: InputResolverKey.middleNameTitle, @@ -212,6 +231,7 @@ abstract class ConfirmSignInFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a name component. @@ -220,6 +240,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyNameConfirmSignInFormField, titleKey: InputResolverKey.nameTitle, @@ -228,6 +249,7 @@ abstract class ConfirmSignInFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a nickname component. @@ -236,6 +258,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyNicknameConfirmSignInFormField, titleKey: InputResolverKey.nicknameTitle, @@ -244,6 +267,7 @@ abstract class ConfirmSignInFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a phoneNumber component. @@ -252,6 +276,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInPhoneField( key: key ?? keyPhoneNumberConfirmSignInFormField, titleKey: InputResolverKey.phoneNumberTitle, @@ -260,6 +285,7 @@ abstract class ConfirmSignInFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a preferredUsername component. @@ -268,6 +294,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key ?? keyPreferredUsernameConfirmSignInFormField, titleKey: InputResolverKey.preferredUsernameTitle, @@ -276,6 +303,7 @@ abstract class ConfirmSignInFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a custom attribute component. @@ -287,6 +315,7 @@ abstract class ConfirmSignInFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignInTextField( key: key, title: title, @@ -296,6 +325,7 @@ abstract class ConfirmSignInFormField attributeKey: attributeKey, required: required, autofillHints: autofillHints, + controller: controller, ); /// Custom Cognito attribute key. @@ -491,8 +521,11 @@ class _ConfirmSignInPhoneField extends ConfirmSignInFormField { super.validator, super.required, super.autofillHints, + this.controller, }) : super._(customAttributeKey: attributeKey); + final TextEditingController? controller; + @override _ConfirmSignInPhoneFieldState createState() => _ConfirmSignInPhoneFieldState(); @@ -546,8 +579,11 @@ class _ConfirmSignInTextField extends ConfirmSignInFormField { super.validator, super.required, super.autofillHints, + this.controller, }) : super._(customAttributeKey: attributeKey); + final TextEditingController? controller; + @override _ConfirmSignInTextFieldState createState() => _ConfirmSignInTextFieldState(); } @@ -558,6 +594,9 @@ class _ConfirmSignInTextFieldState extends _ConfirmSignInFormFieldState ConfirmSignInField, ConfirmSignInFormField > { + @override + TextEditingController? get textController => widget.controller; + @override String? get initialValue { switch (widget.field) { diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart index 88086d6fcc..df932e0660 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart @@ -28,6 +28,7 @@ abstract class ConfirmSignUpFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignUpUsernameField( key: key ?? keyUsernameConfirmSignUpFormField, titleKey: InputResolverKey.usernameTitle, @@ -35,6 +36,7 @@ abstract class ConfirmSignUpFormField field: ConfirmSignUpField.username, validator: validator, autofillHints: autofillHints, + controller: controller, ); /// Creates a verificationCode component. @@ -42,6 +44,7 @@ abstract class ConfirmSignUpFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _ConfirmSignUpTextField( key: key ?? keyCodeConfirmSignUpFormField, titleKey: InputResolverKey.verificationCodeTitle, @@ -49,6 +52,7 @@ abstract class ConfirmSignUpFormField field: ConfirmSignUpField.code, validator: validator, autofillHints: autofillHints, + controller: controller, ); @override @@ -131,14 +135,20 @@ class _ConfirmSignUpTextField extends ConfirmSignUpFormField { super.hintTextKey, super.validator, super.autofillHints, + this.controller, }) : super._(); + final TextEditingController? controller; + @override _ConfirmSignUpTextFieldState createState() => _ConfirmSignUpTextFieldState(); } class _ConfirmSignUpTextFieldState extends _ConfirmSignUpFormFieldState with AuthenticatorTextField { + @override + TextEditingController? get textController => widget.controller; + @override String? get initialValue { switch (widget.field) { @@ -199,8 +209,11 @@ class _ConfirmSignUpUsernameField super.hintTextKey, super.validator, super.autofillHints, + this.controller, }) : super._(); + final TextEditingController? controller; + @override _ConfirmSignUpUsernameFieldState createState() => _ConfirmSignUpUsernameFieldState(); @@ -213,6 +226,9 @@ class _ConfirmSignUpUsernameFieldState ConfirmSignUpField, ConfirmSignUpFormField > { + @override + TextEditingController? get textController => widget.controller; + @override Widget? get surlabel => null; } diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/email_setup_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/email_setup_form_field.dart index e96145063b..929920ef71 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/email_setup_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/email_setup_form_field.dart @@ -21,13 +21,17 @@ class EmailSetupFormField super.hintText, super.validator, super.autofillHints, + this.controller, }) : super._(); + final TextEditingController? controller; + /// Creates an email FormField for the email setup step. const EmailSetupFormField.email({ Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) : this._( key: key ?? keyEmailSetupFormField, field: EmailSetupField.email, @@ -35,6 +39,7 @@ class EmailSetupFormField hintTextKey: InputResolverKey.emailHint, validator: validator, autofillHints: autofillHints, + controller: controller, ); @override @@ -53,6 +58,9 @@ class _EmailSetupFormFieldState EmailSetupFormField > with AuthenticatorTextField { + @override + TextEditingController? get textController => widget.controller; + @override TextInputType get keyboardType { return TextInputType.emailAddress; diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/phone_number_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/phone_number_field.dart index 44337d566e..bf4a1e898c 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/phone_number_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/phone_number_field.dart @@ -15,6 +15,7 @@ class AuthenticatorPhoneField this.initialValue, this.errorMaxLines, super.autofillHints, + this.controller, }) : super._( titleKey: InputResolverKey.phoneNumberTitle, hintTextKey: InputResolverKey.phoneNumberHint, @@ -25,6 +26,7 @@ class AuthenticatorPhoneField final ValueChanged? onChanged; final FormFieldValidator? validator; final int? errorMaxLines; + final TextEditingController? controller; @override AuthenticatorComponentState> @@ -45,6 +47,9 @@ class AuthenticatorPhoneField 'validator', validator, ), + ) + ..add( + DiagnosticsProperty('controller', controller), ); } } @@ -71,6 +76,9 @@ class _AuthenticatorPhoneFieldState return initialValue; } + @override + TextEditingController? get textController => widget.controller; + @override bool get enabled => widget.enabled ?? super.enabled; diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/reset_password_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/reset_password_form_field.dart index 64d117c5b9..2a9433bb47 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/reset_password_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/reset_password_form_field.dart @@ -19,23 +19,29 @@ class ResetPasswordFormField super.hintTextKey, super.validator, super.autofillHints, + this.controller, }) : super._(); + final TextEditingController? controller; + const ResetPasswordFormField.verificationCode({ Key? key, Iterable? autofillHints, + TextEditingController? controller, }) : this._( key: key ?? keyVerificationCodeResetPasswordFormField, field: ResetPasswordField.verificationCode, titleKey: InputResolverKey.verificationCodeTitle, hintTextKey: InputResolverKey.verificationCodeHint, autofillHints: autofillHints, + controller: controller, ); const ResetPasswordFormField.newPassword({ Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) : this._( key: key ?? keyPasswordResetPasswordFormField, field: ResetPasswordField.newPassword, @@ -43,17 +49,20 @@ class ResetPasswordFormField hintTextKey: InputResolverKey.newPasswordHint, validator: validator, autofillHints: autofillHints, + controller: controller, ); const ResetPasswordFormField.passwordConfirmation({ Key? key, Iterable? autofillHints, + TextEditingController? controller, }) : this._( key: key ?? keyPasswordConfirmationResetPasswordFormField, field: ResetPasswordField.passwordConfirmation, titleKey: InputResolverKey.passwordConfirmationTitle, hintTextKey: InputResolverKey.passwordConfirmationHint, autofillHints: autofillHints, + controller: controller, ); @override @@ -76,6 +85,9 @@ class _ResetPasswordFormFieldState ResetPasswordFormField > with AuthenticatorTextField { + @override + TextEditingController? get textController => widget.controller; + @override bool get obscureText { switch (widget.field) { diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart index 8df1979c61..c49d5ba3ce 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart @@ -33,10 +33,12 @@ abstract class SignInFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _SignInUsernameField( key: key ?? keyUsernameSignInFormField, validator: validator, autofillHints: autofillHints, + controller: controller, ); /// Creates a password FormField for the sign in step. @@ -44,6 +46,7 @@ abstract class SignInFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _SignInTextField( key: key ?? keyPasswordSignInFormField, titleKey: InputResolverKey.passwordTitle, @@ -51,6 +54,7 @@ abstract class SignInFormField field: SignInField.password, validator: validator, autofillHints: autofillHints, + controller: controller, ); @override @@ -130,14 +134,20 @@ class _SignInTextField extends SignInFormField { super.hintTextKey, super.validator, super.autofillHints, + this.controller, }) : super._(); + final TextEditingController? controller; + @override _SignInTextFieldState createState() => _SignInTextFieldState(); } class _SignInTextFieldState extends _SignInFormFieldState with AuthenticatorTextField { + @override + TextEditingController? get textController => widget.controller; + @override String? get initialValue { switch (widget.field) { @@ -182,7 +192,12 @@ class _SignInTextFieldState extends _SignInFormFieldState } class _SignInUsernameField extends SignInFormField { - const _SignInUsernameField({Key? key, super.validator, super.autofillHints}) + const _SignInUsernameField({ + Key? key, + super.validator, + super.autofillHints, + this.controller, + }) : super._( key: key ?? keyUsernameSignInFormField, titleKey: InputResolverKey.usernameTitle, @@ -190,9 +205,14 @@ class _SignInUsernameField extends SignInFormField { field: SignInField.username, ); + final TextEditingController? controller; + @override _SignInUsernameFieldState createState() => _SignInUsernameFieldState(); } class _SignInUsernameFieldState extends _SignInFormFieldState - with AuthenticatorUsernameField {} + with AuthenticatorUsernameField { + @override + TextEditingController? get textController => widget.controller; +} diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart index f819ce5f45..bbc80d758d 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart @@ -40,10 +40,12 @@ abstract class SignUpFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpUsernameField( key: key ?? keyUsernameSignUpFormField, validator: validator, autofillHints: autofillHints, + controller: controller, ); /// Creates a password component. @@ -51,6 +53,7 @@ abstract class SignUpFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyPasswordSignUpFormField, titleKey: InputResolverKey.passwordTitle, @@ -58,6 +61,7 @@ abstract class SignUpFormField field: SignUpField.password, validator: validator, autofillHints: autofillHints, + controller: controller, ); /// Creates a passwordConfirmation component. @@ -65,6 +69,7 @@ abstract class SignUpFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyPasswordConfirmationSignUpFormField, titleKey: InputResolverKey.passwordConfirmationTitle, @@ -72,6 +77,7 @@ abstract class SignUpFormField field: SignUpField.passwordConfirmation, validator: validator, autofillHints: autofillHints, + controller: controller, ); /// Creates an address component. @@ -80,6 +86,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyAddressSignUpFormField, titleKey: InputResolverKey.addressTitle, @@ -88,6 +95,7 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a birthdate component. @@ -112,6 +120,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyEmailSignUpFormField, titleKey: InputResolverKey.emailTitle, @@ -120,6 +129,7 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a familyName component. @@ -128,6 +138,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyFamilyNameSignUpFormField, titleKey: InputResolverKey.familyNameTitle, @@ -136,6 +147,7 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a gender component. @@ -144,6 +156,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyGenderSignUpFormField, titleKey: InputResolverKey.genderTitle, @@ -152,6 +165,7 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a givenName component. @@ -160,6 +174,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyGivenNameSignUpFormField, titleKey: InputResolverKey.givenNameTitle, @@ -168,6 +183,7 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a middleName component. @@ -176,6 +192,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyMiddleNameSignUpFormField, titleKey: InputResolverKey.middleNameTitle, @@ -184,6 +201,7 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a name component. @@ -192,6 +210,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyNameSignUpFormField, titleKey: InputResolverKey.nameTitle, @@ -200,6 +219,7 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a nickname component. @@ -208,6 +228,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyNicknameSignUpFormField, titleKey: InputResolverKey.nicknameTitle, @@ -216,6 +237,7 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a phoneNumber component. @@ -224,6 +246,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpPhoneField( key: key ?? keyPhoneNumberSignUpFormField, titleKey: InputResolverKey.phoneNumberTitle, @@ -232,6 +255,7 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a preferredUsername component. @@ -240,6 +264,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key ?? keyPreferredUsernameSignUpFormField, titleKey: InputResolverKey.preferredUsernameTitle, @@ -248,6 +273,7 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, ); /// Creates a custom attribute component. @@ -259,6 +285,7 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + TextEditingController? controller, }) => _SignUpTextField( key: key, title: title, @@ -268,6 +295,7 @@ abstract class SignUpFormField attributeKey: attributeKey, required: required, autofillHints: autofillHints, + controller: controller, ); /// Custom Cognito attribute key. @@ -454,14 +482,20 @@ class _SignUpTextField extends SignUpFormField { super.validator, super.required, super.autofillHints, + this.controller, }) : super._(customAttributeKey: attributeKey); + final TextEditingController? controller; + @override _SignUpTextFieldState createState() => _SignUpTextFieldState(); } class _SignUpTextFieldState extends _SignUpFormFieldState with AuthenticatorTextField { + @override + TextEditingController? get textController => widget.controller; + @override String? get initialValue { switch (widget.field) { @@ -629,19 +663,29 @@ class _SignUpTextFieldState extends _SignUpFormFieldState } class _SignUpUsernameField extends SignUpFormField { - const _SignUpUsernameField({super.key, super.validator, super.autofillHints}) + const _SignUpUsernameField({ + super.key, + super.validator, + super.autofillHints, + this.controller, + }) : super._( field: SignUpField.username, titleKey: InputResolverKey.usernameTitle, hintTextKey: InputResolverKey.usernameHint, ); + final TextEditingController? controller; + @override _SignUpUsernameFieldState createState() => _SignUpUsernameFieldState(); } class _SignUpUsernameFieldState extends _SignUpFormFieldState - with AuthenticatorUsernameField {} + with AuthenticatorUsernameField { + @override + TextEditingController? get textController => widget.controller; +} class _SignUpPhoneField extends SignUpFormField { const _SignUpPhoneField({ @@ -653,8 +697,11 @@ class _SignUpPhoneField extends SignUpFormField { super.validator, super.required, super.autofillHints, + this.controller, }) : super._(customAttributeKey: attributeKey); + final TextEditingController? controller; + @override _SignUpPhoneFieldState createState() => _SignUpPhoneFieldState(); } diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart index b09ee26600..2b7c18b6d6 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart @@ -35,6 +35,7 @@ abstract class VerifyUserFormField Key? key, FormFieldValidator? validator, Iterable? autofillHints, + TextEditingController? controller, }) => _VerifyUserTextField( key: keyVerifyUserConfirmationCode, titleKey: InputResolverKey.verificationCodeTitle, @@ -42,6 +43,7 @@ abstract class VerifyUserFormField field: VerifyAttributeField.confirmVerify, validator: validator, autofillHints: autofillHints, + controller: controller, ); @override @@ -69,14 +71,20 @@ class _VerifyUserTextField extends VerifyUserFormField { super.hintTextKey, super.validator, super.autofillHints, + this.controller, }) : super._(); + final TextEditingController? controller; + @override _VerifyUserTextFieldState createState() => _VerifyUserTextFieldState(); } class _VerifyUserTextFieldState extends _VerifyUserFormFieldState with AuthenticatorTextField { + @override + TextEditingController? get textController => widget.controller; + @override bool get obscureText { return false; diff --git a/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart b/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart new file mode 100644 index 0000000000..1887387045 --- /dev/null +++ b/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; +import 'package:amplify_authenticator/amplify_authenticator.dart'; +import 'package:amplify_authenticator/src/state/inherited_authenticator_state.dart'; +import 'package:amplify_authenticator_test/amplify_authenticator_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Authenticator text field controllers', () { + testWidgets('SignUpFormField.username syncs with controller', (tester) async { + final usernameController = AuthenticatorTextFieldController(text: 'initial'); + addTearDown(usernameController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(controller: usernameController), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + + // Initial text populates state on first build. + expect(authState.username, equals('initial')); + + // Programmatic updates flow from controller -> state. + usernameController.text = 'updated'; + await tester.pump(); + expect(authState.username, equals('updated')); + + // State updates propagate back to the controller. + authState.username = 'state-origin'; + await tester.pump(); + expect(usernameController.text, equals('state-origin')); + }); + + testWidgets('SignUpFormField.address syncs controller and attributes', (tester) async { + final addressController = AuthenticatorTextFieldController(); + addTearDown(addressController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + SignUpFormField.address(controller: addressController), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + + addressController.text = '123 Main St'; + await tester.pump(); + expect( + authState.getAttribute(CognitoUserAttributeKey.address), + equals('123 Main St'), + ); + + authState.address = '987 Baker Ave'; + await tester.pump(); + expect(addressController.text, equals('987 Baker Ave')); + }); + }); +} From 2a801d59bca88f710cc1f92216283697e2126d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Rodem?= Date: Mon, 3 Nov 2025 20:25:26 -0300 Subject: [PATCH 2/5] feat(authenticator): expose optional TextEditingController and improve sync timing - Add nullable controller getter to AuthenticatorFormField and include it in diagnostics - Add debugFillProperties implementations to form field widgets to surface the controller prop - Rework text controller sync logic in AuthenticatorTextField and AuthenticatorUsernameField: - Skip syncing in initState and avoid syncing during build - Schedule post-frame callbacks in didChangeDependencies to first apply controller->state (if controller has initial text) then sync state->controller - Minor conditional/formatting cleanup in username field sync --- .../src/mixins/authenticator_text_field.dart | 20 ++++++++--- .../mixins/authenticator_username_field.dart | 19 +++++++--- .../lib/src/widgets/form_field.dart | 6 ++++ .../confirm_sign_in_form_field.dart | 16 +++++++++ .../confirm_sign_up_form_field.dart | 16 +++++++++ .../form_fields/sign_in_form_field.dart | 29 +++++++++++---- .../form_fields/sign_up_form_field.dart | 35 +++++++++++++++---- .../form_fields/verify_user_form_field.dart | 8 +++++ 8 files changed, 127 insertions(+), 22 deletions(-) diff --git a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart index 2501acbd5c..990bebf6e5 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart @@ -68,14 +68,25 @@ mixin AuthenticatorTextField< void initState() { super.initState(); _maybeUpdateEffectiveController(); - _syncControllerText(force: true); + // Skip sync in initState since 'state' isn't available yet } @override void didChangeDependencies() { super.didChangeDependencies(); _maybeUpdateEffectiveController(); - _syncControllerText(); + // Schedule both syncs after build completes + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + // First sync controller -> state if controller has initial text + if (_effectiveController != null && + _lastSyncedControllerValue == null) { + _handleControllerChanged(); + } + // Then sync state -> controller to ensure they're in sync + _syncControllerText(); + } + }); } @override @@ -105,14 +116,13 @@ mixin AuthenticatorTextField< ).obscureTextToggleValue, builder: (BuildContext context, bool toggleObscureText, Widget? _) { final obscureText = this.obscureText && toggleObscureText; - _syncControllerText(); + // Don't sync during build return TextFormField( style: enabled ? null : TextStyle(color: Theme.of(context).disabledColor), controller: _effectiveController, - initialValue: - _effectiveController == null ? initialValue : null, + initialValue: _effectiveController == null ? initialValue : null, enabled: enabled, validator: widget.validatorOverride ?? validator, onChanged: onChanged, diff --git a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart index 03edde678f..73bf34d21b 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart @@ -69,7 +69,8 @@ mixin AuthenticatorUsernameField< } void _syncControllerText({bool force = false}) { - if (_controller == null || selectedUsernameType == UsernameType.phoneNumber) { + if (_controller == null || + selectedUsernameType == UsernameType.phoneNumber) { return; } @@ -93,14 +94,24 @@ mixin AuthenticatorUsernameField< void initState() { super.initState(); _updateController(); - _syncControllerText(force: true); + // Skip sync in initState since 'state' isn't available yet } @override void didChangeDependencies() { super.didChangeDependencies(); _updateController(); - _syncControllerText(); + // Schedule both syncs after build completes + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + // First sync controller -> state if controller has initial text + if (_controller != null && _lastSyncedText == null) { + _handleControllerChanged(); + } + // Then sync state -> controller to ensure they're in sync + _syncControllerText(); + } + }); } @override @@ -327,7 +338,7 @@ mixin AuthenticatorUsernameField< } _updateController(); - _syncControllerText(); + // Don't sync during build return TextFormField( style: enabled ? null : TextStyle(color: Theme.of(context).disabledColor), diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart index ce13d4005f..3429a2f8b9 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart @@ -96,6 +96,9 @@ abstract class AuthenticatorFormField< /// Autocomplete hints to override the default value final Iterable? autofillHints; + /// Optional text controller exposed by text-driven form fields. + TextEditingController? get controller => null; + /// Whether the field is required in the form. /// /// Defaults to `false`. @@ -126,6 +129,9 @@ abstract class AuthenticatorFormField< ..add(DiagnosticsProperty('requiredOverride', requiredOverride)) ..add(EnumProperty('usernameType', usernameType)) ..add(IterableProperty('autofillHints', autofillHints)); + properties.add( + DiagnosticsProperty('controller', controller), + ); } } diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart index 72b2a48f82..c36d616e56 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart @@ -529,6 +529,14 @@ class _ConfirmSignInPhoneField extends ConfirmSignInFormField { @override _ConfirmSignInPhoneFieldState createState() => _ConfirmSignInPhoneFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _ConfirmSignInPhoneFieldState extends _ConfirmSignInTextFieldState @@ -586,6 +594,14 @@ class _ConfirmSignInTextField extends ConfirmSignInFormField { @override _ConfirmSignInTextFieldState createState() => _ConfirmSignInTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _ConfirmSignInTextFieldState extends _ConfirmSignInFormFieldState diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart index df932e0660..da28e218ab 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart @@ -142,6 +142,14 @@ class _ConfirmSignUpTextField extends ConfirmSignUpFormField { @override _ConfirmSignUpTextFieldState createState() => _ConfirmSignUpTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _ConfirmSignUpTextFieldState extends _ConfirmSignUpFormFieldState @@ -217,6 +225,14 @@ class _ConfirmSignUpUsernameField @override _ConfirmSignUpUsernameFieldState createState() => _ConfirmSignUpUsernameFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _ConfirmSignUpUsernameFieldState diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart index c49d5ba3ce..d77d220e76 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart @@ -141,6 +141,14 @@ class _SignInTextField extends SignInFormField { @override _SignInTextFieldState createState() => _SignInTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _SignInTextFieldState extends _SignInFormFieldState @@ -197,18 +205,25 @@ class _SignInUsernameField extends SignInFormField { super.validator, super.autofillHints, this.controller, - }) - : super._( - key: key ?? keyUsernameSignInFormField, - titleKey: InputResolverKey.usernameTitle, - hintTextKey: InputResolverKey.usernameHint, - field: SignInField.username, - ); + }) : super._( + key: key ?? keyUsernameSignInFormField, + titleKey: InputResolverKey.usernameTitle, + hintTextKey: InputResolverKey.usernameHint, + field: SignInField.username, + ); final TextEditingController? controller; @override _SignInUsernameFieldState createState() => _SignInUsernameFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _SignInUsernameFieldState extends _SignInFormFieldState diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart index bbc80d758d..52722077e4 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart @@ -489,6 +489,14 @@ class _SignUpTextField extends SignUpFormField { @override _SignUpTextFieldState createState() => _SignUpTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _SignUpTextFieldState extends _SignUpFormFieldState @@ -668,17 +676,24 @@ class _SignUpUsernameField extends SignUpFormField { super.validator, super.autofillHints, this.controller, - }) - : super._( - field: SignUpField.username, - titleKey: InputResolverKey.usernameTitle, - hintTextKey: InputResolverKey.usernameHint, - ); + }) : super._( + field: SignUpField.username, + titleKey: InputResolverKey.usernameTitle, + hintTextKey: InputResolverKey.usernameHint, + ); final TextEditingController? controller; @override _SignUpUsernameFieldState createState() => _SignUpUsernameFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _SignUpUsernameFieldState extends _SignUpFormFieldState @@ -704,6 +719,14 @@ class _SignUpPhoneField extends SignUpFormField { @override _SignUpPhoneFieldState createState() => _SignUpPhoneFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _SignUpPhoneFieldState extends _SignUpTextFieldState diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart index 2b7c18b6d6..33696c9bbb 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart @@ -78,6 +78,14 @@ class _VerifyUserTextField extends VerifyUserFormField { @override _VerifyUserTextFieldState createState() => _VerifyUserTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _VerifyUserTextFieldState extends _VerifyUserFormFieldState From 0db4572dfacca51e49e62b882536c0a732ccb1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Rodem?= Date: Mon, 3 Nov 2025 21:26:21 -0300 Subject: [PATCH 3/5] feat(authenticator): add AuthenticatorTextFieldController and TextEditingController support for form fields - Export AuthenticatorTextFieldController from package API. - Integrate optional controller parameter into prebuilt form fields (sign-in, sign-up, confirm, reset password, verify user, email setup, etc.) with proper diagnostics. - Ensure bidirectional sync between controllers and internal authenticator state. - Add example demonstrating controller usage and pre-population (examples/authenticator_with_controllers.dart). - Add comprehensive widget tests for controller <-> state synchronization, standard TextEditingController compatibility, and rapid updates. - Update CHANGELOG with 2.4.0 notes. --- .../amplify_authenticator/CHANGELOG.md | 11 + .../lib/authenticator_with_controllers.dart | 180 ++++++++ .../example/lib/main.dart | 7 + .../lib/amplify_authenticator.dart | 2 +- .../authenticator_text_field_controller.dart | 3 +- .../lib/src/widgets/form_field.dart | 8 +- .../confirm_sign_in_form_field.dart | 2 + .../confirm_sign_up_form_field.dart | 2 + .../form_fields/email_setup_form_field.dart | 33 +- .../reset_password_form_field.dart | 31 +- .../form_fields/sign_in_form_field.dart | 2 + .../form_fields/sign_up_form_field.dart | 3 + .../form_fields/verify_user_form_field.dart | 1 + .../test/form_field_controller_test.dart | 428 +++++++++++++++++- 14 files changed, 672 insertions(+), 41 deletions(-) create mode 100644 packages/authenticator/amplify_authenticator/example/lib/authenticator_with_controllers.dart diff --git a/packages/authenticator/amplify_authenticator/CHANGELOG.md b/packages/authenticator/amplify_authenticator/CHANGELOG.md index f126025394..32a05c601e 100644 --- a/packages/authenticator/amplify_authenticator/CHANGELOG.md +++ b/packages/authenticator/amplify_authenticator/CHANGELOG.md @@ -1,3 +1,14 @@ +## 2.4.0 + +### Features + +- feat(authenticator): Add TextEditingController support to form fields + - Added `AuthenticatorTextFieldController` class for programmatic control of form fields + - All text-based form fields now accept an optional `controller` parameter + - Enables pre-populating fields (e.g., from GPS/API data) and auto-filling verification codes + - Bidirectional sync between controller and internal state + - Compatible with standard `TextEditingController` for flexibility + ## 2.3.8 ### Chores diff --git a/packages/authenticator/amplify_authenticator/example/lib/authenticator_with_controllers.dart b/packages/authenticator/amplify_authenticator/example/lib/authenticator_with_controllers.dart new file mode 100644 index 0000000000..764c51f7ed --- /dev/null +++ b/packages/authenticator/amplify_authenticator/example/lib/authenticator_with_controllers.dart @@ -0,0 +1,180 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; +import 'package:amplify_authenticator/amplify_authenticator.dart'; +import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:flutter/material.dart'; + +import 'amplifyconfiguration.dart'; + +/// Example demonstrating the use of TextEditingController with +/// Amplify Authenticator form fields. +/// +/// This allows programmatic control over form field values, enabling +/// use cases such as: +/// - Pre-populating fields with data from APIs (e.g., GPS location) +/// - Auto-filling verification codes from SMS +/// - Dynamic form validation and manipulation +class AuthenticatorWithControllers extends StatefulWidget { + const AuthenticatorWithControllers({super.key}); + + @override + State createState() => + _AuthenticatorWithControllersState(); +} + +class _AuthenticatorWithControllersState + extends State { + // Controllers for programmatic access to form fields + final _usernameController = AuthenticatorTextFieldController(); + final _emailController = AuthenticatorTextFieldController(); + final _addressController = AuthenticatorTextFieldController(); + final _phoneController = AuthenticatorTextFieldController(); + + @override + void initState() { + super.initState(); + _configureAmplify(); + + // Example: Pre-populate fields with default/fetched data + _usernameController.text = 'amplify_user'; + _emailController.text = 'user@amplify.example.com'; + } + + @override + void dispose() { + // Clean up controllers when the widget is disposed + _usernameController.dispose(); + _emailController.dispose(); + _addressController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + void _configureAmplify() async { + final authPlugin = AmplifyAuthCognito( + // FIXME: In your app, make sure to remove this line and set up + /// Keychain Sharing in Xcode as described in the docs: + /// https://docs.amplify.aws/lib/project-setup/platform-setup/q/platform/flutter/#enable-keychain + secureStorageFactory: AmplifySecureStorage.factoryFrom( + macOSOptions: + // ignore: invalid_use_of_visible_for_testing_member + MacOSSecureStorageOptions(useDataProtection: false), + ), + ); + try { + await Amplify.addPlugin(authPlugin); + await Amplify.configure(amplifyconfig); + safePrint('Successfully configured'); + } on Exception catch (e) { + safePrint('Error configuring Amplify: $e'); + } + } + + /// Simulates fetching user location and populating the address field + Future _fetchAndPopulateAddress() async { + // In a real app, you would use a geolocation service here + await Future.delayed(const Duration(seconds: 1)); + + // Simulate fetched address + final fetchedAddress = '123 Main Street, Seattle, WA 98101'; + + // Update the address field programmatically + _addressController.text = fetchedAddress; + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Address populated: $fetchedAddress')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Authenticator( + // Custom sign-up form with controller support + signUpForm: SignUpForm.custom( + fields: [ + // Username field with controller - can be pre-populated or modified + SignUpFormField.username(controller: _usernameController), + + // Email field with controller + SignUpFormField.email(controller: _emailController, required: true), + + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + + // Address field with controller - can be populated from GPS/API + SignUpFormField.address(controller: _addressController), + + // Phone number field with controller + SignUpFormField.phoneNumber(controller: _phoneController), + ], + ), + + child: MaterialApp( + title: 'Authenticator with Controllers', + theme: ThemeData.light(useMaterial3: true), + darkTheme: ThemeData.dark(useMaterial3: true), + debugShowCheckedModeBanner: false, + builder: Authenticator.builder(), + home: Scaffold( + appBar: AppBar(title: const Text('Controller Example')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'You are logged in!', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + + // Display current controller values + Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Form Field Values:', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Text('Username: ${_usernameController.text}'), + Text('Email: ${_emailController.text}'), + Text('Address: ${_addressController.text}'), + Text('Phone: ${_phoneController.text}'), + ], + ), + ), + ), + + const SizedBox(height: 20), + + ElevatedButton.icon( + onPressed: _fetchAndPopulateAddress, + icon: const Icon(Icons.location_on), + label: const Text('Fetch GPS Address'), + ), + + const SizedBox(height: 20), + const SignOutButton(), + ], + ), + ), + ), + ), + ); + } +} + +void main() { + runApp(const AuthenticatorWithControllers()); +} diff --git a/packages/authenticator/amplify_authenticator/example/lib/main.dart b/packages/authenticator/amplify_authenticator/example/lib/main.dart index 009dbdbdcb..12c0ab6cf6 100644 --- a/packages/authenticator/amplify_authenticator/example/lib/main.dart +++ b/packages/authenticator/amplify_authenticator/example/lib/main.dart @@ -210,6 +210,13 @@ class _MyAppState extends State { // Widget build(BuildContext context) { // return const AuthenticatorWithCustomAuthFlow(); // } + + // Below is an example showing TextEditingController support for + // programmatic form field control + // @override + // Widget build(BuildContext context) { + // return const AuthenticatorWithControllers(); + // } } /// The screen which is shown once the user is logged in. We can use diff --git a/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart b/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart index 55afb09d1d..33de32cda7 100644 --- a/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart +++ b/packages/authenticator/amplify_authenticator/lib/amplify_authenticator.dart @@ -42,6 +42,7 @@ export 'package:amplify_authenticator/src/utils/dial_code.dart' show DialCode; export 'package:amplify_authenticator/src/utils/dial_code_options.dart' show DialCodeOptions; +export 'src/controllers/authenticator_text_field_controller.dart'; export 'src/enums/enums.dart' show AuthenticatorStep, Gender; export 'src/l10n/auth_strings_resolver.dart' hide ButtonResolverKeyType; export 'src/models/authenticator_exception.dart'; @@ -94,7 +95,6 @@ export 'src/widgets/form_field.dart' TotpSetupFormField, VerifyUserFormField; export 'src/widgets/social/social_button.dart'; -export 'src/controllers/authenticator_text_field_controller.dart'; /// {@template amplify_authenticator.authenticator} /// # Amplify Authenticator diff --git a/packages/authenticator/amplify_authenticator/lib/src/controllers/authenticator_text_field_controller.dart b/packages/authenticator/amplify_authenticator/lib/src/controllers/authenticator_text_field_controller.dart index b6f71096f0..ed2cdb4c12 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/controllers/authenticator_text_field_controller.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/controllers/authenticator_text_field_controller.dart @@ -11,6 +11,5 @@ import 'package:flutter/widgets.dart'; class AuthenticatorTextFieldController extends TextEditingController { AuthenticatorTextFieldController({super.text}); - AuthenticatorTextFieldController.fromValue(TextEditingValue value) - : super.fromValue(value); + AuthenticatorTextFieldController.fromValue(super.value) : super.fromValue(); } diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart index 3429a2f8b9..512833beb5 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart @@ -128,10 +128,10 @@ abstract class AuthenticatorFormField< ..add(DiagnosticsProperty('required', required)) ..add(DiagnosticsProperty('requiredOverride', requiredOverride)) ..add(EnumProperty('usernameType', usernameType)) - ..add(IterableProperty('autofillHints', autofillHints)); - properties.add( - DiagnosticsProperty('controller', controller), - ); + ..add(IterableProperty('autofillHints', autofillHints)) + ..add( + DiagnosticsProperty('controller', controller), + ); } } diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart index c36d616e56..91363de867 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart @@ -524,6 +524,7 @@ class _ConfirmSignInPhoneField extends ConfirmSignInFormField { this.controller, }) : super._(customAttributeKey: attributeKey); + @override final TextEditingController? controller; @override @@ -590,6 +591,7 @@ class _ConfirmSignInTextField extends ConfirmSignInFormField { this.controller, }) : super._(customAttributeKey: attributeKey); + @override final TextEditingController? controller; @override diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart index da28e218ab..5c84547f13 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart @@ -138,6 +138,7 @@ class _ConfirmSignUpTextField extends ConfirmSignUpFormField { this.controller, }) : super._(); + @override final TextEditingController? controller; @override @@ -220,6 +221,7 @@ class _ConfirmSignUpUsernameField this.controller, }) : super._(); + @override final TextEditingController? controller; @override diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/email_setup_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/email_setup_form_field.dart index 929920ef71..e0e2bcf277 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/email_setup_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/email_setup_form_field.dart @@ -9,6 +9,22 @@ part of '../form_field.dart'; /// {@endtemplate} class EmailSetupFormField extends AuthenticatorFormField { + /// Creates an email FormField for the email setup step. + const EmailSetupFormField.email({ + Key? key, + FormFieldValidator? validator, + Iterable? autofillHints, + TextEditingController? controller, + }) : this._( + key: key ?? keyEmailSetupFormField, + field: EmailSetupField.email, + titleKey: InputResolverKey.emailTitle, + hintTextKey: InputResolverKey.emailHint, + validator: validator, + autofillHints: autofillHints, + controller: controller, + ); + /// {@macro amplify_authenticator.email_setup_form_field} /// /// Either [titleKey] or [title] is required. @@ -24,24 +40,9 @@ class EmailSetupFormField this.controller, }) : super._(); + @override final TextEditingController? controller; - /// Creates an email FormField for the email setup step. - const EmailSetupFormField.email({ - Key? key, - FormFieldValidator? validator, - Iterable? autofillHints, - TextEditingController? controller, - }) : this._( - key: key ?? keyEmailSetupFormField, - field: EmailSetupField.email, - titleKey: InputResolverKey.emailTitle, - hintTextKey: InputResolverKey.emailHint, - validator: validator, - autofillHints: autofillHints, - controller: controller, - ); - @override bool get required => true; diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/reset_password_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/reset_password_form_field.dart index 2a9433bb47..04678f62b8 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/reset_password_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/reset_password_form_field.dart @@ -9,21 +9,6 @@ part of '../form_field.dart'; /// {@endtemplate} class ResetPasswordFormField extends AuthenticatorFormField { - /// {@macro amplify_authenticator.sign_up_form_field} - /// - /// Either [titleKey] or [title] is required. - const ResetPasswordFormField._({ - super.key, - required super.field, - super.titleKey, - super.hintTextKey, - super.validator, - super.autofillHints, - this.controller, - }) : super._(); - - final TextEditingController? controller; - const ResetPasswordFormField.verificationCode({ Key? key, Iterable? autofillHints, @@ -65,6 +50,22 @@ class ResetPasswordFormField controller: controller, ); + /// {@macro amplify_authenticator.sign_up_form_field} + /// + /// Either [titleKey] or [title] is required. + const ResetPasswordFormField._({ + super.key, + required super.field, + super.titleKey, + super.hintTextKey, + super.validator, + super.autofillHints, + this.controller, + }) : super._(); + + @override + final TextEditingController? controller; + @override bool get required => true; diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart index d77d220e76..b8784237f5 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_in_form_field.dart @@ -137,6 +137,7 @@ class _SignInTextField extends SignInFormField { this.controller, }) : super._(); + @override final TextEditingController? controller; @override @@ -212,6 +213,7 @@ class _SignInUsernameField extends SignInFormField { field: SignInField.username, ); + @override final TextEditingController? controller; @override diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart index 52722077e4..699b1dd87a 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart @@ -485,6 +485,7 @@ class _SignUpTextField extends SignUpFormField { this.controller, }) : super._(customAttributeKey: attributeKey); + @override final TextEditingController? controller; @override @@ -682,6 +683,7 @@ class _SignUpUsernameField extends SignUpFormField { hintTextKey: InputResolverKey.usernameHint, ); + @override final TextEditingController? controller; @override @@ -715,6 +717,7 @@ class _SignUpPhoneField extends SignUpFormField { this.controller, }) : super._(customAttributeKey: attributeKey); + @override final TextEditingController? controller; @override diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart index 33696c9bbb..ea7409fba0 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/verify_user_form_field.dart @@ -74,6 +74,7 @@ class _VerifyUserTextField extends VerifyUserFormField { this.controller, }) : super._(); + @override final TextEditingController? controller; @override diff --git a/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart b/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart index 1887387045..d4e1160c2f 100644 --- a/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart +++ b/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart @@ -5,14 +5,19 @@ import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_authenticator/amplify_authenticator.dart'; import 'package:amplify_authenticator/src/state/inherited_authenticator_state.dart'; import 'package:amplify_authenticator_test/amplify_authenticator_test.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('Authenticator text field controllers', () { - testWidgets('SignUpFormField.username syncs with controller', (tester) async { - final usernameController = AuthenticatorTextFieldController(text: 'initial'); + testWidgets('SignUpFormField.username syncs with controller', ( + tester, + ) async { + final usernameController = AuthenticatorTextFieldController( + text: 'initial', + ); addTearDown(usernameController.dispose); await tester.pumpWidget( @@ -46,7 +51,9 @@ void main() { expect(usernameController.text, equals('state-origin')); }); - testWidgets('SignUpFormField.address syncs controller and attributes', (tester) async { + testWidgets('SignUpFormField.address syncs controller and attributes', ( + tester, + ) async { final addressController = AuthenticatorTextFieldController(); addTearDown(addressController.dispose); @@ -79,5 +86,420 @@ void main() { await tester.pump(); expect(addressController.text, equals('987 Baker Ave')); }); + + testWidgets('SignInFormField.username syncs with controller', ( + tester, + ) async { + final usernameController = AuthenticatorTextFieldController( + text: 'testuser', + ); + addTearDown(usernameController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signIn, + signInForm: SignInForm.custom( + fields: [ + SignInFormField.username(controller: usernameController), + SignInFormField.password(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signInContext = tester.element(find.byType(SignInForm)); + final authState = InheritedAuthenticatorState.of(signInContext); + + // Initial text populates state on first build. + expect(authState.username, equals('testuser')); + + // Programmatic updates flow from controller -> state. + usernameController.text = 'newuser'; + await tester.pump(); + expect(authState.username, equals('newuser')); + + // State updates propagate back to the controller. + authState.username = 'another-user'; + await tester.pump(); + expect(usernameController.text, equals('another-user')); + }); + + testWidgets('SignInFormField.password syncs with controller', ( + tester, + ) async { + final passwordController = AuthenticatorTextFieldController( + text: 'pass123', + ); + addTearDown(passwordController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signIn, + signInForm: SignInForm.custom( + fields: [ + SignInFormField.username(), + SignInFormField.password(controller: passwordController), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signInContext = tester.element(find.byType(SignInForm)); + final authState = InheritedAuthenticatorState.of(signInContext); + + // Initial password is set + expect(authState.password, equals('pass123')); + + // Controller updates propagate to state + passwordController.text = 'newpass456'; + await tester.pump(); + expect(authState.password, equals('newpass456')); + + // State updates propagate to controller + authState.password = 'statepass789'; + await tester.pump(); + expect(passwordController.text, equals('statepass789')); + }); + + testWidgets('SignUpFormField.password syncs with controller', ( + tester, + ) async { + final passwordController = AuthenticatorTextFieldController(); + addTearDown(passwordController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.password(controller: passwordController), + SignUpFormField.passwordConfirmation(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + + // Update through controller + passwordController.text = 'SecurePass123!'; + await tester.pump(); + expect(authState.password, equals('SecurePass123!')); + + // Update through state + authState.password = 'NewSecurePass456!'; + await tester.pump(); + expect(passwordController.text, equals('NewSecurePass456!')); + }); + + testWidgets('SignUpFormField.email syncs with controller', (tester) async { + final emailController = AuthenticatorTextFieldController( + text: 'test@example.com', + ); + addTearDown(emailController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + SignUpFormField.email(controller: emailController), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + + // Initial email is set + expect( + authState.getAttribute(CognitoUserAttributeKey.email), + equals('test@example.com'), + ); + + // Controller updates propagate + emailController.text = 'new@example.com'; + await tester.pump(); + expect( + authState.getAttribute(CognitoUserAttributeKey.email), + equals('new@example.com'), + ); + + // State updates propagate + authState.email = 'updated@example.com'; + await tester.pump(); + expect(emailController.text, equals('updated@example.com')); + }); + + testWidgets('SignUpFormField.custom syncs with controller', (tester) async { + final bioController = AuthenticatorTextFieldController( + text: 'I love Flutter', + ); + addTearDown(bioController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + SignUpFormField.custom( + title: 'Bio', + attributeKey: const CognitoUserAttributeKey.custom('bio'), + controller: bioController, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + + // Initial value is set + expect( + authState.getAttribute(const CognitoUserAttributeKey.custom('bio')), + equals('I love Flutter'), + ); + + // Controller updates propagate + bioController.text = 'I love AWS Amplify'; + await tester.pump(); + expect( + authState.getAttribute(const CognitoUserAttributeKey.custom('bio')), + equals('I love AWS Amplify'), + ); + + // State updates propagate + authState.setCustomAttribute( + const CognitoUserAttributeKey.custom('bio'), + 'Flutter + Amplify = Amazing', + ); + await tester.pump(); + expect(bioController.text, equals('Flutter + Amplify = Amazing')); + }); + + testWidgets('Multiple controllers work independently', (tester) async { + final addressController = AuthenticatorTextFieldController( + text: '123 Main St', + ); + final nameController = AuthenticatorTextFieldController(text: 'John Doe'); + final phoneController = AuthenticatorTextFieldController( + text: '+1234567890', + ); + + addTearDown(addressController.dispose); + addTearDown(nameController.dispose); + addTearDown(phoneController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + SignUpFormField.address(controller: addressController), + SignUpFormField.name(controller: nameController), + SignUpFormField.phoneNumber(controller: phoneController), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + + // Verify all initial values are set + expect( + authState.getAttribute(CognitoUserAttributeKey.address), + equals('123 Main St'), + ); + expect( + authState.getAttribute(CognitoUserAttributeKey.name), + equals('John Doe'), + ); + expect( + authState.getAttribute(CognitoUserAttributeKey.phoneNumber), + equals('+1234567890'), + ); + + // Update one controller + addressController.text = '456 Oak Ave'; + await tester.pump(); + + // Verify only that field changed + expect( + authState.getAttribute(CognitoUserAttributeKey.address), + equals('456 Oak Ave'), + ); + expect( + authState.getAttribute(CognitoUserAttributeKey.name), + equals('John Doe'), + ); + expect( + authState.getAttribute(CognitoUserAttributeKey.phoneNumber), + equals('+1234567890'), + ); + + // Update another controller + nameController.text = 'Jane Smith'; + await tester.pump(); + + // Verify the correct field changed + expect( + authState.getAttribute(CognitoUserAttributeKey.address), + equals('456 Oak Ave'), + ); + expect( + authState.getAttribute(CognitoUserAttributeKey.name), + equals('Jane Smith'), + ); + expect( + authState.getAttribute(CognitoUserAttributeKey.phoneNumber), + equals('+1234567890'), + ); + }); + + testWidgets('Controller can be added after initial build', (tester) async { + final controller = AuthenticatorTextFieldController(); + addTearDown(controller.dispose); + + // Build without controller first + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + SignUpFormField.address(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + var authState = InheritedAuthenticatorState.of(signUpContext); + + // Set state value + authState.address = 'Initial Address'; + await tester.pump(); + + // Rebuild with controller + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + SignUpFormField.address(controller: controller), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + // Controller should sync with existing state + expect(controller.text, equals('Initial Address')); + + // Controller updates should work + controller.text = 'Updated Address'; + await tester.pump(); + + authState = InheritedAuthenticatorState.of(signUpContext); + expect( + authState.getAttribute(CognitoUserAttributeKey.address), + equals('Updated Address'), + ); + }); + + testWidgets('Controller works with standard TextEditingController', ( + tester, + ) async { + // Test that standard TextEditingController also works + final usernameController = TextEditingController(text: 'standard'); + addTearDown(usernameController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(controller: usernameController), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + + // Standard TextEditingController should also work + expect(authState.username, equals('standard')); + + usernameController.text = 'updated-standard'; + await tester.pump(); + expect(authState.username, equals('updated-standard')); + }); + + testWidgets('Controller updates are handled correctly for rapid changes', ( + tester, + ) async { + final controller = AuthenticatorTextFieldController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + SignUpFormField.email(controller: controller), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + + // Rapid updates + controller.text = 'a@test.com'; + controller.text = 'b@test.com'; + controller.text = 'c@test.com'; + await tester.pump(); + + // Should reflect the latest value + expect( + authState.getAttribute(CognitoUserAttributeKey.email), + equals('c@test.com'), + ); + }); }); } From 7e71fa7d7608d54a19a31a2791f89b4166fc2ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Rodem?= Date: Tue, 4 Nov 2025 10:37:26 -0300 Subject: [PATCH 4/5] fix(authenticator): defer controller->state updates to post-frame and stabilize sync Buffer controller changes and apply them in a post-frame callback to avoid calling setState during build. Normalize trailing whitespace when comparing controller and state to reduce unnecessary writes. Ensure didUpdateWidget sync runs after build and clear pending state on dispose. Add widget tests covering typing, special keys, rapid updates, and controller interactions. --- .../src/mixins/authenticator_text_field.dart | 50 ++++- .../mixins/authenticator_username_field.dart | 71 ++++-- .../test/form_field_controller_test.dart | 206 ++++++++++++++++++ 3 files changed, 306 insertions(+), 21 deletions(-) diff --git a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart index 990bebf6e5..3a3b000841 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_text_field.dart @@ -4,6 +4,7 @@ import 'package:amplify_authenticator/src/widgets/form.dart'; import 'package:amplify_authenticator/src/widgets/form_field.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; mixin AuthenticatorTextField< @@ -14,6 +15,8 @@ mixin AuthenticatorTextField< TextEditingController? _effectiveController; bool _isApplyingControllerText = false; String? _lastSyncedControllerValue; + String? _pendingControllerText; + bool _controllerUpdateScheduled = false; @protected TextEditingController? get textController => null; @@ -26,6 +29,7 @@ mixin AuthenticatorTextField< _effectiveController?.removeListener(_handleControllerChanged); _effectiveController = controller; _lastSyncedControllerValue = null; + _pendingControllerText = null; if (_effectiveController != null) { _effectiveController!.addListener(_handleControllerChanged); } @@ -37,11 +41,27 @@ mixin AuthenticatorTextField< return; } final text = controller.text; - if (text == _lastSyncedControllerValue) { + if (text == _lastSyncedControllerValue && _pendingControllerText == null) { return; } - _lastSyncedControllerValue = text; - onChanged(text); + _pendingControllerText = text; + if (_controllerUpdateScheduled) { + return; + } + _controllerUpdateScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _controllerUpdateScheduled = false; + final pendingText = _pendingControllerText; + _pendingControllerText = null; + if (!mounted || pendingText == null) { + return; + } + if (pendingText == _lastSyncedControllerValue) { + return; + } + _lastSyncedControllerValue = pendingText; + onChanged(pendingText); + }); } void _syncControllerText({bool force = false}) { @@ -50,8 +70,16 @@ mixin AuthenticatorTextField< return; } final target = initialValue ?? ''; - if (!force && controller.text == target) { - _lastSyncedControllerValue = controller.text; + final controllerText = controller.text; + if (!force && controllerText == target) { + _lastSyncedControllerValue = controllerText; + return; + } + + final normalizedController = controllerText.trimRight(); + final normalizedTarget = target.trimRight(); + if (normalizedController == normalizedTarget) { + _lastSyncedControllerValue = controllerText; return; } _isApplyingControllerText = true; @@ -61,6 +89,7 @@ mixin AuthenticatorTextField< composing: TextRange.empty, ); _lastSyncedControllerValue = target; + _pendingControllerText = null; _isApplyingControllerText = false; } @@ -93,13 +122,19 @@ mixin AuthenticatorTextField< void didUpdateWidget(covariant T oldWidget) { super.didUpdateWidget(oldWidget); _maybeUpdateEffectiveController(); - _syncControllerText(force: true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _syncControllerText(force: true); + } + }); } @override void dispose() { _effectiveController?.removeListener(_handleControllerChanged); _effectiveController = null; + _pendingControllerText = null; + _controllerUpdateScheduled = false; super.dispose(); } @@ -117,6 +152,7 @@ mixin AuthenticatorTextField< builder: (BuildContext context, bool toggleObscureText, Widget? _) { final obscureText = this.obscureText && toggleObscureText; // Don't sync during build + final shouldHandleChangeImmediately = _effectiveController == null; return TextFormField( style: enabled ? null @@ -125,7 +161,7 @@ mixin AuthenticatorTextField< initialValue: _effectiveController == null ? initialValue : null, enabled: enabled, validator: widget.validatorOverride ?? validator, - onChanged: onChanged, + onChanged: shouldHandleChangeImmediately ? onChanged : null, autocorrect: false, decoration: InputDecoration( labelText: labelText, diff --git a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart index 73bf34d21b..eeeceeab62 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/mixins/authenticator_username_field.dart @@ -9,6 +9,7 @@ import 'package:amplify_authenticator/src/utils/validators.dart'; import 'package:amplify_authenticator/src/widgets/component.dart'; import 'package:amplify_authenticator/src/widgets/form_field.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; mixin AuthenticatorUsernameField< FieldType extends Enum, @@ -19,6 +20,8 @@ mixin AuthenticatorUsernameField< UsernameType? _controllerUsernameType; bool _applyingControllerText = false; String? _lastSyncedText; + String? _pendingControllerText; + bool _controllerUpdateScheduled = false; @protected TextEditingController? get textController => null; @@ -42,6 +45,7 @@ mixin AuthenticatorUsernameField< _controller = controller; _controllerUsernameType = type; _lastSyncedText = null; + _pendingControllerText = null; if (_controller != null && shouldListen) { _controller!.addListener(_handleControllerChanged); @@ -55,17 +59,35 @@ mixin AuthenticatorUsernameField< } final text = controller.text; - if (text == _lastSyncedText) { + if (text == _lastSyncedText && _pendingControllerText == null) { return; } - _lastSyncedText = text; - _applyingControllerText = true; - try { - onChanged(UsernameInput(type: selectedUsernameType, username: text)); - } finally { - _applyingControllerText = false; + _pendingControllerText = text; + if (_controllerUpdateScheduled) { + return; } + _controllerUpdateScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _controllerUpdateScheduled = false; + final pendingText = _pendingControllerText; + _pendingControllerText = null; + if (!mounted || pendingText == null) { + return; + } + if (pendingText == _lastSyncedText) { + return; + } + _applyingControllerText = true; + try { + onChanged( + UsernameInput(type: selectedUsernameType, username: pendingText), + ); + _lastSyncedText = pendingText; + } finally { + _applyingControllerText = false; + } + }); } void _syncControllerText({bool force = false}) { @@ -75,8 +97,16 @@ mixin AuthenticatorUsernameField< } final target = initialValue?.username ?? ''; - if (!force && _controller!.text == target) { - _lastSyncedText = _controller!.text; + final controllerText = _controller!.text; + if (!force && controllerText == target) { + _lastSyncedText = controllerText; + return; + } + + final normalizedController = controllerText.trimRight(); + final normalizedTarget = target.trimRight(); + if (normalizedController == normalizedTarget) { + _lastSyncedText = controllerText; return; } @@ -87,6 +117,7 @@ mixin AuthenticatorUsernameField< composing: TextRange.empty, ); _lastSyncedText = target; + _pendingControllerText = null; _applyingControllerText = false; } @@ -118,13 +149,19 @@ mixin AuthenticatorUsernameField< void didUpdateWidget(covariant T oldWidget) { super.didUpdateWidget(oldWidget); _updateController(); - _syncControllerText(force: true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _syncControllerText(force: true); + } + }); } @override void dispose() { _controller?.removeListener(_handleControllerChanged); _controller = null; + _pendingControllerText = null; + _controllerUpdateScheduled = false; super.dispose(); } @@ -310,8 +347,8 @@ mixin AuthenticatorUsernameField< final inputResolver = stringResolver.inputs; final hintText = inputResolver.resolve(context, hintKey); - void onChanged(String username) { - return this.onChanged( + void handleChanged(String username) { + return onChanged( UsernameInput(type: selectedUsernameType, username: username), ); } @@ -327,7 +364,7 @@ mixin AuthenticatorUsernameField< return AuthenticatorPhoneField( field: widget.field, requiredOverride: true, - onChanged: onChanged, + onChanged: handleChanged, validator: validator, enabled: enabled, errorMaxLines: errorMaxLines, @@ -340,13 +377,19 @@ mixin AuthenticatorUsernameField< _updateController(); // Don't sync during build + final controllerInUse = _controller != null; + return TextFormField( style: enabled ? null : TextStyle(color: Theme.of(context).disabledColor), controller: _controller, initialValue: _controller == null ? initialValue?.username : null, enabled: enabled, validator: validator, - onChanged: onChanged, + onChanged: (username) { + if (!controllerInUse) { + handleChanged(username); + } + }, autocorrect: false, decoration: InputDecoration( prefixIcon: prefix, diff --git a/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart b/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart index d4e1160c2f..54a572d64c 100644 --- a/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart +++ b/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart @@ -3,8 +3,10 @@ import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_authenticator/amplify_authenticator.dart'; +import 'package:amplify_authenticator/src/keys.dart'; import 'package:amplify_authenticator/src/state/inherited_authenticator_state.dart'; import 'package:amplify_authenticator_test/amplify_authenticator_test.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -163,6 +165,51 @@ void main() { expect(passwordController.text, equals('statepass789')); }); + testWidgets('typing with controller defers state updates to after build', ( + tester, + ) async { + final usernameController = AuthenticatorTextFieldController(); + addTearDown(usernameController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signIn, + signInForm: SignInForm.custom( + fields: [ + SignInFormField.username(controller: usernameController), + SignInFormField.password(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final fieldFinder = find.byKey(keyUsernameSignInFormField); + await tester.tap(fieldFinder); + await tester.pump(); + await tester.showKeyboard(fieldFinder); + + expect(tester.takeException(), isNull); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.pump(); + expect(tester.takeException(), isNull); + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + expect(tester.takeException(), isNull); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + expect(tester.takeException(), isNull); + + final signInContext = tester.element(find.byType(SignInForm)); + final authState = InheritedAuthenticatorState.of(signInContext); + + expect(usernameController.text, equals('a')); + expect(authState.username, equals('a')); + }); + testWidgets('SignUpFormField.password syncs with controller', ( tester, ) async { @@ -501,5 +548,164 @@ void main() { equals('c@test.com'), ); }); + + testWidgets( + 'No setState during build when typing special keys (space, backspace)', + (tester) async { + final controller = AuthenticatorTextFieldController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(controller: controller), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + + // Type regular characters + controller.text = 'test'; + await tester.pump(); + expect(authState.username, equals('test')); + + // Type space character - this should not cause setState during build + controller.text = 'test '; + await tester.pump(); + expect(authState.username, equals('test ')); + + // Type more characters after space + controller.text = 'test user'; + await tester.pump(); + expect(authState.username, equals('test user')); + + // Simulate backspace by removing characters + controller.text = 'test use'; + await tester.pump(); + expect(authState.username, equals('test use')); + + controller.text = 'test us'; + await tester.pump(); + expect(authState.username, equals('test us')); + + // Multiple rapid changes including special keys + controller.text = 'test us '; + await tester.pump(); + controller.text = 'test us a'; + await tester.pump(); + controller.text = 'test us'; + await tester.pump(); + controller.text = 'test'; + await tester.pump(); + expect(authState.username, equals('test')); + + // Test passes if no exception is thrown during the above operations + }, + ); + + testWidgets( + 'Controller updates during form field interactions do not cause errors', + (tester) async { + final controller = AuthenticatorTextFieldController(text: 'initial'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(controller: controller), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + + // Verify initial state + expect(authState.username, equals('initial')); + + // Simulate complex editing sequence + controller.text = 'initial text'; + await tester.pump(); + expect(authState.username, equals('initial text')); + + // Add space + controller.text = 'initial text '; + await tester.pump(); + expect(authState.username, equals('initial text ')); + + // Continue typing + controller.text = 'initial text with spaces'; + await tester.pump(); + expect(authState.username, equals('initial text with spaces')); + + // Delete to space + controller.text = 'initial text with '; + await tester.pump(); + expect(authState.username, equals('initial text with ')); + + // Delete space + controller.text = 'initial text with'; + await tester.pump(); + expect(authState.username, equals('initial text with')); + + // Complete deletion + controller.text = ''; + await tester.pump(); + expect(authState.username, equals('')); + }, + ); + + testWidgets( + 'SignInFormField.password controller handles special characters correctly', + (tester) async { + final passwordController = AuthenticatorTextFieldController(); + addTearDown(passwordController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signIn, + signInForm: SignInForm.custom( + fields: [ + SignInFormField.username(), + SignInFormField.password(controller: passwordController), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final signInContext = tester.element(find.byType(SignInForm)); + final authState = InheritedAuthenticatorState.of(signInContext); + + // Test password with special characters and spaces + const complexPassword = r'Pass word123! @#$'; + + // Set the final password + passwordController.text = complexPassword; + await tester.pump(); + + // Verify final password + expect(authState.password, equals(complexPassword)); + + // Test deletion + passwordController.text = ''; + await tester.pump(); + expect(authState.password, equals('')); + }, + ); }); } From f17198ae56a0e1f26a0405691a0e8eb583bd8d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Rodem?= Date: Tue, 4 Nov 2025 20:26:58 -0300 Subject: [PATCH 5/5] feat(authenticator): allow SignUp form fields to be disabled or hidden - Add enabledOverride and visible to AuthenticatorFormField and include in diagnostics - Use enabledOverride to determine enabled state; honor override in ConfirmSignUpFormField - Render hidden fields offstage with Visibility maintaining state (so programmatic updates still sync) - Propagate enabled/visible through SignUpFormField factory APIs and concrete field constructors - Add tests verifying disabled fields still sync with controllers and hidden fields retain state synchronization --- .../amplify_authenticator/CHANGELOG.md | 1 + .../lib/src/widgets/form_field.dart | 32 +++- .../confirm_sign_up_form_field.dart | 4 + .../form_fields/sign_up_form_field.dart | 162 +++++++++++++++++- .../test/form_field_controller_test.dart | 83 ++++++++- 5 files changed, 277 insertions(+), 5 deletions(-) diff --git a/packages/authenticator/amplify_authenticator/CHANGELOG.md b/packages/authenticator/amplify_authenticator/CHANGELOG.md index 32a05c601e..a7383f9456 100644 --- a/packages/authenticator/amplify_authenticator/CHANGELOG.md +++ b/packages/authenticator/amplify_authenticator/CHANGELOG.md @@ -8,6 +8,7 @@ - Enables pre-populating fields (e.g., from GPS/API data) and auto-filling verification codes - Bidirectional sync between controller and internal state - Compatible with standard `TextEditingController` for flexibility +- feat(authenticator): Allow SignUpFormField inputs to be disabled or hidden so apps can prefill values programmatically or keep legacy attributes off-screen ## 2.3.8 diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart index 512833beb5..76e57976f8 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_field.dart @@ -66,6 +66,8 @@ abstract class AuthenticatorFormField< FormFieldValidator? validator, this.requiredOverride, this.autofillHints, + this.enabledOverride, + this.visible = true, }) : validatorOverride = validator; /// Resolver key for the title @@ -99,6 +101,17 @@ abstract class AuthenticatorFormField< /// Optional text controller exposed by text-driven form fields. TextEditingController? get controller => null; + /// Whether the field can receive manual input. + /// + /// When `null`, the widget decides its enabled state. + final bool? enabledOverride; + + /// Whether the field should be rendered. + /// + /// When `false`, the field stays offstage so programmatic updates can still + /// run without occupying layout space. + final bool visible; + /// Whether the field is required in the form. /// /// Defaults to `false`. @@ -131,7 +144,9 @@ abstract class AuthenticatorFormField< ..add(IterableProperty('autofillHints', autofillHints)) ..add( DiagnosticsProperty('controller', controller), - ); + ) + ..add(DiagnosticsProperty('enabledOverride', enabledOverride)) + ..add(DiagnosticsProperty('visible', visible)); } } @@ -172,7 +187,7 @@ abstract class AuthenticatorFormFieldState< FieldValue? get initialValue => null; /// Whether the form field accepts input. - bool get enabled => true; + bool get enabled => widget.enabledOverride ?? true; /// Widget to show at leading end, typically an [Icon]. Widget? get prefix => null; @@ -270,7 +285,7 @@ abstract class AuthenticatorFormFieldState< @nonVirtual @override Widget build(BuildContext context) { - return Container( + final field = Container( margin: EdgeInsets.only(bottom: marginBottom ?? 0), child: Stack( children: [ @@ -286,6 +301,17 @@ abstract class AuthenticatorFormFieldState< ], ), ); + if (widget.visible) { + return field; + } + return Visibility( + visible: false, + maintainState: true, + maintainAnimation: true, + maintainSemantics: false, + maintainInteractivity: false, + child: field, + ); } @override diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart index 5c84547f13..1589748cb7 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_up_form_field.dart @@ -94,6 +94,10 @@ abstract class _ConfirmSignUpFormFieldState @override bool get enabled { + final override = widget.enabledOverride; + if (override != null) { + return override; + } switch (widget.field) { case ConfirmSignUpField.code: return true; diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart index 699b1dd87a..a7a668917a 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/sign_up_form_field.dart @@ -23,8 +23,10 @@ abstract class SignUpFormField CognitoUserAttributeKey? customAttributeKey, bool? required, super.autofillHints, + bool? enabled, + super.visible, }) : _customAttributeKey = customAttributeKey, - super._(requiredOverride: required); + super._(requiredOverride: required, enabledOverride: enabled); /// {@template amplify_authenticator.username_form_field} /// Creates a username component based on your app's configuration. @@ -41,11 +43,21 @@ abstract class SignUpFormField FormFieldValidator? validator, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` to lock the field when its value should come from + /// background work or autocomplete instead of manual edits. + bool? enabled, + + /// Set to `false` to keep the field hidden while still allowing the app to + /// supply data programmatically (e.g., legacy or system-managed fields). + bool visible = true, }) => _SignUpUsernameField( key: key ?? keyUsernameSignUpFormField, validator: validator, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a password component. @@ -54,6 +66,14 @@ abstract class SignUpFormField FormFieldValidator? validator, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` to lock the field while values are supplied + /// automatically (e.g., when generating passwords in the background). + bool? enabled, + + /// Set to `false` to hide the field when credentials are handled outside + /// of the UI but still need to sync with the form state. + bool visible = true, }) => _SignUpTextField( key: key ?? keyPasswordSignUpFormField, titleKey: InputResolverKey.passwordTitle, @@ -62,6 +82,8 @@ abstract class SignUpFormField validator: validator, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a passwordConfirmation component. @@ -70,6 +92,14 @@ abstract class SignUpFormField FormFieldValidator? validator, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` to keep the confirmation read-only while values are + /// synced from elsewhere (e.g., mirror updates from a generated password). + bool? enabled, + + /// Set to `false` to hide the confirmation when credentials are managed + /// outside of the UI but still need validation. + bool visible = true, }) => _SignUpTextField( key: key ?? keyPasswordConfirmationSignUpFormField, titleKey: InputResolverKey.passwordConfirmationTitle, @@ -78,6 +108,8 @@ abstract class SignUpFormField validator: validator, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates an address component. @@ -87,6 +119,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` when the address is auto-derived (e.g., GPS lookup) + /// and should stay read-only for the user. + bool? enabled, + + /// Set to `false` to keep the field hidden while still syncing backend-only + /// attributes such as generated addresses. + bool visible = true, }) => _SignUpTextField( key: key ?? keyAddressSignUpFormField, titleKey: InputResolverKey.addressTitle, @@ -96,6 +136,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a birthdate component. @@ -104,6 +146,14 @@ abstract class SignUpFormField FormFieldValidator? validator, bool? required, Iterable? autofillHints, + + /// Provide `false` to prevent edits when birthdates are sourced from a + /// trusted system record instead of the user. + bool? enabled, + + /// Set to `false` to quietly retain legacy birthdate attributes that are + /// no longer presented to the user. + bool visible = true, }) => _SignUpDateField( key: key ?? keyBirthdateSignUpFormField, titleKey: InputResolverKey.birthdateTitle, @@ -112,6 +162,8 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + enabled: enabled, + visible: visible, ); /// Creates an email component. @@ -121,6 +173,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` when emails are pre-filled or synced from identity + /// providers and should remain read-only. + bool? enabled, + + /// Set to `false` to hide the field while continuing to supply values for + /// federated or system-managed email attributes. + bool visible = true, }) => _SignUpTextField( key: key ?? keyEmailSignUpFormField, titleKey: InputResolverKey.emailTitle, @@ -130,6 +190,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a familyName component. @@ -139,6 +201,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` when last names are sourced from another system and + /// should not be edited by the user. + bool? enabled, + + /// Set to `false` to hide the field while the app populates the value for + /// legacy or backend-only requirements. + bool visible = true, }) => _SignUpTextField( key: key ?? keyFamilyNameSignUpFormField, titleKey: InputResolverKey.familyNameTitle, @@ -148,6 +218,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a gender component. @@ -157,6 +229,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` when gender is pulled from an external profile and + /// should stay read-only in the form. + bool? enabled, + + /// Set to `false` to hide the field while continuing to update backend + /// attributes without user interaction. + bool visible = true, }) => _SignUpTextField( key: key ?? keyGenderSignUpFormField, titleKey: InputResolverKey.genderTitle, @@ -166,6 +246,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a givenName component. @@ -175,6 +257,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` when first names are sourced from a profile service and + /// should stay read-only. + bool? enabled, + + /// Set to `false` to hide the field while still syncing attributes that + /// are set elsewhere. + bool visible = true, }) => _SignUpTextField( key: key ?? keyGivenNameSignUpFormField, titleKey: InputResolverKey.givenNameTitle, @@ -184,6 +274,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a middleName component. @@ -193,6 +285,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` when middle names should reflect external records and + /// must remain read-only. + bool? enabled, + + /// Set to `false` to hide the field while keeping system-provided values + /// in sync. + bool visible = true, }) => _SignUpTextField( key: key ?? keyMiddleNameSignUpFormField, titleKey: InputResolverKey.middleNameTitle, @@ -202,6 +302,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a name component. @@ -211,6 +313,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` when display names are computed or imported and should + /// not be edited manually. + bool? enabled, + + /// Set to `false` to hide the field while still satisfying backend + /// requirements for a name attribute. + bool visible = true, }) => _SignUpTextField( key: key ?? keyNameSignUpFormField, titleKey: InputResolverKey.nameTitle, @@ -220,6 +330,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a nickname component. @@ -229,6 +341,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` when nicknames are generated automatically and the user + /// should not edit them. + bool? enabled, + + /// Set to `false` to hide nickname inputs while still populating values + /// behind the scenes. + bool visible = true, }) => _SignUpTextField( key: key ?? keyNicknameSignUpFormField, titleKey: InputResolverKey.nicknameTitle, @@ -238,6 +358,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a phoneNumber component. @@ -247,6 +369,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` to keep the phone field read-only when values are + /// imported (e.g., from contacts or device settings). + bool? enabled, + + /// Set to `false` to hide the phone field while still syncing values for + /// legacy Cognito setups that require it. + bool visible = true, }) => _SignUpPhoneField( key: key ?? keyPhoneNumberSignUpFormField, titleKey: InputResolverKey.phoneNumberTitle, @@ -256,6 +386,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a preferredUsername component. @@ -265,6 +397,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` when preferred usernames are handled automatically and + /// should not be altered by the user. + bool? enabled, + + /// Set to `false` to hide the preferred username input while keeping the + /// backing attribute synchronized. + bool visible = true, }) => _SignUpTextField( key: key ?? keyPreferredUsernameSignUpFormField, titleKey: InputResolverKey.preferredUsernameTitle, @@ -274,6 +414,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a custom attribute component. @@ -286,6 +428,14 @@ abstract class SignUpFormField bool? required, Iterable? autofillHints, TextEditingController? controller, + + /// Provide `false` when the custom attribute should be supplied by the app + /// rather than the end user (e.g., tokens or IDs). + bool? enabled, + + /// Set to `false` to hide the field while still letting the app populate + /// Cognito attributes that users should not see. + bool visible = true, }) => _SignUpTextField( key: key, title: title, @@ -296,6 +446,8 @@ abstract class SignUpFormField required: required, autofillHints: autofillHints, controller: controller, + enabled: enabled, + visible: visible, ); /// Custom Cognito attribute key. @@ -482,6 +634,8 @@ class _SignUpTextField extends SignUpFormField { super.validator, super.required, super.autofillHints, + super.enabled, + super.visible, this.controller, }) : super._(customAttributeKey: attributeKey); @@ -676,6 +830,8 @@ class _SignUpUsernameField extends SignUpFormField { super.key, super.validator, super.autofillHints, + super.enabled, + super.visible, this.controller, }) : super._( field: SignUpField.username, @@ -714,6 +870,8 @@ class _SignUpPhoneField extends SignUpFormField { super.validator, super.required, super.autofillHints, + super.enabled, + super.visible, this.controller, }) : super._(customAttributeKey: attributeKey); @@ -774,6 +932,8 @@ class _SignUpDateField extends SignUpFormField { super.validator, super.required, super.autofillHints, + super.enabled, + super.visible, }) : super._(customAttributeKey: attributeKey); @override diff --git a/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart b/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart index 54a572d64c..fe90708876 100644 --- a/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart +++ b/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart @@ -6,8 +6,8 @@ import 'package:amplify_authenticator/amplify_authenticator.dart'; import 'package:amplify_authenticator/src/keys.dart'; import 'package:amplify_authenticator/src/state/inherited_authenticator_state.dart'; import 'package:amplify_authenticator_test/amplify_authenticator_test.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -669,6 +669,87 @@ void main() { }, ); + testWidgets( + 'SignUpFormField.username respects enabled flag and still syncs controller', + (tester) async { + final usernameController = AuthenticatorTextFieldController( + text: 'prefill-user', + ); + addTearDown(usernameController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username( + controller: usernameController, + enabled: false, + ), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final fieldFinder = find.byKey(keyUsernameSignUpFormField); + final textField = tester.widget(fieldFinder); + expect(textField.enabled, isFalse); + + usernameController.text = 'automation-user'; + await tester.pump(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + expect(authState.username, equals('automation-user')); + }, + ); + + testWidgets( + 'Hidden sign up field stays in sync via controller', + (tester) async { + final emailController = AuthenticatorTextFieldController(); + addTearDown(emailController.dispose); + + await tester.pumpWidget( + MockAuthenticatorApp( + initialStep: AuthenticatorStep.signUp, + signUpForm: SignUpForm.custom( + fields: [ + SignUpFormField.username(), + SignUpFormField.password(), + SignUpFormField.passwordConfirmation(), + SignUpFormField.email( + controller: emailController, + visible: false, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final emailFieldFinder = find.byKey(keyEmailSignUpFormField); + expect(emailFieldFinder, findsOneWidget); + expect( + () => tester.getTopLeft(emailFieldFinder), + throwsA(isA()), + ); + + emailController.text = 'hidden@example.com'; + await tester.pump(); + + final signUpContext = tester.element(find.byType(SignUpForm)); + final authState = InheritedAuthenticatorState.of(signUpContext); + expect( + authState.getAttribute(CognitoUserAttributeKey.email), + equals('hidden@example.com'), + ); + }, + ); + testWidgets( 'SignInFormField.password controller handles special characters correctly', (tester) async {