From 7c04482d7f3c7f27ffa95f8c3a466bfe286dde0d Mon Sep 17 00:00:00 2001 From: Umair Adil Date: Sat, 31 Jan 2026 19:08:37 +0500 Subject: [PATCH] feat: Add semantics support for PIN code fields - Added `Semantics` widget to `PinCodeTextField` to wrap individual pin cells. - Implemented unique `identifier` for each cell ("otp_digit_$index") to enable Maestro and other UI testing tools to find specific fields. - Added `onTap` handler to `Semantics` to ensures focus is correctly shifted to the field when interacted with via accessibility identifiers. - Added `maestro_test.yaml` for automated PIN entry verification. --- example/android/app/build.gradle | 15 +- example/android/build.gradle | 17 +- .../gradle/wrapper/gradle-wrapper.properties | 4 +- example/android/settings.gradle | 30 +++- lib/src/pin_code_fields.dart | 147 +++++++++--------- maestro_test.yaml | 20 +++ 6 files changed, 131 insertions(+), 102 deletions(-) create mode 100644 maestro_test.yaml diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 5fe3c929..00051d7b 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -21,11 +27,10 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + android { + namespace = "com.example.example" compileSdkVersion flutter.compileSdkVersion compileOptions { @@ -63,6 +68,4 @@ flutter { source '../..' } -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} + diff --git a/example/android/build.gradle b/example/android/build.gradle index 4256f917..ad89b89e 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.6.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() @@ -26,6 +13,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register("clean", Delete) { + delete rootProject.layout.buildDirectory } diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index bc6a58af..bb6c1919 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 44e62bcf..d6b68afa 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.6.1" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false +} + +include ':app' \ No newline at end of file diff --git a/lib/src/pin_code_fields.dart b/lib/src/pin_code_fields.dart index 138d0ce0..8eca7fc5 100644 --- a/lib/src/pin_code_fields.dart +++ b/lib/src/pin_code_fields.dart @@ -588,7 +588,7 @@ class _PinCodeTextFieldState extends State } Widget _renderPinField({ - @required int? index, + int? index, }) { assert(index != null); @@ -864,25 +864,24 @@ class _PinCodeTextFieldState extends State if (data?.text?.isNotEmpty ?? false) { if (widget.beforeTextPaste != null) { if (widget.beforeTextPaste!(data!.text)) { - widget.showPasteConfirmationDialog ? _showPasteDialog(data.text!) : _paste(data.text!); + widget.showPasteConfirmationDialog + ? _showPasteDialog(data.text!) + : _paste(data.text!); } } else { - widget.showPasteConfirmationDialog ? _showPasteDialog(data!.text!) : _paste(data!.text!); + widget.showPasteConfirmationDialog + ? _showPasteDialog(data!.text!) + : _paste(data!.text!); } } } - } else { - _showPasteDialog(data!.text!); - } - } - } : null, child: Row( mainAxisAlignment: widget.mainAxisAlignment, children: _generateFields(), ), ), - ), + ) ], ), ), @@ -897,69 +896,75 @@ class _PinCodeTextFieldState extends State var result = []; for (int i = 0; i < widget.length; i++) { result.add( - Container( - padding: _pinTheme.fieldOuterPadding, - child: AnimatedContainer( - curve: widget.animationCurve, - duration: widget.animationDuration, - width: _pinTheme.fieldWidth, - height: _pinTheme.fieldHeight, - decoration: BoxDecoration( - color: widget.enableActiveFill - ? _getFillColorFromIndex(i) - : Colors.transparent, - boxShadow: (_pinTheme.activeBoxShadows != null || - _pinTheme.inActiveBoxShadows != null) - ? _getBoxShadowFromIndex(i) - : widget.boxShadows, - shape: _pinTheme.shape == PinCodeFieldShape.circle - ? BoxShape.circle - : BoxShape.rectangle, - borderRadius: borderRadius, - border: _pinTheme.shape == PinCodeFieldShape.underline - ? Border( - bottom: BorderSide( - color: _getColorFromIndex(i), - width: _getBorderWidthForIndex(i), - ), - ) - : Border.all( - color: _getColorFromIndex(i), - width: _getBorderWidthForIndex(i), - ), - ), - child: Center( - child: AnimatedSwitcher( - switchInCurve: widget.animationCurve, - switchOutCurve: widget.animationCurve, + Semantics( + identifier: "otp_digit_$i", + onTap: () { + if (widget.onTap != null) widget.onTap!(); + _onFocus(); + }, + child: Container( + padding: _pinTheme.fieldOuterPadding, + child: AnimatedContainer( + curve: widget.animationCurve, duration: widget.animationDuration, - transitionBuilder: (child, animation) { - if (widget.animationType == AnimationType.scale) { - return ScaleTransition( - scale: animation, - child: child, - ); - } else if (widget.animationType == AnimationType.fade) { - return FadeTransition( - opacity: animation, - child: child, - ); - } else if (widget.animationType == AnimationType.none) { - return child; - } else { - return SlideTransition( - position: Tween( - begin: const Offset(0, .5), - end: Offset.zero, - ).animate(animation), - child: child, - ); - } - }, - child: buildChild(i), - ), - ), - )), + width: _pinTheme.fieldWidth, + height: _pinTheme.fieldHeight, + decoration: BoxDecoration( + color: widget.enableActiveFill + ? _getFillColorFromIndex(i) + : Colors.transparent, + boxShadow: (_pinTheme.activeBoxShadows != null || + _pinTheme.inActiveBoxShadows != null) + ? _getBoxShadowFromIndex(i) + : widget.boxShadows, + shape: _pinTheme.shape == PinCodeFieldShape.circle + ? BoxShape.circle + : BoxShape.rectangle, + borderRadius: borderRadius, + border: _pinTheme.shape == PinCodeFieldShape.underline + ? Border( + bottom: BorderSide( + color: _getColorFromIndex(i), + width: _getBorderWidthForIndex(i), + ), + ) + : Border.all( + color: _getColorFromIndex(i), + width: _getBorderWidthForIndex(i), + ), + ), + child: Center( + child: AnimatedSwitcher( + switchInCurve: widget.animationCurve, + switchOutCurve: widget.animationCurve, + duration: widget.animationDuration, + transitionBuilder: (child, animation) { + if (widget.animationType == AnimationType.scale) { + return ScaleTransition( + scale: animation, + child: child, + ); + } else if (widget.animationType == AnimationType.fade) { + return FadeTransition( + opacity: animation, + child: child, + ); + } else if (widget.animationType == AnimationType.none) { + return child; + } else { + return SlideTransition( + position: Tween( + begin: const Offset(0, .5), + end: Offset.zero, + ).animate(animation), + child: child, + ); + } + }, + child: buildChild(i), + ), + ), + ))), ); if (widget.separatorBuilder != null && i != widget.length - 1) { result.add(widget.separatorBuilder!(context, i)); diff --git a/maestro_test.yaml b/maestro_test.yaml new file mode 100644 index 00000000..9334764d --- /dev/null +++ b/maestro_test.yaml @@ -0,0 +1,20 @@ +appId: com.example.pin_code_fields_example +--- +- tapOn: + id: otp_digit_0 +- inputText: 1 +- tapOn: + id: otp_digit_1 +- inputText: 2 +- tapOn: + id: otp_digit_2 +- inputText: 3 +- tapOn: + id: otp_digit_3 +- inputText: 4 +- tapOn: + id: otp_digit_4 +- inputText: 5 +- tapOn: + id: otp_digit_5 +- inputText: 6 \ No newline at end of file