← Back to Blog

Flutter Badge and Notification Count Localization: Complete Guide

flutterbadgenotificationslocalizationcountsaccessibility

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

  1. Number formatting varies by locale (1,000 vs 1.000)
  2. Large number abbreviation (1K, 1M) differs across languages
  3. Badge positioning may need adjustment for RTL languages
  4. Accessibility requires localized announcements
  5. 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:

  1. Format numbers according to locale - Use NumberFormat for proper thousand separators
  2. Handle RTL layouts - Position badges correctly for bidirectional text
  3. Provide accessibility labels - Use pluralized messages for screen readers
  4. Animate thoughtfully - Alert users to changes without being disruptive
  5. Test across locales - Verify badge behavior in different languages
  6. 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.

Additional Resources