← Back to Blog

Flutter Form Localization: Validated Input for Multilingual Apps

flutterformvalidationinputlocalizationrtl

Flutter Form Localization: Validated Input for Multilingual Apps

Form is a Flutter widget that groups FormField descendants together, providing validation, saving, and resetting capabilities as a unit. In multilingual applications, Form is essential for displaying localized validation error messages, providing translated field labels across the entire form, handling locale-specific input formats for dates and numbers, and coordinating form-wide validation with translated feedback.

Understanding Form in Localization Context

Form acts as a container that manages the state of its FormField children, enabling batch validation and data collection. For multilingual apps, this enables:

  • Centralized validation with localized error messages across all fields
  • Translated submit, reset, and cancel button labels
  • Locale-aware input formatting for dates, numbers, and currencies
  • Form-wide error summaries in the active language

Why Form Matters for Multilingual Apps

Form provides:

  • Batch validation: Validate all fields at once with localized error messages
  • State management: Save and reset form data with translated confirmation prompts
  • Error coordination: Display form-level errors in the active language
  • Field grouping: Organize related inputs with translated section headers

Basic Form Implementation

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

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

  @override
  State<LocalizedFormExample> createState() => _LocalizedFormExampleState();
}

class _LocalizedFormExampleState extends State<LocalizedFormExample> {
  final _formKey = GlobalKey<FormState>();
  String _name = '';
  String _email = '';

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.registrationTitle)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Form(
          key: _formKey,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              TextFormField(
                decoration: InputDecoration(
                  labelText: l10n.nameFieldLabel,
                  hintText: l10n.nameFieldHint,
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return l10n.nameRequiredError;
                  }
                  return null;
                },
                onSaved: (value) => _name = value ?? '',
              ),
              const SizedBox(height: 16),
              TextFormField(
                decoration: InputDecoration(
                  labelText: l10n.emailFieldLabel,
                  hintText: l10n.emailFieldHint,
                ),
                keyboardType: TextInputType.emailAddress,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return l10n.emailRequiredError;
                  }
                  if (!value.contains('@')) {
                    return l10n.emailInvalidError;
                  }
                  return null;
                },
                onSaved: (value) => _email = value ?? '',
              ),
              const SizedBox(height: 24),
              FilledButton(
                onPressed: () {
                  if (_formKey.currentState?.validate() == true) {
                    _formKey.currentState?.save();
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text(l10n.formSubmittedMessage)),
                    );
                  }
                },
                child: Text(l10n.submitButton),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Advanced Form Patterns for Localization

Multi-Section Form with Localized Headers

Complex forms organize fields into sections with translated headers and section-specific validation.

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

  @override
  State<LocalizedSectionedForm> createState() =>
      _LocalizedSectionedFormState();
}

