From 368afc1e5777285c5ce160d91d1d0824e69cf359 Mon Sep 17 00:00:00 2001 From: pony Date: Mon, 28 Oct 2024 20:42:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Epinput=E5=BA=93=E7=9A=84?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: pony --- README.md | 1 + packages/Flutter_Pinput/.pubignore | 47 + packages/Flutter_Pinput/CHANGELOG.md | 388 +++++++++ packages/Flutter_Pinput/LICENSE | 21 + packages/Flutter_Pinput/MIGRATION.md | 77 ++ packages/Flutter_Pinput/README.md | 320 +++++++ packages/Flutter_Pinput/_config.yml | 1 + packages/Flutter_Pinput/analysis_options.yaml | 32 + .../Flutter_Pinput/donations_and_awards.md | 24 + packages/Flutter_Pinput/lib/pinput.dart | 4 + .../Flutter_Pinput/lib/src/models/models.dart | 55 ++ .../lib/src/models/pin_theme.dart | 128 +++ .../lib/src/models/sms_retriever.dart | 62 ++ packages/Flutter_Pinput/lib/src/pinput.dart | 800 ++++++++++++++++++ .../Flutter_Pinput/lib/src/pinput_state.dart | 591 +++++++++++++ .../Flutter_Pinput/lib/src/utils/enums.dart | 98 +++ .../lib/src/utils/extensions.dart | 43 + .../lib/src/utils/pinput_constants.dart | 36 + .../lib/src/utils/pinput_utils_mixin.dart | 30 + .../lib/src/widgets/_pin_item.dart | 144 ++++ ...ut_selection_gesture_detector_builder.dart | 49 ++ .../lib/src/widgets/widgets.dart | 117 +++ packages/Flutter_Pinput/pubspec.yaml | 34 + packages/Flutter_Pinput/scripts/publish.sh | 38 + .../Flutter_Pinput/test/helpers/helpers.dart | 1 + .../Flutter_Pinput/test/helpers/pump_app.dart | 10 + packages/Flutter_Pinput/test/pinput_test.dart | 346 ++++++++ 27 files changed, 3497 insertions(+) create mode 100644 packages/Flutter_Pinput/.pubignore create mode 100644 packages/Flutter_Pinput/CHANGELOG.md create mode 100644 packages/Flutter_Pinput/LICENSE create mode 100644 packages/Flutter_Pinput/MIGRATION.md create mode 100644 packages/Flutter_Pinput/README.md create mode 100644 packages/Flutter_Pinput/_config.yml create mode 100644 packages/Flutter_Pinput/analysis_options.yaml create mode 100644 packages/Flutter_Pinput/donations_and_awards.md create mode 100644 packages/Flutter_Pinput/lib/pinput.dart create mode 100644 packages/Flutter_Pinput/lib/src/models/models.dart create mode 100644 packages/Flutter_Pinput/lib/src/models/pin_theme.dart create mode 100644 packages/Flutter_Pinput/lib/src/models/sms_retriever.dart create mode 100644 packages/Flutter_Pinput/lib/src/pinput.dart create mode 100644 packages/Flutter_Pinput/lib/src/pinput_state.dart create mode 100644 packages/Flutter_Pinput/lib/src/utils/enums.dart create mode 100644 packages/Flutter_Pinput/lib/src/utils/extensions.dart create mode 100644 packages/Flutter_Pinput/lib/src/utils/pinput_constants.dart create mode 100644 packages/Flutter_Pinput/lib/src/utils/pinput_utils_mixin.dart create mode 100644 packages/Flutter_Pinput/lib/src/widgets/_pin_item.dart create mode 100644 packages/Flutter_Pinput/lib/src/widgets/_pinput_selection_gesture_detector_builder.dart create mode 100644 packages/Flutter_Pinput/lib/src/widgets/widgets.dart create mode 100644 packages/Flutter_Pinput/pubspec.yaml create mode 100644 packages/Flutter_Pinput/scripts/publish.sh create mode 100644 packages/Flutter_Pinput/test/helpers/helpers.dart create mode 100644 packages/Flutter_Pinput/test/helpers/pump_app.dart create mode 100644 packages/Flutter_Pinput/test/pinput_test.dart diff --git a/README.md b/README.md index def2ce2e6..e5afa6a7e 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,7 @@ | 225 | [flutter_blurhash](https://pub.dev/packages/flutter_blurhash) | 0.7.0 | - | 无需适配 | | 226 | [globbing](https://pub.dev/packages/globbing) | 0.3.1 | - | 无需适配 | | 227 | [graphs](https://pub.dev/packages/graphs) | 2.3.1 | - | 无需适配 | +| 228 | [pinput](https://pub.dev/packages/pinput) | 5.0.0 | [pinput](https://gitee.com/openharmony-sig/flutter_packages/tree/master/packages/Flutter_Pinput) | 已适配 | ## 如何引用这些库 diff --git a/packages/Flutter_Pinput/.pubignore b/packages/Flutter_Pinput/.pubignore new file mode 100644 index 000000000..f2278cd79 --- /dev/null +++ b/packages/Flutter_Pinput/.pubignore @@ -0,0 +1,47 @@ +example/media/* +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +example/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +example/android/app/debug +example/android/app/profile +example/android/app/release diff --git a/packages/Flutter_Pinput/CHANGELOG.md b/packages/Flutter_Pinput/CHANGELOG.md new file mode 100644 index 000000000..bcf1545c6 --- /dev/null +++ b/packages/Flutter_Pinput/CHANGELOG.md @@ -0,0 +1,388 @@ +#### 5.0.0 · 7/06/2024 +- Implemented Pinput.builder to build custom Pinput fields +- Migrated deprecated imperative apply of Flutter's Gradle plugins example app +- Removed smart_auth dependency which was responsible for SMS autofill (Not everyone might need it and it was causing some issues) +- [Migration guide](MIGRATION.md) + +#### 4.0.0 · 10/02/2024 +- Fixed RECEIVER_EXPORTED exception in android SDK 34 PR +- Fix "Namespace not specified" error when upgrading to AGP 8.0 PR +- Updated readme + +#### 3.0.1 · 25/08/2023 + +- Fixed contextMenuBuilder + +#### 3.0.0 · 03/08/2023 + +- Replaced `separator` and `separatorPositions` with `separatorBuilder` + + | Property | Meaning/Default | + |--------------------------|:---------------:| + | separatorBuilder | (_) => const SizedBox(width: 8)| + +#### 2.3.0 · 24/07/2023 + +- Fixed AGP 4.2<= compatibility in smart_auth +- Updated SDK constraints +- Updated default SMS code matcher regex length to 8 digits + + +#### 2.2.31 · 22/02/2023 + +### Added: +| Property | Meaning/Default | +|--------------------------|:---------------:| +| enableIMEPersonalizedLearning | false | + +Whether to enable that the IME update personalized data such as typing history and user dictionary data. +This flag only affects Android. On iOS, there is no equivalent flag. + +#### 2.2.30 · 15/02/2023 +- Fixed "TapDownDetails not match the corresponding type" on Flutter's master channel - [issue](https://github.com/Tkko/Flutter_Pinput/issues/124) + + +#### 2.2.30 · 15/02/2023 +- Fixed "TapDownDetails not match the corresponding type" on Flutter's master channel - [issue](https://github.com/Tkko/Flutter_Pinput/issues/124) + +#### 2.2.23 · 25/01/2023 + +- Added correct SDK constraints +- Improved readme + + +#### 2.2.22 · 25/01/2023 + +- Removed FocusTrapArea +- Removed ToolbarOptions +- Added contextMenuBuilder, which is replacement of ToolbarOptions +- Added onTapOutside callback, which is invoked when a tap is detected outside of the Pinput +- Fixed text selection behavior + +#### 2.2.21 · 29/12/2022 + +- Fixed the case where app was crashing while reading the OTP + +#### 2.2.19 · 19/12/2022 + +- Improved changelog +- Added better demo screenshots + +#### 2.2.18 · 19/12/2022 + +- Added warning for [FocusTrapArea issue](https://github.com/Tkko/Flutter_Pinput/issues/98) +- Improved changelog + +#### 2.2.17 · 19/12/2022 + +- Added demo screenshot for pub +- Added funding URLs + +#### 2.2.16 · 06/10/2022 + +- Improved Docs + +#### 2.2.15 · 06/10/2022 + +- Improved Docs + +#### 2.2.14 · 06/10/2022 + +- Improved Example + +#### 2.2.13 · 03/10/2022 + +- Bumped minimum Flutter SDK version to 2.0.0 +- Added + +| Property | Meaning/Default | +|--------------------------|:---------------:| +| isCursorAnimationEnabled | true | + +#### 2.2.12 · 05/08/2022 + +- Added + +| Property | Meaning/Default | +|-------------------|:----------------------------------------------------------:| +| senderPhoneNumber | null / Optional parameter for Android SMS User Consent API | + +#### 2.2.11 · 14/06/2022 + +- Fixed `smsCodeMatcher` ([PR](https://github.com/Tkko/Flutter_Pinput/pull/94)) + +#### 2.2.10 · 31/05/2022 + +- When this widget receives focus and is not completely visible (for example scrolled partially off + the screen or overlapped by the keyboard) + then it will attempt to make itself visible by scrolling a surrounding [Scrollable], if one is + present. This value controls how far from the edges of a [Scrollable] the TextField will be + positioned after the scroll. + +| Property | Meaning/Default | +|---------------|:------------------:| +| scrollPadding | EdgeInsets.all(20) | + +#### 2.2.9 · 16/05/2022 + +- onCompleted mot called +- Added tests + +#### 2.2.8 · 13/05/2022 + +- Fixed dart 2.17 hints + +#### 2.2.7 · 04/04/2022 + +- Option to listen for multiple sms on android + +| Property | Meaning/Default | +|-------------------------------|:---------------:| +| listenForMultipleSmsOnAndroid | false | + +#### 2.2.6 · 02/04/2022 + +- Updated smart_auth +- Updated readme + +#### 2.2.5 · 28/03/2022 + +Added + +| Property | Meaning/Default | +|--------------------|:------------------------:| +| crossAxisAlignment | CrossAxisAlignment.start | + +#### 2.2.4 · 21/03/2022 + +- Fixed onClipboardFound + +#### 2.2.3 · 19/03/2022 + +Added Android SMS Autofill support + +| Property | Meaning/Default | +|--------------------------|:-------------------------------------------------------------------------------------------:| +| androidSmsAutofillMethod | Options to enable SMS autofill on Android | +| smsCodeMatcher | Used to extract code from SMS for Android Autofill if [androidSmsAutofillMethod] is enabled | + +#### 2.1.4 · 06/03/2022 + +Updated docs + +Added + +| Property | Meaning/Default | +|------------------------|:--------------------------------------------------------------------------------------------------:| +| Validator | To validate Pinput with or without Form | +| pinputAutovalidateMode | PinputAutovalidateMode.onSubmit | +| errorBuilder | To build custom error widget under the Pinput | +| errorTextStyle | Standard error text style, displayed under the Pinput | +| toolbarEnabled | If true, paste button will appear on longPress, doubleTap event / true | +| forceErrorState | If true [errorPinTheme] will be applied and [errorText] will be displayed under the Pinput / false | +| errorText | Text displayed under the Pinput if Pinput is invalid | + +#### 2.0.4 · 03/03/2022 + +- Updated docs +- Added onLongPress + +#### 2.0.3 · 02/03/2022 + +- Updated readme. + +#### 2.0.2 · 25/02/2022 + +Sorry guys this version will break your code 💙 Introduced PinTheme class to control state of the +individual pin easily, see readme's Getting Started section for examples. + +- Refactored, renamed some properties. +- Added new Pinput examples +- With long press user can paste from clipboard + +##### Changes + +| Old | New | +|--------------------------|:--------------------------:| +| onSubmit | onCompleted | +| fieldsCount | length | +| obscureText | obscuringCharacter | +| obscureText | obscuringCharacter | +| eachFieldHeight | PinTheme.height | +| eachFieldWidth | PinTheme.width | +| eachFieldConstraints | PinTheme.constraints | +| disabledDecoration | PinTheme.disabledPinTheme | +| followingFieldDecoration | PinTheme.followingPinTheme | +| selectedFieldDecoration | PinTheme.focusedPinTheme | +| submittedFieldDecoration | PinTheme.submittedPinTheme | +| eachFieldMargin | PinTheme.margin | +| eachFieldPadding | PinTheme.padding | + +#### 1.2.1 · 10/09/2021 + +🔥🚀 Merged PRs and Fixed common issues + +#### 1.2.0 · 03/13/2021 + +🔥🚀 Now PinPut supports custom numpad.(See demos) +Added `checkClipboard` property + +#### 1.1.0 · 03/02/2021 + +🔥🚀 Migrated to Null safety + +#### 1.0.0 · 01/14/2021 + +🔥🚀 Updated Example, Increased package version to `1.0.0` in order to make it more trustful + +#### 0.2.6 · 10/09/2020 + +🔥🚀 Added `cursor`, `preFilledWidget`, `mainAxisSize` and `autovalidateMode` properties. + +#### 0.2.5 · 08/30/2020 + +🔥🚀 Added fake `cursor`, `separatorPositions`, `separator` and optimized project with the help of +community. credits to @[furaiev](https://github.com/furaiev), @[Holofox](https://github.com/Holofox) +, + +#### 0.2.4 · 05/19/2020 + +🔥🚀 Fixed Focus problems. Updated readme. + +#### 0.2.3 · 04/12/2020 + +🔥🚀 Fixed Focus on click after back button click + +#### 0.2.2 · 04/09/2020 + +🔥🚀 Fixed Demo urls + +#### 0.2.1 · 04/09/2020 + +🔥🚀 Minor fixes and demos + +#### 0.2.0 · 04/07/2020 + +🔥🚀 Added some useful Documentation + +#### 0.2.0-dev.1 · 04/07/2020 + +🔥🚀 Breaking changes, changed widget building logic so now it supports: + +- Backspace on keyboard +- Every pixel customization +- Nice animations +- Form validation +- Ios auto fill · testing needed + +#### 0.1.10 -02/08/2019 + +👍 With the help of community: @xportation + +* Added Set autofocus on the first field when the attribute is defined| + +#### 0.1.9 · 07/02/2019 + +* Added + +| Property | Default/Meaning | +|----------|-----------------------| +| onClear | Clear button callback | + +#### 0.1.8 · 06/14/2019 + +👍 With the help of community: @datvo0110, @almeynman + +* Fixed minor bugs +* Added + +| Property | Default/Meaning | +|-----------------|-----------------| +| containerHeight | 100.0 | + +#### 0.1.7 · 05/12/2019 + +👍 With the help of community: @datvo0110, @mwgriffiths88, @inromualdo + +* Fixed minor bugs, check clipboard when app is resumed... +* Added Properties ability to hide keyboard & custumize more + +| Property | Default/Meaning | +|--------------------|-------------------------| +| textCapitalization | TextCapitalization.none | + +#### 0.1.6 · 01/17/2019 + +* Added Properties ability to hide keyboard & custumize more + +| Property | Default/Meaning | +|-----------------|-------------------------------------------------| +| clearButtonIcon | Icon(Icons.backspace, size: 30) | +| pasteButtonIcon | Icon(Icons.content_paste, size: 30) | +| unFocusWhen | Default is False, True to hide keyboard | +| textStyle | TextStyle(fontSize: 30) | +| spaceBetween | space between fields Default: 10.0 | +| inputDecoration | Ability to style field's border, padding etc... | + +#### 0.1.5 · 12/17/2018 + +* Added Copy From Clipboard functionality if copied text length is equal to fields count + +| Property | Default | +|-----------------|---------------------| +| pasteButtonIcon | Icons.content_paste | + +*Note that + +clearButtonEnabled will change with actionButtonEnabled in next release, right now if it is true +both clear and paste functionality works + +#### 0.1.4 · 10/31/2018 + +* Added + +| Property | Default | +|-----------|---------| +| autoFocus | true | + +#### 0.1.3+1 · 11026/2018 + +* Minor fixes + +#### 0.1.3 · 10/26/2018 + +* Transformed plugin to MVVM pattern +* Fixed onSubmit call when all fields aren't filled +* Updated Demo +* Added + +| Property | Default | +|--------------------|-----------------| +| clearButtonIcon | Icons.backspace | +| clearButtonEnabled | true | +| clearButtonColor | 0xFF66BB6A | + +#### 0.1.2 · 10/24/2018 + +* Added + +| Property | Default | +|----------------|:-------:| +| borderRadius | 5.0 | +| keybaordType | number | +| keyboardAction | next | + +#### 0.1.1 · 10/24/2018 + +* Added + +| Property | Default | +|---------------|:--------:| +| onSubmit | Function | +| fieldsCount | 4 | +| isTextObscure | false | +| fontSize | 40.0 | + +#### 0.0.1 · 10/24/2018 + +* Initial release, working base functionality \ No newline at end of file diff --git a/packages/Flutter_Pinput/LICENSE b/packages/Flutter_Pinput/LICENSE new file mode 100644 index 000000000..08786553c --- /dev/null +++ b/packages/Flutter_Pinput/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) +Copyright (c) 2022 Tornike Kurdadze + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/Flutter_Pinput/MIGRATION.md b/packages/Flutter_Pinput/MIGRATION.md new file mode 100644 index 000000000..1adf5b33f --- /dev/null +++ b/packages/Flutter_Pinput/MIGRATION.md @@ -0,0 +1,77 @@ +### Migration to 5.0.0+ + +- If you need SMS autofill on Android, you need to add the smart_auth (or similar) package directly + to your project. + +Before 5.0.0: + +```dart +class Example extends StatelessWidget { + const Example({Key? key}) : super(key: key); + + + @override + Widget build(BuildContext context) { + return Pinput( + androidSmsAutofillMethod: AndroidSmsAutofillMethod.smsUserConsentApi, + listenForMultipleSmsOnAndroid: true, + ); + } +} +``` + +After 5.0.0: + +```agsl +dependencies: + smart_auth: 2.0.0 +``` + +```dart +class SmsRetrieverImpl implements SmsRetriever { + const SmsRetrieverImpl(this.smartAuth); + + final SmartAuth smartAuth; + + @override + Future dispose() { + return smartAuth.removeSmsListener(); + } + + @override + Future getSmsCode() async { + final res = await smartAuth.getSmsCode(); + if (res.succeed && res.codeFound) { + return res.code!; + } + return null; + } + + @override + bool get listenForMultipleSms => false; +} + +class SmartAuthExample extends StatefulWidget { + const SmartAuthExample({Key? key}) : super(key: key); + + @override + State createState() => _SmartAuthExampleState(); +} + +class _SmartAuthExampleState extends State { + late final SmsRetrieverImpl smsRetrieverImpl; + + @override + void initState() { + smsRetrieverImpl = SmsRetrieverImpl(SmartAuth()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Pinput( + smsRetriever: smsRetrieverImpl, + ); + } +} +``` \ No newline at end of file diff --git a/packages/Flutter_Pinput/README.md b/packages/Flutter_Pinput/README.md new file mode 100644 index 000000000..736f6ba4b --- /dev/null +++ b/packages/Flutter_Pinput/README.md @@ -0,0 +1,320 @@ +
+

Flutter pin code input

+ + + + + + + + +

Need anything Flutter related? Reach out on LinkedIn +

+ + +[![Pub package](https://img.shields.io/pub/v/pinput.svg)](https://pub.dev/packages/pinput) +[![Github starts](https://img.shields.io/github/stars/tkko/flutter_pinput.svg?style=flat&logo=github&colorB=deeppink&label=stars)](https://github.com/tkko/flutter_pinput) +[![style: effective dart](https://img.shields.io/badge/style-effective_dart-40c4ff.svg)](https://github.com/tenhobi/effective_dart) +[![pub package](https://img.shields.io/badge/license-MIT-purple.svg)](https://opensource.org/licenses/MIT) + +
+ +Flutter Pinput is a package that provides an easy-to-use and customizable Pin code input field. It offers several features such as animated decoration switching, form validation, SMS autofill, custom cursor, copying from clipboard, and more. It also provides beautiful examples that you can choose from. + +## Features: +- Animated Decoration Switching +- Form validation +- SMS Autofill on iOS +- SMS Autofill on Android +- Standard Cursor +- Custom Cursor +- Cursor Animation +- Copy From Clipboard +- Ready For Custom Keyboard +- Standard Paste option +- Obscuring Character +- Obscuring Widget +- Haptic Feedback +- Close Keyboard After Completion +- Beautiful [Examples](https://github.com/Tkko/Flutter_PinPut/tree/master/example/lib) + +## Support + +PRs Welcome + +Discord [Channel](https://rebrand.ly/qwc3s0d) + +[Examples](https://github.com/Tkko/Flutter_PinPut/tree/master/example/lib) app on Github has multiple templates to choose from + +Don't forget to give it a star ⭐ + +## Demo + +| [Live Demo](https://rebrand.ly/6390b8) | Rounded With Shadows | Rounded With Cursor | +| - | - | - | +| | | | + +| Rounded Filled | With Bottom Cursor | Filled | +| - | - | - | +| | | | + +## Getting Started + +The pin has 6 states `default` `focused`, `submitted`, `following`, `disabled`, `error`, you can customize each state by specifying theme parameter. +Pin smoothly animates from one state to another automatically. +`PinTheme Class` + + +| Property | Default/Type | +|-------------|:------------------:| +| width | 56.0 | +| height | 60.0 | +| textStyle | TextStyle() | +| margin | EdgeInsetsGeometry | +| padding | EdgeInsetsGeometry | +| constraints | BoxConstraints | + +You can use standard Pinput like so + +```dart +Widget buildPinPut() { + return Pinput( + onCompleted: (pin) => print(pin), + ); +} +``` + +If you want to customize it, create `defaultPinTheme` first. + +```dart +final defaultPinTheme = PinTheme( + width: 56, + height: 56, + textStyle: TextStyle(fontSize: 20, color: Color.fromRGBO(30, 60, 87, 1), fontWeight: FontWeight.w600), + decoration: BoxDecoration( + border: Border.all(color: Color.fromRGBO(234, 239, 243, 1)), + borderRadius: BorderRadius.circular(20), + ), +); +``` + +if you want all pins to be the same don't pass other theme parameters, +If not, create `focusedPinTheme`, `submittedPinTheme`, `followingPinTheme`, `errorPinTheme` from `defaultPinTheme` + +```dart +final focusedPinTheme = defaultPinTheme.copyDecorationWith( + border: Border.all(color: Color.fromRGBO(114, 178, 238, 1)), + borderRadius: BorderRadius.circular(8), +); + +final submittedPinTheme = defaultPinTheme.copyWith( + decoration: defaultPinTheme.decoration.copyWith( + color: Color.fromRGBO(234, 239, 243, 1), + ), +); +``` + +Put everything together + +```dart +final defaultPinTheme = PinTheme( + width: 56, + height: 56, + textStyle: TextStyle(fontSize: 20, color: Color.fromRGBO(30, 60, 87, 1), fontWeight: FontWeight.w600), + decoration: BoxDecoration( + border: Border.all(color: Color.fromRGBO(234, 239, 243, 1)), + borderRadius: BorderRadius.circular(20), + ), +); + +final focusedPinTheme = defaultPinTheme.copyDecorationWith( + border: Border.all(color: Color.fromRGBO(114, 178, 238, 1)), + borderRadius: BorderRadius.circular(8), +); + +final submittedPinTheme = defaultPinTheme.copyWith( + decoration: defaultPinTheme.decoration.copyWith( + color: Color.fromRGBO(234, 239, 243, 1), + ), +); + +return Pinput( +defaultPinTheme: defaultPinTheme, +focusedPinTheme: focusedPinTheme, +submittedPinTheme: submittedPinTheme, +validator: (s) { +return s == '2222' ? null : 'Pin is incorrect'; +}, +pinputAutovalidateMode: PinputAutovalidateMode.onSubmit, +showCursor: true, +onCompleted: (pin) => print(pin), +); +``` + +## SMS Autofill + +### iOS + +Works out of the box, by tapping the code on top of the keyboard + +### Android + +If you are using [firebase_auth](https://firebase.flutter.dev/docs/auth/phone#verificationcompleted) you have to set `controller'`s value in `verificationCompleted` callback, here is an example code: +``` dart + Pinput( + controller: pinController, + ); +``` +And set pinController's value in `verificationCompleted` callback: +``` dart + await FirebaseAuth.instance.verifyPhoneNumber( + verificationCompleted: (PhoneAuthCredential credential) { + pinController.setText(credential.smsCode); + }, + verificationFailed: (FirebaseAuthException e) {}, + codeSent: (String verificationId, int? resendToken) {}, + codeAutoRetrievalTimeout: (String verificationId) {}, + ); +``` +--- +If you aren't using firebase_auth, you have two options, [SMS Retriever API](https://developers.google.com/identity/sms-retriever/overview?hl=en) and [SMS User Consent API](https://developers.google.com/identity/sms-retriever/user-consent/overview), + +[SmartAuth](https://pub.dev/packages/smart_auth) is a wrapper package for Flutter for these APIs, so go ahead and add it as a dependency. + +###### SMS Retriever API + +To use Retriever API you need the App signature - [guide](https://stackoverflow.com/questions/53849023/android-sms-retriever-api-computing-apps-hash-string-problem) + +`Note that The App Signature might be different for debug and release mode` + +Once you get the app signature, you should include it in the SMS message in you backend like so: + +SMS example: +``` +Your ExampleApp code is: 123456 +kg+TZ3A5qzS +``` +[Example Code](/example/lib/demo/sms_retriever_api_example.dart) + +Sms code will be automatically applied, without user interaction. + +###### SMS User Consent API + +You don't need the App signature, the user will be prompted to confirm reading the message +[Example Code](/example/lib/demo/user_consent_api_example.dart) + + +Request Hint + +## See Example app for more [templates](https://github.com/Tkko/Flutter_PinPut/tree/master/example/lib) + +## Tips + +- #### Controller + +```dart +/// Create Controller +final pinController = TextEditingController(); + +/// Set text programmatically +pinController.setText('1222'); + +/// Append typed character, useful if you are using custom keyboard +pinController.append('1', 4); + +/// Delete last character +pinController.delete(); + +/// Don't call setText, append, delete in build method, this is just illustration. + +return Pinput( + controller: pinController, +); +``` + +- #### Focus + +```dart +/// Create FocusNode +final pinputFocusNode = FocusNode(); + +/// Focus pinput +pinputFocusNode.requestFocus(); + +/// UnFocus pinput +pinputFocusNode.unfocus(); + +/// Don't call requestFocus, unfocus in build method, this is just illustration. + +return Pinput( + focusNode: pinputFocusNode, +); +``` + +- #### Validation + +```dart +/// Create key +final formKey = GlobalKey(); + +/// Validate manually +/// Don't call validate in build method, this is just illustration. +formKey.currentState!.validate(); + +return Form( + key: formKey, + child: Pinput( + // Without Validator + // If true error state will be applied no matter what validator returns + forceErrorState: true, + // Text will be displayed under the Pinput + errorText: 'Error', + + /// ------------ + /// With Validator + /// Auto validate after user tap on keyboard done button, or completes Pinput + pinputAutovalidateMode: PinputAutovalidateMode.onSubmit, + validator: (pin) { + if (pin == '2224') return null; + + /// Text will be displayed under the Pinput + return 'Pin is incorrect'; + }, + ), +); +``` + +## FAQ + +#### autofill isn't working on iOS? + +- Make sure you are using real device, not simulator +- Temporary replace Pinput with TextField, and check if autofill works. If, not it's probably a + problem with SMS you are getting, autofill doesn't work with most of the languages +- If you are using non stable version of Flutter that might be cause because something might be + broken inside the Framework + +#### are you using firebase_auth? + +You should set `controller'`s value in `verificationCompleted` callback, here is an example code: +``` dart + Pinput( + controller: pinController, + ); + + await FirebaseAuth.instance.verifyPhoneNumber( + verificationCompleted: (PhoneAuthCredential credential) { + pinController.setText(credential.smsCode); + }, + verificationFailed: (FirebaseAuthException e) {}, + codeSent: (String verificationId, int? resendToken) {}, + codeAutoRetrievalTimeout: (String verificationId) {}, + ); +``` \ No newline at end of file diff --git a/packages/Flutter_Pinput/_config.yml b/packages/Flutter_Pinput/_config.yml new file mode 100644 index 000000000..c4192631f --- /dev/null +++ b/packages/Flutter_Pinput/_config.yml @@ -0,0 +1 @@ +theme: jekyll-theme-cayman \ No newline at end of file diff --git a/packages/Flutter_Pinput/analysis_options.yaml b/packages/Flutter_Pinput/analysis_options.yaml new file mode 100644 index 000000000..158e32911 --- /dev/null +++ b/packages/Flutter_Pinput/analysis_options.yaml @@ -0,0 +1,32 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + errors: + missing_required_param: warning + missing_return: warning + todo: ignore + exclude: + - flutter/** + - lib/api/*.dart + language: + strict-raw-types: true + +linter: + rules: + unnecessary_this: false + always_declare_return_types: true + camel_case_types: true + empty_constructor_bodies: true + annotate_overrides: true + avoid_init_to_null: true + constant_identifier_names: true + one_member_abstracts: true + slash_for_doc_comments: true + unnecessary_brace_in_string_interps: true + cancel_subscriptions: true + close_sinks: true + unnecessary_const: true + unnecessary_new: true + prefer_single_quotes: true + require_trailing_commas: true + public_member_api_docs: true diff --git a/packages/Flutter_Pinput/donations_and_awards.md b/packages/Flutter_Pinput/donations_and_awards.md new file mode 100644 index 000000000..1511402c1 --- /dev/null +++ b/packages/Flutter_Pinput/donations_and_awards.md @@ -0,0 +1,24 @@ +# 💰 Donations and Awards 💎 + +💙 **Big Thanks!** Before we jump into the good stuff, a massive shoutout to all the awesome supporters and contributors – you folks rock! + +## 🌟 Donations + +|Supporter Email |Supporter Name|Coffee Count|Coffee Price|Support Currency|Supported On| +|------------------------|--------------|------------|------------|----------------|------------| +|sagarsubedi96@gmail.com |ocean_dev |1 |5.00 |USD |2023-08-28 | +|kothsada@gmail.com |kothsada |1 |5.00 |USD |2023-08-02 | +|martin@sqence.io |Sqence |30 |5.00 |USD |2023-04-17 | +|azhar.i@outlook.com |Someone |1 |5.00 |USD |2022-10-07 | +|gbaladi@live.com |Gaston Baladi |1 |5.00 |USD |2022-05-30 | +|andriy.krupych@gmail.com|Andriy Krupych|1 |5.00 |USD |2022-03-28 | +|andriy.krupych@gmail.com|Andriy Krupych|1 |5.00 |USD |2022-03-24 | +|vinu.somayaji@gmail.com |Vinu Somayaji |1 |5.00 |USD |2021-11-04 | + + +## 🏆 Awards + +#### Google Open Source Peer Bonus + + + diff --git a/packages/Flutter_Pinput/lib/pinput.dart b/packages/Flutter_Pinput/lib/pinput.dart new file mode 100644 index 000000000..dcb60910b --- /dev/null +++ b/packages/Flutter_Pinput/lib/pinput.dart @@ -0,0 +1,4 @@ +/// Flutter package to create easily customizable Pin code input field, that your designers can't even draw in Figma 🤭 +library pinput; + +export 'src/pinput.dart'; diff --git a/packages/Flutter_Pinput/lib/src/models/models.dart b/packages/Flutter_Pinput/lib/src/models/models.dart new file mode 100644 index 000000000..0493bdc9b --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/models/models.dart @@ -0,0 +1,55 @@ +part of '../pinput.dart'; + +/// A widget builder that represents a single pin field. +typedef PinItemWidgetBuilder = Widget Function( + BuildContext context, + PinItemState pinItemBuilderState, +); + +/// An enum that represents the state of a pin item. +enum PinItemStateType { + /// The default state of the pin item. + initial, + + /// The state of the pin item when it is focused. + focused, + + /// The state of the pin item when it is filled + submitted, + + /// The state of the pin item when it is following. + following, + + /// The state of the pin item when it is disabled. + disabled, + + /// The state of the pin item when it has an error. + error, +} + +/// A class that represents the state of a pin item. +class PinItemState { + /// Creates a new instance of [PinItemState]. + const PinItemState({ + required this.value, + required this.index, + required this.type, + }); + + /// The value of the individual pin item. + final String value; + + /// The index of the individual pin item. + final int index; + + /// The state of the individual pin item. + final PinItemStateType type; +} + +class _PinItemBuilder { + const _PinItemBuilder({ + required this.itemBuilder, + }); + + final PinItemWidgetBuilder itemBuilder; +} diff --git a/packages/Flutter_Pinput/lib/src/models/pin_theme.dart b/packages/Flutter_Pinput/lib/src/models/pin_theme.dart new file mode 100644 index 000000000..4e3fb6f6a --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/models/pin_theme.dart @@ -0,0 +1,128 @@ +part of '../pinput.dart'; + +/// Theme of the individual pin items for following states: +/// default, focused pin, submitted pin, following pin, disabled pin and error pin +class PinTheme { + /// width of each [Pinput] field + final double? width; + + /// height of each [Pinput] field + final double? height; + + /// The style to use for PinPut + /// If null, defaults to the `subhead` text style from the current [Theme]. + final TextStyle? textStyle; + + /// Empty space to surround the [Pinput] field container. + final EdgeInsetsGeometry? margin; + + /// Empty space to inscribe the [Pinput] field container. + /// For example space between border and text + final EdgeInsetsGeometry? padding; + + /// Additional constraints to apply to the each field container. + /// properties + /// ```dart + /// this.minWidth = 0.0, + /// this.maxWidth = double.infinity, + /// this.minHeight = 0.0, + /// this.maxHeight = double.infinity, + /// ``` + final BoxConstraints? constraints; + + /// Box decoration of following properties of Pin item + /// You can customize every pixel with it + /// properties are being animated implicitly when value changes + /// ```dart + /// this.color, + /// this.image, + /// this.border, + /// this.borderRadius, + /// this.boxShadow, + /// this.gradient, + /// this.backgroundBlendMode, + /// this.shape = BoxShape.rectangle, + /// ``` + /// The decoration of each [Pinput] submitted field + final BoxDecoration? decoration; + + /// Theme of the individual pin items for following states: + /// default, focused pin, submitted pin, following pin, disabled pin and error pin + const PinTheme({ + this.width, + this.height, + this.margin, + this.padding, + this.textStyle, + this.decoration, + this.constraints, + }); + + /// Merge two [PinTheme] into one + PinTheme apply({required PinTheme theme}) { + return PinTheme( + width: this.width ?? theme.width, + height: this.height ?? theme.height, + textStyle: this.textStyle ?? theme.textStyle, + constraints: this.constraints ?? theme.constraints, + decoration: this.decoration ?? theme.decoration, + padding: this.padding ?? theme.padding, + margin: this.margin ?? theme.margin, + ); + } + + /// Create a new [PinTheme] from the current instance + PinTheme copyWith({ + double? width, + double? height, + TextStyle? textStyle, + BoxConstraints? constraints, + BoxDecoration? decoration, + EdgeInsetsGeometry? padding, + EdgeInsetsGeometry? margin, + }) { + return PinTheme( + width: width ?? this.width, + height: height ?? this.height, + textStyle: textStyle ?? this.textStyle, + constraints: constraints ?? this.constraints, + decoration: decoration ?? this.decoration, + padding: padding ?? this.padding, + margin: margin ?? this.margin, + ); + } + + /// Create a new [PinTheme] from the current instance with new decoration + PinTheme copyDecorationWith({ + Color? color, + DecorationImage? image, + BoxBorder? border, + BorderRadiusGeometry? borderRadius, + List? boxShadow, + Gradient? gradient, + BlendMode? backgroundBlendMode, + BoxShape? shape, + }) { + assert(decoration != null); + return copyWith( + decoration: decoration?.copyWith( + color: color, + image: image, + border: border, + borderRadius: borderRadius, + boxShadow: boxShadow, + gradient: gradient, + backgroundBlendMode: backgroundBlendMode, + shape: shape, + ), + ); + } + + /// Create a new [PinTheme] from the current instance with new border + PinTheme copyBorderWith({required Border border}) { + assert(decoration != null); + return copyWith( + decoration: decoration?.copyWith(border: border), + ); + } +} diff --git a/packages/Flutter_Pinput/lib/src/models/sms_retriever.dart b/packages/Flutter_Pinput/lib/src/models/sms_retriever.dart new file mode 100644 index 000000000..5dd522f67 --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/models/sms_retriever.dart @@ -0,0 +1,62 @@ +part of '../pinput.dart'; + +/// An interface for retrieving sms code. Used for SMS autofill. +/// You, as a developer should implement this interface. +abstract class SmsRetriever { + /// Whether to listen for multiple sms codes. + bool get listenForMultipleSms; + + /// This method should return the sms code. + Future getSmsCode(); + + /// Optional method to dispose the sms listener. + Future dispose(); +} + +/// SmartAuthExample: +// class SmsRetrieverImpl implements SmsRetriever { +// const SmsRetrieverImpl(this.smartAuth); +// +// final SmartAuth smartAuth; +// +// @override +// Future dispose() { +// return smartAuth.removeSmsListener(); +// } +// +// @override +// Future getSmsCode() async { +// final res = await smartAuth.getSmsCode(); +// if (res.succeed && res.codeFound) { +// return res.code!; +// } +// return null; +// } +// +// @override +// bool get listenForMultipleSms => false; +// } +// +// class SmartAuthExample extends StatefulWidget { +// const SmartAuthExample({Key? key}) : super(key: key); +// +// @override +// State createState() => _SmartAuthExampleState(); +// } +// +// class _SmartAuthExampleState extends State { +// late final SmsRetrieverImpl smsRetrieverImpl; +// +// @override +// void initState() { +// smsRetrieverImpl = SmsRetrieverImpl(SmartAuth()); +// super.initState(); +// } +// +// @override +// Widget build(BuildContext context) { +// return Pinput( +// smsRetriever: smsRetrieverImpl, +// ); +// } +// } diff --git a/packages/Flutter_Pinput/lib/src/pinput.dart b/packages/Flutter_Pinput/lib/src/pinput.dart new file mode 100644 index 000000000..76d39a228 --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/pinput.dart @@ -0,0 +1,800 @@ +import 'dart:math'; +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; + +part 'pinput_state.dart'; + +part 'utils/enums.dart'; + +part 'utils/pinput_constants.dart'; + +part 'widgets/widgets.dart'; + +part 'models/pin_theme.dart'; + +part 'models/models.dart'; + +part 'models/sms_retriever.dart'; + +part 'utils/extensions.dart'; + +part 'widgets/_pin_item.dart'; + +part 'utils/pinput_utils_mixin.dart'; + +part 'widgets/_pinput_selection_gesture_detector_builder.dart'; + +/// Flutter package to create easily customizable Pin code input field, that your designers can't even draw in Figma 🤭 +/// +/// ## Features: +/// - Animated Decoration Switching +/// - Form validation +/// - SMS Autofill on iOS +/// - SMS Autofill on Android +/// - Standard Cursor +/// - Custom Cursor +/// - Cursor Animation +/// - Copy From Clipboard +/// - Ready For Custom Keyboard +/// - Standard Paste option +/// - Obscuring Character +/// - Obscuring Widget +/// - Haptic Feedback +/// - Close Keyboard After Completion +/// - Beautiful [Examples](https://github.com/Tkko/Flutter_PinPut/tree/master/example/lib/demo) +class Pinput extends StatefulWidget { + /// Creates a PinPut widget + const Pinput({ + this.length = PinputConstants._defaultLength, + this.smsRetriever, + this.defaultPinTheme, + this.focusedPinTheme, + this.submittedPinTheme, + this.followingPinTheme, + this.disabledPinTheme, + this.errorPinTheme, + this.onChanged, + this.onCompleted, + this.onSubmitted, + this.onTap, + this.onLongPress, + this.onTapOutside, + this.controller, + this.focusNode, + this.preFilledWidget, + this.separatorBuilder, + this.mainAxisAlignment = MainAxisAlignment.center, + this.crossAxisAlignment = CrossAxisAlignment.start, + this.pinContentAlignment = Alignment.center, + this.animationCurve = Curves.easeIn, + this.animationDuration = PinputConstants._animationDuration, + this.pinAnimationType = PinAnimationType.scale, + this.enabled = true, + this.readOnly = false, + this.useNativeKeyboard = true, + this.toolbarEnabled = true, + this.autofocus = false, + this.obscureText = false, + this.showCursor = true, + this.isCursorAnimationEnabled = true, + this.enableIMEPersonalizedLearning = false, + this.enableSuggestions = true, + this.hapticFeedbackType = HapticFeedbackType.disabled, + this.closeKeyboardWhenCompleted = true, + this.keyboardType = TextInputType.number, + this.textCapitalization = TextCapitalization.none, + this.slideTransitionBeginOffset, + this.cursor, + this.keyboardAppearance, + this.inputFormatters = const [], + this.textInputAction, + this.autofillHints = const [ + AutofillHints.oneTimeCode, + ], + this.obscuringCharacter = '•', + this.obscuringWidget, + this.selectionControls, + this.restorationId, + this.onClipboardFound, + this.onAppPrivateCommand, + this.mouseCursor, + this.forceErrorState = false, + this.errorText, + this.validator, + this.errorBuilder, + this.errorTextStyle, + this.pinputAutovalidateMode = PinputAutovalidateMode.onSubmit, + this.scrollPadding = const EdgeInsets.all(20), + this.contextMenuBuilder = _defaultContextMenuBuilder, + Key? key, + }) : assert(obscuringCharacter.length == 1), + assert(length > 0), + assert( + textInputAction != TextInputAction.newline, + 'Pinput is not multiline', + ), + _builder = null, + super(key: key); + + /// Creates a PinPut widget with custom pin item builder + /// This gives you full control over the pin item widget + Pinput.builder({ + required PinItemWidgetBuilder builder, + this.smsRetriever, + this.length = PinputConstants._defaultLength, + this.onChanged, + this.onCompleted, + this.onSubmitted, + this.onTap, + this.onLongPress, + this.onTapOutside, + this.controller, + this.focusNode, + this.separatorBuilder, + this.mainAxisAlignment = MainAxisAlignment.center, + this.crossAxisAlignment = CrossAxisAlignment.start, + this.enabled = true, + this.readOnly = false, + this.useNativeKeyboard = true, + this.toolbarEnabled = true, + this.autofocus = false, + this.enableIMEPersonalizedLearning = false, + this.enableSuggestions = true, + this.hapticFeedbackType = HapticFeedbackType.disabled, + this.closeKeyboardWhenCompleted = true, + this.keyboardType = TextInputType.number, + this.textCapitalization = TextCapitalization.none, + this.keyboardAppearance, + this.inputFormatters = const [], + this.textInputAction, + this.autofillHints, + this.selectionControls, + this.restorationId, + this.onClipboardFound, + this.onAppPrivateCommand, + this.mouseCursor, + this.forceErrorState = false, + this.validator, + this.pinputAutovalidateMode = PinputAutovalidateMode.onSubmit, + this.scrollPadding = const EdgeInsets.all(20), + this.contextMenuBuilder = _defaultContextMenuBuilder, + Key? key, + }) : assert(length > 0), + assert( + textInputAction != TextInputAction.newline, + 'Pinput is not multiline', + ), + _builder = _PinItemBuilder( + itemBuilder: builder, + ), + defaultPinTheme = null, + focusedPinTheme = null, + submittedPinTheme = null, + followingPinTheme = null, + disabledPinTheme = null, + errorPinTheme = null, + preFilledWidget = null, + pinContentAlignment = Alignment.center, + animationCurve = Curves.easeIn, + animationDuration = PinputConstants._animationDuration, + pinAnimationType = PinAnimationType.scale, + obscureText = false, + showCursor = false, + isCursorAnimationEnabled = false, + slideTransitionBeginOffset = null, + cursor = null, + obscuringCharacter = '•', + obscuringWidget = null, + errorText = null, + errorBuilder = null, + errorTextStyle = null, + super(key: key); + + /// Theme of the pin in default state + final PinTheme? defaultPinTheme; + + /// Theme of the pin in focused state + final PinTheme? focusedPinTheme; + + /// Theme of the pin in submitted state + final PinTheme? submittedPinTheme; + + /// Theme of the pin in following state + final PinTheme? followingPinTheme; + + /// Theme of the pin in disabled state + final PinTheme? disabledPinTheme; + + /// Theme of the pin in error state + final PinTheme? errorPinTheme; + + /// If true keyboard will be closed + final bool closeKeyboardWhenCompleted; + + /// Displayed fields count. PIN code length. + final int length; + + /// By default Android autofill is Disabled, you can enable it by passing [smsRetriever] + /// SmsRetriever exposes methods to listen for incoming SMS and extract code from it + /// Recommended package to get sms code on Android is smart_auth https://pub.dev/packages/smart_auth + final SmsRetriever? smsRetriever; + + /// Fires when user completes pin input + final ValueChanged? onCompleted; + + /// Called every time input value changes. + final ValueChanged? onChanged; + + /// See [EditableText.onSubmitted] + final ValueChanged? onSubmitted; + + /// Called when user clicks on PinPut + final VoidCallback? onTap; + + /// Triggered when a pointer has remained in contact with the Pinput at the + /// same location for a long period of time. + final VoidCallback? onLongPress; + + /// Used to get, modify PinPut value and more. + /// Don't forget to dispose controller + /// ``` dart + /// @override + /// void dispose() { + /// controller.dispose(); + /// super.dispose(); + /// } + /// ``` + final TextEditingController? controller; + + /// Defines the keyboard focus for this + /// To give the keyboard focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// Don't forget to dispose focusNode + /// ``` dart + /// @override + /// void dispose() { + /// focusNode.dispose(); + /// super.dispose(); + /// } + /// ``` + final FocusNode? focusNode; + + /// Widget that is displayed before field submitted. + final Widget? preFilledWidget; + + /// Builds a [Pinput] separator + /// If null SizedBox(width: 8) will be used + final JustIndexedWidgetBuilder? separatorBuilder; + + /// Builds a [Pinput] item + /// If null the default _PinItem will be used + final _PinItemBuilder? _builder; + + /// Defines how [Pinput] fields are being placed inside [Row] + final MainAxisAlignment mainAxisAlignment; + + /// Defines how [Pinput] and ([errorText] or [errorBuilder]) are being placed inside [Column] + final CrossAxisAlignment crossAxisAlignment; + + /// Defines how each [Pinput] field are being placed within the container + final AlignmentGeometry pinContentAlignment; + + /// curve of every [Pinput] Animation + final Curve animationCurve; + + /// Duration of every [Pinput] Animation + final Duration animationDuration; + + /// Animation Type of each [Pinput] field + /// options: + /// none, scale, fade, slide, rotation + final PinAnimationType pinAnimationType; + + /// Begin Offset of ever [Pinput] field when [pinAnimationType] is slide + final Offset? slideTransitionBeginOffset; + + /// Defines [Pinput] state + final bool enabled; + + /// See [EditableText.readOnly] + final bool readOnly; + + /// See [EditableText.autofocus] + final bool autofocus; + + /// Whether to use Native keyboard or custom one + /// when flag is set to false [Pinput] wont be focusable anymore + /// so you should set value of [Pinput]'s [TextEditingController] programmatically + final bool useNativeKeyboard; + + /// If true, paste button will appear on longPress event + final bool toolbarEnabled; + + /// Whether show cursor or not + /// Default cursor '|' or [cursor] + final bool showCursor; + + /// Whether to enable cursor animation + final bool isCursorAnimationEnabled; + + /// Whether to enable that the IME update personalized data such as typing history and user dictionary data. + // + // This flag only affects Android. On iOS, there is no equivalent flag. + // + // Defaults to false. Cannot be null. + final bool enableIMEPersonalizedLearning; + + /// If [showCursor] true the focused field will show passed Widget + final Widget? cursor; + + /// The appearance of the keyboard. + /// This setting is only honored on iOS devices. + /// If unset, defaults to [ThemeData.brightness]. + final Brightness? keyboardAppearance; + + /// See [EditableText.inputFormatters] + final List inputFormatters; + + /// See [EditableText.keyboardType] + final TextInputType keyboardType; + + /// Provide any symbol to obscure each [Pinput] pin + /// Recommended ● + final String obscuringCharacter; + + /// IF [obscureText] is true typed text will be replaced with passed Widget + final Widget? obscuringWidget; + + /// Whether hide typed pin or not + final bool obscureText; + + /// See [EditableText.textCapitalization] + final TextCapitalization textCapitalization; + + /// The type of action button to use for the keyboard. + /// + /// Defaults to [TextInputAction.newline] if [keyboardType] is + /// [TextInputType.multiline] and [TextInputAction.done] otherwise. + final TextInputAction? textInputAction; + + /// See [EditableText.autofillHints] + final Iterable? autofillHints; + + /// See [EditableText.enableSuggestions] + final bool enableSuggestions; + + /// See [EditableText.selectionControls] + final TextSelectionControls? selectionControls; + + /// See [TextField.restorationId] + final String? restorationId; + + /// Fires when clipboard has text of Pinput's length + final ValueChanged? onClipboardFound; + + /// Use haptic feedback everytime user types on keyboard + /// See more details in [HapticFeedback] + final HapticFeedbackType hapticFeedbackType; + + /// See [EditableText.onAppPrivateCommand] + final AppPrivateCommandCallback? onAppPrivateCommand; + + /// See [EditableText.mouseCursor] + final MouseCursor? mouseCursor; + + /// If true [errorPinTheme] will be applied and [errorText] will be displayed under the Pinput + final bool forceErrorState; + + /// Text displayed under the Pinput if Pinput is invalid + final String? errorText; + + /// Style of error text + final TextStyle? errorTextStyle; + + /// If [Pinput] has error and [errorBuilder] is passed it will be rendered under the Pinput + final PinputErrorBuilder? errorBuilder; + + /// Return null if pin is valid or any String otherwise + final FormFieldValidator? validator; + + /// Return null if pin is valid or any String otherwise + final PinputAutovalidateMode pinputAutovalidateMode; + + /// When this widget receives focus and is not completely visible (for example scrolled partially + /// off the screen or overlapped by the keyboard) + /// then it will attempt to make itself visible by scrolling a surrounding [Scrollable], if one is present. + /// This value controls how far from the edges of a [Scrollable] the TextField will be positioned after the scroll. + final EdgeInsets scrollPadding; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, will build a default menu based on the platform. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar], which is built by default. + final EditableTextContextMenuBuilder? contextMenuBuilder; + + /// A callback to be invoked when a tap is detected outside of this [TapRegion] + /// The [PointerDownEvent] passed to the function is the event that caused the + /// notification. If this region is part of a group + /// then it's possible that the event may be outside of this immediate region, + /// although it will be within the region of one of the group members. + /// This is useful if you want to un-focus the [Pinput] when user taps outside of it + final TapRegionCallback? onTapOutside; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + } + + @override + State createState() => _PinputState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty( + 'defaultPinTheme', + defaultPinTheme, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'focusedPinTheme', + focusedPinTheme, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'submittedPinTheme', + submittedPinTheme, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'followingPinTheme', + followingPinTheme, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'disabledPinTheme', + disabledPinTheme, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'errorPinTheme', + errorPinTheme, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'controller', + controller, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'focusNode', + focusNode, + defaultValue: null, + ), + ); + properties + .add(DiagnosticsProperty('enabled', enabled, defaultValue: true)); + properties.add( + DiagnosticsProperty( + 'closeKeyboardWhenCompleted', + closeKeyboardWhenCompleted, + defaultValue: true, + ), + ); + properties.add( + DiagnosticsProperty( + 'keyboardType', + keyboardType, + defaultValue: TextInputType.number, + ), + ); + properties.add( + DiagnosticsProperty( + 'length', + length, + defaultValue: PinputConstants._defaultLength, + ), + ); + properties.add( + DiagnosticsProperty?>( + 'onCompleted', + onCompleted, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty?>( + 'onChanged', + onChanged, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty?>( + 'onClipboardFound', + onClipboardFound, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty('onTap', onTap, defaultValue: null), + ); + properties.add( + DiagnosticsProperty( + 'onLongPress', + onLongPress, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'preFilledWidget', + preFilledWidget, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty('cursor', cursor, defaultValue: null), + ); + properties.add( + DiagnosticsProperty( + 'separatorBuilder', + separatorBuilder, + defaultValue: PinputConstants._defaultSeparator, + ), + ); + properties.add( + DiagnosticsProperty<_PinItemBuilder>( + '_builder', + _builder, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'obscuringWidget', + obscuringWidget, + defaultValue: null, + ), + ); + + properties.add( + DiagnosticsProperty( + 'mainAxisAlignment', + mainAxisAlignment, + defaultValue: MainAxisAlignment.center, + ), + ); + properties.add( + DiagnosticsProperty( + 'pinContentAlignment', + pinContentAlignment, + defaultValue: Alignment.center, + ), + ); + properties.add( + DiagnosticsProperty( + 'animationCurve', + animationCurve, + defaultValue: Curves.easeIn, + ), + ); + properties.add( + DiagnosticsProperty( + 'animationDuration', + animationDuration, + defaultValue: PinputConstants._animationDuration, + ), + ); + properties.add( + DiagnosticsProperty( + 'pinAnimationType', + pinAnimationType, + defaultValue: PinAnimationType.scale, + ), + ); + properties.add( + DiagnosticsProperty( + 'slideTransitionBeginOffset', + slideTransitionBeginOffset, + defaultValue: null, + ), + ); + properties + .add(DiagnosticsProperty('enabled', enabled, defaultValue: true)); + properties.add( + DiagnosticsProperty('readOnly', readOnly, defaultValue: false), + ); + properties.add( + DiagnosticsProperty( + 'obscureText', + obscureText, + defaultValue: false, + ), + ); + properties.add( + DiagnosticsProperty('autofocus', autofocus, defaultValue: false), + ); + properties.add( + DiagnosticsProperty( + 'useNativeKeyboard', + useNativeKeyboard, + defaultValue: false, + ), + ); + properties.add( + DiagnosticsProperty( + 'toolbarEnabled', + toolbarEnabled, + defaultValue: true, + ), + ); + properties.add( + DiagnosticsProperty( + 'showCursor', + showCursor, + defaultValue: true, + ), + ); + properties.add( + DiagnosticsProperty( + 'obscuringCharacter', + obscuringCharacter, + defaultValue: '•', + ), + ); + properties.add( + DiagnosticsProperty( + 'obscureText', + obscureText, + defaultValue: false, + ), + ); + properties.add( + DiagnosticsProperty( + 'enableSuggestions', + enableSuggestions, + defaultValue: true, + ), + ); + properties.add( + DiagnosticsProperty>( + 'inputFormatters', + inputFormatters, + defaultValue: const [], + ), + ); + properties.add( + EnumProperty( + 'textInputAction', + textInputAction, + defaultValue: TextInputAction.done, + ), + ); + properties.add( + EnumProperty( + 'textCapitalization', + textCapitalization, + defaultValue: TextCapitalization.none, + ), + ); + properties.add( + DiagnosticsProperty( + 'keyboardAppearance', + keyboardAppearance, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'keyboardType', + keyboardType, + defaultValue: TextInputType.number, + ), + ); + properties.add( + DiagnosticsProperty?>( + 'autofillHints', + autofillHints, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'selectionControls', + selectionControls, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'restorationId', + restorationId, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'onAppPrivateCommand', + onAppPrivateCommand, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'mouseCursor', + mouseCursor, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'errorTextStyle', + errorTextStyle, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'errorBuilder', + errorBuilder, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty?>( + 'validator', + validator, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'pinputAutovalidateMode', + pinputAutovalidateMode, + defaultValue: PinputAutovalidateMode.onSubmit, + ), + ); + properties.add( + DiagnosticsProperty( + 'hapticFeedbackType', + hapticFeedbackType, + defaultValue: HapticFeedbackType.disabled, + ), + ); + properties.add( + DiagnosticsProperty( + 'contextMenuBuilder', + contextMenuBuilder, + defaultValue: _defaultContextMenuBuilder, + ), + ); + } +} diff --git a/packages/Flutter_Pinput/lib/src/pinput_state.dart b/packages/Flutter_Pinput/lib/src/pinput_state.dart new file mode 100644 index 000000000..8751b7a17 --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/pinput_state.dart @@ -0,0 +1,591 @@ +part of 'pinput.dart'; + +/// This allows a value of type T or T? +/// to be treated as a value of type T?. +/// +/// We use this so that APIs that have become +/// non-nullable can still be used with `!` and `?` +/// to support older versions of the API as well. +T? _ambiguate(T? value) => value; + +class _PinputState extends State + with RestorationMixin, WidgetsBindingObserver, _PinputUtilsMixin + implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient { + @override + late bool forcePressEnabled; + + @override + final GlobalKey editableTextKey = + GlobalKey(); + + @override + bool get selectionEnabled => widget.toolbarEnabled; + + @override + String get autofillId => _editableText!.autofillId; + + @override + String? get restorationId => widget.restorationId; + + late TextEditingValue _recentControllerValue; + late final _PinputSelectionGestureDetectorBuilder _gestureDetectorBuilder; + RestorableTextEditingController? _controller; + FocusNode? _focusNode; + bool _isHovering = false; + String? _validatorErrorText; + SmsRetriever? _smsRetriever; + + String? get _errorText => widget.errorText ?? _validatorErrorText; + + bool get _canRequestFocus { + final NavigationMode mode = MediaQuery.maybeOf(context)?.navigationMode ?? + NavigationMode.traditional; + switch (mode) { + case NavigationMode.traditional: + return isEnabled && widget.useNativeKeyboard; + case NavigationMode.directional: + return true && widget.useNativeKeyboard; + } + } + + TextEditingController get _effectiveController => + widget.controller ?? _controller!.value; + + @protected + FocusNode get effectiveFocusNode => + widget.focusNode ?? (_focusNode ??= FocusNode()); + + @protected + bool get hasError => widget.forceErrorState || _validatorErrorText != null; + + @protected + bool get isEnabled => widget.enabled; + + int get _currentLength => _effectiveController.value.text.characters.length; + + EditableTextState? get _editableText => editableTextKey.currentState; + + int get selectedIndex => pin.length; + + String get pin => _effectiveController.text; + + bool get _completed => pin.length == widget.length; + + @override + void initState() { + super.initState(); + _gestureDetectorBuilder = + _PinputSelectionGestureDetectorBuilder(state: this); + if (widget.controller == null) { + _createLocalController(); + _recentControllerValue = TextEditingValue.empty; + } else { + _recentControllerValue = _effectiveController.value; + widget.controller!.addListener(_handleTextEditingControllerChanges); + } + effectiveFocusNode.canRequestFocus = isEnabled && widget.useNativeKeyboard; + _maybeInitSmartAuth(); + _maybeCheckClipboard(); + // https://github.com/Tkko/Flutter_Pinput/issues/89 + _ambiguate(WidgetsBinding.instance)!.addObserver(this); + } + + /// Android Autofill + void _maybeInitSmartAuth() async { + if (_smsRetriever == null && widget.smsRetriever != null) { + _smsRetriever = widget.smsRetriever!; + _listenForSmsCode(); + } + } + + void _listenForSmsCode() async { + final res = await _smsRetriever!.getSmsCode(); + if (res != null && res.length == widget.length) { + _effectiveController.setText(res); + } + // Listen for multiple sms codes + if (_smsRetriever!.listenForMultipleSms) { + _listenForSmsCode(); + } + } + + void _handleTextEditingControllerChanges() { + final textChanged = + _recentControllerValue.text != _effectiveController.value.text; + _recentControllerValue = _effectiveController.value; + if (textChanged) { + _onChanged(pin); + } + } + + void _onChanged(String pin) { + widget.onChanged?.call(pin); + if (_completed) { + widget.onCompleted?.call(pin); + _maybeValidateForm(); + _maybeCloseKeyboard(); + } + } + + void _maybeValidateForm() { + if (widget.pinputAutovalidateMode == PinputAutovalidateMode.onSubmit) { + _validator(); + } + } + + void _maybeCloseKeyboard() { + if (widget.closeKeyboardWhenCompleted) { + effectiveFocusNode.unfocus(); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + effectiveFocusNode.canRequestFocus = _canRequestFocus; + } + + @override + void didUpdateWidget(Pinput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == null && oldWidget.controller != null) { + _createLocalController(oldWidget.controller!.value); + } else if (widget.controller != null && oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.removeListener(_handleTextEditingControllerChanges); + _controller!.dispose(); + _controller = null; + } + + if (widget.controller != oldWidget.controller) { + oldWidget.controller?.removeListener(_handleTextEditingControllerChanges); + widget.controller?.addListener(_handleTextEditingControllerChanges); + } + + effectiveFocusNode.canRequestFocus = _canRequestFocus; + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + void _registerController() { + assert(_controller != null); + registerForRestoration(_controller!, 'controller'); + } + + void _createLocalController([TextEditingValue? value]) { + assert(_controller == null); + _controller = value == null + ? RestorableTextEditingController() + : RestorableTextEditingController.fromValue(value); + _controller!.addListener(_handleTextEditingControllerChanges); + if (!restorePending) { + _registerController(); + } + } + + @override + void dispose() { + widget.controller?.removeListener(_handleTextEditingControllerChanges); + _focusNode?.dispose(); + _controller?.dispose(); + _smsRetriever?.dispose(); + // https://github.com/Tkko/Flutter_Pinput/issues/89 + _ambiguate(WidgetsBinding.instance)!.removeObserver(this); + super.dispose(); + } + + void _requestKeyboard() { + if (effectiveFocusNode.canRequestFocus) { + _editableText?.requestKeyboard(); + } + } + + void _handleSelectionChanged( + TextSelection selection, + SelectionChangedCause? cause, + ) { + _effectiveController.selection = + TextSelection.collapsed(offset: pin.length); + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + if (cause == SelectionChangedCause.longPress || + cause == SelectionChangedCause.drag) { + _editableText?.bringIntoView(selection.extent); + } + break; + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + break; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + if (cause == SelectionChangedCause.drag) { + _editableText?.hideToolbar(); + } + break; + } + } + + /// Toggle the toolbar when a selection handle is tapped. + void _handleSelectionHandleTapped() { + if (_effectiveController.selection.isCollapsed) { + _editableText!.toggleToolbar(); + } + } + + void _handleHover(bool hovering) { + if (hovering != _isHovering) { + setState(() => _isHovering = hovering); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState appLifecycleState) async { + if (appLifecycleState == AppLifecycleState.resumed) { + _maybeCheckClipboard(); + } + } + + void _maybeCheckClipboard() async { + if (widget.onClipboardFound != null) { + final clipboard = await _getClipboardOrEmpty(); + if (clipboard.length == widget.length) { + widget.onClipboardFound!.call(clipboard); + } + } + } + + String? _validator([String? _]) { + final res = widget.validator?.call(pin); + setState(() => _validatorErrorText = res); + return res; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasDirectionality(context)); + final isDense = widget.mainAxisAlignment == MainAxisAlignment.center; + + return isDense ? IntrinsicWidth(child: _buildPinput()) : _buildPinput(); + } + + Widget _buildPinput() { + final theme = Theme.of(context); + VoidCallback? handleDidGainAccessibilityFocus; + TextSelectionControls? textSelectionControls = widget.selectionControls; + + switch (theme.platform) { + case TargetPlatform.iOS: + forcePressEnabled = true; + textSelectionControls ??= cupertinoTextSelectionHandleControls; + break; + case TargetPlatform.macOS: + forcePressEnabled = false; + textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls; + handleDidGainAccessibilityFocus = () { + if (!effectiveFocusNode.hasFocus && + effectiveFocusNode.canRequestFocus) { + effectiveFocusNode.requestFocus(); + } + }; + break; + case TargetPlatform.ohos: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + forcePressEnabled = false; + textSelectionControls ??= materialTextSelectionHandleControls; + break; + case TargetPlatform.linux: + forcePressEnabled = false; + textSelectionControls ??= desktopTextSelectionHandleControls; + break; + case TargetPlatform.windows: + forcePressEnabled = false; + textSelectionControls ??= desktopTextSelectionHandleControls; + handleDidGainAccessibilityFocus = () { + if (!effectiveFocusNode.hasFocus && + effectiveFocusNode.canRequestFocus) { + effectiveFocusNode.requestFocus(); + } + }; + break; + } + + return _PinputFormField( + enabled: isEnabled, + validator: _validator, + initialValue: _effectiveController.text, + builder: (FormFieldState field) { + return MouseRegion( + cursor: _effectiveMouseCursor, + onEnter: (PointerEnterEvent event) => _handleHover(true), + onExit: (PointerExitEvent event) => _handleHover(false), + child: TextFieldTapRegion( + child: IgnorePointer( + ignoring: !isEnabled || !widget.useNativeKeyboard, + child: AnimatedBuilder( + animation: _effectiveController, + builder: (_, Widget? child) => Semantics( + maxValueLength: widget.length, + currentValueLength: _currentLength, + onTap: widget.readOnly ? null : _semanticsOnTap, + onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, + child: child, + ), + child: _gestureDetectorBuilder.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: Stack( + alignment: Alignment.topCenter, + children: [ + _buildEditable(textSelectionControls, field), + _buildFields(), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildEditable( + TextSelectionControls? textSelectionControls, + FormFieldState field, + ) { + final formatters = [ + ...widget.inputFormatters, + LengthLimitingTextInputFormatter( + widget.length, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + ), + ]; + + return RepaintBoundary( + child: UnmanagedRestorationScope( + bucket: bucket, + child: EditableText( + key: editableTextKey, + maxLines: 1, + style: PinputConstants._hiddenTextStyle, + onChanged: (value) { + field.didChange(value); + _maybeUseHaptic(widget.hapticFeedbackType); + }, + expands: false, + showCursor: false, + autocorrect: false, + autofillClient: this, + showSelectionHandles: false, + rendererIgnoresPointer: true, + enableInteractiveSelection: false, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + textInputAction: widget.textInputAction, + textCapitalization: widget.textCapitalization, + selectionColor: Colors.transparent, + keyboardType: widget.keyboardType, + obscureText: widget.obscureText, + onSubmitted: (s) { + widget.onSubmitted?.call(s); + _maybeValidateForm(); + }, + onTapOutside: widget.onTapOutside, + mouseCursor: MouseCursor.defer, + focusNode: effectiveFocusNode, + textAlign: TextAlign.center, + autofocus: widget.autofocus, + inputFormatters: formatters, + restorationId: 'pinput', + clipBehavior: Clip.hardEdge, + cursorColor: Colors.transparent, + controller: _effectiveController, + autofillHints: widget.autofillHints, + scrollPadding: widget.scrollPadding, + selectionWidthStyle: BoxWidthStyle.tight, + backgroundCursorColor: Colors.transparent, + selectionHeightStyle: BoxHeightStyle.tight, + enableSuggestions: widget.enableSuggestions, + contextMenuBuilder: widget.contextMenuBuilder, + obscuringCharacter: widget.obscuringCharacter, + onAppPrivateCommand: widget.onAppPrivateCommand, + onSelectionChanged: _handleSelectionChanged, + onSelectionHandleTapped: _handleSelectionHandleTapped, + readOnly: widget.readOnly || !isEnabled || !widget.useNativeKeyboard, + selectionControls: + widget.toolbarEnabled ? textSelectionControls : null, + keyboardAppearance: + widget.keyboardAppearance ?? Theme.of(context).brightness, + ), + ), + ); + } + + // TODO: Use WidgetStateProperty instead. + MouseCursor get _effectiveMouseCursor { + // ignore: deprecated_member_use + return MaterialStateProperty.resolveAs( + // ignore: deprecated_member_use + widget.mouseCursor ?? MaterialStateMouseCursor.textable, + // ignore: deprecated_member_use + { + // ignore: deprecated_member_use + if (!isEnabled) MaterialState.disabled, + // ignore: deprecated_member_use + if (_isHovering) MaterialState.hovered, + // ignore: deprecated_member_use + if (effectiveFocusNode.hasFocus) MaterialState.focused, + // ignore: deprecated_member_use + if (hasError) MaterialState.error, + }, + ); + } + + void _semanticsOnTap() { + if (!_effectiveController.selection.isValid) { + _effectiveController.selection = + TextSelection.collapsed(offset: _effectiveController.text.length); + } + _requestKeyboard(); + } + + PinItemStateType _getState(int index) { + if (!isEnabled) { + return PinItemStateType.disabled; + } + + if (showErrorState) { + return PinItemStateType.error; + } + + if (hasFocus && index == selectedIndex.clamp(0, widget.length - 1)) { + return PinItemStateType.focused; + } + + if (index < selectedIndex) { + return PinItemStateType.submitted; + } + + return PinItemStateType.following; + } + + Widget _buildFields() { + Widget onlyFields() { + return _SeparatedRaw( + separatorBuilder: widget.separatorBuilder, + mainAxisAlignment: widget.mainAxisAlignment, + children: Iterable.generate(widget.length).map((index) { + if (widget._builder != null) { + return widget._builder!.itemBuilder.call( + context, + PinItemState( + value: pin.length > index ? pin[index] : '', + index: index, + type: _getState(index), + ), + ); + } + + return _PinItem(state: this, index: index); + }).toList(), + ); + } + + return Center( + child: AnimatedBuilder( + animation: Listenable.merge( + [effectiveFocusNode, _effectiveController], + ), + builder: (BuildContext context, Widget? child) { + final shouldHideErrorContent = + widget.validator == null && widget.errorText == null; + + if (shouldHideErrorContent) return onlyFields(); + + return AnimatedSize( + duration: widget.animationDuration, + alignment: Alignment.topCenter, + child: Column( + crossAxisAlignment: widget.crossAxisAlignment, + children: [ + onlyFields(), + _buildError(), + ], + ), + ); + }, + ), + ); + } + + @protected + bool get hasFocus { + final isLastPin = selectedIndex == widget.length; + return effectiveFocusNode.hasFocus || + (!widget.useNativeKeyboard && !isLastPin); + } + + @protected + bool get showErrorState => hasError && (!hasFocus || widget.forceErrorState); + + Widget _buildError() { + if (showErrorState) { + if (widget.errorBuilder != null) { + return widget.errorBuilder!.call(_errorText, pin); + } + + final theme = Theme.of(context); + if (_errorText != null) { + return Padding( + padding: const EdgeInsetsDirectional.only(start: 4, top: 8), + child: Text( + _errorText!, + style: widget.errorTextStyle ?? + theme.textTheme.titleMedium + ?.copyWith(color: theme.colorScheme.error), + ), + ); + } + } + + return const SizedBox.shrink(); + } + + // AutofillClient implementation start. + @override + void autofill(TextEditingValue newEditingValue) => + _editableText!.autofill(newEditingValue); + + @override + TextInputConfiguration get textInputConfiguration { + final List? autofillHints = + widget.autofillHints?.toList(growable: false); + final AutofillConfiguration autofillConfiguration = autofillHints != null + ? AutofillConfiguration( + uniqueIdentifier: autofillId, + autofillHints: autofillHints, + currentEditingValue: _effectiveController.value, + ) + : AutofillConfiguration.disabled; + + return _editableText!.textInputConfiguration + .copyWith(autofillConfiguration: autofillConfiguration); + } +} diff --git a/packages/Flutter_Pinput/lib/src/utils/enums.dart b/packages/Flutter_Pinput/lib/src/utils/enums.dart new file mode 100644 index 000000000..137753e6f --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/utils/enums.dart @@ -0,0 +1,98 @@ +part of '../pinput.dart'; + +/// The mode which determines the Pinput validation method +enum PinputAutovalidateMode { + /// No auto validation will occur. + disabled, + + /// Used to auto-validate [Pinput] only after [Pinput.onCompleted] or [Pinput.onSubmitted] is called + onSubmit, +} + +/// The method that is used to get the sms code on Android +enum AndroidSmsAutofillMethod { + /// Disabled SMS autofill on Android + none, + + /// Automatically reads sms without user interaction + /// Requires SMS to contain The App signature, see readme for more details + /// More about Sms Retriever API https://developers.google.com/identity/sms-retriever/overview?hl=en + smsRetrieverApi, + + /// Requires user interaction to confirm reading a SMS, see readme for more details + /// [AndroidSmsAutofillMethod.smsUserConsentApi] + /// More about SMS User Consent API https://developers.google.com/identity/sms-retriever/user-consent/overview + smsUserConsentApi, +} + +/// The animation type if Pin item +enum PinAnimationType { + /// No animation + none, + + /// Scale animation + scale, + + /// Fade animation + fade, + + /// Slide animation + slide, + + /// Rotation animation + rotation, +} + +/// The vibration type when user types +enum HapticFeedbackType { + /// No vibration + disabled, + + /// Provides a haptic feedback corresponding a collision impact with a light mass. + /// + /// On iOS versions 10 and above, this uses a `UIImpactFeedbackGenerator` with + /// `UIImpactFeedbackStyleLight`. This call has no effects on iOS versions + /// below 10. + /// + /// On Android, this uses `HapticFeedbackConstants.VIRTUAL_KEY`. + lightImpact, + + /// Provides a haptic feedback corresponding a collision impact with a medium mass. + /// + /// On iOS versions 10 and above, this uses a `UIImpactFeedbackGenerator` with + /// `UIImpactFeedbackStyleMedium`. This call has no effects on iOS versions + /// below 10. + /// + /// On Android, this uses `HapticFeedbackConstants.KEYBOARD_TAP`. + mediumImpact, + + /// Provides a haptic feedback corresponding a collision impact with a heavy mass. + /// + /// On iOS versions 10 and above, this uses a `UIImpactFeedbackGenerator` with + /// `UIImpactFeedbackStyleHeavy`. This call has no effects on iOS versions + /// below 10. + /// + /// On Android, this uses `HapticFeedbackConstants.CONTEXT_CLICK` on API levels + /// 23 and above. This call has no effects on Android API levels below 23. + heavyImpact, + + /// Provides a haptic feedback indication selection changing through discrete values. + /// + /// On iOS versions 10 and above, this uses a `UISelectionFeedbackGenerator`. + /// This call has no effects on iOS versions below 10. + /// + /// On Android, this uses `HapticFeedbackConstants.CLOCK_TICK`. + selectionClick, + + /// Provides vibration haptic feedback to the user for a short duration. + /// + /// On iOS devices that support haptic feedback, this uses the default system + /// vibration value (`kSystemSoundID_Vibrate`). + /// + /// On Android, this uses the platform haptic feedback API to simulate a + /// response to a long press (`HapticFeedbackConstants.LONG_PRESS`). + vibrate, +} + +/// Error widget builder of Pinput +typedef PinputErrorBuilder = Widget Function(String? errorText, String pin); diff --git a/packages/Flutter_Pinput/lib/src/utils/extensions.dart b/packages/Flutter_Pinput/lib/src/utils/extensions.dart new file mode 100644 index 000000000..ed8eeda04 --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/utils/extensions.dart @@ -0,0 +1,43 @@ +part of '../pinput.dart'; + +/// Helper methods for Pinput to easily set, delete, append the value programmatically +/// ``` dart +/// final controller = TextEditingController(); +/// +/// controller.setText('1234'); +/// +/// Pinput( +/// controller: controller, +/// ); +/// ``` +/// +extension PinputControllerExt on TextEditingController { + /// The length of the Pinput value + int get length => this.text.length; + + /// Sets Pinput value + void setText(String pin) { + this.text = pin; + this.moveCursorToEnd(); + } + + /// Deletes the last character of Pinput value + void delete() { + if (text.isEmpty) return; + final pin = this.text.substring(0, this.length - 1); + this.text = pin; + this.moveCursorToEnd(); + } + + /// Appends character at the end of the Pinput + void append(String s, int maxLength) { + if (this.length == maxLength) return; + this.text = '${this.text}$s'; + this.moveCursorToEnd(); + } + + /// Moves cursor at the end + void moveCursorToEnd() { + this.selection = TextSelection.collapsed(offset: this.length); + } +} diff --git a/packages/Flutter_Pinput/lib/src/utils/pinput_constants.dart b/packages/Flutter_Pinput/lib/src/utils/pinput_constants.dart new file mode 100644 index 000000000..863314ab7 --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/utils/pinput_constants.dart @@ -0,0 +1,36 @@ +part of '../pinput.dart'; + +/// The constant values for Pinput +class PinputConstants { + const PinputConstants._(); + + /// The default value [Pinput.smsCodeMatcher] + static const defaultSmsCodeMatcher = '\\d{4,7}'; + + /// The default value [Pinput.animationDuration] + static const _animationDuration = Duration(milliseconds: 180); + + /// The default value [Pinput.length] + static const _defaultLength = 4; + + static const _defaultSeparator = SizedBox(width: 8); + + /// The hidden text under the Pinput + static const _hiddenTextStyle = + TextStyle(fontSize: 1, height: 1, color: Colors.transparent); + + /// + static const _defaultPinFillColor = Color.fromRGBO(222, 231, 240, .57); + static const _defaultPinputDecoration = BoxDecoration( + color: _defaultPinFillColor, + borderRadius: BorderRadius.all(Radius.circular(8)), + ); + + /// The default value [Pinput.defaultPinTheme] + static const _defaultPinTheme = PinTheme( + width: 56, + height: 60, + textStyle: TextStyle(), + decoration: _defaultPinputDecoration, + ); +} diff --git a/packages/Flutter_Pinput/lib/src/utils/pinput_utils_mixin.dart b/packages/Flutter_Pinput/lib/src/utils/pinput_utils_mixin.dart new file mode 100644 index 000000000..2c9eec28e --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/utils/pinput_utils_mixin.dart @@ -0,0 +1,30 @@ +part of '../pinput.dart'; + +mixin _PinputUtilsMixin { + void _maybeUseHaptic(HapticFeedbackType hapticFeedbackType) { + switch (hapticFeedbackType) { + case HapticFeedbackType.disabled: + break; + case HapticFeedbackType.lightImpact: + HapticFeedback.lightImpact(); + break; + case HapticFeedbackType.mediumImpact: + HapticFeedback.mediumImpact(); + break; + case HapticFeedbackType.heavyImpact: + HapticFeedback.heavyImpact(); + break; + case HapticFeedbackType.selectionClick: + HapticFeedback.selectionClick(); + break; + case HapticFeedbackType.vibrate: + HapticFeedback.vibrate(); + break; + } + } + + Future _getClipboardOrEmpty() async { + final ClipboardData? clipboardData = await Clipboard.getData('text/plain'); + return clipboardData?.text ?? ''; + } +} diff --git a/packages/Flutter_Pinput/lib/src/widgets/_pin_item.dart b/packages/Flutter_Pinput/lib/src/widgets/_pin_item.dart new file mode 100644 index 000000000..0aed3b3c9 --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/widgets/_pin_item.dart @@ -0,0 +1,144 @@ +part of '../pinput.dart'; + +class _PinItem extends StatelessWidget { + final _PinputState state; + final int index; + + const _PinItem({ + required this.state, + required this.index, + }); + + @override + Widget build(BuildContext context) { + final pinTheme = _pinTheme(index); + + return Flexible( + child: AnimatedContainer( + height: pinTheme.height, + width: pinTheme.width, + constraints: pinTheme.constraints, + padding: pinTheme.padding, + margin: pinTheme.margin, + decoration: pinTheme.decoration, + alignment: state.widget.pinContentAlignment, + duration: state.widget.animationDuration, + curve: state.widget.animationCurve, + child: AnimatedSwitcher( + switchInCurve: state.widget.animationCurve, + switchOutCurve: state.widget.animationCurve, + duration: state.widget.animationDuration, + transitionBuilder: _getTransition, + child: _buildFieldContent(index, pinTheme), + ), + ), + ); + } + + PinTheme _pinTheme(int index) { + final pintState = state._getState(index); + switch (pintState) { + case PinItemStateType.initial: + return _getDefaultPinTheme(); + case PinItemStateType.focused: + return _pinThemeOrDefault(state.widget.focusedPinTheme); + case PinItemStateType.submitted: + return _pinThemeOrDefault(state.widget.submittedPinTheme); + case PinItemStateType.following: + return _pinThemeOrDefault(state.widget.followingPinTheme); + case PinItemStateType.disabled: + return _pinThemeOrDefault(state.widget.disabledPinTheme); + case PinItemStateType.error: + return _pinThemeOrDefault(state.widget.errorPinTheme); + } + } + + PinTheme _getDefaultPinTheme() => + state.widget.defaultPinTheme ?? PinputConstants._defaultPinTheme; + + PinTheme _pinThemeOrDefault(PinTheme? theme) => + theme ?? _getDefaultPinTheme(); + + Widget _buildFieldContent(int index, PinTheme pinTheme) { + final pin = state.pin; + final key = ValueKey(index < pin.length ? pin[index] : ''); + final isSubmittedPin = index < pin.length; + + if (isSubmittedPin) { + if (state.widget.obscureText && state.widget.obscuringWidget != null) { + return SizedBox(key: key, child: state.widget.obscuringWidget); + } + + return Text( + state.widget.obscureText ? state.widget.obscuringCharacter : pin[index], + key: key, + style: pinTheme.textStyle, + ); + } + + final isActiveField = index == pin.length; + final focused = + state.effectiveFocusNode.hasFocus || !state.widget.useNativeKeyboard; + final shouldShowCursor = + state.widget.showCursor && state.isEnabled && isActiveField && focused; + + if (shouldShowCursor) { + return _buildCursor(pinTheme); + } + + if (state.widget.preFilledWidget != null) { + return SizedBox(key: key, child: state.widget.preFilledWidget); + } + + return Text('', key: key, style: pinTheme.textStyle); + } + + Widget _buildCursor(PinTheme pinTheme) { + if (state.widget.isCursorAnimationEnabled) { + return _PinputAnimatedCursor( + textStyle: pinTheme.textStyle, + cursor: state.widget.cursor, + ); + } + + return _PinputCursor( + textStyle: pinTheme.textStyle, + cursor: state.widget.cursor, + ); + } + + Widget _getTransition(Widget child, Animation animation) { + if (child is _PinputAnimatedCursor) { + return child; + } + + switch (state.widget.pinAnimationType) { + case PinAnimationType.none: + return child; + case PinAnimationType.fade: + return FadeTransition( + opacity: animation, + child: child, + ); + case PinAnimationType.scale: + return ScaleTransition( + scale: animation, + child: child, + ); + case PinAnimationType.slide: + return SlideTransition( + position: Tween( + begin: + state.widget.slideTransitionBeginOffset ?? const Offset(0.8, 0), + end: Offset.zero, + ).animate(animation), + child: child, + ); + case PinAnimationType.rotation: + return RotationTransition( + turns: animation, + child: child, + ); + } + } +} diff --git a/packages/Flutter_Pinput/lib/src/widgets/_pinput_selection_gesture_detector_builder.dart b/packages/Flutter_Pinput/lib/src/widgets/_pinput_selection_gesture_detector_builder.dart new file mode 100644 index 000000000..b6648f601 --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/widgets/_pinput_selection_gesture_detector_builder.dart @@ -0,0 +1,49 @@ +part of '../pinput.dart'; + +class _PinputSelectionGestureDetectorBuilder + extends TextSelectionGestureDetectorBuilder { + _PinputSelectionGestureDetectorBuilder({required _PinputState state}) + : _state = state, + super(delegate: state); + + final _PinputState _state; + + @override + void onForcePressStart(details) { + super.onForcePressStart(details); + if (delegate.selectionEnabled && shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + } + + @override + void onSingleTapUp(details) { + super.onSingleTapUp(details); + editableText.hideToolbar(); + _state._requestKeyboard(); + _state.widget.onTap?.call(); + } + + @override + void onSingleLongTapEnd(LongPressEndDetails details) { + super.onSingleLongTapEnd(details); + _state.widget.onLongPress?.call(); + } + + @override + void onSingleLongTapStart(details) { + super.onSingleLongTapStart(details); + if (delegate.selectionEnabled) { + switch (Theme.of(_state.context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + Feedback.forLongPress(_state.context); + } + } + } +} diff --git a/packages/Flutter_Pinput/lib/src/widgets/widgets.dart b/packages/Flutter_Pinput/lib/src/widgets/widgets.dart new file mode 100644 index 000000000..95669015f --- /dev/null +++ b/packages/Flutter_Pinput/lib/src/widgets/widgets.dart @@ -0,0 +1,117 @@ +part of '../pinput.dart'; + +/// Signature for a function that creates a widget for a given index, e.g., in a +/// list. +typedef JustIndexedWidgetBuilder = Widget Function(int index); + +class _PinputFormField extends FormField { + const _PinputFormField({ + required final FormFieldValidator? validator, + required final bool enabled, + required final String? initialValue, + required final Widget Function(FormFieldState field) builder, + Key? key, + }) : super( + key: key, + enabled: enabled, + validator: validator, + autovalidateMode: AutovalidateMode.disabled, + initialValue: initialValue, + builder: builder, + ); +} + +class _SeparatedRaw extends StatelessWidget { + final List children; + final MainAxisAlignment mainAxisAlignment; + final JustIndexedWidgetBuilder? separatorBuilder; + + const _SeparatedRaw({ + required this.children, + required this.mainAxisAlignment, + this.separatorBuilder, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final itemCount = max(0, children.length * 2 - 1); + final indexedList = [for (int i = 0; i < itemCount; i += 1) i]; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: mainAxisAlignment, + mainAxisSize: mainAxisAlignment == MainAxisAlignment.center + ? MainAxisSize.min + : MainAxisSize.max, + children: indexedList.map((index) { + final itemIndex = index ~/ 2; + return index.isEven ? children[itemIndex] : _separator(itemIndex); + }).toList(growable: false), + ); + } + + Widget _separator(int index) => + separatorBuilder?.call(index) ?? PinputConstants._defaultSeparator; +} + +class _PinputCursor extends StatelessWidget { + final Widget? cursor; + final TextStyle? textStyle; + + const _PinputCursor({required this.textStyle, required this.cursor}); + + @override + Widget build(BuildContext context) => cursor ?? Text('|', style: textStyle); +} + +class _PinputAnimatedCursor extends StatefulWidget { + final Widget? cursor; + final TextStyle? textStyle; + + const _PinputAnimatedCursor({ + required this.textStyle, + required this.cursor, + }); + + @override + State<_PinputAnimatedCursor> createState() => _PinputAnimatedCursorState(); +} + +class _PinputAnimatedCursorState extends State<_PinputAnimatedCursor> + with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + + @override + void initState() { + super.initState(); + _startCursorAnimation(); + } + + void _startCursorAnimation() { + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 450), + ); + + _animationController.addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed) { + _animationController.repeat(reverse: true); + } + }); + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _animationController, + child: _PinputCursor(textStyle: widget.textStyle, cursor: widget.cursor), + ); + } +} diff --git a/packages/Flutter_Pinput/pubspec.yaml b/packages/Flutter_Pinput/pubspec.yaml new file mode 100644 index 000000000..8229dd284 --- /dev/null +++ b/packages/Flutter_Pinput/pubspec.yaml @@ -0,0 +1,34 @@ +name: pinput +version: 5.0.0 +description: Pin code input (OTP) text field, iOS SMS autofill, Android SMS autofill One Time Code, Password, Passcode, Captcha, Security, Coupon, Wowcher, 2FA, Two step verification +homepage: https://github.com/Tkko/Flutter_PinPut +repository: https://github.com/Tkko/Flutter_PinPut +issue_tracker: https://github.com/Tkko/Flutter_Pinput/issues +topics: + - otp + - pin-input + - pin-code + - passcode + - sms-autofill +screenshots: + - description: 'Pin input example' + path: example/pinput_demo_1.webp + - description: 'Pin code example' + path: example/pinput_demo_2.webp +funding: + - https://ko-fi.com/flutterman + +environment: + sdk: '>=2.15.0 <4.0.0' + flutter: ">=3.7.0" + + +dependencies: + flutter: + sdk: flutter + universal_platform: ^1.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 \ No newline at end of file diff --git a/packages/Flutter_Pinput/scripts/publish.sh b/packages/Flutter_Pinput/scripts/publish.sh new file mode 100644 index 000000000..3683d7f67 --- /dev/null +++ b/packages/Flutter_Pinput/scripts/publish.sh @@ -0,0 +1,38 @@ +# Publishing the package on pub.dev +function fail { + printf '%s\n' "$1" >&2 + exit "${2-1}" +} + +function run_dart_doc { + flutter pub global activate dartdoc + export FLUTTER_ROOT=~/fvm/default + dart doc . || fail "dart doc failed" +} + +function host_docs { + flutter pub global activate dhttpd + dhttpd --path doc/api +} + +function run_pana { + flutter pub global activate pana + flutter packages pub global run pana --exit-code-threshold=0 --no-warning --source path ./ || fail "run pana failed" +} + +function publish { + flutter packages pub publish || fail "pub publish failed" +} + +function format_and_analyze { + dart format . + flutter analyze || fail "flutter analyze failed" + flutter test || fail "flutter test failed" +} + +cd ../ +format_and_analyze +run_dart_doc +#host_docs +run_pana +#publish diff --git a/packages/Flutter_Pinput/test/helpers/helpers.dart b/packages/Flutter_Pinput/test/helpers/helpers.dart new file mode 100644 index 000000000..b15fe650a --- /dev/null +++ b/packages/Flutter_Pinput/test/helpers/helpers.dart @@ -0,0 +1 @@ +export 'pump_app.dart'; diff --git a/packages/Flutter_Pinput/test/helpers/pump_app.dart b/packages/Flutter_Pinput/test/helpers/pump_app.dart new file mode 100644 index 000000000..971376960 --- /dev/null +++ b/packages/Flutter_Pinput/test/helpers/pump_app.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension PumpApp on WidgetTester { + Future pumpApp(Widget widget) { + return pumpWidget( + MaterialApp(home: Material(child: widget)), + ); + } +} diff --git a/packages/Flutter_Pinput/test/pinput_test.dart b/packages/Flutter_Pinput/test/pinput_test.dart new file mode 100644 index 000000000..9a7ddcada --- /dev/null +++ b/packages/Flutter_Pinput/test/pinput_test.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinput/pinput.dart'; + +import 'helpers/helpers.dart'; + +void main() { + testWidgets('Pins are displayed', (WidgetTester tester) async { + const length = 4; + await tester.pumpApp(const Pinput(length: length)); + + expect(find.byType(Flexible), findsNWidgets(length)); + expect(find.byType(AnimatedContainer), findsNWidgets(length)); + expect(find.byType(Text), findsNWidgets(length)); + }); + + testWidgets('Should properly handle states', (WidgetTester tester) async { + const length = 4; + final focusNode = FocusNode(); + const defaultTheme = PinTheme(decoration: BoxDecoration(color: Colors.red)); + final focusedTheme = defaultTheme.copyDecorationWith( + color: Colors.greenAccent.withOpacity(.9), + ); + final submittedTheme = defaultTheme.copyDecorationWith( + color: Colors.greenAccent.withOpacity(.8), + ); + final followingTheme = defaultTheme.copyDecorationWith( + color: Colors.greenAccent.withOpacity(.7), + ); + final disabledTheme = defaultTheme.copyDecorationWith( + color: Colors.greenAccent.withOpacity(.6), + ); + final errorTheme = defaultTheme.copyDecorationWith( + color: Colors.greenAccent.withOpacity(.5), + ); + + await tester.pumpApp( + Pinput( + length: length, + focusNode: focusNode, + defaultPinTheme: defaultTheme, + focusedPinTheme: focusedTheme, + submittedPinTheme: submittedTheme, + followingPinTheme: followingTheme, + disabledPinTheme: disabledTheme, + errorPinTheme: errorTheme, + ), + ); + + void testState(int count, PinTheme theme) { + expect( + find.byWidgetPredicate( + (w) => w is AnimatedContainer && w.decoration == theme.decoration, + ), + findsNWidgets(count), + ); + } + + // Unfocused + testState(length, followingTheme); + testState(0, focusedTheme); + testState(0, submittedTheme); + testState(0, errorTheme); + testState(0, disabledTheme); + + //Focused + focusNode.requestFocus(); + await tester.pump(); + + testState(length - 1, followingTheme); + testState(1, focusedTheme); + testState(0, submittedTheme); + testState(0, errorTheme); + testState(0, disabledTheme); + + // Focused submitted + await tester.enterText(find.byType(EditableText), '1'); + await tester.pump(); + + testState(length - 2, followingTheme); + testState(1, focusedTheme); + testState(1, submittedTheme); + testState(0, errorTheme); + testState(0, disabledTheme); + + // UnFocused submitted + focusNode.unfocus(); + await tester.pump(); + + testState(length - 1, followingTheme); + testState(0, focusedTheme); + testState(1, submittedTheme); + testState(0, errorTheme); + testState(0, disabledTheme); + }); + + testWidgets('Should properly handle focused state', + (WidgetTester tester) async { + final focusNode = FocusNode(); + const defaultTheme = PinTheme(decoration: BoxDecoration()); + final focusedTheme = defaultTheme.copyDecorationWith(color: Colors.red); + await tester.pumpApp( + Pinput( + focusNode: focusNode, + autofocus: true, + defaultPinTheme: defaultTheme, + focusedPinTheme: focusedTheme, + ), + ); + + expect(focusNode.hasFocus, isTrue); + + await tester.pump(); + // Cursor + expect(find.text('|'), findsOneWidget); + + expect( + find.byWidgetPredicate( + (w) => + w is AnimatedContainer && w.decoration == focusedTheme.decoration, + ), + findsOneWidget, + ); + }); + + testWidgets('Should display custom cursor', (WidgetTester tester) async { + await tester.pumpApp( + const Pinput( + autofocus: true, + cursor: FlutterLogo(), + ), + ); + + await tester.pump(); + expect(find.byType(FlutterLogo), findsOneWidget); + }); + + group('onChanged should work properly', () { + testWidgets('onChanged should work with controller', + (WidgetTester tester) async { + String? fieldValue; + int called = 0; + + await tester.pumpApp( + Pinput( + onChanged: (value) { + fieldValue = value; + called++; + }, + ), + ); + + expect(fieldValue, isNull); + expect(called, 0); + + Future checkText(String testValue) async { + await tester.enterText(find.byType(EditableText), testValue); + expect(fieldValue, equals(testValue)); + } + + await checkText('123'); + expect(called, 1); + + await checkText('123'); + expect(called, 1); + + await checkText(''); + expect(called, 2); + }); + + testWidgets('onChanged should work with controller', + (WidgetTester tester) async { + String? fieldValue; + int called = 0; + final TextEditingController controller = TextEditingController(); + + await tester.pumpApp( + Pinput( + controller: controller, + onChanged: (value) { + fieldValue = value; + called++; + }, + ), + ); + + expect(fieldValue, isNull); + expect(called, 0); + + await tester.enterText(find.byType(EditableText), '11'); + expect(fieldValue, equals('11')); + expect(called, 1); + + controller.setText('12'); + expect(fieldValue, equals('12')); + expect(called, 2); + + controller.setText('12'); + expect(fieldValue, equals('12')); + expect(called, 2); + + controller.clear(); + expect(fieldValue, equals('')); + expect(called, 3); + }); + }); + + group('onCompleted should work properly', () { + testWidgets('onCompleted works without controller', + (WidgetTester tester) async { + String? fieldValue; + int called = 0; + + await tester.pumpApp( + Pinput( + length: 4, + onCompleted: (value) { + fieldValue = value; + called++; + }, + ), + ); + + expect(fieldValue, isNull); + expect(called, 0); + + await tester.enterText(find.byType(EditableText), '1234'); + expect(fieldValue, equals('1234')); + expect(called, 1); + + fieldValue = null; + await tester.enterText(find.byType(EditableText), '123'); + expect(fieldValue, isNull); + expect(called, 1); + }); + + testWidgets('onCompleted callback is called', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + String? fieldValue; + int called = 0; + + await tester.pumpApp( + Pinput( + controller: controller, + onCompleted: (value) { + fieldValue = value; + called++; + }, + ), + ); + + expect(fieldValue, isNull); + expect(called, 0); + + controller.setText('1234'); + expect(fieldValue, equals('1234')); + expect(called, 1); + + controller.clear(); + expect(fieldValue, equals('1234')); + expect(called, 1); + + fieldValue = null; + await tester.enterText(find.byType(EditableText), '123'); + expect(fieldValue, isNull); + expect(called, 1); + + controller.setText('12345'); + expect(fieldValue, isNull); + expect(called, 1); + }); + }); + + testWidgets('onTap is called upon tap', (WidgetTester tester) async { + int tapCount = 0; + + await tester.pumpApp( + Pinput( + onTap: () => ++tapCount, + ), + ); + + expect(tapCount, 0); + await tester.tap(find.byType(EditableText)); + // Wait a bit so they're all single taps and not double taps. + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(EditableText)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(EditableText)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tapCount, 3); + }); + + testWidgets('onTap is not called, field is disabled', + (WidgetTester tester) async { + int tapCount = 0; + + await tester.pumpApp( + Pinput( + enabled: false, + onTap: () => ++tapCount, + ), + ); + + expect(tapCount, 0); + await tester.tap(find.byType(EditableText), warnIfMissed: false); + await tester.tap(find.byType(EditableText), warnIfMissed: false); + expect(tapCount, 0); + }); + + testWidgets('onLongPress is called', (WidgetTester tester) async { + int tapCount = 0; + + await tester.pumpApp( + Pinput( + onLongPress: () => ++tapCount, + ), + ); + + expect(tapCount, 0); + await tester.longPress(find.byType(EditableText)); + // Wait a bit so they're all single taps and not double taps. + await tester.pump(const Duration(milliseconds: 300)); + await tester.longPress(find.byType(EditableText)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.longPress(find.byType(EditableText)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tapCount, 3); + }); + + testWidgets('onSubmitted callback is called', (WidgetTester tester) async { + String? fieldValue; + + await tester.pumpApp( + Pinput( + onSubmitted: (value) => fieldValue = value, + ), + ); + + expect(fieldValue, isNull); + + await tester.enterText(find.byType(EditableText), '123'); + await tester.testTextInput.receiveAction(TextInputAction.done); + expect(fieldValue, equals('123')); + }); +} -- Gitee