← Back to Blog

Flutter TextField Localization: Hints, Labels, Errors, and Input Formatting

fluttertextfieldformslocalizationvalidationinput

Flutter TextField Localization: Hints, Labels, Errors, and Input Formatting

TextFields are essential for user input in Flutter apps. From login forms to search boxes, from profile editing to checkout flows, TextFields appear everywhere. Properly localizing TextField components ensures users can input and understand data correctly regardless of their language or region.

Why TextField Localization Matters

TextFields communicate with users through labels, hints, helper text, and error messages. Poorly localized TextFields confuse users, cause input errors, and create frustration. Proper TextField localization adapts all text, input formatting, and validation messages to the user's locale.

Basic TextField Localization

Let's start with the fundamentals:

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

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

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

    return TextField(
      decoration: InputDecoration(
        labelText: l10n.emailLabel,
        hintText: l10n.emailHint,
        helperText: l10n.emailHelper,
        prefixIcon: const Icon(Icons.email),
      ),
      keyboardType: TextInputType.emailAddress,
      textInputAction: TextInputAction.next,
    );
  }
}

ARB file entries:

{
  "emailLabel": "Email Address",
  "@emailLabel": {
    "description": "Label for email input field"
  },
  "emailHint": "example@email.com",
  "@emailHint": {
    "description": "Hint showing email format"
  },
  "emailHelper": "We'll never share your email",
  "@emailHelper": {
    "description": "Helper text for email field"
  }
}

Login Form with Full Localization

A complete login form demonstrates multiple localized TextFields:

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

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _obscurePassword = true;

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

    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          // Email field
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(
              labelText: l10n.emailLabel,
              hintText: l10n.emailHint,
              prefixIcon: const Icon(Icons.email_outlined),
              errorMaxLines: 2,
            ),
            keyboardType: TextInputType.emailAddress,
            textInputAction: TextInputAction.next,
            autocorrect: false,
            validator: (value) => _validateEmail(value, l10n),
          ),
          const SizedBox(height: 16),
          // Password field
          TextFormField(
            controller: _passwordController,
            decoration: InputDecoration(
              labelText: l10n.passwordLabel,
              hintText: l10n.passwordHint,
              prefixIcon: const Icon(Icons.lock_outlined),
              suffixIcon: IconButton(
                icon: Icon(
                  _obscurePassword
                      ? Icons.visibility_outlined
                      : Icons.visibility_off_outlined,
                ),
                tooltip: _obscurePassword
                    ? l10n.showPassword
                    : l10n.hidePassword,
                onPressed: () {
                  setState(() => _obscurePassword = !_obscurePassword);
                },
              ),
              errorMaxLines: 2,
            ),
            obscureText: _obscurePassword,
            textInputAction: TextInputAction.done,
            validator: (value) => _validatePassword(value, l10n),
            onFieldSubmitted: (_) => _submit(),
          ),
          const SizedBox(height: 8),
          // Forgot password link
          Align(
            alignment: AlignmentDirectional.centerEnd,
            child: TextButton(
              onPressed: () => _forgotPassword(context),
              child: Text(l10n.forgotPassword),
            ),
          ),
          const SizedBox(height: 16),
          // Submit button
          FilledButton(
            onPressed: _submit,
            child: Text(l10n.loginButton),
          ),
        ],
      ),
    );
  }

  String? _validateEmail(String? value, AppLocalizations l10n) {
    if (value == null || value.isEmpty) {
      return l10n.emailRequired;
    }
    if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
      return l10n.emailInvalid;
    }
    return null;
  }

  String? _validatePassword(String? value, AppLocalizations l10n) {
    if (value == null || value.isEmpty) {
      return l10n.passwordRequired;
    }
    if (value.length < 8) {
      return l10n.passwordTooShort(8);
    }
    return null;
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      // Process login
    }
  }
}

Complete ARB entries for login form:

