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
This commit is contained in:
Freek van de Ven 2025-01-28 16:35:59 +01:00
parent 6ced63a602
commit 40d4c6996f
5 changed files with 213 additions and 94 deletions

View file

@ -1,5 +1,6 @@
## 7.2.0 ## 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 * Upgraded pinput to 5.0.1 from 2.3.0
* Updated flutter_iconica_analysis to 7.0.0 with new rules * Updated flutter_iconica_analysis to 7.0.0 with new rules

View file

@ -53,6 +53,7 @@ class LoginOptions {
formFlexValue: 2, formFlexValue: 2,
), ),
this.translations = const LoginTranslations(), this.translations = const LoginTranslations(),
this.accessibilityIdentifiers = const LoginAccessibilityIdentifiers.empty(),
this.validationService, this.validationService,
this.loginButtonBuilder = _createLoginButton, this.loginButtonBuilder = _createLoginButton,
this.forgotPasswordButtonBuilder = _createForgotPasswordButton, this.forgotPasswordButtonBuilder = _createForgotPasswordButton,
@ -134,6 +135,12 @@ class LoginOptions {
/// Translations for various texts on the login screen. /// Translations for various texts on the login screen.
final LoginTranslations translations; 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. /// The validation service used for validating email and password inputs.
final ValidationService? validationService; final ValidationService? validationService;
@ -159,7 +166,9 @@ class LoginOptions {
final AppBar? forgotPasswordCustomAppBar; final AppBar? forgotPasswordCustomAppBar;
} }
/// Translations for all the texts in the component
class LoginTranslations { class LoginTranslations {
/// Provide your own translations to override the default english translations
const LoginTranslations({ const LoginTranslations({
this.emailEmpty = "Please enter your email address", this.emailEmpty = "Please enter your email address",
this.passwordEmpty = "Please enter your password", this.passwordEmpty = "Please enter your password",
@ -179,6 +188,52 @@ class LoginTranslations {
final String registrationButton; 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( Widget _createEmailInputContainer(Widget child) => Padding(
padding: const EdgeInsets.only(bottom: 15), padding: const EdgeInsets.only(bottom: 15),
child: child, child: child,

View file

@ -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,
);
}

View file

@ -2,6 +2,7 @@ import "dart:async";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_login/flutter_login.dart"; import "package:flutter_login/flutter_login.dart";
import "package:flutter_login/src/widgets/custom_semantics.dart";
class EmailPasswordLoginForm extends StatefulWidget { class EmailPasswordLoginForm extends StatefulWidget {
/// Constructs an [EmailPasswordLoginForm] widget. /// Constructs an [EmailPasswordLoginForm] widget.
@ -92,90 +93,106 @@ class _EmailPasswordLoginFormState extends State<EmailPasswordLoginForm> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var options = widget.options; var options = widget.options;
var emailTextFormField = TextFormField( var emailTextFormField = CustomSemantics(
autofillHints: const [ identifier: options.accessibilityIdentifiers.emailTextFieldIdentifier,
AutofillHints.email, child: TextFormField(
AutofillHints.username, autofillHints: const [
], AutofillHints.email,
textAlign: options.emailTextAlign ?? TextAlign.start, AutofillHints.username,
onChanged: _updateCurrentEmail, ],
validator: widget.options.validations.validateEmail, textAlign: options.emailTextAlign ?? TextAlign.start,
initialValue: options.initialEmail, onChanged: _updateCurrentEmail,
keyboardType: TextInputType.emailAddress, validator: widget.options.validations.validateEmail,
textInputAction: TextInputAction.next, initialValue: options.initialEmail,
style: options.emailTextStyle, keyboardType: TextInputType.emailAddress,
decoration: options.emailDecoration, textInputAction: TextInputAction.next,
style: options.emailTextStyle,
decoration: options.emailDecoration,
),
); );
var passwordTextFormField = TextFormField( var passwordTextFormField = CustomSemantics(
autofillHints: const [ identifier: options.accessibilityIdentifiers.passwordTextFieldIdentifier,
AutofillHints.password, child: TextFormField(
], autofillHints: const [
textAlign: options.passwordTextAlign ?? TextAlign.start, AutofillHints.password,
obscureText: _obscurePassword, ],
onChanged: _updateCurrentPassword, textAlign: options.passwordTextAlign ?? TextAlign.start,
validator: widget.options.validations.validatePassword, obscureText: _obscurePassword,
initialValue: options.initialPassword, onChanged: _updateCurrentPassword,
keyboardType: TextInputType.visiblePassword, validator: widget.options.validations.validatePassword,
textInputAction: TextInputAction.done, initialValue: options.initialPassword,
style: options.passwordTextStyle, keyboardType: TextInputType.visiblePassword,
onFieldSubmitted: (_) async => _handleLogin(), textInputAction: TextInputAction.done,
decoration: options.passwordDecoration.copyWith( style: options.passwordTextStyle,
suffixIcon: options.showObscurePassword onFieldSubmitted: (_) async => _handleLogin(),
? IconButton( decoration: options.passwordDecoration.copyWith(
padding: options.suffixIconPadding, suffixIcon: options.showObscurePassword
onPressed: () { ? IconButton(
setState(() { padding: options.suffixIconPadding,
_obscurePassword = !_obscurePassword; onPressed: () {
}); setState(() {
}, _obscurePassword = !_obscurePassword;
icon: Icon( });
_obscurePassword ? Icons.visibility : Icons.visibility_off, },
size: options.suffixIconSize, icon: Icon(
), _obscurePassword ? Icons.visibility : Icons.visibility_off,
) size: options.suffixIconSize,
: null, ),
)
: null,
),
), ),
); );
var forgotPasswordButton = widget.onForgotPassword != null var forgotPasswordButton = widget.onForgotPassword != null
? Align( ? Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,
child: options.forgotPasswordButtonBuilder( child: CustomSemantics(
context, identifier: options
() => widget.onForgotPassword?.call(_currentEmail, context), .accessibilityIdentifiers.forgotPasswordButtonIdentifier,
false, child: options.forgotPasswordButtonBuilder(
() {}, context,
options, () => widget.onForgotPassword?.call(_currentEmail, context),
false,
() {},
options,
),
), ),
) )
: const SizedBox(height: 16); : const SizedBox(height: 16);
var loginButton = AnimatedBuilder( var loginButton = AnimatedBuilder(
animation: _formValid, animation: _formValid,
builder: (context, _) => options.loginButtonBuilder( builder: (context, _) => CustomSemantics(
context, identifier: options.accessibilityIdentifiers.loginButtonIdentifier,
_handleLogin, child: options.loginButtonBuilder(
!_formValid.value, context,
() { _handleLogin,
_formKey.currentState?.validate(); !_formValid.value,
}, () {
options, _formKey.currentState?.validate();
},
options,
),
), ),
); );
var registerButton = options.registrationButtonBuilder( var registrationButton = CustomSemantics(
context, identifier: options.accessibilityIdentifiers.registrationButtonIdentifier,
() async { child: options.registrationButtonBuilder(
widget.onRegister?.call( context,
_currentEmail, () async {
_currentPassword, widget.onRegister?.call(
context, _currentEmail,
); _currentPassword,
}, context,
false, );
() {}, },
options, false,
() {},
options,
),
); );
return Scaffold( return Scaffold(
@ -220,7 +237,7 @@ class _EmailPasswordLoginFormState extends State<EmailPasswordLoginForm> {
], ],
loginButton, loginButton,
if (widget.onRegister != null) ...[ if (widget.onRegister != null) ...[
registerButton, registrationButton,
], ],
if (options.spacers.spacerAfterButton != null) ...[ if (options.spacers.spacerAfterButton != null) ...[
Spacer(flex: options.spacers.spacerAfterButton!), Spacer(flex: options.spacers.spacerAfterButton!),

View file

@ -2,6 +2,7 @@ import "dart:async";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_login/flutter_login.dart"; import "package:flutter_login/flutter_login.dart";
import "package:flutter_login/src/widgets/custom_semantics.dart";
class ForgotPasswordForm extends StatefulWidget { class ForgotPasswordForm extends StatefulWidget {
/// Constructs a [ForgotPasswordForm] widget. /// Constructs a [ForgotPasswordForm] widget.
@ -135,19 +136,23 @@ class _ForgotPasswordFormState extends State<ForgotPasswordForm> {
child: Align( child: Align(
alignment: Alignment.center, alignment: Alignment.center,
child: options.emailInputContainerBuilder( child: options.emailInputContainerBuilder(
TextFormField( CustomSemantics(
autofillHints: const [AutofillHints.email], identifier: options.accessibilityIdentifiers
textAlign: .emailTextFieldIdentifier,
options.emailTextAlign ?? TextAlign.start, child: TextFormField(
focusNode: _focusNode, autofillHints: const [AutofillHints.email],
onChanged: _updateCurrentEmail, textAlign:
validator: options.emailTextAlign ?? TextAlign.start,
widget.options.validations.validateEmail, focusNode: _focusNode,
initialValue: options.initialEmail, onChanged: _updateCurrentEmail,
keyboardType: TextInputType.emailAddress, validator:
textInputAction: TextInputAction.next, widget.options.validations.validateEmail,
style: options.emailTextStyle, initialValue: options.initialEmail,
decoration: options.emailDecoration, keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
style: options.emailTextStyle,
decoration: options.emailDecoration,
),
), ),
), ),
), ),
@ -165,19 +170,24 @@ class _ForgotPasswordFormState extends State<ForgotPasswordForm> {
AnimatedBuilder( AnimatedBuilder(
animation: _formValid, animation: _formValid,
builder: (context, snapshot) => Align( builder: (context, snapshot) => Align(
child: widget.options.requestForgotPasswordButtonBuilder( child: CustomSemantics(
context, identifier: options.accessibilityIdentifiers
() async { .requestForgotPasswordButtonIdentifier,
_formKey.currentState?.validate(); child:
if (_formValid.value) { widget.options.requestForgotPasswordButtonBuilder(
widget.onRequestForgotPassword(_currentEmail); context,
} () async {
}, _formKey.currentState?.validate();
!_formValid.value, if (_formValid.value) {
() { widget.onRequestForgotPassword(_currentEmail);
_formKey.currentState?.validate(); }
}, },
options, !_formValid.value,
() {
_formKey.currentState?.validate();
},
options,
),
), ),
), ),
), ),