diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index f20c23184..36f34fb6f 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/0.5.0...develop) ### Added +- [DemoApp][Library] Create component - `Pin Code Input` ([#307](https://github.com/Orange-OpenSource/ouds-flutter/issues/307)) - [DemoApp][Library] Create component - `Link` ([#46](https://github.com/Orange-OpenSource/ouds-flutter/issues/46)) - [library] Mobile SDK Data Privacy Disclaimer ([#410](https://github.com/Orange-OpenSource/ouds-flutter/issues/#410)) - [DemoApp][Library] Tokens: `link` and `linkMono` ([#390](https://github.com/Orange-OpenSource/ouds-flutter/issues/#390)) diff --git a/app/l10n.yaml b/app/l10n.yaml index 1eedf7707..0f1f1f420 100644 --- a/app/l10n.yaml +++ b/app/l10n.yaml @@ -3,6 +3,7 @@ template-arb-file: ouds_flutter_en.arb output-dir: lib/l10n/gen/ output-localization-file: ouds_flutter_app_localizations.dart synthetic-package: false +header: "/// @nodoc\nlibrary;" # Uncomment this line to find errors in l10n generation #untranslated-messages-file: l10n_errors.txt diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart index 21d7cf930..3ef0d86ad 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart @@ -1,3 +1,6 @@ +/// @nodoc +library; + import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -482,6 +485,30 @@ abstract class AppLocalizations { /// **'Read only'** String get app_components_common_readOnly_label; + /// No description provided for @app_components_common_outlined_label. + /// + /// In en, this message translates to: + /// **'Outlined'** + String get app_components_common_outlined_label; + + /// No description provided for @app_components_common_placeholder_label. + /// + /// In en, this message translates to: + /// **'Placeholder'** + String get app_components_common_placeholder_label; + + /// No description provided for @app_components_common_helperText_label. + /// + /// In en, this message translates to: + /// **'Helper text'** + String get app_components_common_helperText_label; + + /// No description provided for @app_components_common_length_label. + /// + /// In en, this message translates to: + /// **'Length'** + String get app_components_common_length_label; + /// No description provided for @app_components_button_label. /// /// In en, this message translates to: @@ -824,18 +851,6 @@ abstract class AppLocalizations { /// **'Suffix'** String get app_components_text_input_suffix_label; - /// No description provided for @app_components_text_input_placeholder_label. - /// - /// In en, this message translates to: - /// **'Placeholder'** - String get app_components_text_input_placeholder_label; - - /// No description provided for @app_components_text_input_helperText_label. - /// - /// In en, this message translates to: - /// **'Helper text'** - String get app_components_text_input_helperText_label; - /// No description provided for @app_components_text_input_error_label. /// /// In en, this message translates to: @@ -872,6 +887,54 @@ abstract class AppLocalizations { /// **'Next'** String get app_components_link_nextLayout_label; + /// No description provided for @app_components_pin_code_input_label. + /// + /// In en, this message translates to: + /// **'Pin code input'** + String get app_components_pin_code_input_label; + + /// No description provided for @app_components_pin_code_input_description_text. + /// + /// In en, this message translates to: + /// **'A PIN code input is a specialized form field used to capture short, fixed-length numeric codes, typically for authentication or confirmation purposes, such as a 4, 6 or 8-digit personal identification number (PIN).'** + String get app_components_pin_code_input_description_text; + + /// No description provided for @app_components_pin_code_input_helperText_description_text_4. + /// + /// In en, this message translates to: + /// **'Enter the 4-digit code sent to your phone.'** + String get app_components_pin_code_input_helperText_description_text_4; + + /// No description provided for @app_components_pin_code_input_helperText_description_text_6. + /// + /// In en, this message translates to: + /// **'Enter the 6-digit code sent to your phone.'** + String get app_components_pin_code_input_helperText_description_text_6; + + /// No description provided for @app_components_pin_code_input_helperText_description_text_8. + /// + /// In en, this message translates to: + /// **'Enter the 8-digit code sent to your phone.'** + String get app_components_pin_code_input_helperText_description_text_8; + + /// No description provided for @app_components_pin_code_input_error_label. + /// + /// In en, this message translates to: + /// **'Please enter the verification code.'** + String get app_components_pin_code_input_error_label; + + /// No description provided for @app_components_pin_code_input_verification_error_label. + /// + /// In en, this message translates to: + /// **'Verification failed. Check and enter the correct code.'** + String get app_components_pin_code_input_verification_error_label; + + /// No description provided for @app_components_pin_code_input_hidden_password_label. + /// + /// In en, this message translates to: + /// **'Hidden Password'** + String get app_components_pin_code_input_hidden_password_label; + /// No description provided for @app_about_name_label. /// /// In en, this message translates to: diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart index 8ddaf7d61..183714490 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart @@ -1,3 +1,6 @@ +/// @nodoc +library; + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'ouds_flutter_app_localizations.dart'; @@ -208,6 +211,18 @@ class AppLocalizationsAr extends AppLocalizations { @override String get app_components_common_readOnly_label => 'اقرأ فقط'; + @override + String get app_components_common_outlined_label => 'مُحَدَّد'; + + @override + String get app_components_common_placeholder_label => 'العنصر النائب'; + + @override + String get app_components_common_helperText_label => 'نص مساعد'; + + @override + String get app_components_common_length_label => 'الطول'; + @override String get app_components_button_label => 'زر'; @@ -396,12 +411,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get app_components_text_input_suffix_label => 'لاحقة'; - @override - String get app_components_text_input_placeholder_label => 'العنصر النائب'; - - @override - String get app_components_text_input_helperText_label => 'نص مساعد'; - @override String get app_components_text_input_error_label => 'لا يمكن أن يكون هذا الحقل فارغًا.'; @@ -423,6 +432,37 @@ class AppLocalizationsAr extends AppLocalizations { @override String get app_components_link_nextLayout_label => 'التالي'; + @override + String get app_components_pin_code_input_label => 'إدخال الرقم السري الشخصي'; + + @override + String get app_components_pin_code_input_description_text => + 'حقل إدخال الرقم السري الشخصي هو حقل مخصص لإدخال رموز رقمية قصيرة وثابتة الطول، يُستخدم عادةً للمصادقة أو لتأكيد العمليات، مثل الرقم السري الشخصي المكوَّن من 4 أو 6 أو 8 أرقام.'; + + @override + String get app_components_pin_code_input_helperText_description_text_4 => + 'أدخل الرقم السري المكوَّن من 4 أرقام المُرسَل إلى هاتفك.'; + + @override + String get app_components_pin_code_input_helperText_description_text_6 => + 'أدخل الرقم السري المكوَّن من 6 أرقام المُرسَل إلى هاتفك.'; + + @override + String get app_components_pin_code_input_helperText_description_text_8 => + 'أدخل الرقم السري المكوَّن من 8 أرقام المُرسَل إلى هاتفك.'; + + @override + String get app_components_pin_code_input_error_label => + 'يرجى إدخال رمز التحقق.'; + + @override + String get app_components_pin_code_input_verification_error_label => + 'فشلت عملية التحقق. يُرجى التحقق وإدخال الرمز الصحيح.'; + + @override + String get app_components_pin_code_input_hidden_password_label => + 'إخفاء كلمة المرور'; + @override String get app_about_name_label => 'أداة نظام التصميم'; diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart index fd15fe8f8..4585e894b 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart @@ -1,3 +1,6 @@ +/// @nodoc +library; + // ignore: unused_import import 'package:intl/intl.dart' as intl; import 'ouds_flutter_app_localizations.dart'; @@ -207,6 +210,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get app_components_common_readOnly_label => 'Read only'; + @override + String get app_components_common_outlined_label => 'Outlined'; + + @override + String get app_components_common_placeholder_label => 'Placeholder'; + + @override + String get app_components_common_helperText_label => 'Helper text'; + + @override + String get app_components_common_length_label => 'Length'; + @override String get app_components_button_label => 'Button'; @@ -396,12 +411,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get app_components_text_input_suffix_label => 'Suffix'; - @override - String get app_components_text_input_placeholder_label => 'Placeholder'; - - @override - String get app_components_text_input_helperText_label => 'Helper text'; - @override String get app_components_text_input_error_label => 'This field can’t be empty.'; @@ -423,6 +432,37 @@ class AppLocalizationsEn extends AppLocalizations { @override String get app_components_link_nextLayout_label => 'Next'; + @override + String get app_components_pin_code_input_label => 'Pin code input'; + + @override + String get app_components_pin_code_input_description_text => + 'A PIN code input is a specialized form field used to capture short, fixed-length numeric codes, typically for authentication or confirmation purposes, such as a 4, 6 or 8-digit personal identification number (PIN).'; + + @override + String get app_components_pin_code_input_helperText_description_text_4 => + 'Enter the 4-digit code sent to your phone.'; + + @override + String get app_components_pin_code_input_helperText_description_text_6 => + 'Enter the 6-digit code sent to your phone.'; + + @override + String get app_components_pin_code_input_helperText_description_text_8 => + 'Enter the 8-digit code sent to your phone.'; + + @override + String get app_components_pin_code_input_error_label => + 'Please enter the verification code.'; + + @override + String get app_components_pin_code_input_verification_error_label => + 'Verification failed. Check and enter the correct code.'; + + @override + String get app_components_pin_code_input_hidden_password_label => + 'Hidden Password'; + @override String get app_about_name_label => 'Design System Toolbox'; diff --git a/app/lib/l10n/ouds_flutter_ar.arb b/app/lib/l10n/ouds_flutter_ar.arb index da2e72261..23b023090 100644 --- a/app/lib/l10n/ouds_flutter_ar.arb +++ b/app/lib/l10n/ouds_flutter_ar.arb @@ -74,6 +74,10 @@ "app_components_common_icon_a11y": "أيقونة", "app_components_common_style_label": "نمط", "app_components_common_readOnly_label": "اقرأ فقط", + "app_components_common_outlined_label": "مُحَدَّد", + "app_components_common_placeholder_label": "العنصر النائب", + "app_components_common_helperText_label": "نص مساعد", + "app_components_common_length_label": "الطول", "@_components_button": {}, "app_components_button_label": "زر", @@ -152,8 +156,6 @@ "app_components_text_input_trailingIcon_label": "عمل زائدة", "app_components_text_input_prefix_label": "بادئة", "app_components_text_input_suffix_label": "لاحقة", - "app_components_text_input_placeholder_label": "العنصر النائب", - "app_components_text_input_helperText_label": "نص مساعد", "app_components_text_input_error_label": "لا يمكن أن يكون هذا الحقل فارغًا.", "app_components_textInput_trailingIcon_a11y": "وصف محتوى أيقونة النهاية", @@ -163,6 +165,16 @@ "app_components_link_backLayout_label": "عوده", "app_components_link_nextLayout_label": "التالي", + "@_components_pin_code_input": {}, + "app_components_pin_code_input_label": "إدخال الرقم السري الشخصي", + "app_components_pin_code_input_description_text": "حقل إدخال الرقم السري الشخصي هو حقل مخصص لإدخال رموز رقمية قصيرة وثابتة الطول، يُستخدم عادةً للمصادقة أو لتأكيد العمليات، مثل الرقم السري الشخصي المكوَّن من 4 أو 6 أو 8 أرقام.", + "app_components_pin_code_input_helperText_description_text_4": "أدخل الرقم السري المكوَّن من 4 أرقام المُرسَل إلى هاتفك.", + "app_components_pin_code_input_helperText_description_text_6": "أدخل الرقم السري المكوَّن من 6 أرقام المُرسَل إلى هاتفك.", + "app_components_pin_code_input_helperText_description_text_8": "أدخل الرقم السري المكوَّن من 8 أرقام المُرسَل إلى هاتفك.", + "app_components_pin_code_input_error_label": "يرجى إدخال رمز التحقق.", + "app_components_pin_code_input_verification_error_label": "فشلت عملية التحقق. يُرجى التحقق وإدخال الرمز الصحيح.", + "app_components_pin_code_input_hidden_password_label": "إخفاء كلمة المرور", + "@_about_screen": {}, "app_about_name_label": "أداة نظام التصميم", "app_about_privacyPolicy_label": "سياسة الخصوصية", diff --git a/app/lib/l10n/ouds_flutter_en.arb b/app/lib/l10n/ouds_flutter_en.arb index 3a1d0c413..5581f5500 100644 --- a/app/lib/l10n/ouds_flutter_en.arb +++ b/app/lib/l10n/ouds_flutter_en.arb @@ -110,6 +110,10 @@ "app_components_common_icon_a11y": "Icon", "app_components_common_style_label": "Style", "app_components_common_readOnly_label": "Read only", + "app_components_common_outlined_label": "Outlined", + "app_components_common_placeholder_label": "Placeholder", + "app_components_common_helperText_label": "Helper text", + "app_components_common_length_label": "Length", "@_components_button": {}, "app_components_button_label": "Button", @@ -188,8 +192,6 @@ "app_components_text_input_trailingIcon_label": "Trailing action", "app_components_text_input_prefix_label": "Prefix", "app_components_text_input_suffix_label": "Suffix", - "app_components_text_input_placeholder_label": "Placeholder", - "app_components_text_input_helperText_label": "Helper text", "app_components_text_input_error_label": "This field can’t be empty.", "app_components_textInput_trailingIcon_a11y": "Trailing icon content description", @@ -199,6 +201,16 @@ "app_components_link_backLayout_label": "Back", "app_components_link_nextLayout_label": "Next", + "@_components_pin_code_input": {}, + "app_components_pin_code_input_label": "Pin code input", + "app_components_pin_code_input_description_text": "A PIN code input is a specialized form field used to capture short, fixed-length numeric codes, typically for authentication or confirmation purposes, such as a 4, 6 or 8-digit personal identification number (PIN).", + "app_components_pin_code_input_helperText_description_text_4": "Enter the 4-digit code sent to your phone.", + "app_components_pin_code_input_helperText_description_text_6": "Enter the 6-digit code sent to your phone.", + "app_components_pin_code_input_helperText_description_text_8": "Enter the 8-digit code sent to your phone.", + "app_components_pin_code_input_error_label": "Please enter the verification code.", + "app_components_pin_code_input_verification_error_label": "Verification failed. Check and enter the correct code.", + "app_components_pin_code_input_hidden_password_label": "Hidden Password", + "@_about_screen": {}, "app_about_name_label": "Design System Toolbox", "app_about_privacyPolicy_label": "Privacy policy", diff --git a/app/lib/ui/components/components.dart b/app/lib/ui/components/components.dart index 4c9da3a58..6a4e634e0 100644 --- a/app/lib/ui/components/components.dart +++ b/app/lib/ui/components/components.dart @@ -19,6 +19,8 @@ import 'package:ouds_core/components/checkbox/ouds_checkbox.dart'; import 'package:ouds_core/components/chip/ouds_filter_chip.dart'; import 'package:ouds_core/components/divider/ouds_divider.dart'; import 'package:ouds_core/components/link/ouds_link.dart'; +import 'package:ouds_core/components/pin_code_input/digit_input/ouds_digit_input.dart'; +import 'package:ouds_core/components/pin_code_input/ouds_pin_code_input.dart'; import 'package:ouds_core/components/radio_button/ouds_radio_button.dart'; import 'package:ouds_core/components/switch/ouds_switch.dart'; import 'package:ouds_core/components/tag/ouds_tag.dart'; @@ -39,6 +41,7 @@ import 'package:ouds_flutter_demo/ui/components/switch/switch_demo_screen.dart'; import 'package:ouds_flutter_demo/ui/components/switch/switch_item_demo_screen.dart'; import 'package:ouds_flutter_demo/ui/components/tag/tag_demo_screen.dart'; import 'package:ouds_flutter_demo/ui/components/tag/tag_input_demo_screen.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_demo_screen.dart'; import 'package:ouds_flutter_demo/ui/components/text_input/text_input_demo_screen.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; import 'package:ouds_flutter_demo/ui/components/link/link_demo_screen.dart'; @@ -172,6 +175,42 @@ List components(BuildContext context) { context.l10n.app_components_link_description_text, LinkDemoScreen(), ), + Component( + context.l10n.app_components_pin_code_input_label, + ComponentContainer( + child: Padding( + padding: const EdgeInsetsGeometry.directional(start: 10.0, end: 10.0), + child: OudsPinCodeInput( + controllers: [ + TextEditingController( + text: "1" + ), + TextEditingController( + text: "1" + ), + TextEditingController( + text: "1" + ), + TextEditingController( + text: "", + ), + TextEditingController( + text: "" + ), + TextEditingController( + text: "" + ), + ], + digitInputDecoration: OudsDigitInputDecoration( + hintText: '-', + ), + helperText: context.l10n.app_components_pin_code_input_helperText_description_text_6, + ), + ), + ), + context.l10n.app_components_pin_code_input_description_text, + PinCodeInputDemoScreen(), + ), Component.withVariant( context.l10n.app_components_radioButton_label, ComponentContainer( @@ -241,25 +280,20 @@ List components(BuildContext context) { VariantComponent(context.l10n.app_components_tagInput_label, TagInputDemoScreen()), ], ), - Component.withVariant( + Component( context.l10n.app_components_text_input_label, ComponentContainer( child: Padding( padding: const EdgeInsetsGeometry.directional(start: 20.0, end: 20.0), child: Center( child: OudsTextInput( - decoration: OudsInputDecoration(labelText: "Label", helperText: "Helper text.", style: OudsTextInputStyle.defaultStyle), + decoration: OudsInputDecoration(labelText: "Label", helperText: "Helper text."), ), ), ), ), context.l10n.app_components_text_input_description_text, - [ - VariantComponent( - context.l10n.app_components_text_input_label, - TextInputDemoScreen(), - ), - ], + TextInputDemoScreen(), ), ]; } diff --git a/app/lib/ui/components/pin_code_input/pin_code_input_code_generator.dart b/app/lib/ui/components/pin_code_input/pin_code_input_code_generator.dart new file mode 100644 index 000000000..d7393b573 --- /dev/null +++ b/app/lib/ui/components/pin_code_input/pin_code_input_code_generator.dart @@ -0,0 +1,80 @@ +/* + * // Software Name: OUDS Flutter + * // SPDX-FileCopyrightText: Copyright (c) Orange SA + * // SPDX-License-Identifier: MIT + * // + * // This software is distributed under the MIT license, + * // the text of which is available at https://opensource.org/license/MIT/ + * // or see the "LICENSE" file for more details. + * // + * // Software description: Flutter library of reusable graphical components + * // + */ +import 'package:flutter/material.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_customization.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_customization_utils.dart'; + + +class PinCodeInputCodeGenerator { + static String updateCode(BuildContext context) { + final PinCodeInputCustomizationState? state = PinCodeInputCustomization.of(context); + + if (state == null) return "OudsPinCodeInput(),"; + + List lines = []; + + lines.add(" controllers: controllers,"); + + if (state.hasHelperText && !state.hasError) { + lines.add( + ' helperText: "${PinCodeInputCustomizationUtils.getPinCodeHelperText(state)}",'); + } + + if (state.hasError) { + lines.add(' errorText: "${PinCodeInputCustomizationUtils.getPinCodeErrorText(state)}",'); + } + + final length = PinCodeInputCustomizationUtils.getLength(state.selectedPinCodeLength); + lines.add(' length: $length,'); + + lines.add(' onEditingComplete: (value) {\n //handle completed pin code\n},'); + + lines.add(' onChange: (value) {\n //handle change digit input\n},'); + + final String decoration = _digitDecorationCode(state); + + return [ + "OudsPinCodeInput(", + ...lines, + decoration, + ")," + ].join("\n"); + } + + static String _digitDecorationCode(PinCodeInputCustomizationState state) { + List props = []; + + if (state.pinCodePlaceholderText.isNotEmpty) { + final hint = PinCodeInputCustomizationUtils.getPinCodePlaceholderText(state); + props.add(' hintText: "$hint",'); + } + + if (state.hasRoundedCorner) { + props.add(' roundedCorner: ${state.hasRoundedCorner},'); + } + + if (state.hasHiddenPassword) { + props.add(' hiddenPassword: ${state.hasHiddenPassword},'); + } + + if (state.hasOutlined) { + props.add(' isOutlined: ${state.hasOutlined},'); + } + + if (props.isEmpty) { + return "digitInputDecoration: OudsDigitInputDecoration(),"; + } + + return "digitInputDecoration: OudsDigitInputDecoration(\n${props.join("\n")}\n),"; + } +} diff --git a/app/lib/ui/components/pin_code_input/pin_code_input_customization.dart b/app/lib/ui/components/pin_code_input/pin_code_input_customization.dart new file mode 100644 index 000000000..3481d1baf --- /dev/null +++ b/app/lib/ui/components/pin_code_input/pin_code_input_customization.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_enum.dart'; +import 'package:ouds_flutter_demo/ui/utilities/customizable/customizable_widget_state.dart'; + +/// Section for InheritedWidget to pass data down the widget tree +class _PinCodeInputCustomization extends InheritedWidget { + const _PinCodeInputCustomization({ + required super.child, + required this.data, + }); + + final PinCodeInputCustomizationState data; + + @override + bool updateShouldNotify(_PinCodeInputCustomization oldWidget) => true; +} + +/// Main Widget class for PinCodeInput customization +class PinCodeInputCustomization extends StatefulWidget { + const PinCodeInputCustomization({ + super.key, + required this.child, + }); + + final Widget child; + + @override + PinCodeInputCustomizationState createState() => PinCodeInputCustomizationState(); + + static PinCodeInputCustomizationState? of(BuildContext context) { + return (context.dependOnInheritedWidgetOfExactType<_PinCodeInputCustomization>())?.data; + } +} + +/// TextInput customization state management +class PinCodeInputCustomizationState extends CustomizationWidgetState { + late final ErrorState errorState; + late final HiddenPasswordState hiddenPasswordState; + late final PinCodeHelperTextState pinCodeHelperTextState; + late final PinCodeLengthState pinCodeLengthState; + late final PinCodeHasHelperTextState pinCodeHasHelperTextState; + late final PinCodeErrorTextState pinCodeErrorTextState; + late final PinCodePlaceholderTextState pinCodePlaceholderTextState; + late final RoundedCornerState roundedCornerState; + late final OutlinedState outlinedState; + + + @override + void initState() { + super.initState(); + errorState = ErrorState(setState); + hiddenPasswordState = HiddenPasswordState(setState); + pinCodeHasHelperTextState = PinCodeHasHelperTextState(setState); + pinCodeErrorTextState = PinCodeErrorTextState(setState); + pinCodePlaceholderTextState = PinCodePlaceholderTextState(setState); + roundedCornerState = RoundedCornerState(setState); + outlinedState = OutlinedState(setState); + + } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // There is a circular dependency between PinCodeHelperTextState and PinCodeLengthState. + // To resolve this, we first create PinCodeLengthState with a temporary null helper reference, + // then create PinCodeHelperTextState, and finally link the helper back to the length state. + // This ensures both instances are properly initialized without breaking the cycle. + pinCodeLengthState = PinCodeLengthState( + setState, + context, + null, + ); + pinCodeHelperTextState = PinCodeHelperTextState( + setState, + context, + pinCodeLengthState, + ); + pinCodeLengthState.pinCodeHelperTextState = pinCodeHelperTextState; + } + + // Proxy getters and setters to expose state values directly + bool get hasError => errorState.value; + set hasError(bool value) => errorState.value = value; + + // Proxy getters and setters to expose state values directly + bool get hasHiddenPassword => hiddenPasswordState.value; + set hasHiddenPassword(bool value) => hiddenPasswordState.value = value; + + PinCodeLengthEnum get selectedPinCodeLength => pinCodeLengthState.selected; + set selectedPinCodeLength(PinCodeLengthEnum value) => pinCodeLengthState.selected = value; + + // Proxy getters and setters to expose state values directly + bool get hasRoundedCorner => roundedCornerState.value; + set hasRoundedCorner(bool value) => roundedCornerState.value = value; + + // Proxy getters and setters to expose the 'helperText' for pin code input value directly. + String get pinCodeHelperText => pinCodeHelperTextState.value; + set pinCodeHelperText(String value) => pinCodeHelperTextState.value = value; + + // Proxy getters and setters to expose state values directly + bool get hasHelperText => pinCodeHasHelperTextState.value; + set hasHelperText(bool value) => pinCodeHasHelperTextState.value = value; + + // Proxy getters and setters to expose the 'error text' for pin code input value directly. + String get pinCodeErrorText => pinCodeErrorTextState.value; + set pinCodeErrorText(String value) => pinCodeErrorTextState.value = value; + + // Proxy getters and setters to expose the 'pinCodePlaceholderText' value directly. + String get pinCodePlaceholderText => pinCodePlaceholderTextState.value; + set pinCodePlaceholderText(String value) => pinCodePlaceholderTextState.value = value; + + // Proxy getters and setters to expose state values directly + bool get hasOutlined => outlinedState.value; + set hasOutlined(bool value) => outlinedState.value = value; + + @override + Widget build(BuildContext context) { + return _PinCodeInputCustomization( + data: this, + child: widget.child, + ); + } +} + +/// Error State Management +class ErrorState { + ErrorState(this._setState); + + final void Function(void Function()) _setState; + bool _hasError = false; + + bool get value => _hasError; + set value(bool newValue) { + _setState(() { + _hasError = newValue; + }); + } +} + +/// hidden password State Management +class HiddenPasswordState { + HiddenPasswordState(this._setState); + + final void Function(void Function()) _setState; + bool _hiddenPassword = true; + + bool get value => _hiddenPassword; + set value(bool newValue) { + _setState(() { + _hiddenPassword = newValue; + }); + } +} + + +/// Length of Pin Code Input State Management +class PinCodeLengthState { + PinCodeLengthState(this._setState, this._context, this.pinCodeHelperTextState); + + final void Function(void Function()) _setState; + final BuildContext _context; + PinCodeHelperTextState? pinCodeHelperTextState; + + final List _length = [ + PinCodeLengthEnum.four, + PinCodeLengthEnum.six, + PinCodeLengthEnum.eight + ]; + + List get list => _length; + + PinCodeLengthEnum _selected = PinCodeLengthEnum.six; + PinCodeLengthEnum get selected => _selected; + set selected(PinCodeLengthEnum newValue) { + _setState(() { + _selected = newValue; + pinCodeHelperTextState?.value = PinCodeLengthEnum.getHelperText(_context, _selected); + }); + } +} + + +/// HelperText State Management +class PinCodeHelperTextState { + final void Function(void Function()) _setState; + final BuildContext _context; + final PinCodeLengthState pinCodeLengthState; + + late final String _helperText; + late String _helperTextValue; + + PinCodeHelperTextState(this._setState, this._context,this.pinCodeLengthState){ + _helperText = PinCodeLengthEnum.getHelperText(_context, pinCodeLengthState.selected); + _helperTextValue = _helperText; + } + + String get value => _helperTextValue; + set value(String newValue) { + _setState(() { + _helperTextValue = newValue; + }); + } +} + +class PinCodeHasHelperTextState { + PinCodeHasHelperTextState(this._setState); + final void Function(void Function()) _setState; + + bool _hasHelperText = false; + + bool get value => _hasHelperText; + + set value(bool newValue) { + _setState(() { + _hasHelperText = newValue; + }); + } +} + +/// Pincode ErrorText State Management +class PinCodeErrorTextState { + PinCodeErrorTextState(this._setState); + final void Function(void Function()) _setState; + + String _errorTextValue = ""; + + String get value => _errorTextValue; + set value(String newValue) { + _setState(() { + _errorTextValue = newValue; + }); + } +} + +/// PlaceHolderText State Management +class PinCodePlaceholderTextState { + PinCodePlaceholderTextState(this._setState); + + final void Function(void Function()) _setState; + + String _placeholderTextValue = "-"; + String get value => _placeholderTextValue; + + set value(String newValue) { + _setState(() { + _placeholderTextValue = newValue; + }); + } +} + +/// RoundedCorner State Management +class RoundedCornerState { + RoundedCornerState(this._setState); + + final void Function(void Function()) _setState; + bool _hasRoundedCorner = false; + + bool get value => _hasRoundedCorner; + set value(bool newValue) { + _setState(() { + _hasRoundedCorner = newValue; + }); + } +} + +/// Outlined State Management +class OutlinedState { + OutlinedState(this._setState); + + final void Function(void Function()) _setState; + bool _hasOutlined = false; + + bool get value => _hasOutlined; + set value(bool newValue) { + _setState(() { + _hasOutlined = newValue; + }); + } +} \ No newline at end of file diff --git a/app/lib/ui/components/pin_code_input/pin_code_input_customization_utils.dart b/app/lib/ui/components/pin_code_input/pin_code_input_customization_utils.dart new file mode 100644 index 000000000..42e3072ff --- /dev/null +++ b/app/lib/ui/components/pin_code_input/pin_code_input_customization_utils.dart @@ -0,0 +1,53 @@ +// +// Software Name: OUDS Flutter +// SPDX-FileCopyrightText: Copyright (c) Orange SA +// SPDX-License-Identifier: MIT +// +// This software is distributed under the MIT license, +// the text of which is available at https://opensource.org/license/MIT/ +// or see the "LICENSE" file for more details. +// +// Software description: Flutter library of reusable graphical components +// +import 'package:ouds_core/components/pin_code_input/ouds_pin_code_input.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_customization.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_enum.dart'; + +/// Utility class to map button customization options to corresponding PinCodeInput attributes. +/// +/// This class provides static methods to convert customization enums into the appropriate +/// [PinCodeInput] properties. + +class PinCodeInputCustomizationUtils { + + /// Maps the length enum to 'OudsPinCodeInputLength' + static OudsPinCodeInputLength getLength(Object length) { + switch (length) { + case PinCodeLengthEnum.four: + return OudsPinCodeInputLength.four; + case PinCodeLengthEnum.six: + return OudsPinCodeInputLength.six; + default: + return OudsPinCodeInputLength.eight; + } + } + + /// Retrieves the helper text to display based on the current length. + static String? getPinCodeHelperText(PinCodeInputCustomizationState customizationState) { + + final label = customizationState.pinCodeHelperText; + return label.isEmpty ? null : label; + } + + /// Retrieves the pin code placeholder text to display based on the current customization state. + static String? getPinCodePlaceholderText(PinCodeInputCustomizationState customizationState) { + final label = customizationState.pinCodePlaceholderText; + return label.isEmpty ? null : label; + } + + /// Retrieves the pin code error text to display based on the current customization state. + static String? getPinCodeErrorText(PinCodeInputCustomizationState customizationState) { + final label = customizationState.pinCodeErrorText; + return label.isEmpty ? null : label; + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..f8591b03f --- /dev/null +++ b/app/lib/ui/components/pin_code_input/pin_code_input_demo_screen.dart @@ -0,0 +1,359 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:ouds_core/components/pin_code_input/digit_input/ouds_digit_input.dart'; +import 'package:ouds_core/components/pin_code_input/ouds_pin_code_input.dart'; +import 'package:ouds_flutter_demo/l10n/app_localizations.dart'; +import 'package:ouds_flutter_demo/main_app_bar.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_code_generator.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_customization.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_customization_utils.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_enum.dart'; +import 'package:ouds_flutter_demo/ui/theme/theme_controller.dart'; +import 'package:ouds_flutter_demo/ui/utilities/customizable/customizable_chips.dart'; +import 'package:ouds_flutter_demo/ui/utilities/customizable/customizable_section.dart'; +import 'package:ouds_flutter_demo/ui/utilities/customizable/customizable_switch.dart'; +import 'package:ouds_flutter_demo/ui/utilities/customizable/customizable_textfield.dart'; +import 'package:ouds_flutter_demo/ui/utilities/detail_screen_header.dart'; +import 'package:ouds_flutter_demo/ui/utilities/dismiss_keyboard.dart'; +import 'package:ouds_flutter_demo/ui/utilities/reference_design_version_component.dart'; +import 'package:ouds_flutter_demo/ui/utilities/sheets_bottom/ouds_sheets_bottom.dart'; +import 'package:ouds_flutter_demo/ui/utilities/theme_colored_box.dart'; +import 'package:ouds_theme_contract/ouds_component_version.dart'; +import 'package:ouds_theme_contract/ouds_theme.dart'; +import 'package:provider/provider.dart'; +import 'package:ouds_flutter_demo/ui/utilities/code.dart'; + +class PinCodeInputDemoScreen extends StatefulWidget { + const PinCodeInputDemoScreen({super.key}); + + @override + State createState() => _PinCodeInputDemoScreenState(); +} + +class _PinCodeInputDemoScreenState extends State { + final _scaffoldKey = GlobalKey(); + bool _isBottomSheetExpanded = false; + + void _onExpansionChanged(bool isExpanded) { + setState(() { + _isBottomSheetExpanded = isExpanded; + }); + } + + @override + Widget build(BuildContext context) { + return DismissKeyboard( + child: PinCodeInputCustomization( + key: _scaffoldKey, + child: Padding( + padding:EdgeInsets.only(bottom: Platform.isAndroid + ? MediaQuery.of(context).viewPadding.bottom + : OudsTheme.of(context).spaceScheme(context).paddingBlockNone + ), + child: Scaffold( + appBar: MainAppBar(title: context.l10n.app_components_pin_code_input_label), + bottomSheet: OudsSheetsBottom( + onExpansionChanged: _onExpansionChanged, + sheetContent: const _CustomizationContent(), + title: context.l10n.app_common_customize_label, + ), + body: SafeArea( + child: ExcludeSemantics( + excluding: !_isBottomSheetExpanded, + child: const _Body(), + ), + ), + ), + ), + ), + ); + } +} + +class _Body extends StatefulWidget { + const _Body(); + + @override + State<_Body> createState() => _BodyState(); +} + +class _BodyState extends State<_Body> { + @override + Widget build(BuildContext context) { + final themeController = Provider.of(context, listen: false); + return DetailScreenDescription( + description: context.l10n.app_components_pin_code_input_description_text, + widget: Column( + children: [ + const _PinCodeInputDemo(), + SizedBox(height: themeController.currentTheme.spaceScheme(context).fixedMedium), + Code( + code: PinCodeInputCodeGenerator.updateCode(context), + ), + ReferenceDesignVersionComponent( + version: OudsComponentVersion.pinCodeInput + ), + ], + ), + ); + } +} + +class _PinCodeInputDemo extends StatefulWidget { + const _PinCodeInputDemo(); + + @override + State<_PinCodeInputDemo> createState() => _PinCodeInputDemoState(); +} + +class _PinCodeInputDemoState extends State<_PinCodeInputDemo> { + List controllers = []; + late int pinCodeLength; + + + @override + void dispose() { + for (var controller in controllers) { + controller.dispose(); + } + super.dispose(); + } + + @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 themeController = Provider.of(context, listen: true); + // Adding post-frame callback to update theme based on customization state + WidgetsBinding.instance.addPostFrameCallback((_) { + themeController.setOnBorderRadiusTextInputState(customizationState.hasRoundedCorner); + }); + + final getLength = PinCodeInputCustomizationUtils.getLength(customizationState.selectedPinCodeLength as Object); + + + return Column( + children: [ + ThemeBox( + themeContract: themeController.currentTheme, + themeMode: themeController.isInverseDarkTheme ? ThemeMode.light : ThemeMode.dark, + child: Padding( + padding: EdgeInsets.all(16.0), + child: OudsPinCodeInput( + controllers: controllers, + helperText: customizationState.hasHelperText && customizationState.pinCodeHelperText.isNotEmpty + ? PinCodeInputCustomizationUtils.getPinCodeHelperText(customizationState) : null, + length: getLength, + errorText: customizationState.hasError ? PinCodeInputCustomizationUtils.getPinCodeErrorText(customizationState) : null, + digitInputDecoration: OudsDigitInputDecoration( + hintText: PinCodeInputCustomizationUtils.getPinCodePlaceholderText(customizationState), + roundedCorner: customizationState.hasRoundedCorner, + hiddenPassword: customizationState.hasHiddenPassword, + isOutlined: customizationState.hasOutlined, + ), + onEditingComplete: (value) async { + final errorLabel = context.l10n.app_components_pin_code_input_error_label; + final verificationErrorLabel = context.l10n.app_components_pin_code_input_verification_error_label; + await _handleCompleted( + context, + value, + PinCodeInputCustomizationUtils.getLength(customizationState.selectedPinCodeLength as Object).digits, + customizationState, + errorLabel, + verificationErrorLabel + ); + }, + onChanged: (value) { + if(value.isEmpty || value.length < getLength.digits){ + customizationState.hasError = false; + return; + } + }, + ), + ), + ), + ThemeBox( + themeContract: themeController.currentTheme, + themeMode: themeController.isInverseDarkTheme ? ThemeMode.dark : ThemeMode.light, + child: Padding( + padding: EdgeInsets.all(themeController.currentTheme.spaceScheme(context).fixedMedium), + child: OudsPinCodeInput( + controllers: controllers, + helperText: customizationState.hasHelperText && customizationState.pinCodeHelperText.isNotEmpty + ? PinCodeInputCustomizationUtils.getPinCodeHelperText(customizationState) + : null, + length: PinCodeInputCustomizationUtils.getLength(customizationState.selectedPinCodeLength as Object), + errorText: customizationState.hasError ? PinCodeInputCustomizationUtils.getPinCodeErrorText(customizationState) : null, + digitInputDecoration: OudsDigitInputDecoration( + hintText:PinCodeInputCustomizationUtils.getPinCodePlaceholderText(customizationState), + roundedCorner: customizationState.hasRoundedCorner, + hiddenPassword: customizationState.hasHiddenPassword, + isOutlined: customizationState.hasOutlined, + ), + onEditingComplete: (value) async { + final errorLabel = context.l10n.app_components_pin_code_input_error_label; + final verificationErrorLabel = context.l10n.app_components_pin_code_input_verification_error_label; + await _handleCompleted( + context, + value, + PinCodeInputCustomizationUtils.getLength(customizationState.selectedPinCodeLength as Object).digits, + customizationState, + errorLabel, + verificationErrorLabel + ); + }, + onChanged: (value) { + if(value.isEmpty || value.length < getLength.digits){ + customizationState.hasError = false; + return; + } + }, + ) + ), + ), + SizedBox(height: themeController.currentTheme.spaceScheme(context).fixedSmall), + ], + ); + } + + Future _fakeVerify(String code) async { + await Future.delayed(Duration(milliseconds: 300)); + return code == "1234" || code == "123456" || code == "12345678"; // demo logic + } + + + Future _handleCompleted(BuildContext context, String value, int digitLength, + PinCodeInputCustomizationState customizationState, String errorLabel, String verificationErrorLabel) async + { + + final isValid = await _fakeVerify(value); + + String errorText = ""; + bool isError = false; + + if (value.isEmpty || value.length != digitLength) { + errorText = errorLabel; + isError = true; + } else if (!isValid) { + errorText = verificationErrorLabel; + isError = true; + } + + if (!mounted) return; // Check if widget is still mounted + + setState(() { + customizationState.hasError = isError; + customizationState.pinCodeErrorText = errorText; + }); + } + +} + +/// This widget represents the customization content section that appears in the bottom sheet +class _CustomizationContent extends StatefulWidget { + const _CustomizationContent(); + + @override + State<_CustomizationContent> createState() => _CustomizationContentState(); +} + +/// This state class handles the customization options for the text input +class _CustomizationContentState extends State<_CustomizationContent> { + late final FocusNode placeholderFocus; + late final FocusNode helperFocus; + + @override + void initState() { + super.initState(); + placeholderFocus = FocusNode(); + helperFocus = FocusNode(); + } + + @override + void dispose() { + placeholderFocus.dispose(); + helperFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final customizationState = PinCodeInputCustomization.of(context)!; + + return CustomizableSection( + children: [ + CustomizableSwitch( + title: context.l10n.app_components_common_error_label, + value: customizationState.hasError, + onChanged: customizationState.hasHelperText && !customizationState.hasError + ? null + : (value) { + customizationState.hasError = value; + value + ? customizationState.pinCodeErrorText = context.l10n.app_components_pin_code_input_error_label + : customizationState.pinCodeErrorText = "" ; + }, + ), + CustomizableSwitch( + title: context.l10n.app_components_common_helperText_label, + value: customizationState.hasHelperText, + onChanged: customizationState.hasError + ? null + : (value) {customizationState.hasHelperText = value;}, + ), + 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, + ) + ), + CustomizableSwitch( + title: context.l10n.app_components_pin_code_input_hidden_password_label, + value: customizationState.hasHiddenPassword, + onChanged: (value) { + customizationState.hasHiddenPassword = value; + }, + ), + CustomizableChips( + title: PinCodeLengthEnum.enumName(context), + options: customizationState.pinCodeLengthState.list, + selectedOption: customizationState.selectedPinCodeLength, + getText: (option) => option.stringValue(context), + onSelected: (selectedOption) { + setState(() { + customizationState.selectedPinCodeLength = selectedOption; + }); + }, + ), + CustomizableSwitch( + title: context.l10n.app_components_common_outlined_label, + value: customizationState.hasOutlined, + onChanged: (value) { + customizationState.hasOutlined = value; + }, + ), + CustomizableTextField( + title: context.l10n.app_components_common_placeholder_label, + text: customizationState.pinCodePlaceholderText, + focusNode: placeholderFocus, + fieldType: FieldType.placeholder, + ), + CustomizableSwitch( + title: context.l10n.app_components_common_roundedCorner_label, + value: customizationState.hasRoundedCorner, + onChanged: (value) { + customizationState.hasRoundedCorner = value; + }, + ), + ], + ); + } +} diff --git a/app/lib/ui/components/pin_code_input/pin_code_input_enum.dart b/app/lib/ui/components/pin_code_input/pin_code_input_enum.dart new file mode 100644 index 000000000..0cc2abb11 --- /dev/null +++ b/app/lib/ui/components/pin_code_input/pin_code_input_enum.dart @@ -0,0 +1,44 @@ + +import 'package:flutter/cupertino.dart'; +import 'package:ouds_flutter_demo/l10n/app_localizations.dart'; + + +/// Represents the length of an OUDS PinCodeInput. +enum PinCodeLengthEnum { + four, + six, + eight; + + const PinCodeLengthEnum(); + + static String enumName(BuildContext context) { + return context.l10n.app_components_common_length_label; + } + + static String getHelperText(BuildContext context, PinCodeLengthEnum value){ + + switch (value) { + case PinCodeLengthEnum.four: + return context.l10n.app_components_pin_code_input_helperText_description_text_4; + case PinCodeLengthEnum.six: + return context.l10n.app_components_pin_code_input_helperText_description_text_6; + case PinCodeLengthEnum.eight: + return context.l10n.app_components_pin_code_input_helperText_description_text_8; + } + + } +} + +extension CustomElementLayout on PinCodeLengthEnum { + String stringValue(BuildContext context) { + switch (this) { + case PinCodeLengthEnum.four: + return "4"; + case PinCodeLengthEnum.six: + return "6"; + case PinCodeLengthEnum.eight: + return "8"; + } + } +} + diff --git a/app/lib/ui/components/text_input/text_input_customization_utils.dart b/app/lib/ui/components/text_input/text_input_customization_utils.dart index 4f836ae93..d98790045 100644 --- a/app/lib/ui/components/text_input/text_input_customization_utils.dart +++ b/app/lib/ui/components/text_input/text_input_customization_utils.dart @@ -9,7 +9,6 @@ // // Software description: Flutter library of reusable graphical components // - import 'package:ouds_core/components/text_input/ouds_text_input.dart'; import 'package:ouds_flutter_demo/ui/components/text_input/text_input_customization.dart'; import 'package:ouds_flutter_demo/ui/components/text_input/text_input_enum.dart'; @@ -50,7 +49,7 @@ class TextInputCustomizationUtils { return label.isEmpty ? null : label; } - /// Maps the style enum to `OudsButtonStyle`. + /// Maps the style enum to `OudsTextInputStyle`. static OudsTextInputStyle getStyle(Object style) { switch (style) { case TextInputEnumStyle.defaultStyle: @@ -59,4 +58,4 @@ class TextInputCustomizationUtils { return OudsTextInputStyle.alternative; } } -} +} \ No newline at end of file diff --git a/app/lib/ui/components/text_input/text_input_demo_screen.dart b/app/lib/ui/components/text_input/text_input_demo_screen.dart index 31eb5be94..544bd8cf6 100644 --- a/app/lib/ui/components/text_input/text_input_demo_screen.dart +++ b/app/lib/ui/components/text_input/text_input_demo_screen.dart @@ -328,13 +328,13 @@ class _CustomizationContentState extends State<_CustomizationContent> { fieldType: FieldType.suffix, ), CustomizableTextField( - title: context.l10n.app_components_text_input_placeholder_label, + title: context.l10n.app_components_common_placeholder_label, text: customizationState.placeholderText, focusNode: placeholderFocus, fieldType: FieldType.placeholder, ), CustomizableTextField( - title: context.l10n.app_components_text_input_helperText_label, + title: context.l10n.app_components_common_helperText_label, text: customizationState.helperText, focusNode: helperFocus, fieldType: FieldType.helper, diff --git a/app/lib/ui/utilities/customizable/customizable_textfield.dart b/app/lib/ui/utilities/customizable/customizable_textfield.dart index 4746a76c9..84224afe0 100644 --- a/app/lib/ui/utilities/customizable/customizable_textfield.dart +++ b/app/lib/ui/utilities/customizable/customizable_textfield.dart @@ -21,6 +21,7 @@ import 'package:ouds_flutter_demo/ui/components/text_input/text_input_customizat import 'package:ouds_flutter_demo/ui/theme/theme_controller.dart'; import 'package:provider/provider.dart'; import 'package:ouds_flutter_demo/ui/components/link/link_customization.dart'; +import 'package:ouds_flutter_demo/ui/components/pin_code_input/pin_code_input_customization.dart'; enum FieldType { label, @@ -69,6 +70,7 @@ class CustomizableTextFieldState extends State { final tagState = TagCustomization.of(context); final textInputState = TextInputCustomization.of(context); final linkState = LinkCustomization.of(context); + final pinCodeInputState = PinCodeInputCustomization.of(context); _textController.addListener(() { switch (widget.fieldType) { @@ -88,6 +90,8 @@ class CustomizableTextFieldState extends State { controlItemState?.helperLabelText = _textController.text; buttonState?.textValue = _textController.text; textInputState?.helperText = _textController.text; + pinCodeInputState?.pinCodeHelperText = _textController.text; + pinCodeInputState?.pinCodeErrorText = _textController.text; }); break; case FieldType.additional: @@ -101,6 +105,7 @@ class CustomizableTextFieldState extends State { textInputState?.suffixText = _textController.text; case FieldType.placeholder: textInputState?.placeholderText = _textController.text; + pinCodeInputState?.pinCodePlaceholderText = _textController.text; } }); }); diff --git a/ouds_core/CHANGELOG.md b/ouds_core/CHANGELOG.md index b28fb599f..a48a9ebe6 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/0.5.0...develop) ### Added +- [Library] Create component - `Pin Code Input` ([#307](https://github.com/Orange-OpenSource/ouds-flutter/issues/307)) - [Library] Create component - `Link` ([#46](https://github.com/Orange-OpenSource/ouds-flutter/issues/46)) - [library] Mobile SDK Data Privacy Disclaimer ([#410](https://github.com/Orange-OpenSource/ouds-flutter/issues/#410)) - [Library] Tokens: `link` and `linkMono` ([#390](https://github.com/Orange-OpenSource/ouds-flutter/issues/#390)) 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 new file mode 100644 index 000000000..8e2ea76d7 --- /dev/null +++ b/ouds_core/lib/components/pin_code_input/digit_input/ouds_digit_input.dart @@ -0,0 +1,246 @@ +/* + * // Software Name: OUDS Flutter + * // SPDX-FileCopyrightText: Copyright (c) Orange SA + * // SPDX-License-Identifier: MIT + * // + * // This software is distributed under the MIT license, + * // the text of which is available at https://opensource.org/license/MIT/ + * // or see the "LICENSE" file for more details. + * // + * // Software description: Flutter library of reusable graphical components + * // + */ +/// OudsDigitInput +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:ouds_core/components/pin_code_input/ouds_pin_code_input.dart'; +import 'package:ouds_theme_contract/ouds_theme.dart'; +import 'package:ouds_core/components/text_input/internal/modifier/ouds_text_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'; +import 'package:ouds_core/components/pin_code_input/internal/modifier/ouds_pin_code_input_text_color_modifier.dart'; +import 'package:ouds_core/components/pin_code_input/internal/ouds_pin_code_input_control_state.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. +/// +/// Parameters: +/// +/// - [hintText]: A short placeholder or hint shown inside the input when empty. +/// +/// - [roundedCorner]: Defines the visual border shape of the Pin Code. +/// `false` for a square finish , `true` For a finish with rounded corner. +/// +/// - [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. +/// +class OudsDigitInputDecoration { + final String? hintText; //placeholder + final bool roundedCorner; + final bool hiddenPassword; + final bool isOutlined; + + const OudsDigitInputDecoration({ + this.hintText, + this.roundedCorner = false, + this.hiddenPassword = true, + this.isOutlined = false, + }); +} + +// TODO: Add documentation URL once it is available +/// +/// 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. +/// +/// +/// ## You can use [OudsDigitInput] like this : +/// +/// This is the default style of the component. +/// +/// +/// ```dart +/// 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) {}, +/// ) +/// ``` +/// +class OudsDigitInput extends StatefulWidget { + + final int index; + late 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; + + OudsDigitInput({ + super.key, + required this.index, + this.isError = false, + this.digitInputDecoration, + this.controller, + this.focusNode, + this.isHovered = false, + this.onChanged, + this.length = OudsPinCodeInputLength.six, + }); + + @override + State createState() => _OudsDigitInputState(); + +} + +class _OudsDigitInputState extends State { + bool _isHovered = false; + late final FocusNode _keyboardFocusNode; + + @override + void initState() { + super.initState(); + _keyboardFocusNode = FocusNode(skipTraversal: true); // focus technique uniquement pour clavier + } + + @override + void dispose() { + _keyboardFocusNode.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 = OudsTextInputBorderModifier(context); + final pinCodeInputTextModifier = OudsPinCodeInputTextColorModifier(context); + final theme = OudsTheme.of(context); + final isFocused = widget.focusNode?.hasFocus; + + final state = OudsPinCodeInputControlStateDeterminer( + isFocused: isFocused!, + isHovered: _isHovered, + ).determineControlState(); + + return InkWell( + onHover: (hovering) { + if (!mounted) return; + setState(() { + _isHovered = hovering; + }); + }, + child: Container( + constraints: BoxConstraints( + minHeight: textInputToken.sizeMinHeight, + maxWidth: pinCodeToken.sizeMaxWidth, + minWidth: pinCodeToken.sizeMinWidth + ), + child: Container( + constraints: BoxConstraints( + minHeight: textInputToken.sizeMinHeight + ), + padding: EdgeInsets.only( + top : textInputToken.spacePaddingBlockDefault, + bottom: textInputToken.spacePaddingBlockDefault, + right: widget.length == OudsPinCodeInputLength.eight ? 0 : textInputToken.spacePaddingInlineDefault, + left: widget.length == OudsPinCodeInputLength.eight ? 0 : textInputToken.spacePaddingInlineDefault, + + ), + decoration: BoxDecoration( + color: pinCodeInputBackgroundModifier.getPinCodeBackgroundColor(state, widget.isError, widget.digitInputDecoration!.isOutlined), + border: pinCodeInputBorderModifier.getPinCodeBorder(state,widget.isError, widget.digitInputDecoration!.isOutlined), + borderRadius: textInputBorderModifier.getBorderRadius(context, widget.digitInputDecoration?.roundedCorner), + ), + child: KeyboardListener( + focusNode: _keyboardFocusNode, + onKeyEvent: (KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.backspace) { + final text = widget.controller?.text ?? ''; + // If the field is empty and the user presses backspace : move to the previous one + if (text.isEmpty) { + final previousIndex = widget.index - 1; + if (previousIndex >= 0) { + widget.controller?.clear(); + FocusScope.of(context).previousFocus(); + } + } + } + }, + child: TextField( + cursorHeight: theme.fontTokens.lineHeightLabelLarge, + obscureText: widget.digitInputDecoration!.hiddenPassword, + obscuringCharacter: "●", + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + color: theme.colorScheme(context).contentDefault, + ), + cursorColor: pinCodeInputTextModifier.getPinCodeCursorColor(widget.isError), + controller: widget.controller, + focusNode: widget.focusNode, + keyboardType: TextInputType.number, + 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( + color: theme.colorScheme(context).contentMuted, + ), // remove internal padding + ), + 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), + ); + }, + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/ouds_core/lib/components/pin_code_input/internal/modifier/ouds_pin_code_input_background_modifier.dart b/ouds_core/lib/components/pin_code_input/internal/modifier/ouds_pin_code_input_background_modifier.dart new file mode 100644 index 000000000..bf5e65109 --- /dev/null +++ b/ouds_core/lib/components/pin_code_input/internal/modifier/ouds_pin_code_input_background_modifier.dart @@ -0,0 +1,50 @@ +/* + * // Software Name: OUDS Flutter + * // SPDX-FileCopyrightText: Copyright (c) Orange SA + * // SPDX-License-Identifier: MIT + * // + * // This software is distributed under the MIT license, + * // the text of which is available at https://opensource.org/license/MIT/ + * // or see the "LICENSE" file for more details. + * // + * // Software description: Flutter library of reusable graphical components + * // + */ +/// @nodoc +library; + +import 'package:flutter/material.dart'; +import 'package:ouds_theme_contract/ouds_theme.dart'; +import 'package:ouds_core/components/pin_code_input/internal/ouds_pin_code_input_control_state.dart'; + +/// Used to apply the right background color associated to the state +class OudsPinCodeInputBackgroundColorModifier { + final BuildContext context; + + OudsPinCodeInputBackgroundColorModifier(this.context); + + Color? getPinCodeBackgroundColor(OudsPinCodeInputControlState state, [bool isError = false, bool? isOutlined]) { + final theme = OudsTheme.of(context); + final error = isError == true; + + if (error) { + // Error + switch (state) { + case OudsPinCodeInputControlState.enabled: + case OudsPinCodeInputControlState.hovered: + case OudsPinCodeInputControlState.focused: + return !isOutlined! ? theme.colorScheme(context).surfaceStatusNegativeMuted : null; + } + } else { + switch (state) { + case OudsPinCodeInputControlState.enabled: + return !isOutlined! ? theme.colorScheme(context).actionSupportEnabled : null; + case OudsPinCodeInputControlState.hovered: + return theme.colorScheme(context).actionSupportHover; + case OudsPinCodeInputControlState.focused: + return !isOutlined! ? theme.colorScheme(context).actionSupportPressed : null; + + } + } + } +} diff --git a/ouds_core/lib/components/pin_code_input/internal/modifier/ouds_pin_code_input_border_modifier.dart b/ouds_core/lib/components/pin_code_input/internal/modifier/ouds_pin_code_input_border_modifier.dart new file mode 100644 index 000000000..05d566cab --- /dev/null +++ b/ouds_core/lib/components/pin_code_input/internal/modifier/ouds_pin_code_input_border_modifier.dart @@ -0,0 +1,73 @@ +/* + * // Software Name: OUDS Flutter + * // SPDX-FileCopyrightText: Copyright (c) Orange SA + * // SPDX-License-Identifier: MIT + * // + * // This software is distributed under the MIT license, + * // the text of which is available at https://opensource.org/license/MIT/ + * // or see the "LICENSE" file for more details. + * // + * // Software description: Flutter library of reusable graphical components + * // + */ + +/// @nodoc +library; + +import 'package:flutter/material.dart'; +import 'package:ouds_theme_contract/ouds_theme.dart'; +import 'package:ouds_core/components/pin_code_input/internal/ouds_pin_code_input_control_state.dart'; + +/// A class that provides the border color for the OudsPinCodeInput based on its state and selection +class OudsPinCodeInputBorderModifier { + final BuildContext context; + + OudsPinCodeInputBorderModifier(this.context); + + /// Gets the borderSide based on the pin code input state and whether it is selected + Border getPinCodeBorder(OudsPinCodeInputControlState state, [bool isError = false, bool? isOutlined]) { + switch (state) { + case OudsPinCodeInputControlState.enabled: + return Border( + bottom: getPinCodeBorderSideByState(state,isError), + top: !isOutlined! ? BorderSide.none : getPinCodeBorderSideByState(state,isError), + left: !isOutlined ? BorderSide.none : getPinCodeBorderSideByState(state,isError), + right: !isOutlined ? BorderSide.none : getPinCodeBorderSideByState(state,isError), + ); + case OudsPinCodeInputControlState.hovered: + return Border( + bottom: getPinCodeBorderSideByState(state,isError), + top: !isOutlined! ? BorderSide.none : getPinCodeBorderSideByState(state,isError), + left: !isOutlined ? BorderSide.none : getPinCodeBorderSideByState(state,isError), + right: !isOutlined ? BorderSide.none : getPinCodeBorderSideByState(state,isError), + ); + case OudsPinCodeInputControlState.focused: + return Border( + bottom: getPinCodeBorderSideByState(state,isError), + top: !isOutlined! ? BorderSide.none : getPinCodeBorderSideByState(state,isError), + left: !isOutlined ? BorderSide.none : getPinCodeBorderSideByState(state,isError), + right: !isOutlined ? BorderSide.none : getPinCodeBorderSideByState(state,isError), + ); + } + } + + /// Returns a [BorderSide] based on the given [OudsPinCodeInputControlState]. + /// + /// Uses [OudsTheme] to pick the border color and width for each state for PinCodeInput + BorderSide getPinCodeBorderSideByState(OudsPinCodeInputControlState state, bool isError) { + final textInputToken = OudsTheme.of(context).componentsTokens(context).textInput; + final colorToken = OudsTheme.of(context).colorScheme(context); + + switch (state) { + case OudsPinCodeInputControlState.enabled: + return isError? BorderSide(color: colorToken.actionNegativeEnabled, width: textInputToken.borderWidthDefault) + : BorderSide(color: textInputToken.colorBorderEnabled, width: textInputToken.borderWidthDefault); + case OudsPinCodeInputControlState.hovered: + return isError? BorderSide(color: colorToken.actionNegativeEnabled, width: textInputToken.borderWidthDefault) + : BorderSide(color: textInputToken.colorBorderHover, width: textInputToken.borderWidthDefault); + case OudsPinCodeInputControlState.focused: + return isError? BorderSide(color: colorToken.actionNegativeEnabled, width: textInputToken.borderWidthDefault) + : BorderSide(color: textInputToken.colorBorderFocus, width: textInputToken.borderWidthFocus); + } + } +} diff --git a/ouds_core/lib/components/pin_code_input/internal/modifier/ouds_pin_code_input_text_color_modifier.dart b/ouds_core/lib/components/pin_code_input/internal/modifier/ouds_pin_code_input_text_color_modifier.dart new file mode 100644 index 000000000..4cb0dcdd2 --- /dev/null +++ b/ouds_core/lib/components/pin_code_input/internal/modifier/ouds_pin_code_input_text_color_modifier.dart @@ -0,0 +1,44 @@ +/* + * // Software Name: OUDS Flutter + * // SPDX-FileCopyrightText: Copyright (c) Orange SA + * // SPDX-License-Identifier: MIT + * // + * // This software is distributed under the MIT license, + * // the text of which is available at https://opensource.org/license/MIT/ + * // or see the "LICENSE" file for more details. + * // + * // Software description: Flutter library of reusable graphical components + * // + */ +/// @nodoc +library; + +import 'package:flutter/cupertino.dart'; +import 'package:ouds_theme_contract/ouds_theme.dart'; + +/// A class that provides the tick color for the OudsPinCodeInput based on its state and error status. +class OudsPinCodeInputTextColorModifier { + final BuildContext context; + + OudsPinCodeInputTextColorModifier(this.context); + + /// Gets the cursor text color based on the control error status. + Color getPinCodeCursorColor(bool error) { + final colorsScheme = OudsTheme.of(context).colorScheme(context); + if (error) { + return colorsScheme.actionNegativePressed; + } else { + return colorsScheme.contentDefault; + } + } + + /// Gets the helper text for pin code input color based on the error state + Color getPinCodeHelperTextColor(bool error) { + final colorsScheme = OudsTheme.of(context).colorScheme(context); + if (error) { + return colorsScheme.contentStatusNegative; + } else { + return colorsScheme.contentMuted; + } + } +} \ No newline at end of file diff --git a/ouds_core/lib/components/pin_code_input/internal/ouds_pin_code_input_control_state.dart b/ouds_core/lib/components/pin_code_input/internal/ouds_pin_code_input_control_state.dart new file mode 100644 index 000000000..b21ac0b09 --- /dev/null +++ b/ouds_core/lib/components/pin_code_input/internal/ouds_pin_code_input_control_state.dart @@ -0,0 +1,37 @@ +/* + * // Software Name: OUDS Flutter + * // SPDX-FileCopyrightText: Copyright (c) Orange SA + * // SPDX-License-Identifier: MIT + * // + * // This software is distributed under the MIT license, + * // the text of which is available at https://opensource.org/license/MIT/ + * // or see the "LICENSE" file for more details. + * // + * // Software description: Flutter library of reusable graphical components + * // + */ +/// @nodoc +library; + +/// Enum representing the state of the control. +enum OudsPinCodeInputControlState { hovered, focused, enabled } + +/// A class that determines the state of the OudsPinCodeInput. +class OudsPinCodeInputControlStateDeterminer { + final bool isHovered; + final bool isFocused; + final bool isEnabled; + + OudsPinCodeInputControlStateDeterminer({ + this.isHovered = false, + this.isFocused = false, + this.isEnabled = true + }); + + /// Determines the current material state of the control. + OudsPinCodeInputControlState determineControlState() { + if (isHovered) return OudsPinCodeInputControlState.hovered; + if (isFocused) return OudsPinCodeInputControlState.focused; + return OudsPinCodeInputControlState.enabled; + } +} 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 new file mode 100644 index 000000000..b87ca0452 --- /dev/null +++ b/ouds_core/lib/components/pin_code_input/ouds_pin_code_input.dart @@ -0,0 +1,388 @@ +/* + * // Software Name: OUDS Flutter + * // SPDX-FileCopyrightText: Copyright (c) Orange SA + * // SPDX-License-Identifier: MIT + * // + * // This software is distributed under the MIT license, + * // the text of which is available at https://opensource.org/license/MIT/ + * // or see the "LICENSE" file for more details. + * // + * // Software description: Flutter library of reusable graphical components + * // + */ +/// OudsPinCodeInput +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:ouds_theme_contract/ouds_theme.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'; + +/// The [OudsPinCodeInputLength] defines the length of OudsPinCodeInput. +enum OudsPinCodeInputLength{ + four, + six, + eight; + + int get digits { + switch (this) { + case OudsPinCodeInputLength.four: + return 4; + case OudsPinCodeInputLength.six: + return 6; + case OudsPinCodeInputLength.eight: + return 8; + } + } + const OudsPinCodeInputLength(); +} + +/// [OUDS Pin Code Input design guidelines](https://unified-design-system.orange.com/472794e18/p/9767bc-pin-code-input-v1) +/// +/// A PIN code input is a specialized form field used to capture short, fixed-length numeric codes, +/// typically for authentication or confirmation purposes, such as a 4, 6 or 8-digit personal identification number (PIN). +/// +/// 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. +/// +/// 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. +/// +/// Parameters: +/// +/// - [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] +/// +/// ## You can use [OudsPinCodeInput] like this : +/// +/// ```dart +/// OudsPinCodeInput( +/// controllers: controllers, +/// helperText: "Please enter the 4-digit code sent to your phone.", +/// style: OudsTextInputStyle.defaultStyle, +/// length: OudsPinCodeInputLength.four, +/// digitInputDecoration: OudsDigitInputDecoration( +/// hintText : "-", +/// roundedCorner: true, +/// style: OudsTextInputStyle.defaultStyle +/// ), +/// onEditingComplete: (value){}, +/// onChanged: (value){}, +/// ); +/// ``` +/// +class OudsPinCodeInput extends StatefulWidget { + final OudsPinCodeInputLength length; + final String? helperText; + late String? errorText; + final List? controllers; + final void Function(String)? onEditingComplete; + final void Function(String)? onChanged; + final OudsDigitInputDecoration digitInputDecoration; + + OudsPinCodeInput({ + super.key, + this.length = OudsPinCodeInputLength.six, + this.helperText, + this.errorText, + this.controllers, + this.onEditingComplete, + this.onChanged, + required this.digitInputDecoration, + }); + + @override + State createState() => _OudsPinCodeInputState(); + +} + +class _OudsPinCodeInputState extends State { + + final List _focusNodes = []; + late List _isHovered; + int currentIndex = 0; + bool _hasEdited = false; + bool hasAnyFocus = false; + bool? _previousHasFocus; + + @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); + } + + @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); + } + } + } + + @override + void dispose() { + if (!mounted) return; + FocusManager.instance.removeListener(_onGlobalFocusChange); + for (final node in _focusNodes) { + node.removeListener(() => _handleFocusChange(node, _focusNodes.indexOf(node))); + node.dispose(); + } + super.dispose(); + } + + void _handleFocusChange(FocusNode focusNode, int index){ + if (focusNode.hasFocus) { + setState(() { + currentIndex = index; + }); + } + } + + @override + Widget build(BuildContext context) { + + final pinCodeToken = OudsTheme.of(context).componentsTokens(context).pinCodeInput; + final textInputToken = OudsTheme.of(context).componentsTokens(context).textInput; + final theme = OudsTheme.of(context); + final digitsCount = widget.length.digits; + final isError = widget.errorText != null || (widget.errorText != null && widget.errorText!.isEmpty); + final l10n = OudsLocalizations.of(context); + + return Container( + constraints: BoxConstraints( + minHeight: textInputToken.sizeMinHeight + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Semantics( + liveRegion: true, + label: isError ? l10n?.core_pin_code_input_error_a11y : l10n?.core_pin_code_input_pin_code_label_a11y(digitsCount), + child: Row( + mainAxisAlignment: 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_pin_code_input_digit_code_label_a11y(index+1), + child: OudsDigitInput( + index: index, + isError: isError, + length: widget.length, + digitInputDecoration: OudsDigitInputDecoration( + hintText: _hintText(index), + roundedCorner: widget.digitInputDecoration.roundedCorner, + hiddenPassword: widget.digitInputDecoration.hiddenPassword, + isOutlined: widget.digitInputDecoration.isOutlined, + ), + 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 + }); + } + }, + ), + ), + + ); + }), + ), + ), + if (widget.helperText != null || + (widget.errorText != null && isError)) ...[ + Container( + constraints: BoxConstraints( + maxWidth: digitsCount * + pinCodeToken.sizeMaxWidth + + (digitsCount - 1) * + pinCodeToken.spaceColumnGapDigitInput, + ), + child: Padding( + padding: EdgeInsets.only( + top: textInputToken.spacePaddingBlockTopHelperText, + ), + child: + Align( + alignment: Alignment.centerLeft, + child: Text( + softWrap: true, + widget.errorText != null && isError + ? widget.errorText! + : widget.helperText!, + style: theme.typographyTokens + .typeLabelDefaultMedium(context) + .copyWith( + color: OudsPinCodeInputTextColorModifier(context) + .getPinCodeHelperTextColor(isError), + ), + ), + ), + ), + ), + ], + ], + ), + ); + } + + // This method updates focus between fields, assembles the full PIN code, + // and calls the appropriate callbacks: + void _handleDigitInput(String value, int index) { + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + final totalDigits = widget.length.digits; + final controllers = widget.controllers!; + // Case 1: user pasted a code (more than 3 characters) + if (value.length > 3) { + _handlePaste(value); + return; + } + + // Case 2: user tried to add another character into a filled field + if (value.length == 2) { + controllers[index] + ..text = value.characters.last + ..selection = TextSelection.collapsed(offset: 1); + return; + } + + final code = controllers.map((c) => c.text).join(); + widget.onChanged?.call(code); + + // Case 3: deletion stay in the same field + if (value.isEmpty) return; + + // Case 4: normal input move focus forward + if (index < totalDigits - 1) { + _focusNodes[index + 1].requestFocus(); + } else if (code.length == totalDigits) { + _focusNodes[index].unfocus(); + widget.onEditingComplete?.call(code); + } + }); + } + + //handle copy past pin code + void _handlePaste(String value) { + final totalDigits = widget.length.digits; + final controllers = widget.controllers!; + final digits = value.characters.take(totalDigits).toList(); + + for (int i = 0; i < digits.length; i++) { + controllers[i].text = digits[i]; + } + + final code = controllers.map((c) => c.text).join(); + widget.onChanged?.call(code); + + final isComplete = code.length == totalDigits; + + if (isComplete) { + for (final node in _focusNodes) { + node.unfocus(); + } + widget.onEditingComplete?.call(code); + } else { + final nextIndex = digits.length.clamp(0, totalDigits - 1); + _focusNodes[nextIndex].requestFocus(); + } + } + + // 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 = widget.controllers?.map((c) => c.text).join() ?? ""; + + if (!hasAnyFocus && _hasEdited) { + 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 15d3af366..673db649b 100644 --- a/ouds_core/lib/l10n/gen/ouds_localizations.dart +++ b/ouds_core/lib/l10n/gen/ouds_localizations.dart @@ -286,6 +286,24 @@ abstract class OudsLocalizations { /// In en, this message translates to: /// **'TextField'** String get core_text_input_input_a11y; + + /// No description provided for @core_pin_code_input_digit_code_label_a11y. + /// + /// In en, this message translates to: + /// **'Digit code {current}'** + String core_pin_code_input_digit_code_label_a11y(Object current); + + /// No description provided for @core_pin_code_input_pin_code_label_a11y. + /// + /// In en, this message translates to: + /// **'Enter your {digitsCount}-digit code'** + String core_pin_code_input_pin_code_label_a11y(Object digitsCount); + + /// No description provided for @core_pin_code_input_error_a11y. + /// + /// In en, this message translates to: + /// **'Error: Invalid code'** + String get core_pin_code_input_error_a11y; } class _OudsLocalizationsDelegate diff --git a/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart b/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart index 5fcd08cf5..c95ecc34e 100644 --- a/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart +++ b/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart @@ -105,4 +105,17 @@ class OudsLocalizationsAr extends OudsLocalizations { @override String get core_text_input_input_a11y => 'حقل النص'; + + @override + String core_pin_code_input_digit_code_label_a11y(Object current) { + return 'الرقم $current'; + } + + @override + String core_pin_code_input_pin_code_label_a11y(Object digitsCount) { + return 'أدخل رمزك المكوّن من $digitsCount أرقام'; + } + + @override + String get core_pin_code_input_error_a11y => 'خطأ: الرمز غير صحيح'; } diff --git a/ouds_core/lib/l10n/gen/ouds_localizations_en.dart b/ouds_core/lib/l10n/gen/ouds_localizations_en.dart index 192ac20e8..00c0a0882 100644 --- a/ouds_core/lib/l10n/gen/ouds_localizations_en.dart +++ b/ouds_core/lib/l10n/gen/ouds_localizations_en.dart @@ -105,4 +105,17 @@ class OudsLocalizationsEn extends OudsLocalizations { @override String get core_text_input_input_a11y => 'TextField'; + + @override + String core_pin_code_input_digit_code_label_a11y(Object current) { + return 'Digit code $current'; + } + + @override + String core_pin_code_input_pin_code_label_a11y(Object digitsCount) { + return 'Enter your $digitsCount-digit code'; + } + + @override + String get core_pin_code_input_error_a11y => 'Error: Invalid code'; } diff --git a/ouds_core/lib/l10n/ouds_flutter_ar.arb b/ouds_core/lib/l10n/ouds_flutter_ar.arb index 5b03fa190..a0a31f96b 100644 --- a/ouds_core/lib/l10n/ouds_flutter_ar.arb +++ b/ouds_core/lib/l10n/ouds_flutter_ar.arb @@ -47,5 +47,10 @@ "core_tag_loading_a11y": "جاري التحميل", "@_OUDS_TEXT_INPUT": {}, - "core_text_input_input_a11y": "حقل النص" + "core_text_input_input_a11y": "حقل النص", + + "@_OUDS_PIN_CODE_INPUT": {}, + "core_pin_code_input_digit_code_label_a11y": "الرقم {current}", + "core_pin_code_input_pin_code_label_a11y": "أدخل رمزك المكوّن من {digitsCount} أرقام", + "core_pin_code_input_error_a11y": "خطأ: الرمز غير صحيح" } \ No newline at end of file diff --git a/ouds_core/lib/l10n/ouds_flutter_en.arb b/ouds_core/lib/l10n/ouds_flutter_en.arb index cbb2edf4f..52ee17883 100644 --- a/ouds_core/lib/l10n/ouds_flutter_en.arb +++ b/ouds_core/lib/l10n/ouds_flutter_en.arb @@ -46,5 +46,10 @@ "core_tag_tag_input_hint_a11y": "Double tap to delete this item", "@_OUDS_TEXT_INPUT": {}, - "core_text_input_input_a11y": "TextField" + "core_text_input_input_a11y": "TextField", + + "@_OUDS_PIN_CODE_INPUT": {}, + "core_pin_code_input_digit_code_label_a11y": "Digit code {current}", + "core_pin_code_input_pin_code_label_a11y": "Enter your {digitsCount}-digit code", + "core_pin_code_input_error_a11y": "Error: Invalid code" } \ No newline at end of file diff --git a/ouds_theme_contract/lib/ouds_theme.dart b/ouds_theme_contract/lib/ouds_theme.dart index bfeb4180a..82e7e068e 100644 --- a/ouds_theme_contract/lib/ouds_theme.dart +++ b/ouds_theme_contract/lib/ouds_theme.dart @@ -89,4 +89,4 @@ class OudsTheme extends InheritedModel { } return false; } -} +} \ No newline at end of file diff --git a/ouds_theme_orange/lib/orange_theme.dart b/ouds_theme_orange/lib/orange_theme.dart index d2e94ffad..5f0085201 100644 --- a/ouds_theme_orange/lib/orange_theme.dart +++ b/ouds_theme_orange/lib/orange_theme.dart @@ -12,6 +12,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:ouds_theme_contract/ouds_theme.dart'; import 'package:ouds_theme_contract/ouds_theme_contract.dart'; import 'package:ouds_theme_contract/ouds_tokens_provider.dart'; import 'package:ouds_theme_contract/theme/scheme/color/ouds_color_scheme.dart'; diff --git a/ouds_theme_sosh/lib/ouds_theme_sosh.dart b/ouds_theme_sosh/lib/ouds_theme_sosh.dart index 9843677cd..0a1d6984a 100644 --- a/ouds_theme_sosh/lib/ouds_theme_sosh.dart +++ b/ouds_theme_sosh/lib/ouds_theme_sosh.dart @@ -11,6 +11,7 @@ */ import 'package:flutter/material.dart'; +import 'package:ouds_theme_contract/ouds_theme.dart'; import 'package:ouds_theme_contract/ouds_theme_contract.dart'; import 'package:ouds_theme_contract/ouds_tokens_provider.dart'; import 'package:ouds_theme_contract/theme/scheme/color/ouds_color_scheme.dart';