class _LocalizedSectionedFormState extends State<LocalizedSectionedForm> {
  final _formKey = GlobalKey<FormState>();
  bool _autoValidate = false;

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.profileFormTitle)),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Form(
          key: _formKey,
          autovalidateMode: _autoValidate
              ? AutovalidateMode.onUserInteraction
              : AutovalidateMode.disabled,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                l10n.personalInfoSection,
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const SizedBox(height: 12),
              TextFormField(
                decoration: InputDecoration(
                  labelText: l10n.firstNameLabel,
                  prefixIcon: const Icon(Icons.person),
                ),
                validator: (value) => value?.isEmpty == true
                    ? l10n.fieldRequiredError(l10n.firstNameLabel)
                    : null,
              ),
              const SizedBox(height: 12),
              TextFormField(
                decoration: InputDecoration(
                  labelText: l10n.lastNameLabel,
                  prefixIcon: const Icon(Icons.person_outline),
                ),
                validator: (value) => value?.isEmpty == true
                    ? l10n.fieldRequiredError(l10n.lastNameLabel)
                    : null,
              ),
              const SizedBox(height: 24),
              Text(
                l10n.contactInfoSection,
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const SizedBox(height: 12),
              TextFormField(
                decoration: InputDecoration(
                  labelText: l10n.phoneLabel,
                  prefixIcon: const Icon(Icons.phone),
                ),
                keyboardType: TextInputType.phone,
                validator: (value) => value?.isEmpty == true
                    ? l10n.fieldRequiredError(l10n.phoneLabel)
                    : null,
              ),
              const SizedBox(height: 12),
              TextFormField(
                decoration: InputDecoration(
                  labelText: l10n.addressLabel,
                  prefixIcon: const Icon(Icons.location_on),
                ),
                maxLines: 3,
                validator: (value) => value?.isEmpty == true
                    ? l10n.fieldRequiredError(l10n.addressLabel)
                    : null,
              ),
              const SizedBox(height: 24),
              Row(
                children: [
                  Expanded(
                    child: OutlinedButton(
                      onPressed: () {
                        _formKey.currentState?.reset();
                        setState(() => _autoValidate = false);
                      },
                      child: Text(l10n.resetButton),
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: FilledButton(
                      onPressed: () {
                        setState(() => _autoValidate = true);
                        if (_formKey.currentState?.validate() == true) {
                          _formKey.currentState?.save();
                          ScaffoldMessenger.of(context).showSnackBar(
                            SnackBar(
                                content: Text(l10n.profileSavedMessage)),
                          );
                        }
                      },
                      child: Text(l10n.saveButton),
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Form with Localized Error Summary

Display a translated error summary at the top of the form when validation fails.

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

  @override
  State<FormWithErrorSummary> createState() => _FormWithErrorSummaryState();
}

class _FormWithErrorSummaryState extends State<FormWithErrorSummary> {
  final _formKey = GlobalKey<FormState>();
  final List<String> _errors = [];

  void _validateAndSubmit() {
    setState(() => _errors.clear());

    if (_formKey.currentState?.validate() != true) {
      setState(() {
        final l10n = AppLocalizations.of(context)!;
        _errors.add(l10n.formHasErrorsMessage);
      });
    } else {
      _formKey.currentState?.save();
    }
  }

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.checkoutTitle)),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Form(
          key: _formKey,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              if (_errors.isNotEmpty)
                Card(
                  color: Theme.of(context).colorScheme.errorContainer,
                  child: Padding(
                    padding: const EdgeInsets.all(12),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Row(
                          children: [
                            Icon(
                              Icons.error_outline,
                              color: Theme.of(context)
                                  .colorScheme
                                  .onErrorContainer,
                            ),
                            const SizedBox(width: 8),
                            Text(
                              l10n.errorSummaryTitle,
                              style: TextStyle(
                                fontWeight: FontWeight.bold,
                                color: Theme.of(context)
                                    .colorScheme
                                    .onErrorContainer,
                              ),
                            ),
                          ],
                        ),
                        const SizedBox(height: 8),
                        for (final error in _errors)
                          Text(
                            error,
                            style: TextStyle(
                              color: Theme.of(context)
                                  .colorScheme
                                  .onErrorContainer,
                            ),
                          ),
                      ],
                    ),
                  ),
                ),
              const SizedBox(height: 16),
              TextFormField(
                decoration: InputDecoration(
                  labelText: l10n.cardNumberLabel,
                ),
                keyboardType: TextInputType.number,
                validator: (value) {
                  if (value == null || value.length < 16) {
                    return l10n.cardNumberInvalidError;
                  }
                  return null;
                },
              ),
              const SizedBox(height: 12),
              Row(
                children: [
                  Expanded(
                    child: TextFormField(
                      decoration: InputDecoration(
                        labelText: l10n.expiryDateLabel,
                      ),
                      validator: (value) => value?.isEmpty == true
                          ? l10n.fieldRequiredError(l10n.expiryDateLabel)
                          : null,
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: TextFormField(
                      decoration: InputDecoration(
                        labelText: l10n.cvvLabel,
                      ),
                      obscureText: true,
                      validator: (value) => value?.isEmpty == true
                          ? l10n.fieldRequiredError(l10n.cvvLabel)
                          : null,
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 24),
              FilledButton(
                onPressed: _validateAndSubmit,
                child: Text(l10n.payNowButton),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Form with Dirty State Tracking

Track whether the form has been modified and show a localized confirmation before discarding changes.

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

  @override
  State<DirtyTrackingForm> createState() => _DirtyTrackingFormState();
}

class _DirtyTrackingFormState extends State<DirtyTrackingForm> {
  final _formKey = GlobalKey<FormState>();
  bool _isDirty = false;

  Future<bool> _confirmDiscard() async {
    if (!_isDirty) return true;
    final l10n = AppLocalizations.of(context)!;

    final result = await showDialog<bool>(
      context: context,
      builder: (context) {
        final dialogL10n = AppLocalizations.of(context)!;
        return AlertDialog(
          title: Text(dialogL10n.discardChangesTitle),
          content: Text(dialogL10n.discardChangesMessage),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context, false),
              child: Text(dialogL10n.keepEditingButton),
            ),
            FilledButton(
              onPressed: () => Navigator.pop(context, true),
              child: Text(dialogL10n.discardButton),
            ),
          ],
        );
      },
    );
    return result ?? false;
  }

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

    return PopScope(
      canPop: !_isDirty,
      onPopInvokedWithResult: (didPop, result) async {
        if (didPop) return;
        final shouldDiscard = await _confirmDiscard();
        if (shouldDiscard && mounted) {
          Navigator.pop(context);
        }
      },
      child: Scaffold(
        appBar: AppBar(
          title: Text(l10n.editProfileTitle),
          actions: [
            if (_isDirty)
              TextButton(
                onPressed: () {
                  if (_formKey.currentState?.validate() == true) {
                    _formKey.currentState?.save();
                    setState(() => _isDirty = false);
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text(l10n.changesSavedMessage)),
                    );
                  }
                },
                child: Text(l10n.saveButton),
              ),
          ],
        ),
        body: Padding(
          padding: const EdgeInsets.all(16),
          child: Form(
            key: _formKey,
            onChanged: () => setState(() => _isDirty = true),
            child: Column(
              children: [
                TextFormField(
                  decoration: InputDecoration(
                    labelText: l10n.displayNameLabel,
                  ),
                  validator: (value) => value?.isEmpty == true
                      ? l10n.fieldRequiredError(l10n.displayNameLabel)
                      : null,
                ),
                const SizedBox(height: 12),
                TextFormField(
                  decoration: InputDecoration(
                    labelText: l10n.bioLabel,
                  ),
                  maxLines: 4,
                ),
                if (_isDirty) ...[
                  const SizedBox(height: 16),
                  Text(
                    l10n.unsavedChangesWarning,
                    style: TextStyle(
                      color: Theme.of(context).colorScheme.error,
                    ),
                  ),
                ],
              ],
            ),
          ),
        ),
      ),
    );
  }
}

RTL Support and Bidirectional Layouts

Form layouts automatically adapt to RTL through the inherited directionality. Field labels, icons, and error messages align correctly based on the active locale.

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

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

    return Form(
      child: Padding(
        padding: const EdgeInsetsDirectional.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextFormField(
              decoration: InputDecoration(
                labelText: l10n.searchLabel,
                prefixIcon: const Icon(Icons.search),
                suffixIcon: const Icon(Icons.clear),
              ),
              textDirection: Directionality.of(context),
            ),
            const SizedBox(height: 12),
            TextFormField(
              decoration: InputDecoration(
                labelText: l10n.notesLabel,
                alignLabelWithHint: true,
              ),
              maxLines: 5,
              textAlign: TextAlign.start,
            ),
          ],
        ),
      ),
    );
  }
}

