diff --git a/lib/src/pinput.dart b/lib/src/pinput.dart index e0c8c08..64131f1 100644 --- a/lib/src/pinput.dart +++ b/lib/src/pinput.dart @@ -6,6 +6,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/scheduler.dart'; part 'pinput_state.dart'; @@ -95,9 +96,7 @@ class Pinput extends StatefulWidget { this.keyboardAppearance, this.inputFormatters = const [], this.textInputAction, - this.autofillHints = const [ - AutofillHints.oneTimeCode, - ], + this.autofillHints = const [AutofillHints.oneTimeCode], this.obscuringCharacter = '•', this.obscuringWidget, this.selectionControls, @@ -112,16 +111,17 @@ class Pinput extends StatefulWidget { this.errorBuilder, this.errorTextStyle, this.pinputAutovalidateMode = PinputAutovalidateMode.onSubmit, + this.autovalidateMode = AutovalidateMode.disabled, this.scrollPadding = const EdgeInsets.all(20), this.contextMenuBuilder = _defaultContextMenuBuilder, super.key, - }) : assert(obscuringCharacter.length == 1), - assert(length > 0), - assert( - textInputAction != TextInputAction.newline, - 'Pinput is not multiline', - ), - _builder = null; + }) : assert(obscuringCharacter.length == 1), + assert(length > 0), + assert( + textInputAction != TextInputAction.newline, + 'Pinput is not multiline', + ), + _builder = null; /// Creates a PinPut widget with custom pin item builder /// This gives you full control over the pin item widget @@ -166,38 +166,37 @@ class Pinput extends StatefulWidget { this.showErrorWhenFocused = false, this.validator, this.pinputAutovalidateMode = PinputAutovalidateMode.onSubmit, + this.autovalidateMode = AutovalidateMode.disabled, this.scrollPadding = const EdgeInsets.all(20), this.contextMenuBuilder = _defaultContextMenuBuilder, super.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; + }) : 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; /// Theme of the pin in default state final PinTheme? defaultPinTheme; @@ -421,6 +420,13 @@ class Pinput extends StatefulWidget { /// Return null if pin is valid or any String otherwise final PinputAutovalidateMode pinputAutovalidateMode; + /// Controls when the [validator] is automatically invoked. + /// + /// When set to [AutovalidateMode.onUserInteraction], the validator fires on + /// every keystroke so errors appear as the user types. Defaults to + /// [AutovalidateMode.disabled] to preserve existing behavior. + final AutovalidateMode autovalidateMode; + /// 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. @@ -538,8 +544,9 @@ class Pinput extends StatefulWidget { defaultValue: null, ), ); - properties - .add(DiagnosticsProperty('enabled', enabled, defaultValue: true)); + properties.add( + DiagnosticsProperty('enabled', enabled, defaultValue: true), + ); properties.add( DiagnosticsProperty( 'closeKeyboardWhenCompleted', @@ -666,8 +673,9 @@ class Pinput extends StatefulWidget { defaultValue: null, ), ); - properties - .add(DiagnosticsProperty('enabled', enabled, defaultValue: true)); + properties.add( + DiagnosticsProperty('enabled', enabled, defaultValue: true), + ); properties.add( DiagnosticsProperty('readOnly', readOnly, defaultValue: false), ); @@ -696,11 +704,7 @@ class Pinput extends StatefulWidget { ), ); properties.add( - DiagnosticsProperty( - 'showCursor', - showCursor, - defaultValue: true, - ), + DiagnosticsProperty('showCursor', showCursor, defaultValue: true), ); properties.add( DiagnosticsProperty( @@ -821,6 +825,13 @@ class Pinput extends StatefulWidget { defaultValue: PinputAutovalidateMode.onSubmit, ), ); + properties.add( + EnumProperty( + 'autovalidateMode', + autovalidateMode, + defaultValue: AutovalidateMode.disabled, + ), + ); properties.add( DiagnosticsProperty( 'hapticFeedbackType', diff --git a/lib/src/pinput_state.dart b/lib/src/pinput_state.dart index 601f1d1..bb5213e 100644 --- a/lib/src/pinput_state.dart +++ b/lib/src/pinput_state.dart @@ -38,7 +38,8 @@ class _PinputState extends State String? get _errorText => widget.errorText ?? _validatorErrorText; bool get _canRequestFocus { - final NavigationMode mode = MediaQuery.maybeOf(context)?.navigationMode ?? + final NavigationMode mode = + MediaQuery.maybeOf(context)?.navigationMode ?? NavigationMode.traditional; switch (mode) { case NavigationMode.traditional: @@ -74,8 +75,9 @@ class _PinputState extends State @override void initState() { super.initState(); - _gestureDetectorBuilder = - _PinputSelectionGestureDetectorBuilder(state: this); + _gestureDetectorBuilder = _PinputSelectionGestureDetectorBuilder( + state: this, + ); if (widget.controller != null) { _recentControllerValue = widget.controller!.value; widget.controller!.addListener(_handleTextEditingControllerChanges); @@ -213,11 +215,13 @@ class _PinputState extends State ) { // Selecting part of the text is not allowed. final allSelected = selection.start == 0 && selection.end == _currentLength; - final lastCharSelected = selection.start == _currentLength - 1 && + final lastCharSelected = + selection.start == _currentLength - 1 && selection.end == _currentLength; if (!allSelected && !lastCharSelected) { - _effectiveController.selection = - TextSelection.collapsed(offset: _currentLength); + _effectiveController.selection = TextSelection.collapsed( + offset: _currentLength, + ); } switch (Theme.of(context).platform) { @@ -279,6 +283,22 @@ class _PinputState extends State } String? _validator([String? _]) { + // When autovalidateMode triggers validation during FormField's build phase, + // skip calling the user's validator entirely and defer to the next frame. + // This prevents setState-during-build violations in the user's validator + // callback (e.g. calling setState to update a counter). + if (SchedulerBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final res = widget.validator?.call(pin); + if (res != _validatorErrorText) { + setState(() => _validatorErrorText = res); + } + } + }); + return _validatorErrorText; + } final res = widget.validator?.call(pin); setState(() => _validatorErrorText = res); return res; @@ -338,6 +358,7 @@ class _PinputState extends State return _PinputFormField( enabled: isEnabled, validator: _validator, + autovalidateMode: widget.autovalidateMode, initialValue: _effectiveController.text, builder: (field) { return MouseRegion( @@ -451,8 +472,9 @@ class _PinputState extends State onSelectionChanged: _handleSelectionChanged, onSelectionHandleTapped: _handleSelectionHandleTapped, readOnly: widget.readOnly || !isEnabled || !widget.useNativeKeyboard, - selectionControls: - widget.toolbarEnabled ? textSelectionControls : null, + selectionControls: widget.toolbarEnabled + ? textSelectionControls + : null, keyboardAppearance: widget.keyboardAppearance ?? Theme.of(context).brightness, ), @@ -482,8 +504,9 @@ class _PinputState extends State void _semanticsOnTap() { if (!_effectiveController.selection.isValid) { - _effectiveController.selection = - TextSelection.collapsed(offset: _effectiveController.text.length); + _effectiveController.selection = TextSelection.collapsed( + offset: _effectiveController.text.length, + ); } _requestKeyboard(); } @@ -532,9 +555,10 @@ class _PinputState extends State return Center( child: AnimatedBuilder( - animation: Listenable.merge( - [_effectiveFocusNode, _effectiveController], - ), + animation: Listenable.merge([ + _effectiveFocusNode, + _effectiveController, + ]), builder: (BuildContext context, Widget? child) { final shouldHideErrorContent = widget.validator == null && widget.errorText == null; @@ -546,10 +570,7 @@ class _PinputState extends State alignment: Alignment.topCenter, child: Column( crossAxisAlignment: widget.crossAxisAlignment, - children: [ - onlyFields(), - _buildError(), - ], + children: [onlyFields(), _buildError()], ), ); }, @@ -582,9 +603,11 @@ class _PinputState extends State padding: const EdgeInsetsDirectional.only(start: 4, top: 8), child: Text( _errorText!, - style: widget.errorTextStyle ?? - theme.textTheme.titleMedium - ?.copyWith(color: theme.colorScheme.error), + style: + widget.errorTextStyle ?? + theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.error, + ), ), ); } @@ -600,8 +623,9 @@ class _PinputState extends State @override TextInputConfiguration get textInputConfiguration { - final List? autofillHints = - widget.autofillHints?.toList(growable: false); + final List? autofillHints = widget.autofillHints?.toList( + growable: false, + ); final AutofillConfiguration autofillConfiguration = autofillHints != null ? AutofillConfiguration( uniqueIdentifier: autofillId, @@ -610,7 +634,8 @@ class _PinputState extends State ) : AutofillConfiguration.disabled; - return _editableText!.textInputConfiguration - .copyWith(autofillConfiguration: autofillConfiguration); + return _editableText!.textInputConfiguration.copyWith( + autofillConfiguration: autofillConfiguration, + ); } } diff --git a/lib/src/widgets/widgets.dart b/lib/src/widgets/widgets.dart index 005f1dc..18d669c 100644 --- a/lib/src/widgets/widgets.dart +++ b/lib/src/widgets/widgets.dart @@ -10,9 +10,8 @@ class _PinputFormField extends FormField { required super.enabled, required super.initialValue, required super.builder, - }) : super( - autovalidateMode: AutovalidateMode.disabled, - ); + super.autovalidateMode = AutovalidateMode.disabled, + }); } class _SeparatedRaw extends StatelessWidget { @@ -36,10 +35,12 @@ class _SeparatedRaw extends StatelessWidget { 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), + children: indexedList + .map((index) { + final itemIndex = index ~/ 2; + return index.isEven ? children[itemIndex] : _separator(itemIndex); + }) + .toList(growable: false), ); } @@ -61,10 +62,7 @@ class _PinputAnimatedCursor extends StatefulWidget { final Widget? cursor; final TextStyle? textStyle; - const _PinputAnimatedCursor({ - required this.textStyle, - required this.cursor, - }); + const _PinputAnimatedCursor({required this.textStyle, required this.cursor}); @override State<_PinputAnimatedCursor> createState() => _PinputAnimatedCursorState(); diff --git a/test/pinput_test.dart b/test/pinput_test.dart index 2cd21da..c2f78ec 100644 --- a/test/pinput_test.dart +++ b/test/pinput_test.dart @@ -94,8 +94,9 @@ void main() { testState(0, disabledTheme); }); - testWidgets('Should properly handle focused state', - (WidgetTester tester) async { + testWidgets('Should properly handle focused state', ( + WidgetTester tester, + ) async { final focusNode = FocusNode(); const defaultTheme = PinTheme(decoration: BoxDecoration()); final focusedTheme = defaultTheme.copyDecorationWith(color: Colors.red); @@ -124,20 +125,16 @@ void main() { }); testWidgets('Should display custom cursor', (WidgetTester tester) async { - await tester.pumpApp( - const Pinput( - autofocus: true, - cursor: FlutterLogo(), - ), - ); + 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 { + testWidgets('onChanged should work with controller', ( + WidgetTester tester, + ) async { String? fieldValue; int called = 0; @@ -168,8 +165,9 @@ void main() { expect(called, 2); }); - testWidgets('onChanged should work with controller', - (WidgetTester tester) async { + testWidgets('onChanged should work with controller', ( + WidgetTester tester, + ) async { String? fieldValue; int called = 0; final TextEditingController controller = TextEditingController(); @@ -206,8 +204,9 @@ void main() { }); group('onCompleted should work properly', () { - testWidgets('onCompleted works without controller', - (WidgetTester tester) async { + testWidgets('onCompleted works without controller', ( + WidgetTester tester, + ) async { String? fieldValue; int called = 0; @@ -274,11 +273,7 @@ void main() { testWidgets('onTap is called upon tap', (WidgetTester tester) async { int tapCount = 0; - await tester.pumpApp( - Pinput( - onTap: () => ++tapCount, - ), - ); + await tester.pumpApp(Pinput(onTap: () => ++tapCount)); expect(tapCount, 0); await tester.tap(find.byType(EditableText)); @@ -291,16 +286,12 @@ void main() { expect(tapCount, 3); }); - testWidgets('onTap is not called, field is disabled', - (WidgetTester tester) async { + testWidgets('onTap is not called, field is disabled', ( + WidgetTester tester, + ) async { int tapCount = 0; - await tester.pumpApp( - Pinput( - enabled: false, - onTap: () => ++tapCount, - ), - ); + await tester.pumpApp(Pinput(enabled: false, onTap: () => ++tapCount)); expect(tapCount, 0); await tester.tap(find.byType(EditableText), warnIfMissed: false); @@ -311,11 +302,7 @@ void main() { testWidgets('onLongPress is called', (WidgetTester tester) async { int tapCount = 0; - await tester.pumpApp( - Pinput( - onLongPress: () => ++tapCount, - ), - ); + await tester.pumpApp(Pinput(onLongPress: () => ++tapCount)); expect(tapCount, 0); await tester.longPress(find.byType(EditableText)); @@ -331,11 +318,7 @@ void main() { testWidgets('onSubmitted callback is called', (WidgetTester tester) async { String? fieldValue; - await tester.pumpApp( - Pinput( - onSubmitted: (value) => fieldValue = value, - ), - ); + await tester.pumpApp(Pinput(onSubmitted: (value) => fieldValue = value)); expect(fieldValue, isNull); @@ -343,4 +326,155 @@ void main() { await tester.testTextInput.receiveAction(TextInputAction.done); expect(fieldValue, equals('123')); }); + + group('autovalidateMode should work properly', () { + testWidgets( + 'validator is not called while typing when autovalidateMode is disabled', + (WidgetTester tester) async { + int validatorCalled = 0; + + await tester.pumpApp( + Pinput( + length: 4, + autovalidateMode: AutovalidateMode.disabled, + validator: (value) { + validatorCalled++; + return value == null || value.length < 4 ? 'error' : null; + }, + ), + ); + + await tester.enterText(find.byType(EditableText), '12'); + await tester.pump(); + + expect(validatorCalled, 0); + }, + ); + + testWidgets( + 'validator is called on every keystroke with onUserInteraction', + (WidgetTester tester) async { + int validatorCalled = 0; + + await tester.pumpApp( + Pinput( + length: 4, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + validatorCalled++; + return value == null || value.length < 4 ? 'error' : null; + }, + ), + ); + + await tester.enterText(find.byType(EditableText), '1'); + await tester.pump(); + + expect(validatorCalled, greaterThan(0)); + }, + ); + + testWidgets( + 'error text is displayed after partial entry with onUserInteraction', + (WidgetTester tester) async { + const errorMessage = 'PIN must be 4 digits'; + + await tester.pumpApp( + Pinput( + length: 4, + autofocus: true, + showErrorWhenFocused: true, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) => + (value == null || value.length < 4) ? errorMessage : null, + ), + ); + + await tester.enterText(find.byType(EditableText), '12'); + // Two pumps needed: first runs FormField validation and the deferred + // postFrameCallback, second rebuilds _PinputState with the error text. + await tester.pump(); + await tester.pump(); + + expect(find.text(errorMessage), findsOneWidget); + }, + ); + + testWidgets( + 'error text disappears when PIN becomes valid with onUserInteraction', + (WidgetTester tester) async { + const errorMessage = 'PIN must be 4 digits'; + + await tester.pumpApp( + Pinput( + length: 4, + autofocus: true, + showErrorWhenFocused: true, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) => + (value == null || value.length < 4) ? errorMessage : null, + ), + ); + + await tester.enterText(find.byType(EditableText), '12'); + await tester.pump(); + await tester.pump(); + expect(find.text(errorMessage), findsOneWidget); + + await tester.enterText(find.byType(EditableText), '1234'); + await tester.pump(); + await tester.pump(); + expect(find.text(errorMessage), findsNothing); + }, + ); + + testWidgets('omitting autovalidateMode preserves legacy behavior', ( + WidgetTester tester, + ) async { + int validatorCalled = 0; + + await tester.pumpApp( + Pinput( + length: 4, + validator: (value) { + validatorCalled++; + return null; + }, + ), + ); + + await tester.enterText(find.byType(EditableText), '12'); + await tester.pump(); + + expect(validatorCalled, 0); + }); + + testWidgets('Pinput.builder respects autovalidateMode', ( + WidgetTester tester, + ) async { + int validatorCalled = 0; + + await tester.pumpApp( + Pinput.builder( + length: 4, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + validatorCalled++; + return value == null || value.length < 4 ? 'error' : null; + }, + builder: (context, state) => Container( + key: ValueKey(state.index), + width: 40, + height: 40, + color: Colors.blue, + ), + ), + ); + + await tester.enterText(find.byType(EditableText), '1'); + await tester.pump(); + + expect(validatorCalled, greaterThan(0)); + }); + }); }