← Back to Blog

Flutter Dropdown Localization: Menus, Selections, and Form Integration

flutterdropdownformslocalizationselectionmenus

Flutter Dropdown Localization: Menus, Selections, and Form Integration

Dropdowns are fundamental selection components in Flutter apps. From country selectors to category filters, from language pickers to form fields, dropdown menus appear throughout mobile applications. Properly localizing dropdown components ensures users worldwide can navigate your app's options with ease.

Why Dropdown Localization Matters

Dropdowns present choices to users. When those choices aren't properly translated or formatted, users struggle to find what they need. A well-localized dropdown adapts option labels, hint text, validation messages, and even the order of items based on locale conventions.

Basic DropdownButton Localization

Let's start with the fundamentals:

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

class LocalizedDropdown extends StatefulWidget {
  const LocalizedDropdown({super.key});

  @override
  State<LocalizedDropdown> createState() => _LocalizedDropdownState();
}

class _LocalizedDropdownState extends State<LocalizedDropdown> {
  String? _selectedValue;

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

    return DropdownButtonFormField<String>(
      decoration: InputDecoration(
        labelText: l10n.categoryLabel,
        hintText: l10n.categoryHint,
      ),
      value: _selectedValue,
      items: [
        DropdownMenuItem(
          value: 'electronics',
          child: Text(l10n.categoryElectronics),
        ),
        DropdownMenuItem(
          value: 'clothing',
          child: Text(l10n.categoryClothing),
        ),
        DropdownMenuItem(
          value: 'books',
          child: Text(l10n.categoryBooks),
        ),
        DropdownMenuItem(
          value: 'home',
          child: Text(l10n.categoryHome),
        ),
      ],
      onChanged: (value) {
        setState(() {
          _selectedValue = value;
        });
      },
    );
  }
}

Your ARB file would include:

{
  "categoryLabel": "Category",
  "@categoryLabel": {
    "description": "Label for category dropdown"
  },
  "categoryHint": "Select a category",
  "@categoryHint": {
    "description": "Hint text for category dropdown"
  },
  "categoryElectronics": "Electronics",
  "@categoryElectronics": {
    "description": "Electronics category option"
  },
  "categoryClothing": "Clothing & Accessories",
  "@categoryClothing": {
    "description": "Clothing category option"
  },
  "categoryBooks": "Books & Media",
  "@categoryBooks": {
    "description": "Books category option"
  },
  "categoryHome": "Home & Garden",
  "@categoryHome": {
    "description": "Home category option"
  }
}

Country Selector Dropdown

A common use case with locale-aware sorting:

class CountryDropdown extends StatefulWidget {
  final ValueChanged<String> onCountrySelected;

  const CountryDropdown({
    super.key,
    required this.onCountrySelected,
  });

  @override
  State<CountryDropdown> createState() => _CountryDropdownState();
}

class _CountryDropdownState extends State<CountryDropdown> {
  String? _selectedCountry;

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

    final countries = _getLocalizedCountries(l10n);

    // Sort countries alphabetically based on current locale
    countries.sort((a, b) => a['name']!.compareTo(b['name']!));

    return DropdownButtonFormField<String>(
      decoration: InputDecoration(
        labelText: l10n.countryLabel,
        hintText: l10n.countryHint,
        prefixIcon: const Icon(Icons.public),
      ),
      value: _selectedCountry,
      items: countries.map((country) {
        return DropdownMenuItem(
          value: country['code'],
          child: Row(
            children: [
              Text(
                country['flag']!,
                style: const TextStyle(fontSize: 20),
              ),
              const SizedBox(width: 8),
              Text(country['name']!),
            ],
          ),
        );
      }).toList(),
      onChanged: (value) {
        setState(() {
          _selectedCountry = value;
        });
        if (value != null) {
          widget.onCountrySelected(value);
        }
      },
    );
  }

  List<Map<String, String>> _getLocalizedCountries(AppLocalizations l10n) {
    return [
      {'code': 'US', 'name': l10n.countryUS, 'flag': '🇺🇸'},
      {'code': 'GB', 'name': l10n.countryGB, 'flag': '🇬🇧'},
      {'code': 'FR', 'name': l10n.countryFR, 'flag': '🇫🇷'},
      {'code': 'DE', 'name': l10n.countryDE, 'flag': '🇩🇪'},
      {'code': 'ES', 'name': l10n.countryES, 'flag': '🇪🇸'},
      {'code': 'IT', 'name': l10n.countryIT, 'flag': '🇮🇹'},
      {'code': 'JP', 'name': l10n.countryJP, 'flag': '🇯🇵'},
      {'code': 'CN', 'name': l10n.countryCN, 'flag': '🇨🇳'},
      {'code': 'BR', 'name': l10n.countryBR, 'flag': '🇧🇷'},
      {'code': 'IN', 'name': l10n.countryIN, 'flag': '🇮🇳'},
    ];
  }
}

