@@ -159,7 +159,9 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
159159 if (! mounted) return ;
160160 FocusManager .instance.removeListener (_onGlobalFocusChange);
161161 for (final node in _focusNodes) {
162- node.removeListener (() => _handleFocusChange (node, _focusNodes.indexOf (node)));
162+ node.removeListener (
163+ () => _handleFocusChange (node, _focusNodes.indexOf (node)),
164+ );
163165 node.dispose ();
164166 }
165167 super .dispose ();
@@ -175,45 +177,68 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
175177
176178 @override
177179 Widget build (BuildContext context) {
178- final pinCodeToken = OudsTheme .of (context).componentsTokens (context).pinCodeInput;
179- final textInputToken = OudsTheme .of (context).componentsTokens (context).textInput;
180+ final pinCodeToken = OudsTheme .of (
181+ context,
182+ ).componentsTokens (context).pinCodeInput;
183+ final textInputToken = OudsTheme .of (
184+ context,
185+ ).componentsTokens (context).textInput;
180186 final theme = OudsTheme .of (context);
181187 final digitsCount = widget.length.digits;
182- final isError = widget.errorText != null || (widget.errorText != null && widget.errorText! .isEmpty);
188+ final isError =
189+ widget.errorText != null ||
190+ (widget.errorText != null && widget.errorText! .isEmpty);
183191 final l10n = OudsLocalizations .of (context);
184- final hintSemanticText = "${ widget .errorText != null && isError ? widget .errorText ! : widget .helperText != null ? widget .helperText ! : '' }"
192+ final hintSemanticText =
193+ "${widget .errorText != null && isError
194+ ? widget .errorText !
195+ : widget .helperText != null
196+ ? widget .helperText !
197+ : '' }"
185198 " , ${l10n ?.core_common_hint_a11y }" ;
186199
187200 return Container (
188201 constraints: BoxConstraints (
189202 minHeight: textInputToken.sizeMinHeight,
190203 minWidth: textInputToken.sizeMinWidth,
191- maxWidth: widget.digitInputDecoration.constrainedMaxWidth ? textInputToken.sizeMaxWidth : double .infinity,
204+ maxWidth: widget.digitInputDecoration.constrainedMaxWidth
205+ ? textInputToken.sizeMaxWidth
206+ : double .infinity,
192207 ),
193208 child: Column (
194- mainAxisAlignment: widget.digitInputDecoration.constrainedMaxWidth ? MainAxisAlignment .start : MainAxisAlignment .center,
209+ mainAxisAlignment: widget.digitInputDecoration.constrainedMaxWidth
210+ ? MainAxisAlignment .start
211+ : MainAxisAlignment .center,
195212 children: [
196213 Semantics (
197214 hint: hintSemanticText,
198- label: isError ? l10n? .core_common_error_a11y : l10n? .core_pinCodeInput_pinCode_label_a11y (digitsCount),
215+ label: isError
216+ ? l10n? .core_common_error_a11y
217+ : l10n? .core_pinCodeInput_pinCode_label_a11y (digitsCount),
199218 child: Row (
200- mainAxisAlignment: widget.digitInputDecoration.constrainedMaxWidth ? MainAxisAlignment .start : MainAxisAlignment .center,
201- spacing: widget.length == OudsPinCodeInputLength .eight ? 6 : pinCodeToken.spaceColumnGapDigitInput,
219+ mainAxisAlignment: widget.digitInputDecoration.constrainedMaxWidth
220+ ? MainAxisAlignment .start
221+ : MainAxisAlignment .center,
222+ spacing: widget.length == OudsPinCodeInputLength .eight
223+ ? 6
224+ : pinCodeToken.spaceColumnGapDigitInput,
202225 children: List .generate (digitsCount, (index) {
203226 return Flexible (
204227 fit: FlexFit .loose,
205228 child: Semantics (
206229 liveRegion: true ,
207- label: "${l10n ?.core_pinCodeInput_digitCode_label_a11y (index + 1 )}, "
208- "${!widget .digitInputDecoration .hiddenPassword && widget .controllers != null ? widget .controllers ![index ].text : '' }, "
230+ label:
231+ "${l10n ?.core_pinCodeInput_digitCode_label_a11y (index + 1 )}, "
232+ "${!widget .digitInputDecoration .hiddenPassword && widget .controllers != null ? widget .controllers ![index ].text : '' }, "
209233 "${l10n ?.core_pinCodeInput_trait_a11y }" ,
210234 child: OudsDigitInput (
211235 index: index,
212236 isError: isError,
213237 length: widget.length,
214238 digitInputDecoration: OudsDigitInputDecoration (
215239 hintText: _hintText (index),
216- hiddenPassword: widget.digitInputDecoration.hiddenPassword,
240+ hiddenPassword:
241+ widget.digitInputDecoration.hiddenPassword,
217242 isOutlined: widget.digitInputDecoration.isOutlined,
218243 ),
219244 focusNode: _focusNodes[index],
@@ -223,20 +248,27 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
223248 _handleDigitInput (value, index);
224249 if (! _hasEdited) {
225250 setState (() {
226- _hasEdited = true ; // The user has interacted with the PIN at least once
251+ _hasEdited =
252+ true ; // The user has interacted with the PIN at least once
227253 });
228254 }
229255 },
256+ onBackspaceOnEmpty: () => _handleBackspaceOnEmpty (index),
230257 ),
231258 ),
232259 );
233260 }),
234261 ),
235262 ),
236- if (widget.helperText != null || (widget.errorText != null && isError)) ...[
263+ if (widget.helperText != null ||
264+ (widget.errorText != null && isError)) ...[
237265 Container (
238266 constraints: BoxConstraints (
239- maxWidth: widget.digitInputDecoration.constrainedMaxWidth ? double .infinity : digitsCount * pinCodeToken.sizeMaxWidth + (digitsCount - 1 ) * pinCodeToken.spaceColumnGapDigitInput,
267+ maxWidth: widget.digitInputDecoration.constrainedMaxWidth
268+ ? double .infinity
269+ : digitsCount * pinCodeToken.sizeMaxWidth +
270+ (digitsCount - 1 ) *
271+ pinCodeToken.spaceColumnGapDigitInput,
240272 ),
241273 child: Padding (
242274 padding: EdgeInsets .only (
@@ -247,9 +279,15 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
247279 child: ExcludeSemantics (
248280 child: Text (
249281 softWrap: true ,
250- widget.errorText != null && isError ? widget.errorText! : widget.helperText! ,
251- style: theme.typographyTokens.typeLabelDefaultMedium (context).copyWith (
252- color: OudsPinCodeInputTextColorModifier (context).getPinCodeHelperTextColor (isError),
282+ widget.errorText != null && isError
283+ ? widget.errorText!
284+ : widget.helperText! ,
285+ style: theme.typographyTokens
286+ .typeLabelDefaultMedium (context)
287+ .copyWith (
288+ color: OudsPinCodeInputTextColorModifier (
289+ context,
290+ ).getPinCodeHelperTextColor (isError),
253291 ),
254292 ),
255293 ),
@@ -270,6 +308,7 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
270308
271309 final totalDigits = widget.length.digits;
272310 final controllers = widget.controllers! ;
311+ var effectiveValue = value;
273312 // Case 1: user pasted a code (more than 3 characters)
274313 if (value.length > 3 ) {
275314 _handlePaste (value);
@@ -281,25 +320,78 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
281320 controllers[index]
282321 ..text = value.characters.last
283322 ..selection = TextSelection .collapsed (offset: 1 );
284- return ;
323+ effectiveValue = controllers[index].text ;
285324 }
286325
287- final code = controllers. map ((c) => c.text). join ();
288- widget.onChanged ? . call (code);
326+ final code = _currentCode ();
327+ _emitChanged (code);
289328
290- // Case 3: deletion stay in the same field
291- if (value.isEmpty) return ;
329+ // Case 3: deletion on a filled cell. Move backward so one backspace removes one digit.
330+ if (effectiveValue.isEmpty) {
331+ _requestFocusOnPreviousField (index);
332+ return ;
333+ }
292334
293335 // Case 4: normal input move focus forward
294- if (index < totalDigits - 1 ) {
295- _focusNodes[index + 1 ].requestFocus ();
296- } else if (code.length == totalDigits) {
297- _focusNodes[index].unfocus ();
298- widget.onEditingComplete? .call (code);
299- }
336+ _requestFocusOnNextFieldOrComplete (
337+ index: index,
338+ totalDigits: totalDigits,
339+ code: code,
340+ );
300341 });
301342 }
302343
344+ /// Builds the current PIN value by concatenating all digit controllers.
345+ String _currentCode () {
346+ final controllers = widget.controllers;
347+ if (controllers == null ) return '' ;
348+ return controllers.map ((c) => c.text).join ();
349+ }
350+
351+ /// Emits onChanged with the provided code, or with the current PIN when omitted.
352+ void _emitChanged ([String ? code]) {
353+ widget.onChanged? .call (code ?? _currentCode ());
354+ }
355+
356+ /// Moves focus to the previous digit field when the index is valid.
357+ void _requestFocusOnPreviousField (int index) {
358+ if (index <= 0 ) return ;
359+ final previousIndex = index - 1 ;
360+ if (previousIndex >= _focusNodes.length) return ;
361+ _focusNodes[previousIndex].requestFocus ();
362+ }
363+
364+ /// Returns the previous index when both controller and focus node bounds are valid.
365+ /// Returns null when there is no previous field or when collections are inconsistent.
366+ int ? _validPreviousIndex (int index) {
367+ final controllers = widget.controllers;
368+ if (controllers == null || index <= 0 ) return null ;
369+
370+ final previousIndex = index - 1 ;
371+ if (previousIndex >= controllers.length ||
372+ previousIndex >= _focusNodes.length) {
373+ return null ;
374+ }
375+ return previousIndex;
376+ }
377+
378+ /// Moves focus forward when possible, or completes editing on the last filled field.
379+ void _requestFocusOnNextFieldOrComplete ({
380+ required int index,
381+ required int totalDigits,
382+ required String code,
383+ }) {
384+ if (index < totalDigits - 1 ) {
385+ _focusNodes[index + 1 ].requestFocus ();
386+ return ;
387+ }
388+
389+ if (code.length == totalDigits) {
390+ _focusNodes[index].unfocus ();
391+ widget.onEditingComplete? .call (code);
392+ }
393+ }
394+
303395 //handle copy past pin code
304396 void _handlePaste (String value) {
305397 final totalDigits = widget.length.digits;
@@ -310,8 +402,8 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
310402 controllers[i].text = digits[i];
311403 }
312404
313- final code = controllers. map ((c) => c.text). join ();
314- widget.onChanged ? . call (code);
405+ final code = _currentCode ();
406+ _emitChanged (code);
315407
316408 final isComplete = code.length == totalDigits;
317409
@@ -326,6 +418,26 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
326418 }
327419 }
328420
421+ // Called when the user presses backspace on an already-empty digit cell.
422+ // Clears the previous cell's content AND moves focus there in a single step,
423+ // so deletion feels instant instead of requiring two key presses.
424+ void _handleBackspaceOnEmpty (int index) {
425+ final controllers = widget.controllers;
426+ if (controllers == null ) return ;
427+ final previousIndex = _validPreviousIndex (index);
428+ if (previousIndex == null ) return ;
429+
430+ final previousController = controllers[previousIndex];
431+ final wasNonEmpty = previousController.text.isNotEmpty;
432+
433+ previousController.clear ();
434+ _requestFocusOnPreviousField (index);
435+
436+ if (wasNonEmpty) {
437+ _emitChanged ();
438+ }
439+ }
440+
329441 // This method is called whenever the global focus changes, using a FocusManager listener.
330442 // It updates the internal `hasAnyFocus` state to reflect whether any of the PIN input fields currently have focus.
331443 //
@@ -345,7 +457,7 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
345457 if (_previousHasFocus == hasAnyFocus) return ;
346458
347459 _previousHasFocus = hasAnyFocus;
348- final code = widget.controllers ? . map ((c) => c.text). join () ?? "" ;
460+ final code = _currentCode () ;
349461
350462 if (! hasAnyFocus && _hasEdited) {
351463 widget.onEditingComplete? .call (code);
@@ -362,8 +474,13 @@ class _OudsPinCodeInputState extends State<OudsPinCodeInput> {
362474 final text = widget.controllers? [index].text;
363475
364476 // Special case: all fields are empty, user has already edited, and cursor is invisible
365- final isPinCompletelyEmpty = widget.controllers? .every ((c) => c.text.isEmpty);
366- if (isPinCompletelyEmpty != null && isPinCompletelyEmpty && hasFocus && _hasEdited) {
477+ final isPinCompletelyEmpty = widget.controllers? .every (
478+ (c) => c.text.isEmpty,
479+ );
480+ if (isPinCompletelyEmpty != null &&
481+ isPinCompletelyEmpty &&
482+ hasFocus &&
483+ _hasEdited) {
367484 return hint;
368485 }
369486
0 commit comments