← All articles
8 min readInternationalization,Localization,Text Rendering,Flutter

Shipping Flowo in Seven Languages: Plurals, RTL and Fallbacks

By Mykhailo Boichuk · Chief Technology Officer

In short

Flowo ships in seven languages, two of them right-to-left, from 2,274 messages per catalog. The bugs that reached a screen were rarely missing translations: they sat in plural rules, unit abbreviations, text direction and language fallbacks. BBMM Technologies turned each one into a test that runs on every build.

The catalogs and the generation step

Flowo keeps one ARB catalog per language: English, Ukrainian, Arabic, German, Spanish, French and Hebrew. English is the template. At the time of writing it holds 2,274 messages, 36 of them ICU plurals and 4 of them ICU selects.

Adding a message takes five steps, and we run them in this order.

  1. 1.Add the message to the English catalog.
  2. 2.Translate it in the six other catalogs with the same ICU placeholders.
  3. 3.Run flutter gen-l10n with an untranslated-messages file as output. That file is an empty JSON object when nothing is missing.
  4. 4.Run dart format on the generated directory. The generator writes unformatted Dart, and our format check fails on it.
  5. 5.Run the localization tests.

The generated Dart classes are committed with the change and are never edited by hand.

What the completeness test enforces

One test file reads all seven catalogs from disk and compares them with the English template. A missing key is the easy case. The rules that follow it found more.

  • Every catalog has exactly the English keys, and no value is empty.
  • Every message uses the same placeholder names as its English version.
  • Every plural message has the plural categories its language needs.
  • Nineteen consent messages must differ from the English text in every other language, so a copied English string cannot pass as a translation.
  • Screen and widget code contains no Cyrillic string literal and no hardcoded English sentence in a text widget.
  • The generated registry exposes all seven locales, and Arabic and Hebrew resolve to right-to-left.

Plural categories are checked per language

A plural message copied from English compiles in every language and shows wrong text in some of them. English has two categories, one and other. Ukrainian has four. Hebrew needs one, two and other. Arabic needs all six: zero, one, two, few, many and other.

Our test holds that table and walks all 36 plural messages in all seven catalogs. An explicit branch such as =0, =1 or =2 counts for the category it pins. Any message that lacks a required category fails the build with its key and the missing names.

Key parity cannot catch this class of bug. An Arabic message with only the other branch has the right key and placeholders, and its value is not empty. It still prints the number 1 next to a plural noun. Ukrainian has the opposite trap: 21 takes the singular form, and a separate test pins 21 days to the same noun form as 1 day.

Units belong inside the message

A duration is one ICU message per language that receives hours and minutes together. We stopped gluing a number to a translated unit letter after seeing what that produced. An earlier French catalog abbreviated minutes with the word for months, and the Spanish one used the word for metre. The German one used capital letters that read as months. Every whole hour also read as one hour and zero minutes.

The current messages have a zero branch for minutes, so a whole hour prints the hour alone. Each language spaces and abbreviates its own way: 1h 30m in English, 1 h 30 min in French, 1 Std. 30 Min. in German, 1 год 30 хв in Ukrainian.

A test pins seven sample durations per language, from 0 to 180 minutes, and checks each through both code paths that screens use: the message itself and the shared duration formatter. A second French test checks register. It fails if a listed set of messages addresses the person as tu, because the app uses vous throughout.

One function decides the language

One function answers which language a person is working in, and every consumer calls it. The order is fixed.

  1. 1.An explicit choice made in the app.
  2. 2.The language stored in the profile.
  3. 3.The device language, when Flowo ships it.
  4. 4.English.

Dictation used to implement only part of this order, while the interface ran all of it. On a fresh install on a Ukrainian phone, that produced a Ukrainian screen listening for English speech.

