Random messages in Dart intl

As people, we are always craving novelty, things that we are familiar with makes us feel cozy but also do not attract as as much they were right on the beginning. This why your app should constantly engage the user. How to achieve this? You can for example have multiple messages for same operations.

In HabitChallenge there are two features that have multiple localized messages:

  • local notifications,
  • streak detection.

Each notification will have different message in form of a question, asking if you already did your habit.

For streak detection it is a bit more complex. Each streak message depends on the streak length and has not only random message, but also header and text that you can share with your friends.

Nevertheless both require a way of providing pseudo random message in one of the supported languages. Plus some of the languages have more options than others (yes, Polish users are less likely to see the same message in row).

In react-native times, this was easy! Since i18n-js relays on JSON configuration file, I was able to get extract translated raw list, then it was easy to check its size and finally generate random number within given range.

It is not as easy in Flutter. Mainly because initially I decided to go with the standard localization library intl, and learned half way through that I cannot use the same approach as I had in react-native with it.

You may ask why? First of all intl does not use JSON to store translations, but specialized ARB files. Main advantage of ARB is that it is supported by software that is used by professional translators.

The downside, is that only data structure that it supports is a map and you can’t access it directly. This makes things a bit harder… but there is more!

intl, does not use ARB files directly, they are only used as a transportation layer. With intl you can extract all messages from your app into ARB file, give this file to translation team (if you are lucky to have one). Then get back one ARB file per language and import them back to your app as dart source files.

Yes, there is some black voodoo magic with code generation in between. When you get ARB files, you need to run them through intl command line tools, to generate dart file with translation.

With code generation there are also some limitations. You can use placeholders, but you can’t call any methods on them. Instead of passing an object and extracting its properties, you need to pass each property separately as a parameter 😐

Enough talking, lets jump into the code. Or at least lets start talking code 😉

As I mentioned before, intl have support for maps… not exactly map data structure, but at least we can think about it as a map. I am talking here about Intl.select() where you can select one option base on provided key. Lets try to use it:

String _notificationMessages(int key, String habitName) => Intl.select(
      key,
      {
        key: 'It is high time to: $habitName',
        [...]
      },
      locale: localeName,
      name: '_notificationMessages',
      args: [key, habitName],
      desc: 'message shown in the notification area',
    );

If we now run ARB generator it will finish without any complains. But on a closer of ARB file there is only one translation assigned to a ‘key’. Simply because ARB generator didn’t get that in this context ‘key‘ is a variable, crap… We can easily fix this, just replace key: with ‘$key’: that should do!

No it do not 🙁 selectors must match regex [a-zA-Z][a-zA-Z0-9_-]*. This means selector must start from a letter and cannot have $ sign. This means we cannot use string interpolation here… crap… But there is another workaround for this!

Instead of doing interpolation in Intl.select() parameters, we can do it one level above:

String randomMessage(int key, String habitName) => 
    _notificationMessages('m_$key', habitName);

String _notificationMessages(int key, String habitName) => Intl.select(
      key,
      {
        'm_1': 'It is high time to: $habitName',
        [...]
      },
      locale: localeName,
      name: '_notificationMessages',
      args: [key, habitName],
      desc: 'message shown in the notification area',
    );

Now intl will parse our code without any complaints and generate valid ARB and dart files! Yeah! Now we can get translation based on a number!

Side note: I have chosen ‘m_$key’ format on purpose. Internally ARB will keep maps in a single long string where each option is formatted like so:
m_1{First message}m_2{Second message}m_3{Third message}. Since I personally edit all of the translation files manually in a plain text, it is easier to eyeball separators like ‘m_1’. You can choose different approach.

Lets now make these translations random:

String randomMessage(String habitName) {
     final index = Random().nextInt() + 1;
     final msg = _notificationMessages('m_$index', habitName);
     if (msg.isEmpty) {
       return _notificationMessages('m_1', habitName);
     }
     return msg;
   }

String _notificationMessages(String key, String habitName) => Intl.select(
      key,
      {
        'm_1': 'It is high time to: $habitName',
        [...]
      },
      locale: localeName,
      name: '_notificationMessages',
      args: [key, habitName],
      desc: 'message shown in the notification area',
    );

That was easy! We simply take another random number, add one, get translation from int, if it is empty fallback to ‘m_1’ as our key otherwise, return value that we got from intl.

Now, the last part. We need to set upper boundary for random generator. If we do not do that, then most of time we will get translation that is set under ‘m_1’ key.

There are many ways how to solve this. One is to store this in code in some form of an object, map or constant. I have decided to keep it together with translation and store it in file. This mean, we have one more Intl.message() that will return us a number. Here is complete code:

Lets now make these translations random:

String randomMessage(String habitName) {
     final index = Random().nextInt(_notificationMessageCount) + 1;
     final msg = _notificationMessages('m_$index', habitName);
     if (msg.isEmpty) {
       return _notificationMessages('m_1', habitName);
     }
     return msg;
   }

int get _notificationMessageCount => int.tryParse(Intl.message('18',
       locale: localeName,
       name: '_notificationMessageCount',
       desc: 'non-translable, number of notification messages'));

String _notificationMessages(String key, String habitName) => Intl.select(
      key,
      {
        'm_1': 'It is high time to: $habitName',
        [...]
      },
      locale: localeName,
      name: '_notificationMessages',
      args: [key, habitName],
      desc: 'message shown in the notification area',
    );

That is it, when ever we call randomMessage() we will get different message from the list that is defined for given language. Simple and easy… after you connect all the dots initially 😉

Hope that was helpful!