pinput 8.0.0-beta.1
pinput: ^8.0.0-beta.1 copied to clipboard
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
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, app-controlled validation feedback, SMS autofill, custom cursor, and more. It also provides beautiful examples that you can choose from.
Features: #
- Animated Decoration Switching
- App-controlled validation feedback (Form integration via
FormField) - SMS Autofill on iOS
- SMS Autofill on Android (set the code on the controller, see Android)
- Standard Cursor
- Custom Cursor
- Cursor Animation
- Ready For Custom Keyboard
- Standard Paste option
- Obscuring Character
- Obscuring Widget
- Haptic Feedback
- Close Keyboard After Completion
- Beautiful Examples
Support #
PRs Welcome
Discord Channel
Examples app on Github has multiple templates to choose from
Don't forget to give it a star ⭐
Demo #
| Live Demo | Rounded With Shadows | Rounded With Cursor |
|---|---|---|
![]() |
![]() |
![]() |
| Rounded Filled | With Bottom Cursor | Filled |
|---|---|---|
![]() |
![]() |
![]() |
Getting Started #
The pin has 6 states default focused, filled, empty, 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
Widget buildPinPut() {
return Pinput(
onCompleted: (pin) => print(pin),
);
}
If you want to customize it, create a base PinTheme first.
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 in every state, pass PinThemes.all(defaultPinTheme). Without a theme the built-in look
marks the focused, disabled and error states.
If not, create the focused, filled, empty, disabled and error themes from defaultPinTheme
final focusedPinTheme = defaultPinTheme.copyDecorationWith(
border: Border.all(color: Color.fromRGBO(114, 178, 238, 1)),
borderRadius: BorderRadius.circular(8),
);
final filledPinTheme = defaultPinTheme.copyDecorationWith(
color: Color.fromRGBO(234, 239, 243, 1),
);
Put everything together
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 filledPinTheme = defaultPinTheme.copyDecorationWith(
color: Color.fromRGBO(234, 239, 243, 1),
);
return Pinput(
theme: PinThemes(
fallback: defaultPinTheme,
focused: focusedPinTheme,
filled: filledPinTheme,
),
validationState: const PinValidationState.idle(),
cursor: const PinCursor.blinking(),
onCompleted: (pin) => print(pin),
);
PinThemes mirrors WidgetStateProperty: use PinThemes(fallback:, focused:, …), PinThemes.all, or
PinThemes.resolveWith((item, validation) => ...) to pick a theme per pin. item has the pin's index, value,
type (PinItemStateType: focused, filled, empty, disabled, error), isFilled, isFocused and showCursor.
isFocused stays accurate while type is error, and showCursor tells a full PIN's focused last pin from the cursor.
In the named form, a state you don't set uses fallback. Set valid or validating to style the pins while the app
supplies PinValidationState.valid() or .validating(); otherwise those states don't change the look.
Implement PinThemes to share a theme as your own type, or wrap part of your app in
DefaultPinThemes(themes: ..., child: ...) to style every Pinput below it that has no theme.
A state theme is used exactly as written and is not merged with fallback, so derive it
with copyWith. PinTheme.decoration accepts any Decoration (BoxDecoration, ShapeDecoration, …); copyDecorationWith and copyBorderWith require a BoxDecoration. PinTheme.placeholder replaces preFilledWidget. PinTheme.defaults is the built-in theme to derive from.
SMS Autofill #
iOS #
Works out of the box, by tapping the code on top of the keyboard
Android #
If you are using firebase_auth you have to set controller's value in verificationCompleted callback, here is an example code:
Pinput(
controller: pinController,
);
And set pinController's value in verificationCompleted callback:
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 and SMS User Consent API,
SmartAuth is a wrapper package for Flutter for these APIs, so go ahead and add it as a dependency.
Pinput doesn't read SMS itself. Your app listens for the code and sets it on the controller, so it also owns starting and stopping the listener:
class _OtpPageState extends State<OtpPage> {
final pinController = TextEditingController();
@override
void initState() {
super.initState();
_listenForSmsCode();
}
Future<void> _listenForSmsCode() async {
final res = await SmartAuth.instance.getSmsWithRetrieverApi();
final code = res.data?.code;
if (mounted && code != null) pinController.setText(code);
}
@override
void dispose() {
SmartAuth.instance.removeSmsRetrieverApiListener();
pinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Pinput(controller: pinController);
}
To keep listening for more messages, call _listenForSmsCode again after each result.
SMS Retriever API
To use Retriever API you need the App signature - guide
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
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
See Example app for more templates #
Tips #
-
Controller
/// Create Controller
final pinController = TextEditingController();
/// Set text programmatically
pinController.setText('1222');
/// Append typed character, useful if you are using custom keyboard
pinController.appendPin('1', 4);
/// Delete last character
pinController.deleteLastPin();
/// Text longer than Pinput.length is cut to that length, like typed text.
/// Don't call setText, appendPin, deleteLastPin in build method, this is just illustration.
return Pinput(
controller: pinController,
);
-
Focus
/// 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,
);
-
Cursor, masking, and animation
Pinput(
cursor: PinCursor.blinking(child: MyCursor()), // static() or hidden() are also available
obscuring: const PinObscuring.character('*'), // null displays original text
animation: const PinAnimation.slide(
beginOffset: Offset(0, 0.5),
duration: Duration(milliseconds: 250),
),
)
PinObscuring.character() uses a bullet by default. To keep a typed pin visible for a moment before masking, pass a
duration: PinObscuring.character('*', Duration(seconds: 1)) or PinObscuring.widget(MyDot(), revealDuration: …).
PinObscuring.widget(MyDot()) supplies custom visible masking and uses a bullet
for native obscuring. A character must be one UTF-16 code unit, matching Flutter's
native requirement. Original text remains available in the controller and callbacks.
Masking widgets may be stateful: each slot creates its own State. Do not reuse
a masking widget containing a shared GlobalKey across slots.
PinAnimation.custom(transitionBuilder: …) supplies your own content transition, and
PinCursor.blinking(blinkDuration: …) sets the blink speed. Animations and blinking stop when the platform asks to reduce
motion. PinLayout(textDirection: TextDirection.ltr) keeps a numeric code left to right in an RTL app.
With itemBuilder, obscuring only enables native masking; the builder must mask
visible content itself.
PinAnimation supports none(), scale() (default), fade(), slide(), and
rotation(). Slide defaults to Offset(0.8, 0). Every constructor accepts duration and curve,
which control the transition, decoration, and error layout.
Cursor blinking is independent of pin-content transitions.
Set enableInteractiveSelection: false to disable interactive selection.
Set contextMenuBuilder: null to hide the modern context menu while keeping
selection enabled. Legacy custom selectionControls that build their own
toolbar follow Flutter's legacy toolbar behavior; use controls with
TextSelectionHandleControls to customize handles with the modern menu API.
-
Validation
Pinput renders app-owned validation feedback through validationState:
The validating() factory uses the PinValidationValidating subtype for
pattern matching. Presentation configurations (PinAnimation and PinObscuring)
support value equality; custom masking widgets use normal widget equality.
| State | Behavior |
|---|---|
PinValidationState.idle() |
Default; no current result or feedback. |
PinValidationState.validating() |
Pending; normal appearance, no error. |
PinValidationState.valid() |
Passed; normal appearance, no error. |
PinValidationState.invalid([message]) |
Error pin theme and optional error message, even while focused. |
Pinput(
validationState: state.pinValidation,
onChanged: cubit.pinChanged,
onCompleted: cubit.verifyPin,
)
Run synchronous or asynchronous validation in your app, using onChanged,
onCompleted, onSubmitted, or a submit button. onCompleted reports a full PIN
with the cursor at the end, not successful validation. Clear old results when
the PIN changes and ignore async responses for outdated inputs or disposed views.
Pinput does not clear supplied feedback, run checks, or modify text or focus.
Pending and valid states do not add loading/success visuals or disable editing;
use inputMode: PinInputMode.readOnly or PinInputMode.disabled explicitly when needed.
For a runnable async example, open Async example in the default example app,
or the Async validation tab in the demo gallery. Enter 2222 for success.
The example shows progress, errors, and discarding outdated responses when edited.
See AsyncValidationExample.
errorTextStyle styles the default error text. errorBuilder(context, message, pin)
replaces the default error widget and ignores errorTextStyle. Both are available
in Pinput, including with itemBuilder.
The invalid state's message is optional: invalid() applies error styling
without default text or error padding. An errorBuilder still runs with a null
message, so it can supply its own localized text or other content.
Flutter Form integration #
Pinput does not register itself with Form. Wrap it in FormField<String>:
FormField<String>(
initialValue: '',
validator: (pin) => pin == '2224' ? null : 'Pin is incorrect',
builder: (field) => Pinput(
onChanged: field.didChange,
validationState: field.errorText == null
? const PinValidationState.idle()
: PinValidationState.invalid(field.errorText!),
),
)
Configure autovalidateMode on the wrapper for automatic Form validation.
FormState.validate() remains synchronous: it cannot await server verification.
An external async result must already be available if the wrapper uses it.
When using a text controller, synchronize it with the wrapper using a listener (including programmatic changes). For example, in the owning State:
final pinController = TextEditingController();
final pinFieldKey = GlobalKey<FormFieldState<String>>();
@override
void initState() {
super.initState();
pinController.addListener(() {
final field = pinFieldKey.currentState;
if (field != null && field.value != pinController.text) {
field.didChange(pinController.text);
}
});
}
// Inside the Form:
FormField<String>(
key: pinFieldKey,
initialValue: '',
onReset: pinController.clear,
validator: (pin) => pin == '2224' ? null : 'Pin is incorrect',
builder: (field) => Pinput(
controller: pinController,
validationState: field.errorText == null
? const PinValidationState.idle()
: PinValidationState.invalid(field.errorText!),
),
)
Dispose pinController in the owning State. For non-empty initial values, restore
that same text in onReset rather than clearing it.
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:
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) {},
);






