From 60cb85d18760edae6ab9b1e48953c823b4110eb9 Mon Sep 17 00:00:00 2001 From: DatDang Date: Fri, 1 Nov 2024 17:11:59 +0700 Subject: [PATCH] TF-3236 Display SearchSnippet in search result --- .../views/text/rich_text_builder.dart | 157 ++++++++++++++++-- core/pubspec.yaml | 5 +- .../controller/search_controller.dart | 2 + .../widgets/search_input_form_widget.dart | 5 +- .../email/presentation/search_email_view.dart | 4 +- .../mixin/base_email_item_tile.dart | 4 + .../thread/presentation/thread_view.dart | 3 +- .../presentation_email_extension.dart | 7 + 8 files changed, 168 insertions(+), 19 deletions(-) diff --git a/core/lib/presentation/views/text/rich_text_builder.dart b/core/lib/presentation/views/text/rich_text_builder.dart index 9e1c36879..8bab7a763 100644 --- a/core/lib/presentation/views/text/rich_text_builder.dart +++ b/core/lib/presentation/views/text/rich_text_builder.dart @@ -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 createState() => _RichTextBuilderState(); +} + +class _RichTextBuilderState extends State with AutomaticKeepAliveClientMixin { + static const String _startMark = ''; + static const String _endMark = ''; + 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 _getSpans({ + bool get noHighlightAvailable { + return widget.preMarkedText == null + && widget.wordToStyle.isNotEmpty + && !widget.textOrigin.toLowerCase().contains(widget.wordToStyle.toLowerCase()); + } + + List _getSpans({ required String text, required String word, required TextStyle styleOrigin, required TextStyle styleWord, }) { - List spans = []; + if (word.isEmpty) { + return [TextSpan(text: text, style: styleOrigin)]; + } + + List 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 _getSpansFromPreMarkedText() { + List spans = []; + String? processingText = widget.preMarkedText; + + while (processingText != null && processingText.isNotEmpty) { + final startIndex = processingText.indexOf(_startMark); + + // There is no more 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 but no end + if (endIndex == -1) { + spans.add(TextSpan(text: processingText, style: widget.styleOrigin)); + return spans; + } + + // 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 + processingText = processingText.substring(endIndex + _endMark.length); + } + + return spans; + } + + @override + bool get wantKeepAlive => widget.ensureHighlightVisible; } \ No newline at end of file diff --git a/core/pubspec.yaml b/core/pubspec.yaml index 36db52920..4502549f2 100644 --- a/core/pubspec.yaml +++ b/core/pubspec.yaml @@ -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. \ No newline at end of file + # identifiers, which may be completely independent or the same as these. diff --git a/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart index 54880b628..be158dbfe 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/search_controller.dart @@ -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, diff --git a/lib/features/mailbox_dashboard/presentation/widgets/search_input_form_widget.dart b/lib/features/mailbox_dashboard/presentation/widgets/search_input_form_widget.dart index 630811173..c022b085b 100644 --- a/lib/features/mailbox_dashboard/presentation/widgets/search_input_form_widget.dart +++ b/lib/features/mailbox_dashboard/presentation/widgets/search_input_form_widget.dart @@ -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, diff --git a/lib/features/search/email/presentation/search_email_view.dart b/lib/features/search/email/presentation/search_email_view.dart index d90cdc3a4..85017f160 100644 --- a/lib/features/search/email/presentation/search_email_view.dart +++ b/lib/features/search/email/presentation/search_email_view.dart @@ -576,6 +576,7 @@ class SearchEmailView extends GetWidget 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 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; diff --git a/lib/features/thread/presentation/mixin/base_email_item_tile.dart b/lib/features/thread/presentation/mixin/base_email_item_tile.dart index 51b539e28..2f386e15d 100644 --- a/lib/features/thread/presentation/mixin/base_email_item_tile.dart +++ b/lib/features/thread/presentation/mixin/base_email_item_tile.dart @@ -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, diff --git a/lib/features/thread/presentation/thread_view.dart b/lib/features/thread/presentation/thread_view.dart index 3f6b52b9b..a12a60f5a 100644 --- a/lib/features/thread/presentation/thread_view.dart +++ b/lib/features/thread/presentation/thread_view.dart @@ -425,7 +425,8 @@ class ThreadView extends GetWidget 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(); } diff --git a/model/lib/extensions/presentation_email_extension.dart b/model/lib/extensions/presentation_email_extension.dart index d1b92731e..0a0fa4026 100644 --- a/model/lib/extensions/presentation_email_extension.dart +++ b/model/lib/extensions/presentation_email_extension.dart @@ -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); } \ No newline at end of file