Skip to content

ADR 0006 Interface Text Is Tokens

github-actions[bot] edited this page Oct 22, 2026 · 1 revision

ADR-0006: Every interface string is a token, from the first screen

Source of truth: this wiki page.

  • Status: Accepted
  • Date: 2026-09-21

Context

Fluenough is a language-agnostic drilling app whose earliest target languages are Bengali, Hindi, Gujarati, Telugu and Urdu. A learner of Hindi is a Hindi speaker. The overwhelming likelihood is that they want the app in a language they already read, and for four of those five the interface language and the reading direction are both wrong by default.

The UI does not exist yet. Every screen in this repository is unwritten: #7 through #15 are the ones that will write them. That is the entire reason this decision is being taken now rather than later — retrofitting localisation is not a refactor of the strings, it is a re-layout of every screen that was built assuming English word lengths and left-to-right flow, and it is reliably one of the most expensive things a project can defer.

The specific trap here is #33, right-to-left layout, which is already on the roadmap for Urdu. An interface whose strings are extracted but whose widgets hardcode EdgeInsets.only(left: …) is no more localisable than one with the strings inline.

Decision

No user-visible string is written as a literal in a widget. Every label, title, button, hint, error, empty state, and every piece of prose in the interface is referenced by a token and resolved at runtime.

The mechanism is Flutter's own: ARB files under lib/l10n/, an l10n.yaml, and the AppLocalizations class generated by gen_l10n. This adds flutter_localizations (an SDK package) and intl.

English is the base locale and ships built in, as app_en.arb. It is a translation like any other, not a fallback hardcoded into the widgets.

What counts

Is a token Is not
Labels, titles, buttons, hints, tooltips Deck content — target, native, reading, notes
Error and empty-state text shown to a user Log messages, exception text, assertions
Semantic labels for screen readers Card ids, deck ids, mode names, BCP-47 tags
Anything a user reads that is not deck content Debug-only output

Deck content is already data and stays data. The distinction is the one the architecture already draws: a deck is content the app displays, the interface is the app.

The half people skip

Extraction alone does not make an interface localisable. Also required, from the first screen:

  • Directional insets, never sided ones. EdgeInsetsDirectional, AlignmentDirectional, TextAlign.start. Never left/right.
  • No string concatenation to build a sentence. Use placeholders, so the translator controls word order.
  • Plurals through ICU plural, not an if (n == 1).
  • No layout that assumes English string length. German and Telugu are longer; a fixed-width button will clip.

Consequences

The cost is two dependencies and a step in the loop: adding a string means adding it to app_en.arb rather than typing it into the widget. That is the whole of the ongoing cost, and it is paid per string, in seconds.

What it buys is that the app can be translated by someone who does not write Dart, which for this project is the relevant population — a deck contributor for Telugu is exactly the person who can translate the interface into Telugu, and under this decision they never have to open a .dart file to do it.

It also makes #33 tractable. RTL support becomes a matter of Directionality and a pass over the widgets, rather than a rewrite.

It does not, on its own, ship a second language. No translation beyond English is committed here. The point is that adding one later is a file, not a project.

Alternatives considered

Hardcode English now, extract later. Rejected. This is the default path and the reason so many apps are English-only: the extraction is easy and the re-layout that follows it is not, so it never happens. The cost of deciding this now is close to zero because no screen exists; in six months it is a sprint.

A hand-rolled Map<String, String> per locale. No dependency, which fits the project's instinct. Rejected because it means reimplementing plural rules, locale resolution and fallback, none of which is interesting work, and because no translation tool reads it. ARB is a published format with an ecosystem, and gen_l10n ships in the SDK we already require.

intl alone, without gen_l10n. Rejected: it gives up compile-time checking of token names, which is the property that makes a missing string a build failure rather than a blank label on a device.

See also: Contributing · ADR-Index · Home

Clone this wiki locally