← Back to Blog

Flutter gen-l10n Rich Text: Bold and Links in One ARB Key

fluttergen-l10narbtext.richtextspanlocalization

Translators need the whole sentence. A consent line like "I agree to the Terms of Service and the Privacy Policy" has two tappable phrases, and flutter gen-l10n only hands you plain strings. The usual fix is three or four ARB keys stitched together with Text.rich, and that is exactly what breaks in German, Japanese and Arabic.

This post keeps one ARB key per sentence, marks the styled parts with lightweight tags such as <b> and <privacy>, and turns them into TextSpans with styles and tap handlers in about 50 lines of Dart. Placeholders and plurals keep working, and there is a widget test at the end.

Why gen-l10n cannot do this yet

Rich text output in gen-l10n is tracked in flutter/flutter #147974, a proposal opened in May 2024. As of November 2026 it is still open, labelled P3 and "would require significant investment", with no assignee and no linked pull request. The proposal itself describes today's situation: developers define several separate strings and combine them into Text.rich widgets by hand.

So the generator gives you a String. Everything after that is up to you.

Why splitting the sentence fails

Here is the split version most teams start with:

"agreePrefix": "I agree to the ",
"agreeTerms": "Terms of Service",
"agreeMiddle": " and the ",
"agreePrivacy": "Privacy Policy"

The Dart side glues them in English order: prefix, terms, middle, privacy. Three problems follow.

  • Word order is hardcoded. Japanese puts the verb last: 利用規約とプライバシーポリシーに同意します. Nothing comes before the first link, so the translator has to leave agreePrefix empty and cram the verb into the last fragment.
  • Translators lose context. In a translation tool each fragment is a separate row. "and the" on its own could become den, die or das in German, and the translator cannot see which noun follows.
  • Leading and trailing spaces get trimmed. Fragments like " and the " depend on whitespace that many tools and reviewers strip silently.

One key per sentence removes all three. The translator sees the full sentence, moves the tagged phrases wherever the grammar needs them, and the tags survive because they are just text.

One ARB key per sentence

Mark the tappable or styled parts with tags. Use a distinct tag per link so each gets its own handler:

{
  "@@locale": "en",
  "agreeToTerms": "I agree to the <terms>Terms of Service</terms> and the <privacy>Privacy Policy</privacy>.",
  "@agreeToTerms": {
    "description": "Consent checkbox label. Keep the <terms> and <privacy> tags around the translated phrase; they become tappable links."
  },
  "newMessages": "Welcome back, <b>{name}</b>. {count, plural, =0{No new messages} =1{<b>One</b> new message} other{<b>{count}</b> new messages}}.",
  "@newMessages": {
    "placeholders": {
      "name": {"type": "String"},
      "count": {"type": "int"}
    }
  }
}

The German, Japanese and Arabic files reorder freely:

"agreeToTerms": "Ich akzeptiere die <terms>Nutzungsbedingungen</terms> und die <privacy>Datenschutzerklärung</privacy>."
"agreeToTerms": "<terms>利用規約</terms>と<privacy>プライバシーポリシー</privacy>に同意します。"
"agreeToTerms": "أوافق على <terms>شروط الخدمة</terms> و<privacy>سياسة الخصوصية</privacy>."

Angle brackets are not special in ICU MessageFormat, so gen-l10n passes them through untouched. Only braces and apostrophes need care. French "J'accepte" is the classic case, and the fix is covered in Flutter ARB apostrophes and use-escaping.

Because gen-l10n resolves placeholders and plurals before you ever see the string, <b>{count}</b> just works. By the time the parser runs, the string already reads <b>3</b> new messages.

The tag parser

A StatefulWidget, so the TapGestureRecognizers can be disposed. Pass a style per tag and an optional tap handler per tag.

import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';

class TaggedText extends StatefulWidget {
  const TaggedText(
    this.text, {
    super.key,
    this.style,
    this.styles = const {},
    this.onTap = const {},
    this.textAlign,
  });

  final String text;
  final TextStyle? style;
  final Map<String, TextStyle> styles;
  final Map<String, VoidCallback> onTap;
  final TextAlign? textAlign;

  @override
  State<TaggedText> createState() => _TaggedTextState();
}

class _TaggedTextState extends State<TaggedText> {
  static final _tag = RegExp(r'<(\w+)>(.*?)</\1>', dotAll: true);
  final _recognizers = <TapGestureRecognizer>[];

  @override
  void dispose() {
    for (final r in _recognizers) {
      r.dispose();
    }
    super.dispose();
  }

