Sign up screens link to Terms of Use and Privacy Policy from inside a sentence. Each language puts those links in a different spot. English ends with them, Turkish starts with them:
🇬🇧 I agree to the Terms of Use and Privacy Policy
🇹🇷 Kullanım Koşullarını ve Gizlilik Politikasını kabul ediyorum
Don't build it from separate widgets glued together in code, they can't move like that. Keep it as one string and mark the links with tags:
LinkedText( text: 'I agree to the <terms>Terms of Use</terms> and <privacy>Privacy Policy</privacy>', style: const TextStyle( fontSize: 14, color: Colors.black87, ), linkColor: Colors.blue, onTap: (tag) { if (tag == 'terms') openTerms(); if (tag == 'privacy') openPrivacy(); },)
The string goes into your localization files as is. Translators move the tags to wherever their language puts the links, and the tag name tells you which link was tapped, same in every language.
Copy this widget and use it as is:
import 'package:flutter/gestures.dart';import 'package:flutter/material.dart';/// Text with tappable stretches marked inline as `<terms>Terms of Use</terms>`.////// Keeping the tags in the string is what makes this translatable: a/// sentence can put its links anywhere, in any order, without the layout/// knowing. [onTap] is handed the tag name, so it stays the same in every/// language no matter where the link moves.class LinkedText extends StatefulWidget { final String text; final void Function(String tag) onTap; final TextStyle style; final Color linkColor; final TextAlign textAlign; const LinkedText({ super.key, required this.text, required this.onTap, required this.linkColor, required this.style, this.textAlign = .center, }); @override State<LinkedText> createState() => _LinkedTextState();}class _LinkedTextState extends State<LinkedText> { static final RegExp _pattern = RegExp( r'<(\w+)>(.*?)</\1>', dotAll: true, ); List<RegExpMatch> _matches = <RegExpMatch>[]; List<TapGestureRecognizer> _recognizers = <TapGestureRecognizer>[]; @override void initState() { super.initState(); _parse(); } @override void didUpdateWidget(LinkedText oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.text != widget.text) { _disposeRecognizers(); _parse(); } } @override void dispose() { _disposeRecognizers(); super.dispose(); } void _parse() { _matches = _pattern.allMatches(widget.text).toList(); _recognizers = List<TapGestureRecognizer>.generate( _matches.length, (int index) { final String tag = _matches[index].group(1) ?? ''; return TapGestureRecognizer() ..onTap = () { widget.onTap(tag); }; }, ); } void _disposeRecognizers() { for (final TapGestureRecognizer recognizer in _recognizers) { recognizer.dispose(); } _recognizers = <TapGestureRecognizer>[]; } @override Widget build(BuildContext context) { final TextStyle style = widget.style; final TextStyle linkStyle = style.copyWith( color: widget.linkColor, fontWeight: .w600, ); if (_matches.isEmpty) { return Text( widget.text, style: style, textAlign: widget.textAlign, ); } final List<InlineSpan> spans = <InlineSpan>[]; int cursor = 0; for (int index = 0; index < _matches.length; index++) { final RegExpMatch match = _matches[index]; final String before = widget.text.substring(cursor, match.start); if (before.isNotEmpty) { spans.add( TextSpan( text: before, ), ); } spans.add( TextSpan( text: match.group(2) ?? '', style: linkStyle, recognizer: _recognizers[index], ), ); cursor = match.end; } final String tail = widget.text.substring(cursor); if (tail.isNotEmpty) { spans.add( TextSpan( text: tail, ), ); } return Text.rich( TextSpan( children: spans, style: style, ), textAlign: widget.textAlign, ); }}