Skip to content

Commit c33ee61

Browse files
committed
text: Introduce TextWithLink widget
Fixes part of #1285. In particular this handles the case we need at the moment for #667, read receipts: a sentence with a single link in the middle of it. Other cases noted in #1285 are for a code font; or italics; or inserting more string fragments before and after a tagged string (for a Markdown link in quote-and-reply). There's also #1553 which calls for a sentence with two different links in it (to use in an empty inbox).
1 parent 6ae29f9 commit c33ee61

File tree

2 files changed

+152
-0
lines changed

2 files changed

+152
-0
lines changed

lib/widgets/text.dart

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import 'dart:io';
22
import 'package:collection/collection.dart';
33
import 'package:flutter/foundation.dart';
4+
import 'package:flutter/gestures.dart';
45
import 'package:flutter/material.dart';
56

7+
import 'theme.dart';
8+
69
/// An app-wide [Typography] for Zulip, customized from the Material default.
710
///
811
/// Include this in the app-wide [MaterialApp.theme].
@@ -415,3 +418,102 @@ TextBaseline localizedTextBaseline(BuildContext context) {
415418
ScriptCategory.tall => TextBaseline.alphabetic,
416419
};
417420
}
421+
422+
/// A text widget with an embedded link.
423+
///
424+
/// The text and link are given in [markup], in a simple HTML-like markup.
425+
/// The markup string must not contain arbitrary user-controlled text.
426+
///
427+
/// The portion of the text that is the link will be styled as a link,
428+
/// and will respond to taps by calling the [onTap] callback.
429+
///
430+
/// If the entire text is meant to be a link, there's no need for this widget;
431+
/// instead, use [Text] inside a [GestureDetector], with [GestureDetector.onTap]
432+
/// invoking [PlatformActions.launchUrl].
433+
///
434+
/// TODO(#1285): Integrate this with l10n so that the markup can be parsed
435+
/// from the constant translated string, with placeholders for any variables,
436+
/// rather than the string that results from interpolating variables.
437+
/// That way it'll be fine to interpolate variables with arbitrary text.
438+
/// TODO(#1285): Generalize this to other styling, like code font and italics.
439+
/// TODO(#1553): Generalize this to multiple links in one string.
440+
class TextWithLink extends StatefulWidget {
441+
const TextWithLink({super.key, this.style, required this.onTap, required this.markup});
442+
443+
final TextStyle? style;
444+
445+
/// A callback to be called when the user taps the link.
446+
///
447+
/// Consider using [PlatformActions.launchUrl] to open a web page,
448+
/// or [Navigator.push] to open a page of the app.
449+
final VoidCallback onTap;
450+
451+
/// The text to display, in a simple HTML-like markup.
452+
///
453+
/// This string must contain the tags `<z-link>` and `</z-link>` as substrings,
454+
/// in that order, and must contain no other `<` characters.
455+
///
456+
/// In particular this means the string must not contain any arbitrary
457+
/// user-controlled text, which might have '<' characters.
458+
///
459+
/// The contents other than the two tags will be shown as text.
460+
/// The portion between the tags will be the link.
461+
//
462+
// (Why the name `<z-link>`? Well, it matches Zulip web's practice;
463+
// and here's the reasoning for that name there:
464+
// https://github.com/zulip/zulip/pull/18075#discussion_r611067127
465+
// )
466+
final String markup;
467+
468+
@override
469+
State<TextWithLink> createState() => _TextWithLinkState();
470+
}
471+
472+
class _TextWithLinkState extends State<TextWithLink> {
473+
late final GestureRecognizer _recognizer;
474+
475+
@override
476+
void initState() {
477+
super.initState();
478+
_recognizer = TapGestureRecognizer()
479+
..onTap = widget.onTap;
480+
}
481+
482+
@override
483+
void dispose() {
484+
_recognizer.dispose();
485+
super.dispose();
486+
}
487+
488+
static final _markupPattern = RegExp(r'^([^<]*)<z-link>([^<]*)</z-link>([^<]*)$');
489+
490+
@override
491+
Widget build(BuildContext context) {
492+
final designVariables = DesignVariables.of(context);
493+
494+
final match = _markupPattern.firstMatch(widget.markup);
495+
final InlineSpan span;
496+
if (match == null) {
497+
// TODO(log): The markup text was invalid.
498+
// Probably a translation (used by this widget's caller) didn't carry the
499+
// syntax through correctly.
500+
// This can also happen if the markup string contains user-controlled
501+
// text (which is a bug) and that introduced a '<' character.
502+
// Fall back to showing plain text.
503+
// (It's important not to try to interpret any markup here, in case it
504+
// comes buggily from user-controlled text.)
505+
span = TextSpan(text: widget.markup);
506+
} else {
507+
span = TextSpan(text: match.group(1), children: [
508+
TextSpan(text: match.group(2), recognizer: _recognizer,
509+
style: TextStyle(
510+
decoration: TextDecoration.underline,
511+
color: designVariables.link,
512+
decorationColor: designVariables.link)),
513+
TextSpan(text: match.group(3)),
514+
]);
515+
}
516+
517+
return Text.rich(span, style: widget.style);
518+
}
519+
}

test/widgets/text_test.dart

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:checks/checks.dart';
22
import 'package:collection/collection.dart';
33
import 'package:flutter/material.dart';
4+
import 'package:flutter_checks/flutter_checks.dart';
45
import 'package:flutter_test/flutter_test.dart';
56
import 'package:zulip/widgets/text.dart';
67

@@ -418,4 +419,53 @@ void main() {
418419
// "und" is a special language code meaning undefined; see [Locale]
419420
testLocalizedTextBaseline(const Locale('und'), TextBaseline.alphabetic);
420421
});
422+
423+
group('TextWithLink', () {
424+
testWidgets('responds correctly to taps', (tester) async {
425+
int calls = 0;
426+
addTearDown(testBinding.reset);
427+
await tester.pumpWidget(TestZulipApp(
428+
child: Center(
429+
child: TextWithLink(onTap: () => calls++,
430+
markup: 'asd <z-link>fgh</z-link> jkl'))));
431+
await tester.pump();
432+
433+
final findText = find.text('asd fgh jkl', findRichText: true);
434+
final center = tester.getCenter(findText);
435+
final width = tester.getSize(findText).width;
436+
437+
// No response to tapping the words not in the link.
438+
await tester.tapAt(center + Offset(-0.3 * width, 0));
439+
check(calls).equals(0);
440+
await tester.tapAt(center + Offset(0.3 * width, 0));
441+
check(calls).equals(0);
442+
443+
// Tapping the word in the link calls the callback.
444+
await tester.tapAt(center);
445+
check(calls).equals(1);
446+
await tester.tapAt(center);
447+
check(calls).equals(2);
448+
});
449+
450+
testWidgets('rejects extra tags', (tester) async {
451+
final markup = '<z-link>spurious</z-link><z-link>markup</z-link>';
452+
final plainText = 'spuriousmarkup';
453+
454+
int calls = 0;
455+
addTearDown(testBinding.reset);
456+
await tester.pumpWidget(TestZulipApp(
457+
child: Center(
458+
child: TextWithLink(onTap: () => calls++,
459+
markup: markup))));
460+
await tester.pump();
461+
462+
// The widget appears with the markup string as plain text.
463+
check(find.text(plainText, findRichText: true)).findsNothing();
464+
check(find.text(markup)).findsOne();
465+
466+
// Nothing happens on tapping it.
467+
await tester.tap(find.text(markup));
468+
check(calls).equals(0);
469+
});
470+
});
421471
}

0 commit comments

Comments
 (0)