{
  "emailLabel": "Email",
  "@emailLabel": {
    "description": "Label for email field"
  },
  "emailHint": "Enter your email",
  "@emailHint": {
    "description": "Placeholder text for email field"
  },
  "emailRequired": "Please enter your email address",
  "@emailRequired": {
    "description": "Error when email is empty"
  },
  "emailInvalid": "Please enter a valid email address",
  "@emailInvalid": {
    "description": "Error when email format is invalid"
  },
  "passwordLabel": "Password",
  "@passwordLabel": {
    "description": "Label for password field"
  },
  "passwordHint": "Enter your password",
  "@passwordHint": {
    "description": "Placeholder text for password field"
  },
  "passwordRequired": "Please enter your password",
  "@passwordRequired": {
    "description": "Error when password is empty"
  },
  "passwordTooShort": "Password must be at least {minLength} characters",
  "@passwordTooShort": {
    "description": "Error when password is too short",
    "placeholders": {
      "minLength": {
        "type": "int",
        "example": "8"
      }
    }
  },
  "showPassword": "Show password",
  "@showPassword": {
    "description": "Tooltip to show password"
  },
  "hidePassword": "Hide password",
  "@hidePassword": {
    "description": "Tooltip to hide password"
  },
  "forgotPassword": "Forgot password?",
  "@forgotPassword": {
    "description": "Link to reset password"
  },
  "loginButton": "Log In",
  "@loginButton": {
    "description": "Login button text"
  }
}

Number Input with Locale-Aware Formatting

Different locales use different decimal and thousands separators:

class LocalizedNumberField extends StatefulWidget {
  final String label;
  final ValueChanged<double?> onChanged;

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

  @override
  State<LocalizedNumberField> createState() => _LocalizedNumberFieldState();
}

class _LocalizedNumberFieldState extends State<LocalizedNumberField> {
  final _controller = TextEditingController();
  late NumberFormat _numberFormat;
  late String _decimalSeparator;
  late String _thousandsSeparator;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final locale = Localizations.localeOf(context);
    _numberFormat = NumberFormat.decimalPattern(locale.toString());
    _decimalSeparator = _numberFormat.symbols.DECIMAL_SEP;
    _thousandsSeparator = _numberFormat.symbols.GROUP_SEP;
  }

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

    return TextFormField(
      controller: _controller,
      decoration: InputDecoration(
        labelText: widget.label,
        hintText: _numberFormat.format(1234.56),
        helperText: l10n.numberFormatHint(
          _decimalSeparator,
          _thousandsSeparator,
        ),
      ),
      keyboardType: TextInputType.numberWithOptions(decimal: true),
      inputFormatters: [
        _LocaleAwareNumberFormatter(
          decimalSeparator: _decimalSeparator,
          thousandsSeparator: _thousandsSeparator,
        ),
      ],
      onChanged: (value) {
        final parsed = _parseLocalizedNumber(value);
        widget.onChanged(parsed);
      },
      validator: (value) {
        if (value == null || value.isEmpty) {
          return l10n.numberRequired;
        }
        if (_parseLocalizedNumber(value) == null) {
          return l10n.invalidNumber;
        }
        return null;
      },
    );
  }

  double? _parseLocalizedNumber(String value) {
    try {
      // Remove thousands separators and replace decimal separator
      final normalized = value
          .replaceAll(_thousandsSeparator, '')
          .replaceAll(_decimalSeparator, '.');
      return double.parse(normalized);
    } catch (e) {
      return null;
    }
  }
}

class _LocaleAwareNumberFormatter extends TextInputFormatter {
  final String decimalSeparator;
  final String thousandsSeparator;

  _LocaleAwareNumberFormatter({
    required this.decimalSeparator,
    required this.thousandsSeparator,
  });

  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    // Allow only digits, decimal separator, and minus sign
    final allowedChars = RegExp('[0-9$decimalSeparator-]');
    final filtered = newValue.text.split('').where((char) {
      return allowedChars.hasMatch(char);
    }).join();

