← Back to Blog

Flutter Form Validation Localization: Complete Guide to Multilingual Error Messages

flutterformsvalidationlocalizationerror-messagesi18n

Flutter Form Validation Localization: Complete Guide to Multilingual Error Messages

Forms are the backbone of user input in mobile apps. From login screens to checkout flows, forms collect critical data. But validation error messages like "This field is required" mean nothing to users who don't speak English. This guide covers everything you need to know about localizing form validation in Flutter.

Why Form Validation Localization Matters

Consider these scenarios:

  • A Spanish user sees "Invalid email format" and doesn't understand what's wrong
  • A German user encounters "Password must be 8 characters" but can't read it
  • A Japanese user abandons checkout because error messages are incomprehensible

Localized validation messages increase form completion rates by up to 40% according to UX research.

Project Setup

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter

flutter:
  generate: true
# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

Basic Validation Messages

ARB Files for Validation

// lib/l10n/app_en.arb
{
  "@@locale": "en",

  "validationRequired": "This field is required",
  "@validationRequired": {
    "description": "Generic required field error"
  },

  "validationRequiredField": "{fieldName} is required",
  "@validationRequiredField": {
    "description": "Required field error with field name",
    "placeholders": {
      "fieldName": {
        "type": "String",
        "example": "Email"
      }
    }
  },

  "validationEmail": "Please enter a valid email address",
  "@validationEmail": {
    "description": "Invalid email error"
  },

  "validationMinLength": "Must be at least {min} characters",
  "@validationMinLength": {
    "description": "Minimum length error",
    "placeholders": {
      "min": {
        "type": "int",
        "example": "8"
      }
    }
  },

  "validationMaxLength": "Must be no more than {max} characters",
  "@validationMaxLength": {
    "description": "Maximum length error",
    "placeholders": {
      "max": {
        "type": "int",
        "example": "100"
      }
    }
  },

  "validationPasswordMismatch": "Passwords do not match",
  "@validationPasswordMismatch": {
    "description": "Password confirmation mismatch"
  },

  "validationPhoneInvalid": "Please enter a valid phone number",
  "@validationPhoneInvalid": {
    "description": "Invalid phone number"
  },

  "validationUrlInvalid": "Please enter a valid URL",
  "@validationUrlInvalid": {
    "description": "Invalid URL"
  },

  "validationNumberOnly": "Please enter numbers only",
  "@validationNumberOnly": {
    "description": "Numbers only validation"
  },

  "validationRange": "Value must be between {min} and {max}",
  "@validationRange": {
    "description": "Range validation error",
    "placeholders": {
      "min": {
        "type": "String",
        "example": "1"
      },
      "max": {
        "type": "String",
        "example": "100"
      }
    }
  }
}
// lib/l10n/app_es.arb
{
  "@@locale": "es",

  "validationRequired": "Este campo es obligatorio",
  "validationRequiredField": "{fieldName} es obligatorio",
  "validationEmail": "Por favor, introduce un correo electronico valido",
  "validationMinLength": "Debe tener al menos {min} caracteres",
  "validationMaxLength": "No debe exceder {max} caracteres",
  "validationPasswordMismatch": "Las contrasenas no coinciden",
  "validationPhoneInvalid": "Por favor, introduce un numero de telefono valido",
  "validationUrlInvalid": "Por favor, introduce una URL valida",
  "validationNumberOnly": "Por favor, introduce solo numeros",
  "validationRange": "El valor debe estar entre {min} y {max}"
}
// lib/l10n/app_de.arb
{
  "@@locale": "de",

  "validationRequired": "Dieses Feld ist erforderlich",
  "validationRequiredField": "{fieldName} ist erforderlich",
  "validationEmail": "Bitte geben Sie eine gultige E-Mail-Adresse ein",
  "validationMinLength": "Muss mindestens {min} Zeichen lang sein",
  "validationMaxLength": "Darf maximal {max} Zeichen lang sein",
  "validationPasswordMismatch": "Passworter stimmen nicht uberein",
  "validationPhoneInvalid": "Bitte geben Sie eine gultige Telefonnummer ein",
  "validationUrlInvalid": "Bitte geben Sie eine gultige URL ein",
  "validationNumberOnly": "Bitte nur Zahlen eingeben",
  "validationRange": "Der Wert muss zwischen {min} und {max} liegen"
}

