Skip to content

Commit

Permalink
feat: adds TriggerStrategy to indicate how immediate the search callb…
Browse files Browse the repository at this point in the history
…ack should be invoked when a search trigger character is detected
  • Loading branch information
Crazelu committed Dec 31, 2024
1 parent 873a916 commit 19fa75d
Show file tree
Hide file tree
Showing 7 changed files with 116 additions and 53 deletions.
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,10 @@

## 2.2.1

* Documentation update.
* 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.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 📥
Expand All @@ -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`
Expand Down
7 changes: 2 additions & 5 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
@override
void initState() {
super.initState();
// _focusNode.addListener(_focusListener);
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 150),
Expand Down Expand Up @@ -86,16 +85,14 @@ class _HomeViewState extends State<HomeView> 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) {
Expand Down
6 changes: 2 additions & 4 deletions example/lib/views/view_models/search_view_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ class SearchViewModel {
}

Future<void> searchUser(String query) async {
if (query.isEmpty) return;

_activeView.value = SearchResultView.users;
if (query.isEmpty) return;

query = query.toLowerCase().trim();

Expand All @@ -52,9 +51,8 @@ class SearchViewModel {
}

Future<void> searchHashtag(String query) async {
if (query.isEmpty) return;

_activeView.value = SearchResultView.hashtag;
if (query.isEmpty) return;

query = query.toLowerCase().trim();

Expand Down
31 changes: 29 additions & 2 deletions lib/src/tagger.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
///
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<FlutterTagger> createState() => _FlutterTaggerState();
}
Expand Down Expand Up @@ -354,10 +370,11 @@ class _FlutterTaggerState extends State<FlutterTagger> {
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;

Expand Down Expand Up @@ -655,6 +672,9 @@ class _FlutterTaggerState extends State<FlutterTagger> {
if (position >= 0 && triggerCharacters.contains(text[position])) {
_shouldSearch = true;
_currentTriggerChar = text[position];
if (widget.triggerStrategy == TriggerStrategy.eager) {
_extractAndSearch(text, position);
}
}
return;
}
Expand Down Expand Up @@ -693,6 +713,9 @@ class _FlutterTaggerState extends State<FlutterTagger> {
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();
Expand Down Expand Up @@ -722,6 +745,9 @@ class _FlutterTaggerState extends State<FlutterTagger> {
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;
Expand Down Expand Up @@ -775,7 +801,8 @@ class _FlutterTaggerState extends State<FlutterTagger> {
/// 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;

Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
107 changes: 68 additions & 39 deletions test/fluttertagger_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -53,7 +60,7 @@ void main() {
triggerChar = triggerCharacter;
};

testWidget = MaterialApp(
final testWidget = MaterialApp(
home: Scaffold(
body: Center(
child: FlutterTagger(
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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#!";
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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);

Expand Down

0 comments on commit 19fa75d

Please sign in to comment.