    // Ensure only one decimal separator
    final parts = filtered.split(decimalSeparator);
    final result = parts.length > 2
        ? '${parts[0]}$decimalSeparator${parts.sublist(1).join('')}'
        : filtered;

    return TextEditingValue(
      text: result,
      selection: TextSelection.collapsed(offset: result.length),
    );
  }
}

Currency Input Field

Currency input needs special handling for locale:

class CurrencyField extends StatefulWidget {
  final String label;
  final String currencyCode;
  final ValueChanged<double?> onChanged;

  const CurrencyField({
    super.key,
    required this.label,
    required this.currencyCode,
    required this.onChanged,
  });

  @override
  State<CurrencyField> createState() => _CurrencyFieldState();
}

class _CurrencyFieldState extends State<CurrencyField> {
  final _controller = TextEditingController();
  late NumberFormat _currencyFormat;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final locale = Localizations.localeOf(context);
    _currencyFormat = NumberFormat.currency(
      locale: locale.toString(),
      symbol: _getCurrencySymbol(widget.currencyCode),
      decimalDigits: 2,
    );
  }

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

    return TextFormField(
      controller: _controller,
      decoration: InputDecoration(
        labelText: widget.label,
        hintText: _currencyFormat.format(0),
        prefixText: _getCurrencySymbol(widget.currencyCode),
        helperText: l10n.currencyHint(widget.currencyCode),
      ),
      keyboardType: const TextInputType.numberWithOptions(decimal: true),
      inputFormatters: [
        FilteringTextInputFormatter.allow(
          RegExp(r'[0-9' + _currencyFormat.symbols.DECIMAL_SEP + ']'),
        ),
      ],
      onChanged: (value) {
        final parsed = _parseCurrency(value);
        widget.onChanged(parsed);
      },
      validator: (value) {
        if (value == null || value.isEmpty) {
          return l10n.amountRequired;
        }
        final amount = _parseCurrency(value);
        if (amount == null || amount < 0) {
          return l10n.invalidAmount;
        }
        return null;
      },
    );
  }

  double? _parseCurrency(String value) {
    try {
      final decimalSep = _currencyFormat.symbols.DECIMAL_SEP;
      final normalized = value.replaceAll(decimalSep, '.');
      return double.parse(normalized);
    } catch (e) {
      return null;
    }
  }

  String _getCurrencySymbol(String code) {
    final symbols = {
      'USD': '\$',
      'EUR': '€',
      'GBP': '£',
      'JPY': '¥',
      'SAR': 'ر.س',
    };
    return symbols[code] ?? code;
  }
}

Date Input with Locale-Aware Picker

Date fields should respect locale formatting:

class LocalizedDateField extends StatefulWidget {
  final String label;
  final DateTime? initialDate;
  final ValueChanged<DateTime?> onChanged;

  const LocalizedDateField({
    super.key,
    required this.label,
    this.initialDate,
    required this.onChanged,
  });

  @override
  State<LocalizedDateField> createState() => _LocalizedDateFieldState();
}

class _LocalizedDateFieldState extends State<LocalizedDateField> {
  final _controller = TextEditingController();
  DateTime? _selectedDate;

