From cce31c8b603ae733e36f34563835b61a4c158e63 Mon Sep 17 00:00:00 2001
From: Alex Melnyk <30177329+alex-melnyk@users.noreply.github.com>
Date: Thu, 14 Mar 2024 17:41:08 +0200
Subject: [PATCH] fix issues
---
.gitignore | 2 +
example/pubspec.lock | 38 +++---
example/pubspec.yaml | 4 +
lib/http_parser.dart | 27 ++--
lib/linkable.dart | 314 +++++++++++++++++++++++++++++++++----------
lib/parser.dart | 4 +-
lib/tel_parser.dart | 22 +--
7 files changed, 305 insertions(+), 106 deletions(-)
diff --git a/.gitignore b/.gitignore
index 96486fd..35ee281 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,5 @@ migrate_working_dir/
.dart_tool/
.packages
build/
+.flutter-plugins
+.flutter-plugins-dependencies
diff --git a/example/pubspec.lock b/example/pubspec.lock
index 5b843ca..2c0b3dd 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -13,15 +13,23 @@ packages:
dependency: transitive
description:
name: collection
- sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
+ sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
- version: "1.17.2"
+ version: "1.18.0"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
+ flutter_lints:
+ dependency: "direct dev"
+ description:
+ name: flutter_lints
+ sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.3"
flutter_web_plugins:
dependency: transitive
description: flutter
@@ -34,22 +42,30 @@ packages:
relative: true
source: path
version: "3.0.2"
+ lints:
+ dependency: transitive
+ description:
+ name: lints
+ sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
+ sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev"
source: hosted
- version: "0.5.0"
+ version: "0.8.0"
meta:
dependency: transitive
description:
name: meta
- sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
+ sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04
url: "https://pub.dev"
source: hosted
- version: "1.9.1"
+ version: "1.11.0"
plugin_platform_interface:
dependency: transitive
description:
@@ -135,14 +151,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
- web:
- dependency: transitive
- description:
- name: web
- sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
- url: "https://pub.dev"
- source: hosted
- version: "0.1.4-beta"
sdks:
- dart: ">=3.1.3 <4.0.0"
+ dart: ">=3.2.0-0 <4.0.0"
flutter: ">=3.13.0"
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index 0b9882c..6d9d872 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -14,5 +14,9 @@ dependencies:
linkable:
path: ..
+
+dev_dependencies:
+ flutter_lints: ^2.0.0
+
flutter:
uses-material-design: true
\ No newline at end of file
diff --git a/lib/http_parser.dart b/lib/http_parser.dart
index cbde0f8..357ea77 100644
--- a/lib/http_parser.dart
+++ b/lib/http_parser.dart
@@ -8,16 +8,27 @@ class HttpParser implements Parser {
HttpParser(this.text);
@override
- parse() {
- String pattern =
- r"(http(s)?:\/\/)?(www.)?[a-zA-Z0-9]{2,256}\.[a-zA-Z0-9]{2,256}(\.[a-zA-Z0-9]{2,256})?([-a-zA-Z0-9@:%_\+~#?&//=.]*)([-a-zA-Z0-9@:%_\+~#?&//=]+)";
+ List parse() {
+ const pattern =
+ r'\b((www\.|https?://)[^\s]+)|([a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)+(:\d+)?(/[^\s]*)?)\b';
- RegExp regExp = RegExp(pattern, caseSensitive: false);
+ final regExp = RegExp(pattern, caseSensitive: false);
+ final allMatches = regExp.allMatches(text);
+ final links = [];
- Iterable allMatches = regExp.allMatches(text);
- List links = [];
- for (RegExpMatch match in allMatches) {
- links.add(Link(regExpMatch: match, type: http));
+ for (final match in allMatches) {
+ String urlString = match.group(0)!;
+
+ if (!urlString.startsWith('http://') &&
+ !urlString.startsWith('https://')) {
+ urlString = 'https://$urlString';
+ }
+
+ final uri = Uri.parse(urlString);
+
+ if ((uri.scheme == 'http' || uri.scheme == 'https') && uri.hasAuthority) {
+ links.add(Link(regExpMatch: match, type: http));
+ }
}
return links;
}
diff --git a/lib/linkable.dart b/lib/linkable.dart
index 114442d..b8d407c 100644
--- a/lib/linkable.dart
+++ b/lib/linkable.dart
@@ -1,5 +1,3 @@
-library linkable;
-
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:linkable/constants.dart';
@@ -8,114 +6,223 @@ import 'package:linkable/http_parser.dart';
import 'package:linkable/link.dart';
import 'package:linkable/parser.dart';
import 'package:linkable/tel_parser.dart';
-import 'package:url_launcher/url_launcher.dart';
+import 'package:url_launcher/url_launcher_string.dart';
-class Linkable extends StatelessWidget {
- final String text;
+class Linkable extends StatefulWidget {
+ const Linkable({
+ Key? key,
+ required this.text,
+ this.style,
+ this.linkStyle = const TextStyle(color: Colors.blue),
+ this.textAlign = TextAlign.start,
+ this.textDirection,
+ this.textScaleFactor = 1.0,
+ this.textScaler = TextScaler.noScaling,
+ this.maxLines,
+ this.strutStyle,
+ this.textWidthBasis = TextWidthBasis.parent,
+ this.textHeightBehavior,
+ this.mobileRegExp,
+ this.onTelephoneTap,
+ this.onLinkTap,
+ this.onEmailTap,
+ this.mobileSpan,
+ this.parsersPriorityOrder = const [tel, email, http],
+ this.selectable = false,
+ }) : explicitPhoneNumbers = false,
+ phoneNumberMatches = const [],
+ super(key: key);
+
+ /// Constructor that requires explicit list of phone numbers to be specified.
+ /// Those phone numbers will be highlighted as links instead of parsing via
+ /// default parser.
+ const Linkable.explicitPhoneNumbers({
+ Key? key,
+ required this.text,
+ required this.phoneNumberMatches,
+ this.linkStyle = const TextStyle(color: Colors.blue),
+ this.style,
+ this.textAlign = TextAlign.start,
+ this.textDirection,
+ this.textScaleFactor = 1.0,
+ this.textScaler = TextScaler.noScaling,
+ this.maxLines,
+ this.strutStyle,
+ this.textWidthBasis = TextWidthBasis.parent,
+ this.textHeightBehavior,
+ this.mobileRegExp,
+ this.onTelephoneTap,
+ this.onLinkTap,
+ this.onEmailTap,
+ this.mobileSpan,
+ this.parsersPriorityOrder = const [tel, email, http],
+ this.selectable = false,
+ }) : explicitPhoneNumbers = true,
+ super(key: key);
- final Color? textColor;
+ final String text;
- final Color? linkColor;
+ final TextStyle? linkStyle;
final TextStyle? style;
- final TextAlign? textAlign;
+ final TextAlign textAlign;
final TextDirection? textDirection;
final int? maxLines;
- final double? textScaleFactor;
+ final double textScaleFactor;
+
+ final TextScaler textScaler;
final StrutStyle? strutStyle;
- final TextWidthBasis? textWidthBasis;
+ final TextWidthBasis textWidthBasis;
final TextHeightBehavior? textHeightBehavior;
- final List _parsers = [];
- final List _links = [];
+ final void Function(String value)? onTelephoneTap;
- Linkable({
- Key? key,
- required this.text,
- this.textColor = Colors.black,
- this.linkColor = Colors.blue,
- this.style,
- this.textAlign = TextAlign.start,
- this.textDirection,
- this.textScaleFactor = 1.0,
- this.maxLines,
- this.strutStyle,
- this.textWidthBasis = TextWidthBasis.parent,
- this.textHeightBehavior,
- }) : super(key: key);
+ final void Function(String value)? onLinkTap;
+
+ final void Function(String value)? onEmailTap;
+
+ final TextSpan Function(String value, GestureRecognizer function)? mobileSpan;
+
+ final String? mobileRegExp;
+
+ final bool explicitPhoneNumbers;
+
+ /// List of phone numbers to be highlighted as links instead of parsing via
+ /// default tel parser.
+ final List phoneNumberMatches;
+
+ /// Defines how link will be recognized in case if it has matches for more
+ /// then one pattern. Default is [tel, email, http], which means that tel
+ /// matches have the highest priority.
+ final List parsersPriorityOrder;
+
+ final bool selectable;
+
+ @override
+ State createState() => _LinkableState();
+}
+
+class _LinkableState extends State {
+ final _parsers = [];
+ final _links = [];
@override
Widget build(BuildContext context) {
init();
- return SelectableText.rich(
- TextSpan(
- text: '',
- style: style,
+
+ if (widget.selectable) {
+ return SelectableText.rich(
+ TextSpan(
+ style: widget.style,
+ children: _getTextSpans(),
+ ),
+ textAlign: widget.textAlign,
+ textDirection: widget.textDirection,
+ // ignore: deprecated_member_use
+ textScaleFactor: widget.textScaleFactor,
+ textScaler: widget.textScaler,
+ maxLines: widget.maxLines,
+ strutStyle: widget.strutStyle,
+ textWidthBasis: widget.textWidthBasis,
+ textHeightBehavior: widget.textHeightBehavior,
+ );
+ }
+
+ return RichText(
+ textAlign: widget.textAlign,
+ textDirection: widget.textDirection,
+ // ignore: deprecated_member_use
+ textScaleFactor: widget.textScaleFactor,
+ textScaler: widget.textScaler,
+ maxLines: widget.maxLines,
+ strutStyle: widget.strutStyle,
+ textWidthBasis: widget.textWidthBasis,
+ textHeightBehavior: widget.textHeightBehavior,
+ text: TextSpan(
+ style: widget.style,
children: _getTextSpans(),
),
- textAlign: textAlign,
- textDirection: textDirection,
- textScaleFactor: textScaleFactor,
- maxLines: maxLines,
- strutStyle: strutStyle,
- textWidthBasis: textWidthBasis,
- textHeightBehavior: textHeightBehavior,
);
}
- _getTextSpans() {
- List textSpans = [];
+ List _getTextSpans() {
+ final textSpans = [];
int i = 0;
int pos = 0;
- while (i < text.length) {
- textSpans.add(_text(text.substring(
+ while (i < widget.text.length) {
+ textSpans.add(_text(widget.text.substring(
i,
pos < _links.length && i <= _links[pos].regExpMatch.start
? _links[pos].regExpMatch.start
- : text.length)));
+ : widget.text.length)));
if (pos < _links.length && i <= _links[pos].regExpMatch.start) {
textSpans.add(_link(
- text.substring(
+ widget.text.substring(
_links[pos].regExpMatch.start, _links[pos].regExpMatch.end),
_links[pos].type));
i = _links[pos].regExpMatch.end;
pos++;
} else {
- i = text.length;
+ i = widget.text.length;
}
}
return textSpans;
}
- _text(String text) {
- return TextSpan(text: text, style: TextStyle(color: textColor));
+ TextSpan _text(String text) {
+ return TextSpan(text: text, style: widget.style);
}
- _link(String text, String type) {
+ TextSpan _link(String text, String type) {
+ if (widget.mobileSpan != null && type == tel) {
+ return widget.mobileSpan!(
+ text,
+ TapGestureRecognizer()..onTap = () => _onTap(text, type),
+ );
+ }
+
return TextSpan(
- text: text,
- style: TextStyle(color: linkColor),
- recognizer: TapGestureRecognizer()
- ..onTap = () {
- _launch(_getUrl(text, type));
- });
+ text: text,
+ style: (widget.style?.merge(widget.linkStyle)) ?? widget.linkStyle,
+ recognizer: TapGestureRecognizer()..onTap = () => _onTap(text, type),
+ );
}
- _launch(String url) async {
- if (!await launchUrl(Uri.parse(url),
- mode: LaunchMode.externalApplication)) {
- throw Exception('Could not launch $url');
+ void _onTap(String text, String type) {
+ switch (type) {
+ case http:
+ return widget.onLinkTap != null
+ ? widget.onLinkTap!(text)
+ : _launch(_getUrl(text, type));
+ case email:
+ return widget.onEmailTap != null
+ ? widget.onEmailTap!(text)
+ : _launch(_getUrl(text, type));
+ case tel:
+ return widget.onTelephoneTap != null
+ ? widget.onTelephoneTap!(text)
+ : _launch(_getUrl(text, type));
+ default:
+ return _launch(_getUrl(text, type));
+ }
+ }
+
+ void _launch(String url) async {
+ if (await canLaunchUrlString(url)) {
+ await launchUrlString(url);
+ } else {
+ throw 'Could not launch $url';
}
}
- _getUrl(String text, String type) {
+ String _getUrl(String text, String type) {
switch (type) {
case http:
return text.substring(0, 4) == 'http' ? text : 'http://$text';
@@ -128,29 +235,90 @@ class Linkable extends StatelessWidget {
}
}
- init() {
+ void init() {
_addParsers();
_parseLinks();
_filterLinks();
}
- _addParsers() {
- _parsers.add(EmailParser(text));
- _parsers.add(HttpParser(text));
- _parsers.add(TelParser(text));
+ void _addParsers() {
+ for (final parserType in widget.parsersPriorityOrder) {
+ switch (parserType) {
+ case tel:
+ if (widget.explicitPhoneNumbers) {
+ /// If explicitPhoneNumbers is true, then TelParser is not need because
+ /// phone number matches are provided by [phoneNumberMatches]
+ break;
+ }
+ _parsers.add(
+ TelParser(
+ widget.text,
+ regExpPattern: widget.mobileRegExp,
+ ),
+ );
+ break;
+ case email:
+ _parsers.add(EmailParser(widget.text));
+ break;
+ case http:
+ _parsers.add(HttpParser(widget.text));
+ break;
+ }
+ }
+
+ _parsers.add(HttpParser(widget.text));
}
- _parseLinks() {
- for (Parser parser in _parsers) {
- _links.addAll(parser.parse().toList());
+ void _parseLinks() {
+ for (final parserName in widget.parsersPriorityOrder) {
+ if (parserName == tel && widget.explicitPhoneNumbers) {
+ _parseExplicitPhoneNumbers();
+ } else {
+ _links.addAll(_getParserByName(parserName).parse().toList());
+ }
+ }
+
+ if (widget.explicitPhoneNumbers) {
+ for (final matchStr in widget.phoneNumberMatches) {
+ final match = RegExp(RegExp.escape(matchStr)).firstMatch(widget.text);
+
+ if (match != null) {
+ _links.add(Link(regExpMatch: match, type: tel));
+ }
+ }
+ }
+ }
+
+ Parser _getParserByName(String name) {
+ switch (name) {
+ case tel:
+ return _parsers.firstWhere((parser) => parser is TelParser);
+ case email:
+ return _parsers.firstWhere((parser) => parser is EmailParser);
+ case http:
+ return _parsers.firstWhere((parser) => parser is HttpParser);
+ default:
+ throw Exception('Parser name $name not found');
+ }
+ }
+
+ void _parseExplicitPhoneNumbers() {
+ for (final matchStr in widget.phoneNumberMatches) {
+ final matches = RegExp(RegExp.escape(matchStr)).allMatches(widget.text);
+
+ if (matches.isNotEmpty) {
+ for (final match in matches) {
+ _links.add(Link(regExpMatch: match, type: tel));
+ }
+ }
}
}
- _filterLinks() {
- _links.sort(
- (Link a, Link b) => a.regExpMatch.start.compareTo(b.regExpMatch.start));
+ void _filterLinks() {
+ _links.sort((a, b) => a.regExpMatch.start.compareTo(b.regExpMatch.start));
+
+ final filteredLinks = [];
- List filteredLinks = [];
if (_links.isNotEmpty) {
filteredLinks.add(_links[0]);
}
@@ -160,7 +328,9 @@ class Linkable extends StatelessWidget {
filteredLinks.add(_links[i + 1]);
}
}
- _links.clear();
- _links.addAll(filteredLinks);
+
+ _links
+ ..clear()
+ ..addAll(filteredLinks);
}
}
diff --git a/lib/parser.dart b/lib/parser.dart
index d6c2606..a419cd8 100644
--- a/lib/parser.dart
+++ b/lib/parser.dart
@@ -1,7 +1,5 @@
import 'package:linkable/link.dart';
class Parser {
- List parse() {
- return [];
- }
+ List parse() => [];
}
diff --git a/lib/tel_parser.dart b/lib/tel_parser.dart
index bb5449d..2f4203b 100644
--- a/lib/tel_parser.dart
+++ b/lib/tel_parser.dart
@@ -3,21 +3,27 @@ import 'package:linkable/link.dart';
import 'package:linkable/parser.dart';
class TelParser implements Parser {
- String text;
+ const TelParser(
+ this.text, {
+ this.regExpPattern,
+ });
- TelParser(this.text);
+ final String text;
+ final String? regExpPattern;
@override
- parse() {
- String pattern = r"\+?\(?([0-9]{2,4})\)?[- ]?([0-9]{3,4})[- ]?([0-9]{3,7})";
+ List parse() {
+ const pattern =
+ r"\+?\(?([0-9]{2,4})\)?[- .]?([0-9]{3,4})[- .]?([0-9]{3,7})";
- RegExp regExp = RegExp(pattern);
+ final regExp = RegExp(regExpPattern ?? pattern);
+ final allMatches = regExp.allMatches(text);
+ final links = [];
- Iterable allMatches = regExp.allMatches(text);
- List links = [];
- for (RegExpMatch match in allMatches) {
+ for (final match in allMatches) {
links.add(Link(regExpMatch: match, type: tel));
}
+
return links;
}
}