ARB entries for countries:

{
  "countryLabel": "Country",
  "@countryLabel": {
    "description": "Label for country dropdown"
  },
  "countryHint": "Select your country",
  "@countryHint": {
    "description": "Hint for country selection"
  },
  "countryUS": "United States",
  "@countryUS": {
    "description": "United States country name"
  },
  "countryGB": "United Kingdom",
  "@countryGB": {
    "description": "United Kingdom country name"
  },
  "countryFR": "France",
  "@countryFR": {
    "description": "France country name"
  },
  "countryDE": "Germany",
  "@countryDE": {
    "description": "Germany country name"
  },
  "countryES": "Spain",
  "@countryES": {
    "description": "Spain country name"
  },
  "countryIT": "Italy",
  "@countryIT": {
    "description": "Italy country name"
  },
  "countryJP": "Japan",
  "@countryJP": {
    "description": "Japan country name"
  },
  "countryCN": "China",
  "@countryCN": {
    "description": "China country name"
  },
  "countryBR": "Brazil",
  "@countryBR": {
    "description": "Brazil country name"
  },
  "countryIN": "India",
  "@countryIN": {
    "description": "India country name"
  }
}

Language Picker Dropdown

For in-app language selection:

class LanguagePicker extends StatelessWidget {
  final Locale currentLocale;
  final ValueChanged<Locale> onLocaleChanged;

  const LanguagePicker({
    super.key,
    required this.currentLocale,
    required this.onLocaleChanged,
  });

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

    final languages = [
      _LanguageOption(
        locale: const Locale('en'),
        nativeName: 'English',
        localizedName: l10n.languageEnglish,
      ),
      _LanguageOption(
        locale: const Locale('es'),
        nativeName: 'Español',
        localizedName: l10n.languageSpanish,
      ),
      _LanguageOption(
        locale: const Locale('fr'),
        nativeName: 'Français',
        localizedName: l10n.languageFrench,
      ),
      _LanguageOption(
        locale: const Locale('de'),
        nativeName: 'Deutsch',
        localizedName: l10n.languageGerman,
      ),
      _LanguageOption(
        locale: const Locale('ar'),
        nativeName: 'العربية',
        localizedName: l10n.languageArabic,
      ),
      _LanguageOption(
        locale: const Locale('zh'),
        nativeName: '中文',
        localizedName: l10n.languageChinese,
      ),
    ];

    return DropdownButtonFormField<Locale>(
      decoration: InputDecoration(
        labelText: l10n.languageLabel,
        prefixIcon: const Icon(Icons.language),
      ),
      value: currentLocale,
      items: languages.map((lang) {
        return DropdownMenuItem(
          value: lang.locale,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(lang.nativeName),
              Text(
                lang.localizedName,
                style: Theme.of(context).textTheme.bodySmall?.copyWith(
                  color: Theme.of(context).colorScheme.onSurfaceVariant,
                ),
              ),
            ],
          ),
        );
      }).toList(),
      onChanged: (locale) {
        if (locale != null) {
          onLocaleChanged(locale);
        }
      },
    );
  }
}

class _LanguageOption {
  final Locale locale;
  final String nativeName;
  final String localizedName;

  const _LanguageOption({
    required this.locale,
    required this.nativeName,
    required this.localizedName,
  });
}

ARB entries:

{
  "languageLabel": "Language",
  "@languageLabel": {
    "description": "Label for language selector"
  },
  "languageEnglish": "English",
  "@languageEnglish": {
    "description": "English language name"
  },
  "languageSpanish": "Spanish",
  "@languageSpanish": {
    "description": "Spanish language name"
  },
  "languageFrench": "French",
  "@languageFrench": {
    "description": "French language name"
  },
  "languageGerman": "German",
  "@languageGerman": {
    "description": "German language name"
  },
  "languageArabic": "Arabic",
  "@languageArabic": {
    "description": "Arabic language name"
  },
  "languageChinese": "Chinese",
  "@languageChinese": {
    "description": "Chinese language name"
  }
}

