|
14 | 14 | library; |
15 | 15 |
|
16 | 16 | import 'package:flutter/material.dart'; |
| 17 | +import 'package:flutter/services.dart'; |
17 | 18 | import 'package:ouds_core/components/pin_code_input/digit_input/ouds_digit_input.dart'; |
18 | 19 | import 'package:ouds_core/components/pin_code_input/internal/modifier/ouds_pin_code_input_text_color_modifier.dart'; |
19 | 20 | import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; |
@@ -268,6 +269,7 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> { |
268 | 269 | } |
269 | 270 | }, |
270 | 271 | onBackspaceOnEmpty: () => _handleBackspaceOnEmpty(index), |
| 272 | + onPasteRequested: _pasteFromClipboard, |
271 | 273 | ), |
272 | 274 | ), |
273 | 275 | ); |
@@ -314,39 +316,52 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> { |
314 | 316 | ); |
315 | 317 | } |
316 | 318 |
|
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`. |
319 | 325 | void _handleDigitInput(String value, int index) { |
320 | 326 | WidgetsBinding.instance.addPostFrameCallback((_) { |
321 | 327 | if (!mounted) return; |
322 | 328 |
|
323 | 329 | 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) { |
329 | 334 | return; |
330 | 335 | } |
331 | 336 |
|
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 | + ); |
338 | 355 | } |
339 | 356 |
|
340 | 357 | final code = _currentCode(); |
341 | 358 | _emitChanged(code); |
342 | 359 |
|
343 | | - // Case 3: deletion on a filled cell. Move backward so one backspace removes one digit. |
344 | | - if (effectiveValue.isEmpty) { |
| 360 | + if (effective.isEmpty) { |
345 | 361 | _requestFocusOnPreviousField(index); |
346 | 362 | return; |
347 | 363 | } |
348 | 364 |
|
349 | | - // Case 4: normal input move focus forward |
350 | 365 | _requestFocusOnNextFieldOrComplete( |
351 | 366 | index: index, |
352 | 367 | totalDigits: totalDigits, |
@@ -406,31 +421,84 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> { |
406 | 421 | } |
407 | 422 | } |
408 | 423 |
|
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) { |
411 | 460 | 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 | + |
416 | 474 | 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 | + } |
417 | 487 |
|
418 | | - for (int i = 0; i < digits.length; i++) { |
419 | | - controllers[i].text = digits[i]; |
| 488 | + if (!_hasEdited) { |
| 489 | + _hasEdited = true; |
420 | 490 | } |
421 | 491 |
|
422 | 492 | final code = _currentCode(); |
423 | 493 | _emitChanged(code); |
424 | 494 |
|
425 | | - final isComplete = code.length == totalDigits; |
426 | | - |
427 | | - if (isComplete) { |
| 495 | + if (code.characters.length == totalDigits) { |
428 | 496 | for (final node in _focusNodes) { |
429 | 497 | node.unfocus(); |
430 | 498 | } |
431 | 499 | widget.onEditingComplete?.call(code); |
432 | 500 | } else { |
433 | | - final nextIndex = digits.length.clamp(0, totalDigits - 1); |
| 501 | + final nextIndex = filledCount.clamp(0, totalDigits - 1).toInt(); |
434 | 502 | _focusNodes[nextIndex].requestFocus(); |
435 | 503 | } |
436 | 504 | } |
@@ -476,7 +544,9 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> { |
476 | 544 | _previousHasFocus = hasAnyFocus; |
477 | 545 | final code = _currentCode(); |
478 | 546 |
|
479 | | - if (!hasAnyFocus && _hasEdited) { |
| 547 | + if (!hasAnyFocus && |
| 548 | + _hasEdited && |
| 549 | + code.characters.length == widget.length.digits) { |
480 | 550 | widget.onEditingComplete?.call(code); |
481 | 551 | } else if (hasAnyFocus) { |
482 | 552 | widget.onChanged?.call(code); |
|
0 commit comments