From 577130933442eb4a44ed6a1e07c6716c6031a281 Mon Sep 17 00:00:00 2001 From: abdelghafour amiri Date: Fri, 8 May 2026 11:49:12 +0100 Subject: [PATCH 1/3] fix(pinCodeInput): delete previous digit instantly on backspace # Conflicts: # ouds_core/lib/components/pin_code_input/digit_input/ouds_digit_input.dart --- .../digit_input/ouds_digit_input.dart | 11 ++++------ .../pin_code_input/ouds_pin_code_input.dart | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/ouds_core/lib/components/pin_code_input/digit_input/ouds_digit_input.dart b/ouds_core/lib/components/pin_code_input/digit_input/ouds_digit_input.dart index 37dbccff8..e4b57f8be 100644 --- a/ouds_core/lib/components/pin_code_input/digit_input/ouds_digit_input.dart +++ b/ouds_core/lib/components/pin_code_input/digit_input/ouds_digit_input.dart @@ -103,6 +103,7 @@ class OudsDigitInput extends StatefulWidget { late final bool isHovered; final void Function(String, int)? onChanged; final OudsPinCodeInputLength length; + final VoidCallback? onBackspaceOnEmpty; OudsDigitInput({ super.key, @@ -114,6 +115,7 @@ class OudsDigitInput extends StatefulWidget { this.isHovered = false, this.onChanged, this.length = OudsPinCodeInputLength.six, + this.onBackspaceOnEmpty, }); @override @@ -176,13 +178,8 @@ class _OudsDigitInputState extends State { onKeyEvent: (KeyEvent event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.backspace) { final text = widget.controller?.text ?? ''; - // If the field is empty and the user presses backspace : move to the previous one - if (text.isEmpty) { - final previousIndex = widget.index - 1; - if (previousIndex >= 0) { - widget.controller?.clear(); - FocusScope.of(context).previousFocus(); - } + if (text.isEmpty && widget.index > 0) { + widget.onBackspaceOnEmpty?.call(); } } }, diff --git a/ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart b/ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart index b09b2eec8..2ddf4e799 100644 --- a/ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart +++ b/ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart @@ -227,6 +227,7 @@ class _OudsPinCodeInputState extends State { }); } }, + onBackspaceOnEmpty: () => _handleBackspaceOnEmpty(index), ), ), ); @@ -326,6 +327,27 @@ class _OudsPinCodeInputState extends State { } } + // Called when the user presses backspace on an already-empty digit cell. + // Clears the previous cell's content AND moves focus there in a single step, + // so deletion feels instant instead of requiring two key presses. + void _handleBackspaceOnEmpty(int index) { + if (index <= 0) return; + final controllers = widget.controllers; + if (controllers == null) return; + final previousIndex = index - 1; + if (previousIndex >= controllers.length || previousIndex >= _focusNodes.length) return; + + final previousController = controllers[previousIndex]; + final wasNonEmpty = previousController.text.isNotEmpty; + + previousController.clear(); + _focusNodes[previousIndex].requestFocus(); + + if (wasNonEmpty) { + widget.onChanged?.call(controllers.map((c) => c.text).join()); + } + } + // This method is called whenever the global focus changes, using a FocusManager listener. // It updates the internal `hasAnyFocus` state to reflect whether any of the PIN input fields currently have focus. // From 8d872cada44120c95cefe4d949f284380b2033db Mon Sep 17 00:00:00 2001 From: abdelghafour amiri Date: Fri, 8 May 2026 11:52:00 +0100 Subject: [PATCH 2/3] chore(pinCodeInput): update changelog for #735 --- ouds_core/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ouds_core/CHANGELOG.md b/ouds_core/CHANGELOG.md index 88121d709..2487462ee 100644 --- a/ouds_core/CHANGELOG.md +++ b/ouds_core/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Library] `Pin Code Input` Role is missing on digit code input([#486](https://github.com/Orange-OpenSource/ouds-flutter/issues/486)) - [Library] `Pin Code Input` Read helper text with the group label([#487](https://github.com/Orange-OpenSource/ouds-flutter/issues/487)) - [Library] Nothing happens when clicking on the `suggestion chip` ([#723](https://github.com/Orange-OpenSource/ouds-flutter/issues/723)) +- [Library] `Pin code input` deletion requires two backspace presses on a typed digit ([#735](https://github.com/Orange-OpenSource/ouds-flutter/issues/735)) ## [1.2.0](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.1.2...1.2.0) - 2026-04-21 ### Added From 7d5bac51a307d107318ede89189a3dd8e20cf7d5 Mon Sep 17 00:00:00 2001 From: Ahmed Amine Zribi Date: Fri, 8 May 2026 16:24:17 +0100 Subject: [PATCH 3/3] chore: update pincode logic and changelog --- app/CHANGELOG.md | 1 + ouds_core/CHANGELOG.md | 2 +- .../pin_code_input/ouds_pin_code_input.dart | 175 ++++++++++++++---- 3 files changed, 137 insertions(+), 41 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 3332448c9..56768d97c 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Library] update tokens 1.9.0 - Component Alert ([#672](https://github.com/Orange-OpenSource/ouds-flutter/issues/672)) ### Fixed +- [Library] `Pin code input` deletion requires two backspace presses on a typed digit ([#735](https://github.com/Orange-OpenSource/ouds-flutter/issues/735)) - [Library] `orange compact` some components are not displayed correctly ([#630](https://github.com/Orange-OpenSource/ouds-flutter/issues/630)) - [Library] `Password Input` Change the accessible name on show/hide button ([#599](https://github.com/Orange-OpenSource/ouds-flutter/issues/599)) - [Library] `Password input` Hidden password is clearly read by screen readers([#488](https://github.com/Orange-OpenSource/ouds-flutter/issues/488)) diff --git a/ouds_core/CHANGELOG.md b/ouds_core/CHANGELOG.md index 2487462ee..35a8557d0 100644 --- a/ouds_core/CHANGELOG.md +++ b/ouds_core/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Library] update tokens 1.9.0 - Component Alert ([#672](https://github.com/Orange-OpenSource/ouds-flutter/issues/672)) ### Fixed +- [Library] `Pin code input` deletion requires two backspace presses on a typed digit ([#735](https://github.com/Orange-OpenSource/ouds-flutter/issues/735)) - [Library] `orange compact` some components are not displayed correctly ([#630](https://github.com/Orange-OpenSource/ouds-flutter/issues/630)) - [Library] `Password Input` Change the accessible name on show/hide button ([#599](https://github.com/Orange-OpenSource/ouds-flutter/issues/599)) - [Library] `Password input` Hidden password is clearly read by screen readers([#488](https://github.com/Orange-OpenSource/ouds-flutter/issues/488)) @@ -25,7 +26,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Library] `Pin Code Input` Role is missing on digit code input([#486](https://github.com/Orange-OpenSource/ouds-flutter/issues/486)) - [Library] `Pin Code Input` Read helper text with the group label([#487](https://github.com/Orange-OpenSource/ouds-flutter/issues/487)) - [Library] Nothing happens when clicking on the `suggestion chip` ([#723](https://github.com/Orange-OpenSource/ouds-flutter/issues/723)) -- [Library] `Pin code input` deletion requires two backspace presses on a typed digit ([#735](https://github.com/Orange-OpenSource/ouds-flutter/issues/735)) ## [1.2.0](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.1.2...1.2.0) - 2026-04-21 ### Added diff --git a/ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart b/ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart index 2ddf4e799..a5b0279f1 100644 --- a/ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart +++ b/ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart @@ -159,7 +159,9 @@ class _OudsPinCodeInputState extends State { if (!mounted) return; FocusManager.instance.removeListener(_onGlobalFocusChange); for (final node in _focusNodes) { - node.removeListener(() => _handleFocusChange(node, _focusNodes.indexOf(node))); + node.removeListener( + () => _handleFocusChange(node, _focusNodes.indexOf(node)), + ); node.dispose(); } super.dispose(); @@ -175,37 +177,59 @@ class _OudsPinCodeInputState extends State { @override Widget build(BuildContext context) { - final pinCodeToken = OudsTheme.of(context).componentsTokens(context).pinCodeInput; - final textInputToken = OudsTheme.of(context).componentsTokens(context).textInput; + final pinCodeToken = OudsTheme.of( + context, + ).componentsTokens(context).pinCodeInput; + final textInputToken = OudsTheme.of( + context, + ).componentsTokens(context).textInput; final theme = OudsTheme.of(context); final digitsCount = widget.length.digits; - final isError = widget.errorText != null || (widget.errorText != null && widget.errorText!.isEmpty); + final isError = + widget.errorText != null || + (widget.errorText != null && widget.errorText!.isEmpty); final l10n = OudsLocalizations.of(context); - final hintSemanticText = "${ widget.errorText != null && isError ? widget.errorText! : widget.helperText != null ? widget.helperText! : ''}" + final hintSemanticText = + "${widget.errorText != null && isError + ? widget.errorText! + : widget.helperText != null + ? widget.helperText! + : ''}" " , ${l10n?.core_common_hint_a11y}"; return Container( constraints: BoxConstraints( minHeight: textInputToken.sizeMinHeight, minWidth: textInputToken.sizeMinWidth, - maxWidth: widget.digitInputDecoration.constrainedMaxWidth ? textInputToken.sizeMaxWidth : double.infinity, + maxWidth: widget.digitInputDecoration.constrainedMaxWidth + ? textInputToken.sizeMaxWidth + : double.infinity, ), child: Column( - mainAxisAlignment: widget.digitInputDecoration.constrainedMaxWidth ? MainAxisAlignment.start : MainAxisAlignment.center, + mainAxisAlignment: widget.digitInputDecoration.constrainedMaxWidth + ? MainAxisAlignment.start + : MainAxisAlignment.center, children: [ Semantics( hint: hintSemanticText, - label: isError ? l10n?.core_common_error_a11y : l10n?.core_pinCodeInput_pinCode_label_a11y(digitsCount), + label: isError + ? l10n?.core_common_error_a11y + : l10n?.core_pinCodeInput_pinCode_label_a11y(digitsCount), child: Row( - mainAxisAlignment: widget.digitInputDecoration.constrainedMaxWidth ? MainAxisAlignment.start : MainAxisAlignment.center, - spacing: widget.length == OudsPinCodeInputLength.eight ? 6 : pinCodeToken.spaceColumnGapDigitInput, + mainAxisAlignment: widget.digitInputDecoration.constrainedMaxWidth + ? MainAxisAlignment.start + : MainAxisAlignment.center, + spacing: widget.length == OudsPinCodeInputLength.eight + ? 6 + : pinCodeToken.spaceColumnGapDigitInput, children: List.generate(digitsCount, (index) { return Flexible( fit: FlexFit.loose, child: Semantics( liveRegion: true, - label: "${l10n?.core_pinCodeInput_digitCode_label_a11y(index + 1)}, " - "${!widget.digitInputDecoration.hiddenPassword && widget.controllers != null? widget.controllers![index].text : ''}, " + label: + "${l10n?.core_pinCodeInput_digitCode_label_a11y(index + 1)}, " + "${!widget.digitInputDecoration.hiddenPassword && widget.controllers != null ? widget.controllers![index].text : ''}, " "${l10n?.core_pinCodeInput_trait_a11y}", child: OudsDigitInput( index: index, @@ -213,7 +237,8 @@ class _OudsPinCodeInputState extends State { length: widget.length, digitInputDecoration: OudsDigitInputDecoration( hintText: _hintText(index), - hiddenPassword: widget.digitInputDecoration.hiddenPassword, + hiddenPassword: + widget.digitInputDecoration.hiddenPassword, isOutlined: widget.digitInputDecoration.isOutlined, ), focusNode: _focusNodes[index], @@ -223,7 +248,8 @@ class _OudsPinCodeInputState extends State { _handleDigitInput(value, index); if (!_hasEdited) { setState(() { - _hasEdited = true; // The user has interacted with the PIN at least once + _hasEdited = + true; // The user has interacted with the PIN at least once }); } }, @@ -234,10 +260,15 @@ class _OudsPinCodeInputState extends State { }), ), ), - if (widget.helperText != null || (widget.errorText != null && isError)) ...[ + if (widget.helperText != null || + (widget.errorText != null && isError)) ...[ Container( constraints: BoxConstraints( - maxWidth: widget.digitInputDecoration.constrainedMaxWidth ? double.infinity : digitsCount * pinCodeToken.sizeMaxWidth + (digitsCount - 1) * pinCodeToken.spaceColumnGapDigitInput, + maxWidth: widget.digitInputDecoration.constrainedMaxWidth + ? double.infinity + : digitsCount * pinCodeToken.sizeMaxWidth + + (digitsCount - 1) * + pinCodeToken.spaceColumnGapDigitInput, ), child: Padding( padding: EdgeInsets.only( @@ -248,9 +279,15 @@ class _OudsPinCodeInputState extends State { child: ExcludeSemantics( child: Text( softWrap: true, - widget.errorText != null && isError ? widget.errorText! : widget.helperText!, - style: theme.typographyTokens.typeLabelDefaultMedium(context).copyWith( - color: OudsPinCodeInputTextColorModifier(context).getPinCodeHelperTextColor(isError), + widget.errorText != null && isError + ? widget.errorText! + : widget.helperText!, + style: theme.typographyTokens + .typeLabelDefaultMedium(context) + .copyWith( + color: OudsPinCodeInputTextColorModifier( + context, + ).getPinCodeHelperTextColor(isError), ), ), ), @@ -271,6 +308,7 @@ class _OudsPinCodeInputState extends State { final totalDigits = widget.length.digits; final controllers = widget.controllers!; + var effectiveValue = value; // Case 1: user pasted a code (more than 3 characters) if (value.length > 3) { _handlePaste(value); @@ -282,25 +320,78 @@ class _OudsPinCodeInputState extends State { controllers[index] ..text = value.characters.last ..selection = TextSelection.collapsed(offset: 1); - return; + effectiveValue = controllers[index].text; } - final code = controllers.map((c) => c.text).join(); - widget.onChanged?.call(code); + final code = _currentCode(); + _emitChanged(code); - // Case 3: deletion stay in the same field - if (value.isEmpty) return; + // Case 3: deletion on a filled cell. Move backward so one backspace removes one digit. + if (effectiveValue.isEmpty) { + _requestFocusOnPreviousField(index); + return; + } // Case 4: normal input move focus forward - if (index < totalDigits - 1) { - _focusNodes[index + 1].requestFocus(); - } else if (code.length == totalDigits) { - _focusNodes[index].unfocus(); - widget.onEditingComplete?.call(code); - } + _requestFocusOnNextFieldOrComplete( + index: index, + totalDigits: totalDigits, + code: code, + ); }); } + /// Builds the current PIN value by concatenating all digit controllers. + String _currentCode() { + final controllers = widget.controllers; + if (controllers == null) return ''; + return controllers.map((c) => c.text).join(); + } + + /// Emits onChanged with the provided code, or with the current PIN when omitted. + void _emitChanged([String? code]) { + widget.onChanged?.call(code ?? _currentCode()); + } + + /// Moves focus to the previous digit field when the index is valid. + void _requestFocusOnPreviousField(int index) { + if (index <= 0) return; + final previousIndex = index - 1; + if (previousIndex >= _focusNodes.length) return; + _focusNodes[previousIndex].requestFocus(); + } + + /// Returns the previous index when both controller and focus node bounds are valid. + /// Returns null when there is no previous field or when collections are inconsistent. + int? _validPreviousIndex(int index) { + final controllers = widget.controllers; + if (controllers == null || index <= 0) return null; + + final previousIndex = index - 1; + if (previousIndex >= controllers.length || + previousIndex >= _focusNodes.length) { + return null; + } + return previousIndex; + } + + /// Moves focus forward when possible, or completes editing on the last filled field. + void _requestFocusOnNextFieldOrComplete({ + required int index, + required int totalDigits, + required String code, + }) { + if (index < totalDigits - 1) { + _focusNodes[index + 1].requestFocus(); + return; + } + + if (code.length == totalDigits) { + _focusNodes[index].unfocus(); + widget.onEditingComplete?.call(code); + } + } + //handle copy past pin code void _handlePaste(String value) { final totalDigits = widget.length.digits; @@ -311,8 +402,8 @@ class _OudsPinCodeInputState extends State { controllers[i].text = digits[i]; } - final code = controllers.map((c) => c.text).join(); - widget.onChanged?.call(code); + final code = _currentCode(); + _emitChanged(code); final isComplete = code.length == totalDigits; @@ -331,20 +422,19 @@ class _OudsPinCodeInputState extends State { // Clears the previous cell's content AND moves focus there in a single step, // so deletion feels instant instead of requiring two key presses. void _handleBackspaceOnEmpty(int index) { - if (index <= 0) return; final controllers = widget.controllers; if (controllers == null) return; - final previousIndex = index - 1; - if (previousIndex >= controllers.length || previousIndex >= _focusNodes.length) return; + final previousIndex = _validPreviousIndex(index); + if (previousIndex == null) return; final previousController = controllers[previousIndex]; final wasNonEmpty = previousController.text.isNotEmpty; previousController.clear(); - _focusNodes[previousIndex].requestFocus(); + _requestFocusOnPreviousField(index); if (wasNonEmpty) { - widget.onChanged?.call(controllers.map((c) => c.text).join()); + _emitChanged(); } } @@ -367,7 +457,7 @@ class _OudsPinCodeInputState extends State { if (_previousHasFocus == hasAnyFocus) return; _previousHasFocus = hasAnyFocus; - final code = widget.controllers?.map((c) => c.text).join() ?? ""; + final code = _currentCode(); if (!hasAnyFocus && _hasEdited) { widget.onEditingComplete?.call(code); @@ -384,8 +474,13 @@ class _OudsPinCodeInputState extends State { final text = widget.controllers?[index].text; // Special case: all fields are empty, user has already edited, and cursor is invisible - final isPinCompletelyEmpty = widget.controllers?.every((c) => c.text.isEmpty); - if (isPinCompletelyEmpty != null && isPinCompletelyEmpty && hasFocus && _hasEdited) { + final isPinCompletelyEmpty = widget.controllers?.every( + (c) => c.text.isEmpty, + ); + if (isPinCompletelyEmpty != null && + isPinCompletelyEmpty && + hasFocus && + _hasEdited) { return hint; }