Dropdown with Validation

Form dropdowns that require selection:

class ValidatedDropdownForm extends StatefulWidget {
  const ValidatedDropdownForm({super.key});

  @override
  State<ValidatedDropdownForm> createState() => _ValidatedDropdownFormState();
}

class _ValidatedDropdownFormState extends State<ValidatedDropdownForm> {
  final _formKey = GlobalKey<FormState>();
  String? _selectedSize;
  String? _selectedColor;

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

    return Form(
      key: _formKey,
      child: Column(
        children: [
          DropdownButtonFormField<String>(
            decoration: InputDecoration(
              labelText: l10n.sizeLabel,
              hintText: l10n.sizeHint,
            ),
            value: _selectedSize,
            validator: (value) {
              if (value == null || value.isEmpty) {
                return l10n.sizeRequired;
              }
              return null;
            },
            items: [
              DropdownMenuItem(value: 'xs', child: Text(l10n.sizeXS)),
              DropdownMenuItem(value: 's', child: Text(l10n.sizeS)),
              DropdownMenuItem(value: 'm', child: Text(l10n.sizeM)),
              DropdownMenuItem(value: 'l', child: Text(l10n.sizeL)),
              DropdownMenuItem(value: 'xl', child: Text(l10n.sizeXL)),
            ],
            onChanged: (value) {
              setState(() {
                _selectedSize = value;
              });
            },
          ),
          const SizedBox(height: 16),
          DropdownButtonFormField<String>(
            decoration: InputDecoration(
              labelText: l10n.colorLabel,
              hintText: l10n.colorHint,
            ),
            value: _selectedColor,
            validator: (value) {
              if (value == null || value.isEmpty) {
                return l10n.colorRequired;
              }
              return null;
            },
            items: [
              DropdownMenuItem(
                value: 'red',
                child: _ColorOption(color: Colors.red, label: l10n.colorRed),
              ),
              DropdownMenuItem(
                value: 'blue',
                child: _ColorOption(color: Colors.blue, label: l10n.colorBlue),
              ),
              DropdownMenuItem(
                value: 'green',
                child: _ColorOption(color: Colors.green, label: l10n.colorGreen),
              ),
              DropdownMenuItem(
                value: 'black',
                child: _ColorOption(color: Colors.black, label: l10n.colorBlack),
              ),
            ],
            onChanged: (value) {
              setState(() {
                _selectedColor = value;
              });
            },
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                // Form is valid
              }
            },
            child: Text(l10n.addToCartButton),
          ),
        ],
      ),
    );
  }
}

class _ColorOption extends StatelessWidget {
  final Color color;
  final String label;

  const _ColorOption({
    required this.color,
    required this.label,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Container(
          width: 20,
          height: 20,
          decoration: BoxDecoration(
            color: color,
            shape: BoxShape.circle,
            border: Border.all(color: Colors.grey),
          ),
        ),
        const SizedBox(width: 8),
        Text(label),
      ],
    );
  }
}

ARB entries:

{
  "sizeLabel": "Size",
  "@sizeLabel": {
    "description": "Size dropdown label"
  },
  "sizeHint": "Select a size",
  "@sizeHint": {
    "description": "Size dropdown hint"
  },
  "sizeRequired": "Please select a size",
  "@sizeRequired": {
    "description": "Size validation error"
  },
  "sizeXS": "Extra Small (XS)",
  "@sizeXS": {
    "description": "Extra small size option"
  },
  "sizeS": "Small (S)",
  "@sizeS": {
    "description": "Small size option"
  },
  "sizeM": "Medium (M)",
  "@sizeM": {
    "description": "Medium size option"
  },
  "sizeL": "Large (L)",
  "@sizeL": {
    "description": "Large size option"
  },
  "sizeXL": "Extra Large (XL)",
  "@sizeXL": {
    "description": "Extra large size option"
  },
  "colorLabel": "Color",
  "@colorLabel": {
    "description": "Color dropdown label"
  },
  "colorHint": "Select a color",
  "@colorHint": {
    "description": "Color dropdown hint"
  },
  "colorRequired": "Please select a color",
  "@colorRequired": {
    "description": "Color validation error"
  },
  "colorRed": "Red",
  "@colorRed": {
    "description": "Red color option"
  },
  "colorBlue": "Blue",
  "@colorBlue": {
    "description": "Blue color option"
  },
  "colorGreen": "Green",
  "@colorGreen": {
    "description": "Green color option"
  },
  "colorBlack": "Black",
  "@colorBlack": {
    "description": "Black color option"
  },
  "addToCartButton": "Add to Cart",
  "@addToCartButton": {
    "description": "Add to cart button text"
  }
}

