Todos os exemplos abaixo foram testados com Flutter 3.47.5 e flutter gen-l10n, usando um template app_en.arb e uma tradução app_fr.arb.
Com pressa? Gere o bloco de plural de cada idioma direto no navegador: grátis, sem cadastro, nada é enviado.
O que o gen-l10n bloqueia
A sintaxe ICU é verificada em todos os arquivos, não só no template. Um plural sem branch other interrompe a geração, com o nome do arquivo e a posição do erro:
[app_fr.arb:itemsOne] ICU Syntax Error: Plural expressions must have an "other" case.
{count, plural, one{{count} article}}
^
O mesmo vale para uma chave não fechada. Esses erros aparecem na hora; os das seções seguintes, não.
Um placeholder perdido passa sem um pio
O template em inglês declara um parâmetro:
"welcomeUser": "Welcome back, {name}!",
"@welcomeUser": { "placeholders": { "name": { "type": "String" } } }
A tradução francesa o perdeu: "welcomeUser": "Bon retour parmi nous !". A geração funciona, o analyzer fica quieto e o método gerado simplesmente ignora o argumento:
@override
String welcomeUser(String name) {
return 'Bon retour parmi nous !';
}
Nada quebra, mas os usuários francófonos nunca veem o próprio nome. Às vezes é proposital (a frase fica melhor sem ele), geralmente é um erro, e nenhuma ferramenta padrão diferencia os dois casos. É preciso comparar os placeholders de cada tradução com os do template; há um script para isso mais abaixo.
Um placeholder renomeado muda a API de todos os locales
Este é mais estranho. A tradução usa um nome que o template não tem, por exemplo {nombre} em vez de {count}:
"reminder": "Il reste {nombre} articles dans {listName}."
O gen-l10n continua sem reclamar. Ele adiciona nombre como parâmetro extra na classe abstrata, ou seja, em todos os locales:
String reminder(String listName, int count, Object nombre);
O erro aparece depois, quando cada chamada existente é compilada: Too few positional arguments: 3 required, 2 given. Ele aponta para o seu código Dart, não para o arquivo ARB que o causou. Se um método de localização de repente ganhar um parâmetro Object, procure esse nome nas suas traduções.
Em francês, one também cobre o zero
As categorias one, few, many… seguem as regras CLDR de cada idioma. Em inglês, one significa apenas 1. Em francês, significa 0 e 1. Com esta mensagem:
"itemsOne": "{count, plural, one{{count} article} other{{count} articles}}"
| count | Inglês | Francês |
|---|---|---|
| 0 | 0 items | 0 article |
| 1 | 1 item | 1 article |
| 2 | 2 items | 2 articles |
"0 article" é francês correto, então aqui não há problema. A armadilha está em outro lugar: o gen-l10n transforma =0 e =1 nos argumentos zero e one de Intl.pluralLogic. Um branch =1 vira, portanto, a categoria one, e em francês essa categoria também se aplica ao zero:
"exactOne": "{count, plural, =1{1 article restant} other{{count} articles restants}}"
Com count = 0, o inglês mostra "0 items left" e o francês mostra "1 article restant" (resta um item), sem nenhum aviso. A correção: adicione um branch =0, ou escreva one{{count} article restant} para que o número exibido esteja sempre certo. Todo idioma em que one cobre mais do que 1 tem o mesmo problema, e o português do Brasil é um deles: pelas regras CLDR, one cobre 0 e 1 em pt.
Apóstrofos e use-escaping
Por padrão, um apóstrofo reto é só um caractere: "Don't forget the milk" funciona. Se você ativar use-escaping: true no l10n.yaml (para escrever chaves literais), ele vira o caractere de escape do ICU, e um apóstrofo isolado interrompe a geração:
[app_en.arb:apostrophe] ICU Lexing Error: Unmatched single quotes.
Don't forget the milk
^
Aí é preciso duplicá-lo (Don''t) ou usar o apóstrofo tipográfico ’, que não é afetado. Em um projeto existente, ligar essa opção significa revisar todos os idiomas; as contrações do inglês e as elisões do francês vão dar trabalho.
Uma verificação que vale a pena colocar na CI
O caso silencioso é o placeholder perdido. Este script lê os placeholders declarados no template e verifica se cada tradução ainda os usa:
import 'dart:convert';
import 'dart:io';
void main() {
final template = _read('lib/l10n/app_en.arb');
var errors = 0;
for (final file in Directory('lib/l10n').listSync().whereType<File>()) {
if (!file.path.endsWith('.arb') || file.path.endsWith('app_en.arb')) continue;
final translation = _read(file.path);
for (final key in template.keys.where((k) => !k.startsWith('@'))) {
final text = translation[key];
if (text is! String) continue; // missing key: see untranslated-messages-file
final meta = template['@$key'] as Map<String, dynamic>?;
final declared = (meta?['placeholders'] as Map<String, dynamic>?)?.keys ?? const <String>[];
for (final name in declared) {
if (!RegExp(r'\{\s*' + name + r'\s*[,}]').hasMatch(text)) {
stderr.writeln('${file.path} · $key: {$name} missing');
errors++;
}
}
}
}
if (errors > 0) exit(1);
}
Map<String, dynamic> _read(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
No exemplo desta página, ele imprime lib/l10n/app_fr.arb · welcomeUser: {name} missing e sai com erro. Seus limites: ele só olha os placeholders declarados em @key.placeholders e, em um plural, verifica se a variável aparece em algum ponto da mensagem, não em cada branch. As duas armadilhas de plural acima são mais bem pegas por um teste que chama suas mensagens com 0, 1 e 2.