pinput 8.0.0-beta.3
pinput: ^8.0.0-beta.3 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
Pinput #
A customizable PIN / OTP code input for Flutter, with SMS autofill.
Live demo · Install · Usage · SMS autofill · Migrating · Examples · pub.dev · Changelog
🚀 Have a project in mind? Let's work together on LinkedIn.
Enjoying this package? Help keep it maintained.
| Live demo | Rounded with shadows | Rounded with cursor |
|---|---|---|
![]() |
![]() |
![]() |
More styles
| Rounded filled | Bottom cursor | Filled |
|---|---|---|
![]() |
![]() |
![]() |
Features #
- Animated decoration switching between pin states
- App-controlled validation feedback, with
Formintegration throughFormField - SMS autofill on iOS and Android
- Standard, custom and animated cursor
- Obscuring by character or widget
- Custom keyboard support, paste, haptic feedback
- Closes the keyboard after completion
- Ready-made templates to pick from
Platforms: Android, iOS, web, macOS, Windows and Linux. SMS autofill works out of the box on iOS; on Android the app sets the code on the controller, see SMS autofill.
Install #
flutter pub add pinput
import 'package:pinput/pinput.dart';
Usage #
Pinput(
onCompleted: (pin) => print(pin),
)
By default Pinput shows 6 pins. Set length for another size, for example Pinput(length: 4).
Use inputMode to change how it accepts input:
PinInputMode |
Behavior |
|---|---|
editable |
Default; the native keyboard edits the PIN |
readOnly |
Focusable and selectable, but can't be edited |
disabled |
Ignores input and uses the disabled pin state |
customKeyboard |
Focusable, but ignores pointer input and never opens the native keyboard. Drive it with your own keys, see Controller and focus |
Theming #
A pin has six states: default, focused, filled, empty, disabled and error. Each can have its own theme, and the pin animates between them automatically. Create a base PinTheme, then derive the others from it:
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),
);
To make every state look the same, pass PinThemes.all(defaultPinTheme). Without a theme, the built-in look marks the focused, disabled and error states.
PinTheme property |
Type / default |
|---|---|
width |
56.0 |
height |
60.0 |
textStyle |
TextStyle |
margin, padding |
EdgeInsetsGeometry |
constraints |
BoxConstraints |
decoration |
any Decoration |
placeholder |
Widget (replaces preFilledWidget) |
PinThemes in depth
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
validorvalidatingto style the pins while the app suppliesPinValidationState.valid()or.validating(). Otherwise those states don't change the look. - Implement
PinThemesto share a theme as your own type, or wrap part of your app inDefaultPinThemes(themes: ..., child: ...)to style everyPinputbelow it that has notheme. - A state theme is used exactly as written and is not merged with
fallback, so derive it withcopyWith. PinTheme.decorationaccepts anyDecoration(BoxDecoration,ShapeDecoration, …), butcopyDecorationWithandcopyBorderWithrequire aBoxDecoration.PinTheme.defaultsis the built-in theme to derive from.
Controller and focus #
final pinController = TextEditingController();
pinController.setText('1222'); // set text programmatically
pinController.appendPin('1', 4); // append a typed character, useful for a custom keyboard
pinController.deleteLastPin(); // delete the last character
// Text longer than Pinput.length is cut to that length, like typed text.
final pinputFocusNode = FocusNode();
pinputFocusNode.requestFocus();
pinputFocusNode.unfocus();
Pinput(
controller: pinController,
focusNode: pinputFocusNode,
);
Don't call setText, appendPin, deleteLastPin, requestFocus or unfocus in build. The snippet is only an illustration.
Cursor, masking and animation #
Pinput(
cursor: PinCursor.blinking(child: MyCursor()), // static() or hidden() are also available
obscuring: const PinObscuring.character('*'), // null shows the original text
animation: const PinAnimation.slide(
beginOffset: Offset(0, 0.5),
duration: Duration(milliseconds: 250),
),
)
PinAnimation supports none(), scale() (default), fade(), slide(), rotation() and custom(transitionBuilder: …). Every constructor accepts duration and curve. Cursor blinking is independent of pin-content transitions, and PinCursor.blinking(blinkDuration: …) sets its speed. Animations and blinking stop when the platform asks to reduce motion.
Obscuring, layout and selection details
PinObscuring.character()uses a bullet by default. A character must be one UTF-16 code unit, matching Flutter's native requirement.- To keep a typed pin visible for a moment before masking, pass a duration:
PinObscuring.character('*', Duration(seconds: 1))orPinObscuring.widget(MyDot(), revealDuration: …). PinObscuring.widget(MyDot())supplies custom visible masking and uses a bullet for native obscuring. Masking widgets may be stateful: each slot creates its ownState. Don't reuse a masking widget containing a sharedGlobalKeyacross slots.- The original text remains available in the controller and callbacks.
- With
itemBuilder,obscuringonly enables native masking. The builder must mask visible content itself. PinLayout(textDirection: TextDirection.ltr)keeps a numeric code left to right in an RTL app.- Set
enableInteractiveSelection: falseto disable interactive selection. SetcontextMenuBuilder: nullto hide the modern context menu while keeping selection enabled. Legacy customselectionControlsfollow Flutter's legacy toolbar behavior. Use controls withTextSelectionHandleControlsto customize handles with the modern menu API.
Validation #
Pinput renders app-owned validation feedback through validationState:
| 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.
Validation details and error widgets
- Pinput does not clear supplied feedback, run checks, or modify text or focus.
- Pending and valid states add no loading or success visuals and don't disable editing. Use
inputMode: PinInputMode.readOnlyorPinInputMode.disabledexplicitly when needed. errorTextStylestyles the default error text.errorBuilder(context, message, pin)replaces the default error widget and ignoreserrorTextStyle. Both work inPinput, including withitemBuilder.- The message of
invalid()is optional: without it, error styling applies with no default text or error padding. AnerrorBuilderstill runs with a null message, so it can supply its own localized text. - The
validating()factory uses thePinValidationValidatingsubtype for pattern matching.PinAnimationandPinObscuringsupport value equality, and custom masking widgets use normal widget equality. - For a runnable async example, open Async example in the default example app, or the Async validation tab in the demo gallery. Enter
2222for success. SeeAsyncValidationExample.
Form integration #
Pinput does not register itself with Form. Wrap it in a 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!),
),
)
Set autovalidateMode on the wrapper for automatic validation. FormState.validate() is synchronous, so it can't await server verification. An external async result must already be available if the wrapper uses it.
Using a text controller with FormField
Synchronize the controller with the wrapper using a listener, including programmatic changes. 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.
SMS autofill #
iOS #
Works out of the box: the user taps the code above the keyboard.
Android #
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.
With firebase_auth, set the controller in the verificationCompleted callback:
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) {},
);
Without firebase_auth, use the SMS Retriever API or the SMS User Consent API. smart_auth wraps both:
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.
| API | App signature | User interaction | Example |
|---|---|---|---|
| SMS Retriever | Required | None, the code is applied automatically | example |
| SMS User Consent | Not needed | The user confirms reading the message | example |
SMS Retriever: app signature and message format
You need the app signature, see this guide. The signature can differ between debug and release builds.
Include it in the SMS your backend sends:
Your ExampleApp code is: 123456
kg+TZ3A5qzS
FAQ #
Autofill isn't working on iOS
- Use a real device, not the simulator.
- Temporarily replace
Pinputwith aTextFieldand check if autofill works. If not, it's probably a problem with the SMS you receive, since autofill doesn't work with most languages. - On a non-stable Flutter version, something may be broken inside the framework.
I'm using firebase_auth
Set the controller's value in the verificationCompleted callback, see SMS autofill.
Migrating to 8.0 #
Renamed and removed options
| Previous options | Replacement |
|---|---|
defaultPinTheme, focusedPinTheme, submittedPinTheme, followingPinTheme, disabledPinTheme, errorPinTheme |
theme: PinThemes(fallback:, focused:, filled:, empty:, disabled:, error:) |
preFilledWidget |
PinTheme(placeholder: widget) |
pinContentAlignment |
PinTheme(alignment: …) |
animationDuration, animationCurve, pinAnimationType, slideTransitionBeginOffset |
animation: PinAnimation.none/scale/fade/slide/rotation(duration:, curve:) |
showCursor: false |
cursor: const PinCursor.hidden() |
showCursor: true, isCursorAnimationEnabled: false |
cursor: const PinCursor.static() |
cursor: widget |
cursor: PinCursor.blinking(child: widget) (or static) |
enabled: false |
inputMode: PinInputMode.disabled |
readOnly: true |
inputMode: PinInputMode.readOnly |
useNativeKeyboard: false |
inputMode: PinInputMode.customKeyboard |
obscureText, obscuringCharacter, obscuringWidget |
obscuring: PinObscuring.character('*') or PinObscuring.widget(widget); null for none |
toolbarEnabled: false |
enableInteractiveSelection: false, or contextMenuBuilder: null |
mainAxisAlignment, crossAxisAlignment, separatorBuilder |
layout: PinLayout(…); separatorBuilder now takes (context, index) |
errorBuilder: (message, pin) |
errorBuilder: (context, message, pin) |
keyboardType, textCapitalization, textInputAction, keyboardAppearance, enableSuggestions, autofillHints |
keyboard: PinKeyboard(…) |
Pinput.builder(builder:) |
Pinput(itemBuilder:) |
pinEditMode |
editMode |
PinItemStateType.submitted, .following, .initial |
.filled, .empty, and PinThemes(fallback:) |
PinputValidationState and subtypes |
PinValidationState and subtypes |
controller.length, .append(…), .delete() |
controller.pinLength, .appendPin(…), .deleteLastPin() |
PinTheme.apply |
Use copyWith |
onClipboardFound, onAppPrivateCommand, enableIMEPersonalizedLearning |
Removed |
smsRetriever, AndroidSmsAutofillMethod |
Removed, see SMS autofill |
validator, pinputAutovalidateMode, errorText, forceErrorState, showErrorWhenFocused |
Removed, see Validation |
Behavior changes
lengthdefaults to 6, not 4. Passlength: 4to keep four pins.- Themes are not merged. A state theme is used exactly as written, so derive it with
copyWith. A state you leave out falls back tofallback, which defaults toPinTheme.defaults. - The built-in look marks states. Without a
theme, focused, disabled and error pins look different. Setfallbackor usePinThemes.allto keep one look. - Validation is app-owned. Pinput no longer runs validators or registers a
FormField. UsevalidationState, see Validation and Form integration. - SMS autofill on Android is app-owned. Listen for the code in your
Stateand callcontroller.setText, see SMS autofill. iOS autofill is unchanged. - Over-long programmatic text is cut to
length, like typed text, andonCompletedfires. - Screen readers get the value once, from the input, instead of each pin.
- Reduced motion is respected: animations take no time and the cursor doesn't blink when
MediaQuery.disableAnimationsis set. disablednever takes focus, andcustomKeyboardis focusable but ignores pointer input.- With
itemBuilder,themeandcursorare not used, andobscuringonly enables native masking, so the builder masks visible content itself. - The default error message is announced as a live region. Add your own
Semantics(liveRegion: true)to a customerrorBuilderif needed.
Contributing #
PRs are welcome. The example app has multiple templates to choose from.
Made by Tornike · MIT License
If this package helps you, a ⭐ on GitHub means a lot.






