Last active
October 2, 2025 19:33
-
-
Save PlugFox/86221b732b753bd1132ac0333d440846 to your computer and use it in GitHub Desktop.
Consent rich text
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:flutter/gestures.dart'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:localization/localization.dart'; | |
| /* | |
| * I consent to the processing of personal data, | |
| * the use of <c>cookies</c>, | |
| * agree to the <t>terms and conditions</t>, | |
| * and acknowledge the <p>privacy policy</p>. | |
| */ | |
| /// {@template consent_text} | |
| /// A text widget that displays a consent message with clickable links for | |
| /// cookies, terms of service, and privacy policy. | |
| /// {@endtemplate} | |
| class ConsentText extends StatefulWidget { | |
| /// {@macro consent_text} | |
| const ConsentText({ | |
| required this.onCookiesTap, | |
| required this.onTermsTap, | |
| required this.onPrivacyTap, | |
| this.textStyle, | |
| this.linkStyle, | |
| this.textAlign, | |
| this.maxLines, | |
| this.overflow, | |
| super.key, | |
| }); | |
| /// Callback when the "cookies" link is tapped. | |
| final VoidCallback onCookiesTap; | |
| /// Callback when the "terms and conditions" link is tapped. | |
| final VoidCallback onTermsTap; | |
| /// Callback when the "privacy policy" link is tapped. | |
| final VoidCallback onPrivacyTap; | |
| /// The style to use for the main text. | |
| final TextStyle? textStyle; | |
| /// The style to use for the links. | |
| final TextStyle? linkStyle; | |
| /// How the text should be aligned horizontally. | |
| final TextAlign? textAlign; | |
| /// The maximum number of lines for the text to span, wrapping if necessary. | |
| final int? maxLines; | |
| /// How visual overflow should be handled. | |
| final TextOverflow? overflow; | |
| @override | |
| State<ConsentText> createState() => _ConsentTextState(); | |
| } | |
| /// State for widget ConsentText. | |
| class _ConsentTextState extends State<ConsentText> { | |
| static const String _cookiesTag = 'c'; | |
| static const String _termsTag = 't'; | |
| static const String _privacyTag = 'p'; | |
| static final RegExp _tagRegExp = RegExp(r'<(c|t|p)>(.*?)</\1>', dotAll: true); | |
| late TapGestureRecognizer _cookiesRecognizer; | |
| late TapGestureRecognizer _termsRecognizer; | |
| late TapGestureRecognizer _privacyRecognizer; | |
| late SignUpLocalization _l10n; | |
| late ThemeData _theme; | |
| late List<InlineSpan> _spans; | |
| late TextStyle _textStyle; | |
| late String _semanticText; | |
| /// Gets the computed link style | |
| TextStyle get _linkStyle => | |
| widget.linkStyle ?? _textStyle.copyWith(color: _theme.colorScheme.primary, decoration: TextDecoration.underline); | |
| // Cache for avoiding unnecessary rebuilds | |
| String? _cachedRawText; | |
| TextStyle? _cachedTextStyle; | |
| TextStyle? _cachedLinkStyle; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _cookiesRecognizer = TapGestureRecognizer()..onTap = widget.onCookiesTap; | |
| _termsRecognizer = TapGestureRecognizer()..onTap = widget.onTermsTap; | |
| _privacyRecognizer = TapGestureRecognizer()..onTap = widget.onPrivacyTap; | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| _l10n = SignUpLocalization.of(context); | |
| _theme = Theme.of(context); | |
| _textStyle = widget.textStyle ?? DefaultTextStyle.of(context).style; | |
| // Only rebuild if dependencies actually changed | |
| if (_shouldRebuildSpans()) { | |
| _rebuildSpans(); | |
| } | |
| } | |
| @override | |
| void didUpdateWidget(covariant ConsentText oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| // Update recognizers only if callbacks changed | |
| if (oldWidget.onCookiesTap != widget.onCookiesTap) { | |
| _cookiesRecognizer.onTap = widget.onCookiesTap; | |
| } | |
| if (oldWidget.onTermsTap != widget.onTermsTap) { | |
| _termsRecognizer.onTap = widget.onTermsTap; | |
| } | |
| if (oldWidget.onPrivacyTap != widget.onPrivacyTap) { | |
| _privacyRecognizer.onTap = widget.onPrivacyTap; | |
| } | |
| // Update text style if changed | |
| if (oldWidget.textStyle != widget.textStyle) { | |
| _textStyle = widget.textStyle ?? DefaultTextStyle.of(context).style; | |
| } | |
| // Only rebuild if styles changed | |
| if (oldWidget.textStyle != widget.textStyle || oldWidget.linkStyle != widget.linkStyle) { | |
| _rebuildSpans(); | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| _cookiesRecognizer.dispose(); | |
| _termsRecognizer.dispose(); | |
| _privacyRecognizer.dispose(); | |
| super.dispose(); | |
| } | |
| /// Checks if spans need to be rebuilt based on cached values | |
| bool _shouldRebuildSpans() { | |
| final rawText = _l10n.consentFull; | |
| final linkStyle = _linkStyle; | |
| return _cachedRawText != rawText || _cachedTextStyle != _textStyle || _cachedLinkStyle != linkStyle; | |
| } | |
| // Parser for tags <c>...</c>, <t>...</t>, <p>...</p> | |
| static List<InlineSpan> _buildTaggedSpans( | |
| String input, { | |
| required TextStyle base, | |
| required TextStyle link, | |
| required Map<String, TapGestureRecognizer> recognizers, | |
| }) { | |
| final spans = <InlineSpan>[]; | |
| var cursor = 0; | |
| for (final match in _tagRegExp.allMatches(input)) { | |
| // Add text before the tag | |
| if (match.start > cursor) spans.add(TextSpan(text: input.substring(cursor, match.start), style: base)); | |
| final tag = match.group(1); | |
| final text = match.group(2); | |
| if (tag != null && text != null) { | |
| final recognizer = recognizers[tag]; | |
| if (recognizer != null) { | |
| spans.add( | |
| TextSpan( | |
| text: text, | |
| style: link, | |
| recognizer: recognizer, | |
| mouseCursor: SystemMouseCursors.click, | |
| semanticsLabel: text, | |
| ), | |
| ); | |
| } else { | |
| // Fallback if recognizer not found | |
| assert(false, 'ConsentText: Unknown tag "$tag" in consent text'); | |
| spans.add(TextSpan(text: text, style: base)); | |
| } | |
| } | |
| cursor = match.end; | |
| } | |
| // Add remaining text after the last tag | |
| if (cursor < input.length) spans.add(TextSpan(text: input.substring(cursor), style: base)); | |
| return spans; | |
| } | |
| void _rebuildSpans() { | |
| final rawText = _l10n.consentFull; | |
| _semanticText = rawText.replaceAll(RegExp(r'</?[ctp]>'), ''); | |
| final linkStyle = _linkStyle; | |
| try { | |
| _spans = _buildTaggedSpans( | |
| rawText, | |
| base: _textStyle, | |
| link: linkStyle, | |
| recognizers: <String, TapGestureRecognizer>{ | |
| _cookiesTag: _cookiesRecognizer, | |
| _termsTag: _termsRecognizer, | |
| _privacyTag: _privacyRecognizer, | |
| }, | |
| ); | |
| // Update cache | |
| _cachedRawText = rawText; | |
| _cachedTextStyle = _textStyle; | |
| _cachedLinkStyle = linkStyle; | |
| } on Object catch (e) { | |
| assert(false, 'ConsentText: Error parsing tagged text: $e'); | |
| // Fallback to plain text | |
| _spans = <InlineSpan>[TextSpan(text: _semanticText, style: _textStyle)]; | |
| } | |
| } | |
| @override | |
| Widget build(BuildContext context) => Text.rich( | |
| TextSpan(children: _spans, style: _textStyle), | |
| textAlign: widget.textAlign, | |
| maxLines: widget.maxLines, | |
| overflow: widget.overflow ?? TextOverflow.clip, | |
| textWidthBasis: TextWidthBasis.parent, | |
| softWrap: true, | |
| semanticsLabel: _semanticText, | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment