diff --git a/README.md b/README.md index d35c41a..03f1d5c 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,10 @@ AutocompleteTrigger( // "Hello @l" -> Shows zero suggestions. // "Hello @lu" -> Shows suggestions for @lu. minimumRequiredCharacters: 2, + + // The pattern accepted by [trigger] to recognize in the + // input text + pattern: RegExp(r'^[\w.]*$'), // The options view builder is used to build the options view // that will be shown when the [trigger] is detected. diff --git a/example/android/build.gradle b/example/android/build.gradle index 83ae220..3cdaac9 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/example/lib/main.dart b/example/lib/main.dart index 8fe662b..64ef0da 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -94,6 +94,7 @@ class _MyHomePageState extends State { ), AutocompleteTrigger( trigger: ':', + pattern: RegExp(r'^[\w:]*$'), optionsViewBuilder: (context, autocompleteQuery, controller) { return EmojiAutocompleteOptions( query: autocompleteQuery.query, diff --git a/example/lib/src/options/hashtag_autocomplete_options.dart b/example/lib/src/options/hashtag_autocomplete_options.dart index 791bd6b..3193c88 100644 --- a/example/lib/src/options/hashtag_autocomplete_options.dart +++ b/example/lib/src/options/hashtag_autocomplete_options.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:example/src/models.dart'; -class HashtagAutocompleteOptions extends StatelessWidget { +class HashtagAutocompleteOptions extends StatefulWidget { const HashtagAutocompleteOptions({ Key? key, required this.query, @@ -14,15 +14,49 @@ class HashtagAutocompleteOptions extends StatelessWidget { final ValueSetter onHashtagTap; @override - Widget build(BuildContext context) { - final hashtags = kHashtags.where((it) { + State createState() => + _HashtagAutocompleteOptionsState(); +} + +class _HashtagAutocompleteOptionsState + extends State { + bool _isLoading = false; + Iterable _items = List.empty(); + + @override + void initState() { + super.initState(); + + _search(); + } + + @override + void didUpdateWidget(HashtagAutocompleteOptions oldWidget) { + super.didUpdateWidget(oldWidget); + + _search(); + } + + Future _search() async { + setState(() { + _isLoading = true; + }); + + await Future.delayed(const Duration(milliseconds: 500)); + + _items = kHashtags.where((it) { final normalizedOption = it.name.toLowerCase(); - final normalizedQuery = query.toLowerCase(); + final normalizedQuery = widget.query.toLowerCase(); return normalizedOption.contains(normalizedQuery); }); - if (hashtags.isEmpty) return const SizedBox.shrink(); + _isLoading = false; + setState(() {}); + } + + @override + Widget build(BuildContext context) { return Card( margin: const EdgeInsets.all(8), elevation: 2, @@ -38,41 +72,58 @@ class HashtagAutocompleteOptions extends StatelessWidget { child: ListTile( dense: true, horizontalTitleGap: 0, - title: Text("Hashtags matching '$query'"), + title: Text("Hashtags matching '${widget.query}'"), ), ), const Divider(height: 0), LimitedBox( maxHeight: MediaQuery.of(context).size.height * 0.3, - child: ListView.separated( - padding: EdgeInsets.zero, - shrinkWrap: true, - itemCount: hashtags.length, - separatorBuilder: (_, __) => const Divider(height: 0), - itemBuilder: (context, i) { - final hashtag = hashtags.elementAt(i); - return ListTile( - dense: true, - leading: CircleAvatar( - backgroundColor: const Color(0xFFF7F7F8), - backgroundImage: NetworkImage( - hashtag.image, - scale: 0.5, - ), - ), - title: Text('#${hashtag.name}'), - subtitle: Text( - hashtag.description, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: () => onHashtagTap(hashtag), - ); - }, - ), + child: (_isLoading) ? _buildLoader() : _buildList(), ), ], ), ); } + + Widget _buildLoader() { + return const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ); + } + + Widget _buildList() { + return ListView.separated( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 0), + itemBuilder: (context, i) { + final hashtag = _items.elementAt(i); + return ListTile( + dense: true, + leading: CircleAvatar( + backgroundColor: const Color(0xFFF7F7F8), + backgroundImage: NetworkImage( + hashtag.image, + scale: 0.5, + ), + ), + title: Text('#${hashtag.name}'), + subtitle: Text( + hashtag.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => widget.onHashtagTap(hashtag), + ); + }, + ); + } } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ad04fcf..b78bb06 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 flutter_parsed_text: ^2.2.1 - google_fonts: ^3.0.1 + google_fonts: ^4.0.4 multi_trigger_autocomplete: path: ../ diff --git a/lib/src/autocomplete_trigger.dart b/lib/src/autocomplete_trigger.dart index 3450687..d045eed 100644 --- a/lib/src/autocomplete_trigger.dart +++ b/lib/src/autocomplete_trigger.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:multi_trigger_autocomplete/src/autocomplete_query.dart'; @@ -12,9 +14,10 @@ typedef AutocompleteTriggerOptionsViewBuilder = Widget Function( class AutocompleteTrigger { /// Creates a [AutocompleteTrigger] which can be used to trigger /// autocomplete suggestions. - const AutocompleteTrigger({ + AutocompleteTrigger({ required this.trigger, required this.optionsViewBuilder, + this.pattern, this.triggerOnlyAtStart = false, this.triggerOnlyAfterSpace = true, this.minimumRequiredCharacters = 0, @@ -28,6 +31,9 @@ class AutocompleteTrigger { /// Whether the [trigger] should only be recognised at the start of the input. final bool triggerOnlyAtStart; + /// The pattern accepted by [trigger] to recognize autocomplete options + RegExp? pattern; + /// Whether the [trigger] should only be recognised after a space. final bool triggerOnlyAfterSpace; @@ -61,6 +67,9 @@ class AutocompleteTrigger { /// Checks if the user is invoking the recognising [trigger] and returns /// the autocomplete query if so. AutocompleteQuery? invokingTrigger(TextEditingValue textEditingValue) { + // If the pattern is not defined, the default is set + pattern ??= RegExp(r'^[\w.]*$'); + final text = textEditingValue.text; final cursorPosition = textEditingValue.selection.baseOffset; @@ -81,9 +90,11 @@ class AutocompleteTrigger { // valid examples: "@user", "Hello @user" // invalid examples: "Hello@user" final textBeforeTrigger = text.substring(0, firstTriggerIndexBeforeCursor); + final lastCharBeforeTrigger = + textBeforeTrigger.substring(max(textBeforeTrigger.length - 1, 0)); if (triggerOnlyAfterSpace && textBeforeTrigger.isNotEmpty && - !textBeforeTrigger.endsWith(' ')) { + !(lastCharBeforeTrigger == ' ' || lastCharBeforeTrigger == '\n')) { return null; } @@ -96,7 +107,7 @@ class AutocompleteTrigger { // valid example: "@luke_skywa..." // invalid example: "@luke skywa..." final suggestionText = text.substring(suggestionStart, suggestionEnd); - if (suggestionText.contains(' ')) return null; + if (!pattern!.hasMatch(suggestionText)) return null; // A minimum number of characters can be provided to only show // suggestions after the customer has input enough characters.