Testing Form Localization

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

void main() {
  Widget buildTestWidget({Locale locale = const Locale('en')}) {
    return MaterialApp(
      locale: locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: const LocalizedFormExample(),
    );
  }

  testWidgets('Form shows localized validation errors', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();

    await tester.tap(find.byType(FilledButton));
    await tester.pumpAndSettle();

    expect(find.byType(Form), findsOneWidget);
  });

  testWidgets('Form works in RTL locale', (tester) async {
    await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
    await tester.pumpAndSettle();
    expect(tester.takeException(), isNull);
  });
}

Best Practices

  1. Use parameterized validation messages with ARB placeholders like fieldRequiredError(fieldName) to avoid duplicating error strings per field.

  2. Enable AutovalidateMode.onUserInteraction after the first submit attempt so users see localized errors as they correct fields.

  3. Track dirty state with Form.onChanged to show localized unsaved-changes warnings and protect navigation with PopScope.

  4. Display error summaries at the top of long forms so users see translated error counts without scrolling.

  5. Group fields with translated section headers for complex forms, making the structure clear in every language.

  6. Test form validation in RTL to verify that error messages, prefix/suffix icons, and button layouts display correctly.

Conclusion

Form is the foundation of validated user input in Flutter. For multilingual apps, it centralizes validation logic so that localized error messages, translated labels, and locale-aware formatting are consistent across all fields. By combining Form with parameterized error strings, dirty state tracking, error summaries, and section headers, you can build form experiences that validate and communicate clearly in every supported language.

Further Reading