diff --git a/packages/authenticator/amplify_authenticator/CHANGELOG.md b/packages/authenticator/amplify_authenticator/CHANGELOG.md index f126025394..a7383f9456 100644 --- a/packages/authenticator/amplify_authenticator/CHANGELOG.md +++ b/packages/authenticator/amplify_authenticator/CHANGELOG.md @@ -1,3 +1,15 @@ +## 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 +- 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 ### 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 3f85c897ef..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'; 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..ed2cdb4c12 --- /dev/null +++ b/packages/authenticator/amplify_authenticator/lib/src/controllers/authenticator_text_field_controller.dart @@ -0,0 +1,15 @@ +// 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(super.value) : super.fromValue(); +} 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..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< @@ -11,26 +12,156 @@ mixin AuthenticatorTextField< T extends AuthenticatorFormField > on AuthenticatorFormFieldState { + TextEditingController? _effectiveController; + bool _isApplyingControllerText = false; + String? _lastSyncedControllerValue; + String? _pendingControllerText; + bool _controllerUpdateScheduled = false; + + @protected + TextEditingController? get textController => null; + + void _maybeUpdateEffectiveController() { + final controller = textController; + if (identical(controller, _effectiveController)) { + return; + } + _effectiveController?.removeListener(_handleControllerChanged); + _effectiveController = controller; + _lastSyncedControllerValue = null; + _pendingControllerText = null; + if (_effectiveController != null) { + _effectiveController!.addListener(_handleControllerChanged); + } + } + + void _handleControllerChanged() { + final controller = _effectiveController; + if (controller == null || _isApplyingControllerText) { + return; + } + final text = controller.text; + if (text == _lastSyncedControllerValue && _pendingControllerText == null) { + return; + } + _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}) { + final controller = _effectiveController; + if (controller == null) { + return; + } + final target = initialValue ?? ''; + 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; + controller.value = controller.value.copyWith( + text: target, + selection: TextSelection.collapsed(offset: target.length), + composing: TextRange.empty, + ); + _lastSyncedControllerValue = target; + _pendingControllerText = null; + _isApplyingControllerText = false; + } + + @override + void initState() { + super.initState(); + _maybeUpdateEffectiveController(); + // Skip sync in initState since 'state' isn't available yet + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _maybeUpdateEffectiveController(); + // 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 + void didUpdateWidget(covariant T oldWidget) { + super.didUpdateWidget(oldWidget); + _maybeUpdateEffectiveController(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _syncControllerText(force: true); + } + }); + } + + @override + void dispose() { + _effectiveController?.removeListener(_handleControllerChanged); + _effectiveController = null; + _pendingControllerText = null; + _controllerUpdateScheduled = false; + 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; + // Don't sync during build + final shouldHandleChangeImmediately = _effectiveController == null; 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, + 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 0db8988714..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,12 +9,162 @@ 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, T extends AuthenticatorFormField > on AuthenticatorFormFieldState { + TextEditingController? _controller; + UsernameType? _controllerUsernameType; + bool _applyingControllerText = false; + String? _lastSyncedText; + String? _pendingControllerText; + bool _controllerUpdateScheduled = false; + + @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; + _pendingControllerText = 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 && _pendingControllerText == null) { + return; + } + + _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}) { + if (_controller == null || + selectedUsernameType == UsernameType.phoneNumber) { + return; + } + + final target = initialValue?.username ?? ''; + 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; + } + + _applyingControllerText = true; + _controller!.value = _controller!.value.copyWith( + text: target, + selection: TextSelection.collapsed(offset: target.length), + composing: TextRange.empty, + ); + _lastSyncedText = target; + _pendingControllerText = null; + _applyingControllerText = false; + } + + @override + void initState() { + super.initState(); + _updateController(); + // Skip sync in initState since 'state' isn't available yet + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateController(); + // 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 + void didUpdateWidget(covariant T oldWidget) { + super.didUpdateWidget(oldWidget); + _updateController(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _syncControllerText(force: true); + } + }); + } + + @override + void dispose() { + _controller?.removeListener(_handleControllerChanged); + _controller = null; + _pendingControllerText = null; + _controllerUpdateScheduled = false; + super.dispose(); + } + @override UsernameInput? get initialValue { return UsernameInput(type: selectedUsernameType, username: state.username); @@ -197,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), ); } @@ -214,20 +364,32 @@ mixin AuthenticatorUsernameField< return AuthenticatorPhoneField( field: widget.field, requiredOverride: true, - onChanged: onChanged, + onChanged: handleChanged, validator: validator, enabled: enabled, errorMaxLines: errorMaxLines, initialValue: state.username, autofillHints: autofillHints, + controller: textController, ); } + + _updateController(); + // Don't sync during build + + final controllerInUse = _controller != null; + 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, + onChanged: (username) { + if (!controllerInUse) { + handleChanged(username); + } + }, autocorrect: false, decoration: InputDecoration( prefixIcon: prefix, 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..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 @@ -96,6 +98,20 @@ 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 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`. @@ -125,7 +141,12 @@ abstract class AuthenticatorFormField< ..add(DiagnosticsProperty('required', required)) ..add(DiagnosticsProperty('requiredOverride', requiredOverride)) ..add(EnumProperty('usernameType', usernameType)) - ..add(IterableProperty('autofillHints', autofillHints)); + ..add(IterableProperty('autofillHints', autofillHints)) + ..add( + DiagnosticsProperty('controller', controller), + ) + ..add(DiagnosticsProperty('enabledOverride', enabledOverride)) + ..add(DiagnosticsProperty('visible', visible)); } } @@ -166,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; @@ -264,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: [ @@ -280,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_in_form_field.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form_fields/confirm_sign_in_form_field.dart index 8b24a36ace..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 @@ -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,11 +521,23 @@ class _ConfirmSignInPhoneField extends ConfirmSignInFormField { super.validator, super.required, super.autofillHints, + this.controller, }) : super._(customAttributeKey: attributeKey); + @override + final TextEditingController? controller; + @override _ConfirmSignInPhoneFieldState createState() => _ConfirmSignInPhoneFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _ConfirmSignInPhoneFieldState extends _ConfirmSignInTextFieldState @@ -546,10 +588,22 @@ class _ConfirmSignInTextField extends ConfirmSignInFormField { super.validator, super.required, super.autofillHints, + this.controller, }) : super._(customAttributeKey: attributeKey); + @override + final TextEditingController? controller; + @override _ConfirmSignInTextFieldState createState() => _ConfirmSignInTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _ConfirmSignInTextFieldState extends _ConfirmSignInFormFieldState @@ -558,6 +612,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..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 @@ -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 @@ -90,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; @@ -131,14 +139,29 @@ class _ConfirmSignUpTextField extends ConfirmSignUpFormField { super.hintTextKey, super.validator, super.autofillHints, + this.controller, }) : super._(); + @override + final TextEditingController? controller; + @override _ConfirmSignUpTextFieldState createState() => _ConfirmSignUpTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _ConfirmSignUpTextFieldState extends _ConfirmSignUpFormFieldState with AuthenticatorTextField { + @override + TextEditingController? get textController => widget.controller; + @override String? get initialValue { switch (widget.field) { @@ -199,11 +222,23 @@ class _ConfirmSignUpUsernameField super.hintTextKey, super.validator, super.autofillHints, + this.controller, }) : super._(); + @override + final TextEditingController? controller; + @override _ConfirmSignUpUsernameFieldState createState() => _ConfirmSignUpUsernameFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _ConfirmSignUpUsernameFieldState @@ -213,6 +248,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..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. @@ -21,21 +37,11 @@ class EmailSetupFormField super.hintText, super.validator, super.autofillHints, + this.controller, }) : super._(); - /// Creates an email FormField for the email setup step. - const EmailSetupFormField.email({ - Key? key, - FormFieldValidator? validator, - Iterable? autofillHints, - }) : this._( - key: key ?? keyEmailSetupFormField, - field: EmailSetupField.email, - titleKey: InputResolverKey.emailTitle, - hintTextKey: InputResolverKey.emailHint, - validator: validator, - autofillHints: autofillHints, - ); + @override + final TextEditingController? controller; @override bool get required => true; @@ -53,6 +59,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..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,33 +9,24 @@ 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, - }) : super._(); - 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,19 +34,38 @@ 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, ); + /// {@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; @@ -76,6 +86,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..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 @@ -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,29 @@ class _SignInTextField extends SignInFormField { super.hintTextKey, super.validator, super.autofillHints, + this.controller, }) : super._(); + @override + final TextEditingController? controller; + @override _SignInTextFieldState createState() => _SignInTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _SignInTextFieldState extends _SignInFormFieldState with AuthenticatorTextField { + @override + TextEditingController? get textController => widget.controller; + @override String? get initialValue { switch (widget.field) { @@ -182,17 +201,35 @@ class _SignInTextFieldState extends _SignInFormFieldState } class _SignInUsernameField extends SignInFormField { - const _SignInUsernameField({Key? key, super.validator, super.autofillHints}) - : super._( - key: key ?? keyUsernameSignInFormField, - titleKey: InputResolverKey.usernameTitle, - hintTextKey: InputResolverKey.usernameHint, - field: SignInField.username, - ); + const _SignInUsernameField({ + Key? key, + super.validator, + super.autofillHints, + this.controller, + }) : super._( + key: key ?? keyUsernameSignInFormField, + titleKey: InputResolverKey.usernameTitle, + hintTextKey: InputResolverKey.usernameHint, + field: SignInField.username, + ); + + @override + final TextEditingController? controller; @override _SignInUsernameFieldState createState() => _SignInUsernameFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } 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..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. @@ -40,10 +42,22 @@ abstract class SignUpFormField Key? key, 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. @@ -51,6 +65,15 @@ abstract class SignUpFormField Key? key, 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, @@ -58,6 +81,9 @@ abstract class SignUpFormField field: SignUpField.password, validator: validator, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a passwordConfirmation component. @@ -65,6 +91,15 @@ abstract class SignUpFormField Key? key, 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, @@ -72,6 +107,9 @@ abstract class SignUpFormField field: SignUpField.passwordConfirmation, validator: validator, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates an address component. @@ -80,6 +118,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -88,6 +135,9 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a birthdate component. @@ -96,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, @@ -104,6 +162,8 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + enabled: enabled, + visible: visible, ); /// Creates an email component. @@ -112,6 +172,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -120,6 +189,9 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a familyName component. @@ -128,6 +200,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -136,6 +217,9 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a gender component. @@ -144,6 +228,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -152,6 +245,9 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a givenName component. @@ -160,6 +256,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -168,6 +273,9 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a middleName component. @@ -176,6 +284,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -184,6 +301,9 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a name component. @@ -192,6 +312,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -200,6 +329,9 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a nickname component. @@ -208,6 +340,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -216,6 +357,9 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a phoneNumber component. @@ -224,6 +368,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -232,6 +385,9 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a preferredUsername component. @@ -240,6 +396,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -248,6 +413,9 @@ abstract class SignUpFormField validator: validator, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Creates a custom attribute component. @@ -259,6 +427,15 @@ abstract class SignUpFormField FormFieldValidator? validator, 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, @@ -268,6 +445,9 @@ abstract class SignUpFormField attributeKey: attributeKey, required: required, autofillHints: autofillHints, + controller: controller, + enabled: enabled, + visible: visible, ); /// Custom Cognito attribute key. @@ -454,14 +634,31 @@ class _SignUpTextField extends SignUpFormField { super.validator, super.required, super.autofillHints, + super.enabled, + super.visible, + this.controller, }) : super._(customAttributeKey: attributeKey); + @override + final TextEditingController? controller; + @override _SignUpTextFieldState createState() => _SignUpTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _SignUpTextFieldState extends _SignUpFormFieldState with AuthenticatorTextField { + @override + TextEditingController? get textController => widget.controller; + @override String? get initialValue { switch (widget.field) { @@ -629,19 +826,39 @@ class _SignUpTextFieldState extends _SignUpFormFieldState } class _SignUpUsernameField extends SignUpFormField { - const _SignUpUsernameField({super.key, super.validator, super.autofillHints}) - : super._( - field: SignUpField.username, - titleKey: InputResolverKey.usernameTitle, - hintTextKey: InputResolverKey.usernameHint, - ); + const _SignUpUsernameField({ + super.key, + super.validator, + super.autofillHints, + super.enabled, + super.visible, + this.controller, + }) : super._( + field: SignUpField.username, + titleKey: InputResolverKey.usernameTitle, + hintTextKey: InputResolverKey.usernameHint, + ); + + @override + final TextEditingController? controller; @override _SignUpUsernameFieldState createState() => _SignUpUsernameFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _SignUpUsernameFieldState extends _SignUpFormFieldState - with AuthenticatorUsernameField {} + with AuthenticatorUsernameField { + @override + TextEditingController? get textController => widget.controller; +} class _SignUpPhoneField extends SignUpFormField { const _SignUpPhoneField({ @@ -653,10 +870,24 @@ class _SignUpPhoneField extends SignUpFormField { super.validator, super.required, super.autofillHints, + super.enabled, + super.visible, + this.controller, }) : super._(customAttributeKey: attributeKey); + @override + final TextEditingController? controller; + @override _SignUpPhoneFieldState createState() => _SignUpPhoneFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } class _SignUpPhoneFieldState extends _SignUpTextFieldState @@ -701,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/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..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 @@ -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,29 @@ class _VerifyUserTextField extends VerifyUserFormField { super.hintTextKey, super.validator, super.autofillHints, + this.controller, }) : super._(); + @override + final TextEditingController? controller; + @override _VerifyUserTextFieldState createState() => _VerifyUserTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + } } 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..fe90708876 --- /dev/null +++ b/packages/authenticator/amplify_authenticator/test/form_field_controller_test.dart @@ -0,0 +1,792 @@ +// 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/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_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')); + }); + + 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('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 { + 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'), + ); + }); + + 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( + '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 { + 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('')); + }, + ); + }); +}