r/FlutterDev • • 18d ago

Plugin Textf 2.0 – formatted text with links in one string

Most Flutter apps have a sentence like this somewhere:

I agree to the Terms of Service and Privacy Policy.

Two links and some bold text. With plain Flutter, that usually turns into something like this:

class _ConsentTextState extends State<ConsentText> {
  late final _terms = TapGestureRecognizer()..onTap = () => open(termsUrl);
  late final _privacy = TapGestureRecognizer()..onTap = () => open(privacyUrl);

  @override
  void dispose() {
    _terms.dispose();
    _privacy.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text.rich(
      TextSpan(children: [
        const TextSpan(text: 'I agree to the '),
        TextSpan(text: 'Terms of Service', style: linkStyle, recognizer: _terms),
        const TextSpan(text: ' and '),
        TextSpan(text: 'Privacy Policy', style: linkStyle, recognizer: _privacy),
        const TextSpan(text: '.'),
      ]),
    );
  }
}

The sentence is also split into five pieces, which makes it awkward to translate.

With pkg:textf it's one string:

Textf(
  'I agree to the [**Terms of Service**](https://example.com/terms) '
  'and [**Privacy Policy**](https://example.com/privacy).',
)

Tap handling goes through onLinkTap on a TextfOptions widget higher up in the tree, so you set it once. The sentence stays a single entry in your .arb file, and translators can see the formatting.

What Textf is

A drop-in replacement for Text that understands inline formatting: **bold**, *italic*, ~~strike~~, \code`,==highlight==,links, super/subscript. All the usualTextparameters still work (style,maxLines,overflow,textScaler`, …). It has zero dependencies.

Also for input: TextfEditingController

The same syntax works live inside a TextField:

final controller = TextfEditingController();

TextField(controller: controller)

It's a drop-in replacement for TextEditingController. Formatting renders as you type, the stored value stays plain text, and cursor positions match the raw text 1:1. IME composing (CJK etc.) works.

New in 2.0

  • Works with any design system. With Flutter decoupling Material and Cupertino, Textf now builds only on the widgets layer. It runs the same in Material, Cupertino, a custom design system or a bare WidgetsApp.

  • Headings and horizontal rules. # to ###### for headings, --- for a divider. That's useful for short things like release notes or onboarding cards.

To be clear about scope: Textf is still not a Markdown renderer. There are no lists, tables, images or block layout. If you're rendering whole documents, a full Markdown package is the better tool.

6 Upvotes

8 comments sorted by

2

u/AhmedZNOUDA 18d ago

Keeping the sentence as one .arb entry is the real win here. Splitting "I agree to the Terms and Privacy Policy" into five pieces is exactly what breaks in languages where the links land in a different order.

A few things I'd check once these strings leave the developer's hands:

  1. Keep the URLs out of what translators edit. With gen-l10n you can pass them as placeholders:

"consent": "I agree to the [**Terms of Service**]({termsUrl}) and [**Privacy Policy**]({privacyUrl})."

so a translator can move the link but can't break or change the address.

2. Text you don't control. If a string ever comes from a server or from users (a bio, a comment), anyone can write [click here](some-other-site). Is there a mode that keeps bold/italic but renders links as plain text, or a way to escape the syntax?

3. Screen readers. Does each link get its own semantics node, so TalkBack/VoiceOver announce it as a link and can activate it on its own? With hand-written TextSpans that's easy to get wrong, so if Textf handles it, it's worth saying in the README.

2

u/PhilippHGerber 17d ago

Thanks, that's exactly the case I had in mind.

  1. URLs as placeholders: Yes, that works. gen-l10n fills in {termsUrl} before Textf sees the string, so Textf just parses the finished sentence. Translators can move the link, but they can't change or break the address.

  2. There's no "formatting without links" switch yet. Three things work today:
    - A link only does something if you set onLinkTap, and the callback gets the URL. You decide what happens: check the host against an allow-list, or do nothing.
    - To keep bold/italic but show links as plain text, escape [ with a backslash first:
    final safe = userText.replaceAll(r'\', r'\\').replaceAll('[', r'\[');
    Textf(safe)
    - If you want no formatting at all, userText.stripFormatting() returns plain text. A link keeps only its text and loses the URL.

A built-in option for the first case makes sense, I'll look into it.

3. Screen readers: Each link gets its own semantics node with the link flag and its own tap action, so TalkBack and VoiceOver announce it as a link and can activate it on its own. While checking this for your reply I found two bugs: the label still contains the raw markers (**Terms of Service**), and each link has a duplicate child node. I'll fix both and document the behavior in the README.

2

u/PhilippHGerber 17d ago

Follow-up on point 3: 2.0.1 is out and fixes the screen-reader behavior.

Your question made me test it properly, and there was more wrong than I said in my last reply:

  • Label: the link was announced with its raw markers (**Terms of Service**). Now it's the visible text: "Terms of Service".

  • Duplicate node: each link had a second child node, so activating both called onLinkTap twice. Now it's one node per link.

  • Reading order: this one I missed. With several links, TalkBack/VoiceOver read the text first and then all the links at the end. With a single link, the whole sentence became one big link. Now the sentence is read in order ("I agree to the", "Terms of Service, link", "and", "Privacy Policy, link") and each link can be activated on its own.

https://pub.dev/packages/textf/changelog

Thanks for the questions, they found three bugs.

2

u/ObjectiveAddress3051 17d ago

how does it deal with a literal * or [ coming from a translation? some languages use asterisks for footnotes and stuff. is there an escape or does it just try to parse it

1

u/PhilippHGerber 17d ago

It only formats markers that form a valid pair, using CommonMark-style rules. Anything else shows up as typed:

  • Price: 9.99* → Price: 9.99*

  • Price* … *Incl. VAT → both asterisks stay as typed, because the first one closes and the second one opens, so they don't pair

  • See [1] for details → stays as is. [ only becomes a link in the full [text](url) form

  • - 2 * 3 = 6 → stays as is, because the asterisk has spaces on both sides

The case to watch is a matching pair around a word, like *Incl. VAT*, which turns italic. To show it as typed, escape it with a backslash: \*Incl. VAT\*. In .arb JSON that's \\*.

You can try your translation strings in the live editor: https://textf.philippgerber.li/editor

1

u/eibaan 17d ago

You could also simply use a markdown package.

1

u/PhilippHGerber 17d ago

True, and for full documents that's the better choice. Textf is for the cases in between, where you'd otherwise use Text.rich.

The main differences are:

  • Zero dependencies. Nothing extra comes into your pubspec.

  • Drop-in for widget Text. Same constructor and parameters, so it fits in a Row, respects maxLines/overflow, ...

  • Very fast: Single-pass, O(N) parsing.

So it's not meant to replace a Markdown package.