Creating a Localized Validator

The Validators Class

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

/// Provides localized form validation
class Validators {
  final AppLocalizations l10n;

  Validators(this.l10n);

  /// Factory constructor using BuildContext
  factory Validators.of(BuildContext context) {
    return Validators(AppLocalizations.of(context)!);
  }

  /// Required field validator
  String? required(String? value) {
    if (value == null || value.trim().isEmpty) {
      return l10n.validationRequired;
    }
    return null;
  }

  /// Required field validator with field name
  String? requiredField(String? value, String fieldName) {
    if (value == null || value.trim().isEmpty) {
      return l10n.validationRequiredField(fieldName);
    }
    return null;
  }

  /// Email validator
  String? email(String? value) {
    if (value == null || value.isEmpty) {
      return l10n.validationRequired;
    }

    final emailRegex = RegExp(
      r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
    );

    if (!emailRegex.hasMatch(value)) {
      return l10n.validationEmail;
    }
    return null;
  }

  /// Minimum length validator
  String? minLength(String? value, int min) {
    if (value == null || value.length < min) {
      return l10n.validationMinLength(min);
    }
    return null;
  }

  /// Maximum length validator
  String? maxLength(String? value, int max) {
    if (value != null && value.length > max) {
      return l10n.validationMaxLength(max);
    }
    return null;
  }

  /// Combined min/max length validator
  String? lengthRange(String? value, int min, int max) {
    if (value == null || value.length < min) {
      return l10n.validationMinLength(min);
    }
    if (value.length > max) {
      return l10n.validationMaxLength(max);
    }
    return null;
  }

  /// Phone number validator
  String? phone(String? value) {
    if (value == null || value.isEmpty) {
      return l10n.validationRequired;
    }

    // Basic international phone regex
    final phoneRegex = RegExp(r'^\+?[\d\s\-\(\)]{10,}$');

    if (!phoneRegex.hasMatch(value)) {
      return l10n.validationPhoneInvalid;
    }
    return null;
  }

  /// URL validator
  String? url(String? value) {
    if (value == null || value.isEmpty) {
      return null; // URL might be optional
    }

    final urlRegex = RegExp(
      r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$',
    );

    if (!urlRegex.hasMatch(value)) {
      return l10n.validationUrlInvalid;
    }
    return null;
  }

  /// Numeric only validator
  String? numeric(String? value) {
    if (value == null || value.isEmpty) {
      return null;
    }

    if (!RegExp(r'^\d+$').hasMatch(value)) {
      return l10n.validationNumberOnly;
    }
    return null;
  }

  /// Numeric range validator
  String? range(String? value, num min, num max) {
    if (value == null || value.isEmpty) {
      return l10n.validationRequired;
    }

    final number = num.tryParse(value);
    if (number == null) {
      return l10n.validationNumberOnly;
    }

    if (number < min || number > max) {
      return l10n.validationRange(min.toString(), max.toString());
    }
    return null;
  }

  /// Password match validator
  String? passwordMatch(String? value, String password) {
    if (value != password) {
      return l10n.validationPasswordMismatch;
    }
    return null;
  }

  /// Combine multiple validators
  String? Function(String?) combine(List<String? Function(String?)> validators) {
    return (String? value) {
      for (final validator in validators) {
        final error = validator(value);
        if (error != null) return error;
      }
      return null;
    };
  }
}

Using Validators in Forms

// lib/screens/registration_form.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../utils/validators.dart';

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

  @override
  State<RegistrationForm> createState() => _RegistrationFormState();
}

class _RegistrationFormState extends State<RegistrationForm> {
  final _formKey = GlobalKey<FormState>();
  final _passwordController = TextEditingController();

  @override
  void dispose() {
    _passwordController.dispose();
    super.dispose();
  }

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

