|
1 | 1 | import 'dart:io';
|
2 | 2 | import 'package:collection/collection.dart';
|
3 | 3 | import 'package:flutter/foundation.dart';
|
| 4 | +import 'package:flutter/gestures.dart'; |
4 | 5 | import 'package:flutter/material.dart';
|
5 | 6 |
|
| 7 | +import 'theme.dart'; |
| 8 | + |
6 | 9 | /// An app-wide [Typography] for Zulip, customized from the Material default.
|
7 | 10 | ///
|
8 | 11 | /// Include this in the app-wide [MaterialApp.theme].
|
@@ -415,3 +418,102 @@ TextBaseline localizedTextBaseline(BuildContext context) {
|
415 | 418 | ScriptCategory.tall => TextBaseline.alphabetic,
|
416 | 419 | };
|
417 | 420 | }
|
| 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 | +} |
0 commit comments