diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index dc538d955..9d4d07f53 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.3.1...develop) ### Added ### Changed +- [Library] update `Pin code input` component to v1.3 ([#691](https://github.com/Orange-OpenSource/ouds-flutter/issues/691)) - [Library] `tab bar component`, update the animation of the `selected tab indicator` ([#633](https://github.com/Orange-OpenSource/ouds-flutter/issues/633)) - [DemoApp][Library] Update `ToolBar Top`, with Badge in Trailing Actions ([#642](https://github.com/Orange-OpenSource/ouds-flutter/issues/642)) - [DemoApp][Library] update `alert message`, `switch`, `radio`, `checkbox`, `text input`, `pin code input`, `phone number input`, components to use rich text ([#782](https://github.com/Orange-OpenSource/ouds-flutter/issues/782)) @@ -25,6 +26,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [DemoApp][Library] update tokens 2.4.0 ([#726](https://github.com/Orange-OpenSource/ouds-flutter/issues/726)) ### Fixed +- [Library] `Pin code input` Issue with Delete Functionality in the PIN Field ([#791](https://github.com/Orange-OpenSource/ouds-flutter/issues/791)) +- [Library] `Pin code input` glitch when typing on keyboard ([#776](https://github.com/Orange-OpenSource/ouds-flutter/issues/776)) +- [Library] `Pin code input` Focus should not move automatically to the next field ([#649](https://github.com/Orange-OpenSource/ouds-flutter/issues/649)) +- [Library] `Pin code input` Remove the hint on the group of digits ([#628](https://github.com/Orange-OpenSource/ouds-flutter/issues/628)) - [DemoApp][Library] `Bottom Bar` Inconsistent order of the accessible ([#625](https://github.com/Orange-OpenSource/ouds-flutter/issues/625)) - [Library] `Bottom Bar` Overlap when zoom is activated ([#627](https://github.com/Orange-OpenSource/ouds-flutter/issues/627)) - [DemoApp] Update pubspec icons 1.6 ([#794](https://github.com/Orange-OpenSource/ouds-flutter/issues/794)) diff --git a/app/lib/ui/components/pin_code_input/pin_code_input_demo_screen.dart b/app/lib/ui/components/pin_code_input/pin_code_input_demo_screen.dart index ff7af4398..08a69c32c 100644 --- a/app/lib/ui/components/pin_code_input/pin_code_input_demo_screen.dart +++ b/app/lib/ui/components/pin_code_input/pin_code_input_demo_screen.dart @@ -36,7 +36,7 @@ import 'package:provider/provider.dart'; class PinCodeInputDemoScreen extends StatefulWidget { final String? previousPageTitle; - const PinCodeInputDemoScreen({super.key,this.previousPageTitle}); + const PinCodeInputDemoScreen({super.key, this.previousPageTitle}); @override State createState() => _PinCodeInputDemoScreenState(); @@ -58,13 +58,17 @@ class _PinCodeInputDemoScreenState extends State { child: PinCodeInputCustomization( key: _scaffoldKey, child: Padding( - padding: EdgeInsets.only(bottom: defaultTargetPlatform == TargetPlatform.android ? MediaQuery.of(context).viewPadding.bottom : OudsTheme.of(context).spaceScheme(context).paddingBlockNone), + padding: EdgeInsets.only( + bottom: defaultTargetPlatform == TargetPlatform.android + ? MediaQuery.of(context).viewPadding.bottom + : OudsTheme.of(context).spaceScheme(context).paddingBlockNone, + ), child: Scaffold( extendBodyBehindAppBar: true, appBar: MainAppBar( - showBackButton: true, - title: context.l10n.app_components_pinCodeInput_label, - previousPageTitle: widget.previousPageTitle, + showBackButton: true, + title: context.l10n.app_components_pinCodeInput_label, + previousPageTitle: widget.previousPageTitle, ), bottomSheet: OudsSheetsBottom( onExpansionChanged: _onExpansionChanged, @@ -92,17 +96,24 @@ class _Body extends StatefulWidget { class _BodyState extends State<_Body> { @override Widget build(BuildContext context) { - final themeController = Provider.of(context, listen: false); + final themeController = Provider.of( + context, + listen: false, + ); return DetailScreenDescription( description: context.l10n.app_components_pinCodeInput_description_text, widget: Column( children: [ const _PinCodeInputDemo(), - SizedBox(height: themeController.currentTheme.spaceScheme(context).fixedMedium), - Code( - code: PinCodeInputCodeGenerator.updateCode(context), + SizedBox( + height: themeController.currentTheme + .spaceScheme(context) + .fixedMedium, + ), + Code(code: PinCodeInputCodeGenerator.updateCode(context)), + ReferenceDesignVersionComponent( + version: OudsComponentVersion.pinCodeInput, ), - ReferenceDesignVersionComponent(version: OudsComponentVersion.pinCodeInput), ], ), ); @@ -118,7 +129,18 @@ class _PinCodeInputDemo extends StatefulWidget { class _PinCodeInputDemoState extends State<_PinCodeInputDemo> { List controllers = []; - late int pinCodeLength; + OudsPinCodeInputLength? _currentLength; + + /// Initialises (or reinitialises) the controller list whenever the PIN + /// length changes. Previous controllers are disposed before recreation. + void _syncControllers(OudsPinCodeInputLength length) { + if (_currentLength == length) return; + for (final c in controllers) { + c.dispose(); + } + _currentLength = length; + controllers = List.generate(length.digits, (_) => TextEditingController()); + } @override void dispose() { @@ -131,31 +153,57 @@ class _PinCodeInputDemoState extends State<_PinCodeInputDemo> { @override Widget build(BuildContext context) { final customizationState = PinCodeInputCustomization.of(context)!; - for (int i = 0; i < PinCodeInputCustomizationUtils.getLength(customizationState.selectedPinCodeLength as Object).digits; i++) { - controllers.add(TextEditingController()); - } + final getLength = PinCodeInputCustomizationUtils.getLength( + customizationState.selectedPinCodeLength as Object, + ); - final getLength = PinCodeInputCustomizationUtils.getLength(customizationState.selectedPinCodeLength as Object); + _syncControllers(getLength); return LightDarkBox( hasConstrainedMaxWidthOption: true, child: OudsPinCodeInput( controllers: controllers, - helperText: customizationState.hasHelperText && customizationState.pinCodeHelperText.isNotEmpty ? PinCodeInputCustomizationUtils.getPinCodeHelperText(customizationState) : null, + helperText: + customizationState.hasHelperText && + customizationState.pinCodeHelperText.isNotEmpty + ? PinCodeInputCustomizationUtils.getPinCodeHelperText( + customizationState, + ) + : null, length: getLength, - errorText: customizationState.hasError ? PinCodeInputCustomizationUtils.getPinCodeErrorText(customizationState) : null, + errorText: customizationState.hasError + ? PinCodeInputCustomizationUtils.getPinCodeErrorText( + customizationState, + ) + : null, digitInputDecoration: OudsDigitInputDecoration( - hintText: PinCodeInputCustomizationUtils.getPinCodePlaceholderText(customizationState), + hintText: PinCodeInputCustomizationUtils.getPinCodePlaceholderText( + customizationState, + ), hiddenPassword: customizationState.hasHiddenPassword, isOutlined: customizationState.hasOutlined, - constrainedMaxWidth: customizationState.hasConstrainedMaxWidth ? true : false, - keyboardType: PinCodeInputCustomizationUtils.getKeyboardType(customizationState.selectedKeyboardType), + constrainedMaxWidth: customizationState.hasConstrainedMaxWidth + ? true + : false, + keyboardType: PinCodeInputCustomizationUtils.getKeyboardType( + customizationState.selectedKeyboardType, + ), ), onEditingComplete: (value) async { - final errorLabel = context.l10n.app_components_pinCodeInput_error_label; - final verificationErrorLabel = context.l10n.app_components_pinCodeInput_verification_error_label; + final errorLabel = + context.l10n.app_components_pinCodeInput_error_label; + final verificationErrorLabel = + context.l10n.app_components_pinCodeInput_verification_error_label; await _handleCompleted( - context, value, PinCodeInputCustomizationUtils.getLength(customizationState.selectedPinCodeLength as Object).digits, customizationState, errorLabel, verificationErrorLabel); + context, + value, + PinCodeInputCustomizationUtils.getLength( + customizationState.selectedPinCodeLength as Object, + ).digits, + customizationState, + errorLabel, + verificationErrorLabel, + ); }, onChanged: (value) { if (value.isEmpty || value.length < getLength.digits) { @@ -169,10 +217,19 @@ class _PinCodeInputDemoState extends State<_PinCodeInputDemo> { Future _fakeVerify(String code) async { await Future.delayed(Duration(milliseconds: 300)); - return code == "1234" || code == "123456" || code == "12345678"; // demo logic + return code == "1234" || + code == "123456" || + code == "12345678"; // demo logic } - Future _handleCompleted(BuildContext context, String value, int digitLength, PinCodeInputCustomizationState customizationState, String errorLabel, String verificationErrorLabel) async { + Future _handleCompleted( + BuildContext context, + String value, + int digitLength, + PinCodeInputCustomizationState customizationState, + String errorLabel, + String verificationErrorLabel, + ) async { final isValid = await _fakeVerify(value); String errorText = ""; @@ -231,11 +288,15 @@ class _CustomizationContentState extends State<_CustomizationContent> { CustomizableSwitch( title: context.l10n.app_components_common_error_label, value: customizationState.hasError, - onChanged: customizationState.hasHelperText && !customizationState.hasError + onChanged: + customizationState.hasHelperText && !customizationState.hasError ? null : (value) { customizationState.hasError = value; - value ? customizationState.pinCodeErrorText = context.l10n.app_components_pinCodeInput_error_label : customizationState.pinCodeErrorText = ""; + value + ? customizationState.pinCodeErrorText = + context.l10n.app_components_pinCodeInput_error_label + : customizationState.pinCodeErrorText = ""; }, ), CustomizableSwitch( @@ -248,14 +309,15 @@ class _CustomizationContentState extends State<_CustomizationContent> { }, ), Visibility( - visible: customizationState.hasHelperText, - child: CustomizableTextField( - fieldEnable: !customizationState.hasError, - title: context.l10n.app_components_common_helperText_label, - text: customizationState.pinCodeHelperText, - focusNode: helperFocus, - fieldType: FieldType.helper, - )), + visible: customizationState.hasHelperText, + child: CustomizableTextField( + fieldEnable: !customizationState.hasError, + title: context.l10n.app_components_common_helperText_label, + text: customizationState.pinCodeHelperText, + focusNode: helperFocus, + fieldType: FieldType.helper, + ), + ), CustomizableSwitch( title: context.l10n.app_components_pinCodeInput_hidden_password_label, value: customizationState.hasHiddenPassword, diff --git a/ouds_core/CHANGELOG.md b/ouds_core/CHANGELOG.md index 633af7a3f..0fee98179 100644 --- a/ouds_core/CHANGELOG.md +++ b/ouds_core/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.3.1...develop) ### Added ### Changed +- [Library] update `Pin code input` component to v1.3 ([#691](https://github.com/Orange-OpenSource/ouds-flutter/issues/691)) - [Library] `tab bar component`, update the animation of the `selected tab indicator` ([#633](https://github.com/Orange-OpenSource/ouds-flutter/issues/633)) - [Library] Update `ToolBar Top`, with Badge in Trailing Actions ([#642](https://github.com/Orange-OpenSource/ouds-flutter/issues/642)) - [Library] update `alert message`, `switch`, `radio`, `checkbox`, `text input`, `pin code input`, `phone number input`, components to use rich text ([#782](https://github.com/Orange-OpenSource/ouds-flutter/issues/782)) @@ -24,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Library] update tokens 2.4.0 ([#726](https://github.com/Orange-OpenSource/ouds-flutter/issues/726)) ### Fixed +- [Library] `Pin code input` Issue with Delete Functionality in the PIN Field ([#791](https://github.com/Orange-OpenSource/ouds-flutter/issues/791)) +- [Library] `Pin code input` glitch when typing on keyboard ([#776](https://github.com/Orange-OpenSource/ouds-flutter/issues/776)) +- [Library] `Pin code input` Focus should not move automatically to the next field ([#649](https://github.com/Orange-OpenSource/ouds-flutter/issues/649)) +- [Library] `Pin code input` Remove the hint on the group of digits ([#628](https://github.com/Orange-OpenSource/ouds-flutter/issues/628)) - [Library] `Bottom Bar` Inconsistent order of the accessible ([#625](https://github.com/Orange-OpenSource/ouds-flutter/issues/625)) - [Library] `Bottom Bar` Overlap when zoom is activated ([#627](https://github.com/Orange-OpenSource/ouds-flutter/issues/627)) - [Library] `Password input` Label is truncated when zoom is applied ([#600](https://github.com/Orange-OpenSource/ouds-flutter/issues/600)) 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 6ac5a984d..6a338016f 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 @@ -10,10 +10,10 @@ * // Software description: Flutter library of reusable graphical components * // */ -/// @nodoc +/// {@category PIN code input} +library; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:ouds_core/components/form_input/internal/modifier/ouds_form_input_border_modifier.dart'; import 'package:ouds_core/components/pin_code_input/internal/modifier/ouds_pin_code_input_background_modifier.dart'; import 'package:ouds_core/components/pin_code_input/internal/modifier/ouds_pin_code_input_border_modifier.dart'; @@ -24,27 +24,20 @@ import 'package:ouds_theme_contract/ouds_theme.dart'; /// [OUDS Pin Code Input guidelines](https://unified-design-system.orange.com/472794e18/p/9767bc-pin-code-input-v1) /// -/// Configuration for decorating the [OudsDigitInput] widget. -/// -/// Provides properties to customize hints, error status, hidden password and styling. +/// Visual decoration configuration for each digit cell in an [OudsPinCodeInput]. /// /// Parameters: -/// -/// - [hintText]: A short placeholder or hint shown inside the input when empty. -/// -/// - [hiddenPassword]: Controls whether the characters entered in the pin code input should be displayed as plain text or hidden. -/// -/// - [isOutlined]: A boolean value that defines the visual style of the Pin Code Input. -/// Set to `false` for the default filled style used in standard form pages, -/// or `true` for the outlined variant, which provides a lighter appearance suitable for contextual or secondary use. -/// - [constrainedMaxWidth]: When `true`, the item width is constrained to a maximum value defined by the design system. -/// When `false`, no specific width constraint is applied, allowing the component to size itself or follow external modifiers. -/// Defaults to `false`. -/// - [keyboardType]: Soft keyboard requested when a digit cell is focused. Defaults to [OudsPinCodeInputKeyboardType.numeric]. -/// Use [OudsPinCodeInputKeyboardType.alphanumeric] to allow letters in addition to digits. +/// - [hintText]: Placeholder shown in an empty, unfocused cell (e.g. `"-"`). +/// - [hiddenPassword]: When `true` (default), filled cells show `●` instead of +/// the actual character. +/// - [isOutlined]: `false` (default) for a filled style, `true` for outlined. +/// - [constrainedMaxWidth]: When `true`, cells are capped to the design-token +/// maximum width. Defaults to `false`. +/// - [keyboardType]: Keyboard variant for the cells. Defaults to +/// [OudsPinCodeInputKeyboardType.numeric]. /// class OudsDigitInputDecoration { - final String? hintText; //placeholder + final String? hintText; final bool hiddenPassword; final bool isOutlined; final bool constrainedMaxWidth; @@ -59,213 +52,230 @@ class OudsDigitInputDecoration { }); } -// TODO: Add documentation URL once it is available +/// A purely visual widget that renders a single digit cell of a PIN code input. /// -/// A Digit Input refers to a single input box that accepts exactly one numeric character (0–9). -/// In the context of a PIN code or OTP, multiple digit inputs are placed side by side, -/// each holding one digit, to form the complete code. -/// -/// Parameters: -/// - [index]: The index of this digit input within the PIN code sequence. -/// - [isError]: The Error status indicates that the user input does not meet validation rules or expected formatting. -/// It provides immediate visual feedback, typically through a red border, error icon, and a clear, accessible error message positioned below the input -/// - [digitInputDecoration]: Defines the decoration of each digit input box [OudsDigitInputDecoration] -/// - [controller]: Controller for managing the text value of this digit. -/// - [focusNode]: Focus node to manage keyboard focus for this digit input. -/// - [isHovered]: Whether the digit input is currently hovered. -/// - [onChanged]: Callback triggered when the digit value changes. Provides the new value and the index of this digit. +/// Keyboard input is handled entirely by the parent [OudsPinCodeInput] via a +/// single hidden [TextField]; [displayValue] is simply passed down for rendering. /// +/// ## Visual states /// -/// ## You can use [OudsDigitInput] like this : +/// | Condition | Content shown | +/// |---|---| +/// | Not focused, empty | Hint placeholder | +/// | Not focused, filled | Value (`●` when `hiddenPassword` is `true`) | +/// | Focused, empty | Blinking cursor | +/// | Focused, filled — normal mode | Blinking cursor only | +/// | Focused, filled — accessibility mode | Value **+** blinking cursor | /// -/// This is the default style of the component. +/// The last row applies when [isAccessibilityActive] is `true`, giving +/// assistive-technology users a clear indicator that the cell is selected even +/// after it has been filled. /// +/// ## Example /// /// ```dart +/// // Typically created by OudsPinCodeInput — shown here for illustration. /// OudsDigitInput( -/// index: index, -/// isError: true, -/// hiddenPassword: widget.hiddenPassword, -/// digitInputDecoration: OudsDigitInputDecoration( -/// hintText: widget.hintText, -/// style: widget.style, -/// roundedCorner: widget.roundedCorner -/// ), -/// focusNode: _focusNodes[index], -/// isHovered: _isHovered[index], -/// controller: widget.controllers[index], -/// onChanged: (value, index) {}, -/// ) +/// index: 0, +/// isFocused: true, +/// displayValue: '3', +/// isAccessibilityActive: false, +/// digitInputDecoration: OudsDigitInputDecoration( +/// hintText: '-', +/// hiddenPassword: true, +/// ), +/// ) /// ``` /// +/// Parameters: +/// - [index]: Zero-based position of this cell in the PIN sequence. +/// - [isError]: When `true`, the cell adopts the error visual style. +/// - [isFocused]: Whether this cell is the currently active position. +/// - [displayValue]: Character to display; empty string when the cell is empty. +/// - [digitInputDecoration]: Decoration options (hint, masking, style, …). +/// class OudsDigitInput extends StatefulWidget { final int index; - late final bool isError; + final bool isError; final OudsDigitInputDecoration? digitInputDecoration; - final TextEditingController? controller; - final FocusNode? focusNode; - late final bool isHovered; - final void Function(String, int)? onChanged; - final OudsPinCodeInputLength length; - final VoidCallback? onBackspaceOnEmpty; - final VoidCallback? onPasteRequested; + final bool isFocused; + final String displayValue; - OudsDigitInput({ + const OudsDigitInput({ super.key, required this.index, this.isError = false, this.digitInputDecoration, - this.controller, - this.focusNode, - this.isHovered = false, - this.onChanged, - this.length = OudsPinCodeInputLength.six, - this.onBackspaceOnEmpty, - this.onPasteRequested, + this.isFocused = false, + this.displayValue = '', }); @override State createState() => _OudsDigitInputState(); } -class _OudsDigitInputState extends State { +class _OudsDigitInputState extends State + with SingleTickerProviderStateMixin { bool _isHovered = false; - late final FocusNode _keyboardFocusNode; + + /// Drives the blinking cursor animation (530 ms, repeating). + late final AnimationController _cursorBlink; @override void initState() { super.initState(); - _keyboardFocusNode = FocusNode(skipTraversal: true); // focus technique uniquement pour clavier + _cursorBlink = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 530), + ); + // Start blinking immediately if the cell is already focused. + if (widget.isFocused) { + _cursorBlink.repeat(reverse: true); + } + } + + @override + void didUpdateWidget(OudsDigitInput oldWidget) { + super.didUpdateWidget(oldWidget); + // Sync cursor animation with focus state. + if (widget.isFocused && !_cursorBlink.isAnimating) { + _cursorBlink.repeat(reverse: true); + } else if (!widget.isFocused && _cursorBlink.isAnimating) { + _cursorBlink.stop(); + _cursorBlink.value = 0; + } } @override void dispose() { - _keyboardFocusNode.dispose(); + _cursorBlink.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final pinCodeToken = OudsTheme.of(context).componentsTokens(context).pinCodeInput; - final textInputToken = OudsTheme.of(context).componentsTokens(context).textInput; - final pinCodeInputBackgroundModifier = OudsPinCodeInputBackgroundColorModifier(context); - final pinCodeInputBorderModifier = OudsPinCodeInputBorderModifier(context); - final textInputBorderModifier = OudsFormFieldsBorderModifier(context); - final pinCodeInputTextModifier = OudsPinCodeInputTextColorModifier(context); + final pinCodeToken = OudsTheme.of( + context, + ).componentsTokens(context).pinCodeInput; + final textInputToken = OudsTheme.of( + context, + ).componentsTokens(context).textInput; + final backgroundModifier = OudsPinCodeInputBackgroundColorModifier(context); + final borderModifier = OudsPinCodeInputBorderModifier(context); + final formBorderModifier = OudsFormFieldsBorderModifier(context); final theme = OudsTheme.of(context); - final isFocused = widget.focusNode?.hasFocus; + final cursorColorModifier = OudsPinCodeInputTextColorModifier(context); + + final isOutlined = widget.digitInputDecoration?.isOutlined ?? false; + final hiddenPassword = widget.digitInputDecoration?.hiddenPassword ?? true; final state = OudsPinCodeInputControlStateDeterminer( - isFocused: isFocused!, + isFocused: widget.isFocused, isHovered: _isHovered, ).determineControlState(); + // Show hint only when the cell is empty and not focused. + final showHint = widget.displayValue.isEmpty && !widget.isFocused; + + // Mask filled value with a bullet when hiddenPassword is enabled. + final displayText = widget.displayValue.isNotEmpty + ? (hiddenPassword ? '●' : widget.displayValue) + : ''; + + // Show cursor whenever the cell is focused. + final showCursor = widget.isFocused; + + // show value + cursor together on a focused filled + // cell + final showValueWithCursor = showCursor && widget.displayValue.isNotEmpty; + final cursorColor = cursorColorModifier.getPinCodeCursorColor( + widget.isError, + ); + final cursorHeight = theme.fontTokens.lineHeightLabelLarge; + + // Builds the animated blinking cursor. + Widget buildCursor() => AnimatedBuilder( + animation: _cursorBlink, + builder: (context, _) => Opacity( + opacity: _cursorBlink.value, + child: Container(width: 2, height: cursorHeight, color: cursorColor), + ), + ); + return ExcludeSemantics( - child: InkWell( - onHover: (hovering) { - if (!mounted) return; - setState(() { - _isHovered = hovering; - }); + child: MouseRegion( + onEnter: (_) { + if (mounted) setState(() => _isHovered = true); + }, + onExit: (_) { + if (mounted) setState(() => _isHovered = false); }, child: Container( height: textInputToken.sizeMinHeight, - constraints: BoxConstraints( - maxWidth: pinCodeToken.sizeMaxWidth, - minWidth: pinCodeToken.sizeMinWidth), - decoration: BoxDecoration( - color: pinCodeInputBackgroundModifier.getPinCodeBackgroundColor(state, widget.isError, widget.digitInputDecoration!.isOutlined), - border: pinCodeInputBorderModifier.getPinCodeBorder(state, widget.isError, widget.digitInputDecoration!.isOutlined), - borderRadius: textInputBorderModifier.getBorderRadius(context), + constraints: BoxConstraints( + maxWidth: pinCodeToken.sizeMaxWidth, + minWidth: pinCodeToken.sizeMinWidth, + ), + decoration: BoxDecoration( + color: backgroundModifier.getPinCodeBackgroundColor( + state, + widget.isError, + isOutlined, + ), + border: borderModifier.getPinCodeBorder( + state, + widget.isError, + isOutlined, ), - child: Center( - child: KeyboardListener( - focusNode: _keyboardFocusNode, - onKeyEvent: (KeyEvent event) { - if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.backspace) { - final text = widget.controller?.text ?? ''; - if (text.isEmpty && widget.index > 0) { - widget.onBackspaceOnEmpty?.call(); - } - } - }, - child: TextField( - cursorHeight: theme.fontTokens.lineHeightLabelLarge, - obscureText: widget.digitInputDecoration!.hiddenPassword, - obscuringCharacter: "●", - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: theme.colorScheme(context).contentDefault, + borderRadius: formBorderModifier.getBorderRadius(context), + ), + child: Center( + child: showValueWithCursor + // Accessibility + focused + filled: value and cursor side-by-side. + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + displayText, + style: theme.typographyTokens + .typeLabelDefaultLarge(context) + .copyWith( + color: theme.colorScheme(context).contentDefault, + ), + textAlign: TextAlign.center, + maxLines: 1, ), - cursorColor: pinCodeInputTextModifier.getPinCodeCursorColor(widget.isError), - controller: widget.controller, - focusNode: widget.focusNode, - keyboardType: widget.digitInputDecoration!.keyboardType == OudsPinCodeInputKeyboardType.numeric - ? TextInputType.number - : TextInputType.text, - inputFormatters: [ - // Let a full pasted code arrive intact in one cell so the - // parent's `_distributeCode` can spread it. Without this, - // Flutter's default `maxLength` behaviour would clip. - LengthLimitingTextInputFormatter(widget.length.digits), - if (widget.digitInputDecoration!.keyboardType == - OudsPinCodeInputKeyboardType.numeric) - FilteringTextInputFormatter.digitsOnly, - ], - // Long-press paste bypasses the TextField entirely: we - // rebuild the platform toolbar but swap the Paste action - // for the parent's clipboard-direct handler. - contextMenuBuilder: widget.onPasteRequested == null - ? null - : (context, editableTextState) { - final items = editableTextState - .contextMenuButtonItems - .map((item) { - if (item.type == - ContextMenuButtonType.paste) { - return ContextMenuButtonItem( - type: ContextMenuButtonType.paste, - onPressed: () { - editableTextState.hideToolbar(); - widget.onPasteRequested!(); - }, - ); - } - return item; - }) - .toList(); - return AdaptiveTextSelectionToolbar.buttonItems( - anchors: editableTextState.contextMenuAnchors, - buttonItems: items, - ); - }, - textAlign: TextAlign.center, - maxLines: 1, - buildCounter: (_, {required currentLength, required isFocused, required maxLength}) => null, // to hide the counter - decoration: InputDecoration( - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - counterText: '', - hintText: widget.digitInputDecoration?.hintText, - hintStyle: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + buildCursor(), + ], + ) + // Focused: cursor only. + : showCursor + ? buildCursor() + // Not focused, empty: hint placeholder. + : showHint && widget.digitInputDecoration?.hintText != null + ? Text( + widget.digitInputDecoration!.hintText!, + style: theme.typographyTokens + .typeLabelDefaultLarge(context) + .copyWith( color: theme.colorScheme(context).contentMuted, - ), // remove internal padding + ), + textAlign: TextAlign.center, + maxLines: 1, + ) + // Not focused, filled: masked or plain value. + : Text( + displayText, + style: theme.typographyTokens + .typeLabelDefaultLarge(context) + .copyWith( + color: theme.colorScheme(context).contentDefault, + ), + textAlign: TextAlign.center, + maxLines: 1, ), - onChanged: (value) { - widget.onChanged!(value, widget.index); - setState(() {}); - }, - onTap: () { - //cursor should be always at the end of digit input - final text = widget.controller?.text; - widget.controller?.selection = TextSelection.fromPosition( - TextPosition(offset: text!.length), - ); - }, - ), - ), - ), + ), ), ), ); 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 6386ad23f..b5b470228 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 @@ -14,18 +14,20 @@ library; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:ouds_core/components/pin_code_input/digit_input/ouds_digit_input.dart'; import 'package:ouds_core/components/pin_code_input/internal/modifier/ouds_pin_code_input_text_color_modifier.dart'; import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; -/// The [OudsPinCodeInputLength] defines the length of OudsPinCodeInput. +/// Number of digit cells in an [OudsPinCodeInput]: [four], [six] or [eight]. enum OudsPinCodeInputLength { four, six, eight; + /// Returns the actual number of digits for this variant. int get digits { switch (this) { case OudsPinCodeInputLength.four: @@ -40,12 +42,10 @@ enum OudsPinCodeInputLength { const OudsPinCodeInputLength(); } -/// The [OudsPinCodeInputKeyboardType] defines which soft keyboard the digit cells request. +/// Keyboard variant used by [OudsPinCodeInput]. /// -/// - [numeric]: numeric keyboard (digits 0–9 only). Non-digit input is filtered out, including paste. -/// - [alphanumeric]: standard text keyboard. Any character is accepted. -/// -/// Defaults to [numeric] to match the historical PIN behavior. +/// - [numeric]: digits-only keyboard; non-digit input is stripped automatically. +/// - [alphanumeric]: standard keyboard; any character is accepted. enum OudsPinCodeInputKeyboardType { numeric, alphanumeric; @@ -55,59 +55,61 @@ enum OudsPinCodeInputKeyboardType { /// [OUDS PIN Code Input Design Guidelines](https://r.orange.fr/r/S-ouds-doc-pin-code-input) /// -/// **Reference design version : 1.2.0** +/// **Reference design version : 1.3.0** /// -/// PIN code input is a UI element that allows to capture short, fixed-length numeric codes, -/// typically for authentication or confirmation purposes, such as a four, -/// six or height-digit personal identification number (PIN). +/// A fixed-length PIN code input composed of individual digit cells, typically +/// used for authentication or confirmation flows. /// -/// It is often presented as a series of individual input fields or boxes, each representing a single digit, -/// to enhance readability and encourage accurate input. +/// ## Architecture /// -/// This component must support smooth keyboard navigation (automatic focus shift, backspace handling), -/// secure input masking if needed. It is commonly used in sensitive flows like login, verification, -/// or transaction confirmation. +/// A single invisible [TextField] captures all keyboard input and holds the +/// full PIN string, keeping the soft keyboard open and stable across all cell +/// transitions. The visual cells ([OudsDigitInput]) are purely decorative. /// -/// Parameters: +/// ## Accessibility /// -/// - [length]: Defines the fixed number of digits required for the PIN code , Example [OudsPinCodeInputLength.six.value] -/// - [helperText] Supporting text conveys additional information about the input field, such as how it will be used. -/// eg. 'Enter the 4-digit code sent to your phone.'. -/// - [errorText]: Text shown below the input indicating an error state or invalid input. -/// - [controllers]: List of controllers managing the text of each digit input field. -/// - [onEditingComplete]: Callback triggered when the PIN input is completely filled. -/// Provides the concatenated PIN value as a string. -/// - [onChanged]: Callback triggered when the pin code value changes. Provides the new value of the pin code input. -/// - [digitInputDecoration]: Defines the decoration of each digit input box [OudsDigitInputDecoration] +/// When any platform accessibility feature is active (screen reader, bold text, +/// high contrast, reduced motion, …) the component switches to an +/// accessibility-friendly mode: +/// - **No automatic focus advance** — the user moves to the next cell manually. +/// - **Cell-by-cell deletion** — backspace clears only the selected cell. +/// - **Live regions disabled** — prevents the screen reader from jumping to +/// cells whose content changed after a keystroke. /// -/// ### You can use [OudsPinCodeInput] component in your project, customizing parameters as needed : +/// ## Example /// /// ```dart /// OudsPinCodeInput( -/// controllers: controllers, -/// helperText: "Please enter the 4-digit code sent to your phone.", -/// style: OudsTextInputStyle.defaultStyle, -/// length: OudsPinCodeInputLength.four, +/// length: OudsPinCodeInputLength.six, +/// helperText: 'Enter your 6-digit code', /// digitInputDecoration: OudsDigitInputDecoration( -/// hintText : "-", -/// roundedCorner: true, -/// style: OudsTextInputStyle.defaultStyle -/// ), -/// onEditingComplete: (value){}, -/// onChanged: (value){}, -/// ); +/// hintText: '-', +/// hiddenPassword: true, +/// ), +/// onChanged: (value) => print('Current PIN: $value'), +/// onEditingComplete: (value) => print('PIN complete: $value'), +/// ) /// ``` /// +/// Parameters: +/// - [length]: Number of digit cells. Defaults to [OudsPinCodeInputLength.six]. +/// - [helperText]: Supporting text shown below the input. +/// - [errorText]: Error message shown below the input; also sets the error state. +/// - [controllers]: Optional per-cell controllers for reading individual values. +/// - [onEditingComplete]: Called with the full PIN when all cells are filled. +/// - [onChanged]: Called with the current PIN on every keystroke. +/// - [digitInputDecoration]: Visual and keyboard configuration for the cells. +/// class OudsPinCodeInput extends StatefulWidget { final OudsPinCodeInputLength length; final String? helperText; - late String? errorText; + final String? errorText; final List? controllers; final void Function(String)? onEditingComplete; final void Function(String)? onChanged; final OudsDigitInputDecoration digitInputDecoration; - OudsPinCodeInput({ + const OudsPinCodeInput({ super.key, this.length = OudsPinCodeInputLength.six, this.helperText, @@ -122,73 +124,279 @@ class OudsPinCodeInput extends StatefulWidget { State createState() => _OudsPinCodeInputState(); } -class _OudsPinCodeInputState extends State { - final List _focusNodes = []; - late List _isHovered; - int currentIndex = 0; +class _OudsPinCodeInputState extends State + with WidgetsBindingObserver { + /// Holds the full PIN string typed by the user. + /// A single controller is shared across all cells so the soft keyboard + /// never resets between cell transitions. + late final TextEditingController _hiddenController; + + /// Focus node attached to the hidden [TextField]. + late final FocusNode _hiddenFocusNode; + + /// Whether the hidden [TextField] currently has keyboard focus. + bool _hasFocus = false; + + /// `true` once the user has typed at least one character. bool _hasEdited = false; - bool hasAnyFocus = false; - bool? _previousHasFocus; + + // Flag to prevent re-entrant updates when syncing from external controllers. + bool _updatingFromExternal = false; + + // ─── Lifecycle ─────────────────────────────────────────────────────────── @override void initState() { super.initState(); - _isHovered = List.filled(widget.length.digits, false); // init hover states - for (int i = 0; i < widget.length.digits; i++) { - final focusNode = FocusNode(); - focusNode.addListener(() => _handleFocusChange(focusNode, i)); - _focusNodes.add(focusNode); - } - FocusManager.instance.addListener(_onGlobalFocusChange); + WidgetsBinding.instance.addObserver(this); + + // Pre-fill the hidden controller if individual controllers were provided. + final initial = widget.controllers?.map((c) => c.text).join() ?? ''; + _hiddenController = TextEditingController(text: initial); + _hiddenFocusNode = FocusNode(); + _hiddenFocusNode.addListener(_onFocusChange); + _hiddenController.addListener(_onHiddenControllerChanged); + // Listen to external controllers so this instance stays in sync when + // another instance (e.g. the dark-mode box in LightDarkBox) updates them. + _addExternalControllerListeners(widget.controllers); } @override void didUpdateWidget(OudsPinCodeInput oldWidget) { super.didUpdateWidget(oldWidget); - - if (oldWidget.length.digits != widget.length.digits) { - for (final node in _focusNodes) { - node.dispose(); - } - _focusNodes.clear(); - - for (int i = 0; i < widget.length.digits; i++) { - final focusNode = FocusNode(); - focusNode.addListener(() { - if (!mounted) return; - if (focusNode.hasFocus) { - setState(() { - currentIndex = i; - }); - } - }); - _focusNodes.add(focusNode); - _isHovered = List.filled(widget.length.digits, false); - } + // Trim stored text if the number of cells decreases at runtime. + if (oldWidget.length != widget.length) { + final text = _hiddenController.text.characters + .take(widget.length.digits) + .toString(); + _hiddenController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } + // Update external controller listeners when the list reference changes. + if (oldWidget.controllers != widget.controllers) { + _removeExternalControllerListeners(oldWidget.controllers); + _addExternalControllerListeners(widget.controllers); } } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + _hiddenFocusNode.removeListener(_onFocusChange); + _hiddenController.removeListener(_onHiddenControllerChanged); + _removeExternalControllerListeners(widget.controllers); + _hiddenController.dispose(); + _hiddenFocusNode.dispose(); + super.dispose(); + } + + // ─── Focus ─────────────────────────────────────────────────────────────── + + void _onFocusChange() { if (!mounted) return; - FocusManager.instance.removeListener(_onGlobalFocusChange); - for (final node in _focusNodes) { - node.removeListener( - () => _handleFocusChange(node, _focusNodes.indexOf(node)), + setState(() => _hasFocus = _hiddenFocusNode.hasFocus); + } + + // ─── External controller sync ───────────────────────────────────────────── + + void _addExternalControllerListeners(List? list) { + list?.forEach((c) => c.addListener(_onExternalControllerChanged)); + } + + void _removeExternalControllerListeners(List? list) { + list?.forEach((c) => c.removeListener(_onExternalControllerChanged)); + } + + /// Called when an external per-cell controller changes from outside + /// (e.g. a sibling instance updating shared controllers in LightDarkBox). + /// Rebuilds the internal hidden controller to stay in sync. + void _onExternalControllerChanged() { + if (!mounted || _updatingFromExternal) return; + final newText = widget.controllers?.map((c) => c.text).join() ?? ''; + if (newText != _hiddenController.text) { + _updatingFromExternal = true; + _hiddenController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), ); - node.dispose(); + _updatingFromExternal = false; } - super.dispose(); } - void _handleFocusChange(FocusNode focusNode, int index) { - if (focusNode.hasFocus) { - setState(() { - currentIndex = index; + // ─── Input handling ────────────────────────────────────────────────────── + + /// Central listener called on every change to [_hiddenController]. + /// + /// 1. **Sanitise** — strip non-digits (numeric mode) and truncate to max length. + /// 2. **Accessibility deletion** — redirect native backspace to remove only + /// the character at [_voiceOverActiveIndex] instead of the last char. + /// 3. **Accessibility overflow guard** — cap input to the active cell so a + /// typed character cannot silently fill the next cell. + /// 4. **Sync individual controllers** — keep per-cell controllers up to date. + /// 5. **Completion** — unfocus and fire [widget.onEditingComplete] when all + /// cells are filled (skipped in accessibility mode). + void _onHiddenControllerChanged() { + if (!mounted) return; + + final raw = _hiddenController.text; + final totalDigits = widget.length.digits; + + // Step 1 — Sanitise input. + final sanitized = + widget.digitInputDecoration.keyboardType == + OudsPinCodeInputKeyboardType.numeric + ? raw.replaceAll(RegExp(r'\D'), '') + : raw; + final trimmed = sanitized.characters.take(totalDigits).toString(); + + // If sanitisation changed the text, rewrite the controller and let the + // listener fire again with the clean value. + if (raw != trimmed) { + _hiddenController.value = TextEditingValue( + text: trimmed, + selection: TextSelection.collapsed(offset: trimmed.length), + ); + return; + } + + // Step 4 — Sync per-cell controllers. + final controllers = widget.controllers; + if (controllers != null) { + for (int i = 0; i < totalDigits; i++) { + final char = i < trimmed.length ? trimmed[i] : ''; + if (controllers[i].text != char) { + controllers[i].value = TextEditingValue( + text: char, + selection: TextSelection.collapsed(offset: char.length), + ); + } + } + } + + if (!_hasEdited && trimmed.isNotEmpty) _hasEdited = true; + + setState(() {}); + widget.onChanged?.call(trimmed); + + //when focus moves to the next field, the new focused input shall be vocalized to inform the user + if (_hiddenFocusNode.hasFocus && trimmed.length < totalDigits) { + Future.delayed(const Duration(milliseconds: 100), () { + if (!mounted) return; + + final nextIndex = _activeIndex + 1; + + SemanticsService.announce( + OudsLocalizations.of( + context, + )?.core_pinCodeInput_digitPosition_a11y(nextIndex, totalDigits) ?? + '', + Directionality.of(context), + ); }); } + // Step 5 — Completion. + // In normal mode, unfocus when the last cell is filled so the keyboard + // dismisses automatically. In accessibility mode we intentionally keep + // focus so the assistive technology can continue reading the filled cells. + if (trimmed.length == totalDigits) { + _hiddenFocusNode.unfocus(); + widget.onEditingComplete?.call(trimmed); + } + } + + // ─── Paste ─────────────────────────────────────────────────────────────── + + /// Reads text from the clipboard and populates all cells at once. + /// Triggered by a long-press on the cell row. + Future _pasteFromClipboard() async { + try { + final data = await Clipboard.getData( + Clipboard.kTextPlain, + ).timeout(const Duration(seconds: 2), onTimeout: () => null); + if (!mounted) return; + final text = data?.text; + if (text == null || text.isEmpty) return; + + final totalDigits = widget.length.digits; + final sanitized = + widget.digitInputDecoration.keyboardType == + OudsPinCodeInputKeyboardType.numeric + ? text.replaceAll(RegExp(r'\D'), '') + : text; + final trimmed = sanitized.characters.take(totalDigits).toString(); + + _hiddenController.value = TextEditingValue( + text: trimmed, + selection: TextSelection.collapsed(offset: trimmed.length), + ); + _hiddenFocusNode.requestFocus(); + } catch (_) {} + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + /// Index of the cell that shows the focused border and blinking cursor. + /// + int get _activeIndex { + final len = _hiddenController.text.length; + return len.clamp(0, widget.length.digits - 1); + } + + /// Returns the hint placeholder for [index], or `null` if the cell is + /// filled or currently active. + String? _hintText(int index) { + final hint = widget.digitInputDecoration.hintText; + if (hint == null) return null; + final char = index < _hiddenController.text.length + ? _hiddenController.text[index] + : ''; + if (char.isNotEmpty) return null; + if (_hasFocus && index == _activeIndex) return null; + return hint; + } + + /// Returns a localized accessibility label for a PIN input position. + /// + /// Converts the zero-based [index] into a human-readable ordinal position + /// (for example: "1st", "2nd", "3rd" in English or "1er", "2ème" in French) + /// according to the current locale. + /// + /// This label is used by screen readers to announce each PIN cell position + /// more clearly and avoid confusion between the field position and its value. + String getDigitPositionLabel(BuildContext context, int index) { + final l10n = OudsLocalizations.of(context)!; + final position = index + 1; + + String ordinal; + + switch (Localizations.localeOf(context).languageCode) { + case 'fr': + ordinal = position == 1 ? '${position}er' : '${position}ème'; + break; + + case 'en': + if (position % 10 == 1 && position != 11) { + ordinal = '${position}st'; + } else if (position % 10 == 2 && position != 12) { + ordinal = '${position}nd'; + } else if (position % 10 == 3 && position != 13) { + ordinal = '${position}rd'; + } else { + ordinal = '${position}th'; + } + break; + + default: + ordinal = '$position'; + } + + return l10n.core_pinCodeInput_digitCode_label_a11y(ordinal); } + // ─── Build ─────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { final pinCodeToken = OudsTheme.of( @@ -203,13 +411,12 @@ class _OudsPinCodeInputState extends State { 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! - : ''}" - " , ${l10n?.core_common_hint_a11y}"; + final hintSemanticText = widget.errorText != null && isError + ? widget.errorText! + : widget.helperText != null + ? widget.helperText! + : ''; + final currentText = _hiddenController.text; return Container( constraints: BoxConstraints( @@ -224,58 +431,98 @@ class _OudsPinCodeInputState extends State { ? MainAxisAlignment.start : MainAxisAlignment.center, children: [ - Semantics( - hint: hintSemanticText, - 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, - 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 : ''}, " - "${l10n?.core_pinCodeInput_trait_a11y}", - child: OudsDigitInput( - index: index, - isError: isError, - length: widget.length, - digitInputDecoration: OudsDigitInputDecoration( - hintText: _hintText(index), - hiddenPassword: - widget.digitInputDecoration.hiddenPassword, - isOutlined: widget.digitInputDecoration.isOutlined, - keyboardType: widget.digitInputDecoration.keyboardType, + // ── Hidden TextField ───────────────────────────────────────────── + // A single invisible input widget (opacity 0, height 1) that holds + // the entire PIN string. Keeping a single focused TextField alive + // for the whole input session prevents the soft keyboard from + // closing and reopening between cells. + ExcludeSemantics( + child: Opacity( + opacity: 0.0, + child: SizedBox( + height: 1, + child: TextField( + controller: _hiddenController, + focusNode: _hiddenFocusNode, + showCursor: false, + enableInteractiveSelection: false, + keyboardType: + widget.digitInputDecoration.keyboardType == + OudsPinCodeInputKeyboardType.numeric + ? TextInputType.number + : TextInputType.text, + inputFormatters: [ + LengthLimitingTextInputFormatter(widget.length.digits), + if (widget.digitInputDecoration.keyboardType == + OudsPinCodeInputKeyboardType.numeric) + FilteringTextInputFormatter.digitsOnly, + ], + textInputAction: TextInputAction.done, + decoration: const InputDecoration.collapsed(hintText: null), + ), + ), + ), + ), + + // ── Visual cell row ────────────────────────────────────────────── + // A GestureDetector wraps the entire row so a tap anywhere opens the + // keyboard. Long-press triggers clipboard paste. + GestureDetector( + onTap: () => _hiddenFocusNode.requestFocus(), + onLongPress: _pasteFromClipboard, + child: Semantics( + hint: hintSemanticText, + 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, + children: List.generate(digitsCount, (index) { + final char = index < currentText.length + ? currentText[index] + : ''; + // True when this cell should show the active border/cursor. + final isActive = _hasFocus && index == _activeIndex; + return Flexible( + fit: FlexFit.loose, + child: Semantics( + // Disable live regions in accessibility mode to prevent + // the screen reader from jumping to cells whose content + // changed after a keystroke. + liveRegion: true, + hint: l10n?.core_common_hint_a11y, + label: + "${getDigitPositionLabel(context, index)}, " + "${!widget.digitInputDecoration.hiddenPassword && widget.controllers != null ? widget.controllers![index].text : ''}, " + "${l10n?.core_pinCodeInput_trait_a11y}", + child: OudsDigitInput( + index: index, + isError: isError, + isFocused: isActive, + displayValue: char, + digitInputDecoration: OudsDigitInputDecoration( + hintText: _hintText(index), + hiddenPassword: + widget.digitInputDecoration.hiddenPassword, + isOutlined: widget.digitInputDecoration.isOutlined, + keyboardType: + widget.digitInputDecoration.keyboardType, + ), ), - focusNode: _focusNodes[index], - isHovered: _isHovered[index], - controller: widget.controllers?[index], - onChanged: (value, index) { - _handleDigitInput(value, index); - if (!_hasEdited) { - setState(() { - _hasEdited = - true; // The user has interacted with the PIN at least once - }); - } - }, - onBackspaceOnEmpty: () => _handleBackspaceOnEmpty(index), - onPasteRequested: _pasteFromClipboard, ), - ), - ); - }), + ); + }), + ), ), ), + + // ── Helper / error text ────────────────────────────────────────── if (widget.helperText != null || (widget.errorText != null && isError)) ...[ Container( @@ -315,269 +562,4 @@ class _OudsPinCodeInputState extends State { ), ); } - - // Handles keyboard-path input from a single cell. Any multi-grapheme value - // (soft-keyboard paste suggestion, Cmd+V, OTP autofill) is routed to the - // single distribution method `_distributeCode`. Single characters are - // written atomically and focus advances or retreats. Long-press paste is - // handled entirely outside this method via `_pasteFromClipboard` wired - // through each cell's `contextMenuBuilder`. - void _handleDigitInput(String value, int index) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - - final totalDigits = widget.length.digits; - final controllers = widget.controllers; - if (controllers == null) return; - if (controllers.length < totalDigits || - _focusNodes.length < totalDigits) { - return; - } - - final sanitized = widget.digitInputDecoration.keyboardType == - OudsPinCodeInputKeyboardType.numeric - ? value.replaceAll(RegExp(r'\D'), '') - : value; - final chars = sanitized.characters.toList(); - - // Multi-grapheme arrival → treat as paste, redistribute. - if (chars.length > 1) { - _distributeCode(value); - return; - } - - final effective = chars.isEmpty ? '' : chars.first; - if (controllers[index].text != effective) { - controllers[index].value = TextEditingValue( - text: effective, - selection: TextSelection.collapsed(offset: effective.length), - ); - } - - final code = _currentCode(); - _emitChanged(code); - - if (effective.isEmpty) { - _requestFocusOnPreviousField(index); - return; - } - - _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); - } - } - - // ───────────────────────── paste pipeline ───────────────────────── - // Three small methods, single responsibility each: - // - // _safeReadClipboard – reads the system clipboard with a hard 2 s - // timeout; returns null on null/error/timeout. - // _pasteFromClipboard – entrypoint wired to each cell's context-menu - // "Paste" action; sole long-press paste path. - // _distributeCode – the ONLY place that writes to the cells. Takes - // a raw string, sanitises, truncates to PIN - // length, fills every cell (clearing trailing - // ones), updates focus, and fires callbacks. - // - // The keyboard path (`_handleDigitInput`) also delegates to - // `_distributeCode` whenever it sees a multi-grapheme value, so all paste - // flows converge on one implementation. - - Future _safeReadClipboard() async { - try { - final data = await Clipboard.getData(Clipboard.kTextPlain).timeout( - const Duration(seconds: 2), - onTimeout: () => null, - ); - return data?.text; - } catch (_) { - return null; - } - } - - Future _pasteFromClipboard() async { - final text = await _safeReadClipboard(); - if (!mounted) return; - if (text == null || text.isEmpty) return; - _distributeCode(text); - } - - void _distributeCode(String raw) { - final totalDigits = widget.length.digits; - final controllers = widget.controllers; - if (controllers == null) return; - if (controllers.length < totalDigits || - _focusNodes.length < totalDigits) { - return; - } - - final sanitized = widget.digitInputDecoration.keyboardType == - OudsPinCodeInputKeyboardType.numeric - ? raw.replaceAll(RegExp(r'\D'), '') - : raw; - if (sanitized.isEmpty) return; - - final digits = sanitized.characters.take(totalDigits).toList(); - final filledCount = digits.length; - - // Write every cell — atomic `value =` (sets text + selection in one go) - // and explicit empty for cells beyond the pasted range, so a shorter - // paste cannot leave stale digits behind. - for (int i = 0; i < totalDigits; i++) { - final text = i < filledCount ? digits[i] : ''; - controllers[i].value = TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: text.length), - ); - } - - if (!_hasEdited) { - _hasEdited = true; - } - - final code = _currentCode(); - _emitChanged(code); - - if (code.characters.length == totalDigits) { - for (final node in _focusNodes) { - node.unfocus(); - } - widget.onEditingComplete?.call(code); - } else { - final nextIndex = filledCount.clamp(0, totalDigits - 1).toInt(); - _focusNodes[nextIndex].requestFocus(); - } - } - - // 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) { - final controllers = widget.controllers; - if (controllers == null) return; - final previousIndex = _validPreviousIndex(index); - if (previousIndex == null) return; - - final previousController = controllers[previousIndex]; - final wasNonEmpty = previousController.text.isNotEmpty; - - previousController.clear(); - _requestFocusOnPreviousField(index); - - if (wasNonEmpty) { - _emitChanged(); - } - } - - // 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. - // - // - If the focus state has not changed since the last check, the method returns immediately. - // - Otherwise, it updates the `_previousHasFocus` to the new state. - // - If all fields have lost focus (`hasAnyFocus == false`) and the user has interacted with the PIN (`_hasEdited`), - // it triggers the `onEditingComplete` callback with the current PIN code. - // - If any field still has focus (`hasAnyFocus == true`), it triggers the `onChanged` callback with the current PIN code. - // - // This ensures that the component reacts only to real focus changes, and that the PIN validation - // or change callbacks are called at the appropriate time. - void _onGlobalFocusChange() { - setState(() { - hasAnyFocus = _focusNodes.any((f) => f.hasFocus); - }); - - if (_previousHasFocus == hasAnyFocus) return; - - _previousHasFocus = hasAnyFocus; - final code = _currentCode(); - - if (!hasAnyFocus && - _hasEdited && - code.characters.length == widget.length.digits) { - widget.onEditingComplete?.call(code); - } else if (hasAnyFocus) { - widget.onChanged?.call(code); - } - } - - String? _hintText(int index) { - final hint = widget.digitInputDecoration.hintText; - if (hint == null) return null; - - final hasFocus = _focusNodes[index].hasFocus; - 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) { - return hint; - } - - // No hint if the field is focused (cursor visible) - if (hasFocus) return null; - - // Show hint if the field is empty - if (text != null && text.isEmpty) return hint; - - // Otherwise, no hint - return null; - } } diff --git a/ouds_core/lib/l10n/gen/ouds_localizations.dart b/ouds_core/lib/l10n/gen/ouds_localizations.dart index 1ddc429b3..442f2dea2 100644 --- a/ouds_core/lib/l10n/gen/ouds_localizations.dart +++ b/ouds_core/lib/l10n/gen/ouds_localizations.dart @@ -310,7 +310,7 @@ abstract class OudsLocalizations { /// No description provided for @core_pinCodeInput_digitCode_label_a11y. /// /// In en, this message translates to: - /// **'Digit code {current}'** + /// **'{current} digit'** String core_pinCodeInput_digitCode_label_a11y(Object current); /// No description provided for @core_pinCodeInput_pinCode_label_a11y. @@ -331,6 +331,12 @@ abstract class OudsLocalizations { /// **'Error: Invalid code'** String get core_pinCodeInput_error_a11y; + /// No description provided for @core_pinCodeInput_digitPosition_a11y. + /// + /// In en, this message translates to: + /// **'Digit {current} of {total}'** + String core_pinCodeInput_digitPosition_a11y(Object current, Object total); + /// No description provided for @core_topAppBar_backNavigationIcon_a11y. /// /// In en, this message translates to: diff --git a/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart b/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart index 493884627..0c0b73658 100644 --- a/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart +++ b/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart @@ -118,7 +118,7 @@ class OudsLocalizationsAr extends OudsLocalizations { @override String core_pinCodeInput_digitCode_label_a11y(Object current) { - return 'الرقم $current'; + return 'الخانة $current'; } @override @@ -132,6 +132,11 @@ class OudsLocalizationsAr extends OudsLocalizations { @override String get core_pinCodeInput_error_a11y => 'خطأ: الرمز غير صحيح'; + @override + String core_pinCodeInput_digitPosition_a11y(Object current, Object total) { + return 'الرقم $current من $total'; + } + @override String get core_topAppBar_backNavigationIcon_a11y => 'رجوع'; diff --git a/ouds_core/lib/l10n/gen/ouds_localizations_en.dart b/ouds_core/lib/l10n/gen/ouds_localizations_en.dart index 7c61cde0d..25bbc37b4 100644 --- a/ouds_core/lib/l10n/gen/ouds_localizations_en.dart +++ b/ouds_core/lib/l10n/gen/ouds_localizations_en.dart @@ -118,7 +118,7 @@ class OudsLocalizationsEn extends OudsLocalizations { @override String core_pinCodeInput_digitCode_label_a11y(Object current) { - return 'Digit code $current'; + return '$current digit'; } @override @@ -132,6 +132,11 @@ class OudsLocalizationsEn extends OudsLocalizations { @override String get core_pinCodeInput_error_a11y => 'Error: Invalid code'; + @override + String core_pinCodeInput_digitPosition_a11y(Object current, Object total) { + return 'Digit $current of $total'; + } + @override String get core_topAppBar_backNavigationIcon_a11y => 'Back'; diff --git a/ouds_core/lib/l10n/gen/ouds_localizations_fr.dart b/ouds_core/lib/l10n/gen/ouds_localizations_fr.dart index da678a3ef..96aef9350 100644 --- a/ouds_core/lib/l10n/gen/ouds_localizations_fr.dart +++ b/ouds_core/lib/l10n/gen/ouds_localizations_fr.dart @@ -120,7 +120,7 @@ class OudsLocalizationsFr extends OudsLocalizations { @override String core_pinCodeInput_digitCode_label_a11y(Object current) { - return 'Code à chiffres $current'; + return '$current chiffre'; } @override @@ -134,6 +134,11 @@ class OudsLocalizationsFr extends OudsLocalizations { @override String get core_pinCodeInput_error_a11y => 'Error: Invalid code'; + @override + String core_pinCodeInput_digitPosition_a11y(Object current, Object total) { + return 'Chiffre $current sur $total'; + } + @override String get core_topAppBar_backNavigationIcon_a11y => 'Retour'; diff --git a/ouds_core/lib/l10n/ouds_flutter_ar.arb b/ouds_core/lib/l10n/ouds_flutter_ar.arb index 0b977e0d6..06482bf18 100644 --- a/ouds_core/lib/l10n/ouds_flutter_ar.arb +++ b/ouds_core/lib/l10n/ouds_flutter_ar.arb @@ -60,10 +60,11 @@ "core_passwordInput_hidePassword_a11y": "إخفاء كلمة المرو", "@_OUDS_PIN_CODE_INPUT": {}, - "core_pinCodeInput_digitCode_label_a11y": "الرقم {current}", + "core_pinCodeInput_digitCode_label_a11y": "الخانة {current}", "core_pinCodeInput_pinCode_label_a11y": "أدخل رمزك المكوّن من {digitsCount} أرقام", "core_pinCodeInput_trait_a11y": "حقل النص", "core_pinCodeInput_error_a11y": "خطأ: الرمز غير صحيح", + "core_pinCodeInput_digitPosition_a11y": "الرقم {current} من {total}", "@_OUDS_TOP_APP_BAR": {}, "core_topAppBar_backNavigationIcon_a11y": "رجوع", diff --git a/ouds_core/lib/l10n/ouds_flutter_en.arb b/ouds_core/lib/l10n/ouds_flutter_en.arb index 0aa1a5a7b..ec0e469d2 100644 --- a/ouds_core/lib/l10n/ouds_flutter_en.arb +++ b/ouds_core/lib/l10n/ouds_flutter_en.arb @@ -59,10 +59,11 @@ "@_OUDS_PIN_CODE_INPUT": {}, - "core_pinCodeInput_digitCode_label_a11y": "Digit code {current}", + "core_pinCodeInput_digitCode_label_a11y": "{current} digit", "core_pinCodeInput_pinCode_label_a11y": "Enter your {digitsCount}-digit code", "core_pinCodeInput_trait_a11y": "EditBox", "core_pinCodeInput_error_a11y": "Error: Invalid code", + "core_pinCodeInput_digitPosition_a11y": "Digit {current} of {total}", "@_OUDS_TOP_APP_BAR": {}, "core_topAppBar_backNavigationIcon_a11y": "Back", diff --git a/ouds_core/lib/l10n/ouds_flutter_fr.arb b/ouds_core/lib/l10n/ouds_flutter_fr.arb index ab5302f12..f352ea576 100644 --- a/ouds_core/lib/l10n/ouds_flutter_fr.arb +++ b/ouds_core/lib/l10n/ouds_flutter_fr.arb @@ -58,11 +58,13 @@ "core_passwordInput_hidePassword_a11y": "Masquer le mot de passe", "@_OUDS_PIN_CODE_INPUT": {}, - "core_pinCodeInput_digitCode_label_a11y": "Code à chiffres {current}", + "core_pinCodeInput_digitCode_label_a11y": "{current} chiffre", "core_pinCodeInput_pinCode_label_a11y": "Entrez votre code à {digitsCount} chiffres", "core_pinCodeInput_trait_a11y": "Champ de saisie", + "core_pinCodeInput_digitPosition_a11y": "Chiffre {current} sur {total}", - "@_OUDS_TOP_APP_BAR": {}, + + "@_OUDS_TOP_APP_BAR": {}, "core_topAppBar_backNavigationIcon_a11y": "Retour", "core_topAppBar_menuNavigationIcon_a11y": "Menu", "core_topAppBar_closeNavigationIcon_a11y": "Fermer", diff --git a/ouds_theme_orange/lib/components/orange_alert_tokens.dart b/ouds_theme_orange/lib/components/orange_alert_tokens.dart index 6b067638b..04c916ad2 100644 --- a/ouds_theme_orange/lib/components/orange_alert_tokens.dart +++ b/ouds_theme_orange/lib/components/orange_alert_tokens.dart @@ -33,7 +33,8 @@ class OrangeAlertTokens extends OudsAlertTokens { @override double get sizeMinHeight => providersTokens.sizeTokens.minInteractiveArea; @override - double get sizeMinHeightBottomActionPlacement => DimensionRawTokens.dimension1250; + double get sizeMinHeightBottomActionPlacement => + DimensionRawTokens.dimension1250; @override double get sizeMinWidth => DimensionRawTokens.dimension2000; @override @@ -41,9 +42,11 @@ class OrangeAlertTokens extends OudsAlertTokens { @override double get spaceColumnGapAction => providersTokens.spaceTokens.columnGapSmall; @override - double get spacePaddingBlock => providersTokens.spaceTokens.paddingBlockMedium; + double get spacePaddingBlock => + providersTokens.spaceTokens.paddingBlockMedium; @override - double get spacePaddingInline => providersTokens.spaceTokens.paddingInlineLarge; + double get spacePaddingInline => + providersTokens.spaceTokens.paddingInlineLarge; @override double get spaceRowGap => providersTokens.spaceTokens.rowGapSmall; @override