    return Form(
      key: _formKey,
      child: Column(
        children: [
          // Name field
          TextFormField(
            decoration: InputDecoration(
              labelText: l10n.fieldName,
              hintText: l10n.fieldNameHint,
            ),
            validator: (value) => validators.requiredField(value, l10n.fieldName),
          ),

          const SizedBox(height: 16),

          // Email field
          TextFormField(
            decoration: InputDecoration(
              labelText: l10n.fieldEmail,
              hintText: l10n.fieldEmailHint,
            ),
            keyboardType: TextInputType.emailAddress,
            validator: validators.email,
          ),

          const SizedBox(height: 16),

          // Phone field
          TextFormField(
            decoration: InputDecoration(
              labelText: l10n.fieldPhone,
              hintText: l10n.fieldPhoneHint,
            ),
            keyboardType: TextInputType.phone,
            validator: validators.phone,
          ),

          const SizedBox(height: 16),

          // Password field
          TextFormField(
            controller: _passwordController,
            decoration: InputDecoration(
              labelText: l10n.fieldPassword,
              hintText: l10n.fieldPasswordHint,
            ),
            obscureText: true,
            validator: validators.combine([
              validators.required,
              (v) => validators.minLength(v, 8),
            ]),
          ),

          const SizedBox(height: 16),

          // Confirm password field
          TextFormField(
            decoration: InputDecoration(
              labelText: l10n.fieldConfirmPassword,
              hintText: l10n.fieldConfirmPasswordHint,
            ),
            obscureText: true,
            validator: validators.combine([
              validators.required,
              (v) => validators.passwordMatch(v, _passwordController.text),
            ]),
          ),

          const SizedBox(height: 24),

          ElevatedButton(
            onPressed: _submit,
            child: Text(l10n.buttonRegister),
          ),
        ],
      ),
    );
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      // Form is valid, proceed with registration
    }
  }
}

Advanced Validation Patterns

Password Strength Validator

// lib/utils/password_validator.dart
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

enum PasswordStrength { weak, fair, good, strong }

class PasswordValidator {
  final AppLocalizations l10n;

  PasswordValidator(this.l10n);

  /// Validate password with detailed requirements
  String? validate(String? value) {
    if (value == null || value.isEmpty) {
      return l10n.validationRequired;
    }

    final errors = <String>[];

    if (value.length < 8) {
      errors.add(l10n.passwordRequirementLength(8));
    }

    if (!value.contains(RegExp(r'[A-Z]'))) {
      errors.add(l10n.passwordRequirementUppercase);
    }

    if (!value.contains(RegExp(r'[a-z]'))) {
      errors.add(l10n.passwordRequirementLowercase);
    }

    if (!value.contains(RegExp(r'[0-9]'))) {
      errors.add(l10n.passwordRequirementNumber);
    }

    if (!value.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'))) {
      errors.add(l10n.passwordRequirementSpecial);
    }

    if (errors.isEmpty) return null;

    return errors.join('\n');
  }