The splash screen needed its own rule, because it is built before any stored preference can be read. Flutter's default resolution falls back to the first supported locale, and in an alphabetical list that is Arabic. A phone set to a language we do not ship met a right-to-left Arabic splash. Our resolver now takes the first device language that matches a shipped one and otherwise returns English. A test asserts that five unshipped language codes never resolve to Arabic.

The language picker shows each language in its own script and never translates the names. Its rows are built from the list of supported codes, so a new locale cannot ship without being selectable.

Right-to-left beyond mirrored rows

Flutter mirrors rows and directional padding for Arabic and Hebrew, and it does not mirror anything drawn on a canvas. Our quota progress bar is a custom painter whose fills grow from x = 0. For right-to-left it translates the canvas by its width and flips the x axis once, before drawing. That single transform is safe there because the painter draws no text, so nothing is reversed that should stay readable.

Index arithmetic needs the same care. The coach mark that points at a tab computes the tab's slot from its position in the bar, and in right-to-left it uses the count minus one minus the index.

Mixed-script sentences are the third case. Where a renderer takes paragraph direction from the first strong character, a Hebrew sentence that opens with a Latin brand name is laid out left-to-right. Our Hebrew permission strings and a few chat messages that open with Flowo or a quoted title begin with U+200F, the right-to-left mark. The Arabic permission strings open with a verb, so they need no mark.

The language sent to the cloud model

A request to the cloud model carries the app language, so the model does not guess it from the text. The assistant used to infer the language from the message, and a person with an English interface could get Ukrainian replies.

Task breakdown sends the locale with the task. Subtask titles are interface text, so they follow the app language even when the parent task was typed in another one. Sorting notes sends the English name of the language, such as Ukrainian, because the backend places it inside a prompt. One function maps codes to names, and the backend accepts a locale only when it matches the shape of a BCP 47 tag.

Key takeaways

  • Flowo has seven ARB catalogs with 2,274 messages each, and a test requires identical keys and placeholders across all of them.
  • A test requires every plural message to carry the categories its language needs, which is six for Arabic and four for Ukrainian.
  • Durations come from one ICU message per language that receives hours and minutes together, checked on seven sample values.
  • One function resolves the language in the order app choice, profile, device, English, and dictation uses the same answer as the interface.
  • The splash screen falls back to English because Flutter's default would pick the first supported locale, which is Arabic.
  • Custom painters in Flowo mirror the canvas themselves for Arabic and Hebrew.

Frequently asked questions

How do you test that every translation in a Flutter app is complete?
Flowo reads all seven ARB files in a unit test and compares each with the English template. The test requires identical keys and placeholder names, and no value may be empty. It also checks plural categories per language and requires consent text to differ from English, which catches strings that were copied instead of translated.
Why are English plural forms wrong in Arabic and Ukrainian?
English uses two plural categories, one and other. Arabic uses six and Ukrainian uses four, so a message with only the English branches selects the wrong noun form for many numbers. Flowo's test keeps the required categories for each of its seven languages and fails when a plural message lacks one.
What happens in Flutter when the device language is not supported by the app?
By default Flutter falls back to the first entry in the supported locales list. In an alphabetically sorted list that can be Arabic, which turns the whole interface right-to-left. Flowo supplies its own resolution callback that returns the first matching device language and otherwise English.
Does Flutter mirror custom painters for right-to-left languages?
No. Flutter mirrors layout widgets that use directional alignment, and a canvas keeps its left-to-right coordinates. In Flowo, a painter that fills a bar from the leading edge reads the text direction and flips the canvas horizontally once before drawing. This works for painters that draw shapes and no text.
Why does gen-l10n output fail a dart format check?
The Flutter localization generator writes Dart that the formatter would change. A CI step that runs dart format with a failing exit code on changes will reject it. Flowo runs dart format on the localization directory right after generation and commits the formatted files.

References

About the author

Mykhailo Boichuk

Chief Technology Officer

Mykhailo builds the systems behind the apps. He works on macOS and iOS performance, and on the sync problems that come with software that has to run offline.