TF-3236 Display SearchSnippet in search result

This commit is contained in:
DatDang
2024-11-01 17:11:59 +07:00
committed by Dat H. Pham
parent 9c1d991c74
commit 60cb85d187
8 changed files with 168 additions and 19 deletions
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
class RichTextBuilder extends StatelessWidget {
class RichTextBuilder extends StatefulWidget {
final String textOrigin;
final String wordToStyle;
final TextStyle styleOrigin;
final TextStyle styleWord;
final String? preMarkedText;
final bool ensureHighlightVisible;
const RichTextBuilder({
super.key,
@@ -13,33 +15,90 @@ class RichTextBuilder extends StatelessWidget {
required this.wordToStyle,
required this.styleOrigin,
required this.styleWord,
this.preMarkedText,
this.ensureHighlightVisible = false,
});
@override
State<RichTextBuilder> createState() => _RichTextBuilderState();
}
class _RichTextBuilderState extends State<RichTextBuilder> with AutomaticKeepAliveClientMixin {
static const String _startMark = '<mark>';
static const String _endMark = '</mark>';
final firstHighlightKey = GlobalKey();
bool firstHighlightKeyed = false;
void scrollDebounceListener() {
if (!firstHighlightKeyed) return;
if (firstHighlightKey.currentContext == null) return;
final scrollable = Scrollable.maybeOf(
firstHighlightKey.currentContext!,
axis: Axis.horizontal);
final renderBox = firstHighlightKey.currentContext!.findRenderObject() as RenderBox?;
if (renderBox != null) {
scrollable?.position.ensureVisible(
renderBox,
alignment: 0.5,
duration: const Duration(milliseconds: 300),
curve: Curves.easeIn);
}
}
@override
Widget build(BuildContext context) {
return Text.rich(
super.build(context);
final text = Text.rich(
TextSpan(
style: styleOrigin,
children: _getSpans(
text: textOrigin,
word: wordToStyle,
styleOrigin: styleOrigin,
styleWord: styleWord,
)
style: widget.styleOrigin,
children: widget.preMarkedText != null
? _getSpansFromPreMarkedText()
: _getSpans(
text: widget.textOrigin,
word: widget.wordToStyle,
styleOrigin: widget.styleOrigin,
styleWord: widget.styleWord,
)
),
style: styleOrigin,
style: widget.styleOrigin,
maxLines: 1,
overflow: TextOverflow.ellipsis
);
if (!widget.ensureHighlightVisible) return text;
if (noHighlightAvailable) return text;
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollDebounceListener();
});
return SingleChildScrollView(
key: const PageStorageKey('rich_text_builder'),
physics: const NeverScrollableScrollPhysics(),
scrollDirection: Axis.horizontal,
child: text,
);
}
List<TextSpan> _getSpans({
bool get noHighlightAvailable {
return widget.preMarkedText == null
&& widget.wordToStyle.isNotEmpty
&& !widget.textOrigin.toLowerCase().contains(widget.wordToStyle.toLowerCase());
}
List<InlineSpan> _getSpans({
required String text,
required String word,
required TextStyle styleOrigin,
required TextStyle styleWord,
}) {
List<TextSpan> spans = [];
if (word.isEmpty) {
return [TextSpan(text: text, style: styleOrigin)];
}
List<InlineSpan> spans = [];
int spanBoundary = 0;
do {
// look for the next match
@@ -56,7 +115,20 @@ class RichTextBuilder extends StatelessWidget {
// style the matched text
final endIndex = startIndex + word.length;
final spanText = text.substring(startIndex, endIndex);
spans.add(TextSpan(text: spanText, style: styleWord));
if (!firstHighlightKeyed) {
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Text(
spanText,
key: firstHighlightKey,
style: styleWord,
),
));
firstHighlightKeyed = true;
} else {
spans.add(TextSpan(text: spanText, style: styleWord));
}
// mark the boundary to start the next search from
spanBoundary = endIndex;
// continue until there are no more matches
@@ -64,4 +136,63 @@ class RichTextBuilder extends StatelessWidget {
return spans;
}
List<InlineSpan> _getSpansFromPreMarkedText() {
List<InlineSpan> spans = [];
String? processingText = widget.preMarkedText;
while (processingText != null && processingText.isNotEmpty) {
final startIndex = processingText.indexOf(_startMark);
// There is no more <mark> in the text
if (startIndex == -1) {
spans.add(TextSpan(text: processingText, style: widget.styleOrigin));
return spans;
}
final endIndex = processingText.indexOf(
_endMark,
startIndex + _startMark.length);
// There is start <mark> but no end </mark>
if (endIndex == -1) {
spans.add(TextSpan(text: processingText, style: widget.styleOrigin));
return spans;
}
// <mark> is not the left most in the text
if (startIndex > 0) {
spans.add(TextSpan(
text: processingText.substring(0, startIndex),
style: widget.styleOrigin));
}
// style the marked text
final markedText = processingText.substring(
startIndex + _startMark.length,
endIndex);
if (!firstHighlightKeyed) {
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Text(
markedText,
key: firstHighlightKey,
style: widget.styleWord,
),
));
firstHighlightKeyed = true;
} else {
spans.add(TextSpan(
text: markedText,
style: widget.styleWord));
}
// Process the next <mark>
processingText = processingText.substring(endIndex + _endMark.length);
}
return spans;
}
@override
bool get wantKeepAlive => widget.ensureHighlightVisible;
}
+2 -3
View File
@@ -112,7 +112,7 @@ dev_dependencies:
build_runner: 2.3.3
mockito: 5.4.4
mockito: 5.4.4
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@@ -154,10 +154,9 @@ flutter:
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages
# This section identifies your Flutter project as a module meant for
# embedding in a native host app. These identifiers should _not_ ordinarily
# be changed after generation - they are used to ensure that the tooling can
# maintain consistency when adding or modifying assets and plugins.
# They also do not have any bearing on your native host application's
# identifiers, which may be completely independent or the same as these.
# identifiers, which may be completely independent or the same as these.
@@ -51,6 +51,7 @@ class SearchController extends BaseController with DateRangePickerMixin {
SearchQuery? get searchQuery => searchEmailFilter.value.text;
FocusNode searchFocus = FocusNode();
String currentSearchText = '';
SearchController(
this._quickSearchEmailInteractor,
@@ -89,6 +90,7 @@ class SearchController extends BaseController with DateRangePickerMixin {
required AccountId accountId,
required String query,
}) async {
currentSearchText = query;
return await _quickSearchEmailInteractor.execute(
session,
accountId,
@@ -121,7 +121,10 @@ class SearchInputFormWidget extends StatelessWidget with AppLoaderMixin {
itemRecentBuilder: (context, recent) => RecentSearchItemTileWidget(recent),
onRecentSelected: _invokeSelectRecentItem,
suggestionsCallback: _dashBoardController.quickSearchEmails,
itemBuilder: (context, email) => EmailQuickSearchItemTileWidget(email, _dashBoardController.selectedMailbox.value),
itemBuilder: (context, email) => EmailQuickSearchItemTileWidget(
email,
_dashBoardController.selectedMailbox.value,
searchQuery: SearchQuery(_searchController.currentSearchText.trim())),
onSuggestionSelected: _invokeSelectSuggestionItem,
contactItemBuilder: (context, emailAddress) => ContactQuickSearchItem(emailAddress: emailAddress),
contactSuggestionsCallback: _dashBoardController.getContactSuggestion,
@@ -576,6 +576,7 @@ class SearchEmailView extends GetWidget<SearchEmailController>
child: EmailQuickSearchItemTileWidget(
listSuggestionSearch[index],
controller.currentMailbox,
searchQuery: SearchQuery(controller.currentSearchText.value.trim()),
contentPadding: SearchEmailViewStyle.getSearchSuggestionListPadding(
context,
controller.responsiveUtils
@@ -626,7 +627,8 @@ class SearchEmailView extends GetWidget<SearchEmailController>
onNotification: (ScrollNotification scrollInfo) {
if (scrollInfo is ScrollEndNotification
&& controller.searchMoreState != SearchMoreState.waiting
&& scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent) {
&& scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent
&& scrollInfo.metrics.axisDirection == AxisDirection.down) {
controller.searchMoreEmailsAction();
}
return false;
@@ -122,6 +122,8 @@ mixin BaseEmailItemTile {
return RichTextBuilder(
textOrigin: email.getEmailTitle(),
wordToStyle: query?.value ?? '',
preMarkedText: email.sanitizedSearchSnippetSubject,
ensureHighlightVisible: true,
styleOrigin: TextStyle(
fontSize: 13,
color: buildTextColorForReadEmail(email),
@@ -155,6 +157,8 @@ mixin BaseEmailItemTile {
return RichTextBuilder(
textOrigin: email.getPartialContent(),
wordToStyle: query?.value ?? '',
preMarkedText: email.sanitizedSearchSnippetPreview,
ensureHighlightVisible: true,
styleOrigin: const TextStyle(
fontSize: 13,
color: AppColor.colorContentEmail,
@@ -425,7 +425,8 @@ class ThreadView extends GetWidget<ThreadController>
bool _handleScrollNotificationListener(ScrollNotification scrollInfo) {
if (scrollInfo is ScrollEndNotification &&
scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent &&
!controller.loadingMoreStatus.value.isRunning
!controller.loadingMoreStatus.value.isRunning &&
scrollInfo.metrics.axisDirection == AxisDirection.down
) {
controller.handleLoadMoreEmailsRequest();
}
@@ -291,4 +291,11 @@ extension PresentationEmailExtension on PresentationEmail {
type: MediaType.parse(Constant.octetStreamMimeType)
);
}
String? _sanitizeSearchSnippet(String? searchSnippet) => searchSnippet
?.replaceAll('\r', '')
.replaceAll('\n', '')
.replaceAll('\t', '');
String? get sanitizedSearchSnippetSubject => _sanitizeSearchSnippet(searchSnippetSubject);
String? get sanitizedSearchSnippetPreview => _sanitizeSearchSnippet(searchSnippetPreview);
}