Flutter internationalization with .arb files

blog post cover: Flutter internationalization with .arb files

Internationalization is obviously a big deal. We want our apps to be understandable and accessible for everyone, not only a single group of people. How exactly is it done? Let’s go over the details on why you need to introduce an additional technology to your project and then see how to localize your app in practice.

Flutter internationalization: Rationale

If you were to decide that you don’t need anything fancy and prefer to implement everything by yourself you’d probably do something like following:

class AppLocalizations {
  AppLocalizations(this.strings);

  final Map strings;

  static final en = {
    'counterTitle': 'You have pushed the button this many times:',
  };

  String get counterTitle => strings["counterTitle"]!;
}

As for actually using this, here’s an example:

AppLocalizations strings() {
  // you'd probably want to cache chosen translations
  // somewhere, omitted for brevity
  switch (window.locale.languageCode) {
    case "en":
    default:
      return AppLocalizations(AppLocalizations.en);
  }
}
class CounterHeader extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text(
      strings().counterTitle,
    );
  }
}

While it may be fine when you’ve just started the project, I’m pretty sure you’ll encounter problems soon, for example translating strings that contain parameters or information about gender/plurality. Implementing necessary logic to handle these cases will surely annoy the heck out of you when it happens more than a couple of times.

enum Gender { male, female, other }

class AppLocalizations {
  AppLocalizations(this.strings);

  final Map strings;

  static final en = {
    'counterTitleMale': 'He has pushed the button this many times:',
    'counterTitleFemale': 'She has pushed the button this many times:',
    'counterTitleOther': 'They have pushed the button this many times:',
  };

  String counterTitle(Gender gender) {
    switch (gender) {
      case Gender.male:
        return strings['counterTitleMale']!;
      case Gender.female:
        return strings['counterTitleFemale']!;
      case Gender.other:
        return strings['counterTitleOther']!;
    }
  }
}

Now that doesn’t look to good. The level of complexity increases exponentially each time you add a parameter to the string. Such solution is very likely to have bugs as you have to manually handle each case.

Flutter internationalization: Project setup

Single module

If you only have a single module in your project, then you probably don’t need to go beyond what’s in official internationalization docs

Warning: this will only work if you run the app from the same directory as your l10n.yaml config (in other words, if you don’t have independent modules with separate localization setups). For a solution, read the section below.

It’s because when you execute flutter run it looks for l10n.yaml only in the current directory.

Multiple modules

Flutter doesn’t really support this requirement, so a workaround will be necessary. Basically you need to set generate target to the folder with your sources and trigger the build manually. You’ll have to commit generated sources, unless in your CI before flutter build you trigger the generation as well. The rest (enabling new translations and using them) stays the same.

Let’s say you have the following project structure:

├── apps
│   └── some_fun_application
│       │── lib
│       └── pubspec.yaml
└── modules
    ├── feature_one
    │   │── lib
    │   │── l10n.yaml
    │   └── pubspec.yaml
    ├── feature_two
    │   │── lib
    │   │── l10n.yaml
    │   └── pubspec.yaml
    └── core
        │── lib
        └── pubspec.yaml

Firstly, make sure all your feature modules contain the following (you don’t need generate set to true in the rest of the configs):

modules/feature_*/pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: ^0.17.0     # Check the latest version on https://pub.dev/packages/intl

flutter:
  generate: true

As for localization configs, tweak the output directory and file/class:

l10n.yaml

arb-dir: lib/l10n
template-arb-file: module_en.arb

output-dir: lib/src
synthetic-package: false

output-localization-file: app_localizations.g.dart
output-class: AppLocalizations

Now, after adding .arb files just as in a single module case, run the following command (inside directories with localization configs), and you should see the following output:

$ flutter gen-l10n
Because l10n.yaml exists, the options defined there will be used instead.
To use the command line arguments, delete the l10n.yaml file in the Flutter project.

Running this command manually in every feature module each time you clone the project is unreasonable. I strongly advise using the following bash script or an equivalent:

generate_localizations.sh

generate_localizations() {
  for DIR in $(ls $1); do
    path=$1/$DIR

    (
      cd "$path" || exit
      cat l10n.yaml &>/dev/null && echo "Found localizations config in $path" && flutter gen-l10n
    )
  done
}

generate_localizations modules

Flutter internationalization: .arb files

ARB stands for Application Resource Bundle and you can think of it as a buffed JSON. It’s not Flutter/Dart specific, nonetheless it has fantastic support for Flutter environment that allows for seamless integration with any project.

{
  "formLabelDone": "Done",
  "formLabelMandatory": "Mandatory"
}

Description

Nothing unusual so far, it looks just like any other JSON. However, it starts to shine when you want a third party to contribute a translation as it allows for adding metadata to each and every field. This provides extra context for the translator which directly turns into superb translation quality.

{
  "formLabelDone": "Done",
  "@formLabelDone": {
    "description": "Label being displayed below every form field that is filled and valid"
  }
}

The description is also added as a documentation in generated code:

abstract class AppLocalizations {
  /// Label being displayed below every form field that is filled and valid
  ///
  /// In en, this message translates to:
  /// **'Done'**
  String get formLabelDone;
}

Parameters

You can also add Parameters along with type information:

{
  "contactDetailsPopupEmailCopiedMessage": "Copied {email} to clipboard",
  "@contactDetailsPopupEmailCopiedMessage": {
    "description": "Message being displayed in a snackbar upon long-clicking email in contact details popup",
    "placeholders": {
      "email": {
        "type": "String",
        "example": "[email protected]"
      }
    }
  }
}
abstract class AppLocalizations {
  /// Message being displayed in a snackbar upon long-clicking email in contact details popup
  ///
  /// In en, this message translates to:
  /// **'Copied {email} to clipboard'**
  String contactDetailsPopupEmailCopiedMessage(String email);
}

Numbers, currencies

These are being parsed with the help of intl package. For a complete list of possible formats and their parameters visit intl’s NumberFormat documentation.

{
  "productCostInfo": "Cost: {cost}",
  "@productCostInfo": {
    "placeholders": {
      "cost": {
        "type": "double",
        "format": "currency",
        "optionalParameters": {
          "symbol": "€",
          "decimalDigits": 3
        }
      }
    }
  }
}

Here’s the generated code for the default locale:

@override
String productCostInfo(double cost) {
  final intl.NumberFormat costNumberFormat = intl.NumberFormat.currency(
      locale: localeName,
      symbol: "€",
      decimalDigits: 3
  );
  final String costString = costNumberFormat.format(cost);

  return 'Cost: ${costString}';
}

Dates

They work pretty much the same as numbers and currencies with a single exception: the class that’s being used to format the input. In this case, NumberFormat is being utilized.

{
  "postCreatedInfo": "Created: {date}",
  "@postCreatedInfo": {
    "placeholders": {
      "date": {
        "type": "DateTime",
        "format": "MMMd"
      }
    }
  }
}
@override
String postCreatedInfo(DateTime date) {
  final intl.DateFormat dateDateFormat = intl.DateFormat.MMMd(localeName);
  final String dateString = dateDateFormat.format(date);

  return 'Created: ${dateString}';
}

There’s one catch though. You can’t specify any format you want due to the way it’s implemented right now. The only ones you can use are named constructors in DateFormat class.

Plurals

Just as before, there’s a piece of code responsible for parsing the input of localization. This time it’s a static function, and it’s called plural. This case has its own limitation – anything outside curly braces is being ignored. This means you can’t do something like Please contact {count,plural, =1{an organiser} other{organisers}}. You have to add two separate entries for that.

{
  "roomUnavailableContactOrganiserDialogCount": "{count,plural, =1{an organiser} other{organisers}}",
  "@roomUnavailableContactOrganiserDialogCount": {
    "placeholders": {
      "count": {}
    }
  }
}
@override
String roomUnavailableContactOrganiserDialogCount(int count) {
  return intl.Intl.pluralLogic(
    count,
    locale: localeName,
    one: 'an organiser',
    other: 'organisers',
  );
}

Summary

Examples above should handle most of your requirements. As you can see, localization with .arb is intuitive and satisfying. If you want to go more in-depth, there’s an amazing document on Google Docs. For exploring all the features of ARB format that aren’t necessarily related with Flutter, visit ARB specification.