  @override
  void initState() {
    super.initState();
    _selectedDate = widget.initialDate;
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_selectedDate != null) {
      _controller.text = _formatDate(_selectedDate!);
    }
  }

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

    return TextFormField(
      controller: _controller,
      decoration: InputDecoration(
        labelText: widget.label,
        hintText: _getDateFormatHint(),
        prefixIcon: const Icon(Icons.calendar_today),
        suffixIcon: _selectedDate != null
            ? IconButton(
                icon: const Icon(Icons.clear),
                tooltip: l10n.clearDate,
                onPressed: () {
                  setState(() {
                    _selectedDate = null;
                    _controller.clear();
                  });
                  widget.onChanged(null);
                },
              )
            : null,
      ),
      readOnly: true,
      onTap: () => _showDatePicker(context),
      validator: (value) {
        if (value == null || value.isEmpty) {
          return l10n.dateRequired;
        }
        return null;
      },
    );
  }

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

  String _getDateFormatHint() {
    final locale = Localizations.localeOf(context).toString();
    final format = DateFormat.yMd(locale);
    return format.format(DateTime(2026, 1, 15));
  }

  Future<void> _showDatePicker(BuildContext context) async {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);

    final picked = await showDatePicker(
      context: context,
      initialDate: _selectedDate ?? DateTime.now(),
      firstDate: DateTime(1900),
      lastDate: DateTime(2100),
      locale: locale,
      helpText: l10n.selectDate,
      cancelText: l10n.cancel,
      confirmText: l10n.ok,
    );

    if (picked != null) {
      setState(() {
        _selectedDate = picked;
        _controller.text = _formatDate(picked);
      });
      widget.onChanged(picked);
    }
  }
}

Phone Number Input with Country Code

International phone input with localization:

class PhoneNumberField extends StatefulWidget {
  final ValueChanged<String?> onChanged;

  const PhoneNumberField({
    super.key,
    required this.onChanged,
  });

  @override
  State<PhoneNumberField> createState() => _PhoneNumberFieldState();
}

class _PhoneNumberFieldState extends State<PhoneNumberField> {
  String _selectedCountryCode = '+1';
  final _phoneController = TextEditingController();

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

    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // Country code dropdown
        SizedBox(
          width: 100,
          child: DropdownButtonFormField<String>(
            value: _selectedCountryCode,
            decoration: InputDecoration(
              labelText: l10n.countryCode,
            ),
            items: _countryCodes.map((code) {
              return DropdownMenuItem(
                value: code['dial'],
                child: Text('${code['flag']} ${code['dial']}'),
              );
            }).toList(),
            onChanged: (value) {
              setState(() => _selectedCountryCode = value!);
              _updateFullNumber();
            },
          ),
        ),
        const SizedBox(width: 12),
        // Phone number
        Expanded(
          child: TextFormField(
            controller: _phoneController,
            decoration: InputDecoration(
              labelText: l10n.phoneNumber,
              hintText: l10n.phoneNumberHint,
              prefixIcon: const Icon(Icons.phone),
            ),
            keyboardType: TextInputType.phone,
            inputFormatters: [
              FilteringTextInputFormatter.digitsOnly,
              _PhoneNumberFormatter(),
            ],
            onChanged: (_) => _updateFullNumber(),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return l10n.phoneRequired;
              }
              if (value.replaceAll(RegExp(r'\D'), '').length < 10) {
                return l10n.phoneInvalid;
              }
              return null;
            },
          ),
        ),
      ],
    );
  }

  void _updateFullNumber() {
    final digits = _phoneController.text.replaceAll(RegExp(r'\D'), '');
    if (digits.isNotEmpty) {
      widget.onChanged('$_selectedCountryCode$digits');
    } else {
      widget.onChanged(null);
    }
  }

  static const _countryCodes = [
    {'flag': '🇺🇸', 'dial': '+1', 'code': 'US'},
    {'flag': '🇬🇧', 'dial': '+44', 'code': 'GB'},
    {'flag': '🇩🇪', 'dial': '+49', 'code': 'DE'},
    {'flag': '🇫🇷', 'dial': '+33', 'code': 'FR'},
    {'flag': '🇯🇵', 'dial': '+81', 'code': 'JP'},
    {'flag': '🇸🇦', 'dial': '+966', 'code': 'SA'},
    {'flag': '🇮🇳', 'dial': '+91', 'code': 'IN'},
    {'flag': '🇨🇳', 'dial': '+86', 'code': 'CN'},
  ];
}

