← Back to Blog

Flutter Card Localization: Complete Guide to Titles, Actions, and Content

fluttercardmateriallocalizationproductsdashboard

Flutter Card Localization: Complete Guide to Titles, Actions, and Content

Cards are fundamental Material Design components used extensively in Flutter apps. From product displays to user profiles, from news articles to dashboard widgets, cards organize content in digestible chunks. Properly localizing cards ensures your content resonates with users worldwide.

Why Card Localization Matters

Cards often contain the most important information users interact with. A poorly localized card can confuse users, break layouts, and diminish trust. Proper card localization adapts text, images, formatting, and layout direction to match user expectations.

Basic Card Localization

Let's start with a simple localized card:

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedCard extends StatelessWidget {
  const LocalizedCard({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              l10n.welcomeTitle,
              style: Theme.of(context).textTheme.headlineSmall,
            ),
            const SizedBox(height: 8),
            Text(l10n.welcomeDescription),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.end,
              children: [
                TextButton(
                  onPressed: () {},
                  child: Text(l10n.learnMore),
                ),
                const SizedBox(width: 8),
                FilledButton(
                  onPressed: () {},
                  child: Text(l10n.getStarted),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

ARB file entries:

{
  "welcomeTitle": "Welcome to Our App",
  "@welcomeTitle": {
    "description": "Title on welcome card"
  },
  "welcomeDescription": "Discover amazing features that help you achieve your goals.",
  "@welcomeDescription": {
    "description": "Description text on welcome card"
  },
  "learnMore": "Learn More",
  "@learnMore": {
    "description": "Button to learn more"
  },
  "getStarted": "Get Started",
  "@getStarted": {
    "description": "Button to start using the app"
  }
}

Product Cards with Localized Pricing

E-commerce apps need locale-aware product cards:

class ProductCard extends StatelessWidget {
  final Product product;

  const ProductCard({
    super.key,
    required this.product,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);

    return Card(
      clipBehavior: Clip.antiAlias,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Product image with localized alt text
          AspectRatio(
            aspectRatio: 16 / 9,
            child: Image.network(
              product.imageUrl,
              fit: BoxFit.cover,
              semanticLabel: l10n.productImage(product.name),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                // Product name (may need translation)
                Text(
                  product.localizedName(locale),
                  style: Theme.of(context).textTheme.titleMedium,
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
                const SizedBox(height: 4),
                // Localized price formatting
                _buildPriceRow(context),
                const SizedBox(height: 8),
                // Rating with localized count
                _buildRatingRow(context),
                const SizedBox(height: 12),
                // Localized action buttons
                _buildActionButtons(context),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildPriceRow(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);
    final currencyFormat = NumberFormat.currency(
      locale: locale.toString(),
      symbol: _getCurrencySymbol(locale),
    );

    return Row(
      children: [
        Text(
          currencyFormat.format(product.price),
          style: Theme.of(context).textTheme.titleLarge?.copyWith(
            fontWeight: FontWeight.bold,
            color: Theme.of(context).colorScheme.primary,
          ),
        ),
        if (product.originalPrice != null) ...[
          const SizedBox(width: 8),
          Text(
            currencyFormat.format(product.originalPrice),
            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
              decoration: TextDecoration.lineThrough,
              color: Colors.grey,
            ),
          ),
          const SizedBox(width: 8),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
            decoration: BoxDecoration(
              color: Colors.red,
              borderRadius: BorderRadius.circular(4),
            ),
            child: Text(
              l10n.discountBadge(product.discountPercent),
              style: const TextStyle(
                color: Colors.white,
                fontSize: 12,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ],
      ],
    );
  }

  Widget _buildRatingRow(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);

    return Row(
      children: [
        ...List.generate(
          5,
          (index) => Icon(
            index < product.rating.floor()
                ? Icons.star
                : (index < product.rating ? Icons.star_half : Icons.star_border),
            size: 16,
            color: Colors.amber,
          ),
        ),
        const SizedBox(width: 4),
        Text(
          NumberFormat.decimalPattern(locale.toString())
              .format(product.rating),
          style: Theme.of(context).textTheme.bodySmall,
        ),
        const SizedBox(width: 4),
        Text(
          l10n.reviewCount(product.reviewCount),
          style: Theme.of(context).textTheme.bodySmall?.copyWith(
            color: Colors.grey,
          ),
        ),
      ],
    );
  }

  Widget _buildActionButtons(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Row(
      children: [
        Expanded(
          child: OutlinedButton.icon(
            onPressed: () => _addToWishlist(context),
            icon: Icon(
              product.isInWishlist ? Icons.favorite : Icons.favorite_border,
            ),
            label: Text(
              product.isInWishlist
                  ? l10n.removeFromWishlist
                  : l10n.addToWishlist,
            ),
          ),
        ),
        const SizedBox(width: 8),
        Expanded(
          child: FilledButton.icon(
            onPressed: product.inStock
                ? () => _addToCart(context)
                : null,
            icon: const Icon(Icons.shopping_cart),
            label: Text(
              product.inStock ? l10n.addToCart : l10n.outOfStock,
            ),
          ),
        ),
      ],
    );
  }

  String _getCurrencySymbol(Locale locale) {
    // Map locales to currency symbols
    final currencies = {
      'en_US': '\$',
      'en_GB': '£',
      'de': '€',
      'ja': '¥',
      'ar': 'ر.س',
    };
    return currencies[locale.toString()] ?? '\$';
  }
}

ARB entries for product cards:

{
  "productImage": "Image of {productName}",
  "@productImage": {
    "description": "Semantic label for product image",
    "placeholders": {
      "productName": {
        "type": "String",
        "example": "Wireless Headphones"
      }
    }
  },
  "discountBadge": "-{percent}%",
  "@discountBadge": {
    "description": "Discount percentage badge",
    "placeholders": {
      "percent": {
        "type": "int",
        "example": "20"
      }
    }
  },
  "reviewCount": "({count} reviews)",
  "@reviewCount": {
    "description": "Number of reviews",
    "placeholders": {
      "count": {
        "type": "int",
        "format": "compact",
        "example": "1234"
      }
    }
  },
  "addToWishlist": "Wishlist",
  "@addToWishlist": {
    "description": "Button to add product to wishlist"
  },
  "removeFromWishlist": "Saved",
  "@removeFromWishlist": {
    "description": "Button when product is in wishlist"
  },
  "addToCart": "Add to Cart",
  "@addToCart": {
    "description": "Button to add product to shopping cart"
  },
  "outOfStock": "Out of Stock",
  "@outOfStock": {
    "description": "Shown when product is not available"
  }
}

News Article Cards

News and blog apps need content-rich localized cards:

class ArticleCard extends StatelessWidget {
  final Article article;

  const ArticleCard({
    super.key,
    required this.article,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);

    return Card(
      clipBehavior: Clip.antiAlias,
      child: InkWell(
        onTap: () => _openArticle(context),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Featured image
            if (article.imageUrl != null)
              AspectRatio(
                aspectRatio: 16 / 9,
                child: Stack(
                  fit: StackFit.expand,
                  children: [
                    Image.network(
                      article.imageUrl!,
                      fit: BoxFit.cover,
                    ),
                    // Category badge
                    Positioned(
                      top: 8,
                      left: 8,
                      child: Container(
                        padding: const EdgeInsets.symmetric(
                          horizontal: 8,
                          vertical: 4,
                        ),
                        decoration: BoxDecoration(
                          color: _getCategoryColor(article.category),
                          borderRadius: BorderRadius.circular(4),
                        ),
                        child: Text(
                          l10n.articleCategory(article.category),
                          style: const TextStyle(
                            color: Colors.white,
                            fontSize: 12,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // Title
                  Text(
                    article.localizedTitle(locale),
                    style: Theme.of(context).textTheme.titleMedium?.copyWith(
                      fontWeight: FontWeight.bold,
                    ),
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                  ),
                  const SizedBox(height: 8),
                  // Excerpt
                  Text(
                    article.localizedExcerpt(locale),
                    style: Theme.of(context).textTheme.bodyMedium,
                    maxLines: 3,
                    overflow: TextOverflow.ellipsis,
                  ),
                  const SizedBox(height: 12),
                  // Metadata row
                  _buildMetadataRow(context),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildMetadataRow(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);

    return Row(
      children: [
        // Author
        CircleAvatar(
          radius: 12,
          backgroundImage: article.author.avatarUrl != null
              ? NetworkImage(article.author.avatarUrl!)
              : null,
          child: article.author.avatarUrl == null
              ? Text(article.author.initials, style: const TextStyle(fontSize: 10))
              : null,
        ),
        const SizedBox(width: 8),
        Expanded(
          child: Text(
            article.author.name,
            style: Theme.of(context).textTheme.bodySmall,
            overflow: TextOverflow.ellipsis,
          ),
        ),
        // Date
        Text(
          _formatDate(context, article.publishedAt),
          style: Theme.of(context).textTheme.bodySmall?.copyWith(
            color: Colors.grey,
          ),
        ),
        const SizedBox(width: 8),
        // Reading time
        Text(
          l10n.readingTime(article.readingTimeMinutes),
          style: Theme.of(context).textTheme.bodySmall?.copyWith(
            color: Colors.grey,
          ),
        ),
      ],
    );
  }

  String _formatDate(BuildContext context, DateTime date) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context).toString();
    final now = DateTime.now();
    final difference = now.difference(date);

    if (difference.inHours < 1) {
      return l10n.justNow;
    } else if (difference.inHours < 24) {
      return l10n.hoursAgo(difference.inHours);
    } else if (difference.inDays < 7) {
      return l10n.daysAgo(difference.inDays);
    } else {
      return DateFormat.MMMd(locale).format(date);
    }
  }

  Color _getCategoryColor(String category) {
    final colors = {
      'technology': Colors.blue,
      'business': Colors.green,
      'sports': Colors.orange,
      'entertainment': Colors.purple,
      'health': Colors.red,
    };
    return colors[category] ?? Colors.grey;
  }
}

ARB entries:

{
  "articleCategory": "{category, select, technology{Technology} business{Business} sports{Sports} entertainment{Entertainment} health{Health} other{General}}",
  "@articleCategory": {
    "description": "Localized article category name",
    "placeholders": {
      "category": {
        "type": "String"
      }
    }
  },
  "readingTime": "{minutes} min read",
  "@readingTime": {
    "description": "Estimated reading time",
    "placeholders": {
      "minutes": {
        "type": "int",
        "example": "5"
      }
    }
  },
  "justNow": "Just now",
  "@justNow": {
    "description": "Published less than an hour ago"
  },
  "hoursAgo": "{hours, plural, =1{1 hour ago} other{{hours} hours ago}}",
  "@hoursAgo": {
    "description": "Published hours ago",
    "placeholders": {
      "hours": {
        "type": "int"
      }
    }
  },
  "daysAgo": "{days, plural, =1{1 day ago} other{{days} days ago}}",
  "@daysAgo": {
    "description": "Published days ago",
    "placeholders": {
      "days": {
        "type": "int"
      }
    }
  }
}

Dashboard Stat Cards

Analytics dashboards need number-formatted cards:

class StatCard extends StatelessWidget {
  final String titleKey;
  final num value;
  final num? previousValue;
  final IconData icon;
  final Color? color;

  const StatCard({
    super.key,
    required this.titleKey,
    required this.value,
    this.previousValue,
    required this.icon,
    this.color,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(icon, color: color ?? Theme.of(context).colorScheme.primary),
                const Spacer(),
                if (previousValue != null) _buildChangeIndicator(context),
              ],
            ),
            const SizedBox(height: 12),
            Text(
              _formatValue(locale, value),
              style: Theme.of(context).textTheme.headlineMedium?.copyWith(
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 4),
            Text(
              _getLocalizedTitle(l10n, titleKey),
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                color: Colors.grey,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildChangeIndicator(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);
    final change = ((value - previousValue!) / previousValue! * 100);
    final isPositive = change >= 0;

    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: isPositive ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
        borderRadius: BorderRadius.circular(4),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(
            isPositive ? Icons.arrow_upward : Icons.arrow_downward,
            size: 14,
            color: isPositive ? Colors.green : Colors.red,
          ),
          const SizedBox(width: 2),
          Text(
            l10n.percentageChange(
              NumberFormat.decimalPattern(locale.toString())
                  .format(change.abs()),
            ),
            style: TextStyle(
              fontSize: 12,
              fontWeight: FontWeight.bold,
              color: isPositive ? Colors.green : Colors.red,
            ),
          ),
        ],
      ),
    );
  }

  String _formatValue(Locale locale, num value) {
    if (value >= 1000000) {
      return NumberFormat.compact(locale: locale.toString()).format(value);
    }
    return NumberFormat.decimalPattern(locale.toString()).format(value);
  }

  String _getLocalizedTitle(AppLocalizations l10n, String key) {
    switch (key) {
      case 'totalUsers':
        return l10n.statTotalUsers;
      case 'activeUsers':
        return l10n.statActiveUsers;
      case 'revenue':
        return l10n.statRevenue;
      case 'orders':
        return l10n.statOrders;
      default:
        return key;
    }
  }
}

// Usage
class DashboardScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GridView.count(
      crossAxisCount: 2,
      padding: const EdgeInsets.all(16),
      mainAxisSpacing: 16,
      crossAxisSpacing: 16,
      children: [
        StatCard(
          titleKey: 'totalUsers',
          value: 125430,
          previousValue: 118250,
          icon: Icons.people,
          color: Colors.blue,
        ),
        StatCard(
          titleKey: 'activeUsers',
          value: 45230,
          previousValue: 48100,
          icon: Icons.person_outline,
          color: Colors.green,
        ),
        StatCard(
          titleKey: 'revenue',
          value: 892450,
          previousValue: 756200,
          icon: Icons.attach_money,
          color: Colors.orange,
        ),
        StatCard(
          titleKey: 'orders',
          value: 12840,
          previousValue: 11230,
          icon: Icons.shopping_bag,
          color: Colors.purple,
        ),
      ],
    );
  }
}

User Profile Cards

Social and community apps need localized profile cards:

class UserProfileCard extends StatelessWidget {
  final User user;

  const UserProfileCard({
    super.key,
    required this.user,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            // Avatar with online status
            Stack(
              children: [
                CircleAvatar(
                  radius: 40,
                  backgroundImage: user.avatarUrl != null
                      ? NetworkImage(user.avatarUrl!)
                      : null,
                  child: user.avatarUrl == null
                      ? Text(user.initials, style: const TextStyle(fontSize: 24))
                      : null,
                ),
                if (user.isOnline)
                  Positioned(
                    bottom: 0,
                    right: 0,
                    child: Container(
                      width: 16,
                      height: 16,
                      decoration: BoxDecoration(
                        color: Colors.green,
                        shape: BoxShape.circle,
                        border: Border.all(color: Colors.white, width: 2),
                      ),
                    ),
                  ),
              ],
            ),
            const SizedBox(height: 12),
            // Name
            Text(
              user.displayName,
              style: Theme.of(context).textTheme.titleLarge,
              textAlign: TextAlign.center,
            ),
            // Username/handle
            Text(
              '@${user.username}',
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                color: Colors.grey,
              ),
            ),
            const SizedBox(height: 8),
            // Bio
            if (user.bio != null)
              Text(
                user.bio!,
                style: Theme.of(context).textTheme.bodyMedium,
                textAlign: TextAlign.center,
                maxLines: 3,
                overflow: TextOverflow.ellipsis,
              ),
            const SizedBox(height: 16),
            // Stats row
            _buildStatsRow(context),
            const SizedBox(height: 16),
            // Action buttons
            _buildActionButtons(context),
          ],
        ),
      ),
    );
  }

  Widget _buildStatsRow(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);
    final numberFormat = NumberFormat.compact(locale: locale.toString());

    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        _StatColumn(
          value: numberFormat.format(user.postsCount),
          label: l10n.userPosts,
        ),
        _StatColumn(
          value: numberFormat.format(user.followersCount),
          label: l10n.userFollowers,
        ),
        _StatColumn(
          value: numberFormat.format(user.followingCount),
          label: l10n.userFollowing,
        ),
      ],
    );
  }

  Widget _buildActionButtons(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    if (user.isCurrentUser) {
      return SizedBox(
        width: double.infinity,
        child: OutlinedButton.icon(
          onPressed: () => _editProfile(context),
          icon: const Icon(Icons.edit),
          label: Text(l10n.editProfile),
        ),
      );
    }

    return Row(
      children: [
        Expanded(
          child: user.isFollowing
              ? OutlinedButton(
                  onPressed: () => _unfollow(context),
                  child: Text(l10n.unfollowUser),
                )
              : FilledButton(
                  onPressed: () => _follow(context),
                  child: Text(l10n.followUser),
                ),
        ),
        const SizedBox(width: 8),
        OutlinedButton.icon(
          onPressed: () => _sendMessage(context),
          icon: const Icon(Icons.message),
          label: Text(l10n.sendMessage),
        ),
      ],
    );
  }
}

class _StatColumn extends StatelessWidget {
  final String value;
  final String label;

  const _StatColumn({
    required this.value,
    required this.label,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          value,
          style: Theme.of(context).textTheme.titleLarge?.copyWith(
            fontWeight: FontWeight.bold,
          ),
        ),
        Text(
          label,
          style: Theme.of(context).textTheme.bodySmall?.copyWith(
            color: Colors.grey,
          ),
        ),
      ],
    );
  }
}

RTL Support for Cards

Cards need proper RTL handling:

class RTLAwareCard extends StatelessWidget {
  const RTLAwareCard({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final isRTL = Directionality.of(context) == TextDirection.rtl;

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            // Icon on start (left in LTR, right in RTL)
            const Icon(Icons.info_outline, size: 40),
            const SizedBox(width: 16),
            // Text content expands
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    l10n.importantNotice,
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 4),
                  Text(l10n.noticeDescription),
                ],
              ),
            ),
            const SizedBox(width: 16),
            // Action on end (right in LTR, left in RTL)
            IconButton(
              onPressed: () => _dismiss(context),
              icon: const Icon(Icons.close),
              tooltip: l10n.dismiss,
            ),
          ],
        ),
      ),
    );
  }
}

