← Back to Blog

Flutter ARB Files for Translators: Agency Handoff Guide

flutterarbtranslation workflowlocalizationicu pluralstranslators

To hand Flutter strings to an outside agency and get usable translations back, you need three things: a template ARB file that carries context (a description, a typed placeholder with an example, and the plural message written out in full), an automatic check on every file that comes back (missing keys, changed placeholders, missing plural forms), and a review state per string so you know what is safe to ship. The first two you can do today in code. The third is where email and spreadsheets stop working.

This post does the manual handoff properly first, then shows what changes when translators work in a dashboard instead of in your files.

What a translator needs that the string does not say

A translator opening app_en.arb sees "Open". Is it a verb on a button or a status on an invoice? In German that is "Öffnen" or "Offen". They cannot see your screen, so the context has to travel with the key.

In a Flutter ARB file the context lives in the @key entry of the template file. Three fields matter:

  • description: where the string appears and what it does.
  • placeholders with type and example: what gets inserted, so the translator can pick the right case and word order.
  • The full ICU plural message, so they know the sentence changes with a number.
{
  "@@locale": "en",
  "invoiceStatusOpen": "Open",
  "@invoiceStatusOpen": {
    "description": "Status badge on an invoice that has not been paid yet. Adjective, not the verb 'to open'."
  },
  "welcomeBack": "Welcome back, {firstName}!",
  "@welcomeBack": {
    "description": "Greeting at the top of the home screen after login.",
    "placeholders": {
      "firstName": {
        "type": "String",
        "example": "Anna"
      }
    }
  },
  "unpaidInvoices": "{count, plural, =0{No unpaid invoices} one{{count} unpaid invoice} other{{count} unpaid invoices}}",
  "@unpaidInvoices": {
    "description": "Summary line on the dashboard card. count is the number of unpaid invoices.",
    "placeholders": {
      "count": {
        "type": "int",
        "example": "3"
      }
    }
  }
}

Only the template file needs the @key metadata. The German, Polish and French files hold just the translated values. If ARB metadata is new to you, the Flutter ARB file guide and the placeholders guide cover every field.

  • lib/
  • l10n/
  • app_en.arbtemplate: values plus @key descriptions and placeholders
  • app_de.arbcomes back from the agency
  • app_pl.arbcomes back from the agency
  • app_fr.arbcomes back from the agency
  • tool/
  • check_arb.dartvalidates what came back
  • l10n.yaml

Plural forms: what each language must return

English has two plural forms. Polish has four, and a translator who copies the English structure will hand back a file that reads wrong for 2, 5 and 22 invoices. The categories come from Unicode CLDR, and Flutter's generated code picks the branch at runtime.

Language Forms the translator must write Example for "invoice"
English (en) one, other 1 invoice, 5 invoices
German (de) one, other 1 Rechnung, 5 Rechnungen
French (fr) one, other 0 facture, 1 facture, 5 factures
Polish (pl) one, few, many, other 1 faktura, 2 faktury, 5 faktur, 1,5 faktury

Two details catch agencies out. French uses one for 0 as well as 1. CLDR also defines a many form for French, but it only applies to exact millions, so most apps never need it. And every plural needs other, in every language, or flutter gen-l10n rejects the message.

A correct Polish value looks like this:

{
  "unpaidInvoices": "{count, plural, =0{Brak nieopłaconych faktur} one{{count} nieopłacona faktura} few{{count} nieopłacone faktury} many{{count} nieopłaconych faktur} other{{count} nieopłaconej faktury}}"
}

How translators break ICU syntax

The same mistakes come back from every agency, because ICU looks like text and most CAT tools treat it as text:

  • The placeholder name gets translated: {firstName} becomes {Vorname}.
  • A keyword gets translated: other{...} becomes inne{...}.
  • A closing brace is lost while editing.
  • The whole plural is flattened into one sentence.
  • A typographic quote or a trailing comma makes the file invalid JSON.

Send the agency one short rule with the files: translate only the text inside the innermost braces of a plural, and never change anything that sits directly after an opening brace. Then check the result with a script, because the rule will be broken anyway.

A Dart script that validates returned ARB files

Save this as tool/check_arb.dart. It has no dependencies. It compares app_de.arb, app_pl.arb and app_fr.arb with the English template and reports missing keys, removed or renamed placeholders, unbalanced braces and missing plural forms.

import 'dart:convert';
import 'dart:io';

const requiredPlurals = {
  'de': ['one', 'other'],
  'fr': ['one', 'other'],
  'pl': ['one', 'few', 'many', 'other'],
};

class Icu {
  final args = <String>{};
  final plurals = <String, Set<String>>{};
  bool balanced = true;
}

/// Walks an ICU message and collects argument names and plural branches.
Icu parse(String msg) {
  final out = Icu();
  // null = inside branch text, a name = inside that plural/select argument
  final stack = <String?>[];
  for (var i = 0; i < msg.length; i++) {
    final c = msg[i];
    if (c == '{') {
      if (stack.isNotEmpty && stack.last != null) {
        final keyword =
            msg.substring(0, i).trimRight().split(RegExp(r'[\s,}]')).last;
        out.plurals[stack.last]?.add(keyword);
        stack.add(null);
        continue;
      }
      final end = msg.indexOf(RegExp('[,}]'), i);
      if (end == -1) {
        out.balanced = false;
        return out;
      }
      final name = msg.substring(i + 1, end).trim();
      out.args.add(name);
      if (msg[end] == ',') {
        if (msg.substring(end + 1).trimLeft().startsWith('plural')) {
          out.plurals[name] = {};
        }
        stack.add(name);
      }
      i = end;
    } else if (c == '}') {
      if (stack.isEmpty) {
        out.balanced = false;
        return out;
      }
      stack.removeLast();
    }
  }
  if (stack.isNotEmpty) out.balanced = false;
  return out;
}