  /// Get password strength indicator
  PasswordStrength getStrength(String password) {
    int score = 0;

    if (password.length >= 8) score++;
    if (password.length >= 12) score++;
    if (password.contains(RegExp(r'[A-Z]'))) score++;
    if (password.contains(RegExp(r'[a-z]'))) score++;
    if (password.contains(RegExp(r'[0-9]'))) score++;
    if (password.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'))) score++;

    if (score <= 2) return PasswordStrength.weak;
    if (score <= 3) return PasswordStrength.fair;
    if (score <= 4) return PasswordStrength.good;
    return PasswordStrength.strong;
  }

  /// Get localized strength label
  String getStrengthLabel(PasswordStrength strength) {
    switch (strength) {
      case PasswordStrength.weak:
        return l10n.passwordStrengthWeak;
      case PasswordStrength.fair:
        return l10n.passwordStrengthFair;
      case PasswordStrength.good:
        return l10n.passwordStrengthGood;
      case PasswordStrength.strong:
        return l10n.passwordStrengthStrong;
    }
  }
}

ARB entries for password validation:

// lib/l10n/app_en.arb (add these)
{
  "passwordRequirementLength": "At least {count} characters",
  "@passwordRequirementLength": {
    "placeholders": { "count": { "type": "int" } }
  },
  "passwordRequirementUppercase": "At least one uppercase letter",
  "passwordRequirementLowercase": "At least one lowercase letter",
  "passwordRequirementNumber": "At least one number",
  "passwordRequirementSpecial": "At least one special character",
  "passwordStrengthWeak": "Weak",
  "passwordStrengthFair": "Fair",
  "passwordStrengthGood": "Good",
  "passwordStrengthStrong": "Strong"
}

Credit Card Validator

// lib/utils/credit_card_validator.dart
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class CreditCardValidator {
  final AppLocalizations l10n;

  CreditCardValidator(this.l10n);

  /// Validate card number
  String? validateNumber(String? value) {
    if (value == null || value.isEmpty) {
      return l10n.validationRequired;
    }

    // Remove spaces and dashes
    final cleaned = value.replaceAll(RegExp(r'[\s\-]'), '');

    if (!RegExp(r'^\d{13,19}$').hasMatch(cleaned)) {
      return l10n.cardNumberInvalid;
    }

    // Luhn algorithm check
    if (!_luhnCheck(cleaned)) {
      return l10n.cardNumberInvalid;
    }

    return null;
  }

  /// Validate expiry date
  String? validateExpiry(String? value) {
    if (value == null || value.isEmpty) {
      return l10n.validationRequired;
    }

    // Expected format: MM/YY
    final regex = RegExp(r'^(0[1-9]|1[0-2])\/(\d{2})$');
    final match = regex.firstMatch(value);

    if (match == null) {
      return l10n.cardExpiryFormat;
    }

    final month = int.parse(match.group(1)!);
    final year = int.parse('20${match.group(2)}');
    final now = DateTime.now();
    final expiry = DateTime(year, month + 1, 0); // Last day of month

    if (expiry.isBefore(now)) {
      return l10n.cardExpired;
    }

    return null;
  }

  /// Validate CVV
  String? validateCVV(String? value, {bool isAmex = false}) {
    if (value == null || value.isEmpty) {
      return l10n.validationRequired;
    }

    final expectedLength = isAmex ? 4 : 3;

    if (!RegExp(r'^\d+$').hasMatch(value)) {
      return l10n.cardCvvInvalid;
    }

    if (value.length != expectedLength) {
      return l10n.cardCvvLength(expectedLength);
    }

    return null;
  }

  bool _luhnCheck(String cardNumber) {
    int sum = 0;
    bool alternate = false;

    for (int i = cardNumber.length - 1; i >= 0; i--) {
      int digit = int.parse(cardNumber[i]);

      if (alternate) {
        digit *= 2;
        if (digit > 9) digit -= 9;
      }

      sum += digit;
      alternate = !alternate;
    }

    return sum % 10 == 0;
  }
}

Date Validator with Locale-Aware Formatting

// lib/utils/date_validator.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';

class DateValidator {
  final AppLocalizations l10n;
  final String locale;

  DateValidator(this.l10n, this.locale);

  factory DateValidator.of(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context).toString();
    return DateValidator(l10n, locale);
  }

  /// Get expected date format for current locale
  String get expectedFormat {
    switch (locale) {
      case 'en_US':
        return 'MM/DD/YYYY';
      case 'en_GB':
      case 'de':
      case 'es':
      case 'fr':
        return 'DD/MM/YYYY';
      case 'ja':
      case 'zh':
        return 'YYYY/MM/DD';
      default:
        return 'DD/MM/YYYY';
    }
  }

  /// Validate date string
  String? validate(String? value) {
    if (value == null || value.isEmpty) {
      return l10n.validationRequired;
    }

    final date = _parseDate(value);
    if (date == null) {
      return l10n.dateFormatInvalid(expectedFormat);
    }

    return null;
  }

  /// Validate date is in the past
  String? validatePast(String? value) {
    final baseError = validate(value);
    if (baseError != null) return baseError;

    final date = _parseDate(value!);
    if (date!.isAfter(DateTime.now())) {
      return l10n.dateMustBePast;
    }

    return null;
  }

  /// Validate date is in the future
  String? validateFuture(String? value) {
    final baseError = validate(value);
    if (baseError != null) return baseError;

    final date = _parseDate(value!);
    if (date!.isBefore(DateTime.now())) {
      return l10n.dateMustBeFuture;
    }

    return null;
  }

  /// Validate age (for birthdate)
  String? validateAge(String? value, int minimumAge) {
    final baseError = validate(value);
    if (baseError != null) return baseError;

    final birthDate = _parseDate(value!);
    final today = DateTime.now();
    int age = today.year - birthDate!.year;

    if (today.month < birthDate.month ||
        (today.month == birthDate.month && today.day < birthDate.day)) {
      age--;
    }

    if (age < minimumAge) {
      return l10n.ageMinimumRequired(minimumAge);
    }

    return null;
  }

