diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index fd9e2b5bd..dc538d955 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.3.1...develop) ### Added ### Changed +- [Library] `tab bar component`, update the animation of the `selected tab indicator` ([#633](https://github.com/Orange-OpenSource/ouds-flutter/issues/633)) - [DemoApp][Library] Update `ToolBar Top`, with Badge in Trailing Actions ([#642](https://github.com/Orange-OpenSource/ouds-flutter/issues/642)) - [DemoApp][Library] update `alert message`, `switch`, `radio`, `checkbox`, `text input`, `pin code input`, `phone number input`, components to use rich text ([#782](https://github.com/Orange-OpenSource/ouds-flutter/issues/782)) - [Library] Update text input component to v1.4 ([#692](https://github.com/Orange-OpenSource/ouds-flutter/issues/692)) @@ -24,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [DemoApp][Library] update tokens 2.4.0 ([#726](https://github.com/Orange-OpenSource/ouds-flutter/issues/726)) ### Fixed +- [DemoApp][Library] `Bottom Bar` Inconsistent order of the accessible ([#625](https://github.com/Orange-OpenSource/ouds-flutter/issues/625)) +- [Library] `Bottom Bar` Overlap when zoom is activated ([#627](https://github.com/Orange-OpenSource/ouds-flutter/issues/627)) - [DemoApp] Update pubspec icons 1.6 ([#794](https://github.com/Orange-OpenSource/ouds-flutter/issues/794)) - [Library] `Password input` Label is truncated when zoom is applied ([#600](https://github.com/Orange-OpenSource/ouds-flutter/issues/600)) - [Library] `Filter chip` is not reached by keyboard focus or Switch Access focus ([#474](https://github.com/Orange-OpenSource/ouds-flutter/issues/474)) diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart index 5fdfa3847..a72cf9960 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart @@ -1314,6 +1314,18 @@ abstract class AppLocalizations { /// **'Last item badge'** String get app_components_navigationBar_lastItemBadge_label; + /// No description provided for @app_components_navigationBar_badge_count_a11y. + /// + /// In en, this message translates to: + /// **'{count, plural, =1 {1 unread notification} other {{count} unread notifications}}'** + String app_components_navigationBar_badge_count_a11y(int count); + + /// No description provided for @app_components_navigationBar_badge_dot_a11y. + /// + /// In en, this message translates to: + /// **'Unread notification'** + String get app_components_navigationBar_badge_dot_a11y; + /// No description provided for @app_components_topAppBar_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 a903745ff..d2d1cc23f 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart @@ -689,6 +689,24 @@ class AppLocalizationsAr extends AppLocalizations { String get app_components_navigationBar_lastItemBadge_label => 'Last item badge'; + @override + String app_components_navigationBar_badge_count_a11y(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count إشعار غير مقروء', + many: '$count إشعارًا غير مقروء', + few: '$count إشعارات غير مقروءة', + two: 'إشعاران غير مقروءين', + one: 'إشعار واحد غير مقروء', + zero: 'لا توجد إشعارات غير مقروءة', + ); + return '$_temp0'; + } + + @override + String get app_components_navigationBar_badge_dot_a11y => 'إشعار غير مقروء'; + @override String get app_components_topAppBar_label => 'Top bar'; 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 42e5ed21f..ecefb48a7 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart @@ -689,6 +689,21 @@ class AppLocalizationsEn extends AppLocalizations { String get app_components_navigationBar_lastItemBadge_label => 'Last item badge'; + @override + String app_components_navigationBar_badge_count_a11y(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count unread notifications', + one: '1 unread notification', + ); + return '$_temp0'; + } + + @override + String get app_components_navigationBar_badge_dot_a11y => + 'Unread notification'; + @override String get app_components_topAppBar_label => 'Top bar'; 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 d58b3bfcd..4ad50307f 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_fr.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_fr.dart @@ -693,6 +693,21 @@ class AppLocalizationsFr extends AppLocalizations { String get app_components_navigationBar_lastItemBadge_label => 'Last item badge'; + @override + String app_components_navigationBar_badge_count_a11y(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count notifications non lues', + one: '1 notification non lue', + ); + return '$_temp0'; + } + + @override + String get app_components_navigationBar_badge_dot_a11y => + 'Notification non lue'; + @override String get app_components_topAppBar_label => 'Top bar'; diff --git a/app/lib/l10n/ouds_flutter_ar.arb b/app/lib/l10n/ouds_flutter_ar.arb index 8bfc71e3d..fa42dad13 100644 --- a/app/lib/l10n/ouds_flutter_ar.arb +++ b/app/lib/l10n/ouds_flutter_ar.arb @@ -125,6 +125,15 @@ "@_components_navigation_bar": {}, "app_components_navigationBar_description_text": "Bottom bars يوفر الوصول إلى الوجهات الرئيسية للتطبيق باستخدام 3 إلى 5 علامات تبويب دائمة. يتم تمثيل كل وجهة بواسطة أيقونة وعلامة نصية اختيارية.", + "app_components_navigationBar_badge_count_a11y": "{count, plural, zero {لا توجد إشعارات غير مقروءة} one {إشعار واحد غير مقروء} two {إشعاران غير مقروءين} few {{count} إشعارات غير مقروءة} many {{count} إشعارًا غير مقروء} other {{count} إشعار غير مقروء}}", + "@app_components_navigationBar_badge_count_a11y": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "app_components_navigationBar_badge_dot_a11y": "إشعار غير مقروء", "@_components_topAppBar": {}, "app_components_topAppBar_description_text": "Top bar هو مكون موجه من الأعلى يعرض عنوان الشاشة ويوفر الوصول إلى الإجراءات الرئيسية وعناصر التنقل.", diff --git a/app/lib/l10n/ouds_flutter_en.arb b/app/lib/l10n/ouds_flutter_en.arb index 7f8a66996..b941df3d2 100644 --- a/app/lib/l10n/ouds_flutter_en.arb +++ b/app/lib/l10n/ouds_flutter_en.arb @@ -294,6 +294,15 @@ "app_components_navigationBar_description_text": "The Bottom bar provides access to an app’s primary destinations using 3 to 5 persistent tabs. Each destination is represented by an icon and optionally a text label.", "app_components_navigationBar_itemCount_label": "Item count", "app_components_navigationBar_lastItemBadge_label": "Last item badge", + "app_components_navigationBar_badge_count_a11y": "{count, plural, =1 {1 unread notification} other {{count} unread notifications}}", + "@app_components_navigationBar_badge_count_a11y": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "app_components_navigationBar_badge_dot_a11y": "Unread notification", "@_components_topAppBar": {}, "app_components_topAppBar_label": "Top bar", diff --git a/app/lib/l10n/ouds_flutter_fr.arb b/app/lib/l10n/ouds_flutter_fr.arb index 5dc3dba4d..84b07225f 100644 --- a/app/lib/l10n/ouds_flutter_fr.arb +++ b/app/lib/l10n/ouds_flutter_fr.arb @@ -122,6 +122,15 @@ "@_components_navigation_bar": {}, "app_components_navigationBar_description_text": "Une Bottom bar (ou barre inférieure) permet d'accéder aux principales destinations d'une application grâce à 3 à 5 onglets permanents. Chaque destination est représentée par une icône et, éventuellement, par un libellé textuel.", + "app_components_navigationBar_badge_count_a11y": "{count, plural, =1 {1 notification non lue} other {{count} notifications non lues}}", + "@app_components_navigationBar_badge_count_a11y": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "app_components_navigationBar_badge_dot_a11y": "Notification non lue", "@_components_topAppBar": {}, "app_components_topAppBar_description_text": "L'app bar (ou barre d'application) est un composant situé en haut de l'ecran qui affiche le titre et donne accès aux actions principales et aux éléments de navigation.", diff --git a/app/lib/ui/components/components.dart b/app/lib/ui/components/components.dart index 9e98f0036..cb6229da8 100644 --- a/app/lib/ui/components/components.dart +++ b/app/lib/ui/components/components.dart @@ -157,6 +157,7 @@ List components(BuildContext context) { onDestinationSelected: (index) {}, translucent: true, destinations: NavigationBarCustomizationUtils.buildItems( + context: context, themeController: themeController, ), ), diff --git a/app/lib/ui/components/navigation/navigation_bar_customization_utils.dart b/app/lib/ui/components/navigation/navigation_bar_customization_utils.dart index 219209401..dc60273fe 100644 --- a/app/lib/ui/components/navigation/navigation_bar_customization_utils.dart +++ b/app/lib/ui/components/navigation/navigation_bar_customization_utils.dart @@ -10,7 +10,9 @@ * // Software description: Flutter library of reusable graphical components * // */ +import 'package:flutter/widgets.dart'; import 'package:ouds_core/components/navigation/ouds_navigation_bar_item.dart'; +import 'package:ouds_flutter_demo/l10n/gen/ouds_flutter_app_localizations.dart'; import 'package:ouds_flutter_demo/ui/components/navigation/navigation_bar_customization.dart'; import 'package:ouds_flutter_demo/ui/components/navigation/navigation_bar_enum.dart'; import 'package:ouds_flutter_demo/ui/theme/theme_controller.dart'; @@ -23,39 +25,54 @@ class NavigationBarCustomizationUtils { /// Returns an optional navigation bar item badge based on the selected badge type /// (count badge, standard badge, or none). - static OudsNavigationBarItemBadge? getItemBadge(NavigationBarCustomizationState customizationState) { + static OudsNavigationBarItemBadge? getItemBadge( + NavigationBarCustomizationState customizationState, + BuildContext context, + ) { + final l10n = AppLocalizations.of(context)!; + return customizationState.selectedItemBadge == ItemBadge.count - ? OudsNavigationBarItemBadge(contentDescription: "$itemBadgeCount notification unread", count: itemBadgeCount) + ? OudsNavigationBarItemBadge( + contentDescription: l10n + .app_components_navigationBar_badge_count_a11y(itemBadgeCount), + count: itemBadgeCount, + ) : customizationState.selectedItemBadge == ItemBadge.dot - ? OudsNavigationBarItemBadge(contentDescription: "notification unread") - : null; + ? OudsNavigationBarItemBadge( + contentDescription: + l10n.app_components_navigationBar_badge_dot_a11y, + ) + : null; } /// Generates a list of consecutive item count values from [minItemCount] to [maxItemCount] (inclusive). - static final itemCountOptions = List.generate(maxItemCount - minItemCount + 1, (index) => minItemCount + index); + static final itemCountOptions = List.generate( + maxItemCount - minItemCount + 1, + (index) => minItemCount + index, + ); /// Builds a shared list of navigation bar items based on the current /// customization state and theme. static List buildItems({ required ThemeController themeController, + required BuildContext context, NavigationBarCustomizationState? customizationState, int itemCount = minItemCount, }) { final safeItemCount = itemCount.clamp(minItemCount, maxItemCount); - return List.generate( - safeItemCount, - (index) { - final isLastItem = index == safeItemCount - 1; + return List.generate(safeItemCount, (index) { + final isLastItem = index == safeItemCount - 1; - return OudsNavigationBarItem( - icon: AppAssets.icons.functionalSocialAndEngagementHeartEmpty( - themeController, - ), - label: 'Label', //'item ${index + 1}', - badge: (isLastItem && customizationState != null) ? getItemBadge(customizationState) : null, - ); - }, - ); + return OudsNavigationBarItem( + icon: AppAssets.icons.functionalSocialAndEngagementHeartEmpty( + themeController, + ), + label: 'Label', //'item ${index + 1}', + badge: (isLastItem && customizationState != null) + ? getItemBadge(customizationState, context) + : null, + ); + }); } } diff --git a/app/lib/ui/components/navigation/navigation_bar_demo_screen.dart b/app/lib/ui/components/navigation/navigation_bar_demo_screen.dart index 175d59866..9b03c3965 100644 --- a/app/lib/ui/components/navigation/navigation_bar_demo_screen.dart +++ b/app/lib/ui/components/navigation/navigation_bar_demo_screen.dart @@ -36,10 +36,15 @@ import 'package:provider/provider.dart'; class NavigationBarDemoScreen extends StatefulWidget { final bool indeterminate; final String? previousPageTitle; - const NavigationBarDemoScreen({super.key, this.indeterminate = false,this.previousPageTitle}); // Default value set to false + const NavigationBarDemoScreen({ + super.key, + this.indeterminate = false, + this.previousPageTitle, + }); // Default value set to false @override - State createState() => _NavigationBarDemoScreenState(); + State createState() => + _NavigationBarDemoScreenState(); } class _NavigationBarDemoScreenState extends State { @@ -56,7 +61,11 @@ class _NavigationBarDemoScreenState extends State { Widget build(BuildContext context) { return NavigationBarCustomization( child: Padding( - padding: EdgeInsets.only(bottom: defaultTargetPlatform == TargetPlatform.android ? MediaQuery.of(context).viewPadding.bottom : OudsTheme.of(context).spaceScheme(context).paddingBlockNone), + padding: EdgeInsets.only( + bottom: defaultTargetPlatform == TargetPlatform.android + ? MediaQuery.of(context).viewPadding.bottom + : OudsTheme.of(context).spaceScheme(context).paddingBlockNone, + ), child: Scaffold( bottomSheet: OudsSheetsBottom( onExpansionChanged: _onExpansionChanged, @@ -65,9 +74,9 @@ class _NavigationBarDemoScreenState extends State { ), key: _scaffoldKey, appBar: MainAppBar( - showBackButton: true, - title: context.l10n.app_components_navigationBar_label, - previousPageTitle: widget.previousPageTitle, + showBackButton: true, + title: context.l10n.app_components_navigationBar_label, + previousPageTitle: widget.previousPageTitle, ), body: SafeArea( child: ExcludeSemantics( @@ -89,21 +98,24 @@ class _Body extends StatefulWidget { class _BodyState extends State<_Body> { @override Widget build(BuildContext context) { - ThemeController? themeController = Provider.of(context, listen: false); + ThemeController? themeController = Provider.of( + context, + listen: false, + ); return DetailScreenDescription( description: context.l10n.app_components_navigationBar_description_text, widget: Column( children: [ _NavigationBarDemo(), - SizedBox(height: themeController.currentTheme.spaceScheme(context).fixedMedium), - Code( - code: NavigationBarCodeGenerator.updateCode( - context, - ), + SizedBox( + height: themeController.currentTheme + .spaceScheme(context) + .fixedMedium, ), + Code(code: NavigationBarCodeGenerator.updateCode(context)), ReferenceDesignVersionComponent( version: OudsComponentVersion.navigationBar, - ) + ), ], ), ); @@ -135,6 +147,7 @@ class _NavigationBarDemoState extends State<_NavigationBarDemo> { customizationState = NavigationBarCustomization.of(context); themeController = Provider.of(context, listen: true); final items = NavigationBarCustomizationUtils.buildItems( + context: context, themeController: themeController!, customizationState: customizationState!, itemCount: customizationState!.itemSelected, @@ -176,7 +189,8 @@ class _CustomizationContentState extends State<_CustomizationContent> { @override Widget build(BuildContext context) { - final NavigationBarCustomizationState? customizationState = NavigationBarCustomization.of(context); + final NavigationBarCustomizationState? customizationState = + NavigationBarCustomization.of(context); var badgeType = customizationState!.itemBadgeState.list; return CustomizableSection( children: [ diff --git a/ouds_core/CHANGELOG.md b/ouds_core/CHANGELOG.md index c74f5e7fe..633af7a3f 100644 --- a/ouds_core/CHANGELOG.md +++ b/ouds_core/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Orange-OpenSource/ouds-flutter/compare/1.3.1...develop) ### Added ### Changed +- [Library] `tab bar component`, update the animation of the `selected tab indicator` ([#633](https://github.com/Orange-OpenSource/ouds-flutter/issues/633)) - [Library] Update `ToolBar Top`, with Badge in Trailing Actions ([#642](https://github.com/Orange-OpenSource/ouds-flutter/issues/642)) - [Library] update `alert message`, `switch`, `radio`, `checkbox`, `text input`, `pin code input`, `phone number input`, components to use rich text ([#782](https://github.com/Orange-OpenSource/ouds-flutter/issues/782)) - [Library] Update text input component to v1.4 ([#692](https://github.com/Orange-OpenSource/ouds-flutter/issues/692)) @@ -23,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Library] update tokens 2.4.0 ([#726](https://github.com/Orange-OpenSource/ouds-flutter/issues/726)) ### Fixed +- [Library] `Bottom Bar` Inconsistent order of the accessible ([#625](https://github.com/Orange-OpenSource/ouds-flutter/issues/625)) +- [Library] `Bottom Bar` Overlap when zoom is activated ([#627](https://github.com/Orange-OpenSource/ouds-flutter/issues/627)) - [Library] `Password input` Label is truncated when zoom is applied ([#600](https://github.com/Orange-OpenSource/ouds-flutter/issues/600)) - [Library] `Filter chip` is not reached by keyboard focus or Switch Access focus ([#474](https://github.com/Orange-OpenSource/ouds-flutter/issues/474)) - [Library] `Phone number input` Add a hint to explain how to interact with fields ([#571](https://github.com/Orange-OpenSource/ouds-flutter/issues/571)) diff --git a/ouds_core/lib/components/navigation/internal/ouds_navigation_bar_a11y.dart b/ouds_core/lib/components/navigation/internal/ouds_navigation_bar_a11y.dart new file mode 100644 index 000000000..d4dafbb2c --- /dev/null +++ b/ouds_core/lib/components/navigation/internal/ouds_navigation_bar_a11y.dart @@ -0,0 +1,96 @@ +/* + * // 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/navigation/ouds_navigation_bar_item.dart'; + +// --- Text-scale cap ---------------------------------------------------------- +/// Maximum text-scale factor applied inside navigation bar items. +/// +/// Capped at 160 % to prevent icon/label/badge overlap at large accessibility +/// text sizes on iOS. +const double kNavBarMaxTextScale = 1.6; + +/// Returns a [TextScaler] capped at [kNavBarMaxTextScale]. +/// +/// Apply via a [MediaQuery] override so all descendants (labels, badges, +/// border ring) automatically respect the cap. +/// +/// ```dart +/// MediaQuery( +/// data: MediaQuery.of(context).copyWith( +/// textScaler: clampNavBarTextScaler(context), +/// ), +/// child: child, +/// ) +/// ``` +TextScaler clampNavBarTextScaler(BuildContext context) => + MediaQuery.textScalerOf(context).clamp(maxScaleFactor: kNavBarMaxTextScale); + +// --- Semantic label helpers -------------------------------------------------- + +/// Accessibility helper for OUDS navigation bar items. +/// +/// Builds the semantic labels announced by TalkBack (Android) and +/// VoiceOver (iOS) for each navigation destination. +/// +/// ### TalkBack reading order +/// +/// On Android with a badge, TalkBack announces: +/// +/// ``` +/// "Label, 1 notification, Tab 2 of 3" +/// ``` +/// +/// This relies on a `Semantics` node with the badge description placed **below** +/// `NavigationDestination` in the item's `Column`. Material's `IndexedSemantics` +/// appends the positional info automatically. +/// +/// > **Important**: the badge `Semantics` child must use `SizedBox(height: 1)`, +/// > not `SizedBox.shrink()`. A 0×0 rect causes Flutter to mark the node +/// > invisible and TalkBack will silently skip it. +/// +/// ```dart +/// OudsNavigationBarA11y.buildTabSemanticLabel('Home', badge); +/// // → 'Home' (no badge) +/// // → 'Home, 3 messages' (with badge) +/// ``` +abstract final class OudsNavigationBarA11y { + /// Builds the semantic label for a navigation tab. + /// + /// Returns [label] alone when [badge] is `null`, or + /// `"label, badge description"` when a badge is present. + static String buildTabSemanticLabel( + String label, + OudsNavigationBarItemBadge? badge, + ) { + if (badge == null) return label; + return '$label, ${badge.contentDescription}'; + } +} + +/// Builds the accessible label for a navigation bar item. +/// +/// Combines [label] with [badge]'s description: `"Label, badge description"`. +/// +/// **Deprecated** — prefer [OudsNavigationBarA11y.buildTabSemanticLabel]. +@Deprecated('Use OudsNavigationBarA11y.buildTabSemanticLabel instead.') +String buildNavItemAccessibleLabel( + String label, + OudsNavigationBarItemBadge? badge, +) { + if (badge == null) return label; + return '$label, ${badge.contentDescription}'; +} diff --git a/ouds_core/lib/components/navigation/internal/ouds_navigation_bar_indicator_animation.dart b/ouds_core/lib/components/navigation/internal/ouds_navigation_bar_indicator_animation.dart new file mode 100644 index 000000000..d557fe29c --- /dev/null +++ b/ouds_core/lib/components/navigation/internal/ouds_navigation_bar_indicator_animation.dart @@ -0,0 +1,220 @@ +/* + * // Software Name: OUDS Flutter + * // SPDX-FileCopyrightText: Copyright (c) Orange SA + * // SPDX-License-Identifier: MIT + * // + * // This software is distributed under the MIT license, + * // the text of which is available at https://opensource.org/license/MIT/ + * // or see the "LICENSE" file for more details. + * // + * // Software description: Flutter library of reusable graphical components + * // + */ + +/// @nodoc +library; + +import 'package:flutter/material.dart'; +import 'package:ouds_theme_contract/theme/tokens/components/ouds_bar_tokens.dart'; + +/// A custom painter for drawing an animated navigation bar indicator. +/// +/// The indicator expands from the center of the tab outwards to its edges, +/// creating a smooth animation effect when a tab becomes selected. +class _OudsIndicatorPainter extends CustomPainter { + /// The animation value (0.0 to 1.0) controlling the expansion from center. + final double animationValue; + + /// The color of the indicator line. + final Color color; + + /// The height (thickness) of the indicator line. + final double thickness; + + /// The width of the tab (used to determine expansion limits). + final double tabWidth; + + /// The border radius of the indicator. + final double borderRadius; + + _OudsIndicatorPainter({ + required this.animationValue, + required this.color, + required this.thickness, + required this.tabWidth, + required this.borderRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + /// Calculate the expansion: starts from center and expands to edges + final centerX = tabWidth / 2; + final maxExpansion = tabWidth / 2; + final currentExpansion = maxExpansion * animationValue; + + /// Starting point (left edge) and ending point (right edge) of the indicator + final startX = centerX - currentExpansion; + final endX = centerX + currentExpansion; + final rectWidth = endX - startX; + + /// Only draw if width is positive + if (rectWidth > 0) { + final rect = Rect.fromLTWH(startX, 0, rectWidth, thickness); + final rrect = RRect.fromRectAndRadius( + rect, + Radius.circular(borderRadius), + ); + + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + canvas.drawRRect(rrect, paint); + } + } + + @override + bool shouldRepaint(covariant _OudsIndicatorPainter oldDelegate) { + return oldDelegate.animationValue != animationValue || + oldDelegate.color != color || + oldDelegate.thickness != thickness || + oldDelegate.tabWidth != tabWidth || + oldDelegate.borderRadius != borderRadius; + } +} + +/// A widget that renders an animated indicator with expansion from center animation. +/// +/// Supports an optional [externalController] to allow the parent widget to +/// control the animation lifecycle. This is required on iOS where +/// [CupertinoTabBar] rebuilds items from scratch on each tap, preventing +/// [didUpdateWidget] from being called. +class OudsAnimatedIndicator extends StatefulWidget { + /// Whether the indicator should be visible and animated. + final bool isSelected; + + /// The color of the indicator. + final Color color; + + /// The height (thickness) of the indicator line. + final double thickness; + + /// The width of the tab containing this indicator. + final double tabWidth; + + /// The border radius of the indicator. + final double borderRadius; + + /// The duration of the animation. + final Duration animationDuration; + + /// Optional external [AnimationController] managed by the parent widget. + /// + /// When provided, the widget uses this controller instead of creating its own. + /// This is necessary on iOS to survive tab rebuilds. + final AnimationController? externalController; + + const OudsAnimatedIndicator({ + super.key, + required this.isSelected, + required this.color, + required this.thickness, + required this.tabWidth, + required this.borderRadius, + this.animationDuration = const Duration(milliseconds: 240), + this.externalController, //240 Optional external controller for iOS + }); + + @override + State createState() => _OudsAnimatedIndicatorState(); +} + +class _OudsAnimatedIndicatorState extends State + with SingleTickerProviderStateMixin { + AnimationController? _internalController; + + /// Use external controller if provided, otherwise fallback to internal + AnimationController get _controller => + widget.externalController ?? _internalController!; + + @override + void initState() { + super.initState(); + + /// Only create internal controller if no external one is provided (Android) + if (widget.externalController == null) { + _internalController = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + /// Animate from 0 to 1 on first render if selected (Android) + if (widget.isSelected) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _internalController?.animateTo( + 1.0, + duration: widget.animationDuration, + curve: Curves.easeInOut, + ); + } + }); + } else { + _internalController?.value = 0.0; + } + } + } + + @override + void didUpdateWidget(covariant OudsAnimatedIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + + /// Only handle animation here for Android (internal controller) + /// iOS animations are driven by the external controller in OudsTabBar + if (widget.externalController == null && + widget.isSelected != oldWidget.isSelected) { + _controller.animateTo( + widget.isSelected ? 1.0 : 0.0, + duration: widget.animationDuration, + curve: Curves.easeInOut, + ); + } + } + + @override + void dispose() { + /// Only dispose internal controller, never the external one + _internalController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.thickness, + width: widget.tabWidth, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return CustomPaint( + painter: _OudsIndicatorPainter( + animationValue: _controller.value, + color: widget.color, + thickness: widget.thickness, + tabWidth: widget.tabWidth, + borderRadius: widget.borderRadius, + ), + ); + }, + ), + ); + } +} + +/// Extension to easily access indicator animation properties from bar tokens. +extension OudsBarTokensIndicatorExtension on OudsBarTokens { + /// Gets the animation duration for the indicator. + Duration getIndicatorAnimationDuration() { + return const Duration(milliseconds: 240); + } +} diff --git a/ouds_core/lib/components/navigation/ouds_navigation_bar.dart b/ouds_core/lib/components/navigation/ouds_navigation_bar.dart index 6db78d747..d80fa739c 100644 --- a/ouds_core/lib/components/navigation/ouds_navigation_bar.dart +++ b/ouds_core/lib/components/navigation/ouds_navigation_bar.dart @@ -126,7 +126,10 @@ class _OudsNavigationBarState extends State { @override void initState() { super.initState(); - _selectedIndex = widget.selectedIndex.clamp(0, widget.destinations.length - 1); + _selectedIndex = widget.selectedIndex.clamp( + 0, + widget.destinations.length - 1, + ); } /// Updates the selected index if [currentIndex] changes. @@ -134,16 +137,28 @@ class _OudsNavigationBarState extends State { void didUpdateWidget(covariant OudsNavigationBar oldWidget) { super.didUpdateWidget(oldWidget); if (widget.selectedIndex != oldWidget.selectedIndex) { - _selectedIndex = widget.selectedIndex.clamp(0, widget.destinations.length - 1); + _selectedIndex = widget.selectedIndex.clamp( + 0, + widget.destinations.length - 1, + ); } } /// Builds the navigation bar with dynamic label and icon colors and a custom indicator shape. @override Widget build(BuildContext context) { - final interactionModelHover = OudsInheritedInteractionModel.of(context, InteractionAspect.hover); - final interactionModelPressed = OudsInheritedInteractionModel.of(context, InteractionAspect.pressed); - final interactionModelFocused = OudsInheritedInteractionModel.of(context, InteractionAspect.focused); + final interactionModelHover = OudsInheritedInteractionModel.of( + context, + InteractionAspect.hover, + ); + final interactionModelPressed = OudsInheritedInteractionModel.of( + context, + InteractionAspect.pressed, + ); + final interactionModelFocused = OudsInheritedInteractionModel.of( + context, + InteractionAspect.focused, + ); final isHovered = interactionModelHover?.state.isHovered ?? false; final isPressed = interactionModelPressed?.state.isPressed ?? false; @@ -158,8 +173,12 @@ class _OudsNavigationBarState extends State { final barControlState = barStateDeterminer.determineControlState(); final navigationBarModifier = OudsNavigationBarStatusModifier(context); - final navigationBarBgModifier = OudsNavigationBarBackgroundColorModifier(context); - final navigationBarBorderModifier = OudsNavigationBarBorderModifier(context); + final navigationBarBgModifier = OudsNavigationBarBackgroundColorModifier( + context, + ); + final navigationBarBorderModifier = OudsNavigationBarBorderModifier( + context, + ); final safeIndex = _selectedIndex.clamp(0, widget.destinations.length - 1); @@ -180,28 +199,43 @@ class _OudsNavigationBarState extends State { ), // `overlayColor` is the transient ink overlay used for interaction feedback (pressed/hovered/focused), // resolved per destination via `WidgetState`. - overlayColor: WidgetStateProperty.resolveWith( - (states) { - final isSelected = states.contains(WidgetState.selected); - return navigationBarModifier.getMaterialIndicatorBarColor(barControlState, isSelected); - }, + overlayColor: WidgetStateProperty.resolveWith((states) { + final isSelected = states.contains(WidgetState.selected); + return navigationBarModifier.getMaterialIndicatorBarColor( + barControlState, + isSelected, + ); + }), + backgroundColor: navigationBarBgModifier.getBackgroundColor( + widget.translucent, ), - backgroundColor: navigationBarBgModifier.getBackgroundColor(widget.translucent), // Label text style resolved per destination via `WidgetState` (at minimum selected/unselected). - labelTextStyle: WidgetStateProperty.resolveWith( - (states) { - final isSelected = states.contains(WidgetState.selected); - return OudsTheme.of(context).typographyTokens.typeLabelDefaultMedium(context).copyWith( - color: navigationBarModifier.getTextIconItemColor(barControlState, isSelected), - ); - }, - ), + labelTextStyle: WidgetStateProperty.resolveWith(( + states, + ) { + final isSelected = states.contains(WidgetState.selected); + return TextStyle( + color: navigationBarModifier.getTextIconItemColor( + barControlState, + isSelected, + ), + overflow: TextOverflow.ellipsis, + fontFamily: OudsTheme.of(context).fontFamily, + ).copyWith(fontSize: 12); + }), destinations: List.generate( widget.destinations.length, (index) => widget.destinations[index].toNavigationDestination( context, barControlState, isSelected: index == safeIndex, + index: index, + total: widget.destinations.length, + onTap: () { + if (index == safeIndex) return; + setState(() => _selectedIndex = index); + widget.onDestinationSelected?.call(index); + }, ), ), onDestinationSelected: (index) { diff --git a/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart b/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart index c8e56d6f7..f9fd34391 100644 --- a/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart +++ b/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart @@ -17,44 +17,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:ouds_core/components/badge/ouds_badge.dart'; import 'package:ouds_core/components/common/ouds_icon_status.dart'; +import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_a11y.dart'; +import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_indicator_animation.dart'; import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_state.dart'; import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_status_modifier.dart'; +import 'package:ouds_core/components/utilities/badge_border_utils.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; import 'package:ouds_theme_contract/theme/tokens/components/ouds_bar_tokens.dart'; +/// A single destination in an OUDS bottom navigation component. /// -/// An OUDS navigation bar item. +/// Used by [OudsNavigationBar] (Material/Android) and [OudsTabBar] (iOS). +/// Each item has an icon, a label, and an optional badge. Visual appearance +/// adapts to the [OudsNavigationBarControlState] (enabled/hovered/pressed/focused). /// -/// An [OudsNavigationBarItem] represents a single destination displayed in an -/// OUDS bottom navigation component (e.g. [OudsNavigationBar] on Material, or -/// [OudsTabBar] on iOS). -/// -/// Each item consists of an icon, a label, and optionally a badge. -/// Visual appearance can vary depending on selection and the resolved -/// [OudsNavigationBarControlState] (enabled/hovered/pressed/focused). -/// -/// ### Parameters: -/// - [icon]: Asset path of the SVG icon to display. -/// - [label]: Text label of the item. -/// - [badge]: Optional [OudsNavigationBarItemBadge] displayed over the icon. -/// -/// ### Example usage: /// ```dart -/// OudsNavigationBarItem( -/// icon: 'assets/home.svg', -/// label: 'Home', -/// ); -/// ``` +/// OudsNavigationBarItem(icon: 'assets/home.svg', label: 'Home'); /// -/// With a badge: -/// ```dart +/// // With badge: /// OudsNavigationBarItem( /// icon: 'assets/home.svg', /// label: 'Home', -/// badge: OudsNavigationBarItemBadge( -/// contentDescription: 'Notifications', -/// count: 3, -/// ), +/// badge: OudsNavigationBarItemBadge(contentDescription: 'Notifications', count: 3), /// ); /// ``` class OudsNavigationBarItem { @@ -99,49 +83,56 @@ class OudsNavigationBarItem { excludeFromSemantics: true, assetName, fit: BoxFit.contain, - height: 26, //sizeIcon.iconDecorativeExtraSmall, - width: 26, //sizeIcon.iconDecorativeExtraSmall, + height: 26, + width: 26, colorFilter: ColorFilter.mode( modifier.getTextIconItemColor(controlState, isSelected), BlendMode.srcIn, ), ); - return badge != null - ? OudsBadge.count( - semanticsLabel: badge.contentDescription, - label: badge.count.toString(), - status: Negative(), - size: badge.hasCount ? OudsBadgeSize.medium : OudsBadgeSize.xsmall, - child: widgetIcon, - ) - : widgetIcon; + if (badge == null) return widgetIcon; + + return buildBadgeWithBorder( + context: context, + hasCount: badge.hasCount, + child: OudsBadge.count( + // The badge semantic label is handled by the parent Semantics node + // in toNavigationDestination to control the TalkBack reading order. + semanticsLabel: null, + label: badge.count.toString(), + status: Negative(), + size: badge.hasCount ? OudsBadgeSize.medium : OudsBadgeSize.xsmall, + child: widgetIcon, + ), + ); } - /// Builds the top indicator shown above the icon when the destination is selected. - Container _buildTopIndicatorBar( + /// Builds the top indicator shown above the icon. + /// + /// [index] is used to generate a unique [ValueKey] per item. + /// [externalController] is optional and used on iOS to survive tab rebuilds. + Widget _buildTopIndicatorBar( BuildContext context, OudsBarTokens bar, bool isSelected, OudsNavigationBarControlState controlState, - ) { + int index, { + AnimationController? externalController, // Optional for iOS + }) { final navigationBarStatusModifier = OudsNavigationBarStatusModifier( context, ); - return Container( - height: bar.sizeHeightCurrentIndicatorCustom, // thickness of the bar - width: - bar.sizeWidthCurrentIndicatorCustomTop, // width of the bar (adjust) - decoration: BoxDecoration( - color: isSelected - ? navigationBarStatusModifier.getIndicatorBarColor(controlState) - : Colors.transparent, - borderRadius: BorderRadius.horizontal( - left: Radius.circular(bar.borderRadiusCurrentIndicatorCustomTop), - right: Radius.circular(bar.borderRadiusCurrentIndicatorCustomTop), - ), - ), + return OudsAnimatedIndicator( + key: ValueKey('indicator_$index'), + isSelected: isSelected, + color: navigationBarStatusModifier.getIndicatorBarColor(controlState), + thickness: bar.sizeHeightCurrentIndicatorCustom, + tabWidth: bar.sizeWidthCurrentIndicatorCustomTop, + borderRadius: bar.borderRadiusCurrentIndicatorCustomTop, + animationDuration: const Duration(milliseconds: 300), + externalController: externalController, // Pass external controller ); } @@ -155,37 +146,63 @@ class OudsNavigationBarItem { /// - [controlState] drives icon/top-indicator colors according to the current /// OUDS navigation control state. /// - [isSelected] indicates whether this destination is currently selected. + /// - [index] zero-based position of this item in the navigation bar. + /// - [total] total number of destinations in the navigation bar. Column toNavigationDestination( BuildContext context, OudsNavigationBarControlState controlState, { required bool isSelected, + required int index, + required int total, + VoidCallback? onTap, }) { final modifier = OudsNavigationBarStatusModifier(context); final bar = OudsTheme.of(context).componentsTokens(context).bar; + // Builds the full TalkBack label: "Label[, badge], Tab X of Y" + final localizations = MaterialLocalizations.of(context); + final contentLabel = OudsNavigationBarA11y.buildTabSemanticLabel( + label, + badge, + ); + final fullSemanticLabel = + '$contentLabel, ${localizations.tabLabel(tabIndex: index + 1, tabCount: total)}'; + return Column( mainAxisSize: MainAxisSize.min, children: [ - // Top active indicator bar (optional visual indicator for selection) - _buildTopIndicatorBar(context, bar, isSelected, controlState), + // Android: no external controller, uses internal animation + _buildTopIndicatorBar(context, bar, isSelected, controlState, index), Flexible( - child: NavigationDestination( - label: label, - icon: _buildBadgeIconNavigationDestination( - context, - icon, - modifier, - controlState, - badge, - isSelected: isSelected, - ), - selectedIcon: _buildBadgeIconNavigationDestination( - context, - icon, - modifier, - controlState, - badge, - isSelected: isSelected, + child: Semantics( + // Override NavigationDestination's internal semantics to enforce + // the correct reading order: "Label[, badge], Tab X of Y". + // onTap restores the activation action lost by ExcludeSemantics. + label: fullSemanticLabel, + selected: isSelected, + onTap: onTap, + child: ExcludeSemantics( + // Suppresses NavigationDestination's own semantic nodes, + // which would otherwise produce a wrong TalkBack reading order. + child: NavigationDestination( + label: label, + icon: _buildBadgeIconNavigationDestination( + context, + icon, + modifier, + controlState, + badge, + isSelected: isSelected, + ), + selectedIcon: _buildBadgeIconNavigationDestination( + context, + icon, + modifier, + controlState, + badge, + isSelected: isSelected, + ), + ), ), ), ), @@ -199,36 +216,50 @@ class OudsNavigationBarItem { /// [CupertinoTabBar] (iOS-style tab bar) and therefore expects a list of /// [BottomNavigationBarItem]. /// + /// Semantics for VoiceOver are intentionally **not** set here. + /// They are managed at the [OudsTabBar] level via a [Stack] overlay of + /// transparent [Semantics] widgets positioned over each tab item, so that + /// VoiceOver sees exactly one node per tab announcing: + /// "Label[, badge], Tab X of Y". + /// /// - [context] : BuildContext to access theme and layout. - /// - [controlState] to drive icon/top-indicator colors, + /// - [controlState] to drive icon/top-indicator colors. /// - [isSelected] for the destination selection state. - /// + /// - [index] zero-based position of this item in the tab bar. + /// - [externalController] optional [AnimationController] managed by the + /// parent [OudsTabBar] to survive tab rebuilds on iOS. BottomNavigationBarItem toBottomNavigationBarItem( BuildContext context, OudsNavigationBarControlState controlState, { required bool isSelected, + required int index, + AnimationController? externalController, }) { final modifier = OudsNavigationBarStatusModifier(context); - return BottomNavigationBarItem( - label: label, - icon: _buildBadgeIconBottomNavigationBarItem( - context, - icon, - modifier, - controlState, - badge, - isSelected: isSelected, - ), - activeIcon: _buildBadgeIconBottomNavigationBarItem( + // Build the raw icon widget. + // All semantics are suppressed here — VoiceOver nodes are managed by + // the OudsTabBar Stack overlay to guarantee a single node per tab. + final iconWidget = ExcludeSemantics( + child: _buildBadgeIconBottomNavigationBarItem( context, icon, modifier, controlState, badge, isSelected: isSelected, + index: index, + externalController: externalController, ), ); + + return BottomNavigationBarItem( + // Keep the real label for visual display under the icon. + label: label, + // All semantics suppressed — managed by OudsTabBar Stack overlay. + icon: iconWidget, + activeIcon: iconWidget, + ); } /// Builds the tab bar icon for a [BottomNavigationBarItem] (used by [CupertinoTabBar]), @@ -253,27 +284,40 @@ class OudsNavigationBarItem { OudsNavigationBarControlState controlState, final OudsNavigationBarItemBadge? badge, { required bool isSelected, + required int index, + AnimationController? externalController, // Optional for iOS }) { final bar = OudsTheme.of(context).componentsTokens(context).bar; final widgetIcon = SvgPicture.asset( excludeFromSemantics: true, assetName, fit: BoxFit.contain, - height: 26, //sizeIcon.iconDecorativeExtraSmall, - width: 26, //sizeIcon.iconDecorativeExtraSmall, + height: 26, + width: 26, colorFilter: ColorFilter.mode( modifier.getTextIconItemColor(controlState, isSelected), BlendMode.srcIn, ), ); - return badge != null - ? Column( - children: [ - _buildTopIndicatorBar(context, bar, isSelected, controlState), - SizedBox(height: 2), - OudsBadge.count( - semanticsLabel: badge.contentDescription, + final children = [ + // iOS: pass external controller to survive rebuilds + _buildTopIndicatorBar( + context, + bar, + isSelected, + controlState, + index, + externalController: externalController, + ), + const SizedBox(height: 2), + badge != null + ? buildBadgeWithBorder( + context: context, + hasCount: badge.hasCount, + child: OudsBadge.count( + // All semantics suppressed — managed by OudsTabBar Stack overlay. + semanticsLabel: null, label: badge.count.toString(), status: Negative(), size: badge.hasCount @@ -281,30 +325,21 @@ class OudsNavigationBarItem { : OudsBadgeSize.xsmall, child: widgetIcon, ), - ], - ) - : Column( - children: [ - _buildTopIndicatorBar(context, bar, isSelected, controlState), - SizedBox(height: 2), - widgetIcon, - ], - ); + ) + : widgetIcon, + ]; + + return Column(children: children); } } -/// Represents an optional badge attached to a navigation item. +/// An optional badge attached to a navigation item. /// -/// Parameters: -/// - [contentDescription] : Semantic description for accessibility. -/// - [count] : Optional integer to display as badge count. +/// [contentDescription] is the semantic text announced by screen readers. +/// [count] is the optional numeric value displayed inside the badge. /// -/// Example usage: /// ```dart -/// OudsNavigationBarItemBadge( -/// contentDescription: 'Unread messages', -/// count: 5, -/// ); +/// OudsNavigationBarItemBadge(contentDescription: 'Unread messages', count: 5); /// ``` class OudsNavigationBarItemBadge { diff --git a/ouds_core/lib/components/navigation/ouds_tab_bar.dart b/ouds_core/lib/components/navigation/ouds_tab_bar.dart index e4da0117a..34f38e79c 100644 --- a/ouds_core/lib/components/navigation/ouds_tab_bar.dart +++ b/ouds_core/lib/components/navigation/ouds_tab_bar.dart @@ -16,6 +16,7 @@ library; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:ouds_core/components/control/internal/interaction/ouds_inherited_interaction_model.dart'; +import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_a11y.dart'; import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_background_modifier.dart'; import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_border_modifier.dart'; import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_state.dart'; @@ -85,7 +86,6 @@ import 'package:ouds_theme_contract/ouds_theme.dart'; /// ], /// selectedIndex: 0, /// translucent: false, -/// , /// onDestinationSelected: (index) { /// print('Selected item: $index'); /// }, @@ -104,7 +104,7 @@ class OudsTabBar extends StatefulWidget { /// Callback invoked when a navigation item is tapped. final ValueChanged? onTap; - /// Creates an OUDS Navigation Bar with configurable items, transparency, and callbacks. + /// Creates an OUDS Tab Bar with configurable items, transparency, and callbacks. const OudsTabBar({ super.key, required this.items, @@ -117,14 +117,30 @@ class OudsTabBar extends StatefulWidget { State createState() => _OudsTabBarState(); } -class _OudsTabBarState extends State { +class _OudsTabBarState extends State with TickerProviderStateMixin { + // TickerProviderStateMixin for multiple controllers + int _selectedIndex = 0; - /// Initializes the selected index from the widget's [currentIndex]. + /// One AnimationController per tab, managed by the parent to survive rebuilds. + late List _indicatorControllers; + @override void initState() { super.initState(); _selectedIndex = widget.currentIndex.clamp(0, widget.items.length - 1); + + /// Create one controller per tab with correct initial value. + _indicatorControllers = List.generate( + widget.items.length, + (index) => AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + value: index == _selectedIndex + ? 1.0 + : 0.0, // No animation on first render + ), + ); } /// Updates the selected index if [currentIndex] changes. @@ -132,15 +148,56 @@ class _OudsTabBarState extends State { void didUpdateWidget(covariant OudsTabBar oldWidget) { super.didUpdateWidget(oldWidget); if (widget.currentIndex != oldWidget.currentIndex) { - _selectedIndex = widget.currentIndex.clamp(0, widget.items.length - 1); + _animateToIndex(widget.currentIndex.clamp(0, widget.items.length - 1)); + } + } + + /// Animates the indicator from the old selected tab to the new one. + void _animateToIndex(int newIndex) { + if (newIndex == _selectedIndex) return; + + // Animate out the previously selected tab + _indicatorControllers[_selectedIndex].animateTo( + 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + + // Animate in the newly selected tab + _indicatorControllers[newIndex].animateTo( + 1.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + + setState(() { + _selectedIndex = newIndex; + }); + } + + @override + void dispose() { + // Dispose all controllers + for (final controller in _indicatorControllers) { + controller.dispose(); } + super.dispose(); } @override Widget build(BuildContext context) { - final interactionModelHover = OudsInheritedInteractionModel.of(context, InteractionAspect.hover); - final interactionModelPressed = OudsInheritedInteractionModel.of(context, InteractionAspect.pressed); - final interactionModelFocused = OudsInheritedInteractionModel.of(context, InteractionAspect.focused); + final interactionModelHover = OudsInheritedInteractionModel.of( + context, + InteractionAspect.hover, + ); + final interactionModelPressed = OudsInheritedInteractionModel.of( + context, + InteractionAspect.pressed, + ); + final interactionModelFocused = OudsInheritedInteractionModel.of( + context, + InteractionAspect.focused, + ); final isHovered = interactionModelHover?.state.isHovered ?? false; final isPressed = interactionModelPressed?.state.isPressed ?? false; @@ -155,44 +212,124 @@ class _OudsTabBarState extends State { final barControlState = barStateDeterminer.determineControlState(); final navigationBarModifier = OudsNavigationBarStatusModifier(context); - final navigationBarBgModifier = OudsNavigationBarBackgroundColorModifier(context); - final navigationBarBorderModifier = OudsNavigationBarBorderModifier(context); + final navigationBarBgModifier = OudsNavigationBarBackgroundColorModifier( + context, + ); + final navigationBarBorderModifier = OudsNavigationBarBorderModifier( + context, + ); final safeIndex = _selectedIndex.clamp(0, widget.items.length - 1); + final total = widget.items.length; // Get the existing Cupertino theme to avoid overwriting other styles. final existingCupertinoTheme = CupertinoTheme.of(context); - return CupertinoTheme( - data: existingCupertinoTheme.copyWith( - textTheme: existingCupertinoTheme.textTheme.copyWith( - tabLabelTextStyle: OudsTheme.of(context).typographyTokens.typeBodyModerateMedium(context).copyWith( - fontSize: 10 - ), // Apply the custom text style. + // Pre-compute full VoiceOver labels for each tab. + // "Label[, badge], Tab X of Y" + final localizations = MaterialLocalizations.of(context); + final semanticLabels = List.generate(total, (index) { + final contentLabel = OudsNavigationBarA11y.buildTabSemanticLabel( + widget.items[index].label, + widget.items[index].badge, + ); + return '$contentLabel, ${localizations.tabLabel(tabIndex: index + 1, tabCount: total)}'; + }); + + // Cap text scale at 160 % to prevent icon / label / badge overlap at very + // large accessibility text sizes on iOS. + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: clampNavBarTextScaler(context)), + child: CupertinoTheme( + data: existingCupertinoTheme.copyWith( + textTheme: existingCupertinoTheme.textTheme.copyWith( + tabLabelTextStyle: TextStyle( + overflow: TextOverflow.ellipsis, + fontFamily: OudsTheme.of(context).fontFamily, + ).copyWith(fontSize: 10, fontWeight: FontWeight.w500), + ), ), - ), - child: ClipRect( - child: BackdropFilter( - filter: navigationBarBorderModifier.getBlurNavigationBar(), - child: CupertinoTabBar( - currentIndex: safeIndex, - activeColor: navigationBarModifier.getTextIconItemColor(barControlState, true), - inactiveColor: navigationBarModifier.getTextIconItemColor(barControlState, false), - border: navigationBarBorderModifier.getBorderNavigationBar(), - backgroundColor: navigationBarBgModifier.getBackgroundColor(widget.translucent), - items: List.generate( - widget.items.length, - (index) => widget.items[index].toBottomNavigationBarItem( - context, - barControlState, - isSelected: index == safeIndex, - ), + child: ClipRect( + child: BackdropFilter( + filter: navigationBarBorderModifier.getBlurNavigationBar(), + // Stack overlays transparent Semantics widgets on top of the + // CupertinoTabBar to provide a single VoiceOver node per tab. + // + // Why a Stack overlay? + // CupertinoTabBar creates its own semantic nodes (icon + label + // as separate nodes). There is no public API to suppress or + // replace them from outside. By wrapping the whole bar in an + // ExcludeSemantics and overlaying our own Semantics nodes, we + // guarantee VoiceOver sees exactly one node per tab with the + // correct label: "Label[, badge], Tab X of Y". + child: Stack( + children: [ + // Layer 1 — visual tab bar with all native semantics suppressed. + ExcludeSemantics( + child: CupertinoTabBar( + currentIndex: safeIndex, + activeColor: navigationBarModifier.getTextIconItemColor( + barControlState, + true, + ), + inactiveColor: navigationBarModifier.getTextIconItemColor( + barControlState, + false, + ), + border: navigationBarBorderModifier + .getBorderNavigationBar(), + backgroundColor: navigationBarBgModifier.getBackgroundColor( + widget.translucent, + ), + items: List.generate( + total, + (index) => widget.items[index].toBottomNavigationBarItem( + context, + barControlState, + isSelected: index == safeIndex, + index: index, + // Pass the external controller for iOS animation + externalController: _indicatorControllers[index], + ), + ), + onTap: (index) { + if (index == safeIndex) return; + _animateToIndex(index); // Trigger animation from parent + widget.onTap?.call(index); + }, + ), + ), + // Layer 2 — transparent Semantics overlay, one node per tab. + // + // Each node covers 1/N of the bar width and the full bar height. + // It is fully transparent (no visual output) and provides the + // single VoiceOver focus point for its tab. + Positioned.fill( + child: Row( + children: List.generate(total, (index) { + return Expanded( + child: Semantics( + // Full VoiceOver label: "Label[, badge], Tab X of Y". + label: semanticLabels[index], + // Marks the tab as selected for VoiceOver. + selected: index == safeIndex, + // Provides the tap/activation action for VoiceOver. + onTap: () { + if (index == safeIndex) return; + _animateToIndex(index); + widget.onTap?.call(index); + }, + // Transparent container — purely semantic, no visual output. + child: const SizedBox.expand(), + ), + ); + }), + ), + ), + ], ), - onTap: (index) { - if (index == safeIndex) return; - setState(() => _selectedIndex = index); - widget.onTap?.call(index); - }, ), ), ), diff --git a/ouds_core/lib/components/utilities/badge_border_utils.dart b/ouds_core/lib/components/utilities/badge_border_utils.dart index 2be0c5ee9..b5e8a5779 100644 --- a/ouds_core/lib/components/utilities/badge_border_utils.dart +++ b/ouds_core/lib/components/utilities/badge_border_utils.dart @@ -10,6 +10,7 @@ * // Software description: Flutter library of reusable graphical components * // */ + /// @nodoc library; diff --git a/ouds_core/test/components/navigation/ouds_navigation_bar_a11y_test.dart b/ouds_core/test/components/navigation/ouds_navigation_bar_a11y_test.dart new file mode 100644 index 000000000..fc0030ee4 --- /dev/null +++ b/ouds_core/test/components/navigation/ouds_navigation_bar_a11y_test.dart @@ -0,0 +1,305 @@ +/* + * // 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 + * // + */ + +// Accessibility tests for OudsNavigationBar — semantic labels & TalkBack contract. +// +// Coverage: +// 1. [OudsNavigationBarA11y.buildTabSemanticLabel] — pure label-building logic. +// 2. [buildNavItemAccessibleLabel] — backward-compat top-level helper. +// 3. Badge Semantics geometry — non-zero rect required by TalkBack. +// 4. TalkBack reading-order — badge node must be below label node. +// 5. [kNavBarMaxTextScale] constant value. +// +// Expected TalkBack announcement for a badged item: +// +// "Label, 1 notification, Tab 2 of 3" +// ──┬── ───────┬─────── ───────┬── +// │ │ └─ IndexedSemantics (Material, not tested here) +// │ └─ Semantics(label: badge.contentDescription, +// │ container: true, child: SizedBox(height: 1)) +// └─ NavigationDestination(label: 'Label') +// +// ⚠ The badge Semantics child MUST have a non-zero rect — SizedBox.shrink() (0×0) +// causes Flutter to mark the node invisible and TalkBack silently skips it. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_a11y.dart'; +import 'package:ouds_core/components/navigation/ouds_navigation_bar_item.dart'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/// Convenience factory for [OudsNavigationBarItemBadge]. +OudsNavigationBarItemBadge _badge({ + String contentDescription = '1 notification', + int? count = 1, +}) => OudsNavigationBarItemBadge( + contentDescription: contentDescription, + count: count, +); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // ─── 1. OudsNavigationBarA11y.buildTabSemanticLabel ─────────────────────── + group('OudsNavigationBarA11y.buildTabSemanticLabel', () { + test('returns label only when badge is null', () { + expect( + OudsNavigationBarA11y.buildTabSemanticLabel('Accueil', null), + 'Accueil', + ); + }); + + test('returns "label, badge description" when badge is present', () { + expect( + OudsNavigationBarA11y.buildTabSemanticLabel( + 'Accueil', + _badge(contentDescription: '1 notification'), + ), + 'Accueil, 1 notification', + ); + }); + + test('does not duplicate label when badge description contains label', () { + expect( + OudsNavigationBarA11y.buildTabSemanticLabel( + 'Messages', + _badge(contentDescription: '3 messages'), + ), + 'Messages, 3 messages', + ); + }); + + test('handles empty badge description gracefully', () { + expect( + OudsNavigationBarA11y.buildTabSemanticLabel( + 'Home', + _badge(contentDescription: ''), + ), + 'Home, ', + ); + }); + }); + + // ─── 2. buildNavItemAccessibleLabel (deprecated top-level helper) ───────── + group('buildNavItemAccessibleLabel', () { + test('returns label when no badge', () { + // ignore: deprecated_member_use + expect(buildNavItemAccessibleLabel('Profile', null), 'Profile'); + }); + + test('returns "label, badge" string when badge present', () { + expect( + // ignore: deprecated_member_use + buildNavItemAccessibleLabel( + 'Profile', + _badge(contentDescription: '5 alerts'), + ), + 'Profile, 5 alerts', + ); + }); + }); + + // ─── 3. Badge Semantics node — geometry contract ────────────────────────── + // + // TalkBack determines node visibility from its layout rect. + // A rect with width == 0 || height == 0 is flagged isInvisible = true and + // skipped. SizedBox(height: 1) keeps the rect non-empty while remaining + // visually imperceptible. + group('Badge Semantics node — geometry contract', () { + testWidgets( + 'SizedBox(height: 1) renders a non-empty rect inside a NavigationBar-style Column', + (WidgetTester tester) async { + // Mirrors the badge-node placement in toNavigationDestination, + // without introducing an OudsTheme dependency. + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + bottomNavigationBar: SizedBox( + height: 80, + child: Row( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 4), // indicator placeholder + const Text('Accueil'), // label placeholder + // Widget under test ↓ + Semantics( + label: '1 notification', + container: true, + child: const SizedBox(height: 1), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final box = tester.renderObject( + find.descendant( + of: find.byWidgetPredicate( + (w) => + w is Semantics && + w.properties.label == '1 notification' && + w.container == true, + ), + matching: find.byType(SizedBox), + ), + ); + + expect( + box.size.height, + greaterThan(0), + reason: + 'height > 0 prevents Flutter from marking the node invisible — TalkBack would skip a 0-height node.', + ); + }, + ); + + testWidgets( + 'Badge Semantics node is discoverable via find.bySemanticsLabel', + (WidgetTester tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + bottomNavigationBar: SizedBox( + height: 80, + child: MergeSemantics( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Semantics( + label: 'Accueil', + container: true, + child: const SizedBox(height: 24), + ), + Semantics( + label: '1 notification', + container: true, + child: const SizedBox(height: 1), // must be > 0 + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.bySemanticsLabel('1 notification'), + findsAtLeastNWidgets(1), + reason: + 'Badge Semantics node must appear in the semantic tree for TalkBack to announce it.', + ); + + handle.dispose(); + }, + ); + }); + + // ─── 4. TalkBack reading-order contract ─────────────────────────────────── + // + // TalkBack traverses nodes by layout position (top-to-bottom, left-to-right). + // The badge Semantics node must be placed BELOW NavigationDestination in the + // Column so that rect.top(badge) ≥ rect.top(label), producing the order: + // "Label → badge description → Tab X of Y" + group('TalkBack reading-order contract', () { + testWidgets( + 'Badge Semantics node is positioned below label node (rect.top ≥ label rect.top)', + (WidgetTester tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + bottomNavigationBar: SizedBox( + height: 80, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 1. Label node — mirrors NavigationDestination. + Semantics( + label: 'Accueil', + container: true, + child: const SizedBox(height: 24), + ), + // 2. Badge node — placed after label so rect.top is larger, + // guaranteeing TalkBack reads: label → badge → Tab X of Y. + Semantics( + label: '1 notification', + container: true, + child: const SizedBox(height: 1), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.bySemanticsLabel('Accueil'), findsAtLeastNWidgets(1)); + expect( + find.bySemanticsLabel('1 notification'), + findsAtLeastNWidgets(1), + reason: + 'A height-0 SizedBox would make the rect empty and prune this node — use SizedBox(height: 1).', + ); + + final labelRect = tester.getRect( + find.bySemanticsLabel('Accueil').first, + ); + final badgeRect = tester.getRect( + find.bySemanticsLabel('1 notification').first, + ); + + expect( + badgeRect.top, + greaterThanOrEqualTo(labelRect.top), + reason: + 'Badge rect.top (${badgeRect.top}) must be ≥ label rect.top (${labelRect.top}) ' + 'to produce the reading order: "Accueil, 1 notification, Tab 2 de 3".', + ); + + handle.dispose(); + }, + ); + }); + + // ─── 5. kNavBarMaxTextScale constant ───────────────────────────────────── + group('kNavBarMaxTextScale', () { + test('is capped at 1.6', () { + expect(kNavBarMaxTextScale, equals(1.6)); + }); + }); +} diff --git a/ouds_core/test/components/navigation/ouds_navigation_bar_indicator_animation_test.dart b/ouds_core/test/components/navigation/ouds_navigation_bar_indicator_animation_test.dart new file mode 100644 index 000000000..92ba809d8 --- /dev/null +++ b/ouds_core/test/components/navigation/ouds_navigation_bar_indicator_animation_test.dart @@ -0,0 +1,221 @@ +/* + * // 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/navigation/internal/ouds_navigation_bar_indicator_animation.dart'; + +void main() { + group('OudsAnimatedIndicator', () { + testWidgets('Indicator displays when selected', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OudsAnimatedIndicator( + isSelected: true, + color: Colors.orange, + thickness: 4.0, + tabWidth: 100.0, + borderRadius: 2.0, + ), + ), + ), + ); + + // Indicator should be rendered + expect(find.byType(OudsAnimatedIndicator), findsOneWidget); + expect( + find.descendant( + of: find.byType(OudsAnimatedIndicator), + matching: find.byType(CustomPaint), + ), + findsOneWidget, + ); + }); + + testWidgets('Indicator animates from invisible to visible when selected', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return Column( + children: [ + OudsAnimatedIndicator( + key: ValueKey('indicator'), + isSelected: true, + color: Colors.orange, + thickness: 4.0, + tabWidth: 100.0, + borderRadius: 2.0, + animationDuration: const Duration(milliseconds: 300), + ), + ], + ); + }, + ), + ), + ), + ); + + // Animation should start + expect(find.byType(OudsAnimatedIndicator), findsOneWidget); + expect( + find.descendant( + of: find.byType(OudsAnimatedIndicator), + matching: find.byType(CustomPaint), + ), + findsOneWidget, + ); + + // Move time forward to check animation progresses + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + expect( + find.descendant( + of: find.byType(OudsAnimatedIndicator), + matching: find.byType(CustomPaint), + ), + findsOneWidget, + ); + + // Complete animation + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + expect( + find.descendant( + of: find.byType(OudsAnimatedIndicator), + matching: find.byType(CustomPaint), + ), + findsOneWidget, + ); + }); + + testWidgets('Indicator animates when selection changes', ( + WidgetTester tester, + ) async { + bool isSelected = true; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return Column( + children: [ + OudsAnimatedIndicator( + key: ValueKey('indicator'), + isSelected: isSelected, + color: Colors.orange, + thickness: 4.0, + tabWidth: 100.0, + borderRadius: 2.0, + animationDuration: const Duration(milliseconds: 300), + ), + ElevatedButton( + onPressed: () { + setState(() => isSelected = !isSelected); + }, + child: Text('Toggle'), + ), + ], + ); + }, + ), + ), + ), + ); + + expect(find.byType(OudsAnimatedIndicator), findsOneWidget); + + // Tap button to deselect + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return Column( + children: [ + OudsAnimatedIndicator( + key: ValueKey('indicator'), + isSelected: false, + color: Colors.orange, + thickness: 4.0, + tabWidth: 100.0, + borderRadius: 2.0, + animationDuration: const Duration(milliseconds: 300), + ), + ElevatedButton(onPressed: () {}, child: Text('Toggle')), + ], + ); + }, + ), + ), + ), + ); + + // Animation should reverse + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + expect(find.byType(OudsAnimatedIndicator), findsOneWidget); + }); + + testWidgets('Multiple indicators expand independently', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + OudsAnimatedIndicator( + key: ValueKey('indicator1'), + isSelected: true, + color: Colors.orange, + thickness: 4.0, + tabWidth: 80.0, + borderRadius: 2.0, + animationDuration: const Duration(milliseconds: 300), + ), + OudsAnimatedIndicator( + key: ValueKey('indicator2'), + isSelected: false, + color: Colors.orange, + thickness: 4.0, + tabWidth: 80.0, + borderRadius: 2.0, + animationDuration: const Duration(milliseconds: 300), + ), + OudsAnimatedIndicator( + key: ValueKey('indicator3'), + isSelected: true, + color: Colors.blue, + thickness: 4.0, + tabWidth: 80.0, + borderRadius: 2.0, + animationDuration: const Duration(milliseconds: 300), + ), + ], + ), + ), + ), + ); + + // All indicators should be rendered + expect(find.byType(OudsAnimatedIndicator), findsWidgets); + expect(find.byType(CustomPaint), findsWidgets); + }); + }); +} diff --git a/ouds_core/test/components/navigation/ouds_navigation_bar_item_test.dart b/ouds_core/test/components/navigation/ouds_navigation_bar_item_test.dart new file mode 100644 index 000000000..4e4e31de8 --- /dev/null +++ b/ouds_core/test/components/navigation/ouds_navigation_bar_item_test.dart @@ -0,0 +1,74 @@ +/* + * // 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_test/flutter_test.dart'; +import 'package:ouds_core/components/navigation/ouds_navigation_bar_item.dart'; + +void main() { + group('OudsNavigationBarItem API', () { + test( + 'OudsNavigationBarItem constructor works with required parameters', + () { + const item = OudsNavigationBarItem( + icon: 'assets/test.svg', + label: 'Test', + ); + + expect(item.icon, 'assets/test.svg'); + expect(item.label, 'Test'); + expect(item.badge, isNull); + }, + ); + + test('OudsNavigationBarItem constructor works with badge', () { + const badge = OudsNavigationBarItemBadge( + contentDescription: 'Test Badge', + count: 5, + ); + + const item = OudsNavigationBarItem( + icon: 'assets/test.svg', + label: 'Test', + badge: badge, + ); + + expect(item.icon, 'assets/test.svg'); + expect(item.label, 'Test'); + expect(item.badge, isNotNull); + expect(item.badge?.contentDescription, 'Test Badge'); + expect(item.badge?.count, 5); + expect(item.badge?.hasCount, isTrue); + }); + + test('OudsNavigationBarItemBadge with no count', () { + const badge = OudsNavigationBarItemBadge( + contentDescription: 'Test Badge', + ); + + expect(badge.contentDescription, 'Test Badge'); + expect(badge.count, isNull); + expect(badge.hasCount, isFalse); + }); + + test('OudsNavigationBarItemBadge with count', () { + const badge = OudsNavigationBarItemBadge( + contentDescription: 'Test Badge', + count: 10, + ); + + expect(badge.contentDescription, 'Test Badge'); + expect(badge.count, 10); + expect(badge.hasCount, isTrue); + }); + }); +}