  List<InlineSpan> _parse(String text) {
    for (final r in _recognizers) {
      r.dispose();
    }
    _recognizers.clear();

    final spans = <InlineSpan>[];
    var cursor = 0;
    for (final m in _tag.allMatches(text)) {
      if (m.start > cursor) {
        spans.add(TextSpan(text: text.substring(cursor, m.start)));
      }
      final tag = m.group(1)!;
      final tap = widget.onTap[tag];
      TapGestureRecognizer? recognizer;
      if (tap != null) {
        recognizer = TapGestureRecognizer()..onTap = tap;
        _recognizers.add(recognizer);
      }
      spans.add(TextSpan(
        text: m.group(2),
        style: widget.styles[tag],
        recognizer: recognizer,
      ));
      cursor = m.end;
    }
    if (cursor < text.length) {
      spans.add(TextSpan(text: text.substring(cursor)));
    }
    return spans;
  }

  @override
  Widget build(BuildContext context) {
    return Text.rich(
      TextSpan(style: widget.style, children: _parse(widget.text)),
      textAlign: widget.textAlign,
    );
  }
}

How it works:

  • The regex matches <tag>content</tag>, and the \1 backreference ensures the closing tag matches the opening one.
  • Text between matches becomes a plain TextSpan that inherits the outer style.
  • A tag with a handler gets a TapGestureRecognizer. Spans with a recognizer get their own semantics node, so screen readers announce "Privacy Policy" as a tappable element.
  • A tag with no style and no handler still renders its inner text, so a typo in a translation shows the words instead of swallowing them.

Nesting (<b><privacy>...</privacy></b>) is not supported. In practice one tag with a bold link style covers that case. Also, the parser only trusts tags from your own ARB files. If a placeholder is filled with user content that might contain angle brackets, strip them before passing the value to gen-l10n.

Wiring it to gen-l10n

import 'package:flutter/material.dart';
import 'package:my_app/l10n/app_localizations.dart';
import 'package:my_app/widgets/tagged_text.dart';
import 'package:url_launcher/url_launcher.dart';

class ConsentLabel extends StatelessWidget {
  const ConsentLabel({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final theme = Theme.of(context);
    final link = TextStyle(
      color: theme.colorScheme.primary,
      decoration: TextDecoration.underline,
    );
    return TaggedText(
      l10n.agreeToTerms,
      style: theme.textTheme.bodyMedium,
      styles: {
        'terms': link,
        'privacy': link,
        'b': const TextStyle(fontWeight: FontWeight.bold),
      },
      onTap: {
        'terms': () => launchUrl(Uri.parse('https://example.com/terms')),
        'privacy': () => launchUrl(Uri.parse('https://example.com/privacy')),
      },
    );
  }
}

The import path assumes the default output directory with synthetic-package: false, which is the only option since Flutter 3.32. The ! matches the default nullable-getter: true; drop it if you set that flag to false.

Widget test

tester.tap(find.text(...)) hits the centre of the whole paragraph, which may land on an unlinked word. Walk the spans and invoke the recognizer directly instead:

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
import 'package:my_app/widgets/tagged_text.dart';

void main() {
  testWidgets('German consent line keeps order and taps privacy', (tester) async {
    var privacyTapped = false;

    await tester.pumpWidget(MaterialApp(
      locale: const Locale('de'),
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: Scaffold(
        body: Builder(
          builder: (context) => TaggedText(
            AppLocalizations.of(context)!.agreeToTerms,
            onTap: {'privacy': () => privacyTapped = true},
          ),
        ),
      ),
    ));
    await tester.pumpAndSettle();

    expect(find.textContaining('Datenschutzerklärung', findRichText: true), findsOneWidget);
    expect(find.textContaining('<privacy>', findRichText: true), findsNothing);

    final paragraph = tester.widget<RichText>(find.descendant(
      of: find.byType(TaggedText),
      matching: find.byType(RichText),
    ));
    TapGestureRecognizer? recognizer;
    paragraph.text.visitChildren((span) {
      if (span is TextSpan && span.text == 'Datenschutzerklärung') {
        recognizer = span.recognizer as TapGestureRecognizer?;
      }
      return recognizer == null;
    });

    expect(recognizer, isNotNull);
    recognizer!.onTap!();
    expect(privacyTapped, isTrue);
  });
}

findRichText: true is required because find.text and find.textContaining ignore RichText widgets by default. The second expectation catches the most common regression: a translator drops a closing tag, the regex no longer matches, and a raw <privacy> leaks into the UI.

Keep translators in the loop

The tags live inside the source string, so translators have to know to keep them. Say so in the description metadata, as in the ARB above. If you edit ARB files in the FlutterLocalisation ARB editor, that description sits next to the value for every locale, which is where a translator actually reads it. The plural branches in newMessages get checked there too: the ICU plural validation flags a locale that is missing a category its language needs, such as few and many for Polish or Arabic, which matters even more once each branch carries tags.

For the widget-level view of the same problem, see Flutter RichText localization.

One sentence, one key, tags for the styled parts. The translator keeps the grammar, your code keeps the tap handlers, and gen-l10n keeps doing what it already does well. Try FlutterLocalisation free to edit the tagged strings across every locale in one place.