← Back to Blog

Flutter Semantic Localization: Context-Aware Translations for Better UX

fluttersemanticcontextlocalizationgrammarformalityux

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: