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