Flutter Radio Button Localization: Single Selection, Groups, and Accessibility
Radio buttons are essential for single-selection scenarios in Flutter apps. From payment methods to shipping options, from survey questions to settings preferences, radio buttons appear throughout mobile applications. Properly localizing radio button components ensures users worldwide can make clear, confident choices.
Why Radio Button Localization Matters
Radio buttons represent mutually exclusive choices. When labels aren't properly translated, users may select the wrong option, leading to frustration or incorrect data. A well-localized radio button group adapts labels, descriptions, and layout direction while maintaining clear visual hierarchy.
Basic Radio Button Localization
Let's start with the fundamentals:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedRadioGroup extends StatefulWidget {
const LocalizedRadioGroup({super.key});
@override
State<LocalizedRadioGroup> createState() => _LocalizedRadioGroupState();
}
class _LocalizedRadioGroupState extends State<LocalizedRadioGroup> {
String? _selectedOption;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.selectGenderLabel,
style: Theme.of(context).textTheme.titleMedium,
),
),
RadioListTile<String>(
title: Text(l10n.genderMale),
value: 'male',
groupValue: _selectedOption,
onChanged: (value) {
setState(() {
_selectedOption = value;
});
},
),
RadioListTile<String>(
title: Text(l10n.genderFemale),
value: 'female',
groupValue: _selectedOption,
onChanged: (value) {
setState(() {
_selectedOption = value;
});
},
),
RadioListTile<String>(
title: Text(l10n.genderOther),
value: 'other',
groupValue: _selectedOption,
onChanged: (value) {
setState(() {
_selectedOption = value;
});
},
),
RadioListTile<String>(
title: Text(l10n.genderPreferNotToSay),
value: 'prefer_not_to_say',
groupValue: _selectedOption,
onChanged: (value) {
setState(() {
_selectedOption = value;
});
},
),
],
);
}
}
Your ARB file would include:
{
"selectGenderLabel": "Select your gender",
"@selectGenderLabel": {
"description": "Label for gender selection radio group"
},
"genderMale": "Male",
"@genderMale": {
"description": "Male gender option"
},
"genderFemale": "Female",
"@genderFemale": {
"description": "Female gender option"
},
"genderOther": "Other",
"@genderOther": {
"description": "Other gender option"
},
"genderPreferNotToSay": "Prefer not to say",
"@genderPreferNotToSay": {
"description": "Prefer not to say gender option"
}
}
Payment Method Selection
A common e-commerce pattern:
class PaymentMethodRadio extends StatefulWidget {
final ValueChanged<String> onPaymentSelected;
const PaymentMethodRadio({
super.key,
required this.onPaymentSelected,
});
@override
State<PaymentMethodRadio> createState() => _PaymentMethodRadioState();
}
class _PaymentMethodRadioState extends State<PaymentMethodRadio> {
String? _selectedMethod;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.paymentMethodTitle,
style: Theme.of(context).textTheme.titleLarge,
),
),
_PaymentOptionTile(
title: l10n.paymentCreditCard,
subtitle: l10n.paymentCreditCardDescription,
icon: Icons.credit_card,
value: 'credit_card',
groupValue: _selectedMethod,
onChanged: _handleSelection,
),
_PaymentOptionTile(
title: l10n.paymentPayPal,
subtitle: l10n.paymentPayPalDescription,
icon: Icons.account_balance_wallet,
value: 'paypal',
groupValue: _selectedMethod,
onChanged: _handleSelection,
),
_PaymentOptionTile(
title: l10n.paymentBankTransfer,
subtitle: l10n.paymentBankTransferDescription,
icon: Icons.account_balance,
value: 'bank_transfer',
groupValue: _selectedMethod,
onChanged: _handleSelection,
),
_PaymentOptionTile(
title: l10n.paymentCashOnDelivery,
subtitle: l10n.paymentCashOnDeliveryDescription,
icon: Icons.local_shipping,
value: 'cod',
groupValue: _selectedMethod,
onChanged: _handleSelection,
),
],
);
}
void _handleSelection(String? value) {
setState(() {
_selectedMethod = value;
});
if (value != null) {
widget.onPaymentSelected(value);
}
}
}
class _PaymentOptionTile extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final String value;
final String? groupValue;
final ValueChanged<String?> onChanged;
const _PaymentOptionTile({
required this.title,
required this.subtitle,
required this.icon,
required this.value,
required this.groupValue,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final isSelected = value == groupValue;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: RadioListTile<String>(
title: Row(
children: [
Icon(icon, size: 24),
const SizedBox(width: 12),
Text(title),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(left: 36),
child: Text(subtitle),
),
value: value,
groupValue: groupValue,
onChanged: onChanged,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
);
}
}
ARB entries:
{
"paymentMethodTitle": "Payment Method",
"@paymentMethodTitle": {
"description": "Title for payment method section"
},
"paymentCreditCard": "Credit/Debit Card",
"@paymentCreditCard": {
"description": "Credit card payment option"
},
"paymentCreditCardDescription": "Visa, Mastercard, American Express",
"@paymentCreditCardDescription": {
"description": "Description for credit card option"
},
"paymentPayPal": "PayPal",
"@paymentPayPal": {
"description": "PayPal payment option"
},
"paymentPayPalDescription": "Pay securely with your PayPal account",
"@paymentPayPalDescription": {
"description": "Description for PayPal option"
},
"paymentBankTransfer": "Bank Transfer",
"@paymentBankTransfer": {
"description": "Bank transfer payment option"
},
"paymentBankTransferDescription": "Direct transfer from your bank account",
"@paymentBankTransferDescription": {
"description": "Description for bank transfer option"
},
"paymentCashOnDelivery": "Cash on Delivery",
"@paymentCashOnDelivery": {
"description": "Cash on delivery payment option"
},
"paymentCashOnDeliveryDescription": "Pay when you receive your order",
"@paymentCashOnDeliveryDescription": {
"description": "Description for cash on delivery option"
}
}
Shipping Options with Prices
Radio buttons with locale-formatted prices:
class ShippingOptionsRadio extends StatefulWidget {
final ValueChanged<ShippingOption> onOptionSelected;
const ShippingOptionsRadio({
super.key,
required this.onOptionSelected,
});
@override
State<ShippingOptionsRadio> createState() => _ShippingOptionsRadioState();
}
class _ShippingOptionsRadioState extends State<ShippingOptionsRadio> {
String? _selectedOption;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final currencyFormat = NumberFormat.currency(
locale: locale.toString(),
symbol: '\$',
);
final options = [
ShippingOption(
id: 'standard',
name: l10n.shippingStandard,
description: l10n.shippingStandardDays(5, 7),
price: 4.99,
),
ShippingOption(
id: 'express',
name: l10n.shippingExpress,
description: l10n.shippingExpressDays(2, 3),
price: 9.99,
),
ShippingOption(
id: 'overnight',
name: l10n.shippingOvernight,
description: l10n.shippingOvernightDescription,
price: 19.99,
),
ShippingOption(
id: 'free',
name: l10n.shippingFree,
description: l10n.shippingFreeDays(7, 14),
price: 0,
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.shippingOptionsTitle,
style: Theme.of(context).textTheme.titleLarge,
),
),
...options.map((option) {
return RadioListTile<String>(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(option.name),
Text(
option.price == 0
? l10n.freeLabel
: currencyFormat.format(option.price),
style: TextStyle(
fontWeight: FontWeight.bold,
color: option.price == 0
? Colors.green
: Theme.of(context).colorScheme.primary,
),
),
],
),
subtitle: Text(option.description),
value: option.id,
groupValue: _selectedOption,
onChanged: (value) {
setState(() {
_selectedOption = value;
});
widget.onOptionSelected(option);
},
);
}),
],
);
}
}
class ShippingOption {
final String id;
final String name;
final String description;
final double price;
const ShippingOption({
required this.id,
required this.name,
required this.description,
required this.price,
});
}
ARB entries with placeholders:
{
"shippingOptionsTitle": "Shipping Options",
"@shippingOptionsTitle": {
"description": "Title for shipping options section"
},
"shippingStandard": "Standard Shipping",
"@shippingStandard": {
"description": "Standard shipping option"
},
"shippingStandardDays": "{min}-{max} business days",
"@shippingStandardDays": {
"description": "Standard shipping delivery time",
"placeholders": {
"min": {"type": "int"},
"max": {"type": "int"}
}
},
"shippingExpress": "Express Shipping",
"@shippingExpress": {
"description": "Express shipping option"
},
"shippingExpressDays": "{min}-{max} business days",
"@shippingExpressDays": {
"description": "Express shipping delivery time",
"placeholders": {
"min": {"type": "int"},
"max": {"type": "int"}
}
},
"shippingOvernight": "Overnight Shipping",
"@shippingOvernight": {
"description": "Overnight shipping option"
},
"shippingOvernightDescription": "Next business day delivery",
"@shippingOvernightDescription": {
"description": "Overnight shipping description"
},
"shippingFree": "Free Shipping",
"@shippingFree": {
"description": "Free shipping option"
},
"shippingFreeDays": "{min}-{max} business days",
"@shippingFreeDays": {
"description": "Free shipping delivery time",
"placeholders": {
"min": {"type": "int"},
"max": {"type": "int"}
}
},
"freeLabel": "FREE",
"@freeLabel": {
"description": "Free price label"
}
}
Survey Question Radio Buttons
For questionnaires and surveys:
class SurveyRadioQuestion extends StatefulWidget {
final String question;
final List<String> options;
final ValueChanged<int> onAnswered;
const SurveyRadioQuestion({
super.key,
required this.question,
required this.options,
required this.onAnswered,
});
@override
State<SurveyRadioQuestion> createState() => _SurveyRadioQuestionState();
}
class _SurveyRadioQuestionState extends State<SurveyRadioQuestion> {
int? _selectedIndex;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.question,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
...widget.options.asMap().entries.map((entry) {
return RadioListTile<int>(
title: Text(entry.value),
value: entry.key,
groupValue: _selectedIndex,
onChanged: (value) {
setState(() {
_selectedIndex = value;
});
if (value != null) {
widget.onAnswered(value);
}
},
contentPadding: EdgeInsets.zero,
);
}),
if (_selectedIndex == null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.surveySelectOption,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
],
),
),
);
}
}
// Usage with localized questions
class SatisfactionSurvey extends StatelessWidget {
const SatisfactionSurvey({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SurveyRadioQuestion(
question: l10n.surveyQuestionSatisfaction,
options: [
l10n.surveyVeryDissatisfied,
l10n.surveyDissatisfied,
l10n.surveyNeutral,
l10n.surveySatisfied,
l10n.surveyVerySatisfied,
],
onAnswered: (index) {
// Handle answer
},
);
}
}
ARB entries:
{
"surveySelectOption": "Please select an option",
"@surveySelectOption": {
"description": "Prompt to select an option"
},
"surveyQuestionSatisfaction": "How satisfied are you with our service?",
"@surveyQuestionSatisfaction": {
"description": "Satisfaction survey question"
},
"surveyVeryDissatisfied": "Very Dissatisfied",
"@surveyVeryDissatisfied": {
"description": "Very dissatisfied option"
},
"surveyDissatisfied": "Dissatisfied",
"@surveyDissatisfied": {
"description": "Dissatisfied option"
},
"surveyNeutral": "Neutral",
"@surveyNeutral": {
"description": "Neutral option"
},
"surveySatisfied": "Satisfied",
"@surveySatisfied": {
"description": "Satisfied option"
},
"surveyVerySatisfied": "Very Satisfied",
"@surveyVerySatisfied": {
"description": "Very satisfied option"
}
}
Radio Buttons with Validation
Form radio groups that require selection:
class ValidatedRadioForm extends StatefulWidget {
const ValidatedRadioForm({super.key});
@override
State<ValidatedRadioForm> createState() => _ValidatedRadioFormState();
}
class _ValidatedRadioFormState extends State<ValidatedRadioForm> {
final _formKey = GlobalKey<FormState>();
String? _selectedSubscription;
String? _selectedFrequency;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FormField<String>(
initialValue: _selectedSubscription,
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.subscriptionRequired;
}
return null;
},
builder: (state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.subscriptionTypeLabel,
style: Theme.of(context).textTheme.titleMedium,
),
),
RadioListTile<String>(
title: Text(l10n.subscriptionBasic),
subtitle: Text(l10n.subscriptionBasicDescription),
value: 'basic',
groupValue: _selectedSubscription,
onChanged: (value) {
setState(() {
_selectedSubscription = value;
});
state.didChange(value);
},
),
RadioListTile<String>(
title: Text(l10n.subscriptionPro),
subtitle: Text(l10n.subscriptionProDescription),
value: 'pro',
groupValue: _selectedSubscription,
onChanged: (value) {
setState(() {
_selectedSubscription = value;
});
state.didChange(value);
},
),
RadioListTile<String>(
title: Text(l10n.subscriptionEnterprise),
subtitle: Text(l10n.subscriptionEnterpriseDescription),
value: 'enterprise',
groupValue: _selectedSubscription,
onChanged: (value) {
setState(() {
_selectedSubscription = value;
});
state.didChange(value);
},
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
state.errorText!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
],
);
},
),
const SizedBox(height: 24),
FormField<String>(
initialValue: _selectedFrequency,
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.billingFrequencyRequired;
}
return null;
},
builder: (state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.billingFrequencyLabel,
style: Theme.of(context).textTheme.titleMedium,
),
),
RadioListTile<String>(
title: Text(l10n.billingMonthly),
value: 'monthly',
groupValue: _selectedFrequency,
onChanged: (value) {
setState(() {
_selectedFrequency = value;
});
state.didChange(value);
},
),
RadioListTile<String>(
title: Text(l10n.billingYearly),
subtitle: Text(l10n.billingSavePercent(20)),
value: 'yearly',
groupValue: _selectedFrequency,
onChanged: (value) {
setState(() {
_selectedFrequency = value;
});
state.didChange(value);
},
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
state.errorText!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
],
);
},
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// Form is valid
}
},
child: Text(l10n.continueButton),
),
),
],
),
);
}
}
ARB entries:
{
"subscriptionTypeLabel": "Choose your plan",
"@subscriptionTypeLabel": {
"description": "Label for subscription type selection"
},
"subscriptionRequired": "Please select a subscription plan",
"@subscriptionRequired": {
"description": "Error when no subscription selected"
},
"subscriptionBasic": "Basic",
"@subscriptionBasic": {
"description": "Basic subscription option"
},
"subscriptionBasicDescription": "Perfect for individuals",
"@subscriptionBasicDescription": {
"description": "Basic plan description"
},
"subscriptionPro": "Professional",
"@subscriptionPro": {
"description": "Pro subscription option"
},
"subscriptionProDescription": "Best for small teams",
"@subscriptionProDescription": {
"description": "Pro plan description"
},
"subscriptionEnterprise": "Enterprise",
"@subscriptionEnterprise": {
"description": "Enterprise subscription option"
},
"subscriptionEnterpriseDescription": "For large organizations",
"@subscriptionEnterpriseDescription": {
"description": "Enterprise plan description"
},
"billingFrequencyLabel": "Billing frequency",
"@billingFrequencyLabel": {
"description": "Label for billing frequency selection"
},
"billingFrequencyRequired": "Please select a billing frequency",
"@billingFrequencyRequired": {
"description": "Error when no frequency selected"
},
"billingMonthly": "Monthly",
"@billingMonthly": {
"description": "Monthly billing option"
},
"billingYearly": "Yearly",
"@billingYearly": {
"description": "Yearly billing option"
},
"billingSavePercent": "Save {percent}%",
"@billingSavePercent": {
"description": "Savings percentage for yearly billing",
"placeholders": {
"percent": {"type": "int"}
}
},
"continueButton": "Continue",
"@continueButton": {
"description": "Continue button text"
}
}
Radio Button Accessibility
Proper accessibility for radio groups:
class AccessibleRadioGroup extends StatefulWidget {
final String groupLabel;
final List<AccessibleRadioOption> options;
final ValueChanged<String> onChanged;
const AccessibleRadioGroup({
super.key,
required this.groupLabel,
required this.options,
required this.onChanged,
});
@override
State<AccessibleRadioGroup> createState() => _AccessibleRadioGroupState();
}
class _AccessibleRadioGroupState extends State<AccessibleRadioGroup> {
String? _selectedValue;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: widget.groupLabel,
hint: l10n.radioGroupHint,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
widget.groupLabel,
style: Theme.of(context).textTheme.titleMedium,
),
),
...widget.options.map((option) {
return Semantics(
label: option.semanticLabel ?? option.label,
selected: option.value == _selectedValue,
inMutuallyExclusiveGroup: true,
child: RadioListTile<String>(
title: Text(option.label),
subtitle: option.description != null
? Text(option.description!)
: null,
value: option.value,
groupValue: _selectedValue,
onChanged: (value) {
setState(() {
_selectedValue = value;
});
if (value != null) {
widget.onChanged(value);
}
},
),
);
}),
],
),
);
}
}
class AccessibleRadioOption {
final String value;
final String label;
final String? description;
final String? semanticLabel;
const AccessibleRadioOption({
required this.value,
required this.label,
this.description,
this.semanticLabel,
});
}
ARB entry:
{
"radioGroupHint": "Select one option from the group",
"@radioGroupHint": {
"description": "Accessibility hint for radio group"
}
}
RTL Support for Radio Buttons
Handle right-to-left languages:
class RTLAwareRadioGroup extends StatefulWidget {
final String title;
final List<RadioOption> options;
final ValueChanged<String> onChanged;
const RTLAwareRadioGroup({
super.key,
required this.title,
required this.options,
required this.onChanged,
});
@override
State<RTLAwareRadioGroup> createState() => _RTLAwareRadioGroupState();
}
class _RTLAwareRadioGroupState extends State<RTLAwareRadioGroup> {
String? _selectedValue;
@override
Widget build(BuildContext context) {
final isRTL = Directionality.of(context) == TextDirection.rtl;
return Column(
crossAxisAlignment:
isRTL ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium,
),
),
...widget.options.map((option) {
return RadioListTile<String>(
title: Text(option.label),
subtitle: option.description != null
? Text(option.description!)
: null,
value: option.value,
groupValue: _selectedValue,
onChanged: (value) {
setState(() {
_selectedValue = value;
});
if (value != null) {
widget.onChanged(value);
}
},
controlAffinity: isRTL
? ListTileControlAffinity.trailing
: ListTileControlAffinity.leading,
);
}),
],
);
}
}
class RadioOption {
final String value;
final String label;
final String? description;
const RadioOption({
required this.value,
required this.label,
this.description,
});
}
Segmented Radio Buttons
For horizontal radio button layouts:
class SegmentedRadioButtons extends StatefulWidget {
final List<SegmentOption> options;
final ValueChanged<String> onChanged;
const SegmentedRadioButtons({
super.key,
required this.options,
required this.onChanged,
});
@override
State<SegmentedRadioButtons> createState() => _SegmentedRadioButtonsState();
}
class _SegmentedRadioButtonsState extends State<SegmentedRadioButtons> {
String? _selectedValue;
@override
Widget build(BuildContext context) {
return SegmentedButton<String>(
segments: widget.options.map((option) {
return ButtonSegment<String>(
value: option.value,
label: Text(option.label),
icon: option.icon != null ? Icon(option.icon) : null,
);
}).toList(),
selected: _selectedValue != null ? {_selectedValue!} : {},
onSelectionChanged: (selection) {
if (selection.isNotEmpty) {
setState(() {
_selectedValue = selection.first;
});
widget.onChanged(selection.first);
}
},
);
}
}
class SegmentOption {
final String value;
final String label;
final IconData? icon;
const SegmentOption({
required this.value,
required this.label,
this.icon,
});
}
// Usage
class ViewModeSelector extends StatelessWidget {
final ValueChanged<String> onModeChanged;
const ViewModeSelector({
super.key,
required this.onModeChanged,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SegmentedRadioButtons(
options: [
SegmentOption(
value: 'grid',
label: l10n.viewModeGrid,
icon: Icons.grid_view,
),
SegmentOption(
value: 'list',
label: l10n.viewModeList,
icon: Icons.list,
),
SegmentOption(
value: 'compact',
label: l10n.viewModeCompact,
icon: Icons.view_agenda,
),
],
onChanged: onModeChanged,
);
}
}
ARB entries:
{
"viewModeGrid": "Grid",
"@viewModeGrid": {
"description": "Grid view mode option"
},
"viewModeList": "List",
"@viewModeList": {
"description": "List view mode option"
},
"viewModeCompact": "Compact",
"@viewModeCompact": {
"description": "Compact view mode option"
}
}
Best Practices
- Group related options: Use clear section headers for radio groups
- Localize all labels: Include titles, descriptions, and error messages
- Consider RTL layouts: Test radio button alignment in RTL languages
- Use semantics: Provide accessibility labels and group hints
- Handle validation: Show localized errors for required selections
- Order options logically: Consider cultural conventions for option ordering
Testing Radio Button Localization
testWidgets('Radio buttons show localized labels', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('es'),
home: const Scaffold(
body: LocalizedRadioGroup(),
),
),
);
expect(find.text('Masculino'), findsOneWidget);
expect(find.text('Femenino'), findsOneWidget);
});
testWidgets('Radio validation shows localized error', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('de'),
home: const Scaffold(
body: ValidatedRadioForm(),
),
),
);
await tester.tap(find.text('Weiter'));
await tester.pump();
expect(find.text('Bitte wählen Sie einen Plan'), findsOneWidget);
});
Conclusion
Radio button localization in Flutter requires attention to labels, descriptions, validation messages, and layout direction. By following these patterns, you ensure your app provides clear, single-selection experiences for users regardless of their language. Remember to test with different locales and provide meaningful context for translators in your ARB files.