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
Use parameterized validation messages with ARB placeholders like
fieldRequiredError(fieldName)to avoid duplicating error strings per field.Enable
AutovalidateMode.onUserInteractionafter the first submit attempt so users see localized errors as they correct fields.Track dirty state with
Form.onChangedto show localized unsaved-changes warnings and protect navigation with PopScope.Display error summaries at the top of long forms so users see translated error counts without scrolling.
Group fields with translated section headers for complex forms, making the structure clear in every language.
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.