Skip to main content

pinput 8.0.0-beta.2 copy "pinput: ^8.0.0-beta.2" to clipboard
pinput: ^8.0.0-beta.2 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.

Pub CI Stars Style License: MIT

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.

Support on Ko-fi Buy Me a Coffee


Live demo Rounded with shadows Rounded with cursor
Live demo Rounded with shadows Rounded with cursor
More styles
Rounded filled Bottom cursor Filled
Rounded filled Bottom cursor Filled

Features #

  • Animated decoration switching between pin states
  • App-controlled validation feedback, with Form integration through FormField
  • 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 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, …), but copyDecorationWith and copyBorderWith require a BoxDecoration.
  • PinTheme.defaults is 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)) or PinObscuring.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 own State. Don't reuse a masking widget containing a shared GlobalKey across slots.
  • The original text remains available in the controller and callbacks.
  • With itemBuilder, obscuring only 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: false to disable interactive selection. Set contextMenuBuilder: null to hide the modern context menu while keeping selection enabled. Legacy custom selectionControls 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:

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.readOnly or PinInputMode.disabled explicitly when needed.
  • errorTextStyle styles the default error text. errorBuilder(context, message, pin) replaces the default error widget and ignores errorTextStyle. Both work in Pinput, including with itemBuilder.
  • The message of invalid() is optional: without it, error styling applies with no default text or error padding. An errorBuilder still runs with a null message, so it can supply its own localized text.
  • The validating() factory uses the PinValidationValidating subtype for pattern matching. PinAnimation and PinObscuring support 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 2222 for success. See AsyncValidationExample.

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 Pinput with a TextField and 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
  • length defaults to 6, not 4. Pass length: 4 to 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 to fallback, which defaults to PinTheme.defaults.
  • The built-in look marks states. Without a theme, focused, disabled and error pins look different. Set fallback or use PinThemes.all to keep one look.
  • Validation is app-owned. Pinput no longer runs validators or registers a FormField. Use validationState, see Validation and Form integration.
  • SMS autofill on Android is app-owned. Listen for the code in your State and call controller.setText, see SMS autofill. iOS autofill is unchanged.
  • Over-long programmatic text is cut to length, like typed text, and onCompleted fires.
  • 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.disableAnimations is set.
  • disabled never takes focus, and customKeyboard is focusable but ignores pointer input.
  • With itemBuilder, theme and cursor are not used, and obscuring only 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 custom errorBuilder if 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.

3.51k
likes
160
points
618k
downloads
screenshot

Documentation

API reference

Publisher

verified publishertornike.dev

Weekly Downloads

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

Repository (GitHub)
View/report issues

Topics

#otp #pin-input #pin-code #passcode #sms-autofill

Funding

Consider supporting this project:

ko-fi.com

License

MIT (license)

Dependencies

flutter

More

Packages that depend on pinput