From e0b01d7f18b67f7533d161cbbc45087262e90efe Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Tue, 28 Jan 2025 16:35:59 +0100 Subject: [PATCH] feat: add Semantics widget to standard inputs This will add accessibility id and id to the inputfields and buttons so they can be accessed in appium for automated tests --- CHANGELOG.md | 1 + lib/src/config/login_options.dart | 55 ++++++++ lib/src/widgets/custom_semantics.dart | 36 +++++ lib/src/widgets/email_password_login.dart | 153 ++++++++++++---------- lib/src/widgets/forgot_password_form.dart | 62 +++++---- 5 files changed, 213 insertions(+), 94 deletions(-) create mode 100644 lib/src/widgets/custom_semantics.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2725f98..7c71b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## 7.2.0 +* Added CustomSemantics widget that is used to wrap all the inputfields and buttons to make the component accessible for e2e testing. * Upgraded pinput to 5.0.1 from 2.3.0 * Updated flutter_iconica_analysis to 7.0.0 with new rules diff --git a/lib/src/config/login_options.dart b/lib/src/config/login_options.dart index 05b51c3..eddeeff 100644 --- a/lib/src/config/login_options.dart +++ b/lib/src/config/login_options.dart @@ -53,6 +53,7 @@ class LoginOptions { formFlexValue: 2, ), this.translations = const LoginTranslations(), + this.accessibilityIdentifiers = const LoginAccessibilityIdentifiers.empty(), this.validationService, this.loginButtonBuilder = _createLoginButton, this.forgotPasswordButtonBuilder = _createForgotPasswordButton, @@ -134,6 +135,12 @@ class LoginOptions { /// Translations for various texts on the login screen. final LoginTranslations translations; + /// Accessibility identifiers for the standard widgets in the component. + /// The inputfields and buttons have accessibility identifiers and their own + /// container so they are visible in the accessibility tree. + /// This is used for testing purposes. + final LoginAccessibilityIdentifiers accessibilityIdentifiers; + /// The validation service used for validating email and password inputs. final ValidationService? validationService; @@ -159,7 +166,9 @@ class LoginOptions { final AppBar? forgotPasswordCustomAppBar; } +/// Translations for all the texts in the component class LoginTranslations { + /// Provide your own translations to override the default english translations const LoginTranslations({ this.emailEmpty = "Please enter your email address", this.passwordEmpty = "Please enter your password", @@ -179,6 +188,52 @@ class LoginTranslations { final String registrationButton; } +/// Accessibility identifiers for the standard widgets in the component. +class LoginAccessibilityIdentifiers { + /// Default [LoginAccessibilityIdentifiers] constructor where all the + /// identifiers are required. This is to ensure that apps automatically break + /// when new identifiers are added. + const LoginAccessibilityIdentifiers({ + required this.emailTextFieldIdentifier, + required this.passwordTextFieldIdentifier, + required this.loginButtonIdentifier, + required this.forgotPasswordButtonIdentifier, + required this.requestForgotPasswordButtonIdentifier, + required this.registrationButtonIdentifier, + }); + + /// Empty [LoginAccessibilityIdentifiers] constructor where all the + /// identifiers are already set to their default values. You can override all + /// or some of the default values. + const LoginAccessibilityIdentifiers.empty({ + this.emailTextFieldIdentifier = "email_text_field", + this.passwordTextFieldIdentifier = "password_text_field", + this.loginButtonIdentifier = "login_button", + this.forgotPasswordButtonIdentifier = "forgot_password_button", + this.requestForgotPasswordButtonIdentifier = + "request_forgot_password_button", + this.registrationButtonIdentifier = "registration_button", + }); + + /// Identifier for the email text field. + final String emailTextFieldIdentifier; + + /// Identifier for the password text field. + final String passwordTextFieldIdentifier; + + /// Identifier for the login button. + final String loginButtonIdentifier; + + /// Identifier for the forgot password button. + final String forgotPasswordButtonIdentifier; + + /// Identifier for the request forgot password button. + final String requestForgotPasswordButtonIdentifier; + + /// Identifier for the registration button. + final String registrationButtonIdentifier; +} + Widget _createEmailInputContainer(Widget child) => Padding( padding: const EdgeInsets.only(bottom: 15), child: child, diff --git a/lib/src/widgets/custom_semantics.dart b/lib/src/widgets/custom_semantics.dart new file mode 100644 index 0000000..5dea8d0 --- /dev/null +++ b/lib/src/widgets/custom_semantics.dart @@ -0,0 +1,36 @@ +import "dart:io"; + +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; + +/// A wrapper that wraps a widget with a [Semantics] widget. +/// This is used for testing purposes to add a unique identifier to a widget. +/// The [identifier] should be unique +/// [container] is set to true to make sure the widget is always its own +/// accessibility element. +/// [excludeSemantics] is set to false to make sure that the widget can still +/// receive input. +class CustomSemantics extends StatelessWidget { + /// Creates a [CustomSemantics] widget. + /// The [identifier] should be unique for the specific screen. + const CustomSemantics({ + required this.identifier, + required this.child, + super.key, + }); + + /// The widget that should be wrapped with a [Semantics] widget. + final Widget child; + + /// Identifier for the widget that should be unique for the specific screen. + final String identifier; + + @override + Widget build(BuildContext context) => Semantics( + excludeSemantics: false, + container: true, + label: kIsWeb || Platform.isIOS ? null : identifier, + identifier: identifier, + child: child, + ); +} diff --git a/lib/src/widgets/email_password_login.dart b/lib/src/widgets/email_password_login.dart index 0a41f6d..a2cfab5 100644 --- a/lib/src/widgets/email_password_login.dart +++ b/lib/src/widgets/email_password_login.dart @@ -2,6 +2,7 @@ import "dart:async"; import "package:flutter/material.dart"; import "package:flutter_login/flutter_login.dart"; +import "package:flutter_login/src/widgets/custom_semantics.dart"; class EmailPasswordLoginForm extends StatefulWidget { /// Constructs an [EmailPasswordLoginForm] widget. @@ -92,90 +93,106 @@ class _EmailPasswordLoginFormState extends State { Widget build(BuildContext context) { var options = widget.options; - var emailTextFormField = TextFormField( - autofillHints: const [ - AutofillHints.email, - AutofillHints.username, - ], - textAlign: options.emailTextAlign ?? TextAlign.start, - onChanged: _updateCurrentEmail, - validator: widget.options.validations.validateEmail, - initialValue: options.initialEmail, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - style: options.emailTextStyle, - decoration: options.emailDecoration, + var emailTextFormField = CustomSemantics( + identifier: options.accessibilityIdentifiers.emailTextFieldIdentifier, + child: TextFormField( + autofillHints: const [ + AutofillHints.email, + AutofillHints.username, + ], + textAlign: options.emailTextAlign ?? TextAlign.start, + onChanged: _updateCurrentEmail, + validator: widget.options.validations.validateEmail, + initialValue: options.initialEmail, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + style: options.emailTextStyle, + decoration: options.emailDecoration, + ), ); - var passwordTextFormField = TextFormField( - autofillHints: const [ - AutofillHints.password, - ], - textAlign: options.passwordTextAlign ?? TextAlign.start, - obscureText: _obscurePassword, - onChanged: _updateCurrentPassword, - validator: widget.options.validations.validatePassword, - initialValue: options.initialPassword, - keyboardType: TextInputType.visiblePassword, - textInputAction: TextInputAction.done, - style: options.passwordTextStyle, - onFieldSubmitted: (_) async => _handleLogin(), - decoration: options.passwordDecoration.copyWith( - suffixIcon: options.showObscurePassword - ? IconButton( - padding: options.suffixIconPadding, - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - icon: Icon( - _obscurePassword ? Icons.visibility : Icons.visibility_off, - size: options.suffixIconSize, - ), - ) - : null, + var passwordTextFormField = CustomSemantics( + identifier: options.accessibilityIdentifiers.passwordTextFieldIdentifier, + child: TextFormField( + autofillHints: const [ + AutofillHints.password, + ], + textAlign: options.passwordTextAlign ?? TextAlign.start, + obscureText: _obscurePassword, + onChanged: _updateCurrentPassword, + validator: widget.options.validations.validatePassword, + initialValue: options.initialPassword, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + style: options.passwordTextStyle, + onFieldSubmitted: (_) async => _handleLogin(), + decoration: options.passwordDecoration.copyWith( + suffixIcon: options.showObscurePassword + ? IconButton( + padding: options.suffixIconPadding, + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + icon: Icon( + _obscurePassword ? Icons.visibility : Icons.visibility_off, + size: options.suffixIconSize, + ), + ) + : null, + ), ), ); var forgotPasswordButton = widget.onForgotPassword != null ? Align( alignment: Alignment.topRight, - child: options.forgotPasswordButtonBuilder( - context, - () => widget.onForgotPassword?.call(_currentEmail, context), - false, - () {}, - options, + child: CustomSemantics( + identifier: options + .accessibilityIdentifiers.forgotPasswordButtonIdentifier, + child: options.forgotPasswordButtonBuilder( + context, + () => widget.onForgotPassword?.call(_currentEmail, context), + false, + () {}, + options, + ), ), ) : const SizedBox(height: 16); var loginButton = AnimatedBuilder( animation: _formValid, - builder: (context, _) => options.loginButtonBuilder( - context, - _handleLogin, - !_formValid.value, - () { - _formKey.currentState?.validate(); - }, - options, + builder: (context, _) => CustomSemantics( + identifier: options.accessibilityIdentifiers.loginButtonIdentifier, + child: options.loginButtonBuilder( + context, + _handleLogin, + !_formValid.value, + () { + _formKey.currentState?.validate(); + }, + options, + ), ), ); - var registerButton = options.registrationButtonBuilder( - context, - () async { - widget.onRegister?.call( - _currentEmail, - _currentPassword, - context, - ); - }, - false, - () {}, - options, + var registrationButton = CustomSemantics( + identifier: options.accessibilityIdentifiers.registrationButtonIdentifier, + child: options.registrationButtonBuilder( + context, + () async { + widget.onRegister?.call( + _currentEmail, + _currentPassword, + context, + ); + }, + false, + () {}, + options, + ), ); return Scaffold( @@ -220,7 +237,7 @@ class _EmailPasswordLoginFormState extends State { ], loginButton, if (widget.onRegister != null) ...[ - registerButton, + registrationButton, ], if (options.spacers.spacerAfterButton != null) ...[ Spacer(flex: options.spacers.spacerAfterButton!), diff --git a/lib/src/widgets/forgot_password_form.dart b/lib/src/widgets/forgot_password_form.dart index ed9e943..159b354 100644 --- a/lib/src/widgets/forgot_password_form.dart +++ b/lib/src/widgets/forgot_password_form.dart @@ -2,6 +2,7 @@ import "dart:async"; import "package:flutter/material.dart"; import "package:flutter_login/flutter_login.dart"; +import "package:flutter_login/src/widgets/custom_semantics.dart"; class ForgotPasswordForm extends StatefulWidget { /// Constructs a [ForgotPasswordForm] widget. @@ -135,19 +136,23 @@ class _ForgotPasswordFormState extends State { child: Align( alignment: Alignment.center, child: options.emailInputContainerBuilder( - TextFormField( - autofillHints: const [AutofillHints.email], - textAlign: - options.emailTextAlign ?? TextAlign.start, - focusNode: _focusNode, - onChanged: _updateCurrentEmail, - validator: - widget.options.validations.validateEmail, - initialValue: options.initialEmail, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - style: options.emailTextStyle, - decoration: options.emailDecoration, + CustomSemantics( + identifier: options.accessibilityIdentifiers + .emailTextFieldIdentifier, + child: TextFormField( + autofillHints: const [AutofillHints.email], + textAlign: + options.emailTextAlign ?? TextAlign.start, + focusNode: _focusNode, + onChanged: _updateCurrentEmail, + validator: + widget.options.validations.validateEmail, + initialValue: options.initialEmail, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + style: options.emailTextStyle, + decoration: options.emailDecoration, + ), ), ), ), @@ -165,19 +170,24 @@ class _ForgotPasswordFormState extends State { AnimatedBuilder( animation: _formValid, builder: (context, snapshot) => Align( - child: widget.options.requestForgotPasswordButtonBuilder( - context, - () async { - _formKey.currentState?.validate(); - if (_formValid.value) { - widget.onRequestForgotPassword(_currentEmail); - } - }, - !_formValid.value, - () { - _formKey.currentState?.validate(); - }, - options, + child: CustomSemantics( + identifier: options.accessibilityIdentifiers + .requestForgotPasswordButtonIdentifier, + child: + widget.options.requestForgotPasswordButtonBuilder( + context, + () async { + _formKey.currentState?.validate(); + if (_formValid.value) { + widget.onRequestForgotPassword(_currentEmail); + } + }, + !_formValid.value, + () { + _formKey.currentState?.validate(); + }, + options, + ), ), ), ),