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
* 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

View file

@ -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,

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_login/flutter_login.dart";
import "package:flutter_login/src/widgets/custom_semantics.dart";
class EmailPasswordLoginForm extends StatefulWidget {
/// Constructs an [EmailPasswordLoginForm] widget.
@ -92,7 +93,9 @@ class _EmailPasswordLoginFormState extends State<EmailPasswordLoginForm> {
Widget build(BuildContext context) {
var options = widget.options;
var emailTextFormField = TextFormField(
var emailTextFormField = CustomSemantics(
identifier: options.accessibilityIdentifiers.emailTextFieldIdentifier,
child: TextFormField(
autofillHints: const [
AutofillHints.email,
AutofillHints.username,
@ -105,9 +108,12 @@ class _EmailPasswordLoginFormState extends State<EmailPasswordLoginForm> {
textInputAction: TextInputAction.next,
style: options.emailTextStyle,
decoration: options.emailDecoration,
),
);
var passwordTextFormField = TextFormField(
var passwordTextFormField = CustomSemantics(
identifier: options.accessibilityIdentifiers.passwordTextFieldIdentifier,
child: TextFormField(
autofillHints: const [
AutofillHints.password,
],
@ -136,11 +142,15 @@ class _EmailPasswordLoginFormState extends State<EmailPasswordLoginForm> {
)
: null,
),
),
);
var forgotPasswordButton = widget.onForgotPassword != null
? Align(
alignment: Alignment.topRight,
child: CustomSemantics(
identifier: options
.accessibilityIdentifiers.forgotPasswordButtonIdentifier,
child: options.forgotPasswordButtonBuilder(
context,
() => widget.onForgotPassword?.call(_currentEmail, context),
@ -148,12 +158,15 @@ class _EmailPasswordLoginFormState extends State<EmailPasswordLoginForm> {
() {},
options,
),
),
)
: const SizedBox(height: 16);
var loginButton = AnimatedBuilder(
animation: _formValid,
builder: (context, _) => options.loginButtonBuilder(
builder: (context, _) => CustomSemantics(
identifier: options.accessibilityIdentifiers.loginButtonIdentifier,
child: options.loginButtonBuilder(
context,
_handleLogin,
!_formValid.value,
@ -162,9 +175,12 @@ class _EmailPasswordLoginFormState extends State<EmailPasswordLoginForm> {
},
options,
),
),
);
var registerButton = options.registrationButtonBuilder(
var registrationButton = CustomSemantics(
identifier: options.accessibilityIdentifiers.registrationButtonIdentifier,
child: options.registrationButtonBuilder(
context,
() async {
widget.onRegister?.call(
@ -176,6 +192,7 @@ class _EmailPasswordLoginFormState extends State<EmailPasswordLoginForm> {
false,
() {},
options,
),
);
return Scaffold(
@ -220,7 +237,7 @@ class _EmailPasswordLoginFormState extends State<EmailPasswordLoginForm> {
],
loginButton,
if (widget.onRegister != null) ...[
registerButton,
registrationButton,
],
if (options.spacers.spacerAfterButton != null) ...[
Spacer(flex: options.spacers.spacerAfterButton!),

View file

@ -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,7 +136,10 @@ class _ForgotPasswordFormState extends State<ForgotPasswordForm> {
child: Align(
alignment: Alignment.center,
child: options.emailInputContainerBuilder(
TextFormField(
CustomSemantics(
identifier: options.accessibilityIdentifiers
.emailTextFieldIdentifier,
child: TextFormField(
autofillHints: const [AutofillHints.email],
textAlign:
options.emailTextAlign ?? TextAlign.start,
@ -155,6 +159,7 @@ class _ForgotPasswordFormState extends State<ForgotPasswordForm> {
),
),
),
),
if (options.forgotPasswordSpacerOptions.spacerBeforeButton !=
null) ...[
Spacer(
@ -165,7 +170,11 @@ class _ForgotPasswordFormState extends State<ForgotPasswordForm> {
AnimatedBuilder(
animation: _formValid,
builder: (context, snapshot) => Align(
child: widget.options.requestForgotPasswordButtonBuilder(
child: CustomSemantics(
identifier: options.accessibilityIdentifiers
.requestForgotPasswordButtonIdentifier,
child:
widget.options.requestForgotPasswordButtonBuilder(
context,
() async {
_formKey.currentState?.validate();
@ -181,6 +190,7 @@ class _ForgotPasswordFormState extends State<ForgotPasswordForm> {
),
),
),
),
if (options.forgotPasswordSpacerOptions.spacerAfterButton !=
null) ...[
Spacer(