From cfc371cd81053c07749f65cea4d389bb2c327403 Mon Sep 17 00:00:00 2001 From: Alejandro Ulate Date: Sun, 6 Jun 2021 11:02:23 -0600 Subject: [PATCH] feat: :sparkles: adds autolink and nullsafety Null Safety --- README.md | 20 +- .../contents.xcworkspacedata | 2 +- example/lib/main.dart | 68 ++++++- example/pubspec.lock | 42 ++-- lib/material/password_textfield.dart | 18 +- lib/shared/autolink_text.dart | 183 ++++++++++++++++++ .../hide_keyboard_on_touch_outside.dart | 2 +- lib/shared/widgetkit.dart | 1 + pubspec.lock | 40 ++-- pubspec.yaml | 6 +- 10 files changed, 316 insertions(+), 66 deletions(-) create mode 100644 lib/shared/autolink_text.dart diff --git a/README.md b/README.md index e353363..3a59aac 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # WidgetKit -A kit of widgets that are (almost) always needed in the different apps. +A kit of widgets that are (almost) always needed in the different Flutter apps. -## List of widgets +## List of Widgets -- Shared: Widgets that can be used globally, regardless of which app you are based off(Material or Cupertino). -- Material: Widgets for MaterialApp based apps or for the Material look and feel. -- (WIP)Cupertino: Widgets for CupertinoApp based apps or for the Cupertino(iOS) look and feel. +- [Shared](#shared): Widgets that can be used globally, regardless of which app you are based off(Material or Cupertino). +- [Material](#material): Widgets for MaterialApp based apps or for the Material look and feel. +- [Cupertino](#cupertino) **(Coming Soon!)**: Widgets for CupertinoApp based apps or for the Cupertino(iOS) look and feel. ### Shared @@ -14,8 +14,16 @@ A kit of widgets that are (almost) always needed in the different apps. A widget that you can use to wrap other widgets (like a Scaffold or a Form) that usually contain inputs, this will help hide the keyboard when touching outside. +#### AutolinkText + +A text widget, that turns URLs, email and phone numbers into clickable inline links in text for flutter. A null safe version of FogNature's [AutolinkText](https://github.com/FogNature/flutter_autolink_text). + ### Material #### Password TextField -A widget that allows you to show or hide the password already embedded. \ No newline at end of file +A widget that allows you to show or hide the password already embedded. + +### Cupertino + +#### Coming Soon diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1d526a1..919434a 100644 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/example/lib/main.dart b/example/lib/main.dart index b750de6..731ef67 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,16 +7,15 @@ void main() { } class MyApp extends StatelessWidget { - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', + title: 'Widgetkit Demo', theme: ThemeData( - primarySwatch: Colors.blue, + primarySwatch: Colors.blueGrey, visualDensity: VisualDensity.adaptivePlatformDensity, ), - home: MyHomePage(title: 'Flutter Demo Home Page'), + home: MyHomePage(title: 'Widgetkit Demo'), ); } } @@ -42,6 +41,11 @@ class _MyHomePageState extends State { padding: const EdgeInsets.all(8.0), child: ListView( children: [ + Text( + "PasswordTextField", + style: Theme.of(context).textTheme.headline6, + ), + SizedBox(height: 16), PasswordTextField(), SizedBox(height: 16), PasswordTextField( @@ -49,10 +53,64 @@ class _MyHomePageState extends State { border: OutlineInputBorder(), ), ), + SizedBox(height: 32), + Text( + "CupertinoPasswordTextField", + style: Theme.of(context).textTheme.headline6, + ), SizedBox(height: 16), CupertinoTextField( obscureText: true, - suffix: Icon(CupertinoIcons.eye), + suffix: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Icon(CupertinoIcons.eye), + ), + ), + SizedBox(height: 32), + Text( + "AutolinkText", + style: Theme.of(context).textTheme.headline6, + ), + SizedBox(height: 16), + AutolinkText( + "Hello world from https://www.flutter.dev/", + textStyle: TextStyle(color: Colors.black), + linkStyle: TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + onWebLinkTap: (String url) => print(url), + ), + SizedBox(height: 16), + AutolinkText( + "Humanized (removes scheme) https://www.flutter.dev/", + humanize: true, + textStyle: TextStyle(color: Colors.black), + linkStyle: TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + onWebLinkTap: (String url) => print(url), + ), + SizedBox(height: 16), + AutolinkText( + "Autolink email me@codingale.dev", + textStyle: TextStyle(color: Colors.black), + linkStyle: TextStyle( + color: Colors.green, + decoration: TextDecoration.underline, + ), + onEmailTap: (String url) => print(url), + ), + SizedBox(height: 16), + AutolinkText( + "Autolink phone +50688884444", + textStyle: TextStyle(color: Colors.black), + linkStyle: TextStyle( + color: Colors.orange, + decoration: TextDecoration.underline, + ), + onPhoneTap: (String url) => print(url), ), ], ), diff --git a/example/pubspec.lock b/example/pubspec.lock index 18f401a..cf58199 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -7,42 +7,42 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.5.0-nullsafety.1" + version: "2.5.0" boolean_selector: dependency: transitive description: name: boolean_selector url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.1" + version: "2.1.0" characters: dependency: transitive description: name: characters url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety.3" + version: "1.1.0" charcode: dependency: transitive description: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" clock: dependency: transitive description: name: clock url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety.1" + version: "1.1.0" collection: dependency: transitive description: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.15.0-nullsafety.3" + version: "1.15.0" cupertino_icons: dependency: "direct main" description: @@ -56,7 +56,7 @@ packages: name: fake_async url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" flutter: dependency: "direct main" description: flutter @@ -73,21 +73,21 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.10-nullsafety.1" + version: "0.12.10" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.3.0-nullsafety.3" + version: "1.3.0" path: dependency: transitive description: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.8.0-nullsafety.1" + version: "1.8.0" sky_engine: dependency: transitive description: flutter @@ -99,63 +99,63 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.0-nullsafety.2" + version: "1.8.0" stack_trace: dependency: transitive description: name: stack_trace url: "https://pub.dartlang.org" source: hosted - version: "1.10.0-nullsafety.1" + version: "1.10.0" stream_channel: dependency: transitive description: name: stream_channel url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.1" + version: "2.1.0" string_scanner: dependency: transitive description: name: string_scanner url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety.1" + version: "1.1.0" term_glyph: dependency: transitive description: name: term_glyph url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.19-nullsafety.2" + version: "0.2.19" typed_data: dependency: transitive description: name: typed_data url: "https://pub.dartlang.org" source: hosted - version: "1.3.0-nullsafety.3" + version: "1.3.0" vector_math: dependency: transitive description: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.3" + version: "2.1.0" widgetkit: dependency: "direct main" description: path: ".." relative: true source: path - version: "0.0.1" + version: "0.2.0" sdks: - dart: ">=2.10.0-110 <2.11.0" - flutter: ">=1.17.0 <2.0.0" + dart: ">=2.12.0 <3.0.0" + flutter: ">=1.17.0" diff --git a/lib/material/password_textfield.dart b/lib/material/password_textfield.dart index 6c7f8c0..1ea85c7 100644 --- a/lib/material/password_textfield.dart +++ b/lib/material/password_textfield.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; class PasswordTextField extends StatefulWidget { - final Key key; - final TextEditingController controller; - final InputDecoration decoration; - final Function(String) validator; - final bool enabled; - final AutovalidateMode autovalidateMode; + final Key? key; + final TextEditingController? controller; + final InputDecoration? decoration; + final String? Function(String?)? validator; + final bool? enabled; + final AutovalidateMode? autovalidateMode; final bool autocorrect; - final void Function(String) onChanged; + final void Function(String)? onChanged; PasswordTextField({ this.key, @@ -26,7 +26,7 @@ class PasswordTextField extends StatefulWidget { } class _PasswordTextFieldState extends State { - bool _passwordHidden; + late bool _passwordHidden; @override void initState() { @@ -67,7 +67,7 @@ class _PasswordTextFieldState extends State { ), ); } else { - decoration = widget.decoration.copyWith( + decoration = widget.decoration!.copyWith( suffixIcon: IconButton( icon: Icon( _passwordHidden ? Icons.visibility : Icons.visibility_off, diff --git a/lib/shared/autolink_text.dart b/lib/shared/autolink_text.dart new file mode 100644 index 0000000..31cc589 --- /dev/null +++ b/lib/shared/autolink_text.dart @@ -0,0 +1,183 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter/gestures.dart'; + +typedef VoidArgumentedCallback = void Function(String); + +RegExp _phoneRegExp = RegExp(r"[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*"); +RegExp _emailRegExp = RegExp( + r"[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*"); +RegExp _linksRegExp = RegExp( + r"(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})"); + +class AutolinkText extends StatelessWidget { + final String text; + final VoidArgumentedCallback? onWebLinkTap, onPhoneTap, onEmailTap; + final TextStyle? textStyle, linkStyle; + final bool humanize; + + AutolinkText( + this.text, { + Key? key, + this.textStyle, + this.linkStyle, + this.onWebLinkTap, + this.onEmailTap, + this.onPhoneTap, + this.humanize = false, + }) : super(key: key); + + void _onLinkTap(String link, _MatchType type) { + switch (type) { + case _MatchType.phone: + onPhoneTap!(link); + break; + case _MatchType.email: + onEmailTap!(link); + break; + case _MatchType.link: + onWebLinkTap!(link); + break; + case _MatchType.none: + break; + } + } + + String _getTypes() { + String types = ''; + if (onWebLinkTap != null) types += 'web'; + if (onEmailTap != null) types += 'email'; + if (onPhoneTap != null) types += 'phone'; + return types; + } + + List _buildTextSpans() { + return _findMatches(text, _getTypes(), humanize).map((match) { + if (match.type == _MatchType.none) + return TextSpan( + text: match.text, + style: textStyle, + ); + final recognizer = TapGestureRecognizer(); + recognizer.onTap = () => _onLinkTap(match.text, match.type); + return TextSpan( + text: match.text, + style: linkStyle, + recognizer: recognizer, + ); + }).toList(); + } + + @override + Widget build(BuildContext context) { + return RichText( + text: TextSpan(children: _buildTextSpans()), + ); + } +} + +enum _MatchType { phone, email, link, none } + +class _MatchedString { + final _MatchType type; + final String text; + + _MatchedString({ + required this.text, + required this.type, + }); + + @override + String toString() { + return text; + } +} + +List<_MatchedString> _findMatches(String text, String types, bool humanize) { + List<_MatchedString> matched = [ + _MatchedString(type: _MatchType.none, text: text) + ]; + + if (types.contains('phone')) { + List<_MatchedString> newMatched = []; + for (_MatchedString matchedBefore in matched) { + if (matchedBefore.type == _MatchType.none) { + newMatched + .addAll(_findLinksByType(matchedBefore.text, _MatchType.phone)); + } else + newMatched.add(matchedBefore); + } + matched = newMatched; + } + + if (types.contains('email')) { + List<_MatchedString> newMatched = []; + for (_MatchedString matchedBefore in matched) { + if (matchedBefore.type == _MatchType.none) { + newMatched + .addAll(_findLinksByType(matchedBefore.text, _MatchType.email)); + } else + newMatched.add(matchedBefore); + } + matched = newMatched; + } + + if (types.contains('web')) { + List<_MatchedString> newMatched = []; + for (_MatchedString matchedBefore in matched) { + if (matchedBefore.type == _MatchType.none) { + final webMatches = + _findLinksByType(matchedBefore.text, _MatchType.link); + for (_MatchedString webMatch in webMatches) { + if (webMatch.type == _MatchType.link && + (webMatch.text.startsWith('http://') || + webMatch.text.startsWith('https://')) && + humanize) { + newMatched.add(_MatchedString( + text: webMatch.text + .substring(webMatch.text.startsWith('http://') ? 7 : 8), + type: _MatchType.link)); + } else { + newMatched.add(webMatch); + } + } + } else + newMatched.add(matchedBefore); + } + matched = newMatched; + } + + return matched; +} + +RegExp? _getRegExpByType(_MatchType type) { + switch (type) { + case _MatchType.phone: + return _phoneRegExp; + case _MatchType.email: + return _emailRegExp; + case _MatchType.link: + return _linksRegExp; + default: + return null; + } +} + +List<_MatchedString> _findLinksByType(String text, _MatchType type) { + List<_MatchedString> output = []; + final matches = _getRegExpByType(type)!.allMatches(text); + int endOfMatch = 0; + for (Match match in matches) { + final before = text.substring(endOfMatch, match.start); + if (before.isNotEmpty) + output.add(_MatchedString(text: before, type: _MatchType.none)); + final lastCharacterIndex = + text[match.end - 1] == ' ' ? match.end - 1 : match.end; + output.add(_MatchedString( + type: type, text: text.substring(match.start, lastCharacterIndex))); + endOfMatch = lastCharacterIndex; + } + final endOfText = text.substring(endOfMatch); + if (endOfText.isNotEmpty) + output.add(_MatchedString(text: endOfText, type: _MatchType.none)); + return output; +} diff --git a/lib/shared/hide_keyboard_on_touch_outside.dart b/lib/shared/hide_keyboard_on_touch_outside.dart index ba226c9..53f7f00 100644 --- a/lib/shared/hide_keyboard_on_touch_outside.dart +++ b/lib/shared/hide_keyboard_on_touch_outside.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; class HideKeyboardOnTouchOutside extends StatelessWidget { final Widget child; - HideKeyboardOnTouchOutside({@required this.child}); + HideKeyboardOnTouchOutside({required this.child}); @override Widget build(BuildContext context) { diff --git a/lib/shared/widgetkit.dart b/lib/shared/widgetkit.dart index 715116a..fe6cd35 100644 --- a/lib/shared/widgetkit.dart +++ b/lib/shared/widgetkit.dart @@ -1 +1,2 @@ export 'hide_keyboard_on_touch_outside.dart'; +export 'autolink_text.dart'; diff --git a/pubspec.lock b/pubspec.lock index 58937f9..b55f540 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,49 +7,49 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.5.0-nullsafety.1" + version: "2.5.0" boolean_selector: dependency: transitive description: name: boolean_selector url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.1" + version: "2.1.0" characters: dependency: transitive description: name: characters url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety.3" + version: "1.1.0" charcode: dependency: transitive description: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" clock: dependency: transitive description: name: clock url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety.1" + version: "1.1.0" collection: dependency: transitive description: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.15.0-nullsafety.3" + version: "1.15.0" fake_async: dependency: transitive description: name: fake_async url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" flutter: dependency: "direct main" description: flutter @@ -66,21 +66,21 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.10-nullsafety.1" + version: "0.12.10" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.3.0-nullsafety.3" + version: "1.3.0" path: dependency: transitive description: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.8.0-nullsafety.1" + version: "1.8.0" sky_engine: dependency: transitive description: flutter @@ -92,56 +92,56 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.0-nullsafety.2" + version: "1.8.0" stack_trace: dependency: transitive description: name: stack_trace url: "https://pub.dartlang.org" source: hosted - version: "1.10.0-nullsafety.1" + version: "1.10.0" stream_channel: dependency: transitive description: name: stream_channel url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.1" + version: "2.1.0" string_scanner: dependency: transitive description: name: string_scanner url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety.1" + version: "1.1.0" term_glyph: dependency: transitive description: name: term_glyph url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.19-nullsafety.2" + version: "0.2.19" typed_data: dependency: transitive description: name: typed_data url: "https://pub.dartlang.org" source: hosted - version: "1.3.0-nullsafety.3" + version: "1.3.0" vector_math: dependency: transitive description: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.3" + version: "2.1.0" sdks: - dart: ">=2.10.0-110 <2.11.0" - flutter: ">=1.17.0 <2.0.0" + dart: ">=2.12.0 <3.0.0" + flutter: ">=1.17.0" diff --git a/pubspec.yaml b/pubspec.yaml index 4aa49fa..1537c4a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,11 @@ name: widgetkit description: A kit of widgets that are (almost) always needed in the different apps. -version: 0.0.1 +version: 0.2.0 author: homepage: environment: - sdk: ">=2.7.0 <3.0.0" + sdk: '>=2.12.0 <3.0.0' flutter: ">=1.17.0 <2.0.0" dependencies: @@ -16,4 +16,4 @@ dev_dependencies: flutter_test: sdk: flutter -flutter: \ No newline at end of file +flutter: