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
16 changes: 16 additions & 0 deletions lib/src/pinput.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class Pinput extends StatefulWidget {
this.readOnly = false,
this.useNativeKeyboard = true,
this.toolbarEnabled = true,
this.showToolbarOnTap = false,
this.autofocus = false,
this.obscureText = false,
this.showCursor = true,
Expand Down Expand Up @@ -145,6 +146,7 @@ class Pinput extends StatefulWidget {
this.readOnly = false,
this.useNativeKeyboard = true,
this.toolbarEnabled = true,
this.showToolbarOnTap = false,
this.autofocus = false,
this.enableIMEPersonalizedLearning = false,
this.enableInteractiveSelection = false,
Expand Down Expand Up @@ -319,6 +321,13 @@ class Pinput extends StatefulWidget {
/// If true, paste button will appear on longPress event
final bool toolbarEnabled;

/// If true, a single tap on an already-focused Pinput will toggle the context
/// menu on iOS, matching the standard [TextField] behavior.
///
/// Defaults to false to preserve the existing Pinput behavior.
/// Only affects iOS; other platforms are unaffected.
final bool showToolbarOnTap;

/// Whether show cursor or not
/// Default cursor '|' or [cursor]
final bool showCursor;
Expand Down Expand Up @@ -695,6 +704,13 @@ class Pinput extends StatefulWidget {
defaultValue: true,
),
);
properties.add(
DiagnosticsProperty<bool>(
'showToolbarOnTap',
showToolbarOnTap,
defaultValue: false,
),
);
properties.add(
DiagnosticsProperty<bool>(
'showCursor',
Expand Down
16 changes: 15 additions & 1 deletion lib/src/pinput_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class _PinputState extends State<Pinput>
RestorableTextEditingController? _controller;
FocusNode? _focusNode;
bool _isHovering = false;
bool _isToolbarVisible = false;
String? _validatorErrorText;
SmsRetriever? _smsRetriever;

Expand Down Expand Up @@ -85,6 +86,7 @@ class _PinputState extends State<Pinput>
}

_effectiveFocusNode.canRequestFocus = isEnabled && widget.useNativeKeyboard;
_effectiveFocusNode.addListener(_handleFocusChange);
_maybeInitSmartAuth();
_maybeCheckClipboard();
// https://github.com/Tkko/Flutter_Pinput/issues/89
Expand Down Expand Up @@ -191,6 +193,7 @@ class _PinputState extends State<Pinput>

@override
void dispose() {
_effectiveFocusNode.removeListener(_handleFocusChange);
widget.controller?.removeListener(_handleTextEditingControllerChanges);
_controller?.removeListener(_handleTextEditingControllerChanges);
_controller?.dispose();
Expand All @@ -201,6 +204,12 @@ class _PinputState extends State<Pinput>
super.dispose();
}

void _handleFocusChange() {
if (!_effectiveFocusNode.hasFocus) {
_isToolbarVisible = false;
}
}

void _requestKeyboard() {
if (_effectiveFocusNode.canRequestFocus) {
_editableText?.requestKeyboard();
Expand Down Expand Up @@ -445,7 +454,12 @@ class _PinputState extends State<Pinput>
backgroundCursorColor: Colors.transparent,
selectionHeightStyle: BoxHeightStyle.tight,
enableSuggestions: widget.enableSuggestions,
contextMenuBuilder: widget.contextMenuBuilder,
contextMenuBuilder: widget.contextMenuBuilder == null
? null
: (context, editableTextState) {
_isToolbarVisible = true;
return widget.contextMenuBuilder!(context, editableTextState);
},
obscuringCharacter: widget.obscuringCharacter,
onAppPrivateCommand: widget.onAppPrivateCommand,
onSelectionChanged: _handleSelectionChanged,
Expand Down
21 changes: 19 additions & 2 deletions lib/src/widgets/_pinput_selection_gesture_detector_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,25 @@ class _PinputSelectionGestureDetectorBuilder

@override
void onSingleTapUp(details) {
super.onSingleTapUp(details);
editableText.hideToolbar();
// pinput's _handleSelectionChanged forcibly rewrites the selection on every
// tap, so the framework's toggleToolbar logic (which compares previousSelection
// == currentSelection) always falls into hideToolbar instead of toggling.
// We bypass super and manually implement the iOS toggle behavior.
if (delegate.selectionEnabled &&
_state.widget.showToolbarOnTap &&
defaultTargetPlatform == TargetPlatform.iOS &&
_state._effectiveFocusNode.hasFocus) {
if (_state._isToolbarVisible) {
_state._isToolbarVisible = false;
editableText.hideToolbar(false);
} else {
// toggleToolbar creates _selectionOverlay if null, which showToolbar() alone cannot.
editableText.toggleToolbar(false);
}
} else {
super.onSingleTapUp(details);
editableText.hideToolbar();
}
_state._requestKeyboard();
_state.widget.onTap?.call();
}
Expand Down
48 changes: 48 additions & 0 deletions test/pinput_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,54 @@ void main() {
expect(tapCount, 3);
});

testWidgets(
'onSingleTapUp shows context menu on second tap on iOS (already focused)',
(WidgetTester tester) async {
final focusNode = FocusNode();
int tapCount = 0;

await tester.pumpApp(
Pinput(
focusNode: focusNode,
showToolbarOnTap: true,
onTap: () => ++tapCount,
),
);

// First tap: gains focus, no menu
await tester.tap(find.byType(EditableText));
await tester.pump(const Duration(milliseconds: 300));
expect(tapCount, 1);
expect(focusNode.hasFocus, isTrue);

// Second tap while focused: onTap is still called
await tester.tap(find.byType(EditableText));
await tester.pump(const Duration(milliseconds: 300));
expect(tapCount, 2);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);

testWidgets(
'onSingleTapUp on non-iOS still calls onTap',
(WidgetTester tester) async {
int tapCount = 0;

await tester.pumpApp(
Pinput(
onTap: () => ++tapCount,
),
);

await tester.tap(find.byType(EditableText));
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byType(EditableText));
await tester.pump(const Duration(milliseconds: 300));
expect(tapCount, 2);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);

testWidgets('onTap is not called, field is disabled',
(WidgetTester tester) async {
int tapCount = 0;
Expand Down