Flutter Badge and Notification Count Localization: Complete Guide
Badges and notification counts are essential UI elements that communicate important information to users. From unread message counts to shopping cart quantities, these small indicators need proper localization to be meaningful across different languages and cultures. This guide covers everything you need to know about localizing badges, counts, and notification indicators in Flutter.
Understanding Badge Localization Challenges
Badge localization involves more than translating text. Different cultures have varying conventions for displaying numbers, abbreviating large counts, and positioning indicators.
Key Considerations
- Number formatting varies by locale (1,000 vs 1.000)
- Large number abbreviation (1K, 1M) differs across languages
- Badge positioning may need adjustment for RTL languages
- Accessibility requires localized announcements
- Pluralization for count descriptions
Setting Up Badge Localization
ARB File Structure
{
"@@locale": "en",
"notificationBadgeCount": "{count, plural, =0{No notifications} =1{1 notification} other{{count} notifications}}",
"@notificationBadgeCount": {
"description": "Notification badge count with pluralization",
"placeholders": {
"count": {
"type": "int",
"example": "5"
}
}
},
"unreadMessages": "{count, plural, =0{No unread messages} =1{1 unread message} other{{count} unread messages}}",
"@unreadMessages": {
"description": "Unread message count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cartItemCount": "{count, plural, =0{Cart is empty} =1{1 item in cart} other{{count} items in cart}}",
"@cartItemCount": {
"description": "Shopping cart item count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"newBadgeLabel": "New",
"@newBadgeLabel": {
"description": "Label for new item badge"
},
"saleBadgeLabel": "Sale",
"@saleBadgeLabel": {
"description": "Label for sale/discount badge"
},
"hotBadgeLabel": "Hot",
"@hotBadgeLabel": {
"description": "Label for trending/hot item badge"
},
"limitedBadgeLabel": "Limited",
"@limitedBadgeLabel": {
"description": "Label for limited availability badge"
},
"outOfStockBadge": "Out of Stock",
"@outOfStockBadge": {
"description": "Badge for unavailable items"
},
"lowStockBadge": "Only {count} left",
"@lowStockBadge": {
"description": "Low stock warning badge",
"placeholders": {
"count": {
"type": "int"
}
}
},
"badgeOverflowIndicator": "{count}+",
"@badgeOverflowIndicator": {
"description": "Badge text when count exceeds display limit",
"placeholders": {
"count": {
"type": "int",
"example": "99"
}
}
}
}
Spanish Translations
{
"@@locale": "es",
"notificationBadgeCount": "{count, plural, =0{Sin notificaciones} =1{1 notificación} other{{count} notificaciones}}",
"unreadMessages": "{count, plural, =0{Sin mensajes no leídos} =1{1 mensaje no leído} other{{count} mensajes no leídos}}",
"cartItemCount": "{count, plural, =0{Carrito vacío} =1{1 artículo en el carrito} other{{count} artículos en el carrito}}",
"newBadgeLabel": "Nuevo",
"saleBadgeLabel": "Oferta",
"hotBadgeLabel": "Popular",
"limitedBadgeLabel": "Limitado",
"outOfStockBadge": "Agotado",
"lowStockBadge": "Solo quedan {count}",
"badgeOverflowIndicator": "{count}+"
}
Arabic Translations (RTL)
{
"@@locale": "ar",
"notificationBadgeCount": "{count, plural, =0{لا إشعارات} =1{إشعار واحد} two{إشعاران} few{{count} إشعارات} many{{count} إشعاراً} other{{count} إشعار}}",
"unreadMessages": "{count, plural, =0{لا رسائل غير مقروءة} =1{رسالة واحدة غير مقروءة} two{رسالتان غير مقروءتان} few{{count} رسائل غير مقروءة} many{{count} رسالة غير مقروءة} other{{count} رسالة غير مقروءة}}",
"cartItemCount": "{count, plural, =0{السلة فارغة} =1{عنصر واحد في السلة} two{عنصران في السلة} few{{count} عناصر في السلة} many{{count} عنصراً في السلة} other{{count} عنصر في السلة}}",
"newBadgeLabel": "جديد",
"saleBadgeLabel": "تخفيض",
"hotBadgeLabel": "رائج",
"limitedBadgeLabel": "محدود",
"outOfStockBadge": "نفد المخزون",
"lowStockBadge": "متبقي {count} فقط",
"badgeOverflowIndicator": "+{count}"
}
Building Localized Badge Components
Basic Localized Badge Widget
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';
class LocalizedBadge extends StatelessWidget {
final int count;
final Widget child;
final Color? backgroundColor;
final Color? textColor;
final int maxCount;
final bool showZero;
final BadgePosition position;
const LocalizedBadge({
super.key,
required this.count,
required this.child,
this.backgroundColor,
this.textColor,
this.maxCount = 99,
this.showZero = false,
this.position = BadgePosition.topEnd,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final isRtl = Directionality.of(context) == TextDirection.rtl;
if (count == 0 && !showZero) {
return child;
}
final badgeText = count > maxCount
? l10n.badgeOverflowIndicator(maxCount)
: _formatCount(count, locale);
return Stack(
clipBehavior: Clip.none,
children: [
child,
Positioned(
top: position.top,
right: isRtl ? null : position.right,
left: isRtl ? position.right : null,
bottom: position.bottom,
child: _BadgeContent(
text: badgeText,
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.error,
textColor: textColor ?? Colors.white,
semanticLabel: l10n.notificationBadgeCount(count),
),
),
],
);
}
String _formatCount(int count, Locale locale) {
final formatter = NumberFormat.decimalPattern(locale.toString());
return formatter.format(count);
}
}
class BadgePosition {
final double? top;
final double? right;
final double? bottom;
final double? left;
const BadgePosition({
this.top,
this.right,
this.bottom,
this.left,
});
static const topEnd = BadgePosition(top: -8, right: -8);
static const topStart = BadgePosition(top: -8, left: -8);
static const bottomEnd = BadgePosition(bottom: -8, right: -8);
static const bottomStart = BadgePosition(bottom: -8, left: -8);
}
class _BadgeContent extends StatelessWidget {
final String text;
final Color backgroundColor;
final Color textColor;
final String semanticLabel;
const _BadgeContent({
required this.text,
required this.backgroundColor,
required this.textColor,
required this.semanticLabel,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: semanticLabel,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
text,
style: TextStyle(
color: textColor,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}
Notification Badge with Animation
class AnimatedNotificationBadge extends StatefulWidget {
final int count;
final Widget child;
final Duration animationDuration;
const AnimatedNotificationBadge({
super.key,
required this.count,
required this.child,
this.animationDuration = const Duration(milliseconds: 300),
});
@override
State<AnimatedNotificationBadge> createState() => _AnimatedNotificationBadgeState();
}
class _AnimatedNotificationBadgeState extends State<AnimatedNotificationBadge>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
int _previousCount = 0;
@override
void initState() {
super.initState();
_previousCount = widget.count;
_controller = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_scaleAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.3), weight: 50),
TweenSequenceItem(tween: Tween(begin: 1.3, end: 1.0), weight: 50),
]).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void didUpdateWidget(AnimatedNotificationBadge oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.count != _previousCount && widget.count > 0) {
_controller.forward(from: 0);
_announceCountChange(context);
}
_previousCount = widget.count;
}
void _announceCountChange(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
SemanticsService.announce(
l10n.notificationBadgeCount(widget.count),
Directionality.of(context),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final isRtl = Directionality.of(context) == TextDirection.rtl;
if (widget.count == 0) {
return widget.child;
}
return Stack(
clipBehavior: Clip.none,
children: [
widget.child,
Positioned(
top: -8,
right: isRtl ? null : -8,
left: isRtl ? -8 : null,
child: ScaleTransition(
scale: _scaleAnimation,
child: _BadgeContent(
text: _formatBadgeCount(widget.count, locale),
backgroundColor: Theme.of(context).colorScheme.error,
textColor: Colors.white,
semanticLabel: l10n.notificationBadgeCount(widget.count),
),
),
),
],
);
}
String _formatBadgeCount(int count, Locale locale) {
if (count > 99) {
return '99+';
}
return NumberFormat.decimalPattern(locale.toString()).format(count);
}
}
Large Number Abbreviation
class LocalizedCountFormatter {
static String formatCompact(int count, Locale locale) {
final formatter = NumberFormat.compact(locale: locale.toString());
return formatter.format(count);
}
static String formatWithAbbreviation(
int count,
Locale locale,
AppLocalizations l10n,
) {
if (count < 1000) {
return NumberFormat.decimalPattern(locale.toString()).format(count);
} else if (count < 1000000) {
final thousands = count / 1000;
return _formatAbbreviated(thousands, 'K', locale);
} else if (count < 1000000000) {
final millions = count / 1000000;
return _formatAbbreviated(millions, 'M', locale);
} else {
final billions = count / 1000000000;
return _formatAbbreviated(billions, 'B', locale);
}
}
static String _formatAbbreviated(double value, String suffix, Locale locale) {
final formatter = NumberFormat('#.#', locale.toString());
return '${formatter.format(value)}$suffix';
}
}
// Usage in badge widget
class CompactBadge extends StatelessWidget {
final int count;
final Widget child;
const CompactBadge({
super.key,
required this.count,
required this.child,
});
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
final l10n = AppLocalizations.of(context)!;
final displayText = LocalizedCountFormatter.formatWithAbbreviation(
count,
locale,
l10n,
);
return Badge(
label: Text(displayText),
child: child,
);
}
}
Shopping Cart Badge
Cart Badge with Localized Count
class CartBadge extends StatelessWidget {
final int itemCount;
final VoidCallback? onTap;
const CartBadge({
super.key,
required this.itemCount,
this.onTap,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Semantics(
button: true,
label: l10n.cartItemCount(itemCount),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.shopping_cart_outlined, size: 28),
if (itemCount > 0)
Positioned(
top: -8,
right: isRtl ? null : -8,
left: isRtl ? -8 : null,
child: _CartBadgeCounter(count: itemCount),
),
],
),
),
),
);
}
}
class _CartBadgeCounter extends StatelessWidget {
final int count;
const _CartBadgeCounter({required this.count});
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
final displayCount = count > 99 ? '99+' : count.toString();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
constraints: const BoxConstraints(minWidth: 20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: 2,
),
),
child: Text(
displayCount,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 11,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
);
}
}
Text Label Badges
Localized Status Badges
enum BadgeType {
newItem,
sale,
hot,
limited,
outOfStock,
lowStock,
featured,
bestseller,
}
class StatusBadge extends StatelessWidget {
final BadgeType type;
final int? stockCount;
const StatusBadge({
super.key,
required this.type,
this.stockCount,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final config = _getBadgeConfig(type, l10n, stockCount);
return Semantics(
label: config.label,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: config.backgroundColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
config.label,
style: TextStyle(
color: config.textColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
);
}
_BadgeConfig _getBadgeConfig(
BadgeType type,
AppLocalizations l10n,
int? stockCount,
) {
switch (type) {
case BadgeType.newItem:
return _BadgeConfig(
label: l10n.newBadgeLabel,
backgroundColor: Colors.green,
textColor: Colors.white,
);
case BadgeType.sale:
return _BadgeConfig(
label: l10n.saleBadgeLabel,
backgroundColor: Colors.red,
textColor: Colors.white,
);
case BadgeType.hot:
return _BadgeConfig(
label: l10n.hotBadgeLabel,
backgroundColor: Colors.orange,
textColor: Colors.white,
);
case BadgeType.limited:
return _BadgeConfig(
label: l10n.limitedBadgeLabel,
backgroundColor: Colors.purple,
textColor: Colors.white,
);
case BadgeType.outOfStock:
return _BadgeConfig(
label: l10n.outOfStockBadge,
backgroundColor: Colors.grey,
textColor: Colors.white,
);
case BadgeType.lowStock:
return _BadgeConfig(
label: l10n.lowStockBadge(stockCount ?? 0),
backgroundColor: Colors.amber,
textColor: Colors.black,
);
case BadgeType.featured:
return _BadgeConfig(
label: l10n.featuredBadgeLabel,
backgroundColor: Colors.blue,
textColor: Colors.white,
);
case BadgeType.bestseller:
return _BadgeConfig(
label: l10n.bestsellerBadgeLabel,
backgroundColor: Colors.teal,
textColor: Colors.white,
);
}
}
}
class _BadgeConfig {
final String label;
final Color backgroundColor;
final Color textColor;
_BadgeConfig({
required this.label,
required this.backgroundColor,
required this.textColor,
});
}
Discount Badge with Percentage
class DiscountBadge extends StatelessWidget {
final double discountPercentage;
const DiscountBadge({
super.key,
required this.discountPercentage,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final formatter = NumberFormat.percentPattern(locale.toString());
final formattedDiscount = formatter.format(discountPercentage / 100);
final label = l10n.discountBadgeLabel(formattedDiscount);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
);
}
}
App Icon Badge (Platform Integration)
Flutter Local Notifications Badge
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class AppBadgeManager {
final FlutterLocalNotificationsPlugin _notifications;
AppBadgeManager(this._notifications);
Future<void> updateBadgeCount(int count) async {
// iOS badge update
await _notifications
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(badge: true);
// The badge count is set through notifications
// or using a dedicated package
}
Future<void> clearBadge() async {
await updateBadgeCount(0);
}
}
Using flutter_app_badger Package
import 'package:flutter_app_badger/flutter_app_badger.dart';
class BadgeService {
Future<bool> get isSupported async {
return await FlutterAppBadger.isAppBadgeSupported();
}
Future<void> setBadgeCount(int count) async {
if (await isSupported) {
FlutterAppBadger.updateBadgeCount(count);
}
}
Future<void> removeBadge() async {
if (await isSupported) {
FlutterAppBadger.removeBadge();
}
}
}
// Integration with notification count
class NotificationBadgeSync {
final BadgeService _badgeService;
final NotificationRepository _repository;
NotificationBadgeSync(this._badgeService, this._repository);
Future<void> syncBadgeCount() async {
final unreadCount = await _repository.getUnreadCount();
await _badgeService.setBadgeCount(unreadCount);
}
Future<void> markAsRead(String notificationId) async {
await _repository.markAsRead(notificationId);
await syncBadgeCount();
}
Future<void> markAllAsRead() async {
await _repository.markAllAsRead();
await _badgeService.removeBadge();
}
}
Dot Badge Indicator
Simple Dot Badge
class DotBadge extends StatelessWidget {
final bool showBadge;
final Widget child;
final Color? color;
final double size;
const DotBadge({
super.key,
required this.showBadge,
required this.child,
this.color,
this.size = 10,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
if (!showBadge) {
return child;
}
return Semantics(
label: l10n.newNotificationIndicator,
child: Stack(
clipBehavior: Clip.none,
children: [
child,
Positioned(
top: 0,
right: isRtl ? null : 0,
left: isRtl ? 0 : null,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color ?? Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: 2,
),
),
),
),
],
),
);
}
}
Animated Dot Badge
class PulsingDotBadge extends StatefulWidget {
final bool showBadge;
final Widget child;
final Color? color;
const PulsingDotBadge({
super.key,
required this.showBadge,
required this.child,
this.color,
});
@override
State<PulsingDotBadge> createState() => _PulsingDotBadgeState();
}
class _PulsingDotBadgeState extends State<PulsingDotBadge>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat(reverse: true);
_animation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
if (!widget.showBadge) {
return widget.child;
}
return Semantics(
label: l10n.urgentNotificationIndicator,
child: Stack(
clipBehavior: Clip.none,
children: [
widget.child,
Positioned(
top: 0,
right: isRtl ? null : 0,
left: isRtl ? 0 : null,
child: FadeTransition(
opacity: _animation,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: widget.color ?? Colors.red,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (widget.color ?? Colors.red).withOpacity(0.5),
blurRadius: 4,
spreadRadius: 2,
),
],
),
),
),
),
],
),
);
}
}
Badge in Bottom Navigation
Localized Navigation Badge
class LocalizedBottomNavigation extends StatelessWidget {
final int currentIndex;
final Function(int) onTap;
final int notificationCount;
final int cartCount;
final bool hasNewMessages;
const LocalizedBottomNavigation({
super.key,
required this.currentIndex,
required this.onTap,
this.notificationCount = 0,
this.cartCount = 0,
this.hasNewMessages = false,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return BottomNavigationBar(
currentIndex: currentIndex,
onTap: onTap,
type: BottomNavigationBarType.fixed,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home),
label: l10n.navHome,
),
BottomNavigationBarItem(
icon: _buildBadgedIcon(
const Icon(Icons.notifications_outlined),
notificationCount,
context,
),
activeIcon: _buildBadgedIcon(
const Icon(Icons.notifications),
notificationCount,
context,
),
label: l10n.navNotifications,
),
BottomNavigationBarItem(
icon: DotBadge(
showBadge: hasNewMessages,
child: const Icon(Icons.chat_outlined),
),
activeIcon: DotBadge(
showBadge: hasNewMessages,
child: const Icon(Icons.chat),
),
label: l10n.navMessages,
),
BottomNavigationBarItem(
icon: _buildBadgedIcon(
const Icon(Icons.shopping_cart_outlined),
cartCount,
context,
),
activeIcon: _buildBadgedIcon(
const Icon(Icons.shopping_cart),
cartCount,
context,
),
label: l10n.navCart,
),
],
);
}
Widget _buildBadgedIcon(Widget icon, int count, BuildContext context) {
if (count == 0) return icon;
return Badge(
label: Text(
count > 99 ? '99+' : count.toString(),
style: const TextStyle(fontSize: 10),
),
child: icon,
);
}
}
Accessibility Considerations
Screen Reader Announcements
class AccessibleBadge extends StatefulWidget {
final int count;
final Widget child;
final String Function(int) getAnnouncementText;
const AccessibleBadge({
super.key,
required this.count,
required this.child,
required this.getAnnouncementText,
});
@override
State<AccessibleBadge> createState() => _AccessibleBadgeState();
}
class _AccessibleBadgeState extends State<AccessibleBadge> {
int _previousCount = 0;
@override
void initState() {
super.initState();
_previousCount = widget.count;
}
@override
void didUpdateWidget(AccessibleBadge oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.count != _previousCount) {
_announceChange();
_previousCount = widget.count;
}
}
void _announceChange() {
final announcement = widget.getAnnouncementText(widget.count);
SemanticsService.announce(
announcement,
Directionality.of(context),
);
}
@override
Widget build(BuildContext context) {
return Semantics(
label: widget.getAnnouncementText(widget.count),
child: LocalizedBadge(
count: widget.count,
child: widget.child,
),
);
}
}
// Usage
AccessibleBadge(
count: unreadCount,
getAnnouncementText: (count) => AppLocalizations.of(context)!.unreadMessages(count),
child: const Icon(Icons.mail),
);
High Contrast Badge
class HighContrastBadge extends StatelessWidget {
final int count;
final Widget child;
const HighContrastBadge({
super.key,
required this.count,
required this.child,
});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final isHighContrast = mediaQuery.highContrast;
return LocalizedBadge(
count: count,
backgroundColor: isHighContrast ? Colors.black : null,
textColor: isHighContrast ? Colors.white : null,
child: child,
);
}
}
Testing Badge Localization
Widget Tests
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
group('LocalizedBadge', () {
testWidgets('displays formatted count for locale', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: LocalizedBadge(
count: 1234,
child: const Icon(Icons.notifications),
),
),
),
);
// German uses dots for thousands
expect(find.text('1.234'), findsOneWidget);
});
testWidgets('shows overflow indicator for large counts', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: LocalizedBadge(
count: 150,
maxCount: 99,
child: const Icon(Icons.notifications),
),
),
),
);
expect(find.text('99+'), findsOneWidget);
});
testWidgets('positions correctly for RTL', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ar'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
body: LocalizedBadge(
count: 5,
child: Icon(Icons.notifications),
),
),
),
),
);
final positioned = tester.widget<Positioned>(
find.byType(Positioned),
);
expect(positioned.left, isNotNull);
expect(positioned.right, isNull);
});
testWidgets('provides semantic label', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: LocalizedBadge(
count: 3,
child: const Icon(Icons.notifications),
),
),
),
);
final semantics = tester.getSemantics(find.byType(LocalizedBadge));
expect(semantics.label, contains('notification'));
});
});
group('CartBadge', () {
testWidgets('uses correct pluralization', (tester) async {
for (final count in [0, 1, 2, 5]) {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: CartBadge(itemCount: count),
),
),
);
final semantics = tester.getSemantics(find.byType(CartBadge));
if (count == 0) {
expect(semantics.label, contains('empty'));
} else if (count == 1) {
expect(semantics.label, contains('1 item'));
} else {
expect(semantics.label, contains('items'));
}
await tester.pumpWidget(const SizedBox()); // Reset
}
});
});
}
Integration Tests
import 'package:integration_test/integration_test.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('badge updates across app navigation', (tester) async {
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
// Initial state - no badges
expect(find.byType(Badge), findsNothing);
// Trigger notification
await tester.tap(find.text('Add Notification'));
await tester.pumpAndSettle();
// Verify badge appears
expect(find.byType(Badge), findsOneWidget);
expect(find.text('1'), findsOneWidget);
// Navigate to notifications
await tester.tap(find.byIcon(Icons.notifications));
await tester.pumpAndSettle();
// Verify badge cleared after viewing
expect(find.byType(Badge), findsNothing);
});
}
Best Practices
1. Consistent Styling
class BadgeTheme extends ThemeExtension<BadgeTheme> {
final Color primaryBadgeColor;
final Color secondaryBadgeColor;
final Color warningBadgeColor;
final TextStyle badgeTextStyle;
final double badgeRadius;
const BadgeTheme({
required this.primaryBadgeColor,
required this.secondaryBadgeColor,
required this.warningBadgeColor,
required this.badgeTextStyle,
required this.badgeRadius,
});
@override
BadgeTheme copyWith({
Color? primaryBadgeColor,
Color? secondaryBadgeColor,
Color? warningBadgeColor,
TextStyle? badgeTextStyle,
double? badgeRadius,
}) {
return BadgeTheme(
primaryBadgeColor: primaryBadgeColor ?? this.primaryBadgeColor,
secondaryBadgeColor: secondaryBadgeColor ?? this.secondaryBadgeColor,
warningBadgeColor: warningBadgeColor ?? this.warningBadgeColor,
badgeTextStyle: badgeTextStyle ?? this.badgeTextStyle,
badgeRadius: badgeRadius ?? this.badgeRadius,
);
}
@override
BadgeTheme lerp(ThemeExtension<BadgeTheme>? other, double t) {
if (other is! BadgeTheme) return this;
return BadgeTheme(
primaryBadgeColor: Color.lerp(primaryBadgeColor, other.primaryBadgeColor, t)!,
secondaryBadgeColor: Color.lerp(secondaryBadgeColor, other.secondaryBadgeColor, t)!,
warningBadgeColor: Color.lerp(warningBadgeColor, other.warningBadgeColor, t)!,
badgeTextStyle: TextStyle.lerp(badgeTextStyle, other.badgeTextStyle, t)!,
badgeRadius: lerpDouble(badgeRadius, other.badgeRadius, t)!,
);
}
}
2. Badge State Management
class BadgeState {
final int notificationCount;
final int cartCount;
final int messageCount;
final bool hasUpdates;
const BadgeState({
this.notificationCount = 0,
this.cartCount = 0,
this.messageCount = 0,
this.hasUpdates = false,
});
BadgeState copyWith({
int? notificationCount,
int? cartCount,
int? messageCount,
bool? hasUpdates,
}) {
return BadgeState(
notificationCount: notificationCount ?? this.notificationCount,
cartCount: cartCount ?? this.cartCount,
messageCount: messageCount ?? this.messageCount,
hasUpdates: hasUpdates ?? this.hasUpdates,
);
}
}
class BadgeNotifier extends ChangeNotifier {
BadgeState _state = const BadgeState();
BadgeState get state => _state;
void updateNotificationCount(int count) {
_state = _state.copyWith(notificationCount: count);
notifyListeners();
}
void updateCartCount(int count) {
_state = _state.copyWith(cartCount: count);
notifyListeners();
}
void clearAll() {
_state = const BadgeState();
notifyListeners();
}
}
Conclusion
Proper badge localization enhances user experience across global markets. Key takeaways:
- Format numbers according to locale - Use
NumberFormatfor proper thousand separators - Handle RTL layouts - Position badges correctly for bidirectional text
- Provide accessibility labels - Use pluralized messages for screen readers
- Animate thoughtfully - Alert users to changes without being disruptive
- Test across locales - Verify badge behavior in different languages
- Use compact formatting - Abbreviate large numbers appropriately for each locale
By implementing these patterns, your Flutter app will display badges that feel native to users worldwide.