class _PhoneNumberFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
    final formatted = _formatPhoneNumber(digits);
    return TextEditingValue(
      text: formatted,
      selection: TextSelection.collapsed(offset: formatted.length),
    );
  }

  String _formatPhoneNumber(String digits) {
    if (digits.length <= 3) return digits;
    if (digits.length <= 6) {
      return '(${digits.substring(0, 3)}) ${digits.substring(3)}';
    }
    return '(${digits.substring(0, 3)}) ${digits.substring(3, 6)}-${digits.substring(6, min(10, digits.length))}';
  }
}

Search Field with Localized Hints

Search fields need dynamic localized hints:

class LocalizedSearchField extends StatelessWidget {
  final String searchContext;
  final ValueChanged<String> onChanged;
  final VoidCallback? onClear;

  const LocalizedSearchField({
    super.key,
    required this.searchContext,
    required this.onChanged,
    this.onClear,
  });

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

    return TextField(
      decoration: InputDecoration(
        hintText: _getSearchHint(l10n, searchContext),
        prefixIcon: const Icon(Icons.search),
        suffixIcon: onClear != null
            ? IconButton(
                icon: const Icon(Icons.clear),
                tooltip: l10n.clearSearch,
                onPressed: onClear,
              )
            : null,
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(30),
        ),
        contentPadding: const EdgeInsets.symmetric(horizontal: 20),
      ),
      textInputAction: TextInputAction.search,
      onChanged: onChanged,
    );
  }

  String _getSearchHint(AppLocalizations l10n, String context) {
    switch (context) {
      case 'products':
        return l10n.searchProducts;
      case 'users':
        return l10n.searchUsers;
      case 'messages':
        return l10n.searchMessages;
      case 'files':
        return l10n.searchFiles;
      default:
        return l10n.searchGeneral;
    }
  }
}

Multi-line Text Input

For longer text with character counting:

class LocalizedTextArea extends StatefulWidget {
  final String label;
  final int maxLength;
  final ValueChanged<String> onChanged;

  const LocalizedTextArea({
    super.key,
    required this.label,
    required this.maxLength,
    required this.onChanged,
  });

  @override
  State<LocalizedTextArea> createState() => _LocalizedTextAreaState();
}

class _LocalizedTextAreaState extends State<LocalizedTextArea> {
  int _currentLength = 0;

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

    return TextFormField(
      decoration: InputDecoration(
        labelText: widget.label,
        alignLabelWithHint: true,
        hintText: l10n.textAreaHint,
        counterText: l10n.characterCount(
          NumberFormat.decimalPattern(locale.toString()).format(_currentLength),
          NumberFormat.decimalPattern(locale.toString()).format(widget.maxLength),
        ),
        border: const OutlineInputBorder(),
      ),
      maxLines: 5,
      maxLength: widget.maxLength,
      buildCounter: (context, {required currentLength, required isFocused, maxLength}) {
        // Custom counter handled in counterText
        return null;
      },
      onChanged: (value) {
        setState(() => _currentLength = value.length);
        widget.onChanged(value);
      },
      validator: (value) {
        if (value == null || value.isEmpty) {
          return l10n.fieldRequired;
        }
        if (value.length < 10) {
          return l10n.minimumCharacters(10);
        }
        return null;
      },
    );
  }
}

ARB entries:

{
  "characterCount": "{current}/{max} characters",
  "@characterCount": {
    "description": "Character counter for text area",
    "placeholders": {
      "current": {
        "type": "String",
        "example": "125"
      },
      "max": {
        "type": "String",
        "example": "500"
      }
    }
  },
  "minimumCharacters": "Please enter at least {count} characters",
  "@minimumCharacters": {
    "description": "Minimum character requirement",
    "placeholders": {
      "count": {
        "type": "int",
        "example": "10"
      }
    }
  }
}

RTL Support for TextFields

