Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 59 additions & 48 deletions lib/src/pinput.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -538,8 +544,9 @@ class Pinput extends StatefulWidget {
defaultValue: null,
),
);
properties
.add(DiagnosticsProperty<bool>('enabled', enabled, defaultValue: true));
properties.add(
DiagnosticsProperty<bool>('enabled', enabled, defaultValue: true),
);
properties.add(
DiagnosticsProperty<bool>(
'closeKeyboardWhenCompleted',
Expand Down Expand Up @@ -666,8 +673,9 @@ class Pinput extends StatefulWidget {
defaultValue: null,
),
);
properties
.add(DiagnosticsProperty<bool>('enabled', enabled, defaultValue: true));
properties.add(
DiagnosticsProperty<bool>('enabled', enabled, defaultValue: true),
);
properties.add(
DiagnosticsProperty<bool>('readOnly', readOnly, defaultValue: false),
);
Expand Down Expand Up @@ -696,11 +704,7 @@ class Pinput extends StatefulWidget {
),
);
properties.add(
DiagnosticsProperty<bool>(
'showCursor',
showCursor,
defaultValue: true,
),
DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: true),
);
properties.add(
DiagnosticsProperty<String>(
Expand Down Expand Up @@ -821,6 +825,13 @@ class Pinput extends StatefulWidget {
defaultValue: PinputAutovalidateMode.onSubmit,
),
);
properties.add(
EnumProperty<AutovalidateMode>(
'autovalidateMode',
autovalidateMode,
defaultValue: AutovalidateMode.disabled,
),
);
properties.add(
DiagnosticsProperty<HapticFeedbackType>(
'hapticFeedbackType',
Expand Down
73 changes: 49 additions & 24 deletions lib/src/pinput_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ class _PinputState extends State<Pinput>
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:
Expand Down Expand Up @@ -74,8 +75,9 @@ class _PinputState extends State<Pinput>
@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);
Expand Down Expand Up @@ -213,11 +215,13 @@ class _PinputState extends State<Pinput>
) {
// 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) {
Expand Down Expand Up @@ -279,6 +283,22 @@ class _PinputState extends State<Pinput>
}

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;
Expand Down Expand Up @@ -338,6 +358,7 @@ class _PinputState extends State<Pinput>
return _PinputFormField(
enabled: isEnabled,
validator: _validator,
autovalidateMode: widget.autovalidateMode,
initialValue: _effectiveController.text,
builder: (field) {
return MouseRegion(
Expand Down Expand Up @@ -451,8 +472,9 @@ class _PinputState extends State<Pinput>
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,
),
Expand Down Expand Up @@ -482,8 +504,9 @@ class _PinputState extends State<Pinput>

void _semanticsOnTap() {
if (!_effectiveController.selection.isValid) {
_effectiveController.selection =
TextSelection.collapsed(offset: _effectiveController.text.length);
_effectiveController.selection = TextSelection.collapsed(
offset: _effectiveController.text.length,
);
}
_requestKeyboard();
}
Expand Down Expand Up @@ -532,9 +555,10 @@ class _PinputState extends State<Pinput>

return Center(
child: AnimatedBuilder(
animation: Listenable.merge(
<Listenable>[_effectiveFocusNode, _effectiveController],
),
animation: Listenable.merge(<Listenable>[
_effectiveFocusNode,
_effectiveController,
]),
builder: (BuildContext context, Widget? child) {
final shouldHideErrorContent =
widget.validator == null && widget.errorText == null;
Expand All @@ -546,10 +570,7 @@ class _PinputState extends State<Pinput>
alignment: Alignment.topCenter,
child: Column(
crossAxisAlignment: widget.crossAxisAlignment,
children: [
onlyFields(),
_buildError(),
],
children: [onlyFields(), _buildError()],
),
);
},
Expand Down Expand Up @@ -582,9 +603,11 @@ class _PinputState extends State<Pinput>
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,
),
),
);
}
Expand All @@ -600,8 +623,9 @@ class _PinputState extends State<Pinput>

@override
TextInputConfiguration get textInputConfiguration {
final List<String>? autofillHints =
widget.autofillHints?.toList(growable: false);
final List<String>? autofillHints = widget.autofillHints?.toList(
growable: false,
);
final AutofillConfiguration autofillConfiguration = autofillHints != null
? AutofillConfiguration(
uniqueIdentifier: autofillId,
Expand All @@ -610,7 +634,8 @@ class _PinputState extends State<Pinput>
)
: AutofillConfiguration.disabled;

return _editableText!.textInputConfiguration
.copyWith(autofillConfiguration: autofillConfiguration);
return _editableText!.textInputConfiguration.copyWith(
autofillConfiguration: autofillConfiguration,
);
}
}
20 changes: 9 additions & 11 deletions lib/src/widgets/widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ class _PinputFormField extends FormField<String> {
required super.enabled,
required super.initialValue,
required super.builder,
}) : super(
autovalidateMode: AutovalidateMode.disabled,
);
super.autovalidateMode = AutovalidateMode.disabled,
});
}

class _SeparatedRaw extends StatelessWidget {
Expand All @@ -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),
);
}

Expand All @@ -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();
Expand Down
Loading