Accessibility for Localized Cards

Make cards accessible in all languages:

class AccessibleCard extends StatelessWidget {
  final Announcement announcement;

  const AccessibleCard({
    super.key,
    required this.announcement,
  });

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Semantics(
      label: _buildSemanticLabel(context),
      hint: l10n.tapToReadMore,
      button: true,
      child: Card(
        child: InkWell(
          onTap: () => _openAnnouncement(context),
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Icon(
                      _getIcon(announcement.type),
                      color: _getColor(announcement.type),
                      semanticLabel: l10n.announcementType(announcement.type),
                    ),
                    const SizedBox(width: 8),
                    Text(
                      l10n.announcementType(announcement.type),
                      style: TextStyle(
                        color: _getColor(announcement.type),
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const Spacer(),
                    Text(
                      _formatDate(context, announcement.publishedAt),
                      style: Theme.of(context).textTheme.bodySmall,
                    ),
                  ],
                ),
                const SizedBox(height: 12),
                Text(
                  announcement.title,
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 4),
                Text(
                  announcement.summary,
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  String _buildSemanticLabel(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    return '${l10n.announcementType(announcement.type)}: '
        '${announcement.title}. '
        '${announcement.summary}. '
        '${_formatDate(context, announcement.publishedAt)}';
  }

  String _formatDate(BuildContext context, DateTime date) {
    final locale = Localizations.localeOf(context).toString();
    return DateFormat.MMMd(locale).format(date);
  }
}

Testing Localized Cards

Comprehensive tests for card localization:

void main() {
  group('ProductCard', () {
    testWidgets('displays localized price in USD', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('en', 'US'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Scaffold(
            body: ProductCard(
              product: Product(
                name: 'Test Product',
                price: 29.99,
              ),
            ),
          ),
        ),
      );

      expect(find.text('\$29.99'), findsOneWidget);
    });

    testWidgets('displays localized price in EUR', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('de'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Scaffold(
            body: ProductCard(
              product: Product(
                name: 'Test Product',
                price: 29.99,
              ),
            ),
          ),
        ),
      );

      // German uses comma as decimal separator
      expect(find.textContaining('29,99'), findsOneWidget);
    });

    testWidgets('pluralizes review count correctly', (tester) async {
      // Test singular
      await _testReviewCount(tester, 1, '(1 review)');

      // Test plural
      await _testReviewCount(tester, 5, '(5 reviews)');

      // Test zero
      await _testReviewCount(tester, 0, '(0 reviews)');
    });
  });
}

Best Practices Summary

  1. Format all numbers using locale-aware formatters
  2. Handle currency based on user's region or preference
  3. Use relative dates with proper pluralization
  4. Verify RTL layouts - especially complex card structures
  5. Add semantic labels for screen readers
  6. Test with various content lengths - translations vary in length
  7. Use placeholders for dynamic content in ARB files
  8. Create reusable card components with consistent localization

Related Resources

Conclusion

Card localization in Flutter extends beyond text translation. It encompasses number and currency formatting, date localization, RTL support, and accessibility. By following the patterns in this guide, you'll create cards that feel natural to users regardless of their language or region.

Start with basic text localization, then layer in locale-aware formatting for numbers, dates, and currencies. Finally, add comprehensive accessibility labels to ensure your cards work for all users worldwide.