TextFields automatically support RTL, but ensure proper alignment:

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

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

    return TextField(
      decoration: InputDecoration(
        labelText: l10n.fullName,
        hintText: l10n.fullNameHint,
        // Icons automatically flip in RTL
        prefixIcon: const Icon(Icons.person),
        // Suffix always stays on the logical end
        suffixIcon: IconButton(
          icon: const Icon(Icons.clear),
          onPressed: () {},
        ),
      ),
      // Text direction follows locale automatically
      textAlign: TextAlign.start,
    );
  }
}

Accessibility for Localized TextFields

Ensure TextFields are accessible in all languages:

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

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

    return Semantics(
      label: l10n.emailFieldAccessibility,
      hint: l10n.emailFieldHint,
      textField: true,
      child: TextFormField(
        decoration: InputDecoration(
          labelText: l10n.emailLabel,
          hintText: l10n.emailHint,
          errorText: null, // Set dynamically
          helperText: l10n.emailHelper,
          prefixIcon: Semantics(
            excludeSemantics: true,
            child: const Icon(Icons.email),
          ),
        ),
        keyboardType: TextInputType.emailAddress,
        autofillHints: const [AutofillHints.email],
      ),
    );
  }
}

ARB entries for accessibility:

{
  "emailFieldAccessibility": "Email address input field",
  "@emailFieldAccessibility": {
    "description": "Accessibility label for email field"
  },
  "emailFieldHint": "Enter your email address to sign in",
  "@emailFieldHint": {
    "description": "Accessibility hint for email field"
  }
}

Testing Localized TextFields

Comprehensive tests for TextField localization:

void main() {
  group('LocalizedTextField', () {
    testWidgets('displays English labels and hints', (tester) async {
      await tester.pumpWidget(
        const MaterialApp(
          locale: Locale('en'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Scaffold(body: LoginForm()),
        ),
      );

      expect(find.text('Email'), findsOneWidget);
      expect(find.text('Password'), findsOneWidget);
    });

    testWidgets('shows localized validation errors', (tester) async {
      await tester.pumpWidget(
        const MaterialApp(
          locale: Locale('en'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Scaffold(body: LoginForm()),
        ),
      );

      // Submit empty form
      await tester.tap(find.text('Log In'));
      await tester.pump();

      expect(find.text('Please enter your email address'), findsOneWidget);
      expect(find.text('Please enter your password'), findsOneWidget);
    });

    testWidgets('validates email format with localized message', (tester) async {
      await tester.pumpWidget(
        const MaterialApp(
          locale: Locale('en'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Scaffold(body: LoginForm()),
        ),
      );

      // Enter invalid email
      await tester.enterText(find.byType(TextFormField).first, 'invalid');
      await tester.tap(find.text('Log In'));
      await tester.pump();

      expect(find.text('Please enter a valid email address'), findsOneWidget);
    });

    testWidgets('respects RTL layout', (tester) async {
      await tester.pumpWidget(
        const MaterialApp(
          locale: Locale('ar'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: Scaffold(body: LoginForm()),
        ),
      );

      final textField = tester.widget<TextField>(find.byType(TextField).first);
      expect(
        Directionality.of(tester.element(find.byType(TextField).first)),
        TextDirection.rtl,
      );
    });
  });
}

Best Practices Summary

  1. Localize all visible text - labels, hints, helpers, and errors
  2. Use locale-aware formatters for numbers, currencies, and dates
  3. Handle RTL automatically - Flutter handles most cases
  4. Provide accessible labels for screen readers
  5. Test validation messages in all supported languages
  6. Use placeholders for dynamic values in error messages
  7. Consider text length variations - German text is often longer
  8. Format input hints using locale patterns (date formats, etc.)

Related Resources

Conclusion

TextField localization in Flutter encompasses labels, hints, helper text, error messages, and input formatting. By following the patterns in this guide, you'll create TextFields that feel native to users regardless of their language or region.

Start with basic label and hint localization, then add locale-aware validation messages and input formatters. Finally, ensure proper accessibility labels to make your forms work for all users worldwide.