flutter gen-l10n compiles every ARB message into a Dart method returning a
String, with the concatenation baked into the generated source:
String termsNotice(String terms) {
return 'By continuing you accept our $terms.';
}That leaves no seam for rich text. To put a tappable link inside a translated sentence you would have to split the message into fragments — which breaks translation, because word order differs per language.
arb_spans reads the same ARB files and generates a second, rich-text API
beside the first:
AppLocalizationSpans.of(context).termsNotice(terms: …)It does not fork, patch, or replace flutter gen-l10n. Both generators run
over the same files, from the same l10n.yaml.
A runnable demo is in example/.
Mark the placeholders that should carry rich text with "isSpan": true in your
template ARB file:
{
"termsNotice": "By continuing you accept our {terms}.",
"@termsNotice": {
"placeholders": {
"terms": { "type": "String", "isSpan": true }
}
}
}Translations need no changes — they just use {terms} wherever their grammar
puts it.
Run the two generators in order:
flutter gen-l10n
dart run arb_spansThen reach the rich-text messages through their own class, parallel to
AppLocalizations.of(context):
import 'l10n/app_localizations_spans.dart';
Text.rich(
AppLocalizationSpans.of(context).termsNotice(
terms: TextSpan(
text: 'the terms',
style: TextStyle(decoration: TextDecoration.underline),
recognizer: TapGestureRecognizer()..onTap = openTerms,
),
),
)Method names are the ARB keys verbatim — termsNotice, not termsNoticeSpans
— because they live on their own class rather than alongside the String
methods. Only messages with a span placeholder appear on it, so typing
AppLocalizationSpans.of(context). lists exactly the rich-text messages.
Important
A TapGestureRecognizer must be disposed. Own it from a State and dispose
it in dispose(), rather than constructing one inside build.
Because the parameter is an InlineSpan, a WidgetSpan works too:
Text.rich(AppLocalizationSpans.of(context).errorServer(
details: const WidgetSpan(child: Icon(Icons.error, size: 14)),
))The generated class holds nothing but the resolved locale, and each method dispatches on it:
@immutable
class AppLocalizationSpans {
const AppLocalizationSpans(this.localeName);
static AppLocalizationSpans of(BuildContext context) =>
AppLocalizationSpans(AppLocalizations.of(context)!.localeName);
final String localeName;
InlineSpan cancelAnytime({required InlineSpan settings}) {
switch (localeName) {
case 'de':
return TextSpan(children: <InlineSpan>[
const TextSpan(text: 'Du kannst dein Abo jederzeit in '),
settings,
const TextSpan(text: ' kündigen.'),
]);
}
return TextSpan(children: <InlineSpan>[
const TextSpan(text: 'You can cancel your subscription at any time in '),
settings,
const TextSpan(text: '.'),
]);
}
}Reading the locale from gen-l10n is what keeps this small. The class hierarchy,
the LocalizationsDelegate, the lookup function and the locale fallback are all
gen-l10n's, and none of it is reproduced here. localeName is the locale of the
ARB file gen-l10n resolved to, so switching on it needs no fallback logic of
its own — and the template locale is the default branch, which also covers any
locale whose ARB omits the message.
The whole coupling to gen-l10n is that one field.
These work, including when a branch contains a span. Intl.pluralLogic and
Intl.selectLogic are already generic over their return type, so the branch is
chosen as a List<InlineSpan> and spread:
return TextSpan(children: <InlineSpan>[
...intl.Intl.pluralLogic<List<InlineSpan>>(
count,
locale: localeName,
one: <InlineSpan>[const TextSpan(text: 'One unread message in '), folder],
other: <InlineSpan>[
TextSpan(text: '$count'),
const TextSpan(text: ' unread messages in '),
folder,
],
),
]);No changes to package:intl are required.
Placeholders that are not marked isSpan keep their declared type and their
formatting. A NumberFormat or DateFormat placeholder is formatted once,
above the locale switch, exactly as gen-l10n formats it:
final amountString =
intl.NumberFormat.currency(locale: localeName, symbol: '€').format(amount);Two independent generators parse the same ICU messages, so in principle they
could disagree about where literal text ends and a placeholder begins. Set
span-conformance-test and arb_spans writes a test that pins one to the
other:
# l10n.yaml
span-conformance-test: test/spans_conformance_test.dartFor every message, every locale, and every plural and select branch, it asserts that flattening the span tree yields exactly the string the gen-l10n method returns. If a Flutter upgrade ever changes gen-l10n's output, this test fails instead of your app quietly rendering the wrong text.
arb_spans reads the same l10n.yaml as flutter gen-l10n, so arb-dir,
template-arb-file, output-dir, output-localization-file, output-class
and use-named-parameters are picked up automatically. Three keys are its own:
| Key | Default | Meaning |
|---|---|---|
output-spans-file |
gen-l10n's output name with a _spans suffix |
Name of the generated file. |
spans-class |
output-class de-pluralised, plus Spans |
Name of the generated class. AppLocalizations gives AppLocalizationSpans. |
span-conformance-test |
(none) | Where to write the conformance test. Skipped if unset. |
All are also available as command-line flags, which override l10n.yaml. Run
dart run arb_spans --help for the full list.
- Placeholder metadata is read from the template ARB only. Per-locale placeholder overrides are ignored; translations are treated as plain ARB.
- The inline
{date, date, yMMMd}argument syntax is not supported. Declare the format on the placeholder instead —{ "type": "DateTime", "format": "yMMMd" }— which is the form gen-l10n's own documentation recommends. Using the inline syntax produces an error naming the message. use-deferred-loadingis not honoured for span methods. The generated class is a single file covering every locale, so those messages are not deferred.- A placeholder used as a plural count, a select choice, or a formatted date/number cannot be a span; the generator rejects it with an error naming the ARB message.
- Because message methods keep the ARB key verbatim, a key that collides with a
member of the generated class (
of,localeName,toString,hashCode,runtimeType,noSuchMethod) is rejected. Rename the message.
lib/src/icu_parser.dart is vendored verbatim from
dart-lang/i18n (pkgs/messages_builder), which is BSD
licensed by the Dart project authors — see LICENSE and
AUTHORS. It is excluded from analysis so it can be re-synced by
copying the upstream file over it.
Everything else in lib/ is specific to span generation and is original to
this project.
Note that this is not a fork of flutter gen-l10n: no part of the Flutter
SDK is vendored here, and gen-l10n is neither patched nor replaced. The only
thing the generated code assumes about gen-l10n's output is the localeName
field on the generated localizations class.