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
- Localize all visible text - labels, hints, helpers, and errors
- Use locale-aware formatters for numbers, currencies, and dates
- Handle RTL automatically - Flutter handles most cases
- Provide accessible labels for screen readers
- Test validation messages in all supported languages
- Use placeholders for dynamic values in error messages
- Consider text length variations - German text is often longer
- Format input hints using locale patterns (date formats, etc.)
Related Resources
- Flutter Form Validation Localization
- Flutter Number and Currency Formatting
- Flutter DateTime Localization
- Flutter RTL Guide
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.