Searchable Dropdown

For dropdowns with many options:

class SearchableDropdown extends StatefulWidget {
  final List<DropdownItem> items;
  final ValueChanged<DropdownItem?> onSelected;

  const SearchableDropdown({
    super.key,
    required this.items,
    required this.onSelected,
  });

  @override
  State<SearchableDropdown> createState() => _SearchableDropdownState();
}

class _SearchableDropdownState extends State<SearchableDropdown> {
  DropdownItem? _selectedItem;
  final _searchController = TextEditingController();

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

    return Autocomplete<DropdownItem>(
      optionsBuilder: (textEditingValue) {
        if (textEditingValue.text.isEmpty) {
          return widget.items;
        }
        return widget.items.where((item) {
          return item.label
              .toLowerCase()
              .contains(textEditingValue.text.toLowerCase());
        });
      },
      displayStringForOption: (option) => option.label,
      fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
        return TextFormField(
          controller: controller,
          focusNode: focusNode,
          decoration: InputDecoration(
            labelText: l10n.searchLabel,
            hintText: l10n.searchHint,
            prefixIcon: const Icon(Icons.search),
            suffixIcon: controller.text.isNotEmpty
                ? IconButton(
                    icon: const Icon(Icons.clear),
                    onPressed: () {
                      controller.clear();
                      setState(() {
                        _selectedItem = null;
                      });
                      widget.onSelected(null);
                    },
                  )
                : null,
          ),
        );
      },
      optionsViewBuilder: (context, onSelected, options) {
        return Align(
          alignment: Alignment.topLeft,
          child: Material(
            elevation: 4,
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxHeight: 200),
              child: ListView.builder(
                padding: EdgeInsets.zero,
                shrinkWrap: true,
                itemCount: options.length,
                itemBuilder: (context, index) {
                  final option = options.elementAt(index);
                  return ListTile(
                    title: Text(option.label),
                    subtitle: option.description != null
                        ? Text(option.description!)
                        : null,
                    onTap: () => onSelected(option),
                  );
                },
              ),
            ),
          ),
        );
      },
      onSelected: (item) {
        setState(() {
          _selectedItem = item;
        });
        widget.onSelected(item);
      },
    );
  }
}

class DropdownItem {
  final String value;
  final String label;
  final String? description;

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

ARB entries:

{
  "searchLabel": "Search",
  "@searchLabel": {
    "description": "Search field label"
  },
  "searchHint": "Type to search...",
  "@searchHint": {
    "description": "Search field hint"
  },
  "noResultsFound": "No results found",
  "@noResultsFound": {
    "description": "Message when search returns no results"
  }
}

Dependent Dropdowns

When one dropdown's options depend on another:

class DependentDropdowns extends StatefulWidget {
  const DependentDropdowns({super.key});

  @override
  State<DependentDropdowns> createState() => _DependentDropdownsState();
}

class _DependentDropdownsState extends State<DependentDropdowns> {
  String? _selectedCountry;
  String? _selectedCity;

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

