From c5cf6cd33cadb1b4c8b28fc9184a21b6791360cb Mon Sep 17 00:00:00 2001 From: Ahmed Amine Zribi Date: Fri, 15 May 2026 13:51:24 +0100 Subject: [PATCH 1/7] chore:update `Bottom Bar` with animation indacator --- ...ds_navigation_bar_indicator_animation.dart | 190 +++++++++++++++++ .../navigation/ouds_navigation_bar_item.dart | 127 +++++++----- ...vigation_bar_indicator_animation_test.dart | 193 ++++++++++++++++++ .../ouds_navigation_bar_item_test.dart | 74 +++++++ 4 files changed, 534 insertions(+), 50 deletions(-) create mode 100644 ouds_core/lib/components/navigation/internal/ouds_navigation_bar_indicator_animation.dart create mode 100644 ouds_core/test/components/navigation/ouds_navigation_bar_indicator_animation_test.dart create mode 100644 ouds_core/test/components/navigation/ouds_navigation_bar_item_test.dart 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..90952f45f --- /dev/null +++ b/ouds_core/lib/components/navigation/internal/ouds_navigation_bar_indicator_animation.dart @@ -0,0 +1,190 @@ +/* + * // 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_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. +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; + + const OudsAnimatedIndicator({ + super.key, + required this.isSelected, + required this.color, + required this.thickness, + required this.tabWidth, + required this.borderRadius, + this.animationDuration = const Duration(milliseconds: 300), + }); + + @override + State createState() => _OudsAnimatedIndicatorState(); +} + +class _OudsAnimatedIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + _animation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + + // Start animation if initially selected + if (widget.isSelected) { + _animationController.forward(); + } + } + + @override + void didUpdateWidget(covariant OudsAnimatedIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.isSelected != oldWidget.isSelected) { + if (widget.isSelected) { + // Forward animation when selecting + _animationController.forward(); + } else { + // Reverse animation when deselecting + _animationController.reverse(); + } + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.thickness, + width: widget.tabWidth, + child: AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return CustomPaint( + painter: _OudsIndicatorPainter( + animationValue: _animation.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: 300); + } +} 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 84dfebd0e..cd90d1777 100644 --- a/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart +++ b/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart @@ -17,6 +17,7 @@ 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_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_theme_contract/ouds_theme.dart'; @@ -102,10 +103,7 @@ class OudsNavigationBarItem { height: 26, //sizeIcon.iconDecorativeExtraSmall, width: 26, //sizeIcon.iconDecorativeExtraSmall, colorFilter: ColorFilter.mode( - modifier.getTextIconItemColor( - controlState, - isSelected, - ), + modifier.getTextIconItemColor(controlState, isSelected), BlendMode.srcIn, ), ); @@ -122,19 +120,30 @@ class OudsNavigationBarItem { } /// Builds the top indicator shown above the icon when the destination is selected. - Container _buildTopIndicatorBar(BuildContext context, OudsBarTokens bar, bool isSelected, OudsNavigationBarControlState controlState) { - final navigationBarStatusModifier = OudsNavigationBarStatusModifier(context); + /// Uses an animated indicator that expands from the center when selected and collapses when deselected. + /// Returns SizedBox.shrink() when not selected to avoid taking space. + Widget _buildTopIndicatorBar( + BuildContext context, + OudsBarTokens bar, + bool isSelected, + OudsNavigationBarControlState controlState, + ) { + // Don't show indicator when not selected to avoid taking space + if (!isSelected) { + return SizedBox.shrink(); + } - return Container( - height: bar.sizeHeightActiveIndicatorCustom, // thickness of the bar - width: bar.sizeWidthActiveIndicatorCustomTop, // width of the bar (adjust) - decoration: BoxDecoration( - color: isSelected ? navigationBarStatusModifier.getIndicatorBarColor(controlState) : Colors.transparent, - borderRadius: BorderRadius.horizontal( - left: Radius.circular(bar.borderRadiusActiveIndicatorCustomTop), - right: Radius.circular(bar.borderRadiusActiveIndicatorCustomTop), - ), - ), + final navigationBarStatusModifier = OudsNavigationBarStatusModifier( + context, + ); + + return OudsAnimatedIndicator( + isSelected: isSelected, + color: navigationBarStatusModifier.getIndicatorBarColor(controlState), + thickness: bar.sizeHeightActiveIndicatorCustom, + tabWidth: bar.sizeWidthActiveIndicatorCustomTop, + borderRadius: bar.borderRadiusActiveIndicatorCustomTop, + animationDuration: const Duration(milliseconds: 300), ); } @@ -164,10 +173,24 @@ class OudsNavigationBarItem { Flexible( child: NavigationDestination( label: label, - icon: _buildBadgeIconNavigationDestination(context, icon, modifier, controlState, badge, isSelected: isSelected), - selectedIcon: _buildBadgeIconNavigationDestination(context, icon, modifier, controlState, badge, isSelected: isSelected), + icon: _buildBadgeIconNavigationDestination( + context, + icon, + modifier, + controlState, + badge, + isSelected: isSelected, + ), + selectedIcon: _buildBadgeIconNavigationDestination( + context, + icon, + modifier, + controlState, + badge, + isSelected: isSelected, + ), ), - ) + ), ], ); } @@ -191,8 +214,22 @@ class OudsNavigationBarItem { return BottomNavigationBarItem( label: label, - icon: _buildBadgeIconBottomNavigationBarItem(context, icon, modifier, controlState, badge, isSelected: isSelected), - activeIcon: _buildBadgeIconBottomNavigationBarItem(context, icon, modifier, controlState, badge, isSelected: isSelected), + icon: _buildBadgeIconBottomNavigationBarItem( + context, + icon, + modifier, + controlState, + badge, + isSelected: isSelected, + ), + activeIcon: _buildBadgeIconBottomNavigationBarItem( + context, + icon, + modifier, + controlState, + badge, + isSelected: isSelected, + ), ); } @@ -227,39 +264,29 @@ class OudsNavigationBarItem { height: 26, //sizeIcon.iconDecorativeExtraSmall, width: 26, //sizeIcon.iconDecorativeExtraSmall, colorFilter: ColorFilter.mode( - modifier.getTextIconItemColor( - controlState, - isSelected, - ), + modifier.getTextIconItemColor(controlState, isSelected), BlendMode.srcIn, ), ); - return badge != null - ? Column( - children: [ - _buildTopIndicatorBar(context, bar, isSelected, controlState), - SizedBox( - height: 2, - ), - OudsBadge.count( - semanticsLabel: badge.contentDescription, - label: badge.count.toString(), - status: Negative(), - size: badge.hasCount ? OudsBadgeSize.medium : OudsBadgeSize.xsmall, - child: widgetIcon, - ), - ], - ) - : Column( - children: [ - _buildTopIndicatorBar(context, bar, isSelected, controlState), - SizedBox( - height: 2, - ), - widgetIcon, - ], - ); + // Build the children list based on selection state + final children = [ + _buildTopIndicatorBar(context, bar, isSelected, controlState), + if (isSelected) const SizedBox(height: 2), + badge != null + ? OudsBadge.count( + semanticsLabel: badge.contentDescription, + label: badge.count.toString(), + status: Negative(), + size: badge.hasCount + ? OudsBadgeSize.medium + : OudsBadgeSize.xsmall, + child: widgetIcon, + ) + : widgetIcon, + ]; + + return Column(children: children); } } 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..056a8735f --- /dev/null +++ b/ouds_core/test/components/navigation/ouds_navigation_bar_indicator_animation_test.dart @@ -0,0 +1,193 @@ +/* + * // 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.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.byType(CustomPaint), findsOneWidget); + + // Move time forward to check animation progresses + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + expect(find.byType(CustomPaint), findsOneWidget); + + // Complete animation + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + expect(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); + }); + }); +} From 726667da1ae2e9fa723985f5a23827ce031eab0b Mon Sep 17 00:00:00 2001 From: Ahmed Amine Zribi Date: Fri, 22 May 2026 12:08:20 +0100 Subject: [PATCH 2/7] chore: Add border to badge in bar and textStyle --- .../navigation/ouds_navigation_bar.dart | 69 +++++++--- .../navigation/ouds_navigation_bar_item.dart | 52 ++++--- .../components/navigation/ouds_tab_bar.dart | 48 +++++-- .../utilities/badge_border_utils.dart | 130 ++++++++++++++++++ 4 files changed, 245 insertions(+), 54 deletions(-) create mode 100644 ouds_core/lib/components/utilities/badge_border_utils.dart diff --git a/ouds_core/lib/components/navigation/ouds_navigation_bar.dart b/ouds_core/lib/components/navigation/ouds_navigation_bar.dart index 6db78d747..c78cd9a41 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,22 +199,30 @@ 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( 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 784fbc4cb..04c93ae15 100644 --- a/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart +++ b/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart @@ -22,6 +22,7 @@ import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_sta import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_status_modifier.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; import 'package:ouds_theme_contract/theme/tokens/components/ouds_bar_tokens.dart'; +import 'package:ouds_core/components/utilities/badge_border_utils.dart'; /// /// An OUDS navigation bar item. @@ -100,23 +101,27 @@ 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( + semanticsLabel: badge.contentDescription, + 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. @@ -256,27 +261,30 @@ 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, ), ); - // Build the children list based on selection state final children = [ _buildTopIndicatorBar(context, bar, isSelected, controlState), const SizedBox(height: 2), badge != null - ? OudsBadge.count( - semanticsLabel: badge.contentDescription, - label: badge.count.toString(), - status: Negative(), - size: badge.hasCount - ? OudsBadgeSize.medium - : OudsBadgeSize.xsmall, - child: widgetIcon, + ? buildBadgeWithBorder( + context: context, + hasCount: badge.hasCount, + child: OudsBadge.count( + semanticsLabel: badge.contentDescription, + label: badge.count.toString(), + status: Negative(), + size: badge.hasCount + ? OudsBadgeSize.medium + : OudsBadgeSize.xsmall, + child: widgetIcon, + ), ) : widgetIcon, ]; diff --git a/ouds_core/lib/components/navigation/ouds_tab_bar.dart b/ouds_core/lib/components/navigation/ouds_tab_bar.dart index e4da0117a..868832a79 100644 --- a/ouds_core/lib/components/navigation/ouds_tab_bar.dart +++ b/ouds_core/lib/components/navigation/ouds_tab_bar.dart @@ -138,9 +138,18 @@ class _OudsTabBarState extends State { @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,8 +164,12 @@ 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); @@ -166,9 +179,14 @@ class _OudsTabBarState extends State { return CupertinoTheme( data: existingCupertinoTheme.copyWith( textTheme: existingCupertinoTheme.textTheme.copyWith( - tabLabelTextStyle: OudsTheme.of(context).typographyTokens.typeBodyModerateMedium(context).copyWith( - fontSize: 10 - ), // Apply the custom text style. + tabLabelTextStyle: + TextStyle( + overflow: TextOverflow.ellipsis, + fontFamily: OudsTheme.of(context).fontFamily, + ).copyWith( + fontSize: 10, + fontWeight: FontWeight.w500, + ), // Apply the custom text style. ), ), child: ClipRect( @@ -176,10 +194,18 @@ class _OudsTabBarState extends State { filter: navigationBarBorderModifier.getBlurNavigationBar(), child: CupertinoTabBar( currentIndex: safeIndex, - activeColor: navigationBarModifier.getTextIconItemColor(barControlState, true), - inactiveColor: navigationBarModifier.getTextIconItemColor(barControlState, false), + activeColor: navigationBarModifier.getTextIconItemColor( + barControlState, + true, + ), + inactiveColor: navigationBarModifier.getTextIconItemColor( + barControlState, + false, + ), border: navigationBarBorderModifier.getBorderNavigationBar(), - backgroundColor: navigationBarBgModifier.getBackgroundColor(widget.translucent), + backgroundColor: navigationBarBgModifier.getBackgroundColor( + widget.translucent, + ), items: List.generate( widget.items.length, (index) => widget.items[index].toBottomNavigationBarItem( diff --git a/ouds_core/lib/components/utilities/badge_border_utils.dart b/ouds_core/lib/components/utilities/badge_border_utils.dart new file mode 100644 index 000000000..9b9830336 --- /dev/null +++ b/ouds_core/lib/components/utilities/badge_border_utils.dart @@ -0,0 +1,130 @@ +/* + * // Software Name: OUDS Flutter + * // SPDX-FileCopyrightText: Copyright (c) Orange SA + * // SPDX-License-Identifier: MIT + * // + * // This software is distributed under the MIT license, + * // the text of which is available at https://opensource.org/license/MIT/ + * // or see the "LICENSE" file for more details. + * // + * // Software description: Flutter library of reusable graphical components + * // + */ + +/// @nodoc +library; + +import 'package:flutter/material.dart'; +import 'package:ouds_theme_contract/ouds_theme.dart'; + +/// Wraps [child] in a [Stack] and draws a 1-px circular border ring around +/// the [OudsBadge] indicator. +/// +/// The ring is rendered via [CustomPaint] — the badge itself is never altered. +/// Its position and radius adapt automatically to any icon size or +/// accessibility text-scale factor. +/// +/// **Parameters** +/// +/// - [context] — resolves badge-size tokens and the border colour from the +/// active OUDS theme. +/// - [child] — the badge widget to wrap (typically an [OudsBadge]). +/// - [hasCount] — `true` for a numeric count badge (medium, 16 dp), +/// `false` for a plain dot badge (xsmall, 8 dp). +/// +/// **Example — dot badge (xsmall)** +/// ```dart +/// buildBadgeWithBorder( +/// context: context, +/// hasCount: false, +/// child: myBadgeWidget, +/// ); +/// ``` +/// +/// **Example — count badge (medium)** +/// ```dart +/// buildBadgeWithBorder( +/// context: context, +/// hasCount: true, +/// child: myBadgeWidget, +/// ); +/// ``` +Widget buildBadgeWithBorder({ + required BuildContext context, + required Widget child, + required bool hasCount, +}) { + final bar = OudsTheme.of(context).componentsTokens(context).bar; + final badgeTokens = OudsTheme.of(context).componentsTokens(context).badge; + + final badgeRadius = + MediaQuery.textScalerOf( + context, + ).scale(hasCount ? badgeTokens.sizeMedium : badgeTokens.sizeXsmall) / + 2; + + return Stack( + clipBehavior: Clip.none, + children: [ + child, + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _BadgeBorderPainter( + badgeRadius: badgeRadius, + hasCount: hasCount, + borderColor: bar.colorBorderBadge, + ), + ), + ), + ), + ], + ); +} + +/// Paints the circular border ring around the badge indicator. +/// +/// The ring centre is derived from Flutter Badge's layout algorithm +/// ([_RenderBadge.performLayout]): +/// +/// - **Dot** (`!hasCount`): `centre = (width − r, r)` +/// - **Count** (`hasCount`): `centre = (width − r + 4, 4)` +/// where `4` is the LTR effective offset applied to labelled badges. +/// +/// The draw radius is set to `badgeRadius + strokeWidth / 2` so that the +/// inner edge of the stroke touches the badge boundary with no gap. +class _BadgeBorderPainter extends CustomPainter { + const _BadgeBorderPainter({ + required this.badgeRadius, + required this.hasCount, + required this.borderColor, + }); + + final double badgeRadius; + final bool hasCount; + final Color borderColor; + + @override + void paint(Canvas canvas, Size size) { + final double cx = hasCount + ? size.width - badgeRadius + 4 + : size.width - badgeRadius; + final double cy = hasCount ? 4.0 : badgeRadius; + + const double strokeWidth = 1.0; + canvas.drawCircle( + Offset(cx, cy), + badgeRadius + strokeWidth / 2, + Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth, + ); + } + + @override + bool shouldRepaint(covariant _BadgeBorderPainter old) => + old.badgeRadius != badgeRadius || + old.hasCount != hasCount || + old.borderColor != borderColor; +} From 35d1eb0e7c389740cc7951aab2d2235d97ceb584 Mon Sep 17 00:00:00 2001 From: Ahmed Amine Zribi Date: Fri, 22 May 2026 21:32:13 +0100 Subject: [PATCH 3/7] chore: resolve a11y zoom and voiceOver --- .../internal/ouds_navigation_bar_a11y.dart | 96 ++++++ .../navigation/ouds_navigation_bar_item.dart | 65 ++-- .../components/navigation/ouds_tab_bar.dart | 87 ++--- .../utilities/badge_border_utils.dart | 115 ++++--- .../ouds_navigation_bar_a11y_test.dart | 305 ++++++++++++++++++ 5 files changed, 532 insertions(+), 136 deletions(-) create mode 100644 ouds_core/lib/components/navigation/internal/ouds_navigation_bar_a11y.dart create mode 100644 ouds_core/test/components/navigation/ouds_navigation_bar_a11y_test.dart 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/ouds_navigation_bar_item.dart b/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart index 04c93ae15..b5544990d 100644 --- a/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart +++ b/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart @@ -20,43 +20,24 @@ import 'package:ouds_core/components/common/ouds_icon_status.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'; -import 'package:ouds_core/components/utilities/badge_border_utils.dart'; +/// A single destination in an OUDS bottom navigation component. /// -/// An OUDS navigation bar item. -/// -/// 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). +/// 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). /// -/// ### 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 { @@ -115,7 +96,10 @@ class OudsNavigationBarItem { context: context, hasCount: badge.hasCount, child: OudsBadge.count( - semanticsLabel: badge.contentDescription, + // semanticsLabel is intentionally null here: the accessible label is + // provided by the parent Semantics wrapper in toNavigationDestination, + // combining label + badge description in the correct reading order. + semanticsLabel: null, label: badge.count.toString(), status: Negative(), size: badge.hasCount ? OudsBadgeSize.medium : OudsBadgeSize.xsmall, @@ -191,6 +175,18 @@ class OudsNavigationBarItem { ), ), ), + // Badge node — placed BELOW NavigationDestination so TalkBack reads it + // after the item label and before the positional info from + // IndexedSemantics: "Label, badge description, Tab X of Y". + // + // SizedBox(height: 1) keeps the rect non-empty so Flutter does not + // mark the node invisible (0×0 rect → TalkBack silently skips it). + if (badge != null) + Semantics( + label: badge!.contentDescription, + container: true, + child: const SizedBox(height: 1), + ), ], ); } @@ -293,18 +289,13 @@ class OudsNavigationBarItem { } } -/// 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 868832a79..31b83914b 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'; @@ -176,52 +177,58 @@ class _OudsTabBarState extends State { // 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: - TextStyle( - overflow: TextOverflow.ellipsis, - fontFamily: OudsTheme.of(context).fontFamily, - ).copyWith( - fontSize: 10, - fontWeight: FontWeight.w500, - ), // Apply the custom text style. + // Cap text scale at 160 % to prevent icon / label / badge overlap at very + 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, + ), // Apply the custom text style. + ), ), - ), - 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, + child: ClipRect( + child: BackdropFilter( + filter: navigationBarBorderModifier.getBlurNavigationBar(), + child: CupertinoTabBar( + currentIndex: safeIndex, + activeColor: navigationBarModifier.getTextIconItemColor( barControlState, - isSelected: index == safeIndex, + 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, + ), ), + onTap: (index) { + if (index == safeIndex) return; + setState(() => _selectedIndex = index); + widget.onTap?.call(index); + }, ), - onTap: (index) { - if (index == safeIndex) return; - setState(() => _selectedIndex = index); - widget.onTap?.call(index); - }, ), ), - ), + ), // MediaQuery ); } } diff --git a/ouds_core/lib/components/utilities/badge_border_utils.dart b/ouds_core/lib/components/utilities/badge_border_utils.dart index 9b9830336..b5e8a5779 100644 --- a/ouds_core/lib/components/utilities/badge_border_utils.dart +++ b/ouds_core/lib/components/utilities/badge_border_utils.dart @@ -17,82 +17,71 @@ library; import 'package:flutter/material.dart'; import 'package:ouds_theme_contract/ouds_theme.dart'; -/// Wraps [child] in a [Stack] and draws a 1-px circular border ring around -/// the [OudsBadge] indicator. +/// Wraps [child] in a [_BadgeBorderWrapper] that draws a 1-px circular border +/// ring around the [OudsBadge] indicator. /// -/// The ring is rendered via [CustomPaint] — the badge itself is never altered. -/// Its position and radius adapt automatically to any icon size or -/// accessibility text-scale factor. +/// The wrapper is a [StatelessWidget] so `badgeRadius` is read from its own +/// [BuildContext] — the same context (and the same clamped [MediaQuery]) that +/// [OudsBadge] uses — preventing a stale-radius gap when the system text scale +/// exceeds the navigation-bar cap (e.g. on iOS at accessibility sizes > 160%). /// -/// **Parameters** -/// -/// - [context] — resolves badge-size tokens and the border colour from the -/// active OUDS theme. -/// - [child] — the badge widget to wrap (typically an [OudsBadge]). -/// - [hasCount] — `true` for a numeric count badge (medium, 16 dp), -/// `false` for a plain dot badge (xsmall, 8 dp). -/// -/// **Example — dot badge (xsmall)** -/// ```dart -/// buildBadgeWithBorder( -/// context: context, -/// hasCount: false, -/// child: myBadgeWidget, -/// ); -/// ``` -/// -/// **Example — count badge (medium)** -/// ```dart -/// buildBadgeWithBorder( -/// context: context, -/// hasCount: true, -/// child: myBadgeWidget, -/// ); -/// ``` +/// - [hasCount] `true` for a count badge (medium, 16 dp), +/// `false` for a dot badge (xsmall, 8 dp). Widget buildBadgeWithBorder({ required BuildContext context, required Widget child, required bool hasCount, }) { - final bar = OudsTheme.of(context).componentsTokens(context).bar; - final badgeTokens = OudsTheme.of(context).componentsTokens(context).badge; + return _BadgeBorderWrapper(hasCount: hasCount, child: child); +} + +class _BadgeBorderWrapper extends StatelessWidget { + const _BadgeBorderWrapper({required this.hasCount, required this.child}); - final badgeRadius = - MediaQuery.textScalerOf( - context, - ).scale(hasCount ? badgeTokens.sizeMedium : badgeTokens.sizeXsmall) / - 2; + final bool hasCount; + final Widget child; - return Stack( - clipBehavior: Clip.none, - children: [ - child, - Positioned.fill( - child: IgnorePointer( - child: CustomPaint( - painter: _BadgeBorderPainter( - badgeRadius: badgeRadius, - hasCount: hasCount, - borderColor: bar.colorBorderBadge, + @override + Widget build(BuildContext context) { + final bar = OudsTheme.of(context).componentsTokens(context).bar; + final badgeTokens = OudsTheme.of(context).componentsTokens(context).badge; + + final badgeRadius = + MediaQuery.textScalerOf( + context, + ).scale(hasCount ? badgeTokens.sizeMedium : badgeTokens.sizeXsmall) / + 2; + + return Stack( + clipBehavior: Clip.none, + children: [ + child, + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _BadgeBorderPainter( + badgeRadius: badgeRadius, + hasCount: hasCount, + borderColor: bar.colorBorderBadge, + ), ), ), ), - ), - ], - ); + ], + ); + } } /// Paints the circular border ring around the badge indicator. /// -/// The ring centre is derived from Flutter Badge's layout algorithm -/// ([_RenderBadge.performLayout]): +/// Centre derived from Flutter Badge's `_RenderBadge.performLayout`: /// -/// - **Dot** (`!hasCount`): `centre = (width − r, r)` -/// - **Count** (`hasCount`): `centre = (width − r + 4, 4)` -/// where `4` is the LTR effective offset applied to labelled badges. +/// - **Dot** (`!hasCount`): `centre = (W − r, r)` — tracks radius as zoom grows. +/// - **Count** (`hasCount`): `centre = (W − 12 + r, 4)` +/// where `W−12 = (W − largeSize16) + effectiveOffsetX4` is the fixed left edge +/// of the pill, and `r = badgeRadius ≈ pillWidth/2` grows with zoom. /// -/// The draw radius is set to `badgeRadius + strokeWidth / 2` so that the -/// inner edge of the stroke touches the badge boundary with no gap. +/// Draw radius = `badgeRadius + strokeWidth/2`. class _BadgeBorderPainter extends CustomPainter { const _BadgeBorderPainter({ required this.badgeRadius, @@ -106,8 +95,16 @@ class _BadgeBorderPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { + // Dot : Flutter Badge offset=Offset.zero, widthOffset=scaledSmallSize + // → top-left = (W − scaledSmallSize, 0) → centre = (W − r, r) + // + // Count: Flutter Badge effectiveOffset = Offset(4,4) (LTR default + Offset(0,8) fix). + // widthOffset = largeSize = 16 (fixed, unscaled). + // pill left edge = (W − 16) + 4 = W − 12 (constant). + // pill centre X = (W − 12) + pillWidth/2 ≈ (W − 12) + badgeRadius. + // pill centre Y = 4.0 (effectiveOffset.dy; ± pillHeight/2 cancels out). final double cx = hasCount - ? size.width - badgeRadius + 4 + ? size.width - 12.0 + badgeRadius : size.width - badgeRadius; final double cy = hasCount ? 4.0 : badgeRadius; 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)); + }); + }); +} From 40d6114737b775c77997a077e70391f96733a8af Mon Sep 17 00:00:00 2001 From: Ahmed Amine Zribi Date: Tue, 26 May 2026 13:24:01 +0100 Subject: [PATCH 4/7] fix: order of the accessible label sur android --- .../navigation/ouds_navigation_bar.dart | 7 ++ .../navigation/ouds_navigation_bar_item.dart | 80 +++++++++++-------- 2 files changed, 53 insertions(+), 34 deletions(-) diff --git a/ouds_core/lib/components/navigation/ouds_navigation_bar.dart b/ouds_core/lib/components/navigation/ouds_navigation_bar.dart index c78cd9a41..d80fa739c 100644 --- a/ouds_core/lib/components/navigation/ouds_navigation_bar.dart +++ b/ouds_core/lib/components/navigation/ouds_navigation_bar.dart @@ -229,6 +229,13 @@ class _OudsNavigationBarState extends State { 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 b5544990d..384a02480 100644 --- a/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart +++ b/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart @@ -17,6 +17,7 @@ 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'; @@ -96,9 +97,8 @@ class OudsNavigationBarItem { context: context, hasCount: badge.hasCount, child: OudsBadge.count( - // semanticsLabel is intentionally null here: the accessible label is - // provided by the parent Semantics wrapper in toNavigationDestination, - // combining label + badge description in the correct reading order. + // 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(), @@ -109,8 +109,6 @@ class OudsNavigationBarItem { } /// Builds the top indicator shown above the icon when the destination is selected. - /// Uses an animated indicator that expands from the center when selected and collapses when deselected. - /// Always reserves space for the indicator to avoid shifting icon and label on selection. Widget _buildTopIndicatorBar( BuildContext context, OudsBarTokens bar, @@ -141,52 +139,66 @@ 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), 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, + ), + ), ), ), ), - // Badge node — placed BELOW NavigationDestination so TalkBack reads it - // after the item label and before the positional info from - // IndexedSemantics: "Label, badge description, Tab X of Y". - // - // SizedBox(height: 1) keeps the rect non-empty so Flutter does not - // mark the node invisible (0×0 rect → TalkBack silently skips it). - if (badge != null) - Semantics( - label: badge!.contentDescription, - container: true, - child: const SizedBox(height: 1), - ), ], ); } From a6ec5463ab4c17b6d398dc4c2fbe30689df7e061 Mon Sep 17 00:00:00 2001 From: Ahmed Amine Zribi Date: Fri, 29 May 2026 14:04:47 +0100 Subject: [PATCH 5/7] fix: switching from on/off without animation ios --- ...ds_navigation_bar_indicator_animation.dart | 83 ++++++++++++------- .../navigation/ouds_navigation_bar_item.dart | 33 ++++++-- .../components/navigation/ouds_tab_bar.dart | 74 +++++++++++++---- 3 files changed, 143 insertions(+), 47 deletions(-) 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 index a69904650..d557fe29c 100644 --- 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 @@ -47,17 +47,17 @@ class _OudsIndicatorPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - // Calculate the expansion: starts from center and expands to edges + /// 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 + /// 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 + /// Only draw if width is positive if (rectWidth > 0) { final rect = Rect.fromLTWH(startX, 0, rectWidth, thickness); final rrect = RRect.fromRectAndRadius( @@ -84,6 +84,11 @@ class _OudsIndicatorPainter extends CustomPainter { } /// 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; @@ -103,6 +108,12 @@ class OudsAnimatedIndicator extends StatefulWidget { /// 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, @@ -110,7 +121,8 @@ class OudsAnimatedIndicator extends StatefulWidget { required this.thickness, required this.tabWidth, required this.borderRadius, - this.animationDuration = const Duration(milliseconds: 300), + this.animationDuration = const Duration(milliseconds: 240), + this.externalController, //240 Optional external controller for iOS }); @override @@ -119,24 +131,37 @@ class OudsAnimatedIndicator extends StatefulWidget { class _OudsAnimatedIndicatorState extends State with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _animation; + AnimationController? _internalController; + + /// Use external controller if provided, otherwise fallback to internal + AnimationController get _controller => + widget.externalController ?? _internalController!; @override void initState() { super.initState(); - _animationController = AnimationController( - duration: widget.animationDuration, - vsync: this, - ); - _animation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), - ); + /// Only create internal controller if no external one is provided (Android) + if (widget.externalController == null) { + _internalController = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); - // Start animation if initially selected - if (widget.isSelected) { - _animationController.forward(); + /// 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; + } } } @@ -144,20 +169,22 @@ class _OudsAnimatedIndicatorState extends State void didUpdateWidget(covariant OudsAnimatedIndicator oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.isSelected != oldWidget.isSelected) { - if (widget.isSelected) { - // Forward animation when selecting - _animationController.forward(); - } else { - // Reverse animation when deselecting - _animationController.reverse(); - } + /// 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() { - _animationController.dispose(); + /// Only dispose internal controller, never the external one + _internalController?.dispose(); super.dispose(); } @@ -167,11 +194,11 @@ class _OudsAnimatedIndicatorState extends State height: widget.thickness, width: widget.tabWidth, child: AnimatedBuilder( - animation: _animation, + animation: _controller, builder: (context, child) { return CustomPaint( painter: _OudsIndicatorPainter( - animationValue: _animation.value, + animationValue: _controller.value, color: widget.color, thickness: widget.thickness, tabWidth: widget.tabWidth, @@ -188,6 +215,6 @@ class _OudsAnimatedIndicatorState extends State extension OudsBarTokensIndicatorExtension on OudsBarTokens { /// Gets the animation duration for the indicator. Duration getIndicatorAnimationDuration() { - return const Duration(milliseconds: 300); + return const Duration(milliseconds: 240); } } 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 384a02480..35561c4b0 100644 --- a/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart +++ b/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart @@ -108,24 +108,31 @@ class OudsNavigationBarItem { ); } - /// Builds the top indicator shown above the icon when the destination is selected. + /// 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 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 ); } @@ -164,8 +171,8 @@ class OudsNavigationBarItem { 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: Semantics( // Override NavigationDestination's internal semantics to enforce @@ -217,6 +224,8 @@ class OudsNavigationBarItem { BuildContext context, OudsNavigationBarControlState controlState, { required bool isSelected, + required int index, + AnimationController? externalController, // Required for iOS animations }) { final modifier = OudsNavigationBarStatusModifier(context); @@ -229,6 +238,8 @@ class OudsNavigationBarItem { controlState, badge, isSelected: isSelected, + index: index, + externalController: externalController, // Pass to icon builder ), activeIcon: _buildBadgeIconBottomNavigationBarItem( context, @@ -237,6 +248,8 @@ class OudsNavigationBarItem { controlState, badge, isSelected: isSelected, + index: index, + externalController: externalController, // Pass to activeIcon builder ), ); } @@ -263,6 +276,8 @@ 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( @@ -278,7 +293,15 @@ class OudsNavigationBarItem { ); final children = [ - _buildTopIndicatorBar(context, bar, isSelected, controlState), + // iOS: pass external controller to survive rebuilds + _buildTopIndicatorBar( + context, + bar, + isSelected, + controlState, + index, + externalController: externalController, + ), const SizedBox(height: 2), badge != null ? buildBadgeWithBorder( diff --git a/ouds_core/lib/components/navigation/ouds_tab_bar.dart b/ouds_core/lib/components/navigation/ouds_tab_bar.dart index 31b83914b..47f986388 100644 --- a/ouds_core/lib/components/navigation/ouds_tab_bar.dart +++ b/ouds_core/lib/components/navigation/ouds_tab_bar.dart @@ -14,7 +14,6 @@ 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'; @@ -118,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. @@ -133,8 +148,40 @@ 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 @@ -185,14 +232,10 @@ class _OudsTabBarState extends State { 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, - ), // Apply the custom text style. + tabLabelTextStyle: TextStyle( + overflow: TextOverflow.ellipsis, + fontFamily: OudsTheme.of(context).fontFamily, + ).copyWith(fontSize: 10, fontWeight: FontWeight.w500), ), ), child: ClipRect( @@ -218,17 +261,20 @@ class _OudsTabBarState extends State { context, barControlState, isSelected: index == safeIndex, + index: index, + // Pass the external controller for iOS animation + externalController: _indicatorControllers[index], ), ), onTap: (index) { if (index == safeIndex) return; - setState(() => _selectedIndex = index); + _animateToIndex(index); // Trigger animation from parent widget.onTap?.call(index); }, ), ), ), - ), // MediaQuery + ), ); } } From 09af5336045d4bafa675d8e1967d14096c0fe83e Mon Sep 17 00:00:00 2001 From: Ahmed Amine Zribi Date: Fri, 29 May 2026 14:41:40 +0100 Subject: [PATCH 6/7] fix: VoiceOver a11y TabBar --- .../navigation/ouds_navigation_bar_item.dart | 45 +++--- .../components/navigation/ouds_tab_bar.dart | 128 +++++++++++++----- 2 files changed, 120 insertions(+), 53 deletions(-) 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 35561c4b0..f9fd34391 100644 --- a/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart +++ b/ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart @@ -216,22 +216,32 @@ 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, // Required for iOS animations + AnimationController? externalController, }) { final modifier = OudsNavigationBarStatusModifier(context); - return BottomNavigationBarItem( - label: label, - icon: _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, @@ -239,19 +249,17 @@ class OudsNavigationBarItem { badge, isSelected: isSelected, index: index, - externalController: externalController, // Pass to icon builder - ), - activeIcon: _buildBadgeIconBottomNavigationBarItem( - context, - icon, - modifier, - controlState, - badge, - isSelected: isSelected, - index: index, - externalController: externalController, // Pass to activeIcon builder + 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]), @@ -308,7 +316,8 @@ class OudsNavigationBarItem { context: context, hasCount: badge.hasCount, child: OudsBadge.count( - semanticsLabel: badge.contentDescription, + // All semantics suppressed — managed by OudsTabBar Stack overlay. + semanticsLabel: null, label: badge.count.toString(), status: Negative(), size: badge.hasCount diff --git a/ouds_core/lib/components/navigation/ouds_tab_bar.dart b/ouds_core/lib/components/navigation/ouds_tab_bar.dart index 47f986388..34f38e79c 100644 --- a/ouds_core/lib/components/navigation/ouds_tab_bar.dart +++ b/ouds_core/lib/components/navigation/ouds_tab_bar.dart @@ -14,6 +14,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'; @@ -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, @@ -122,7 +122,7 @@ class _OudsTabBarState extends State with TickerProviderStateMixin { int _selectedIndex = 0; - /// One AnimationController per tab, managed by the parent to survive rebuilds + /// One AnimationController per tab, managed by the parent to survive rebuilds. late List _indicatorControllers; @override @@ -130,7 +130,7 @@ class _OudsTabBarState extends State with TickerProviderStateMixin { super.initState(); _selectedIndex = widget.currentIndex.clamp(0, widget.items.length - 1); - /// Create one controller per tab with correct initial value + /// Create one controller per tab with correct initial value. _indicatorControllers = List.generate( widget.items.length, (index) => AnimationController( @@ -163,7 +163,7 @@ class _OudsTabBarState extends State with TickerProviderStateMixin { curve: Curves.easeInOut, ); - // Animate in the newly selected tab + // Animate in the newly selected tab _indicatorControllers[newIndex].animateTo( 1.0, duration: const Duration(milliseconds: 300), @@ -177,7 +177,7 @@ class _OudsTabBarState extends State with TickerProviderStateMixin { @override void dispose() { - // Dispose all controllers + // Dispose all controllers for (final controller in _indicatorControllers) { controller.dispose(); } @@ -220,11 +220,24 @@ class _OudsTabBarState extends State with TickerProviderStateMixin { ); 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); + // 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, @@ -241,36 +254,81 @@ class _OudsTabBarState extends State with TickerProviderStateMixin { 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, - index: index, - // Pass the external controller for iOS animation - externalController: _indicatorControllers[index], + // 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; - _animateToIndex(index); // Trigger animation from parent - widget.onTap?.call(index); - }, + ], ), ), ), From 80868a1fa3ae464dc1e6369c3135c268fb1cffd5 Mon Sep 17 00:00:00 2001 From: Ahmed Amine Zribi Date: Wed, 3 Jun 2026 14:18:02 +0200 Subject: [PATCH 7/7] chore: update a11y badge text --- app/CHANGELOG.md | 2 +- .../gen/ouds_flutter_app_localizations.dart | 12 +++++ .../ouds_flutter_app_localizations_ar.dart | 18 +++++++ .../ouds_flutter_app_localizations_en.dart | 15 ++++++ .../ouds_flutter_app_localizations_fr.dart | 15 ++++++ app/lib/l10n/ouds_flutter_ar.arb | 9 ++++ app/lib/l10n/ouds_flutter_en.arb | 9 ++++ app/lib/l10n/ouds_flutter_fr.arb | 9 ++++ app/lib/ui/components/components.dart | 1 + .../navigation_bar_customization_utils.dart | 53 ++++++++++++------- .../navigation_bar_demo_screen.dart | 42 ++++++++++----- 11 files changed, 152 insertions(+), 33 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 0d4899832..dc538d955 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [DemoApp][Library] update tokens 2.4.0 ([#726](https://github.com/Orange-OpenSource/ouds-flutter/issues/726)) ### Fixed -- [Library] `Bottom Bar` Inconsistent order of the accessible ([#625](https://github.com/Orange-OpenSource/ouds-flutter/issues/625)) +- [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)) 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: [