Map<String, dynamic>? load(File file) {
  try {
    return jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
  } on FormatException {
    return null;
  }
}

void main(List<String> argv) {
  final dir = argv.isEmpty ? 'lib/l10n' : argv.first;
  final template = load(File('$dir/app_en.arb'))!;
  final keys = template.keys.where((k) => !k.startsWith('@')).toList();
  var errors = 0;

  for (final locale in requiredPlurals.keys) {
    void fail(String key, String why) {
      errors++;
      print('$locale  $key: $why');
    }

    final file = File('$dir/app_$locale.arb');
    if (!file.existsSync()) {
      fail('*', 'file missing');
      continue;
    }
    final arb = load(file);
    if (arb == null) {
      fail('*', 'not valid JSON');
      continue;
    }

    for (final key in keys) {
      final value = arb[key];
      if (value is! String || value.trim().isEmpty) {
        fail(key, 'missing translation');
        continue;
      }
      final source = parse(template[key] as String);
      final target = parse(value);
      if (!target.balanced) {
        fail(key, 'unbalanced braces');
        continue;
      }
      final removed = source.args.difference(target.args);
      final unknown = target.args.difference(source.args);
      if (removed.isNotEmpty) fail(key, 'placeholder removed: $removed');
      if (unknown.isNotEmpty) fail(key, 'unknown placeholder: $unknown');

      for (final name in source.plurals.keys) {
        final forms = target.plurals[name];
        if (forms == null) {
          fail(key, 'plural on $name was flattened');
          continue;
        }
        final missing =
            requiredPlurals[locale]!.where((f) => !forms.contains(f));
        if (missing.isNotEmpty) {
          fail(key, 'plural forms missing: ${missing.join(', ')}');
        }
      }
    }

    for (final key in arb.keys) {
      if (!key.startsWith('@') && !template.containsKey(key)) {
        fail(key, 'key not in template');
      }
    }
  }

  if (errors > 0) {
    print('$errors problem(s) found');
    exit(1);
  }
  print('All ARB files match the template');
}

Run it from the project root, locally or as a CI step before flutter gen-l10n:

dart run tool/check_arb.dart lib/l10n

A bad delivery prints something like this and exits with code 1:

de  welcomeBack: placeholder removed: {firstName}
de  welcomeBack: unknown placeholder: {Vorname}
pl  unpaidInvoices: plural forms missing: few, many
fr  invoiceStatusOpen: missing translation
4 problem(s) found

For a quick visual check of one returned file against the template, the free ARB Diff tool shows added, removed and changed keys in the browser.

The round trip, and where it loses time

The script tells you a file is structurally sound. It cannot tell you whether a string was reviewed by a second linguist, or whether the English text changed after the German was written. With files by email, that knowledge lives in someone's inbox.

Developer exports ARBEmail to agencyAgency converts to its own toolTranslator editsFile emailed backDeveloper runs check scriptErrors sent back by emailSecond deliveryMerge to Git
Developer adds key in repoGit sync picks it upTranslator edits in dashboardReviewer marks CheckedDashboard commits ARB to Git

The first row is one release. Add a string the day after the handoff and the loop starts again with a file that is already out of date.

Doing the handoff in FlutterLocalisation

This is the step FlutterLocalisation removes. The translators never see Git or JSON.

  1. Import your ARB filesUpload them in the dashboard or run fl import with the CLI, keys and existing translations come in as they are.
  2. Connect the repositoryGit sync works both ways with GitHub, GitLab, Bitbucket Cloud and self-hosted Bitbucket Server.
  3. Invite the agency by emailAdd translators and reviewers as members with read or write access per project.
  4. Let them translate and reviewThey work in the side by side editor, use the plural editor for few and many, and leave comments on unclear strings.
  5. Ship what is CheckedFilter by review state before a release, the dashboard commits the updated ARB files to your repo.

The review states are the answer to "which strings are safe to ship". Checked means a person approved it. Machine means AI translated it and nobody has reviewed it yet. Needs review means the source changed after the translation was written. Draft and Empty speak for themselves. Many teams let AI translation fill every language first, so the agency reviews instead of typing from scratch, with a glossary pinning product terms.

Keep the validation script in CI either way. It costs nothing and guards the files however they were edited.

The Free plan covers 2 projects, 3 members and 500 keys, with no card. Larger teams are on the pricing page; adding languages never costs extra.

Before you send anything

  • Every key in the template has a description a stranger could act on
  • Every placeholder has a type and an example
  • The agency knows which plural forms each language needs
  • The check script runs in CI before flutter gen-l10n
  • You can tell reviewed strings from unreviewed ones

Try FlutterLocalisation free

Import your ARB files, invite your translators by email and let them review in the dashboard while Git sync keeps the repo up to date. Create a free account, or open a file in the free ARB Editor first, no sign-up needed.