    return Column(
      children: [
        DropdownButtonFormField<String>(
          decoration: InputDecoration(
            labelText: l10n.countryLabel,
          ),
          value: _selectedCountry,
          items: [
            DropdownMenuItem(value: 'us', child: Text(l10n.countryUS)),
            DropdownMenuItem(value: 'uk', child: Text(l10n.countryGB)),
            DropdownMenuItem(value: 'fr', child: Text(l10n.countryFR)),
          ],
          onChanged: (value) {
            setState(() {
              _selectedCountry = value;
              _selectedCity = null; // Reset city when country changes
            });
          },
        ),
        const SizedBox(height: 16),
        DropdownButtonFormField<String>(
          decoration: InputDecoration(
            labelText: l10n.cityLabel,
            hintText: _selectedCountry == null
                ? l10n.selectCountryFirst
                : l10n.cityHint,
          ),
          value: _selectedCity,
          items: _getCitiesForCountry(l10n),
          onChanged: _selectedCountry == null
              ? null
              : (value) {
                  setState(() {
                    _selectedCity = value;
                  });
                },
        ),
      ],
    );
  }

  List<DropdownMenuItem<String>>? _getCitiesForCountry(AppLocalizations l10n) {
    if (_selectedCountry == null) return null;

    final citiesByCountry = {
      'us': [
        DropdownMenuItem(value: 'nyc', child: Text(l10n.cityNewYork)),
        DropdownMenuItem(value: 'la', child: Text(l10n.cityLosAngeles)),
        DropdownMenuItem(value: 'chi', child: Text(l10n.cityChicago)),
      ],
      'uk': [
        DropdownMenuItem(value: 'lon', child: Text(l10n.cityLondon)),
        DropdownMenuItem(value: 'man', child: Text(l10n.cityManchester)),
        DropdownMenuItem(value: 'bir', child: Text(l10n.cityBirmingham)),
      ],
      'fr': [
        DropdownMenuItem(value: 'par', child: Text(l10n.cityParis)),
        DropdownMenuItem(value: 'lyo', child: Text(l10n.cityLyon)),
        DropdownMenuItem(value: 'mar', child: Text(l10n.cityMarseille)),
      ],
    };

    return citiesByCountry[_selectedCountry];
  }
}

ARB entries:

{
  "cityLabel": "City",
  "@cityLabel": {
    "description": "City dropdown label"
  },
  "cityHint": "Select a city",
  "@cityHint": {
    "description": "City dropdown hint"
  },
  "selectCountryFirst": "Select a country first",
  "@selectCountryFirst": {
    "description": "Hint when no country is selected"
  },
  "cityNewYork": "New York",
  "@cityNewYork": {
    "description": "New York city name"
  },
  "cityLosAngeles": "Los Angeles",
  "@cityLosAngeles": {
    "description": "Los Angeles city name"
  },
  "cityChicago": "Chicago",
  "@cityChicago": {
    "description": "Chicago city name"
  },
  "cityLondon": "London",
  "@cityLondon": {
    "description": "London city name"
  },
  "cityManchester": "Manchester",
  "@cityManchester": {
    "description": "Manchester city name"
  },
  "cityBirmingham": "Birmingham",
  "@cityBirmingham": {
    "description": "Birmingham city name"
  },
  "cityParis": "Paris",
  "@cityParis": {
    "description": "Paris city name"
  },
  "cityLyon": "Lyon",
  "@cityLyon": {
    "description": "Lyon city name"
  },
  "cityMarseille": "Marseille",
  "@cityMarseille": {
    "description": "Marseille city name"
  }
}

Dropdown Accessibility

Proper accessibility for dropdown menus:

class AccessibleDropdown extends StatefulWidget {
  final String label;
  final List<AccessibleDropdownItem> items;
  final ValueChanged<String?> onChanged;

  const AccessibleDropdown({
    super.key,
    required this.label,
    required this.items,
    required this.onChanged,
  });

  @override
  State<AccessibleDropdown> createState() => _AccessibleDropdownState();
}

class _AccessibleDropdownState extends State<AccessibleDropdown> {
  String? _selectedValue;

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

    return Semantics(
      label: widget.label,
      hint: l10n.dropdownAccessibilityHint,
      child: DropdownButtonFormField<String>(
        decoration: InputDecoration(
          labelText: widget.label,
        ),
        value: _selectedValue,
        items: widget.items.map((item) {
          return DropdownMenuItem(
            value: item.value,
            child: Semantics(
              label: item.semanticLabel ?? item.label,
              excludeSemantics: true,
              child: Text(item.label),
            ),
          );
        }).toList(),
        onChanged: (value) {
          setState(() {
            _selectedValue = value;
          });
          widget.onChanged(value);
        },
      ),
    );
  }
}

