Flutter Semantic Localization: Context-Aware Translations for Better UX
Not all translations are created equal. The word "Open" means different things for a door, a file, or business hours. Semantic localization ensures your translations carry the right meaning in every context. This guide shows you how to implement context-aware translations in Flutter.
What Is Semantic Localization?
Semantic localization goes beyond word-for-word translation:
| Approach | Example | Result |
|---|---|---|
| Literal | "Open" → "Abrir" (Spanish) | Same word for all contexts |
| Semantic | "Open file" → "Abrir archivo" | Context-appropriate translation |
| "Open hours" → "Horario de apertura" | Different word, same meaning |
Why Context Matters
The "Bank" Problem
// ❌ Ambiguous - which meaning?
"bank": "Bank" // Financial institution? River bank?
// ✅ Semantic - clear context
"bankFinancial": "Bank",
"@bankFinancial": {
"description": "Financial institution for money"
},
"bankRiver": "Bank",
"@bankRiver": {
"description": "The side of a river"
}
Real-World Examples
// app_en.arb - English source
{
"save": "Save",
"@save": {
"description": "Generic save action"
},
"saveDocument": "Save",
"@saveDocument": {
"description": "Save a document to storage",
"context": "document_editor"
},
"saveLife": "Save",
"@saveLife": {
"description": "Rescue or preserve life",
"context": "emergency_app"
},
"saveMoney": "Save",
"@saveMoney": {
"description": "Put money aside for later",
"context": "finance_app"
}
}
// app_es.arb - Spanish translations
{
"save": "Guardar",
"saveDocument": "Guardar",
"saveLife": "Salvar",
"saveMoney": "Ahorrar"
}
Implementing Semantic Keys
Naming Convention
/// Semantic key naming patterns:
///
/// [action][Object][Context]
/// Examples:
/// - openFile
/// - openDoor
/// - openBusinessHours
///
/// [noun][Qualifier]
/// Examples:
/// - bankFinancial
/// - bankRiver
///
/// [context]_[key]
/// Examples:
/// - settings_save
/// - document_save
/// - game_save
Structured ARB Files
// app_en.arb
{
"@@locale": "en",
"_ACTIONS_FILE": "=== File Actions ===",
"fileOpen": "Open",
"@fileOpen": {
"description": "Open a file from storage"
},
"fileSave": "Save",
"@fileSave": {
"description": "Save file to storage"
},
"fileClose": "Close",
"@fileClose": {
"description": "Close the current file"
},
"_ACTIONS_DOOR": "=== Physical Actions ===",
"doorOpen": "Open",
"@doorOpen": {
"description": "Open a physical door"
},
"doorClose": "Close",
"@doorClose": {
"description": "Close a physical door"
},
"_BUSINESS": "=== Business Context ===",
"businessOpen": "Open",
"@businessOpen": {
"description": "Business is open for customers"
},
"businessClosed": "Closed",
"@businessClosed": {
"description": "Business is closed"
},
"_VERBS_GENERAL": "=== General Verbs ===",
"actionRun": "Run",
"@actionRun": {
"description": "Execute or start something"
},
"exerciseRun": "Run",
"@exerciseRun": {
"description": "Physical running exercise"
}
}
German Example (Shows Why Context Matters)
// app_de.arb
{
"@@locale": "de",
"fileOpen": "Öffnen",
"fileSave": "Speichern",
"fileClose": "Schließen",
"doorOpen": "Aufmachen",
"doorClose": "Zumachen",
"businessOpen": "Geöffnet",
"businessClosed": "Geschlossen",
"actionRun": "Ausführen",
"exerciseRun": "Laufen"
}
Context Provider Pattern
Building a Context-Aware System
/// Provides semantic context for translations
class SemanticContext {
static final _instance = SemanticContext._();
factory SemanticContext() => _instance;
SemanticContext._();
String _currentContext = 'general';
void setContext(String context) {
_currentContext = context;
}
String get current => _currentContext;
/// Get the contextual key for a base concept
String getKey(String baseConcept) {
final contextualKey = '${_currentContext}_$baseConcept';
return contextualKey;
}
}
/// Widget that sets semantic context for its subtree
class SemanticScope extends StatelessWidget {
final String context;
final Widget child;
const SemanticScope({
required this.context,
required this.child,
});
@override
Widget build(BuildContext context) {
return _SemanticScopeInherited(
context: this.context,
child: child,
);
}
static String of(BuildContext context) {
final inherited = context
.dependOnInheritedWidgetOfExactType<_SemanticScopeInherited>();
return inherited?.context ?? 'general';
}
}
class _SemanticScopeInherited extends InheritedWidget {
final String context;
const _SemanticScopeInherited({
required this.context,
required super.child,
});
@override
bool updateShouldNotify(_SemanticScopeInherited oldWidget) {
return context != oldWidget.context;
}
}
Using Semantic Scopes
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Column(
children: [
// File editor context
SemanticScope(
context: 'file',
child: FileEditorToolbar(),
),
// Smart home context
SemanticScope(
context: 'door',
child: SmartHomeControls(),
),
// Business hours context
SemanticScope(
context: 'business',
child: StoreInfoCard(),
),
],
),
),
);
}
}
class FileEditorToolbar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
// These will use file-context translations
TextButton(
onPressed: () {},
child: Text(l10n.fileOpen), // "Open" for files
),
TextButton(
onPressed: () {},
child: Text(l10n.fileSave), // "Save" for files
),
],
);
}
}
class SmartHomeControls extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
// These will use door-context translations
ElevatedButton(
onPressed: () {},
child: Text(l10n.doorOpen), // "Open" for doors
),
ElevatedButton(
onPressed: () {},
child: Text(l10n.doorClose), // "Close" for doors
),
],
);
}
}
Dynamic Context Resolution
Fallback Chain
class ContextualTranslator {
final Map<String, String> _translations;
final String _locale;
ContextualTranslator(this._translations, this._locale);
/// Get translation with context fallback
String translate(String key, {String? context}) {
// Try context-specific key first
if (context != null) {
final contextKey = '${context}_$key';
if (_translations.containsKey(contextKey)) {
return _translations[contextKey]!;
}
}
// Try base key
if (_translations.containsKey(key)) {
return _translations[key]!;
}
// Return key as fallback
return key;
}
/// Get translation with multiple context attempts
String translateWithFallbacks(
String key,
List<String> contexts,
) {
for (final context in contexts) {
final contextKey = '${context}_$key';
if (_translations.containsKey(contextKey)) {
return _translations[contextKey]!;
}
}
return _translations[key] ?? key;
}
}
Usage Example
class ProductPage extends StatelessWidget {
final Product product;
@override
Widget build(BuildContext context) {
final translator = ContextualTranslator.of(context);
return Column(
children: [
Text(translator.translate(
'save',
context: product.isDigital ? 'file' : 'shopping',
)),
Text(translator.translateWithFallbacks(
'share',
['social', 'file', 'general'],
)),
],
);
}
}
Grammatical Gender and Context
Gender-Aware Translations
// app_en.arb
{
"newItem": "New {itemType}",
"@newItem": {
"placeholders": {
"itemType": {"type": "String"}
}
}
}
// app_de.arb - German has grammatical gender
{
"newItem_masculine": "Neuer {itemType}",
"newItem_feminine": "Neue {itemType}",
"newItem_neuter": "Neues {itemType}",
"itemFile": "Datei",
"itemFile_gender": "feminine",
"itemFolder": "Ordner",
"itemFolder_gender": "masculine",
"itemDocument": "Dokument",
"itemDocument_gender": "neuter"
}
class GenderedTranslator {
final Map<String, String> _translations;
String translateWithGender(String key, String noun) {
// Get the gender of the noun
final genderKey = '${noun}_gender';
final gender = _translations[genderKey] ?? 'neuter';
// Get the gendered translation
final genderedKey = '${key}_$gender';
final template = _translations[genderedKey] ?? _translations[key] ?? key;
// Get the noun translation
final nounTranslation = _translations[noun] ?? noun;
return template.replaceAll('{itemType}', nounTranslation);
}
}
// Usage
final text = translator.translateWithGender('newItem', 'itemFile');
// German: "Neue Datei" (feminine)
Formality Levels
Formal vs Informal Contexts
// app_en.arb
{
"greeting": "Hello",
"greetingFormal": "Good day",
"greetingInformal": "Hey"
}
// app_de.arb
{
"greeting": "Hallo",
"greetingFormal": "Guten Tag",
"greetingInformal": "Hey",
"youHave": "You have",
"youHaveFormal": "Sie haben",
"youHaveInformal": "Du hast"
}
// app_ja.arb - Japanese has many formality levels
{
"greeting": "こんにちは",
"greetingFormal": "お世話になっております",
"greetingInformal": "やあ",
"greetingHumble": "いつもお世話になっております"
}
Formality Provider
enum FormalityLevel {
informal,
neutral,
formal,
humble, // For Japanese keigo
}
class FormalityProvider extends InheritedWidget {
final FormalityLevel level;
const FormalityProvider({
required this.level,
required super.child,
});
static FormalityLevel of(BuildContext context) {
final provider = context
.dependOnInheritedWidgetOfExactType<FormalityProvider>();
return provider?.level ?? FormalityLevel.neutral;
}
@override
bool updateShouldNotify(FormalityProvider oldWidget) {
return level != oldWidget.level;
}
}
// Usage in app
class CustomerServiceChat extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FormalityProvider(
level: FormalityLevel.formal,
child: ChatScreen(),
);
}
}
class FriendChat extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FormalityProvider(
level: FormalityLevel.informal,
child: ChatScreen(),
);
}
}
Domain-Specific Vocabulary
Industry Terminology
// app_en.arb - Medical app
{
"_MEDICAL": "=== Medical Terms ===",
"medical_patient": "Patient",
"medical_prescription": "Prescription",
"medical_appointment": "Appointment",
"medical_history": "Medical History"
}
// app_en.arb - Legal app
{
"_LEGAL": "=== Legal Terms ===",
"legal_client": "Client",
"legal_case": "Case",
"legal_hearing": "Hearing",
"legal_brief": "Brief"
}
// app_en.arb - Gaming app
{
"_GAMING": "=== Gaming Terms ===",
"gaming_player": "Player",
"gaming_level": "Level",
"gaming_save": "Save Game",
"gaming_load": "Load Game"
}
Domain Selector
enum AppDomain {
medical,
legal,
gaming,
general,
}
class DomainLocalizations {
final AppDomain domain;
final AppLocalizations _base;
DomainLocalizations(this.domain, this._base);
String get patient {
switch (domain) {
case AppDomain.medical:
return _base.medical_patient;
case AppDomain.legal:
return _base.legal_client;
default:
return _base.user;
}
}
String get save {
switch (domain) {
case AppDomain.gaming:
return _base.gaming_save;
default:
return _base.save;
}
}
}
Testing Semantic Translations
Unit Tests
void main() {
group('Semantic translations', () {
late Map<String, String> enTranslations;
late Map<String, String> deTranslations;
setUp(() {
enTranslations = {
'fileOpen': 'Open',
'doorOpen': 'Open',
'businessOpen': 'Open',
};
deTranslations = {
'fileOpen': 'Öffnen',
'doorOpen': 'Aufmachen',
'businessOpen': 'Geöffnet',
};
});
test('same English word has different German translations', () {
expect(deTranslations['fileOpen'], 'Öffnen');
expect(deTranslations['doorOpen'], 'Aufmachen');
expect(deTranslations['businessOpen'], 'Geöffnet');
// All mean "Open" in English but different in German
expect(
deTranslations.values.toSet().length,
3, // All different
);
});
test('context fallback works correctly', () {
final translator = ContextualTranslator(enTranslations, 'en');
expect(
translator.translate('open', context: 'file'),
'Open',
);
expect(
translator.translate('open', context: 'unknown'),
'open', // Falls back to key
);
});
});
}
Linting for Missing Context
/// Analyze ARB files for semantic issues
class SemanticLinter {
final Map<String, dynamic> arbContent;
SemanticLinter(this.arbContent);
List<LintIssue> analyze() {
final issues = <LintIssue>[];
// Find potentially ambiguous keys
final ambiguousWords = ['open', 'close', 'save', 'run', 'bank', 'light'];
for (final key in arbContent.keys) {
if (key.startsWith('@') || key.startsWith('_')) continue;
for (final word in ambiguousWords) {
if (key.toLowerCase() == word) {
issues.add(LintIssue(
key: key,
message: 'Potentially ambiguous key "$key". '
'Consider adding context like "${word}File" or "${word}Door".',
severity: Severity.warning,
));
}
}
// Check for missing description
final metaKey = '@$key';
if (!arbContent.containsKey(metaKey)) {
issues.add(LintIssue(
key: key,
message: 'Missing metadata for "$key". '
'Add @$key with description for translator context.',
severity: Severity.info,
));
}
}
return issues;
}
}
Best Practices
1. Always Provide Context in Descriptions
{
"submit": "Submit",
"@submit": {
"description": "Submit a form with user input",
"context": "forms"
}
}
2. Use Semantic Key Names
// ❌ Bad
"btn1": "Open"
"str_open": "Open"
// ✅ Good
"fileOpen": "Open"
"doorOpen": "Open"
3. Group Related Translations
{
"_FILE_ACTIONS": "=== File Actions ===",
"fileNew": "New",
"fileOpen": "Open",
"fileSave": "Save",
"_NAVIGATION": "=== Navigation ===",
"navBack": "Back",
"navForward": "Forward",
"navHome": "Home"
}
4. Document Ambiguous Terms
{
"@bankFinancial": {
"description": "A financial institution (NOT a river bank)",
"context": "finance"
}
}
Conclusion
Semantic localization ensures your app speaks naturally in every language by:
- Providing context for ambiguous words
- Using descriptive keys that convey meaning
- Handling grammatical differences like gender
- Adapting formality for cultural norms
For managing context-aware translations across your app, check out FlutterLocalisation.
Related Articles:
- Flutter Context Localization Best Practices
- Flutter Gender and Select Messages
- Free ARB Editor - Add context to your translations
- ARB Diff Tool - Compare semantic translations