From c808d31436c8ae7aa7aa17a5dd9ebf5d2f0d73a2 Mon Sep 17 00:00:00 2001 From: nhammami Date: Thu, 1 Oct 2026 14:16:46 +0100 Subject: [PATCH 1/4] feat(skeleton): add skeleton component in lib and demo app --- .../gen/ouds_flutter_app_localizations.dart | 24 +++ .../ouds_flutter_app_localizations_ar.dart | 13 ++ .../ouds_flutter_app_localizations_en.dart | 13 ++ .../ouds_flutter_app_localizations_fr.dart | 13 ++ app/lib/l10n/ouds_flutter_ar.arb | 3 + app/lib/l10n/ouds_flutter_en.arb | 6 + app/lib/l10n/ouds_flutter_fr.arb | 3 + app/lib/ui/components/components.dart | 12 ++ .../skeleton/skeleton_code_generator.dart | 35 ++++ .../skeleton/skeleton_customization.dart | 95 ++++++++++ .../skeleton_customization_utils.dart | 19 ++ .../skeleton/skeleton_demo_screen.dart | 171 ++++++++++++++++++ .../components/skeleton/ouds_skeleton.dart | 166 +++++++++++++++++ .../skeleton/ouds_skeleton_test.dart | 118 ++++++++++++ 14 files changed, 691 insertions(+) create mode 100644 app/lib/ui/components/skeleton/skeleton_code_generator.dart create mode 100644 app/lib/ui/components/skeleton/skeleton_customization.dart create mode 100644 app/lib/ui/components/skeleton/skeleton_customization_utils.dart create mode 100644 app/lib/ui/components/skeleton/skeleton_demo_screen.dart create mode 100644 ouds_core/lib/components/skeleton/ouds_skeleton.dart create mode 100644 ouds_core/test/components/skeleton/ouds_skeleton_test.dart diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart index c71f917fe..0cdfa351d 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart @@ -990,6 +990,30 @@ abstract class AppLocalizations { /// **'Extra label'** String get app_components_radioButton_radioButtonItem_extraLabel_label; + /// No description provided for @app_components_skeleton_label. + /// + /// In en, this message translates to: + /// **'Skeleton'** + String get app_components_skeleton_label; + + /// No description provided for @app_components_skeleton_description_text. + /// + /// In en, this message translates to: + /// **'Skeleton is a UI element that displays an animated placeholder shape while content is loading.'** + String get app_components_skeleton_description_text; + + /// No description provided for @app_components_skeleton_animated_tech. + /// + /// In en, this message translates to: + /// **'Animated'** + String get app_components_skeleton_animated_tech; + + /// No description provided for @app_components_skeleton_securityMargin_tech. + /// + /// In en, this message translates to: + /// **'Security Margin'** + String get app_components_skeleton_securityMargin_tech; + /// No description provided for @app_components_switch_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 8bb2b79d7..07d23b9ba 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart @@ -506,6 +506,19 @@ class AppLocalizationsAr extends AppLocalizations { String get app_components_radioButton_radioButtonItem_extraLabel_label => 'Extra label'; + @override + String get app_components_skeleton_label => 'Skeleton'; + + @override + String get app_components_skeleton_description_text => + 'Skeleton هو عنصر واجهة مستخدم يعرض شكلاً متحركًا مؤقتًا أثناء تحميل المحتوى.'; + + @override + String get app_components_skeleton_animated_tech => 'Animated'; + + @override + String get app_components_skeleton_securityMargin_tech => 'Security Margin'; + @override String get app_components_switch_label => 'Switch'; 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 3d79f236c..956a0fb63 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart @@ -506,6 +506,19 @@ class AppLocalizationsEn extends AppLocalizations { String get app_components_radioButton_radioButtonItem_extraLabel_label => 'Extra label'; + @override + String get app_components_skeleton_label => 'Skeleton'; + + @override + String get app_components_skeleton_description_text => + 'Skeleton is a UI element that displays an animated placeholder shape while content is loading.'; + + @override + String get app_components_skeleton_animated_tech => 'Animated'; + + @override + String get app_components_skeleton_securityMargin_tech => 'Security Margin'; + @override String get app_components_switch_label => 'Switch'; diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations_fr.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations_fr.dart index b1d123e56..dbe924077 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_fr.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_fr.dart @@ -509,6 +509,19 @@ class AppLocalizationsFr extends AppLocalizations { String get app_components_radioButton_radioButtonItem_extraLabel_label => 'Extra label'; + @override + String get app_components_skeleton_label => 'Skeleton'; + + @override + String get app_components_skeleton_description_text => + 'Un Skeleton (ou squelette) est un élément d\'interface utilisateur qui affiche une forme animée servant de repère visuel pendant le chargement du contenu.'; + + @override + String get app_components_skeleton_animated_tech => 'Animated'; + + @override + String get app_components_skeleton_securityMargin_tech => 'Security Margin'; + @override String get app_components_switch_label => 'Switch'; diff --git a/app/lib/l10n/ouds_flutter_ar.arb b/app/lib/l10n/ouds_flutter_ar.arb index 1794a50bb..f532690df 100644 --- a/app/lib/l10n/ouds_flutter_ar.arb +++ b/app/lib/l10n/ouds_flutter_ar.arb @@ -89,6 +89,9 @@ "@_components_radio_button": {}, "app_components_radioButton_description_text": "Radio button هو عنصر واجهة مستخدم يسمح باختيار خيار واحد فقط من مجموعة من الخيارات المتعارضة.", + "@_components_skeleton": {}, + "app_components_skeleton_description_text": "Skeleton هو عنصر واجهة مستخدم يعرض شكلاً متحركًا مؤقتًا أثناء تحميل المحتوى.", + "@_components_switch": {}, "app_components_switch_description_text": "Switch هو عنصر واجهة مستخدم يسمح بالتبديل بين حالتين، عادةً \"تشغيل\" و \"إيقاف\"، ويستخدم لتمكين أو تعطيل الميزات أو الخيارات أو الإعدادات.", "app_components_switch_switch_a11y": "وصف محتوى المفتاح", diff --git a/app/lib/l10n/ouds_flutter_en.arb b/app/lib/l10n/ouds_flutter_en.arb index d2a652ff2..3db4496a5 100644 --- a/app/lib/l10n/ouds_flutter_en.arb +++ b/app/lib/l10n/ouds_flutter_en.arb @@ -220,6 +220,12 @@ "app_components_radioButton_radioButtonItem_label": "Radio Button Item", "app_components_radioButton_radioButtonItem_extraLabel_label": "Extra label", + "@_components_skeleton": {}, + "app_components_skeleton_label": "Skeleton", + "app_components_skeleton_description_text": "Skeleton is a UI element that displays an animated placeholder shape while content is loading.", + "app_components_skeleton_animated_tech": "Animated", + "app_components_skeleton_securityMargin_tech": "Security Margin", + "@_components_switch": {}, "app_components_switch_label": "Switch", "app_components_switch_switch_label": "Switch", diff --git a/app/lib/l10n/ouds_flutter_fr.arb b/app/lib/l10n/ouds_flutter_fr.arb index 5636080d8..5aacb9502 100644 --- a/app/lib/l10n/ouds_flutter_fr.arb +++ b/app/lib/l10n/ouds_flutter_fr.arb @@ -81,6 +81,9 @@ "@_components_radio_button": {}, "app_components_radioButton_description_text": "Un Radio button (ou bouton radio) est un élément d'interface utilisateur qui permet de sélectionner une seule option parmi un ensemble de choix mutuellement exclusifs.", + "@_components_skeleton": {}, + "app_components_skeleton_description_text": "Un Skeleton (ou squelette) est un élément d'interface utilisateur qui affiche une forme animée servant de repère visuel pendant le chargement du contenu.", + "@_components_switch": {}, "app_components_switch_description_text": "Un Switch (ou interrupteur) est un élément d'interface utilisateur qui permet de basculer entre deux états, généralement « Marche » et « Arrêt », et qui sert à activer ou désactiver des fonctionnalités, des options ou des paramètres.", "app_components_switch_switch_a11y": "Description du contenu de l'interrupteur", diff --git a/app/lib/ui/components/components.dart b/app/lib/ui/components/components.dart index ba54540b6..2c1f677a2 100644 --- a/app/lib/ui/components/components.dart +++ b/app/lib/ui/components/components.dart @@ -35,6 +35,7 @@ import 'package:ouds_core/components/pin_code_input/digit_input/ouds_digit_input import 'package:ouds_core/components/pin_code_input/ouds_pin_code_input.dart'; import 'package:ouds_core/components/progress_indicator/ouds_progress_indicator.dart'; import 'package:ouds_core/components/radio_button/ouds_radio_button.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.dart'; import 'package:ouds_core/components/switch/ouds_switch.dart'; import 'package:ouds_core/components/tag/ouds_tag.dart'; import 'package:ouds_core/components/top_bar/ouds_top_bar.dart'; @@ -69,6 +70,7 @@ import 'package:ouds_flutter_demo/ui/components/progress_indicator/circular_prog import 'package:ouds_flutter_demo/ui/components/progress_indicator/linear_progress_indicator_demo_screen.dart'; import 'package:ouds_flutter_demo/ui/components/radio_button/radio_button_demo_screen.dart'; import 'package:ouds_flutter_demo/ui/components/radio_button/radio_button_item_demo_screen.dart'; +import 'package:ouds_flutter_demo/ui/components/skeleton/skeleton_demo_screen.dart'; 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/input_tag_demo_screen.dart'; @@ -502,6 +504,16 @@ List components(BuildContext context) { ), ], ), + Component( + context.l10n.app_components_skeleton_label, + ComponentContainer( + child: SizedBox(width: 200, height: 62, child: const OudsSkeleton()), + ), + context.l10n.app_components_skeleton_description_text, + SkeletonDemoScreen( + previousPageTitle: context.l10n.app_bottomBar_components_label, + ), + ), Component.withVariant( context.l10n.app_components_switch_label, ComponentContainer( diff --git a/app/lib/ui/components/skeleton/skeleton_code_generator.dart b/app/lib/ui/components/skeleton/skeleton_code_generator.dart new file mode 100644 index 000000000..554745a68 --- /dev/null +++ b/app/lib/ui/components/skeleton/skeleton_code_generator.dart @@ -0,0 +1,35 @@ +// +// 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/skeleton/skeleton_customization.dart'; + +/// +/// The SkeletonCodeGenerator class is responsible for dynamically generating Flutter +/// code for the customization of a skeleton component. It leverages the skeleton's +/// customization state (width, height, animated) and generates the corresponding code +/// in string format, which can be used for rendering or previewing the skeleton with +/// the selected properties. +/// +class SkeletonCodeGenerator { + // Static method to generate the code based on skeleton customization state + static String updateCode(BuildContext context) { + final customizationState = SkeletonCustomization.of(context)!; + + final List parameters = [ + "securityMargin: ${customizationState.hasSecurityMargin}", + "animated: ${customizationState.hasAnimated}", + ]; + + return "OudsSkeleton(\n${parameters.join(',\n')},\n)"; + } +} diff --git a/app/lib/ui/components/skeleton/skeleton_customization.dart b/app/lib/ui/components/skeleton/skeleton_customization.dart new file mode 100644 index 000000000..37a3aa168 --- /dev/null +++ b/app/lib/ui/components/skeleton/skeleton_customization.dart @@ -0,0 +1,95 @@ +// +// 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/utilities/customizable/customizable_widget_state.dart'; + +/// Section for InheritedWidget to pass data down the widget tree +class _SkeletonCustomization extends InheritedWidget { + const _SkeletonCustomization({required super.child, required this.data}); + + final SkeletonCustomizationState data; + + @override + bool updateShouldNotify(_SkeletonCustomization oldWidget) => true; +} + +/// Main Widget class for skeleton customization +class SkeletonCustomization extends StatefulWidget { + const SkeletonCustomization({super.key, required this.child}); + + final Widget child; + + @override + SkeletonCustomizationState createState() => SkeletonCustomizationState(); + + static SkeletonCustomizationState? of(BuildContext context) { + return (context + .dependOnInheritedWidgetOfExactType<_SkeletonCustomization>()) + ?.data; + } +} + +/// Skeleton customization state management +class SkeletonCustomizationState + extends CustomizationWidgetState { + late final SkeletonAnimatedState animatedState; + late final SkeletonSecurityMarginState securityMarginState; + + @override + void initState() { + super.initState(); + animatedState = SkeletonAnimatedState(setState); + securityMarginState = SkeletonSecurityMarginState(setState); + } + + bool get hasAnimated => animatedState.value; + set hasAnimated(bool value) => animatedState.value = value; + + bool get hasSecurityMargin => securityMarginState.value; + set hasSecurityMargin(bool value) => securityMarginState.value = value; + + @override + Widget build(BuildContext context) { + return _SkeletonCustomization(data: this, child: widget.child); + } +} + +/// Animated State Management +class SkeletonAnimatedState { + SkeletonAnimatedState(this._setState); + + final void Function(void Function()) _setState; + bool _hasAnimated = true; + + bool get value => _hasAnimated; + set value(bool newValue) { + _setState(() { + _hasAnimated = newValue; + }); + } +} + +/// Security margin State Management +class SkeletonSecurityMarginState { + SkeletonSecurityMarginState(this._setState); + + final void Function(void Function()) _setState; + bool _hasSecurityMargin = true; + + bool get value => _hasSecurityMargin; + set value(bool newValue) { + _setState(() { + _hasSecurityMargin = newValue; + }); + } +} diff --git a/app/lib/ui/components/skeleton/skeleton_customization_utils.dart b/app/lib/ui/components/skeleton/skeleton_customization_utils.dart new file mode 100644 index 000000000..85ae6ef95 --- /dev/null +++ b/app/lib/ui/components/skeleton/skeleton_customization_utils.dart @@ -0,0 +1,19 @@ +// +// 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_flutter_demo/ui/components/skeleton/skeleton_customization.dart'; + +/// Utility class to map skeleton customization options to corresponding [OudsSkeleton] attributes. +/// +/// This class provides static methods to sanitize and parse the free-form width/height text fields +/// exposed by [SkeletonCustomizationState] into the numeric values expected by [OudsSkeleton]. +class SkeletonCustomizationUtils {} diff --git a/app/lib/ui/components/skeleton/skeleton_demo_screen.dart b/app/lib/ui/components/skeleton/skeleton_demo_screen.dart new file mode 100644 index 000000000..d28691f62 --- /dev/null +++ b/app/lib/ui/components/skeleton/skeleton_demo_screen.dart @@ -0,0 +1,171 @@ +// +// 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_core/components/skeleton/ouds_skeleton.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/skeleton/skeleton_code_generator.dart'; +import 'package:ouds_flutter_demo/ui/components/skeleton/skeleton_customization.dart'; +import 'package:ouds_flutter_demo/ui/theme/theme_controller.dart'; +import 'package:ouds_flutter_demo/ui/utilities/code.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/detail_screen_header.dart'; +import 'package:ouds_flutter_demo/ui/utilities/dismiss_keyboard.dart'; +import 'package:ouds_flutter_demo/ui/utilities/light_dark_box.dart'; +import 'package:ouds_flutter_demo/ui/utilities/reference_design_version_component.dart'; +import 'package:ouds_flutter_demo/ui/utilities/sheets_bottom/customize_bottom_sheet.dart'; +import 'package:ouds_theme_contract/ouds_component_version.dart'; +import 'package:provider/provider.dart'; + +class SkeletonDemoScreen extends StatefulWidget { + final String? previousPageTitle; + const SkeletonDemoScreen({super.key, this.previousPageTitle}); + + @override + State createState() => _SkeletonDemoScreenState(); +} + +class _SkeletonDemoScreenState extends State { + @override + Widget build(BuildContext context) { + return DismissKeyboard( + child: SkeletonCustomization( + child: CustomizeBottomSheet( + topBar: MainAppBar( + showBackButton: true, + title: context.l10n.app_components_skeleton_label, + previousPageTitle: widget.previousPageTitle, + ), + title: context.l10n.app_common_customize_label, + customizationContent: const _CustomizationContent(), + body: const _Body(), + ), + ), + ); + } +} + +/// This widget represents the body of the screen where the skeleton demo and code will be displayed +class _Body extends StatefulWidget { + const _Body(); + + @override + State<_Body> createState() => _BodyState(); +} + +class _BodyState extends State<_Body> { + @override + Widget build(BuildContext context) { + ThemeController? themeController = Provider.of( + context, + listen: false, + ); + return DetailScreenDescription( + description: context.l10n.app_components_skeleton_description_text, + widget: Column( + children: [ + const _SkeletonDemo(), + SizedBox( + height: themeController.currentTheme + .spaceScheme(context) + .fixedMedium, + ), + Code(code: SkeletonCodeGenerator.updateCode(context)), + ReferenceDesignVersionComponent( + version: OudsComponentVersion.skeleton, + ), + ], + ), + ); + } +} + +/// Component [_SkeletonDemo] demonstrates the behavior and functionality of a skeleton. +class _SkeletonDemo extends StatefulWidget { + const _SkeletonDemo(); + + @override + State<_SkeletonDemo> createState() => _SkeletonDemoState(); +} + +class _SkeletonDemoState extends State<_SkeletonDemo> { + @override + Widget build(BuildContext context) { + final customizationState = SkeletonCustomization.of(context)!; + + return LightDarkBox( + child: OudsSkeleton( + animated: customizationState.hasAnimated, + securityMargin: customizationState.hasSecurityMargin, + ), + ); + } +} + +/// 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 skeleton +class _CustomizationContentState extends State<_CustomizationContent> { + late final FocusNode widthFocus; + late final FocusNode heightFocus; + + @override + void initState() { + super.initState(); + widthFocus = FocusNode(); + heightFocus = FocusNode(); + } + + @override + void dispose() { + widthFocus.dispose(); + heightFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final SkeletonCustomizationState? customizationState = + SkeletonCustomization.of(context); + + return CustomizableSection( + children: [ + CustomizableSwitch( + title: context.l10n.app_components_skeleton_securityMargin_tech, + value: customizationState!.hasSecurityMargin, + onChanged: (value) { + setState(() { + customizationState.hasSecurityMargin = value; + }); + }, + ), + CustomizableSwitch( + title: context.l10n.app_components_skeleton_animated_tech, + value: customizationState.hasAnimated, + onChanged: (value) { + setState(() { + customizationState.hasAnimated = value; + }); + }, + ), + ], + ); + } +} diff --git a/ouds_core/lib/components/skeleton/ouds_skeleton.dart b/ouds_core/lib/components/skeleton/ouds_skeleton.dart new file mode 100644 index 000000000..dd72e7122 --- /dev/null +++ b/ouds_core/lib/components/skeleton/ouds_skeleton.dart @@ -0,0 +1,166 @@ +// +// 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 +// + +/// {@category Skeleton} +library; + +import 'package:flutter/material.dart'; +import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; +import 'package:ouds_theme_contract/ouds_theme.dart'; + +/// [OUDS Skeleton Design Guidelines](https://r.orange.fr/r/S-ouds-doc-skeleton) +/// +/// **Reference design version : 1.0.0** +/// +/// A skeleton is a UI element that indicates when content is loading. The skeleton enhances user experience by +/// temporarily replacing content with gray areas or animations that simulate the visual structure of the forthcoming +/// content. +/// +/// [OudsSkeleton] does not expose `width`/`height` parameters of its own and relies on the size +/// constraints provided by its parent. To display it at a specific size, wrap it in a [SizedBox] +/// (or any other size-constraining widget) instead of passing size values directly: +/// +/// ```dart +/// SizedBox( +/// width: 250, +/// height: 80, +/// child: OudsSkeleton(), +/// ) +/// ``` +/// +/// ```dart +/// OudsSkeleton( +/// animated: true, +/// securityMargin: false, +/// ) +/// ``` +/// +class OudsSkeleton extends StatefulWidget { + /// Whether the shimmer sweep animation is enabled. Defaults to `true`. Automatically disabled + /// when the system "reduce motion" accessibility setting is active, regardless of this value. + final bool animated; + + /// Whether to apply vertical padding to the skeleton. Defaults to true. + final bool securityMargin; + + /// Creates an [OudsSkeleton]. + const OudsSkeleton({ + super.key, + this.animated = true, + this.securityMargin = true, + }); + + @override + State createState() => _OudsSkeletonState(); +} + +class _OudsSkeletonState extends State + with SingleTickerProviderStateMixin { + static const _shimmerDuration = Duration(milliseconds: 1500); + + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: _shimmerDuration) + ..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// Returns `true` when the OS accessibility settings request that animations be suppressed. + /// + /// Combines [MediaQuery.disableAnimationsOf] — Android's global "Remove animations" toggle and + /// any host app override — with iOS's "Reduce Motion" flag, read directly from the platform + /// dispatcher since [MediaQuery.disableAnimationsOf] does not reliably reflect it on iOS. + bool _shouldDisableAnimations(BuildContext context) { + if (MediaQuery.disableAnimationsOf(context)) { + return true; + } + final accessibilityFeatures = View.of( + context, + ).platformDispatcher.accessibilityFeatures; + return accessibilityFeatures.reduceMotion; + } + + @override + Widget build(BuildContext context) { + final tokens = OudsTheme.of(context).componentsTokens(context).skeleton; + final padding = OudsTheme.of( + context, + ).spaceScheme(context).paddingBlockThreeExtraSmall; + final shouldAnimate = widget.animated && !_shouldDisableAnimations(context); + + final placeholder = Container( + padding: widget.securityMargin + ? EdgeInsets.symmetric(vertical: padding) + : EdgeInsets.zero, + width: 200, + height: 62, + color: tokens.colorBg, + ); + + final content = shouldAnimate + ? AnimatedBuilder( + animation: _controller, + child: placeholder, + builder: (context, child) { + return ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (bounds) => LinearGradient( + colors: [ + tokens.colorGradientStartEnd, + tokens.colorGradientMiddle, + tokens.colorGradientStartEnd, + ], + stops: const [0.0, 0.5, 1.0], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + transform: _OudsSkeletonShimmerTransform( + slidePercent: _controller.value, + ), + ).createShader(bounds), + child: child, + ); + }, + ) + : placeholder; + + return Semantics( + label: OudsLocalizations.of(context)?.core_common_loading_a11y, + child: ExcludeSemantics(child: content), + ); + } +} + +/// Slides the shimmer gradient horizontally across the skeleton's bounds as [slidePercent] goes +/// from `0.0` to `1.0`. The gradient enters fully off-screen on one side and exits fully +/// off-screen on the other, so the sweep loops smoothly when the driving animation repeats. +class _OudsSkeletonShimmerTransform extends GradientTransform { + const _OudsSkeletonShimmerTransform({required this.slidePercent}); + + final double slidePercent; + + @override + Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { + return Matrix4.translationValues( + bounds.width * (slidePercent * 3 - 1.5), + 0.0, + 0.0, + ); + } +} diff --git a/ouds_core/test/components/skeleton/ouds_skeleton_test.dart b/ouds_core/test/components/skeleton/ouds_skeleton_test.dart new file mode 100644 index 000000000..5cf8db420 --- /dev/null +++ b/ouds_core/test/components/skeleton/ouds_skeleton_test.dart @@ -0,0 +1,118 @@ +// +// 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:flutter_test/flutter_test.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.dart'; + +import '../../helpers/testable_widget_helper.dart'; + +void main() { + group('OudsSkeleton', () { + testWidgets('fills the available space when width/height are omitted', ( + tester, + ) async { + await tester.pumpWidget( + testableWidget( + const SizedBox(width: 200, height: 80, child: OudsSkeleton()), + ), + ); + + final size = tester.getSize(find.byType(OudsSkeleton)); + expect(size.width, 200); + expect(size.height, 80); + }); + + testWidgets('animates with a shimmer sweep by default', (tester) async { + await tester.pumpWidget( + testableWidget( + const SizedBox(width: 200, height: 80, child: OudsSkeleton()), + ), + ); + + expect(find.byType(ShaderMask), findsOneWidget); + + // Pumping the animation should not throw and should keep the shader mask mounted. + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(ShaderMask), findsOneWidget); + }); + + testWidgets('renders a static placeholder when animated is false', ( + tester, + ) async { + await tester.pumpWidget( + testableWidget( + const SizedBox( + width: 200, + height: 80, + child: OudsSkeleton(animated: false), + ), + ), + ); + + expect(find.byType(ShaderMask), findsNothing); + }); + + testWidgets( + 'renders a static placeholder when the system requests reduced motion', + (tester) async { + await tester.pumpWidget( + testableWidget( + MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: const SizedBox( + width: 120, + height: 24, + child: OudsSkeleton(), + ), + ), + ), + ); + + expect(find.byType(ShaderMask), findsNothing); + }, + ); + + testWidgets('is excluded from semantics as a single loading node', ( + tester, + ) async { + await tester.pumpWidget( + testableWidget( + const SizedBox(width: 120, height: 24, child: OudsSkeleton()), + ), + ); + + expect(find.byType(Semantics), findsWidgets); + expect( + find.descendant( + of: find.byType(OudsSkeleton), + matching: find.byType(ExcludeSemantics), + ), + findsOneWidget, + ); + }); + + testWidgets('disposes its animation controller without error', ( + tester, + ) async { + await tester.pumpWidget( + testableWidget( + const SizedBox(width: 120, height: 24, child: OudsSkeleton()), + ), + ); + + await tester.pumpWidget(testableWidget(const SizedBox.shrink())); + + expect(tester.takeException(), isNull); + }); + }); +} From 54cb8595c309f60b3ae61064e1ee770d04509109 Mon Sep 17 00:00:00 2001 From: nhammami Date: Fri, 2 Oct 2026 12:21:54 +0100 Subject: [PATCH 2/4] feat(skeleton): add skeleton in chips components (lib and demo) --- .../gen/ouds_flutter_app_localizations.dart | 6 + .../ouds_flutter_app_localizations_ar.dart | 3 + .../ouds_flutter_app_localizations_en.dart | 3 + .../ouds_flutter_app_localizations_fr.dart | 3 + app/lib/l10n/ouds_flutter_en.arb | 1 + .../chip/chip_filter_code_generator.dart | 24 +++- .../chip/chip_filter_demo_sreen.dart | 11 ++ .../chip/chip_suggestion_code_generator.dart | 16 ++- .../chip/chip_suggestion_demo_screen.dart | 24 +++- .../skeleton/skeleton_code_generator.dart | 2 +- .../skeleton/skeleton_customization.dart | 2 +- .../skeleton_customization_utils.dart | 19 --- .../customizable_widget_state.dart | 20 +++ .../lib/components/chip/ouds_filter_chip.dart | 118 ++++++++++------- .../components/chip/ouds_suggestion_chip.dart | 66 +++++++--- .../internal/ouds_skeleton_layout.dart | 95 ++++++++++++++ .../components/skeleton/ouds_skeleton.dart | 124 ++++++++++-------- .../internal/ouds_skeleton_layout_test.dart | 89 +++++++++++++ .../skeleton/ouds_skeleton_test.dart | 26 +++- 19 files changed, 495 insertions(+), 157 deletions(-) delete mode 100644 app/lib/ui/components/skeleton/skeleton_customization_utils.dart create mode 100644 ouds_core/lib/components/skeleton/internal/ouds_skeleton_layout.dart create mode 100644 ouds_core/test/components/skeleton/internal/ouds_skeleton_layout_test.dart diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart index 0cdfa351d..19b1e1961 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart @@ -655,6 +655,12 @@ abstract class AppLocalizations { /// **'Untinted'** String get app_components_common_untinted_tech; + /// No description provided for @app_components_common_skeleton_tech. + /// + /// In en, this message translates to: + /// **'Skeleton'** + String get app_components_common_skeleton_tech; + /// No description provided for @app_components_alert_tech. /// /// 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 07d23b9ba..3555851e8 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart @@ -302,6 +302,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get app_components_common_untinted_tech => 'Untinted'; + @override + String get app_components_common_skeleton_tech => 'Skeleton'; + @override String get app_components_alert_tech => 'Alert'; 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 956a0fb63..fdb977426 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart @@ -302,6 +302,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get app_components_common_untinted_tech => 'Untinted'; + @override + String get app_components_common_skeleton_tech => 'Skeleton'; + @override String get app_components_alert_tech => 'Alert'; diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations_fr.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations_fr.dart index dbe924077..8959bedbf 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_fr.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_fr.dart @@ -304,6 +304,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get app_components_common_untinted_tech => 'Untinted'; + @override + String get app_components_common_skeleton_tech => 'Skeleton'; + @override String get app_components_alert_tech => 'Alert'; diff --git a/app/lib/l10n/ouds_flutter_en.arb b/app/lib/l10n/ouds_flutter_en.arb index 3db4496a5..fa8fc372b 100644 --- a/app/lib/l10n/ouds_flutter_en.arb +++ b/app/lib/l10n/ouds_flutter_en.arb @@ -142,6 +142,7 @@ "app_components_common_icon_tech": "Icon", "app_components_common_tinted_tech": "Tinted", "app_components_common_untinted_tech": "Untinted", + "app_components_common_skeleton_tech": "Skeleton", "@_components_alert": {}, "app_components_alert_tech": "Alert", diff --git a/app/lib/ui/components/chip/chip_filter_code_generator.dart b/app/lib/ui/components/chip/chip_filter_code_generator.dart index 505baf923..e14be19b1 100644 --- a/app/lib/ui/components/chip/chip_filter_code_generator.dart +++ b/app/lib/ui/components/chip/chip_filter_code_generator.dart @@ -44,17 +44,17 @@ class ChipFilterCodeGenerator { switch (layout) { case OudsChipLayout.textOnly: code = - """OudsFilterChip(\nlabel: "$label",\nselected: ${customizationState?.hasSelected == true ? "true" : 'false'},\n${disableCode(context)}\n);"""; + """OudsFilterChip(\n label: "$label",\n selected: ${customizationState?.hasSelected == true ? "true" : 'false'},\n${disableCode(context)},\n${skeletonCode(context)});"""; break; case OudsChipLayout.iconOnly: code = - "OudsFilterChip.icon(\nicon: OudsIcon('${customizationState?.isTinted == true ? 'AppAssets.icons.assistanceTipsAndTricks' : 'AppAssets.icons.icUntintedSquare'}', tinted: ${customizationState?.tintedIcon}),\nselected: ${customizationState?.hasSelected == true ? "true" : 'false'},\n${disableCode(context)}\n);"; + "OudsFilterChip.icon(\n icon: OudsIcon('${customizationState?.isTinted == true ? 'AppAssets.icons.assistanceTipsAndTricks' : 'AppAssets.icons.icUntintedSquare'}', tinted: ${customizationState?.tintedIcon}),\nselected: ${customizationState?.hasSelected == true ? "true" : 'false'},\n${disableCode(context)},\n${skeletonCode(context)});"; break; case OudsChipLayout.iconAndText: code = - """OudsFilterChip.icon(\nlabel: "$label",\nicon: OudsIcon('${customizationState?.isTinted == true ? 'AppAssets.icons.assistanceTipsAndTricks' : 'AppAssets.icons.icUntintedSquare'}', tinted: ${customizationState?.tintedIcon}),\nselected: ${customizationState?.hasSelected == true ? "true" : 'false'},\n${disableCode(context)}\n);"""; + """OudsFilterChip.icon(\n label: "$label",\n icon: OudsIcon('${customizationState?.isTinted == true ? 'AppAssets.icons.assistanceTipsAndTricks' : 'AppAssets.icons.icUntintedSquare'}', tinted: ${customizationState?.tintedIcon}),\n selected: ${customizationState?.hasSelected == true ? "true" : 'false'},\n${disableCode(context)},\n${skeletonCode(context)});"""; break; } @@ -67,9 +67,19 @@ class ChipFilterCodeGenerator { context, ); // Return the onChanged callback code with its enabled or disabled state - return "onSelected: ${customizationState?.hasEnabled == true ? "(bool newValue) { \n" - "setState(() {\n " - "isSelected = newValue;\n " - "});\n}" : 'null'},"; + return " onSelected: ${customizationState?.hasEnabled == true ? "(bool newValue) { \n" + " setState(() {\n " + " isSelected = newValue;\n " + " });\n }" : 'null'}"; + } + + // Method to generate the skeleton code + static String skeletonCode(BuildContext context) { + final ChipCustomizationState? customizationState = ChipCustomization.of( + context, + ); + return customizationState?.hasSkeleton == true + ? ' skeleton: OudsSkeleton(),\n' + : ''; } } diff --git a/app/lib/ui/components/chip/chip_filter_demo_sreen.dart b/app/lib/ui/components/chip/chip_filter_demo_sreen.dart index fbe51376e..04122b5d5 100644 --- a/app/lib/ui/components/chip/chip_filter_demo_sreen.dart +++ b/app/lib/ui/components/chip/chip_filter_demo_sreen.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:ouds_core/components/chip/ouds_filter_chip.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.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/chip/chip_customization.dart'; @@ -137,6 +138,7 @@ class _ChipFilterDemoState extends State<_ChipFilterDemo> { }); } : null, + skeleton: customizationState.hasSkeleton ? OudsSkeleton() : null, ); case ChipEnumLayout.iconAndText: return OudsFilterChip.icon( @@ -155,6 +157,7 @@ class _ChipFilterDemoState extends State<_ChipFilterDemo> { }); } : null, + skeleton: customizationState.hasSkeleton ? OudsSkeleton() : null, ); case ChipEnumLayout.iconOnly: return OudsFilterChip.icon( @@ -172,6 +175,7 @@ class _ChipFilterDemoState extends State<_ChipFilterDemo> { }); } : null, + skeleton: customizationState.hasSkeleton ? OudsSkeleton() : null, ); } } @@ -259,6 +263,13 @@ class _CustomizationContentState extends State<_CustomizationContent> { focusNode: labelFocus, fieldType: FieldType.label, ), + CustomizableSwitch( + title: context.l10n.app_components_common_skeleton_tech, + value: customizationState.hasSkeleton, + onChanged: (value) { + customizationState.hasSkeleton = value; + }, + ), ], ); } diff --git a/app/lib/ui/components/chip/chip_suggestion_code_generator.dart b/app/lib/ui/components/chip/chip_suggestion_code_generator.dart index d4600a875..209931a86 100644 --- a/app/lib/ui/components/chip/chip_suggestion_code_generator.dart +++ b/app/lib/ui/components/chip/chip_suggestion_code_generator.dart @@ -44,20 +44,30 @@ class ChipSuggestionCodeGenerator { switch (layout) { case OudsChipLayout.textOnly: code = - """OudsSuggestionChip(\nlabel: "$label",\nonPressed: ${customizationState?.hasEnabled == true ? "() {}" : 'null'},\n);"""; + """OudsSuggestionChip(\n label: "$label",\n onPressed: ${customizationState?.hasEnabled == true ? "() {}" : 'null'},\n${skeletonCode(context)});"""; break; case OudsChipLayout.iconOnly: code = - "OudsSuggestionChip.icon(\nicon: OudsIcon(${customizationState?.isTinted == true ? 'AppAssets.icons.assistanceTipsAndTricks' : 'AppAssets.icons.icUntintedSquare'}, tinted: ${customizationState?.tintedIcon}),\nonPressed: ${customizationState?.hasEnabled == true ? "() {}" : 'null'},\n);"; + "OudsSuggestionChip.icon(\n icon: OudsIcon(${customizationState?.isTinted == true ? 'AppAssets.icons.assistanceTipsAndTricks' : 'AppAssets.icons.icUntintedSquare'}, tinted: ${customizationState?.tintedIcon}),\n onPressed: ${customizationState?.hasEnabled == true ? "() {}" : 'null'},\n${skeletonCode(context)});"; break; case OudsChipLayout.iconAndText: code = - """OudsSuggestionChip.icon(\nlabel: "$label",\nicon: OudsIcon(${customizationState?.isTinted == true ? 'AppAssets.icons.assistanceTipsAndTricks' : 'AppAssets.icons.icUntintedSquare'}, tinted: ${customizationState?.tintedIcon}),\nonPressed: ${customizationState?.hasEnabled == true ? "() {}" : 'null'},\n);"""; + """OudsSuggestionChip.icon(\n label: "$label",\n icon: OudsIcon(${customizationState?.isTinted == true ? 'AppAssets.icons.assistanceTipsAndTricks' : 'AppAssets.icons.icUntintedSquare'}, tinted: ${customizationState?.tintedIcon}),\n onPressed: ${customizationState?.hasEnabled == true ? "() {}" : 'null'},\n${skeletonCode(context)});"""; break; } return code; // Return the generated code as a string } + + // Method to generate the skeleton code + static String skeletonCode(BuildContext context) { + final ChipCustomizationState? customizationState = ChipCustomization.of( + context, + ); + return customizationState?.hasSkeleton == true + ? ' skeleton: OudsSkeleton(),\n' + : ''; + } } diff --git a/app/lib/ui/components/chip/chip_suggestion_demo_screen.dart b/app/lib/ui/components/chip/chip_suggestion_demo_screen.dart index 11da0830d..cf8ca5370 100644 --- a/app/lib/ui/components/chip/chip_suggestion_demo_screen.dart +++ b/app/lib/ui/components/chip/chip_suggestion_demo_screen.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:ouds_core/components/chip/ouds_suggestion_chip.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.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/chip/chip_customization.dart'; @@ -131,6 +132,9 @@ class _ChipSuggestionDemoState extends State<_ChipSuggestionDemo> { return OudsSuggestionChip( label: ChipCustomizationUtils.getText(customizationState), onPressed: customizationState?.hasEnabled == true ? () {} : null, + skeleton: customizationState?.hasSkeleton == true + ? OudsSkeleton() + : null, ); case ChipEnumLayout.iconAndText: return OudsSuggestionChip.icon( @@ -141,7 +145,10 @@ class _ChipSuggestionDemoState extends State<_ChipSuggestionDemo> { colorSurfaceBrandPrimary, null, ), - onPressed: customizationState!.hasEnabled == true ? () {} : null, + onPressed: customizationState?.hasEnabled == true ? () {} : null, + skeleton: customizationState?.hasSkeleton == true + ? OudsSkeleton() + : null, ); case ChipEnumLayout.iconOnly: return OudsSuggestionChip.icon( @@ -151,13 +158,19 @@ class _ChipSuggestionDemoState extends State<_ChipSuggestionDemo> { colorSurfaceBrandPrimary, context.l10n.app_components_common_icon_a11y, ), - onPressed: customizationState!.hasEnabled == true ? () {} : null, + onPressed: customizationState?.hasEnabled == true ? () {} : null, + skeleton: customizationState?.hasSkeleton == true + ? OudsSkeleton() + : null, ); default: return OudsSuggestionChip( label: ChipCustomizationUtils.getText(customizationState), onPressed: customizationState?.hasEnabled == true ? () {} : null, + skeleton: customizationState?.hasSkeleton == true + ? OudsSkeleton() + : null, ); } } @@ -236,6 +249,13 @@ class _CustomizationContentState extends State<_CustomizationContent> { focusNode: labelFocus, fieldType: FieldType.label, ), + CustomizableSwitch( + title: context.l10n.app_components_common_skeleton_tech, + value: customizationState.hasSkeleton, + onChanged: (value) { + customizationState.hasSkeleton = value; + }, + ), ], ); } diff --git a/app/lib/ui/components/skeleton/skeleton_code_generator.dart b/app/lib/ui/components/skeleton/skeleton_code_generator.dart index 554745a68..6e42ce071 100644 --- a/app/lib/ui/components/skeleton/skeleton_code_generator.dart +++ b/app/lib/ui/components/skeleton/skeleton_code_generator.dart @@ -16,7 +16,7 @@ import 'package:ouds_flutter_demo/ui/components/skeleton/skeleton_customization. /// /// The SkeletonCodeGenerator class is responsible for dynamically generating Flutter /// code for the customization of a skeleton component. It leverages the skeleton's -/// customization state (width, height, animated) and generates the corresponding code +/// customization state (animated,hasSecurityMargin) and generates the corresponding code /// in string format, which can be used for rendering or previewing the skeleton with /// the selected properties. /// diff --git a/app/lib/ui/components/skeleton/skeleton_customization.dart b/app/lib/ui/components/skeleton/skeleton_customization.dart index 37a3aa168..2d3faa9e7 100644 --- a/app/lib/ui/components/skeleton/skeleton_customization.dart +++ b/app/lib/ui/components/skeleton/skeleton_customization.dart @@ -84,7 +84,7 @@ class SkeletonSecurityMarginState { SkeletonSecurityMarginState(this._setState); final void Function(void Function()) _setState; - bool _hasSecurityMargin = true; + bool _hasSecurityMargin = false; bool get value => _hasSecurityMargin; set value(bool newValue) { diff --git a/app/lib/ui/components/skeleton/skeleton_customization_utils.dart b/app/lib/ui/components/skeleton/skeleton_customization_utils.dart deleted file mode 100644 index 85ae6ef95..000000000 --- a/app/lib/ui/components/skeleton/skeleton_customization_utils.dart +++ /dev/null @@ -1,19 +0,0 @@ -// -// 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_flutter_demo/ui/components/skeleton/skeleton_customization.dart'; - -/// Utility class to map skeleton customization options to corresponding [OudsSkeleton] attributes. -/// -/// This class provides static methods to sanitize and parse the free-form width/height text fields -/// exposed by [SkeletonCustomizationState] into the numeric values expected by [OudsSkeleton]. -class SkeletonCustomizationUtils {} diff --git a/app/lib/ui/utilities/customizable/customizable_widget_state.dart b/app/lib/ui/utilities/customizable/customizable_widget_state.dart index 4dc3cc2e1..de222124b 100644 --- a/app/lib/ui/utilities/customizable/customizable_widget_state.dart +++ b/app/lib/ui/utilities/customizable/customizable_widget_state.dart @@ -25,6 +25,7 @@ abstract class CustomizationWidgetState late final CenterAlignedState centerAlignedState; late final EdgeState edgeToEdgeState; late final TintedState tintedState; + late final SkeletonState skeletonState; @override void initState() { @@ -38,6 +39,7 @@ abstract class CustomizationWidgetState centerAlignedState = CenterAlignedState(setState); edgeToEdgeState = EdgeState(setState, initial: true); tintedState = TintedState(setState); + skeletonState = SkeletonState(setState); } // Proxy getters and setters to expose state values directly @@ -67,6 +69,9 @@ abstract class CustomizationWidgetState TintedEnum get selectedTinted => tintedState.selected; set selectedTinted(TintedEnum value) => tintedState.selected = value; + + bool get hasSkeleton => skeletonState.value; + set hasSkeleton(bool value) => skeletonState.value = value; } /// Enabled State Management @@ -193,3 +198,18 @@ class TintedState { }); } } + +/// Skeleton State Management +class SkeletonState { + SkeletonState(this._setState); + + final void Function(void Function()) _setState; + bool _hasSkeleton = false; + + bool get value => _hasSkeleton; + set value(bool newValue) { + _setState(() { + _hasSkeleton = newValue; + }); + } +} diff --git a/ouds_core/lib/components/chip/ouds_filter_chip.dart b/ouds_core/lib/components/chip/ouds_filter_chip.dart index 972de6bed..05e0cb9bd 100644 --- a/ouds_core/lib/components/chip/ouds_filter_chip.dart +++ b/ouds_core/lib/components/chip/ouds_filter_chip.dart @@ -25,6 +25,8 @@ import 'package:ouds_core/components/chip/internal/ouds_chip_text_style_modifier import 'package:ouds_core/components/common/OudsBorder.dart'; import 'package:ouds_core/components/common/ouds_icon.dart'; import 'package:ouds_core/components/control/internal/interaction/ouds_inherited_interaction_model.dart'; +import 'package:ouds_core/components/skeleton/internal/ouds_skeleton_layout.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.dart'; import 'package:ouds_core/components/utilities/app_assets.dart'; import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; @@ -54,6 +56,7 @@ enum OudsChipStyle { defaultStyle, selected } /// - [icon]: Icon displayed in the chip. Use an icon to add additional affordance where the icon has a clear and well-established meaning. /// - [selected]: Whether this chip is selected or not. If this value is null so the component is in disabled state. /// - [onSelected] : Called when this chip is clicked. A null value indicates that the component is disabled. +/// - [skeleton] : An optional skeleton that improves the perceived loading time by providing a visual cue of where the filter chip will appear once fully loaded. /// /// ### You can use [OudsFilterChip] component in your project, customizing parameters as needed : /// @@ -80,6 +83,16 @@ enum OudsChipStyle { defaultStyle, selected } /// ) /// ``` /// +/// **Text only filter chip with Skeleton :** +/// +/// ```dart +/// OudsFilterChip( +/// label: 'Label', +/// selected: true, +/// onSelected: () {}, +/// skeleton: OudsSkeleton(), +/// ); +/// ``` class OudsFilterChip extends StatefulWidget { final String? label; @Deprecated( @@ -87,9 +100,13 @@ class OudsFilterChip extends StatefulWidget { ) final String? avatar; final OudsIcon? icon; + @Deprecated( + "This parameter is deprecated and will be removed in a future version. Use icon.semanticsLabel instead in OudsFilterChip.icon constructor .", + ) final String? contentDescription; final bool selected; final ValueChanged? onSelected; + final OudsSkeleton? skeleton; /// Creates a text-only [OudsFilterChip]. /// @@ -103,6 +120,7 @@ class OudsFilterChip extends StatefulWidget { this.avatar, this.selected = false, this.onSelected, + this.skeleton, }) : contentDescription = null, icon = null; @@ -117,6 +135,7 @@ class OudsFilterChip extends StatefulWidget { this.selected = false, this.onSelected, this.contentDescription, + this.skeleton, }) : avatar = null; @override @@ -199,6 +218,10 @@ class _OudsFilterChipState extends State { final isHovered = interactionModelHover?.state.isHovered ?? false; final isPressed = interactionModelPressed?.state.isPressed ?? false; + if (widget.skeleton != null) { + _isFocused = false; + } + final chipStateDeterminer = OudsChipControlStateDeterminer( enabled: !isDisabled, isPressed: _isPressed || isPressed, @@ -238,9 +261,11 @@ class _OudsFilterChipState extends State { final borderTokens = OudsTheme.of(context).borderTokens; final l10n = OudsLocalizations.of(context); final enabled = widget.onSelected != null; + final resolvedSemanticsLabel = + widget.contentDescription ?? widget.icon?.semanticsLabel; String? accessibilityLabel = widget.label == null && widget.icon != null - ? widget.contentDescription + ? resolvedSemanticsLabel : widget.label; String? accessibilityHint; @@ -296,42 +321,37 @@ class _OudsFilterChipState extends State { alignment: Alignment.center, children: [ // Border exterior - if (_isFocused) - Positioned( - top: borderTokens.widthFocus / 2, - bottom: borderTokens.widthFocus / 2, - left: -borderTokens.widthFocus / 2, - right: -borderTokens.widthFocus / 2, - child: Container( - decoration: BoxDecoration( - border: OudsBorder().borderAll( - color: OudsTheme.of( - context, - ).colorScheme(context).borderFocus, - width: borderTokens.widthFocus, - ), - borderRadius: BorderRadius.circular( - OudsTheme.of(context) - .componentsTokens(context) - .chip - .borderRadius + - OudsTheme.of( - context, - ).borderTokens.widthFocus, - ), + Positioned( + top: borderTokens.widthFocus / 2, + bottom: borderTokens.widthFocus / 2, + left: -borderTokens.widthFocus / 2, + right: -borderTokens.widthFocus / 2, + child: Container( + decoration: BoxDecoration( + border: OudsBorder().borderAll( + color: OudsTheme.of( + context, + ).colorScheme(context).borderFocus, + width: borderTokens.widthFocus, + ), + borderRadius: BorderRadius.circular( + OudsTheme.of(context) + .componentsTokens(context) + .chip + .borderRadius + + OudsTheme.of(context).borderTokens.widthFocus, ), ), ), + ), // Border interior + content Container( decoration: BoxDecoration( border: OudsBorder().borderAll( - color: _isFocused - ? OudsTheme.of( - context, - ).colorScheme(context).borderFocusInset - : Colors.transparent, + color: OudsTheme.of( + context, + ).colorScheme(context).borderFocusInset, width: borderTokens.widthFocusInset, ), borderRadius: BorderRadius.circular( @@ -357,30 +377,34 @@ class _OudsFilterChipState extends State { alignment: Alignment.center, children: [ // Border interior + content - Container( - decoration: BoxDecoration( - border: OudsBorder().borderAll( - color: _isFocused - ? OudsTheme.of( - context, - ).colorScheme(context).borderFocusInset - : Colors.transparent, - width: borderTokens.widthFocusInset, - ), + OudsSkeletonLayout( + visible: widget.skeleton != null, + securityMargin: + widget.skeleton?.securityMargin ?? false, + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( OudsTheme.of( context, ).componentsTokens(context).chip.borderRadius, ), ), - child: _buildLayout( - context, - chipBorderModifier, - chipIconColorModifier, - chipBgColorModifier, - chipTextColorModifier, - chipState, - isDisabled, + content: (context) => Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + OudsTheme.of( + context, + ).componentsTokens(context).chip.borderRadius, + ), + ), + child: _buildLayout( + context, + chipBorderModifier, + chipIconColorModifier, + chipBgColorModifier, + chipTextColorModifier, + chipState, + isDisabled, + ), ), ), ], diff --git a/ouds_core/lib/components/chip/ouds_suggestion_chip.dart b/ouds_core/lib/components/chip/ouds_suggestion_chip.dart index 694b50c9f..536c7732e 100644 --- a/ouds_core/lib/components/chip/ouds_suggestion_chip.dart +++ b/ouds_core/lib/components/chip/ouds_suggestion_chip.dart @@ -23,6 +23,8 @@ import 'package:ouds_core/components/chip/internal/ouds_chip_text_style_modifier import 'package:ouds_core/components/common/OudsBorder.dart'; import 'package:ouds_core/components/common/ouds_icon.dart'; import 'package:ouds_core/components/control/internal/interaction/ouds_inherited_interaction_model.dart'; +import 'package:ouds_core/components/skeleton/internal/ouds_skeleton_layout.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.dart'; import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; @@ -49,6 +51,7 @@ enum OudsChipStyle { defaultStyle, selected } /// - [label]: Label displayed in the suggestion chip which describes the chip option. /// - [icon]: Icon displayed in the suggestion chip. Use an icon to add additional affordance where the icon has a clear and well-established meaning. /// - [onPressed]: Callback invoked when the suggestion chip is clicked. +/// - [skeleton] : An optional skeleton that improves the perceived loading time by providing a visual cue of where the suggestion chip will appear once fully loaded. /// /// ### You can use [OudsSuggestionChip] component in your project, customizing parameters as needed : /// @@ -73,6 +76,16 @@ enum OudsChipStyle { defaultStyle, selected } /// ) /// ``` /// +/// **Text only suggestion chip with Skeleton :** +/// +/// ```dart +/// OudsSuggestionChip( +/// label: 'Label', +/// onPressed: () {}, +/// skeleton: OudsSkeleton(), +/// ); +/// ``` +/// class OudsSuggestionChip extends StatefulWidget { final String? label; @Deprecated( @@ -85,6 +98,7 @@ class OudsSuggestionChip extends StatefulWidget { ) final String? contentDescription; final VoidCallback? onPressed; + final OudsSkeleton? skeleton; /// Creates a text-only [OudsSuggestionChip]. /// @@ -101,6 +115,7 @@ class OudsSuggestionChip extends StatefulWidget { ) this.contentDescription, this.onPressed, + this.skeleton, }) : icon = null; /// Creates an [OudsSuggestionChip] with a text and an icon. @@ -112,6 +127,7 @@ class OudsSuggestionChip extends StatefulWidget { this.label, this.icon, this.onPressed, + this.skeleton, }) : avatar = null, contentDescription = null; @@ -191,6 +207,9 @@ class _OudsSuggestionChipState extends State { context, InteractionAspect.pressed, ); + if (widget.skeleton != null) { + _isFocused = false; + } final isHovered = interactionModelHover?.state.isHovered ?? false; final isPressed = interactionModelPressed?.state.isPressed ?? false; final chipStateDeterminer = OudsChipControlStateDeterminer( @@ -292,30 +311,41 @@ class _OudsSuggestionChipState extends State { ), ), // Border interior + content - Container( - decoration: BoxDecoration( - border: OudsBorder().borderAll( - color: _isFocused - ? OudsTheme.of( - context, - ).colorScheme(context).borderFocusInset - : Colors.transparent, - width: borderTokens.widthFocusInset, - ), + OudsSkeletonLayout( + visible: widget.skeleton != null, + securityMargin: widget.skeleton?.securityMargin ?? false, + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( OudsTheme.of( context, ).componentsTokens(context).chip.borderRadius, ), ), - child: _buildLayout( - context, - chipBorderModifier, - chipIconColorModifier, - chipBgColorModifier, - chipTextColorModifier, - chipState, - isDisabled, + content: (context) => Container( + decoration: BoxDecoration( + border: OudsBorder().borderAll( + color: _isFocused + ? OudsTheme.of( + context, + ).colorScheme(context).borderFocusInset + : Colors.transparent, + width: borderTokens.widthFocusInset, + ), + borderRadius: BorderRadius.circular( + OudsTheme.of( + context, + ).componentsTokens(context).chip.borderRadius, + ), + ), + child: _buildLayout( + context, + chipBorderModifier, + chipIconColorModifier, + chipBgColorModifier, + chipTextColorModifier, + chipState, + isDisabled, + ), ), ), ], diff --git a/ouds_core/lib/components/skeleton/internal/ouds_skeleton_layout.dart b/ouds_core/lib/components/skeleton/internal/ouds_skeleton_layout.dart new file mode 100644 index 000000000..107009334 --- /dev/null +++ b/ouds_core/lib/components/skeleton/internal/ouds_skeleton_layout.dart @@ -0,0 +1,95 @@ +// +// 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_core/components/skeleton/ouds_skeleton.dart'; + +/// Lays out [content] as usual so it keeps dictating the natural width/height/shape a component +/// would take, then — when [visible] is `true` — hides that content and overlays an +/// [OudsSkeleton] that exactly fills the same space, clipped to [shape]. +/// +/// This lets any OUDS component expose a "skeleton" state without hardcoding its own +/// width/height tokens for the skeleton: whatever size the component would normally render at +/// (fixed, intrinsic, or driven by parent constraints) is automatically reused. +/// +/// ```dart +/// OudsSkeletonLayout( +/// visible: state == OudsButtonControlState.skeleton, +/// shape: const RoundedRectangleBorder( +/// borderRadius: BorderRadius.all(Radius.circular(8)), +/// ), +/// content: (context) => _ActualButtonContent(), +/// ) +/// ``` +class OudsSkeletonLayout extends StatelessWidget { + /// Whether the skeleton should be displayed instead of [content]. + final bool visible; + + /// Whether to apply vertical padding to the skeleton. Defaults to `true`. + final bool securityMargin; + + /// The shape used to clip the skeleton so it matches the content's own shape (e.g. a button's + /// rounded corners). Defaults to a plain rectangle. + final ShapeBorder shape; + + /// Builds the actual component content whose size/shape the skeleton should match. + final WidgetBuilder content; + + /// Whether the shimmer sweep animation is enabled. Defaults to `true`. + final bool animated; + + /// Creates an [OudsSkeletonLayout]. + const OudsSkeletonLayout({ + super.key, + required this.visible, + required this.content, + this.securityMargin = true, + this.shape = const RoundedRectangleBorder(), + this.animated = true, + }); + + @override + Widget build(BuildContext context) { + if (!visible) { + return content(context); + } + + return Stack( + alignment: Alignment.center, + children: [ + // Keep the content in the tree -invisible but laid out- so it keeps driving the + // overall size, without being interactive, announced or painted. + Visibility( + visible: false, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: IgnorePointer( + child: ExcludeSemantics(child: content(context)), + ), + ), + Positioned.fill( + child: ClipPath( + clipper: ShapeBorderClipper(shape: shape), + child: OudsSkeleton( + securityMargin: securityMargin, + animated: animated, + ), + ), + ), + ], + ); + } +} diff --git a/ouds_core/lib/components/skeleton/ouds_skeleton.dart b/ouds_core/lib/components/skeleton/ouds_skeleton.dart index dd72e7122..3ba01acc5 100644 --- a/ouds_core/lib/components/skeleton/ouds_skeleton.dart +++ b/ouds_core/lib/components/skeleton/ouds_skeleton.dart @@ -56,7 +56,7 @@ class OudsSkeleton extends StatefulWidget { const OudsSkeleton({ super.key, this.animated = true, - this.securityMargin = true, + this.securityMargin = false, }); @override @@ -65,15 +65,25 @@ class OudsSkeleton extends StatefulWidget { class _OudsSkeletonState extends State with SingleTickerProviderStateMixin { - static const _shimmerDuration = Duration(milliseconds: 1500); + // The shimmer sweeps across the skeleton in 800ms, then holds at the end position for 450ms + // before the next sweep starts, giving the animation a brief pause between each pass. + static const _shimmerMoveDuration = Duration(milliseconds: 800); + static const _shimmerPauseDuration = Duration(milliseconds: 450); + static final _shimmerTotalDuration = + _shimmerMoveDuration + _shimmerPauseDuration; + static final _shimmerMoveFraction = + _shimmerMoveDuration.inMilliseconds / + _shimmerTotalDuration.inMilliseconds; late final AnimationController _controller; @override void initState() { super.initState(); - _controller = AnimationController(vsync: this, duration: _shimmerDuration) - ..repeat(); + _controller = AnimationController( + vsync: this, + duration: _shimmerTotalDuration, + )..repeat(); } @override @@ -105,62 +115,68 @@ class _OudsSkeletonState extends State ).spaceScheme(context).paddingBlockThreeExtraSmall; final shouldAnimate = widget.animated && !_shouldDisableAnimations(context); - final placeholder = Container( - padding: widget.securityMargin - ? EdgeInsets.symmetric(vertical: padding) - : EdgeInsets.zero, + // The shimmer is a dedicated gradient layer drawn on top of the background and translated + // from fully off-screen left to fully off-screen right, clipped to the skeleton's bounds. + // Using a separate layer with normal alpha blending (instead of a ShaderMask blend mode) + // keeps the sweep clearly visible regardless of how subtle the gradient token colors are. + final shimmer = LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + return AnimatedBuilder( + animation: _controller, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + tokens.colorGradientStartEnd, + tokens.colorGradientMiddle, + tokens.colorGradientStartEnd, + ], + stops: const [0.0, 0.5, 1.0], + ), + ), + ), + builder: (context, child) { + // Ease the sweep across the move fraction of the cycle, then hold it in place + // (Interval clamps to the curve's end value) for the remaining pause fraction. + final progress = Interval( + 0.0, + _shimmerMoveFraction, + curve: Curves.easeInOut, + ).transform(_controller.value); + final dx = -maxWidth + progress * 2 * maxWidth; + return Transform.translate(offset: Offset(dx, 0), child: child); + }, + ); + }, + ); + + final content = SizedBox( width: 200, height: 62, - color: tokens.colorBg, + child: ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: tokens.colorBg), + if (shouldAnimate) shimmer, + ], + ), + ), ); - final content = shouldAnimate - ? AnimatedBuilder( - animation: _controller, - child: placeholder, - builder: (context, child) { - return ShaderMask( - blendMode: BlendMode.srcATop, - shaderCallback: (bounds) => LinearGradient( - colors: [ - tokens.colorGradientStartEnd, - tokens.colorGradientMiddle, - tokens.colorGradientStartEnd, - ], - stops: const [0.0, 0.5, 1.0], - begin: Alignment.centerLeft, - end: Alignment.centerRight, - transform: _OudsSkeletonShimmerTransform( - slidePercent: _controller.value, - ), - ).createShader(bounds), - child: child, - ); - }, - ) - : placeholder; - return Semantics( label: OudsLocalizations.of(context)?.core_common_loading_a11y, - child: ExcludeSemantics(child: content), - ); - } -} - -/// Slides the shimmer gradient horizontally across the skeleton's bounds as [slidePercent] goes -/// from `0.0` to `1.0`. The gradient enters fully off-screen on one side and exits fully -/// off-screen on the other, so the sweep loops smoothly when the driving animation repeats. -class _OudsSkeletonShimmerTransform extends GradientTransform { - const _OudsSkeletonShimmerTransform({required this.slidePercent}); - - final double slidePercent; - - @override - Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { - return Matrix4.translationValues( - bounds.width * (slidePercent * 3 - 1.5), - 0.0, - 0.0, + child: ExcludeSemantics( + child: Padding( + padding: widget.securityMargin + ? EdgeInsets.symmetric(vertical: padding) + : EdgeInsets.zero, + child: content, + ), + ), ); } } diff --git a/ouds_core/test/components/skeleton/internal/ouds_skeleton_layout_test.dart b/ouds_core/test/components/skeleton/internal/ouds_skeleton_layout_test.dart new file mode 100644 index 000000000..7c4a01e16 --- /dev/null +++ b/ouds_core/test/components/skeleton/internal/ouds_skeleton_layout_test.dart @@ -0,0 +1,89 @@ +// +// 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:flutter_test/flutter_test.dart'; +import 'package:ouds_core/components/skeleton/internal/ouds_skeleton_layout.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.dart'; + +import '../../../helpers/testable_widget_helper.dart'; + +void main() { + group('OudsSkeletonLayout', () { + testWidgets('renders the content directly when not visible', ( + tester, + ) async { + await tester.pumpWidget( + testableWidget( + OudsSkeletonLayout( + visible: false, + content: (context) => const Text('content'), + ), + ), + ); + + expect(find.text('content'), findsOneWidget); + expect(find.byType(OudsSkeleton), findsNothing); + }); + + testWidgets('overlays a skeleton matching the content size when visible', ( + tester, + ) async { + await tester.pumpWidget( + testableWidget( + OudsSkeletonLayout( + visible: true, + content: (context) => + const SizedBox(width: 150, height: 40, child: Text('x')), + ), + ), + ); + + expect(find.byType(OudsSkeleton), findsOneWidget); + final size = tester.getSize(find.byType(OudsSkeletonLayout)); + expect(size.width, 150); + expect(size.height, 40); + }); + + testWidgets('hides the content from semantics and touch when visible', ( + tester, + ) async { + await tester.pumpWidget( + testableWidget( + OudsSkeletonLayout( + visible: true, + content: (context) => const SizedBox( + width: 100, + height: 30, + child: Text('hidden content'), + ), + ), + ), + ); + + expect( + find.descendant( + of: find.byType(OudsSkeletonLayout), + matching: find.byType(IgnorePointer), + ), + findsWidgets, + ); + expect( + find.descendant( + of: find.byType(OudsSkeletonLayout), + matching: find.byType(ExcludeSemantics), + ), + findsWidgets, + ); + }); + }); +} diff --git a/ouds_core/test/components/skeleton/ouds_skeleton_test.dart b/ouds_core/test/components/skeleton/ouds_skeleton_test.dart index 5cf8db420..7eeac2c78 100644 --- a/ouds_core/test/components/skeleton/ouds_skeleton_test.dart +++ b/ouds_core/test/components/skeleton/ouds_skeleton_test.dart @@ -39,11 +39,15 @@ void main() { ), ); - expect(find.byType(ShaderMask), findsOneWidget); + final shimmerFinder = find.descendant( + of: find.byType(OudsSkeleton), + matching: find.byType(Transform), + ); + expect(shimmerFinder, findsOneWidget); - // Pumping the animation should not throw and should keep the shader mask mounted. + // Pumping the animation should not throw and should keep the shimmer layer mounted. await tester.pump(const Duration(milliseconds: 200)); - expect(find.byType(ShaderMask), findsOneWidget); + expect(shimmerFinder, findsOneWidget); }); testWidgets('renders a static placeholder when animated is false', ( @@ -59,7 +63,13 @@ void main() { ), ); - expect(find.byType(ShaderMask), findsNothing); + expect( + find.descendant( + of: find.byType(OudsSkeleton), + matching: find.byType(Transform), + ), + findsNothing, + ); }); testWidgets( @@ -78,7 +88,13 @@ void main() { ), ); - expect(find.byType(ShaderMask), findsNothing); + expect( + find.descendant( + of: find.byType(OudsSkeleton), + matching: find.byType(Transform), + ), + findsNothing, + ); }, ); From cdc4d13b4e4cf35646b418ba7cc4cbfdfb9bd112 Mon Sep 17 00:00:00 2001 From: nhammami Date: Fri, 2 Oct 2026 15:21:17 +0100 Subject: [PATCH 3/4] feat(skeleton): add skeleton in Alert components (lib and demo) --- .../alert/alert_code_generator.dart | 15 ++++ .../alert/alert_message_demo_screen.dart | 9 +++ .../alert/inline_alert_demo_screen.dart | 10 +++ .../components/alert/ouds_alert_message.dart | 51 ++++++++---- .../components/alert/ouds_inline_alert.dart | 77 +++++++++++-------- 5 files changed, 115 insertions(+), 47 deletions(-) diff --git a/app/lib/ui/components/alert/alert_code_generator.dart b/app/lib/ui/components/alert/alert_code_generator.dart index a6d71fe65..2bbdf058a 100644 --- a/app/lib/ui/components/alert/alert_code_generator.dart +++ b/app/lib/ui/components/alert/alert_code_generator.dart @@ -75,6 +75,10 @@ class AlertCodeGenerator { if (customization.hasCloseButton) { buffer.writeln(' onClose: (){},'); } + final skeleton = _skeletonCode(customization); + if (skeleton.isNotEmpty) { + buffer.writeln(' $skeleton'); + } buffer.write(')'); @@ -99,6 +103,10 @@ class AlertCodeGenerator { if (status != null) { buffer.writeln(' status: $status,'); } + final skeleton = _skeletonCode(customization); + if (skeleton.isNotEmpty) { + buffer.writeln(' $skeleton'); + } buffer.write(')'); @@ -126,4 +134,11 @@ class AlertCodeGenerator { return "Neutral(${customization.hasIconStatus ? 'icon: ${customization.isTinted ? 'AppAssets.icons.assistanceTipsAndTricks' : 'AppAssets.icons.icUntintedSquare'} , tinted: ${customization.isTinted}' : ''})"; } } + + /// Method to generate the skeleton code + static String _skeletonCode(AlertCustomizationState customizationState) { + return customizationState.hasSkeleton == true + ? 'skeleton: OudsSkeleton(),' + : ''; + } } diff --git a/app/lib/ui/components/alert/alert_message_demo_screen.dart b/app/lib/ui/components/alert/alert_message_demo_screen.dart index 6005a32b7..499734a4f 100644 --- a/app/lib/ui/components/alert/alert_message_demo_screen.dart +++ b/app/lib/ui/components/alert/alert_message_demo_screen.dart @@ -13,6 +13,7 @@ import 'package:flutter/material.dart'; import 'package:ouds_core/components/alert/ouds_alert_message.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.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/alert/alert_code_generator.dart'; @@ -137,6 +138,7 @@ class _AlertMessageDemoState extends State<_AlertMessageDemo> { onDescriptionLinkTapped: (link) async { await launchUrl(Uri.parse(link)); }, + skeleton: customizationState.hasSkeleton ? OudsSkeleton() : null, ), ); } @@ -312,6 +314,13 @@ class _CustomizationContentState extends State<_CustomizationContent> { focusNode: bulletThreeFocus, fieldType: FieldType.bulletThree, ), + CustomizableSwitch( + title: context.l10n.app_components_common_skeleton_tech, + value: customizationState.hasSkeleton, + onChanged: (value) { + customizationState.hasSkeleton = value; + }, + ), ], ); } diff --git a/app/lib/ui/components/alert/inline_alert_demo_screen.dart b/app/lib/ui/components/alert/inline_alert_demo_screen.dart index 195ecef1d..bdcaaf007 100644 --- a/app/lib/ui/components/alert/inline_alert_demo_screen.dart +++ b/app/lib/ui/components/alert/inline_alert_demo_screen.dart @@ -13,6 +13,7 @@ import 'package:flutter/material.dart'; import 'package:ouds_core/components/alert/ouds_inline_alert.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.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/alert/alert_code_generator.dart'; @@ -24,6 +25,7 @@ import 'package:ouds_flutter_demo/ui/utilities/component/status_enum.dart'; import 'package:ouds_flutter_demo/ui/utilities/customizable/customizable_chips.dart'; import 'package:ouds_flutter_demo/ui/utilities/customizable/customizable_dropdown_menu.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/customizable/tinted_enum.dart'; import 'package:ouds_flutter_demo/ui/utilities/detail_screen_header.dart'; @@ -117,6 +119,7 @@ class _InlineAlertDemoState extends State<_InlineAlertDemo> { customizationState, themeController, ), + skeleton: customizationState.hasSkeleton ? OudsSkeleton() : null, ), ); } @@ -196,6 +199,13 @@ class _CustomizationContentState extends State<_CustomizationContent> { focusNode: labelFocus, fieldType: FieldType.label, ), + CustomizableSwitch( + title: context.l10n.app_components_common_skeleton_tech, + value: customizationState.hasSkeleton, + onChanged: (value) { + customizationState.hasSkeleton = value; + }, + ), ], ); } diff --git a/ouds_core/lib/components/alert/ouds_alert_message.dart b/ouds_core/lib/components/alert/ouds_alert_message.dart index 4d10cc707..062d97169 100644 --- a/ouds_core/lib/components/alert/ouds_alert_message.dart +++ b/ouds_core/lib/components/alert/ouds_alert_message.dart @@ -24,6 +24,8 @@ import 'package:ouds_core/components/common/OudsBorder.dart'; import 'package:ouds_core/components/common/ouds_icon.dart'; import 'package:ouds_core/components/common/ouds_icon_status.dart'; import 'package:ouds_core/components/link/ouds_link.dart'; +import 'package:ouds_core/components/skeleton/internal/ouds_skeleton_layout.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.dart'; import 'package:ouds_core/components/utilities/app_assets.dart'; import 'package:ouds_core/components/utilities/markdown_span_builder.dart'; import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; @@ -123,6 +125,7 @@ class OudsAlertMessageActionLayout { /// Each bullet should remain short, clear and easy to scan. Avoid long sentences or complex structures. /// Supports lightweight inline markdown formatting for text emphasis : /// - Strong text `**bold**`. +/// - [skeleton]: An optional skeleton that improves the perceived loading time by providing a visual cue of where the alert message will appear once fully loaded. /// /// ## Usage Example: /// @@ -134,6 +137,15 @@ class OudsAlertMessageActionLayout { /// ) /// ``` /// +/// ```dart +/// OudsAlertMessage( +/// label: 'Your profile has been updated.', +/// status: Positive(), +/// onClose: () {}, +/// skeleton: OudsSkeleton(), +/// ) +/// ``` +/// class OudsAlertMessage extends StatefulWidget { /// Creates an OudsAlertMessage. const OudsAlertMessage({ @@ -145,6 +157,7 @@ class OudsAlertMessage extends StatefulWidget { this.onDescriptionLinkTapped, this.actionLayout, this.bulletList, + this.skeleton, }); /// The main message displayed in the alert. @@ -178,6 +191,9 @@ class OudsAlertMessage extends StatefulWidget { /// An optional list of bullet points to display below the main content. final List? bulletList; + /// An optional skeleton that improves the perceived loading time by providing a visual cue of where the alert message will appear once fully loaded. + final OudsSkeleton? skeleton; + @override State createState() => _OudsAlertMessageState(); } @@ -387,23 +403,30 @@ class _OudsAlertMessageState extends State { // Wrap the entire component in a Semantics widget for accessibility // and a decorated Container for styling. - return Container( - constraints: BoxConstraints( - minWidth: alertTokens.sizeMinWidth, - minHeight: alertTokens.sizeMinHeight, - ), - padding: EdgeInsetsDirectional.only( - start: alertTokens.spacePaddingInline, + return OudsSkeletonLayout( + visible: widget.skeleton != null, + securityMargin: widget.skeleton?.securityMargin ?? false, + shape: RoundedRectangleBorder( + borderRadius: OudsAlertMessageBorderModifier.getBorderRadius(context), ), - decoration: BoxDecoration( - border: OudsBorder().borderAll( - width: alertMessageTokens.borderWidth, - color: Colors.transparent, + content: (context) => Container( + constraints: BoxConstraints( + minWidth: alertTokens.sizeMinWidth, + minHeight: alertTokens.sizeMinHeight, ), - borderRadius: OudsAlertMessageBorderModifier.getBorderRadius(context), - color: alertMessageStatusModifier.getStatusColor(widget.status), + padding: EdgeInsetsDirectional.only( + start: alertTokens.spacePaddingInline, + ), + decoration: BoxDecoration( + border: OudsBorder().borderAll( + width: alertMessageTokens.borderWidth, + color: Colors.transparent, + ), + borderRadius: OudsAlertMessageBorderModifier.getBorderRadius(context), + color: alertMessageStatusModifier.getStatusColor(widget.status), + ), + child: alertContent, ), - child: alertContent, ); } diff --git a/ouds_core/lib/components/alert/ouds_inline_alert.dart b/ouds_core/lib/components/alert/ouds_inline_alert.dart index 623aa477e..3b76d2dc6 100644 --- a/ouds_core/lib/components/alert/ouds_inline_alert.dart +++ b/ouds_core/lib/components/alert/ouds_inline_alert.dart @@ -16,6 +16,8 @@ library; import 'package:flutter/material.dart'; import 'package:ouds_core/components/alert/internal/ouds_alert_status_modifier.dart'; import 'package:ouds_core/components/common/ouds_icon_status.dart'; +import 'package:ouds_core/components/skeleton/internal/ouds_skeleton_layout.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.dart'; import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; @@ -39,6 +41,7 @@ import 'package:ouds_theme_contract/ouds_theme.dart'; /// Each variant conveys a clear semantic meaning and must always be paired with its dedicated functional icon to ensure clarity and accessibility. /// Use functional alerts to inform user about state changes, confirmations, or issues that are directly connected to system logic or user actions. These /// messages carry functional meaning and help guide user response or acknowledgment. +/// - [skeleton]: An optional skeleton that improves the perceived loading time by providing a visual cue of where the inline message will appear once fully loaded. /// class OudsInlineAlert extends StatefulWidget { /// Creates an OudsInlineAlert. @@ -46,6 +49,7 @@ class OudsInlineAlert extends StatefulWidget { super.key, required this.label, this.status = const Neutral(), + this.skeleton, }); /// The main message displayed in the alert. @@ -54,6 +58,9 @@ class OudsInlineAlert extends StatefulWidget { /// The status of the alert, which determines its background color and icon. final OudsIconStatus status; + /// An optional skeleton that improves the perceived loading time by providing a visual cue of where the inline message will appear once fully loaded. + final OudsSkeleton? skeleton; + @override State createState() => _OudsInlineAlertState(); } @@ -65,43 +72,47 @@ class _OudsInlineAlertState extends State { final alertTokens = theme.componentsTokens(context).alert; final alertStatusModifier = OudsAlertStatusModifier(context); - return Semantics( - label: widget.status is Warning - ? OudsLocalizations.of(context)?.core_common_warning_a11y - : widget.status is Negative - ? OudsLocalizations.of(context)?.core_common_error_a11y - : null, - container: true, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: alertTokens.spaceColumnGap, - children: [ - Container( - color: widget.status.getBackgroundColor, - child: alertStatusModifier.buildStatusIcon( - context, - widget.status, - null, - ), - ), - Expanded( - child: Container( - constraints: BoxConstraints( - maxWidth: theme.sizeScheme(context).maxWidthLabelLarge, + return OudsSkeletonLayout( + visible: widget.skeleton != null, + securityMargin: widget.skeleton?.securityMargin ?? false, + content: (context) => Semantics( + label: widget.status is Warning + ? OudsLocalizations.of(context)?.core_common_warning_a11y + : widget.status is Negative + ? OudsLocalizations.of(context)?.core_common_error_a11y + : null, + container: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: alertTokens.spaceColumnGap, + children: [ + Container( + color: widget.status.getBackgroundColor, + child: alertStatusModifier.buildStatusIcon( + context, + widget.status, + null, ), - child: Text( - widget.label, - style: theme.typographyTokens - .typeLabelModerateLarge(context) - .copyWith( - color: alertStatusModifier.getInlineAlertTextColor( - widget.status, + ), + Expanded( + child: Container( + constraints: BoxConstraints( + maxWidth: theme.sizeScheme(context).maxWidthLabelLarge, + ), + child: Text( + widget.label, + style: theme.typographyTokens + .typeLabelModerateLarge(context) + .copyWith( + color: alertStatusModifier.getInlineAlertTextColor( + widget.status, + ), ), - ), + ), ), ), - ), - ], + ], + ), ), ); } From e0f5eb22bbd54572cc7409554803863a24313eeb Mon Sep 17 00:00:00 2001 From: nhammami Date: Fri, 2 Oct 2026 16:25:56 +0100 Subject: [PATCH 4/4] feat(skeleton): add skeleton in Button components (lib and demo) --- .../button/button_code_generator.dart | 28 +++++++++---- .../components/button/button_demo_screen.dart | 13 ++++++ .../navigation_button_code_generator.dart | 25 +++++++---- .../button/navigation_button_demo_screen.dart | 14 +++++++ .../lib/components/button/ouds_button.dart | 41 ++++++++++++++++++- 5 files changed, 103 insertions(+), 18 deletions(-) diff --git a/app/lib/ui/components/button/button_code_generator.dart b/app/lib/ui/components/button/button_code_generator.dart index e45634414..0eadd16e6 100644 --- a/app/lib/ui/components/button/button_code_generator.dart +++ b/app/lib/ui/components/button/button_code_generator.dart @@ -50,17 +50,17 @@ class ButtonCodeGenerator { switch (layout) { case OudsButtonLayout.textOnly: code = - """${coloredSurfaceCodeModifier(context)}$buttonSizeConstructor(\nlabel: "$label",\nappearance: ${appearance.toString()},${fullWidthCodeModifier(context)}${loaderCodeModifier(context)}\n${disableCode(context)}"""; + """${coloredSurfaceCodeModifier(context)}$buttonSizeConstructor(\n label: "$label",\n appearance: ${appearance.toString()},${fullWidthCodeModifier(context)}${loaderCodeModifier(context)}\n${skeletonCode(context)}\n${disableCode(context)}"""; break; case OudsButtonLayout.iconOnly: code = - """${coloredSurfaceCodeModifier(context)}$buttonSizeConstructor(\nicon: ${iconCode(context)},\nappearance: ${appearance.toString()},${fullWidthCodeModifier(context)}${loaderCodeModifier(context)}\n${disableCode(context)}"""; + """${coloredSurfaceCodeModifier(context)}$buttonSizeConstructor(\n icon: ${iconCode(context)},\n appearance: ${appearance.toString()},${fullWidthCodeModifier(context)}${loaderCodeModifier(context)}\n${skeletonCode(context)}\n${disableCode(context)}\n${skeletonCode(context)}"""; break; case OudsButtonLayout.iconAndText: code = - """${coloredSurfaceCodeModifier(context)}$buttonSizeConstructor(\nicon: ${iconCode(context)},\nlabel: "$label",\nappearance: ${appearance.toString()},${fullWidthCodeModifier(context)}${loaderCodeModifier(context)}\n${disableCode(context)}"""; + """${coloredSurfaceCodeModifier(context)}$buttonSizeConstructor(\nicon: ${iconCode(context)},\n label: "$label",\n appearance: ${appearance.toString()},${fullWidthCodeModifier(context)}${loaderCodeModifier(context)}\n${skeletonCode(context)}\n${disableCode(context)}\n${skeletonCode(context)}"""; break; } @@ -76,7 +76,7 @@ class ButtonCodeGenerator { String end = customizationState?.hasOnColoredBox == true ? " ),\n);" : ");"; // Return the onPressed callback code with its enabled or disabled state - return "onPressed: ${customizationState?.hasEnabled == true ? "() {}" : 'null'},\n$end"; + return " onPressed: ${customizationState?.hasEnabled == true ? "() {}" : 'null'},\n$end"; } static String loaderCodeModifier(BuildContext context) { @@ -84,7 +84,7 @@ class ButtonCodeGenerator { context, ); if (customizationState?.hasLoader == true) { - return "\nisLoading: true,"; + return "\n isLoading: true,"; } else { return ""; } @@ -95,7 +95,7 @@ class ButtonCodeGenerator { context, ); if (customizationState?.hasFullWidth == true) { - return "\nisFullWidth: ${customizationState?.hasFullWidth},"; + return "\n isFullWidth: ${customizationState?.hasFullWidth},"; } else { return ""; } @@ -109,8 +109,8 @@ class ButtonCodeGenerator { ); final bool isTinted = customizationState?.isTinted ?? true; return isTinted - ? "OudsIcon('AppAssets.icons.assistanceTipsAndTricks')" - : "OudsIcon('AppAssets.icons.icUntintedSquare', tinted: false)"; + ? " OudsIcon('AppAssets.icons.assistanceTipsAndTricks')" + : " OudsIcon('AppAssets.icons.icUntintedSquare', tinted: false)"; } // Method to generate the constructor name based on the selected size: @@ -135,9 +135,19 @@ class ButtonCodeGenerator { // If the button should have a colored surface, wrap the button in OudsColoredBox if (customizationState?.hasOnColoredBox == true) { code = - '''OudsColoredBox(\ncolor: OudsColoredBoxColor.brandPrimary,\nchild: '''; + '''OudsColoredBox(\n color: OudsColoredBoxColor.brandPrimary,\n child: '''; } return code; } + + // Method to generate the skeleton code + static String skeletonCode(BuildContext context) { + final ButtonCustomizationState? customizationState = ButtonCustomization.of( + context, + ); + return customizationState?.hasSkeleton == true + ? ' skeleton: OudsSkeleton(),' + : ''; + } } diff --git a/app/lib/ui/components/button/button_demo_screen.dart b/app/lib/ui/components/button/button_demo_screen.dart index 7abbbe65e..6b2da1dd5 100644 --- a/app/lib/ui/components/button/button_demo_screen.dart +++ b/app/lib/ui/components/button/button_demo_screen.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:ouds_core/components/button/ouds_button.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.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/button/button_code_generator.dart'; @@ -127,6 +128,9 @@ class _ButtonDemoState extends State<_ButtonDemo> { final loader = ButtonCustomizationUtils.getLoader(customizationState); final onPressed = customizationState?.hasEnabled == true ? () {} : null; final isFullWidth = customizationState?.hasFullWidth; + final skeleton = customizationState?.hasSkeleton == true + ? OudsSkeleton() + : null; return size == OudsButtonSize.small ? OudsButton.small( @@ -136,6 +140,7 @@ class _ButtonDemoState extends State<_ButtonDemo> { isLoading: loader, onPressed: onPressed, isFullWidth: isFullWidth, + skeleton: skeleton, ) : OudsButton( label: label, @@ -144,6 +149,7 @@ class _ButtonDemoState extends State<_ButtonDemo> { isLoading: loader, onPressed: onPressed, isFullWidth: isFullWidth, + skeleton: skeleton, ); } @@ -288,6 +294,13 @@ class _CustomizationContentState extends State<_CustomizationContent> { focusNode: labelFocus, fieldType: FieldType.label, ), + CustomizableSwitch( + title: context.l10n.app_components_common_skeleton_tech, + value: customizationState.hasSkeleton, + onChanged: (value) { + customizationState.hasSkeleton = value; + }, + ), ], ); } diff --git a/app/lib/ui/components/button/navigation_button_code_generator.dart b/app/lib/ui/components/button/navigation_button_code_generator.dart index f6eafe989..980bc3e4d 100644 --- a/app/lib/ui/components/button/navigation_button_code_generator.dart +++ b/app/lib/ui/components/button/navigation_button_code_generator.dart @@ -59,27 +59,28 @@ class NavigationButtonCodeGenerator { final String layoutStr = layout.toString(); final String appearanceStr = appearance.toString(); - final String labelLine = label != null ? '\nlabel: "$label",' : ''; + final String labelLine = label != null ? '\n label: "$label",' : ''; final String loaderLine = _loaderCodeModifier(customizationState); final String fullWidthLine = _fullWidthCodeModifier(customizationState); final String onPressedLine = _disableCode(customizationState); final String coloredBoxPrefix = _coloredBoxPrefix(customizationState); final String coloredBoxSuffix = _coloredBoxSuffix(customizationState); + final String skeleton = _skeletonCode(context); return '''${coloredBoxPrefix}OudsNavigationButton($labelLine -layout: $layoutStr, -appearance: $appearanceStr,$fullWidthLine$loaderLine + layout: $layoutStr, + appearance: $appearanceStr,$fullWidthLine$loaderLine$skeleton $onPressedLine$coloredBoxSuffix'''; } /// Returns the `loader:` line when the loader is enabled. static String _loaderCodeModifier(ButtonCustomizationState state) { - return state.hasLoader ? '\nloader: Loader(progress: null),' : ''; + return state.hasLoader ? '\n loader: Loader(progress: null),' : ''; } /// Returns the `isFullWidth:` line when full-width is enabled. static String _fullWidthCodeModifier(ButtonCustomizationState state) { - return state.hasFullWidth ? '\nisFullWidth: true,' : ''; + return state.hasFullWidth ? '\n isFullWidth: true,' : ''; } /// Returns the `onPressed:` line with enabled or disabled callback. @@ -89,17 +90,27 @@ $onPressedLine$coloredBoxSuffix'''; static String _disableCode(ButtonCustomizationState state) { final String end = state.hasOnColoredBox ? ' ),\n);' : ');'; final String callback = state.hasEnabled == true ? '() {}' : 'null'; - return 'onPressed: $callback,\n$end'; + return ' onPressed: $callback,\n$end'; } /// Returns the `OudsColoredBox(` opening wrapper when the colored box is enabled. static String _coloredBoxPrefix(ButtonCustomizationState state) { if (state.hasOnColoredBox) { - return 'OudsColoredBox(\ncolor: OudsColoredBoxColor.brandPrimary,\nchild: '; + return 'OudsColoredBox(\n color: OudsColoredBoxColor.brandPrimary,\n child: '; } return ''; } /// Returns an empty string (closing is handled inside [_disableCode]). static String _coloredBoxSuffix(ButtonCustomizationState state) => ''; + + /// Method to generate the skeleton code + static String _skeletonCode(BuildContext context) { + final ButtonCustomizationState? customizationState = ButtonCustomization.of( + context, + ); + return customizationState?.hasSkeleton == true + ? ' skeleton: OudsSkeleton(),' + : ''; + } } diff --git a/app/lib/ui/components/button/navigation_button_demo_screen.dart b/app/lib/ui/components/button/navigation_button_demo_screen.dart index 2b8ac983a..d5e8256f1 100644 --- a/app/lib/ui/components/button/navigation_button_demo_screen.dart +++ b/app/lib/ui/components/button/navigation_button_demo_screen.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:ouds_core/components/button/ouds_button.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.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/button/button_customization.dart'; @@ -137,6 +138,9 @@ class _NavigationButtonDemoState extends State<_NavigationButtonDemo> { context, customizationState, ), + skeleton: customizationState?.hasSkeleton == true + ? OudsSkeleton() + : null, ), ); } else { @@ -159,6 +163,9 @@ class _NavigationButtonDemoState extends State<_NavigationButtonDemo> { context, customizationState, ), + skeleton: customizationState?.hasSkeleton == true + ? OudsSkeleton() + : null, ), ); } @@ -262,6 +269,13 @@ class _CustomizationContentState extends State<_CustomizationContent> { focusNode: labelFocus, fieldType: FieldType.label, ), + CustomizableSwitch( + title: context.l10n.app_components_common_skeleton_tech, + value: customizationState.hasSkeleton, + onChanged: (value) { + customizationState.hasSkeleton = value; + }, + ), ], ); } diff --git a/ouds_core/lib/components/button/ouds_button.dart b/ouds_core/lib/components/button/ouds_button.dart index 30d84bc41..72745189e 100644 --- a/ouds_core/lib/components/button/ouds_button.dart +++ b/ouds_core/lib/components/button/ouds_button.dart @@ -25,6 +25,8 @@ import 'package:ouds_core/components/button/internal/ouds_button_utils.dart'; import 'package:ouds_core/components/common/OudsBorder.dart'; import 'package:ouds_core/components/common/ouds_icon.dart'; import 'package:ouds_core/components/progress_indicator/ouds_progress_indicator.dart'; +import 'package:ouds_core/components/skeleton/internal/ouds_skeleton_layout.dart'; +import 'package:ouds_core/components/skeleton/ouds_skeleton.dart'; import 'package:ouds_core/components/top_bar/ouds_top_bar.dart'; import 'package:ouds_core/components/utilities/app_assets.dart'; import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; @@ -126,6 +128,7 @@ enum OudsButtonComponent { /// must be provided. For instance, suppose a package called `my_icons` has /// `icons/heart.svg` . /// - [isFullWidth]: Flag to let button take all the screen width, set to *false* by default. +/// - [skeleton]: An optional skeleton that improves the perceived loading time by providing a visual cue of where the button will appear once fully loaded. /// /// ## Usage example : /// @@ -141,6 +144,7 @@ enum OudsButtonComponent { /// onPressed: () { /// // Handle button tap. /// }, +/// skeleton: OudsSkeleton(), /// ); /// ``` /// @@ -171,6 +175,19 @@ enum OudsButtonComponent { /// ); /// ``` /// +/// +/// ### Navigation button: +/// +/// ```dart +/// OudsNavigationButton( +/// label: "Label", +/// layout: OudsNavigationButtonLayout.next, +/// appearance: OudsNavigationButtonAppearance.defaultAppearance, +/// skeleton: OudsSkeleton(), +/// onPressed: () {}, +/// ); +/// ``` +/// class OudsButton extends StatefulWidget { final String? label; @Deprecated( @@ -210,6 +227,9 @@ class OudsButton extends StatefulWidget { /// Optional badge to display on the button. final OudsTopBarActionBadge? _badge; + /// Optional skeleton that improves the perceived loading time by providing a visual cue of where the button will appear once fully loaded. + final OudsSkeleton? skeleton; + const OudsButton({ super.key, this.label, @@ -221,6 +241,7 @@ class OudsButton extends StatefulWidget { this.package, this.isFullWidth = false, this.tinted = true, + this.skeleton, }) : _size = OudsButtonSize.defaultSize, _component = OudsButtonComponent.defaultButton, _navigationLayout = null, @@ -252,6 +273,7 @@ class OudsButton extends StatefulWidget { this.package, this.isFullWidth = false, this.tinted = true, + this.skeleton, }) : _size = OudsButtonSize.small, _component = OudsButtonComponent.defaultButton, _navigationLayout = null, @@ -273,6 +295,7 @@ class OudsButton extends StatefulWidget { this.isLoading = false, required OudsNavigationButtonLayout navigationLayout, String? semanticsLabel, + this.skeleton, }) : _size = size, _component = OudsButtonComponent.navigationButton, _navigationLayout = navigationLayout, @@ -336,7 +359,8 @@ class OudsButton extends StatefulWidget { _badge = badge, tinted = true, isLoading = false, - loader = null; + loader = null, + skeleton = null; } class _OudsButtonState extends State { @@ -397,6 +421,9 @@ class _OudsButtonState extends State { @override Widget build(BuildContext context) { + if (widget.skeleton != null) { + _isFocused = false; + } // Determines the local visual state of the button (hovered, pressed, etc.) // using internal flags managed via a [MouseRegion] and gesture listeners. // This state is used to compute dynamic styling (e.g., background color). @@ -471,7 +498,14 @@ class _OudsButtonState extends State { ), ], ) - : _buildLayout(context, buttonState); + : OudsSkeletonLayout( + visible: widget.skeleton != null, + securityMargin: widget.skeleton?.securityMargin ?? false, + shape: RoundedRectangleBorder( + borderRadius: OudsButtonBorderModifier.getBorderRadius(context), + ), + content: (context) => _buildLayout(context, buttonState), + ); } /// Returns the label typography based on [widget._size]: [OudsButtonSize.defaultSize] @@ -1203,6 +1237,7 @@ class OudsNavigationButton extends StatelessWidget { final bool? isFullWidth; final String? semanticsLabel; final OudsButtonSize size; + final OudsSkeleton? skeleton; const OudsNavigationButton({ super.key, @@ -1216,6 +1251,7 @@ class OudsNavigationButton extends StatelessWidget { this.isFullWidth, this.semanticsLabel, this.size = OudsButtonSize.defaultSize, + this.skeleton, }); @override @@ -1243,6 +1279,7 @@ class OudsNavigationButton extends StatelessWidget { isLoading: isLoading, semanticsLabel: semanticsLabel, size: size, + skeleton: skeleton, ); }