Skip to content

Commit 22d7c13

Browse files
theamiriabdelghafour amiriAhmedAmineZr
authored
fix(pinCodeInput): delete previous digit instantly on backspace (#735)(#736)
* fix(pinCodeInput): delete previous digit instantly on backspace # Conflicts: # ouds_core/lib/components/pin_code_input/digit_input/ouds_digit_input.dart * chore(pinCodeInput): update changelog for #735 * chore: update pincode logic and changelog --------- Co-authored-by: abdelghafour amiri <abdelghafour.amiri@sofrecom.com> Co-authored-by: Ahmed Amine Zribi <ahmedamine.zribi@sofrecom.com>
1 parent a622a67 commit 22d7c13

4 files changed

Lines changed: 158 additions & 42 deletions

File tree

app/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- [Library] update tokens 1.9.0 - Component Alert ([#672](https://github.com/Orange-OpenSource/ouds-flutter/issues/672))
1616

1717
### Fixed
18+
- [Library] `Pin code input` deletion requires two backspace presses on a typed digit ([#735](https://github.com/Orange-OpenSource/ouds-flutter/issues/735))
1819
- [Library] `orange compact` some components are not displayed correctly ([#630](https://github.com/Orange-OpenSource/ouds-flutter/issues/630))
1920
- [Library] `Password Input` Change the accessible name on show/hide button ([#599](https://github.com/Orange-OpenSource/ouds-flutter/issues/599))
2021
- [Library] `Password input` Hidden password is clearly read by screen readers([#488](https://github.com/Orange-OpenSource/ouds-flutter/issues/488))

ouds_core/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- [Library] update tokens 1.9.0 - Component Alert ([#672](https://github.com/Orange-OpenSource/ouds-flutter/issues/672))
1717

1818
### Fixed
19+
- [Library] `Pin code input` deletion requires two backspace presses on a typed digit ([#735](https://github.com/Orange-OpenSource/ouds-flutter/issues/735))
1920
- [Library] `orange compact` some components are not displayed correctly ([#630](https://github.com/Orange-OpenSource/ouds-flutter/issues/630))
2021
- [Library] `Password Input` Change the accessible name on show/hide button ([#599](https://github.com/Orange-OpenSource/ouds-flutter/issues/599))
2122
- [Library] `Password input` Hidden password is clearly read by screen readers([#488](https://github.com/Orange-OpenSource/ouds-flutter/issues/488))

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

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class OudsDigitInput extends StatefulWidget {
103103
late final bool isHovered;
104104
final void Function(String, int)? onChanged;
105105
final OudsPinCodeInputLength length;
106+
final VoidCallback? onBackspaceOnEmpty;
106107

107108
OudsDigitInput({
108109
super.key,
@@ -114,6 +115,7 @@ class OudsDigitInput extends StatefulWidget {
114115
this.isHovered = false,
115116
this.onChanged,
116117
this.length = OudsPinCodeInputLength.six,
118+
this.onBackspaceOnEmpty,
117119
});
118120

119121
@override
@@ -176,13 +178,8 @@ class _OudsDigitInputState extends State<OudsDigitInput> {
176178
onKeyEvent: (KeyEvent event) {
177179
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.backspace) {
178180
final text = widget.controller?.text ?? '';
179-
// If the field is empty and the user presses backspace : move to the previous one
180-
if (text.isEmpty) {
181-
final previousIndex = widget.index - 1;
182-
if (previousIndex >= 0) {
183-
widget.controller?.clear();
184-
FocusScope.of(context).previousFocus();
185-
}
181+
if (text.isEmpty && widget.index > 0) {
182+
widget.onBackspaceOnEmpty?.call();
186183
}
187184
}
188185
},

ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart

Lines changed: 152 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)