TF-3236 Display SearchSnippet in search result
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user