Skip to content

Commit d957389

Browse files
theamiriAhmedAmineZrabdelghafour amiri
authored
fix: pin code input properly distribute pasted content across digit cells (#749)(#750)
* fix(pinCodeInput): properly distribute pasted content across digit cells * chore(changelog): add demo app changelog entry for pin code paste fix --------- Co-authored-by: Ahmed Amine Zribi <ahmedamine.zribi@sofrecom.com> Co-authored-by: abdelghafour amiri <abdelghafour.amiri@sofrecom.com>
1 parent d796835 commit d957389

4 files changed

Lines changed: 139 additions & 32 deletions

File tree

app/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111
- [Library] Null check operator used on a null value in all components has isHighContrastEnabled ([#756](https://github.com/Orange-OpenSource/ouds-flutter/issues/756))
1212
- [DemoApp] Layout Overflow on Demo Screen for component version when system font size is increased for accessibility. ([#748](https://github.com/Orange-OpenSource/ouds-flutter/issues/748))
13+
- [Library] `Pin code input` Paste of less than 4 characters drops or merges characters ([#749](https://github.com/Orange-OpenSource/ouds-flutter/issues/749))
1314

1415
## [1.3.0](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.2.0...1.3.0) - 2026-05-08
1516
### Added

ouds_core/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
### Changed
1010
### Fixed
1111
- [Library] Null check operator used on a null value in all components has isHighContrastEnabled ([#756](https://github.com/Orange-OpenSource/ouds-flutter/issues/756))
12+
- [Library] `Pin code input` Paste of less than 4 characters drops or merges characters ([#749](https://github.com/Orange-OpenSource/ouds-flutter/issues/749))
1213

1314
## [1.3.0](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.2.0...1.3.0) - 2026-05-08
1415
### Added

ouds_core/lib/components/pin_code_input/digit_input/ouds_digit_input.dart

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class OudsDigitInput extends StatefulWidget {
108108
final void Function(String, int)? onChanged;
109109
final OudsPinCodeInputLength length;
110110
final VoidCallback? onBackspaceOnEmpty;
111+
final VoidCallback? onPasteRequested;
111112

112113
OudsDigitInput({
113114
super.key,
@@ -120,6 +121,7 @@ class OudsDigitInput extends StatefulWidget {
120121
this.onChanged,
121122
this.length = OudsPinCodeInputLength.six,
122123
this.onBackspaceOnEmpty,
124+
this.onPasteRequested,
123125
});
124126

125127
@override
@@ -200,9 +202,42 @@ class _OudsDigitInputState extends State<OudsDigitInput> {
200202
keyboardType: widget.digitInputDecoration!.keyboardType == OudsPinCodeInputKeyboardType.numeric
201203
? TextInputType.number
202204
: TextInputType.text,
203-
inputFormatters: widget.digitInputDecoration!.keyboardType == OudsPinCodeInputKeyboardType.numeric
204-
? <TextInputFormatter>[FilteringTextInputFormatter.digitsOnly]
205-
: null,
205+
inputFormatters: <TextInputFormatter>[
206+
// Let a full pasted code arrive intact in one cell so the
207+
// parent's `_distributeCode` can spread it. Without this,
208+
// Flutter's default `maxLength` behaviour would clip.
209+
LengthLimitingTextInputFormatter(widget.length.digits),
210+
if (widget.digitInputDecoration!.keyboardType ==
211+
OudsPinCodeInputKeyboardType.numeric)
212+
FilteringTextInputFormatter.digitsOnly,
213+
],
214+
// Long-press paste bypasses the TextField entirely: we
215+
// rebuild the platform toolbar but swap the Paste action
216+
// for the parent's clipboard-direct handler.
217+
contextMenuBuilder: widget.onPasteRequested == null
218+
? null
219+
: (context, editableTextState) {
220+
final items = editableTextState
221+
.contextMenuButtonItems
222+
.map((item) {
223+
if (item.type ==
224+
ContextMenuButtonType.paste) {
225+
return ContextMenuButtonItem(
226+
type: ContextMenuButtonType.paste,
227+
onPressed: () {
228+
editableTextState.hideToolbar();
229+
widget.onPasteRequested!();
230+
},
231+
);
232+
}
233+
return item;
234+
})
235+
.toList();
236+
return AdaptiveTextSelectionToolbar.buttonItems(
237+
anchors: editableTextState.contextMenuAnchors,
238+
buttonItems: items,
239+
);
240+
},
206241
textAlign: TextAlign.center,
207242
maxLines: 1,
208243
buildCounter: (_, {required currentLength, required isFocused, required maxLength}) => null, // to hide the counter

ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart

Lines changed: 99 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
library;
1515

1616
import 'package:flutter/material.dart';
17+
import 'package:flutter/services.dart';
1718
import 'package:ouds_core/components/pin_code_input/digit_input/ouds_digit_input.dart';
1819
import 'package:ouds_core/components/pin_code_input/internal/modifier/ouds_pin_code_input_text_color_modifier.dart';
1920
import 'package:ouds_core/l10n/gen/ouds_localizations.dart';
@@ -268,6 +269,7 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
268269
}
269270
},
270271
onBackspaceOnEmpty: () => _handleBackspaceOnEmpty(index),
272+
onPasteRequested: _pasteFromClipboard,
271273
),
272274
),
273275
);
@@ -314,39 +316,52 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
314316
);
315317
}
316318

317-
// This method updates focus between fields, assembles the full PIN code,
318-
// and calls the appropriate callbacks:
319+
// Handles keyboard-path input from a single cell. Any multi-grapheme value
320+
// (soft-keyboard paste suggestion, Cmd+V, OTP autofill) is routed to the
321+
// single distribution method `_distributeCode`. Single characters are
322+
// written atomically and focus advances or retreats. Long-press paste is
323+
// handled entirely outside this method via `_pasteFromClipboard` wired
324+
// through each cell's `contextMenuBuilder`.
319325
void _handleDigitInput(String value, int index) {
320326
WidgetsBinding.instance.addPostFrameCallback((_) {
321327
if (!mounted) return;
322328

323329
final totalDigits = widget.length.digits;
324-
final controllers = widget.controllers!;
325-
var effectiveValue = value;
326-
// Case 1: user pasted a code (more than 3 characters)
327-
if (value.length > 3) {
328-
_handlePaste(value);
330+
final controllers = widget.controllers;
331+
if (controllers == null) return;
332+
if (controllers.length < totalDigits ||
333+
_focusNodes.length < totalDigits) {
329334
return;
330335
}
331336

332-
// Case 2: user tried to add another character into a filled field
333-
if (value.length == 2) {
334-
controllers[index]
335-
..text = value.characters.last
336-
..selection = TextSelection.collapsed(offset: 1);
337-
effectiveValue = controllers[index].text;
337+
final sanitized = widget.digitInputDecoration.keyboardType ==
338+
OudsPinCodeInputKeyboardType.numeric
339+
? value.replaceAll(RegExp(r'\D'), '')
340+
: value;
341+
final chars = sanitized.characters.toList();
342+
343+
// Multi-grapheme arrival → treat as paste, redistribute.
344+
if (chars.length > 1) {
345+
_distributeCode(value);
346+
return;
347+
}
348+
349+
final effective = chars.isEmpty ? '' : chars.first;
350+
if (controllers[index].text != effective) {
351+
controllers[index].value = TextEditingValue(
352+
text: effective,
353+
selection: TextSelection.collapsed(offset: effective.length),
354+
);
338355
}
339356

340357
final code = _currentCode();
341358
_emitChanged(code);
342359

343-
// Case 3: deletion on a filled cell. Move backward so one backspace removes one digit.
344-
if (effectiveValue.isEmpty) {
360+
if (effective.isEmpty) {
345361
_requestFocusOnPreviousField(index);
346362
return;
347363
}
348364

349-
// Case 4: normal input move focus forward
350365
_requestFocusOnNextFieldOrComplete(
351366
index: index,
352367
totalDigits: totalDigits,
@@ -406,31 +421,84 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
406421
}
407422
}
408423

409-
//handle copy past pin code
410-
void _handlePaste(String value) {
424+
// ───────────────────────── paste pipeline ─────────────────────────
425+
// Three small methods, single responsibility each:
426+
//
427+
// _safeReadClipboard – reads the system clipboard with a hard 2 s
428+
// timeout; returns null on null/error/timeout.
429+
// _pasteFromClipboard – entrypoint wired to each cell's context-menu
430+
// "Paste" action; sole long-press paste path.
431+
// _distributeCode – the ONLY place that writes to the cells. Takes
432+
// a raw string, sanitises, truncates to PIN
433+
// length, fills every cell (clearing trailing
434+
// ones), updates focus, and fires callbacks.
435+
//
436+
// The keyboard path (`_handleDigitInput`) also delegates to
437+
// `_distributeCode` whenever it sees a multi-grapheme value, so all paste
438+
// flows converge on one implementation.
439+
440+
Future<String?> _safeReadClipboard() async {
441+
try {
442+
final data = await Clipboard.getData(Clipboard.kTextPlain).timeout(
443+
const Duration(seconds: 2),
444+
onTimeout: () => null,
445+
);
446+
return data?.text;
447+
} catch (_) {
448+
return null;
449+
}
450+
}
451+
452+
Future<void> _pasteFromClipboard() async {
453+
final text = await _safeReadClipboard();
454+
if (!mounted) return;
455+
if (text == null || text.isEmpty) return;
456+
_distributeCode(text);
457+
}
458+
459+
void _distributeCode(String raw) {
411460
final totalDigits = widget.length.digits;
412-
final controllers = widget.controllers!;
413-
final sanitized = widget.digitInputDecoration.keyboardType == OudsPinCodeInputKeyboardType.numeric
414-
? value.replaceAll(RegExp(r'\D'), '')
415-
: value;
461+
final controllers = widget.controllers;
462+
if (controllers == null) return;
463+
if (controllers.length < totalDigits ||
464+
_focusNodes.length < totalDigits) {
465+
return;
466+
}
467+
468+
final sanitized = widget.digitInputDecoration.keyboardType ==
469+
OudsPinCodeInputKeyboardType.numeric
470+
? raw.replaceAll(RegExp(r'\D'), '')
471+
: raw;
472+
if (sanitized.isEmpty) return;
473+
416474
final digits = sanitized.characters.take(totalDigits).toList();
475+
final filledCount = digits.length;
476+
477+
// Write every cell — atomic `value =` (sets text + selection in one go)
478+
// and explicit empty for cells beyond the pasted range, so a shorter
479+
// paste cannot leave stale digits behind.
480+
for (int i = 0; i < totalDigits; i++) {
481+
final text = i < filledCount ? digits[i] : '';
482+
controllers[i].value = TextEditingValue(
483+
text: text,
484+
selection: TextSelection.collapsed(offset: text.length),
485+
);
486+
}
417487

418-
for (int i = 0; i < digits.length; i++) {
419-
controllers[i].text = digits[i];
488+
if (!_hasEdited) {
489+
_hasEdited = true;
420490
}
421491

422492
final code = _currentCode();
423493
_emitChanged(code);
424494

425-
final isComplete = code.length == totalDigits;
426-
427-
if (isComplete) {
495+
if (code.characters.length == totalDigits) {
428496
for (final node in _focusNodes) {
429497
node.unfocus();
430498
}
431499
widget.onEditingComplete?.call(code);
432500
} else {
433-
final nextIndex = digits.length.clamp(0, totalDigits - 1);
501+
final nextIndex = filledCount.clamp(0, totalDigits - 1).toInt();
434502
_focusNodes[nextIndex].requestFocus();
435503
}
436504
}
@@ -476,7 +544,9 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
476544
_previousHasFocus = hasAnyFocus;
477545
final code = _currentCode();
478546

479-
if (!hasAnyFocus && _hasEdited) {
547+
if (!hasAnyFocus &&
548+
_hasEdited &&
549+
code.characters.length == widget.length.digits) {
480550
widget.onEditingComplete?.call(code);
481551
} else if (hasAnyFocus) {
482552
widget.onChanged?.call(code);

0 commit comments

Comments
 (0)