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'; import 'package:flutter/material.dart';
class RichTextBuilder extends StatelessWidget { class RichTextBuilder extends StatefulWidget {
final String textOrigin; final String textOrigin;
final String wordToStyle; final String wordToStyle;
final TextStyle styleOrigin; final TextStyle styleOrigin;
final TextStyle styleWord; final TextStyle styleWord;
final String? preMarkedText;
final bool ensureHighlightVisible;
const RichTextBuilder({ const RichTextBuilder({
super.key, super.key,
@@ -13,33 +15,90 @@ class RichTextBuilder extends StatelessWidget {
required this.wordToStyle, required this.wordToStyle,
required this.styleOrigin, required this.styleOrigin,
required this.styleWord, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Text.rich( super.build(context);
final text = Text.rich(
TextSpan( TextSpan(
style: styleOrigin, style: widget.styleOrigin,
children: _getSpans( children: widget.preMarkedText != null
text: textOrigin, ? _getSpansFromPreMarkedText()
word: wordToStyle, : _getSpans(
styleOrigin: styleOrigin, text: widget.textOrigin,
styleWord: styleWord, word: widget.wordToStyle,
) styleOrigin: widget.styleOrigin,
styleWord: widget.styleWord,
)
), ),
style: styleOrigin, style: widget.styleOrigin,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis 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 text,
required String word, required String word,
required TextStyle styleOrigin, required TextStyle styleOrigin,
required TextStyle styleWord, required TextStyle styleWord,
}) { }) {
List<TextSpan> spans = []; if (word.isEmpty) {
return [TextSpan(text: text, style: styleOrigin)];
}
List<InlineSpan> spans = [];
int spanBoundary = 0; int spanBoundary = 0;
do { do {
// look for the next match // look for the next match
@@ -56,7 +115,20 @@ class RichTextBuilder extends StatelessWidget {
// style the matched text // style the matched text
final endIndex = startIndex + word.length; final endIndex = startIndex + word.length;
final spanText = text.substring(startIndex, endIndex); 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 // mark the boundary to start the next search from
spanBoundary = endIndex; spanBoundary = endIndex;
// continue until there are no more matches // continue until there are no more matches
@@ -64,4 +136,63 @@ class RichTextBuilder extends StatelessWidget {
return spans; 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 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 # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@@ -154,10 +154,9 @@ flutter:
# For details regarding fonts from package dependencies, # For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages # see https://flutter.dev/custom-fonts/#from-packages
# This section identifies your Flutter project as a module meant for # This section identifies your Flutter project as a module meant for
# embedding in a native host app. These identifiers should _not_ ordinarily # embedding in a native host app. These identifiers should _not_ ordinarily
# be changed after generation - they are used to ensure that the tooling can # be changed after generation - they are used to ensure that the tooling can
# maintain consistency when adding or modifying assets and plugins. # maintain consistency when adding or modifying assets and plugins.
# They also do not have any bearing on your native host application's # 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; SearchQuery? get searchQuery => searchEmailFilter.value.text;
FocusNode searchFocus = FocusNode(); FocusNode searchFocus = FocusNode();
String currentSearchText = '';
SearchController( SearchController(
this._quickSearchEmailInteractor, this._quickSearchEmailInteractor,
@@ -89,6 +90,7 @@ class SearchController extends BaseController with DateRangePickerMixin {
required AccountId accountId, required AccountId accountId,
required String query, required String query,
}) async { }) async {
currentSearchText = query;
return await _quickSearchEmailInteractor.execute( return await _quickSearchEmailInteractor.execute(
session, session,
accountId, accountId,
@@ -121,7 +121,10 @@ class SearchInputFormWidget extends StatelessWidget with AppLoaderMixin {
itemRecentBuilder: (context, recent) => RecentSearchItemTileWidget(recent), itemRecentBuilder: (context, recent) => RecentSearchItemTileWidget(recent),
onRecentSelected: _invokeSelectRecentItem, onRecentSelected: _invokeSelectRecentItem,
suggestionsCallback: _dashBoardController.quickSearchEmails, 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, onSuggestionSelected: _invokeSelectSuggestionItem,
contactItemBuilder: (context, emailAddress) => ContactQuickSearchItem(emailAddress: emailAddress), contactItemBuilder: (context, emailAddress) => ContactQuickSearchItem(emailAddress: emailAddress),
contactSuggestionsCallback: _dashBoardController.getContactSuggestion, contactSuggestionsCallback: _dashBoardController.getContactSuggestion,
@@ -576,6 +576,7 @@ class SearchEmailView extends GetWidget<SearchEmailController>
child: EmailQuickSearchItemTileWidget( child: EmailQuickSearchItemTileWidget(
listSuggestionSearch[index], listSuggestionSearch[index],
controller.currentMailbox, controller.currentMailbox,
searchQuery: SearchQuery(controller.currentSearchText.value.trim()),
contentPadding: SearchEmailViewStyle.getSearchSuggestionListPadding( contentPadding: SearchEmailViewStyle.getSearchSuggestionListPadding(
context, context,
controller.responsiveUtils controller.responsiveUtils
@@ -626,7 +627,8 @@ class SearchEmailView extends GetWidget<SearchEmailController>
onNotification: (ScrollNotification scrollInfo) { onNotification: (ScrollNotification scrollInfo) {
if (scrollInfo is ScrollEndNotification if (scrollInfo is ScrollEndNotification
&& controller.searchMoreState != SearchMoreState.waiting && controller.searchMoreState != SearchMoreState.waiting
&& scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent) { && scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent
&& scrollInfo.metrics.axisDirection == AxisDirection.down) {
controller.searchMoreEmailsAction(); controller.searchMoreEmailsAction();
} }
return false; return false;
@@ -122,6 +122,8 @@ mixin BaseEmailItemTile {
return RichTextBuilder( return RichTextBuilder(
textOrigin: email.getEmailTitle(), textOrigin: email.getEmailTitle(),
wordToStyle: query?.value ?? '', wordToStyle: query?.value ?? '',
preMarkedText: email.sanitizedSearchSnippetSubject,
ensureHighlightVisible: true,
styleOrigin: TextStyle( styleOrigin: TextStyle(
fontSize: 13, fontSize: 13,
color: buildTextColorForReadEmail(email), color: buildTextColorForReadEmail(email),
@@ -155,6 +157,8 @@ mixin BaseEmailItemTile {
return RichTextBuilder( return RichTextBuilder(
textOrigin: email.getPartialContent(), textOrigin: email.getPartialContent(),
wordToStyle: query?.value ?? '', wordToStyle: query?.value ?? '',
preMarkedText: email.sanitizedSearchSnippetPreview,
ensureHighlightVisible: true,
styleOrigin: const TextStyle( styleOrigin: const TextStyle(
fontSize: 13, fontSize: 13,
color: AppColor.colorContentEmail, color: AppColor.colorContentEmail,
@@ -425,7 +425,8 @@ class ThreadView extends GetWidget<ThreadController>
bool _handleScrollNotificationListener(ScrollNotification scrollInfo) { bool _handleScrollNotificationListener(ScrollNotification scrollInfo) {
if (scrollInfo is ScrollEndNotification && if (scrollInfo is ScrollEndNotification &&
scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent && scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent &&
!controller.loadingMoreStatus.value.isRunning !controller.loadingMoreStatus.value.isRunning &&
scrollInfo.metrics.axisDirection == AxisDirection.down
) { ) {
controller.handleLoadMoreEmailsRequest(); controller.handleLoadMoreEmailsRequest();
} }
@@ -291,4 +291,11 @@ extension PresentationEmailExtension on PresentationEmail {
type: MediaType.parse(Constant.octetStreamMimeType) 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);
} }