diff --git a/CHANGELOG.md b/CHANGELOG.md index e1cc479..59bba6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,4 +47,10 @@ ## 2.2.1 -* Documentation update. \ No newline at end of file +* Documentation update. + +## 2.3.0 + +* Updates overlay implementation to use `OverlayPortal` which brings in the fix that lets the overlay reposition itself when the keyboard is dismissed. + +* Introduces `TriggerStrategy` to indicate how immediate the search callback should be invoked when a search trigger character is detected. \ No newline at end of file diff --git a/README.md b/README.md index 9c6fc5f..b78a944 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ In the `pubspec.yaml` of your flutter project, add the following dependency: ```yaml dependencies: - fluttertagger: ^2.2.1 + fluttertagger: ^2.3.0 ``` ## Import the package in your project 📥 @@ -43,6 +43,12 @@ FlutterTagger( '@': TextStyle(color: Colors.pinkAccent), '#': TextStyle(color: Colors.blueAccent), }, + //this will cause the onSearch callback to be invoked + //immediately a trigger character is detected. + //The default behaviour defers the onSearch invocation + //until a searchable character has been entered after + //the trigger character. + triggerStrategy: TriggerStrategy.eager, overlay: SearchResultView(), builder: (context, textFieldKey) { //return a TextField and pass it `textFieldKey` diff --git a/example/lib/main.dart b/example/lib/main.dart index 417155e..2572dd4 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -57,7 +57,6 @@ class _HomeViewState extends State with TickerProviderStateMixin { @override void initState() { super.initState(); - // _focusNode.addListener(_focusListener); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 150), @@ -86,16 +85,14 @@ class _HomeViewState extends State with TickerProviderStateMixin { Widget build(BuildContext context) { var insets = MediaQuery.of(context).viewInsets; return GestureDetector( - onTap: () { - _focusNode.unfocus(); - // _controller.dismissOverlay(); - }, + onTap: _focusNode.unfocus, child: Scaffold( appBar: AppBar( backgroundColor: Colors.redAccent, title: const Text("The Squad"), ), bottomNavigationBar: FlutterTagger( + triggerStrategy: TriggerStrategy.eager, controller: _controller, animationController: _animationController, onSearch: (query, triggerChar) { diff --git a/example/lib/views/view_models/search_view_model.dart b/example/lib/views/view_models/search_view_model.dart index 041e6ff..68932e7 100644 --- a/example/lib/views/view_models/search_view_model.dart +++ b/example/lib/views/view_models/search_view_model.dart @@ -27,9 +27,8 @@ class SearchViewModel { } Future searchUser(String query) async { - if (query.isEmpty) return; - _activeView.value = SearchResultView.users; + if (query.isEmpty) return; query = query.toLowerCase().trim(); @@ -52,9 +51,8 @@ class SearchViewModel { } Future searchHashtag(String query) async { - if (query.isEmpty) return; - _activeView.value = SearchResultView.hashtag; + if (query.isEmpty) return; query = query.toLowerCase().trim(); diff --git a/lib/src/tagger.dart b/lib/src/tagger.dart index 2572dcd..d84cad0 100644 --- a/lib/src/tagger.dart +++ b/lib/src/tagger.dart @@ -25,6 +25,18 @@ typedef FlutterTaggerSearchCallback = void Function( /// Indicates where the overlay should be positioned. enum OverlayPosition { top, bottom } +/// {@template triggerStrategy} +/// Strategy to adopt when a trigger character is detected. +/// +/// [eager] will cause the search callback to be invoked immediately +/// a trigger character is detected. +/// +/// [deferred] will cause the search callback to be invoked only when +/// a searchable character is entered after the trigger +/// character is detected. This is the default. +/// {@endtemplate} +enum TriggerStrategy { eager, deferred } + /// Provides tagging capabilities (e.g user mentions and adding hashtags) /// to a [TextField] returned from [builder]. /// @@ -70,6 +82,7 @@ class FlutterTagger extends StatefulWidget { this.overlayHeight = 380, this.triggerCharacterAndStyles = const {}, this.overlayPosition = OverlayPosition.top, + this.triggerStrategy = TriggerStrategy.deferred, this.onFormattedTextChanged, this.searchRegex, this.triggerCharactersRegex, @@ -143,6 +156,9 @@ class FlutterTagger extends StatefulWidget { /// Position of [overlay] relative to the [TextField]. final OverlayPosition overlayPosition; + /// {@macro triggerStrategy} + final TriggerStrategy triggerStrategy; + @override State createState() => _FlutterTaggerState(); } @@ -354,10 +370,11 @@ class _FlutterTaggerState extends State { int selectionOffset = 0; if (position != text.length - 1) { - index = text.substring(0, position).lastIndexOf(_currentTriggerChar); + index = text.substring(0, position + 1).lastIndexOf(_currentTriggerChar); } else { index = text.lastIndexOf(_currentTriggerChar); } + if (index >= 0) { _defer = true; @@ -655,6 +672,9 @@ class _FlutterTaggerState extends State { if (position >= 0 && triggerCharacters.contains(text[position])) { _shouldSearch = true; _currentTriggerChar = text[position]; + if (widget.triggerStrategy == TriggerStrategy.eager) { + _extractAndSearch(text, position); + } } return; } @@ -693,6 +713,9 @@ class _FlutterTaggerState extends State { if (position >= 0 && triggerCharacters.contains(text[position])) { _shouldSearch = true; _currentTriggerChar = text[position]; + if (widget.triggerStrategy == TriggerStrategy.eager) { + _extractAndSearch(text, position); + } } _recomputeTags(oldCachedText, text, position); _onFormattedTextChanged(); @@ -722,6 +745,9 @@ class _FlutterTaggerState extends State { if (position >= 0 && triggerCharacters.contains(text[position])) { _shouldSearch = true; _currentTriggerChar = text[position]; + if (widget.triggerStrategy == TriggerStrategy.eager) { + _extractAndSearch(text, position); + } _recomputeTags(oldCachedText, text, position); _onFormattedTextChanged(); return; @@ -775,7 +801,8 @@ class _FlutterTaggerState extends State { /// and calls [FlutterTagger.onSearch]. void _extractAndSearch(String text, int endOffset) { try { - int index = text.substring(0, endOffset).lastIndexOf(_currentTriggerChar); + int index = + text.substring(0, endOffset + 1).lastIndexOf(_currentTriggerChar); if (index < 0) return; diff --git a/pubspec.yaml b/pubspec.yaml index ee8800c..3d93cb6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fluttertagger description: A Flutter package that allows for the extension of TextFields to provide tagging capabilities (user mentions, hashtags, etc). -version: 2.2.1 +version: 2.3.0 repository: https://github.com/Crazelu/fluttertagger issue_tracker: https://github.com/Crazelu/fluttertagger/issues topics: [user, mention, hashtag, tagging] diff --git a/test/fluttertagger_test.dart b/test/fluttertagger_test.dart index 3942d78..3f951fd 100644 --- a/test/fluttertagger_test.dart +++ b/test/fluttertagger_test.dart @@ -3,44 +3,51 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttertagger/fluttertagger.dart'; +Widget _buildTestWidget({ + required FlutterTaggerController controller, + Function(String query, String triggerCharacter)? onSearch, + TriggerStrategy triggerStrategy = TriggerStrategy.deferred, +}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: FlutterTagger( + triggerStrategy: triggerStrategy, + overlay: Container( + height: 100, + color: Colors.grey, + child: const Center(child: Text('Overlay')), + ), + controller: controller, + onSearch: onSearch ?? (query, triggerCharacter) {}, + builder: (context, key) { + return TextField( + key: key, + controller: controller, + ); + }, + triggerCharacterAndStyles: const { + '@': TextStyle(color: Colors.blue), + '#': TextStyle(color: Colors.green), + }, + ), + ), + ), + ); +} + void main() { group('FlutterTagger', () { late FlutterTaggerController controller; - late Widget testWidget; late Function(String query, String triggerCharacter) onSearch; setUp(() { controller = FlutterTaggerController(); onSearch = (query, triggerCharacter) {}; - testWidget = MaterialApp( - home: Scaffold( - body: Center( - child: FlutterTagger( - overlay: Container( - height: 100, - color: Colors.grey, - child: const Center(child: Text('Overlay')), - ), - controller: controller, - onSearch: onSearch, - builder: (context, key) { - return TextField( - key: key, - controller: controller, - ); - }, - triggerCharacterAndStyles: const { - '@': TextStyle(color: Colors.blue), - '#': TextStyle(color: Colors.green), - }, - ), - ), - ), - ); }); testWidgets('initializes without errors', (tester) async { - await tester.pumpWidget(testWidget); + await tester.pumpWidget(_buildTestWidget(controller: controller)); expect(find.byType(FlutterTagger), findsOneWidget); expect(find.byType(TextField), findsOneWidget); }); @@ -53,7 +60,7 @@ void main() { triggerChar = triggerCharacter; }; - testWidget = MaterialApp( + final testWidget = MaterialApp( home: Scaffold( body: Center( child: FlutterTagger( @@ -104,7 +111,7 @@ void main() { testWidgets('displays overlay when typing a trigger character', (tester) async { - await tester.pumpWidget(testWidget); + await tester.pumpWidget(_buildTestWidget(controller: controller)); final textField = find.byType(TextField); await tester.tap(textField); @@ -125,10 +132,32 @@ void main() { expect(find.text('Overlay'), findsOneWidget); }); + testWidgets( + 'Given that TriggerStrategy.eager is used, ' + 'Verify that overlay is displayed immediately a trigger character is typed', + (tester) async { + await tester.pumpWidget( + _buildTestWidget( + controller: controller, + triggerStrategy: TriggerStrategy.eager, + ), + ); + + final textField = find.byType(TextField); + await tester.tap(textField); + await tester.pump(); + + await tester.enterText(textField, '@'); + await tester.pumpAndSettle(); + + expect(find.text('Overlay'), findsOneWidget); + }, + ); + testWidgets( 'formats and displays tagged text correctly, selecting tag without space in name', (tester) async { - await tester.pumpWidget(testWidget); + await tester.pumpWidget(_buildTestWidget(controller: controller)); // Simulate typing the trigger character and the tag text final textField = find.byType(TextField); @@ -147,7 +176,7 @@ void main() { }); testWidgets('hides overlay when exiting search context', (tester) async { - await tester.pumpWidget(testWidget); + await tester.pumpWidget(_buildTestWidget(controller: controller)); final textField = find.byType(TextField); await tester.tap(textField); @@ -177,7 +206,7 @@ void main() { }); testWidgets('handles nested tags correctly', (tester) async { - await tester.pumpWidget(testWidget); + await tester.pumpWidget(_buildTestWidget(controller: controller)); // Simulate typing tag final textField = find.byType(TextField); @@ -212,7 +241,7 @@ void main() { child: const Center(child: Text('Overlay Position Test')), ); - testWidget = MaterialApp( + final testWidget = MaterialApp( home: Scaffold( body: FlutterTagger( overlay: overlayContent, @@ -271,7 +300,7 @@ void main() { child: const Center(child: Text('Overlay Position Test')), ); - testWidget = MaterialApp( + final testWidget = MaterialApp( home: Scaffold( body: FlutterTagger( overlay: overlayContent, @@ -324,7 +353,7 @@ void main() { testWidgets('formats text with specific pattern and parser', (tester) async { - await tester.pumpWidget(testWidget); + await tester.pumpWidget(_buildTestWidget(controller: controller)); controller.text = "Hey @11a27531b866ce0016f9e582#brad#. It's time to #11a27531b866ce0016f9e582#Flutter#!"; @@ -338,7 +367,7 @@ void main() { }); testWidgets('adds tags correctly', (tester) async { - await tester.pumpWidget(testWidget); + await tester.pumpWidget(_buildTestWidget(controller: controller)); // Simulate typing the trigger character and the tag text final textField = find.byType(TextField); @@ -356,7 +385,7 @@ void main() { }); testWidgets('removes tags correctly', (tester) async { - await tester.pumpWidget(testWidget); + await tester.pumpWidget(_buildTestWidget(controller: controller)); // Simulate typing the trigger character and the tag text final textField = find.byType(TextField); @@ -392,7 +421,7 @@ void main() { }); testWidgets('clears text correctly', (tester) async { - await tester.pumpWidget(testWidget); + await tester.pumpWidget(_buildTestWidget(controller: controller)); controller.text = '@testUser#123#'; controller.clear(); @@ -403,7 +432,7 @@ void main() { }); testWidgets('returns correct cursor position', (tester) async { - await tester.pumpWidget(testWidget); + await tester.pumpWidget(_buildTestWidget(controller: controller)); expect(controller.cursorPosition, 0);