  DateTime? _parseDate(String value) {
    try {
      final pattern = _getDatePattern();
      final format = DateFormat(pattern, locale);
      return format.parseStrict(value);
    } catch (_) {
      return null;
    }
  }

  String _getDatePattern() {
    switch (locale) {
      case 'en_US':
        return 'MM/dd/yyyy';
      case 'ja':
      case 'zh':
        return 'yyyy/MM/dd';
      default:
        return 'dd/MM/yyyy';
    }
  }
}

Form Field Labels and Hints

Don't forget to localize field labels and hints too:

// lib/l10n/app_en.arb (add these)
{
  "fieldName": "Full Name",
  "fieldNameHint": "Enter your full name",

  "fieldEmail": "Email",
  "fieldEmailHint": "example@email.com",

  "fieldPhone": "Phone Number",
  "fieldPhoneHint": "+1 (555) 123-4567",

  "fieldPassword": "Password",
  "fieldPasswordHint": "Enter a strong password",

  "fieldConfirmPassword": "Confirm Password",
  "fieldConfirmPasswordHint": "Re-enter your password",

  "fieldAddress": "Address",
  "fieldAddressHint": "Street address",

  "fieldCity": "City",
  "fieldCityHint": "Enter your city",

  "fieldPostalCode": "Postal Code",
  "fieldPostalCodeHint": "12345",

  "fieldCountry": "Country",
  "fieldCountryHint": "Select your country",

  "fieldBirthdate": "Date of Birth",
  "fieldBirthdateHint": "MM/DD/YYYY"
}

Real-Time Validation with Localization

// lib/widgets/validated_text_field.dart
import 'package:flutter/material.dart';

class ValidatedTextField extends StatefulWidget {
  final String label;
  final String? hint;
  final String? Function(String?) validator;
  final TextEditingController? controller;
  final TextInputType? keyboardType;
  final bool obscureText;
  final bool validateOnChange;

  const ValidatedTextField({
    super.key,
    required this.label,
    this.hint,
    required this.validator,
    this.controller,
    this.keyboardType,
    this.obscureText = false,
    this.validateOnChange = true,
  });

  @override
  State<ValidatedTextField> createState() => _ValidatedTextFieldState();
}

class _ValidatedTextFieldState extends State<ValidatedTextField> {
  String? _errorText;
  bool _hasInteracted = false;

  void _validate(String value) {
    if (!_hasInteracted) return;

    setState(() {
      _errorText = widget.validator(value);
    });
  }

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: widget.controller,
      decoration: InputDecoration(
        labelText: widget.label,
        hintText: widget.hint,
        errorText: _errorText,
        errorMaxLines: 3, // Allow multi-line errors
      ),
      keyboardType: widget.keyboardType,
      obscureText: widget.obscureText,
      onChanged: widget.validateOnChange ? _validate : null,
      onTap: () {
        setState(() {
          _hasInteracted = true;
        });
      },
      validator: widget.validator,
    );
  }
}

Server-Side Validation Errors

Handle localized errors from your backend:

// lib/services/api_service.dart
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class ApiService {
  /// Map server error codes to localized messages
  static String getLocalizedError(
    AppLocalizations l10n,
    String errorCode,
    Map<String, dynamic>? params,
  ) {
    switch (errorCode) {
      case 'EMAIL_TAKEN':
        return l10n.errorEmailTaken;
      case 'USERNAME_TAKEN':
        return l10n.errorUsernameTaken;
      case 'WEAK_PASSWORD':
        return l10n.errorWeakPassword;
      case 'INVALID_CREDENTIALS':
        return l10n.errorInvalidCredentials;
      case 'ACCOUNT_LOCKED':
        final minutes = params?['minutes'] ?? 30;
        return l10n.errorAccountLocked(minutes);
      case 'RATE_LIMITED':
        final seconds = params?['seconds'] ?? 60;
        return l10n.errorRateLimited(seconds);
      default:
        return l10n.errorUnknown;
    }
  }
}

// Usage in form submission
Future<void> _submitForm() async {
  try {
    await api.register(formData);
  } on ApiException catch (e) {
    final l10n = AppLocalizations.of(context)!;
    final message = ApiService.getLocalizedError(l10n, e.code, e.params);

    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message)),
    );
  }
}

Testing Localized Validation

// test/validators_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

void main() {
  group('Validators', () {
    late Validators validators;

    setUpAll(() async {
      // Load English localizations for testing
      final l10n = await AppLocalizations.delegate.load(const Locale('en'));
      validators = Validators(l10n);
    });

    group('email', () {
      test('returns null for valid email', () {
        expect(validators.email('test@example.com'), isNull);
      });

      test('returns error for invalid email', () {
        expect(validators.email('invalid'), isNotNull);
        expect(validators.email('test@'), isNotNull);
        expect(validators.email('@example.com'), isNotNull);
      });

      test('returns required error for empty', () {
        expect(validators.email(''), isNotNull);
        expect(validators.email(null), isNotNull);
      });
    });

    group('minLength', () {
      test('returns null when length is sufficient', () {
        expect(validators.minLength('12345678', 8), isNull);
        expect(validators.minLength('123456789', 8), isNull);
      });

      test('returns error when too short', () {
        expect(validators.minLength('1234567', 8), isNotNull);
        expect(validators.minLength('', 8), isNotNull);
      });
    });
  });
}

Widget Testing with Multiple Locales

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

void main() {
  group('RegistrationForm Localization', () {
    Widget buildForm(Locale locale) {
      return MaterialApp(
        locale: locale,
        localizationsDelegates: AppLocalizations.localizationsDelegates,
        supportedLocales: AppLocalizations.supportedLocales,
        home: const Scaffold(body: RegistrationForm()),
      );
    }

    testWidgets('shows English validation errors', (tester) async {
      await tester.pumpWidget(buildForm(const Locale('en')));

      // Tap submit without filling form
      await tester.tap(find.text('Register'));
      await tester.pumpAndSettle();

      expect(find.text('This field is required'), findsWidgets);
    });

    testWidgets('shows Spanish validation errors', (tester) async {
      await tester.pumpWidget(buildForm(const Locale('es')));

      // Tap submit without filling form
      await tester.tap(find.text('Registrarse'));
      await tester.pumpAndSettle();

      expect(find.text('Este campo es obligatorio'), findsWidgets);
    });

    testWidgets('shows German validation errors', (tester) async {
      await tester.pumpWidget(buildForm(const Locale('de')));

      // Tap submit without filling form
      await tester.tap(find.text('Registrieren'));
      await tester.pumpAndSettle();

      expect(find.text('Dieses Feld ist erforderlich'), findsWidgets);
    });
  });
}

Best Practices

1. Use Specific Error Messages

Generic errors frustrate users. Be specific:

// Bad
"validationInvalid": "Invalid input"

// Good
"validationEmailInvalid": "Please enter a valid email address (e.g., name@example.com)"

2. Provide Constructive Guidance

Tell users how to fix the error:

// Bad
"passwordTooShort": "Password too short"

// Good
"passwordTooShort": "Password must be at least {min} characters. Add {needed} more characters."

3. Consider Cultural Differences

Some validations are culture-specific:

// Phone number format varies by country
String? validatePhone(String? value, String countryCode) {
  final patterns = {
    'US': r'^\(\d{3}\) \d{3}-\d{4}$',
    'DE': r'^\+49 \d{3} \d{7,8}$',
    'JP': r'^0\d{1,4}-\d{1,4}-\d{4}$',
  };

  final pattern = patterns[countryCode] ?? patterns['US']!;
  // ...
}

4. Handle Pluralization

Use ICU plural syntax for count-based messages:

"validationMinCharacters": "{count, plural, =1{At least 1 character required} other{At least {count} characters required}}"

5. Test with Real Users

Validation messages that make sense to developers might confuse real users. Test with native speakers in each supported language.

Summary

Effective form validation localization requires:

  1. Comprehensive ARB files with all validation messages
  2. Reusable validator classes that accept localization context
  3. Field-specific messages that tell users exactly what's wrong
  4. Locale-aware formatting for dates, numbers, and phone numbers
  5. Server error mapping to handle backend validation
  6. Thorough testing in all supported locales

With these patterns, your forms will guide users through completion regardless of their language, improving conversion rates and user satisfaction.

Related Resources