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; } }