class AccessibleDropdownItem {
  final String value;
  final String label;
  final String? semanticLabel;

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

ARB entry:

{
  "dropdownAccessibilityHint": "Double tap to open menu",
  "@dropdownAccessibilityHint": {
    "description": "Accessibility hint for dropdown"
  }
}

Dynamic Options from API

Loading dropdown options from a backend:

class ApiDropdown extends StatefulWidget {
  const ApiDropdown({super.key});

  @override
  State<ApiDropdown> createState() => _ApiDropdownState();
}

class _ApiDropdownState extends State<ApiDropdown> {
  List<DropdownOption>? _options;
  String? _selectedValue;
  bool _isLoading = true;
  String? _error;

  @override
  void initState() {
    super.initState();
    _loadOptions();
  }

  Future<void> _loadOptions() async {
    try {
      final options = await ApiService.fetchOptions();
      setState(() {
        _options = options;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = e.toString();
        _isLoading = false;
      });
    }
  }

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

    if (_isLoading) {
      return InputDecorator(
        decoration: InputDecoration(
          labelText: l10n.optionsLabel,
        ),
        child: Row(
          children: [
            const SizedBox(
              width: 20,
              height: 20,
              child: CircularProgressIndicator(strokeWidth: 2),
            ),
            const SizedBox(width: 12),
            Text(l10n.loadingOptions),
          ],
        ),
      );
    }

    if (_error != null) {
      return InputDecorator(
        decoration: InputDecoration(
          labelText: l10n.optionsLabel,
          errorText: l10n.loadingError,
        ),
        child: TextButton.icon(
          onPressed: _loadOptions,
          icon: const Icon(Icons.refresh),
          label: Text(l10n.retryButton),
        ),
      );
    }

    return DropdownButtonFormField<String>(
      decoration: InputDecoration(
        labelText: l10n.optionsLabel,
      ),
      value: _selectedValue,
      items: _options?.map((option) {
        return DropdownMenuItem(
          value: option.id,
          child: Text(option.getLocalizedName(Localizations.localeOf(context))),
        );
      }).toList(),
      onChanged: (value) {
        setState(() {
          _selectedValue = value;
        });
      },
    );
  }
}

class DropdownOption {
  final String id;
  final Map<String, String> names;

  const DropdownOption({
    required this.id,
    required this.names,
  });

  String getLocalizedName(Locale locale) {
    return names[locale.languageCode] ?? names['en'] ?? id;
  }
}

ARB entries:

{
  "optionsLabel": "Options",
  "@optionsLabel": {
    "description": "Options dropdown label"
  },
  "loadingOptions": "Loading options...",
  "@loadingOptions": {
    "description": "Loading state text"
  },
  "loadingError": "Failed to load options",
  "@loadingError": {
    "description": "Error message for failed load"
  },
  "retryButton": "Retry",
  "@retryButton": {
    "description": "Retry button text"
  }
}

Best Practices

  1. Always localize all text: Labels, hints, options, and error messages
  2. Sort options appropriately: Consider locale-aware sorting for alphabetical lists
  3. Handle empty states: Show appropriate messages when no options are available
  4. Validate selections: Provide clear, localized error messages
  5. Consider dependent dropdowns: Reset child dropdowns when parent changes
  6. Test RTL layouts: Ensure dropdowns work correctly in RTL languages

Testing Dropdown Localization

testWidgets('Dropdown shows localized options', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      locale: const Locale('es'),
      home: const Scaffold(
        body: LocalizedDropdown(),
      ),
    ),
  );

  await tester.tap(find.byType(DropdownButtonFormField<String>));
  await tester.pumpAndSettle();

  expect(find.text('Electrónica'), findsOneWidget);
  expect(find.text('Ropa y Accesorios'), findsOneWidget);
});

testWidgets('Dropdown validation shows localized error', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      locale: const Locale('fr'),
      home: const Scaffold(
        body: ValidatedDropdownForm(),
      ),
    ),
  );

  await tester.tap(find.text('Ajouter au panier'));
  await tester.pump();

  expect(find.text('Veuillez sélectionner une taille'), findsOneWidget);
});

Conclusion

Dropdown localization in Flutter requires attention to labels, option text, validation messages, and dynamic content. By following these patterns, you ensure your app provides a native selection experience for users regardless of their language. Remember to test with different locales and provide meaningful context for translators in your ARB files.