From 3513c363ff5be56ecfc46bea7179db6b67e38f97 Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 23 May 2022 18:48:29 +0700 Subject: [PATCH] TF-524 Create RecentSearchCache and TypeAHeadQuickSearch widget --- assets/images/ic_attachment_sb.svg | 3 + assets/images/ic_calendar_sb.svg | 3 + assets/images/ic_clock_sb.svg | 3 + assets/images/ic_selected_sb.svg | 3 + assets/images/ic_user_sb.svg | 4 + core/lib/core.dart | 1 + .../presentation/resources/image_paths.dart | 5 + .../typeahead/typeahead_for_quick_search.dart | 1847 +++++++++++++++++ .../presentation/views/typeahead/typedef.dart | 15 + .../presentation/views/typeahead/utils.dart | 5 + .../caching/recent_search_cache_client.dart | 111 + .../caching/utils/caching_constants.dart | 1 + .../widgets/search_form_widget_builder.dart | 64 - .../data/model/recent_search_cache.dart | 21 + .../domain/model/recent_search.dart | 12 + .../mailbox_dashboard_view_web.dart | 142 +- .../email_quick_search_item_tile_widget.dart | 92 + .../recent_search_item_tile_widget.dart | 35 + lib/l10n/intl_messages.arb | 44 +- lib/main/localizations/app_localizations.dart | 24 + pubspec.yaml | 3 - ...ails_in_mailbox_interactor_test.mocks.dart | 19 - ...ails_in_mailbox_interactor_test.mocks.dart | 19 - 23 files changed, 2349 insertions(+), 127 deletions(-) create mode 100644 assets/images/ic_attachment_sb.svg create mode 100644 assets/images/ic_calendar_sb.svg create mode 100644 assets/images/ic_clock_sb.svg create mode 100644 assets/images/ic_selected_sb.svg create mode 100644 assets/images/ic_user_sb.svg create mode 100644 core/lib/presentation/views/typeahead/typeahead_for_quick_search.dart create mode 100644 core/lib/presentation/views/typeahead/typedef.dart create mode 100644 core/lib/presentation/views/typeahead/utils.dart create mode 100644 lib/features/caching/recent_search_cache_client.dart delete mode 100644 lib/features/mailbox/presentation/widgets/search_form_widget_builder.dart create mode 100644 lib/features/mailbox_dashboard/data/model/recent_search_cache.dart create mode 100644 lib/features/mailbox_dashboard/domain/model/recent_search.dart create mode 100644 lib/features/mailbox_dashboard/presentation/widgets/email_quick_search_item_tile_widget.dart create mode 100644 lib/features/mailbox_dashboard/presentation/widgets/recent_search_item_tile_widget.dart diff --git a/assets/images/ic_attachment_sb.svg b/assets/images/ic_attachment_sb.svg new file mode 100644 index 000000000..587f09577 --- /dev/null +++ b/assets/images/ic_attachment_sb.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_calendar_sb.svg b/assets/images/ic_calendar_sb.svg new file mode 100644 index 000000000..547bca3ce --- /dev/null +++ b/assets/images/ic_calendar_sb.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_clock_sb.svg b/assets/images/ic_clock_sb.svg new file mode 100644 index 000000000..2df7eed1c --- /dev/null +++ b/assets/images/ic_clock_sb.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_selected_sb.svg b/assets/images/ic_selected_sb.svg new file mode 100644 index 000000000..d30ae3226 --- /dev/null +++ b/assets/images/ic_selected_sb.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_user_sb.svg b/assets/images/ic_user_sb.svg new file mode 100644 index 000000000..031b55554 --- /dev/null +++ b/assets/images/ic_user_sb.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/lib/core.dart b/core/lib/core.dart index 59a353a0c..97a865cbd 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -62,6 +62,7 @@ export 'presentation/views/modal_sheets/edit_text_modal_sheet_builder.dart'; export 'presentation/views/search/search_bar_view.dart'; export 'presentation/views/popup_menu/popup_menu_item_widget.dart'; export 'presentation/views/tab_bar/custom_tab_indicator.dart'; +export 'presentation/views/typeahead/typeahead_for_quick_search.dart'; // Resources export 'presentation/resources/assets_paths.dart'; diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index 4866722ce..e3fba3905 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -112,6 +112,11 @@ class ImagePaths { String get icTeam => _getImagePath('ic_team.svg'); String get loginGraphic => _getImagePath('login_graphic.png'); String get icPowerByLinagora => _getImagePath('power_by_linagora.svg'); + String get icAttachmentSB => _getImagePath('ic_attachment_sb.svg'); + String get icCalendarSB => _getImagePath('ic_calendar_sb.svg'); + String get icClockSB => _getImagePath('ic_clock_sb.svg'); + String get icSelectedSB => _getImagePath('ic_selected_sb.svg'); + String get icUserSB => _getImagePath('ic_user_sb.svg'); String _getImagePath(String imageName) { return AssetsPaths.images + imageName; diff --git a/core/lib/presentation/views/typeahead/typeahead_for_quick_search.dart b/core/lib/presentation/views/typeahead/typeahead_for_quick_search.dart new file mode 100644 index 000000000..9f712c50e --- /dev/null +++ b/core/lib/presentation/views/typeahead/typeahead_for_quick_search.dart @@ -0,0 +1,1847 @@ +/// # Flutter TypeAhead +/// A TypeAhead widget for Flutter, where you can show suggestions to +/// users as they type +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; + +import 'typedef.dart'; +import 'utils.dart'; + +/// A [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) +/// implementation of [TypeAheadFieldQuickSearch], that allows the value to be saved, +/// validated, etc. +/// +/// See also: +/// +/// * [TypeAheadFieldQuickSearch], A [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) +/// that displays a list of suggestions as the user types +class TypeAheadFormFieldQuickSearch extends FormField { + /// The configuration of the [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) + /// that the TypeAhead widget displays + final QuickSearchTextFieldConfiguration textFieldConfiguration; + + /// Creates a [TypeAheadFormFieldQuickSearch] + TypeAheadFormFieldQuickSearch( + {Key? key, + String? initialValue, + bool getImmediateSuggestions: false, + @Deprecated('Use autovalidateMode parameter which provides more specific ' + 'behavior related to auto validation. ' + 'This feature was deprecated after Flutter v1.19.0.') + bool autovalidate: false, + bool enabled: true, + AutovalidateMode autovalidateMode: AutovalidateMode.disabled, + FormFieldSetter? onSaved, + FormFieldValidator? validator, + ErrorBuilder? errorBuilder, + WidgetBuilder? noItemsFoundBuilder, + WidgetBuilder? loadingBuilder, + Duration debounceDuration: const Duration(milliseconds: 300), + QuickSearchSuggestionsBoxDecoration suggestionsBoxDecoration: + const QuickSearchSuggestionsBoxDecoration(), + QuickSearchSuggestionsBoxController? suggestionsBoxController, + required SuggestionSelectionCallback onSuggestionSelected, + required ItemBuilder itemBuilder, + required SuggestionsCallback suggestionsCallback, + double suggestionsBoxVerticalOffset: 5.0, + this.textFieldConfiguration: const QuickSearchTextFieldConfiguration(), + AnimationTransitionBuilder? transitionBuilder, + Duration animationDuration: const Duration(milliseconds: 500), + double animationStart: 0.25, + AxisDirection direction: AxisDirection.down, + bool hideOnLoading: false, + bool hideOnEmpty: false, + bool hideOnError: false, + bool hideSuggestionsOnKeyboardHide: true, + bool keepSuggestionsOnLoading: true, + bool keepSuggestionsOnSuggestionSelected: false, + bool autoFlipDirection: false, + bool hideKeyboard: false, + int minCharsForSuggestions: 0, + List? listActionButton, + ButtonActionBuilder? actionButtonBuilder, + ButtonActionCallback? buttonActionCallback, + ButtonActionBuilder? buttonShowAllResult, + Widget? titleHeaderRecent, + ItemBuilder? itemRecentBuilder, + FetchRecentActionCallback? fetchRecentActionCallback, + RecentSelectionCallback? onRecentSelected, + EdgeInsets? listActionPadding, + bool hideSuggestionsBox: false, + }) : assert( + initialValue == null || textFieldConfiguration.controller == null), + assert(minCharsForSuggestions >= 0), + super( + key: key, + onSaved: onSaved, + validator: validator, + initialValue: textFieldConfiguration.controller != null + ? textFieldConfiguration.controller!.text + : (initialValue ?? ''), + enabled: enabled, + autovalidateMode: autovalidateMode, + builder: (FormFieldState field) { + final _TypeAheadFormFieldState state = + field as _TypeAheadFormFieldState; + + return TypeAheadFieldQuickSearch( + getImmediateSuggestions: getImmediateSuggestions, + transitionBuilder: transitionBuilder, + errorBuilder: errorBuilder, + noItemsFoundBuilder: noItemsFoundBuilder, + loadingBuilder: loadingBuilder, + debounceDuration: debounceDuration, + suggestionsBoxDecoration: suggestionsBoxDecoration, + suggestionsBoxController: suggestionsBoxController, + textFieldConfiguration: textFieldConfiguration.copyWith( + decoration: textFieldConfiguration.decoration + .copyWith(errorText: state.errorText), + onChanged: (text) { + state.didChange(text); + textFieldConfiguration.onChanged?.call(text); + }, + controller: state._effectiveController, + ), + suggestionsBoxVerticalOffset: suggestionsBoxVerticalOffset, + onSuggestionSelected: onSuggestionSelected, + itemBuilder: itemBuilder, + suggestionsCallback: suggestionsCallback, + animationStart: animationStart, + animationDuration: animationDuration, + direction: direction, + hideOnLoading: hideOnLoading, + hideOnEmpty: hideOnEmpty, + hideOnError: hideOnError, + hideSuggestionsOnKeyboardHide: hideSuggestionsOnKeyboardHide, + keepSuggestionsOnLoading: keepSuggestionsOnLoading, + keepSuggestionsOnSuggestionSelected: + keepSuggestionsOnSuggestionSelected, + autoFlipDirection: autoFlipDirection, + hideKeyboard: hideKeyboard, + minCharsForSuggestions: minCharsForSuggestions, + listActionButton: listActionButton, + actionButtonBuilder: actionButtonBuilder, + buttonActionCallback: buttonActionCallback, + buttonShowAllResult: buttonShowAllResult, + titleHeaderRecent: titleHeaderRecent, + itemRecentBuilder: itemRecentBuilder, + fetchRecentActionCallback: fetchRecentActionCallback, + onRecentSelected: onRecentSelected, + listActionPadding: listActionPadding, + hideSuggestionsBox: hideSuggestionsBox, + ); + }); + + @override + _TypeAheadFormFieldState createState() => _TypeAheadFormFieldState(); +} + +class _TypeAheadFormFieldState extends FormFieldState { + TextEditingController? _controller; + + TextEditingController? get _effectiveController => + widget.textFieldConfiguration.controller ?? _controller; + + @override + TypeAheadFormFieldQuickSearch get widget => super.widget as TypeAheadFormFieldQuickSearch; + + @override + void initState() { + super.initState(); + if (widget.textFieldConfiguration.controller == null) { + _controller = TextEditingController(text: widget.initialValue); + } else { + widget.textFieldConfiguration.controller! + .addListener(_handleControllerChanged); + } + } + + @override + void didUpdateWidget(TypeAheadFormFieldQuickSearch oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.textFieldConfiguration.controller != + oldWidget.textFieldConfiguration.controller) { + oldWidget.textFieldConfiguration.controller + ?.removeListener(_handleControllerChanged); + widget.textFieldConfiguration.controller + ?.addListener(_handleControllerChanged); + + if (oldWidget.textFieldConfiguration.controller != null && + widget.textFieldConfiguration.controller == null) + _controller = TextEditingController.fromValue( + oldWidget.textFieldConfiguration.controller!.value); + if (widget.textFieldConfiguration.controller != null) { + setValue(widget.textFieldConfiguration.controller!.text); + if (oldWidget.textFieldConfiguration.controller == null) + _controller = null; + } + } + } + + @override + void dispose() { + widget.textFieldConfiguration.controller + ?.removeListener(_handleControllerChanged); + super.dispose(); + } + + @override + void reset() { + super.reset(); + setState(() { + _effectiveController!.text = widget.initialValue!; + }); + } + + void _handleControllerChanged() { + // Suppress changes that originated from within this class. + // + // In the case where a controller has been passed in to this widget, we + // register this change listener. In these cases, we'll also receive change + // notifications for changes originating from within this class -- for + // example, the reset() method. In such cases, the FormField value will + // already have been set. + if (_effectiveController!.text != value) + didChange(_effectiveController!.text); + } +} + +/// A [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) +/// that displays a list of suggestions as the user types +/// +/// See also: +/// +/// * [TypeAheadFormFieldQuickSearch], a [FormField](https://docs.flutter.io/flutter/widgets/FormField-class.html) +/// implementation of [TypeAheadFieldQuickSearch] that allows the value to be saved, +/// validated, etc. +class TypeAheadFieldQuickSearch extends StatefulWidget { + /// Called with the search pattern to get the search suggestions. + /// + /// This callback must not be null. It is be called by the TypeAhead widget + /// and provided with the search pattern. It should return a [List](https://api.dartlang.org/stable/2.0.0/dart-core/List-class.html) + /// of suggestions either synchronously, or asynchronously (as the result of a + /// [Future](https://api.dartlang.org/stable/dart-async/Future-class.html)). + /// Typically, the list of suggestions should not contain more than 4 or 5 + /// entries. These entries will then be provided to [itemBuilder] to display + /// the suggestions. + /// + /// Example: + /// ```dart + /// suggestionsCallback: (pattern) async { + /// return await _getSuggestions(pattern); + /// } + /// ``` + final SuggestionsCallback suggestionsCallback; + + /// Called when a suggestion is tapped. + /// + /// This callback must not be null. It is called by the TypeAhead widget and + /// provided with the value of the tapped suggestion. + /// + /// For example, you might want to navigate to a specific view when the user + /// tabs a suggestion: + /// ```dart + /// onSuggestionSelected: (suggestion) { + /// Navigator.of(context).push(MaterialPageRoute( + /// builder: (context) => SearchResult( + /// searchItem: suggestion + /// ) + /// )); + /// } + /// ``` + /// + /// Or to set the value of the text field: + /// ```dart + /// onSuggestionSelected: (suggestion) { + /// _controller.text = suggestion['name']; + /// } + /// ``` + final SuggestionSelectionCallback onSuggestionSelected; + + /// Called for each suggestion returned by [suggestionsCallback] to build the + /// corresponding widget. + /// + /// This callback must not be null. It is called by the TypeAhead widget for + /// each suggestion, and expected to build a widget to display this + /// suggestion's info. For example: + /// + /// ```dart + /// itemBuilder: (context, suggestion) { + /// return ListTile( + /// title: Text(suggestion['name']), + /// subtitle: Text('USD' + suggestion['price'].toString()) + /// ); + /// } + /// ``` + final ItemBuilder itemBuilder; + + /// used to control the scroll behavior of item-builder list + final ScrollController? scrollController; + + /// The decoration of the material sheet that contains the suggestions. + /// + /// If null, default decoration with an elevation of 4.0 is used + /// + + final QuickSearchSuggestionsBoxDecoration suggestionsBoxDecoration; + + /// Used to control the `_SuggestionsBox`. Allows manual control to + /// open, close, toggle, or resize the `_SuggestionsBox`. + final QuickSearchSuggestionsBoxController? suggestionsBoxController; + + /// The duration to wait after the user stops typing before calling + /// [suggestionsCallback] + /// + /// This is useful, because, if not set, a request for suggestions will be + /// sent for every character that the user types. + /// + /// This duration is set by default to 300 milliseconds + final Duration debounceDuration; + + /// Called when waiting for [suggestionsCallback] to return. + /// + /// It is expected to return a widget to display while waiting. + /// For example: + /// ```dart + /// (BuildContext context) { + /// return Text('Loading...'); + /// } + /// ``` + /// + /// If not specified, a [CircularProgressIndicator](https://docs.flutter.io/flutter/material/CircularProgressIndicator-class.html) is shown + final WidgetBuilder? loadingBuilder; + + /// Called when [suggestionsCallback] returns an empty array. + /// + /// It is expected to return a widget to display when no suggestions are + /// avaiable. + /// For example: + /// ```dart + /// (BuildContext context) { + /// return Text('No Items Found!'); + /// } + /// ``` + /// + /// If not specified, a simple text is shown + final WidgetBuilder? noItemsFoundBuilder; + + /// Called when [suggestionsCallback] throws an exception. + /// + /// It is called with the error object, and expected to return a widget to + /// display when an exception is thrown + /// For example: + /// ```dart + /// (BuildContext context, error) { + /// return Text('$error'); + /// } + /// ``` + /// + /// If not specified, the error is shown in [ThemeData.errorColor](https://docs.flutter.io/flutter/material/ThemeData/errorColor.html) + final ErrorBuilder? errorBuilder; + + /// Called to display animations when [suggestionsCallback] returns suggestions + /// + /// It is provided with the suggestions box instance and the animation + /// controller, and expected to return some animation that uses the controller + /// to display the suggestion box. + /// + /// For example: + /// ```dart + /// transitionBuilder: (context, suggestionsBox, animationController) { + /// return FadeTransition( + /// child: suggestionsBox, + /// opacity: CurvedAnimation( + /// parent: animationController, + /// curve: Curves.fastOutSlowIn + /// ), + /// ); + /// } + /// ``` + /// This argument is best used with [animationDuration] and [animationStart] + /// to fully control the animation. + /// + /// To fully remove the animation, just return `suggestionsBox` + /// + /// If not specified, a [SizeTransition](https://docs.flutter.io/flutter/widgets/SizeTransition-class.html) is shown. + final AnimationTransitionBuilder? transitionBuilder; + + /// The duration that [transitionBuilder] animation takes. + /// + /// This argument is best used with [transitionBuilder] and [animationStart] + /// to fully control the animation. + /// + /// Defaults to 500 milliseconds. + final Duration animationDuration; + + /// Determine the [SuggestionBox]'s direction. + /// + /// If [AxisDirection.down], the [SuggestionBox] will be below the [TextField] + /// and the [_SuggestionsList] will grow **down**. + /// + /// If [AxisDirection.up], the [SuggestionBox] will be above the [TextField] + /// and the [_SuggestionsList] will grow **up**. + /// + /// [AxisDirection.left] and [AxisDirection.right] are not allowed. + final AxisDirection direction; + + /// The value at which the [transitionBuilder] animation starts. + /// + /// This argument is best used with [transitionBuilder] and [animationDuration] + /// to fully control the animation. + /// + /// Defaults to 0.25. + final double animationStart; + + /// The configuration of the [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) + /// that the TypeAhead widget displays + final QuickSearchTextFieldConfiguration textFieldConfiguration; + + /// How far below the text field should the suggestions box be + /// + /// Defaults to 5.0 + final double suggestionsBoxVerticalOffset; + + /// If set to true, suggestions will be fetched immediately when the field is + /// added to the view. + /// + /// But the suggestions box will only be shown when the field receives focus. + /// To make the field receive focus immediately, you can set the `autofocus` + /// property in the [textFieldConfiguration] to true + /// + /// Defaults to false + final bool getImmediateSuggestions; + + /// If set to true, no loading box will be shown while suggestions are + /// being fetched. [loadingBuilder] will also be ignored. + /// + /// Defaults to false. + final bool hideOnLoading; + + /// If set to true, nothing will be shown if there are no results. + /// [noItemsFoundBuilder] will also be ignored. + /// + /// Defaults to false. + final bool hideOnEmpty; + + /// If set to true, nothing will be shown if there is an error. + /// [errorBuilder] will also be ignored. + /// + /// Defaults to false. + final bool hideOnError; + + /// If set to false, the suggestions box will stay opened after + /// the keyboard is closed. + /// + /// Defaults to true. + final bool hideSuggestionsOnKeyboardHide; + + /// If set to false, the suggestions box will show a circular + /// progress indicator when retrieving suggestions. + /// + /// Defaults to true. + final bool keepSuggestionsOnLoading; + + /// If set to true, the suggestions box will remain opened even after + /// selecting a suggestion. + /// + /// Note that if this is enabled, the only way + /// to close the suggestions box is either manually via the + /// `QuickSearchSuggestionsBoxController` or when the user closes the software + /// keyboard if `hideSuggestionsOnKeyboardHide` is set to true. Users + /// with a physical keyboard will be unable to close the + /// box without a manual way via `QuickSearchSuggestionsBoxController`. + /// + /// Defaults to false. + final bool keepSuggestionsOnSuggestionSelected; + + /// If set to true, in the case where the suggestions box has less than + /// _SuggestionsBoxController.minOverlaySpace to grow in the desired [direction], the direction axis + /// will be temporarily flipped if there's more room available in the opposite + /// direction. + /// + /// Defaults to false + final bool autoFlipDirection; + final bool hideKeyboard; + + /// The minimum number of characters which must be entered before + /// [suggestionsCallback] is triggered. + /// + /// Defaults to 0. + final int minCharsForSuggestions; + + /// List button action for suggestion box + final List? listActionButton; + final ButtonActionBuilder? actionButtonBuilder; + final ButtonActionCallback? buttonActionCallback; + + /// Button show all result + final ButtonActionBuilder? buttonShowAllResult; + + /// Title header recent suggestion box + final Widget? titleHeaderRecent; + /// Widget item recent + final ItemBuilder? itemRecentBuilder; + /// Get all recent callback + final FetchRecentActionCallback? fetchRecentActionCallback; + /// On listen select recent + final RecentSelectionCallback? onRecentSelected; + /// Padding button action + final EdgeInsets? listActionPadding; + final bool hideSuggestionsBox; + + /// Creates a [TypeAheadFieldQuickSearch] + TypeAheadFieldQuickSearch( + {Key? key, + required this.suggestionsCallback, + required this.itemBuilder, + required this.onSuggestionSelected, + this.textFieldConfiguration: const QuickSearchTextFieldConfiguration(), + this.suggestionsBoxDecoration: const QuickSearchSuggestionsBoxDecoration(), + this.debounceDuration: const Duration(milliseconds: 300), + this.suggestionsBoxController, + this.scrollController, + this.loadingBuilder, + this.noItemsFoundBuilder, + this.errorBuilder, + this.transitionBuilder, + this.animationStart: 0.25, + this.animationDuration: const Duration(milliseconds: 500), + this.getImmediateSuggestions: false, + this.suggestionsBoxVerticalOffset: 5.0, + this.direction: AxisDirection.down, + this.hideOnLoading: false, + this.hideOnEmpty: false, + this.hideOnError: false, + this.hideSuggestionsOnKeyboardHide: true, + this.keepSuggestionsOnLoading: true, + this.keepSuggestionsOnSuggestionSelected: false, + this.autoFlipDirection: false, + this.hideKeyboard: false, + this.minCharsForSuggestions: 0, + this.listActionButton, + this.actionButtonBuilder, + this.buttonActionCallback, + this.buttonShowAllResult, + this.titleHeaderRecent, + this.itemRecentBuilder, + this.fetchRecentActionCallback, + this.onRecentSelected, + this.listActionPadding, + this.hideSuggestionsBox = false, + }) : assert(animationStart >= 0.0 && animationStart <= 1.0), + assert( + direction == AxisDirection.down || direction == AxisDirection.up), + assert(minCharsForSuggestions >= 0), + super(key: key); + + @override + _TypeAheadFieldQuickSearchState createState() => _TypeAheadFieldQuickSearchState(); +} + +class _TypeAheadFieldQuickSearchState extends State> + with WidgetsBindingObserver { + FocusNode? _focusNode; + TextEditingController? _textEditingController; + _SuggestionsBox? _suggestionsBox; + + TextEditingController? get _effectiveController => + widget.textFieldConfiguration.controller ?? _textEditingController; + FocusNode? get _effectiveFocusNode => + widget.textFieldConfiguration.focusNode ?? _focusNode; + late VoidCallback _focusNodeListener; + + final LayerLink _layerLink = LayerLink(); + + // Timer that resizes the suggestion box on each tick. Only active when the user is scrolling. + Timer? _resizeOnScrollTimer; + // The rate at which the suggestion box will resize when the user is scrolling + final Duration _resizeOnScrollRefreshRate = const Duration(milliseconds: 500); + // Will have a value if the typeahead is inside a scrollable widget + ScrollPosition? _scrollPosition; + + // Keyboard detection + final Stream? _keyboardVisibility = + (supportedPlatform) ? KeyboardVisibilityController().onChange : null; + late StreamSubscription? _keyboardVisibilitySubscription; + + @override + void didChangeMetrics() { + // Catch keyboard event and orientation change; resize suggestions list + this._suggestionsBox!.onChangeMetrics(); + } + + @override + void dispose() { + this._suggestionsBox!.close(); + this._suggestionsBox!.widgetMounted = false; + WidgetsBinding.instance?.removeObserver(this); + _keyboardVisibilitySubscription?.cancel(); + _effectiveFocusNode!.removeListener(_focusNodeListener); + _focusNode?.dispose(); + _resizeOnScrollTimer?.cancel(); + _scrollPosition?.removeListener(_scrollResizeListener); + _textEditingController?.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance?.addObserver(this); + + if (widget.textFieldConfiguration.controller == null) { + this._textEditingController = TextEditingController(); + } + + if (widget.textFieldConfiguration.focusNode == null) { + this._focusNode = FocusNode(); + } + + this._suggestionsBox = _SuggestionsBox( + context, + widget.direction, + widget.autoFlipDirection, + widget.hideSuggestionsBox); + widget.suggestionsBoxController?._suggestionsBox = this._suggestionsBox; + widget.suggestionsBoxController?._effectiveFocusNode = + this._effectiveFocusNode; + + this._focusNodeListener = () { + if (_effectiveFocusNode!.hasFocus) { + this._suggestionsBox!.open(); + } else { + if (widget.hideSuggestionsOnKeyboardHide){ + this._suggestionsBox!.close(); + } + } + }; + + this._effectiveFocusNode!.addListener(_focusNodeListener); + + // hide suggestions box on keyboard closed + this._keyboardVisibilitySubscription = + _keyboardVisibility?.listen((bool isVisible) { + if (widget.hideSuggestionsOnKeyboardHide && !isVisible) { + _effectiveFocusNode!.unfocus(); + } + }); + + WidgetsBinding.instance?.addPostFrameCallback((duration) { + if (mounted) { + this._initOverlayEntry(); + // calculate initial suggestions list size + this._suggestionsBox!.resize(); + + // in case we already missed the focus event + if (this._effectiveFocusNode!.hasFocus) { + this._suggestionsBox!.open(); + } + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + ScrollableState? scrollableState = Scrollable.of(context); + if (scrollableState != null) { + // The TypeAheadFieldQuickSearch is inside a scrollable widget + _scrollPosition = scrollableState.position; + + _scrollPosition!.removeListener(_scrollResizeListener); + _scrollPosition!.isScrollingNotifier.addListener(_scrollResizeListener); + } + } + + void _scrollResizeListener() { + bool isScrolling = _scrollPosition!.isScrollingNotifier.value; + _resizeOnScrollTimer?.cancel(); + if (isScrolling) { + // Scroll started + _resizeOnScrollTimer = + Timer.periodic(_resizeOnScrollRefreshRate, (timer) { + _suggestionsBox!.resize(); + }); + } else { + // Scroll finished + _suggestionsBox!.resize(); + } + } + + void _initOverlayEntry() { + this._suggestionsBox!._overlayEntry = OverlayEntry(builder: (context) { + final suggestionsList = _SuggestionsList( + suggestionsBox: _suggestionsBox, + decoration: widget.suggestionsBoxDecoration, + debounceDuration: widget.debounceDuration, + controller: this._effectiveController, + loadingBuilder: widget.loadingBuilder, + scrollController: widget.scrollController, + noItemsFoundBuilder: widget.noItemsFoundBuilder, + errorBuilder: widget.errorBuilder, + transitionBuilder: widget.transitionBuilder, + suggestionsCallback: widget.suggestionsCallback, + animationDuration: widget.animationDuration, + animationStart: widget.animationStart, + getImmediateSuggestions: widget.getImmediateSuggestions, + onSuggestionSelected: (T selection) { + if (!widget.keepSuggestionsOnSuggestionSelected) { + this._effectiveFocusNode!.unfocus(); + this._suggestionsBox!.close(); + } + widget.onSuggestionSelected(selection); + }, + itemBuilder: widget.itemBuilder, + direction: _suggestionsBox!.direction, + hideOnLoading: widget.hideOnLoading, + hideOnEmpty: widget.hideOnEmpty, + hideOnError: widget.hideOnError, + keepSuggestionsOnLoading: widget.keepSuggestionsOnLoading, + minCharsForSuggestions: widget.minCharsForSuggestions, + listActionButton: widget.listActionButton, + actionButtonBuilder: widget.actionButtonBuilder, + buttonActionCallback: widget.buttonActionCallback, + buttonShowAllResult: widget.buttonShowAllResult, + titleHeaderRecent: widget.titleHeaderRecent, + itemRecentBuilder: widget.itemRecentBuilder, + fetchRecentActionCallback: widget.fetchRecentActionCallback, + onRecentSelected: (R selection) { + if (!widget.keepSuggestionsOnSuggestionSelected) { + this._effectiveFocusNode!.unfocus(); + this._suggestionsBox!.close(); + } + if (widget.onRecentSelected != null) { + widget.onRecentSelected!(selection); + } + }, + listActionPadding: widget.listActionPadding, + hideSuggestionsBox: widget.hideSuggestionsBox, + ); + + double w = _suggestionsBox!.textBoxWidth; + if (widget.suggestionsBoxDecoration.constraints != null) { + if (widget.suggestionsBoxDecoration.constraints!.minWidth != 0.0 && + widget.suggestionsBoxDecoration.constraints!.maxWidth != + double.infinity) { + w = (widget.suggestionsBoxDecoration.constraints!.minWidth + + widget.suggestionsBoxDecoration.constraints!.maxWidth) / + 2; + } else if (widget.suggestionsBoxDecoration.constraints!.minWidth != + 0.0 && + widget.suggestionsBoxDecoration.constraints!.minWidth > w) { + w = widget.suggestionsBoxDecoration.constraints!.minWidth; + } else if (widget.suggestionsBoxDecoration.constraints!.maxWidth != + double.infinity && + widget.suggestionsBoxDecoration.constraints!.maxWidth < w) { + w = widget.suggestionsBoxDecoration.constraints!.maxWidth; + } + } + + return Positioned( + width: w, + child: CompositedTransformFollower( + link: this._layerLink, + showWhenUnlinked: false, + offset: Offset( + widget.suggestionsBoxDecoration.offsetX, + _suggestionsBox!.direction == AxisDirection.down + ? _suggestionsBox!.textBoxHeight + + widget.suggestionsBoxVerticalOffset + : _suggestionsBox!.directionUpOffset), + child: _suggestionsBox!.direction == AxisDirection.down + ? suggestionsList + : FractionalTranslation( + translation: + Offset(0.0, -1.0), // visually flips list to go up + child: suggestionsList, + ), + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: this._layerLink, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.textFieldConfiguration.leftButton != null) + widget.textFieldConfiguration.leftButton!, + Expanded( + child: TextField( + focusNode: this._effectiveFocusNode, + controller: this._effectiveController, + decoration: widget.textFieldConfiguration.decoration, + style: widget.textFieldConfiguration.style, + textAlign: widget.textFieldConfiguration.textAlign, + enabled: widget.textFieldConfiguration.enabled, + keyboardType: widget.textFieldConfiguration.keyboardType, + autofocus: widget.textFieldConfiguration.autofocus, + inputFormatters: widget.textFieldConfiguration.inputFormatters, + autocorrect: widget.textFieldConfiguration.autocorrect, + maxLines: widget.textFieldConfiguration.maxLines, + textAlignVertical: widget.textFieldConfiguration.textAlignVertical, + minLines: widget.textFieldConfiguration.minLines, + maxLength: widget.textFieldConfiguration.maxLength, + maxLengthEnforcement: widget.textFieldConfiguration.maxLengthEnforcement, + obscureText: widget.textFieldConfiguration.obscureText, + onChanged: widget.textFieldConfiguration.onChanged, + onSubmitted: widget.textFieldConfiguration.onSubmitted, + onEditingComplete: widget.textFieldConfiguration.onEditingComplete, + onTap: widget.textFieldConfiguration.onTap, + scrollPadding: widget.textFieldConfiguration.scrollPadding, + textInputAction: widget.textFieldConfiguration.textInputAction, + textCapitalization: widget.textFieldConfiguration.textCapitalization, + keyboardAppearance: widget.textFieldConfiguration.keyboardAppearance, + cursorWidth: widget.textFieldConfiguration.cursorWidth, + cursorRadius: widget.textFieldConfiguration.cursorRadius, + cursorColor: widget.textFieldConfiguration.cursorColor, + textDirection: widget.textFieldConfiguration.textDirection, + enableInteractiveSelection: + widget.textFieldConfiguration.enableInteractiveSelection, + readOnly: widget.hideKeyboard, + ), + ), + if (widget.textFieldConfiguration.clearTextButton != null + && this._effectiveController?.text.isNotEmpty == true) + widget.textFieldConfiguration.clearTextButton!, + if (widget.textFieldConfiguration.rightButton != null) + widget.textFieldConfiguration.rightButton!, + ], + ), + ); + } +} + +class _SuggestionsList extends StatefulWidget { + final _SuggestionsBox? suggestionsBox; + final TextEditingController? controller; + final bool getImmediateSuggestions; + final SuggestionSelectionCallback? onSuggestionSelected; + final SuggestionsCallback? suggestionsCallback; + final ItemBuilder? itemBuilder; + final ScrollController? scrollController; + final QuickSearchSuggestionsBoxDecoration? decoration; + final Duration? debounceDuration; + final WidgetBuilder? loadingBuilder; + final WidgetBuilder? noItemsFoundBuilder; + final ErrorBuilder? errorBuilder; + final AnimationTransitionBuilder? transitionBuilder; + final Duration? animationDuration; + final double? animationStart; + final AxisDirection? direction; + final bool? hideOnLoading; + final bool? hideOnEmpty; + final bool? hideOnError; + final bool? keepSuggestionsOnLoading; + final int? minCharsForSuggestions; + final List? listActionButton; + final ButtonActionBuilder? actionButtonBuilder; + final ButtonActionCallback? buttonActionCallback; + final ButtonActionBuilder? buttonShowAllResult; + final Widget? titleHeaderRecent; + final ItemBuilder? itemRecentBuilder; + final FetchRecentActionCallback? fetchRecentActionCallback; + final RecentSelectionCallback? onRecentSelected; + final EdgeInsets? listActionPadding; + final bool hideSuggestionsBox; + + _SuggestionsList({ + required this.suggestionsBox, + this.controller, + this.getImmediateSuggestions: false, + this.onSuggestionSelected, + this.suggestionsCallback, + this.itemBuilder, + this.scrollController, + this.decoration, + this.debounceDuration, + this.loadingBuilder, + this.noItemsFoundBuilder, + this.errorBuilder, + this.transitionBuilder, + this.animationDuration, + this.animationStart, + this.direction, + this.hideOnLoading, + this.hideOnEmpty, + this.hideOnError, + this.keepSuggestionsOnLoading, + this.minCharsForSuggestions, + this.listActionButton, + this.actionButtonBuilder, + this.buttonActionCallback, + this.buttonShowAllResult, + this.titleHeaderRecent, + this.itemRecentBuilder, + this.fetchRecentActionCallback, + this.onRecentSelected, + this.listActionPadding, + this.hideSuggestionsBox: false, + }); + + @override + _SuggestionsListState createState() => _SuggestionsListState(); +} + +class _SuggestionsListState extends State<_SuggestionsList> + with SingleTickerProviderStateMixin { + Iterable? _suggestions; + Iterable? _recentItems; + late bool _suggestionsValid; + late VoidCallback _controllerListener; + Timer? _debounceTimer; + bool? _isLoading, _isQueued; + AnimationController? _animationController; + String? _lastTextValue; + late final ScrollController _scrollController = + widget.scrollController ?? ScrollController(); + + _SuggestionsListState() { + this._controllerListener = () async { + // If we came here because of a change in selected text, not because of + // actual change in text + if (widget.controller!.text == this._lastTextValue) return; + + this._lastTextValue = widget.controller!.text; + + this._debounceTimer?.cancel(); + if (widget.controller!.text.length <= widget.minCharsForSuggestions!) { + if (mounted) { + Iterable? recentItems; + try { + if (widget.fetchRecentActionCallback != null) { + recentItems = await widget.fetchRecentActionCallback!(widget.controller!.text); + } + } catch (e) {} + + setState(() { + _isLoading = false; + _suggestions = null; + _recentItems = recentItems; + _suggestionsValid = true; + }); + } + return; + } else { + this._debounceTimer = Timer(widget.debounceDuration!, () async { + if (this._debounceTimer!.isActive) return; + if (_isLoading!) { + _isQueued = true; + return; + } + + await this.invalidateSuggestions(); + while (_isQueued!) { + _isQueued = false; + await this.invalidateSuggestions(); + } + }); + } + }; + } + + @override + void didUpdateWidget(_SuggestionsList oldWidget) { + super.didUpdateWidget(oldWidget); + widget.controller!.addListener(this._controllerListener); + _getSuggestions(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _getSuggestions(); + } + + @override + void initState() { + super.initState(); + + this._animationController = AnimationController( + vsync: this, + duration: widget.animationDuration, + ); + + this._suggestionsValid = widget.minCharsForSuggestions! > 0 ? true : false; + this._isLoading = false; + this._isQueued = false; + this._lastTextValue = widget.controller!.text; + + if (widget.getImmediateSuggestions) { + this._getSuggestions(); + } + + widget.controller!.addListener(this._controllerListener); + } + + Future invalidateSuggestions() async { + if (widget.hideSuggestionsBox) return; + _suggestionsValid = false; + await _getSuggestions(); + } + + Future _getSuggestions() async { + if (_suggestionsValid || widget.hideSuggestionsBox) return; + _suggestionsValid = true; + + if (mounted) { + setState(() { + this._animationController!.forward(from: 1.0); + + this._isLoading = true; + }); + + Iterable? suggestions; + Iterable? recentItems; + Object? error; + + try { + suggestions = await widget.suggestionsCallback!(widget.controller!.text); + if (widget.fetchRecentActionCallback != null) { + recentItems = await widget.fetchRecentActionCallback!(widget.controller!.text); + } + } catch (e) { + error = e; + } + + if (this.mounted) { + // if it wasn't removed in the meantime + setState(() { + double? animationStart = widget.animationStart; + // allow suggestionsCallback to return null and not throw error here + if (error != null || suggestions?.isEmpty == true) { + animationStart = 1.0; + } + this._animationController!.forward(from: animationStart); + + this._isLoading = false; + this._suggestions = suggestions; + this._recentItems = recentItems; + }); + } + } + } + + @override + void dispose() { + _animationController!.dispose(); + _debounceTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.hideSuggestionsBox) { + return SizedBox.shrink(); + } + + Widget child; + + if (this._suggestions?.isNotEmpty == true && widget.controller?.text.isNotEmpty == true) { + child = createSuggestionsWidget(); + } else { + child = createRecentWidget(); + } + + final animationChild = widget.transitionBuilder != null + ? widget.transitionBuilder!(context, child, this._animationController) + : SizeTransition( + axisAlignment: -1.0, + sizeFactor: CurvedAnimation( + parent: this._animationController!, + curve: Curves.fastOutSlowIn), + child: child, + ); + + BoxConstraints constraints; + if (widget.decoration!.constraints == null) { + constraints = BoxConstraints( + maxHeight: widget.suggestionsBox!.maxHeight, + ); + } else { + double maxHeight = min(widget.decoration!.constraints!.maxHeight, + widget.suggestionsBox!.maxHeight); + constraints = widget.decoration!.constraints!.copyWith( + minHeight: min(widget.decoration!.constraints!.minHeight, maxHeight), + maxHeight: maxHeight, + ); + } + + var container = Material( + elevation: widget.decoration!.elevation, + color: widget.decoration!.color, + shape: widget.decoration!.shape, + borderRadius: widget.decoration!.borderRadius, + shadowColor: widget.decoration!.shadowColor, + clipBehavior: widget.decoration!.clipBehavior, + child: ConstrainedBox( + constraints: constraints, + child: animationChild, + ), + ); + + return PointerInterceptor(child: container); + } + + Widget createSuggestionsWidget() { + final listItemSuggestionWidget = this._suggestions?.map((T suggestion) { + if ( widget.itemBuilder != null) { + return InkWell( + child: widget.itemBuilder!(context, suggestion), + onTap: () { + widget.onSuggestionSelected!(suggestion); + }, + ); + } else { + return SizedBox.shrink(); + } + }).toList() ?? []; + + final loadingWidget = widget.loadingBuilder != null + ? widget.loadingBuilder!(context) + : Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: CircularProgressIndicator(), + ), + ); + + final listAction = Row( + mainAxisSize: MainAxisSize.min, + children: widget.listActionButton!.map((dynamic action) { + if (widget.actionButtonBuilder != null) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: InkWell( + child: widget.actionButtonBuilder!(context, action), + borderRadius: BorderRadius.all(Radius.circular(10)), + onTap: () { + if (widget.buttonActionCallback != null) { + widget.buttonActionCallback!(action); + this.invalidateSuggestions(); + } + }, + ), + ); + } else { + return SizedBox.shrink(); + } + }).toList()); + + Widget child = ListView( + padding: EdgeInsets.zero, + primary: false, + shrinkWrap: true, + controller: _scrollController, + reverse: widget.suggestionsBox!.direction == AxisDirection.down ? false : true, // reverses the list to start at the bottom + children: [ + if (widget.listActionButton != null && widget.listActionButton?.isNotEmpty == true) + Padding( + padding: widget.listActionPadding ?? EdgeInsets.zero, + child: listAction, + ), + if (this._isLoading == true && widget.hideOnLoading == false && widget.keepSuggestionsOnLoading == false) + loadingWidget, + if (widget.buttonShowAllResult != null && widget.controller?.text.isNotEmpty == true) + widget.buttonShowAllResult!(context, widget.controller?.text), + if (listItemSuggestionWidget.isNotEmpty) + ... [ + ... listItemSuggestionWidget, + SizedBox(height: 16) + ], + ], + ); + + if (widget.decoration!.hasScrollbar) { + child = Scrollbar( + controller: _scrollController, + child: child, + ); + } + + return child; + } + + Widget createRecentWidget() { + final listItemRecent = this._recentItems?.map((R recent) { + if (widget.itemRecentBuilder != null) { + return InkWell( + child: widget.itemRecentBuilder!(context, recent), + onTap: () { + if (widget.onRecentSelected != null) { + widget.onRecentSelected!(recent); + } + }, + ); + } else { + return SizedBox.shrink(); + } + }).toList() ?? []; + + final loadingWidget = widget.loadingBuilder != null + ? widget.loadingBuilder!(context) + : Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: CircularProgressIndicator(), + ), + ); + + final listAction = Row( + mainAxisSize: MainAxisSize.min, + children: widget.listActionButton!.map((dynamic action) { + if (widget.actionButtonBuilder != null) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: InkWell( + child: widget.actionButtonBuilder!(context, action), + borderRadius: BorderRadius.all(Radius.circular(10)), + onTap: () { + if (widget.buttonActionCallback != null) { + widget.buttonActionCallback!(action); + this.invalidateSuggestions(); + } + }, + ), + ); + } else { + return SizedBox.shrink(); + } + }).toList()); + + Widget child = ListView( + padding: EdgeInsets.zero, + primary: false, + shrinkWrap: true, + controller: _scrollController, + reverse: widget.suggestionsBox!.direction == AxisDirection.down ? false : true, // reverses the list to start at the bottom + children: [ + if (widget.listActionButton != null && widget.listActionButton?.isNotEmpty == true) + Padding( + padding: widget.listActionPadding ?? EdgeInsets.zero, + child: listAction, + ), + if (this._isLoading == true && widget.hideOnLoading == false && widget.keepSuggestionsOnLoading == false) + loadingWidget, + if (widget.buttonShowAllResult != null && widget.controller?.text.isNotEmpty == true) + widget.buttonShowAllResult!(context, widget.controller?.text), + if (_recentItems?.isNotEmpty == true && widget.itemRecentBuilder != null && widget.titleHeaderRecent != null) + widget.titleHeaderRecent!, + if (listItemRecent.isNotEmpty) + ... [ + ... listItemRecent, + SizedBox(height: 16) + ], + ], + ); + + if (widget.decoration!.hasScrollbar) { + child = Scrollbar( + controller: _scrollController, + child: child, + ); + } + + return child; + } +} + +/// Supply an instance of this class to the [TypeAhead.suggestionsBoxDecoration] +/// property to configure the suggestions box decoration +class QuickSearchSuggestionsBoxDecoration { + /// The z-coordinate at which to place the suggestions box. This controls the size + /// of the shadow below the box. + /// + /// Same as [Material.elevation](https://docs.flutter.io/flutter/material/Material/elevation.html) + final double elevation; + + /// The color to paint the suggestions box. + /// + /// Same as [Material.color](https://docs.flutter.io/flutter/material/Material/color.html) + final Color? color; + + /// Defines the material's shape as well its shadow. + /// + /// Same as [Material.shape](https://docs.flutter.io/flutter/material/Material/shape.html) + final ShapeBorder? shape; + + /// Defines if a scrollbar will be displayed or not. + final bool hasScrollbar; + + /// If non-null, the corners of this box are rounded by this [BorderRadius](https://docs.flutter.io/flutter/painting/BorderRadius-class.html). + /// + /// Same as [Material.borderRadius](https://docs.flutter.io/flutter/material/Material/borderRadius.html) + final BorderRadius? borderRadius; + + /// The color to paint the shadow below the material. + /// + /// Same as [Material.shadowColor](https://docs.flutter.io/flutter/material/Material/shadowColor.html) + final Color shadowColor; + + /// The constraints to be applied to the suggestions box + final BoxConstraints? constraints; + + /// Adds an offset to the suggestions box + final double offsetX; + + /// The content will be clipped (or not) according to this option. + /// + /// Same as [Material.clipBehavior](https://api.flutter.dev/flutter/material/Material/clipBehavior.html) + final Clip clipBehavior; + + /// Creates a QuickSearchSuggestionsBoxDecoration + const QuickSearchSuggestionsBoxDecoration( + {this.elevation: 4.0, + this.color, + this.shape, + this.hasScrollbar: true, + this.borderRadius, + this.shadowColor: const Color(0xFF000000), + this.constraints, + this.clipBehavior: Clip.none, + this.offsetX: 0.0}); +} + +/// Supply an instance of this class to the [TypeAhead.textFieldConfiguration] +/// property to configure the displayed text field +class QuickSearchTextFieldConfiguration { + /// The decoration to show around the text field. + /// + /// Same as [TextField.decoration](https://docs.flutter.io/flutter/material/TextField/decoration.html) + final InputDecoration decoration; + + /// Controls the text being edited. + /// + /// If null, this widget will create its own [TextEditingController](https://docs.flutter.io/flutter/widgets/TextEditingController-class.html). + /// A typical use case for this field in the TypeAhead widget is to set the + /// text of the widget when a suggestion is selected. For example: + /// + /// ```dart + /// final _controller = TextEditingController(); + /// ... + /// ... + /// TypeAheadFieldQuickSearch( + /// controller: _controller, + /// ... + /// ... + /// onSuggestionSelected: (suggestion) { + /// _controller.text = suggestion['city_name']; + /// } + /// ) + /// ``` + final TextEditingController? controller; + + /// Controls whether this widget has keyboard focus. + /// + /// Same as [TextField.focusNode](https://docs.flutter.io/flutter/material/TextField/focusNode.html) + final FocusNode? focusNode; + + /// The style to use for the text being edited. + /// + /// Same as [TextField.style](https://docs.flutter.io/flutter/material/TextField/style.html) + final TextStyle? style; + + /// How the text being edited should be aligned horizontally. + /// + /// Same as [TextField.textAlign](https://docs.flutter.io/flutter/material/TextField/textAlign.html) + final TextAlign textAlign; + + /// Same as [TextField.textDirection](https://docs.flutter.io/flutter/material/TextField/textDirection.html) + /// + /// Defaults to null + final TextDirection? textDirection; + + /// Same as [TextField.textAlignVertical](https://api.flutter.dev/flutter/material/TextField/textAlignVertical.html) + final TextAlignVertical? textAlignVertical; + + /// If false the textfield is "disabled": it ignores taps and its + /// [decoration] is rendered in grey. + /// + /// Same as [TextField.enabled](https://docs.flutter.io/flutter/material/TextField/enabled.html) + final bool enabled; + + /// Whether to show input suggestions as the user types. + /// + /// Same as [TextField.enableSuggestions](https://api.flutter.dev/flutter/material/TextField/enableSuggestions.html) + final bool enableSuggestions; + + /// The type of keyboard to use for editing the text. + /// + /// Same as [TextField.keyboardType](https://docs.flutter.io/flutter/material/TextField/keyboardType.html) + final TextInputType keyboardType; + + /// Whether this text field should focus itself if nothing else is already + /// focused. + /// + /// Same as [TextField.autofocus](https://docs.flutter.io/flutter/material/TextField/autofocus.html) + final bool autofocus; + + /// Optional input validation and formatting overrides. + /// + /// Same as [TextField.inputFormatters](https://docs.flutter.io/flutter/material/TextField/inputFormatters.html) + final List? inputFormatters; + + /// Whether to enable autocorrection. + /// + /// Same as [TextField.autocorrect](https://docs.flutter.io/flutter/material/TextField/autocorrect.html) + final bool autocorrect; + + /// The maximum number of lines for the text to span, wrapping if necessary. + /// + /// Same as [TextField.maxLines](https://docs.flutter.io/flutter/material/TextField/maxLines.html) + final int? maxLines; + + /// The minimum number of lines to occupy when the content spans fewer lines. + /// + /// Same as [TextField.minLines](https://docs.flutter.io/flutter/material/TextField/minLines.html) + final int? minLines; + + /// The maximum number of characters (Unicode scalar values) to allow in the + /// text field. + /// + /// Same as [TextField.maxLength](https://docs.flutter.io/flutter/material/TextField/maxLength.html) + final int? maxLength; + + /// If true, prevents the field from allowing more than [maxLength] + /// characters. + /// + /// Same as [TextField.maxLengthEnforcement](https://api.flutter.dev/flutter/material/TextField/maxLengthEnforcement.html) + final MaxLengthEnforcement? maxLengthEnforcement; + + /// Whether to hide the text being edited (e.g., for passwords). + /// + /// Same as [TextField.obscureText](https://docs.flutter.io/flutter/material/TextField/obscureText.html) + final bool obscureText; + + /// Called when the text being edited changes. + /// + /// Same as [TextField.onChanged](https://docs.flutter.io/flutter/material/TextField/onChanged.html) + final ValueChanged? onChanged; + + /// Called when the user indicates that they are done editing the text in the + /// field. + /// + /// Same as [TextField.onSubmitted](https://docs.flutter.io/flutter/material/TextField/onSubmitted.html) + final ValueChanged? onSubmitted; + + /// The color to use when painting the cursor. + /// + /// Same as [TextField.cursorColor](https://docs.flutter.io/flutter/material/TextField/cursorColor.html) + final Color? cursorColor; + + /// How rounded the corners of the cursor should be. By default, the cursor has a null Radius + /// + /// Same as [TextField.cursorRadius](https://docs.flutter.io/flutter/material/TextField/cursorRadius.html) + final Radius? cursorRadius; + + /// How thick the cursor will be. + /// + /// Same as [TextField.cursorWidth](https://docs.flutter.io/flutter/material/TextField/cursorWidth.html) + final double cursorWidth; + + /// The appearance of the keyboard. + /// + /// Same as [TextField.keyboardAppearance](https://docs.flutter.io/flutter/material/TextField/keyboardAppearance.html) + final Brightness? keyboardAppearance; + + /// Called when the user submits editable content (e.g., user presses the "done" button on the keyboard). + /// + /// Same as [TextField.onEditingComplete](https://docs.flutter.io/flutter/material/TextField/onEditingComplete.html) + final VoidCallback? onEditingComplete; + + /// Called for each distinct tap except for every second tap of a double tap. + /// + /// Same as [TextField.onTap](https://docs.flutter.io/flutter/material/TextField/onTap.html) + final GestureTapCallback? onTap; + + /// Configures padding to edges surrounding a Scrollable when the Textfield scrolls into view. + /// + /// Same as [TextField.scrollPadding](https://docs.flutter.io/flutter/material/TextField/scrollPadding.html) + final EdgeInsets scrollPadding; + + /// Configures how the platform keyboard will select an uppercase or lowercase keyboard. + /// + /// Same as [TextField.TextCapitalization](https://docs.flutter.io/flutter/material/TextField/textCapitalization.html) + final TextCapitalization textCapitalization; + + /// The type of action button to use for the keyboard. + /// + /// Same as [TextField.textInputAction](https://docs.flutter.io/flutter/material/TextField/textInputAction.html) + final TextInputAction? textInputAction; + + final bool enableInteractiveSelection; + + final Widget? leftButton, rightButton; + final Widget? clearTextButton; + + /// Creates a QuickSearchTextFieldConfiguration + const QuickSearchTextFieldConfiguration({ + this.decoration: const InputDecoration(), + this.style, + this.controller, + this.onChanged, + this.onSubmitted, + this.obscureText: false, + this.maxLengthEnforcement, + this.maxLength, + this.maxLines: 1, + this.minLines, + this.textAlignVertical, + this.autocorrect: true, + this.inputFormatters, + this.autofocus: false, + this.keyboardType: TextInputType.text, + this.enabled: true, + this.enableSuggestions: true, + this.textAlign: TextAlign.start, + this.focusNode, + this.cursorColor, + this.cursorRadius, + this.textInputAction, + this.textCapitalization: TextCapitalization.none, + this.cursorWidth: 2.0, + this.keyboardAppearance, + this.onEditingComplete, + this.onTap, + this.textDirection, + this.scrollPadding: const EdgeInsets.all(20.0), + this.enableInteractiveSelection: true, + this.leftButton, + this.rightButton, + this.clearTextButton, + }); + + /// Copies the [QuickSearchTextFieldConfiguration] and only changes the specified + /// properties + QuickSearchTextFieldConfiguration copyWith( + {InputDecoration? decoration, + TextStyle? style, + TextEditingController? controller, + ValueChanged? onChanged, + ValueChanged? onSubmitted, + bool? obscureText, + MaxLengthEnforcement? maxLengthEnforcement, + int? maxLength, + int? maxLines, + int? minLines, + bool? autocorrect, + List? inputFormatters, + bool? autofocus, + TextInputType? keyboardType, + bool? enabled, + bool? enableSuggestions, + TextAlign? textAlign, + FocusNode? focusNode, + Color? cursorColor, + TextAlignVertical? textAlignVertical, + Radius? cursorRadius, + double? cursorWidth, + Brightness? keyboardAppearance, + VoidCallback? onEditingComplete, + GestureTapCallback? onTap, + EdgeInsets? scrollPadding, + TextCapitalization? textCapitalization, + TextDirection? textDirection, + TextInputAction? textInputAction, + bool? enableInteractiveSelection, + Widget? leftButton, + Widget? rightButton, + Widget? clearTextButton}) { + return QuickSearchTextFieldConfiguration( + decoration: decoration ?? this.decoration, + style: style ?? this.style, + controller: controller ?? this.controller, + onChanged: onChanged ?? this.onChanged, + onSubmitted: onSubmitted ?? this.onSubmitted, + obscureText: obscureText ?? this.obscureText, + maxLengthEnforcement: maxLengthEnforcement ?? this.maxLengthEnforcement, + maxLength: maxLength ?? this.maxLength, + maxLines: maxLines ?? this.maxLines, + minLines: minLines ?? this.minLines, + autocorrect: autocorrect ?? this.autocorrect, + inputFormatters: inputFormatters ?? this.inputFormatters, + autofocus: autofocus ?? this.autofocus, + keyboardType: keyboardType ?? this.keyboardType, + enabled: enabled ?? this.enabled, + enableSuggestions: enableSuggestions ?? this.enableSuggestions, + textAlign: textAlign ?? this.textAlign, + textAlignVertical: textAlignVertical ?? this.textAlignVertical, + focusNode: focusNode ?? this.focusNode, + cursorColor: cursorColor ?? this.cursorColor, + cursorRadius: cursorRadius ?? this.cursorRadius, + cursorWidth: cursorWidth ?? this.cursorWidth, + keyboardAppearance: keyboardAppearance ?? this.keyboardAppearance, + onEditingComplete: onEditingComplete ?? this.onEditingComplete, + onTap: onTap ?? this.onTap, + scrollPadding: scrollPadding ?? this.scrollPadding, + textCapitalization: textCapitalization ?? this.textCapitalization, + textInputAction: textInputAction ?? this.textInputAction, + textDirection: textDirection ?? this.textDirection, + enableInteractiveSelection: + enableInteractiveSelection ?? this.enableInteractiveSelection, + leftButton: leftButton ?? this.leftButton, + rightButton: rightButton ?? this.rightButton, + clearTextButton: clearTextButton ?? this.clearTextButton, + ); + } +} + +class _SuggestionsBox { + static const int waitMetricsTimeoutMillis = 1000; + static const double minOverlaySpace = 64.0; + + final BuildContext context; + final AxisDirection desiredDirection; + final bool autoFlipDirection; + final bool hideSuggestionBox; + + OverlayEntry? _overlayEntry; + AxisDirection direction; + + bool isOpened = false; + bool widgetMounted = true; + double maxHeight = 300.0; + double textBoxWidth = 100.0; + double textBoxHeight = 100.0; + late double directionUpOffset; + + _SuggestionsBox( + this.context, + this.direction, + this.autoFlipDirection, + this.hideSuggestionBox + ) : desiredDirection = direction; + + void open() { + if (this.hideSuggestionBox) return; + if (this.isOpened) return; + assert(this._overlayEntry != null); + resize(); + Overlay.of(context)!.insert(this._overlayEntry!); + this.isOpened = true; + } + + void close() { + if (!this.isOpened) return; + assert(this._overlayEntry != null); + this._overlayEntry!.remove(); + this.isOpened = false; + } + + void toggle() { + if (this.isOpened) { + this.close(); + } else { + this.open(); + } + } + + MediaQuery? _findRootMediaQuery() { + MediaQuery? rootMediaQuery; + context.visitAncestorElements((element) { + if (element.widget is MediaQuery) { + rootMediaQuery = element.widget as MediaQuery; + } + return true; + }); + + return rootMediaQuery; + } + + /// Delays until the keyboard has toggled or the orientation has fully changed + Future _waitChangeMetrics() async { + if (widgetMounted) { + // initial viewInsets which are before the keyboard is toggled + EdgeInsets initial = MediaQuery.of(context).viewInsets; + // initial MediaQuery for orientation change + MediaQuery? initialRootMediaQuery = _findRootMediaQuery(); + + int timer = 0; + // viewInsets or MediaQuery have changed once keyboard has toggled or orientation has changed + while (widgetMounted && timer < waitMetricsTimeoutMillis) { + // reduce delay if showDialog ever exposes detection of animation end + await Future.delayed(const Duration(milliseconds: 170)); + timer += 170; + + if (widgetMounted && + (MediaQuery.of(context).viewInsets != initial || + _findRootMediaQuery() != initialRootMediaQuery)) { + return true; + } + } + } + + return false; + } + + void resize() { + // check to see if widget is still mounted + // user may have closed the widget with the keyboard still open + if (widgetMounted) { + _adjustMaxHeightAndOrientation(); + _overlayEntry!.markNeedsBuild(); + } + } + + // See if there's enough room in the desired direction for the overlay to display + // correctly. If not, try the opposite direction if things look more roomy there + void _adjustMaxHeightAndOrientation() { + TypeAheadFieldQuickSearch widget = context.widget as TypeAheadFieldQuickSearch; + + RenderBox? box = context.findRenderObject() as RenderBox?; + if (box == null || box.hasSize == false) { + return; + } + + textBoxWidth = box.size.width; + textBoxHeight = box.size.height; + + // top of text box + double textBoxAbsY = box.localToGlobal(Offset.zero).dy; + + // height of window + double windowHeight = MediaQuery.of(context).size.height; + + // we need to find the root MediaQuery for the unsafe area height + // we cannot use BuildContext.ancestorWidgetOfExactType because + // widgets like SafeArea creates a new MediaQuery with the padding removed + MediaQuery rootMediaQuery = _findRootMediaQuery()!; + + // height of keyboard + double keyboardHeight = rootMediaQuery.data.viewInsets.bottom; + + double maxHDesired = _calculateMaxHeight(desiredDirection, box, widget, + windowHeight, rootMediaQuery, keyboardHeight, textBoxAbsY); + + // if there's enough room in the desired direction, update the direction and the max height + if (maxHDesired >= minOverlaySpace || !autoFlipDirection) { + direction = desiredDirection; + maxHeight = maxHDesired; + } else { + // There's not enough room in the desired direction so see how much room is in the opposite direction + AxisDirection flipped = flipAxisDirection(desiredDirection); + double maxHFlipped = _calculateMaxHeight(flipped, box, widget, + windowHeight, rootMediaQuery, keyboardHeight, textBoxAbsY); + + // if there's more room in this opposite direction, update the direction and maxHeight + if (maxHFlipped > maxHDesired) { + direction = flipped; + maxHeight = maxHFlipped; + } + } + + if (maxHeight < 0) maxHeight = 0; + } + + double _calculateMaxHeight( + AxisDirection direction, + RenderBox box, + TypeAheadFieldQuickSearch widget, + double windowHeight, + MediaQuery rootMediaQuery, + double keyboardHeight, + double textBoxAbsY) { + return direction == AxisDirection.down + ? _calculateMaxHeightDown(box, widget, windowHeight, rootMediaQuery, + keyboardHeight, textBoxAbsY) + : _calculateMaxHeightUp(box, widget, windowHeight, rootMediaQuery, + keyboardHeight, textBoxAbsY); + } + + double _calculateMaxHeightDown( + RenderBox box, + TypeAheadFieldQuickSearch widget, + double windowHeight, + MediaQuery rootMediaQuery, + double keyboardHeight, + double textBoxAbsY) { + // unsafe area, ie: iPhone X 'home button' + // keyboardHeight includes unsafeAreaHeight, if keyboard is showing, set to 0 + double unsafeAreaHeight = + keyboardHeight == 0 ? rootMediaQuery.data.padding.bottom : 0; + + return windowHeight - + keyboardHeight - + unsafeAreaHeight - + textBoxHeight - + textBoxAbsY - + 2 * widget.suggestionsBoxVerticalOffset; + } + + double _calculateMaxHeightUp( + RenderBox box, + TypeAheadFieldQuickSearch widget, + double windowHeight, + MediaQuery rootMediaQuery, + double keyboardHeight, + double textBoxAbsY) { + // recalculate keyboard absolute y value + double keyboardAbsY = windowHeight - keyboardHeight; + + directionUpOffset = textBoxAbsY > keyboardAbsY + ? keyboardAbsY - textBoxAbsY - widget.suggestionsBoxVerticalOffset + : -widget.suggestionsBoxVerticalOffset; + + // unsafe area, ie: iPhone X notch + double unsafeAreaHeight = rootMediaQuery.data.padding.top; + + return textBoxAbsY > keyboardAbsY + ? keyboardAbsY - + unsafeAreaHeight - + 2 * widget.suggestionsBoxVerticalOffset + : textBoxAbsY - + unsafeAreaHeight - + 2 * widget.suggestionsBoxVerticalOffset; + } + + Future onChangeMetrics() async { + if (await _waitChangeMetrics()) { + resize(); + } + } +} + +/// Supply an instance of this class to the [TypeAhead.suggestionsBoxController] +/// property to manually control the suggestions box +class QuickSearchSuggestionsBoxController { + _SuggestionsBox? _suggestionsBox; + FocusNode? _effectiveFocusNode; + + /// Opens the suggestions box + void open() { + _effectiveFocusNode!.requestFocus(); + } + + bool isOpened() { + return _suggestionsBox!.isOpened; + } + + /// Closes the suggestions box + void close() { + _effectiveFocusNode!.unfocus(); + } + + /// Opens the suggestions box if closed and vice-versa + void toggle() { + if (_suggestionsBox!.isOpened) { + close(); + } else { + open(); + } + } + + /// Recalculates the height of the suggestions box + void resize() { + _suggestionsBox!.resize(); + } +} diff --git a/core/lib/presentation/views/typeahead/typedef.dart b/core/lib/presentation/views/typeahead/typedef.dart new file mode 100644 index 000000000..5fc9dd714 --- /dev/null +++ b/core/lib/presentation/views/typeahead/typedef.dart @@ -0,0 +1,15 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +typedef FutureOr> SuggestionsCallback(String pattern); +typedef FutureOr> FetchRecentActionCallback(String pattern); +typedef Widget ItemBuilder(BuildContext context, T itemData); +typedef void SuggestionSelectionCallback(T suggestion); +typedef void RecentSelectionCallback(R recent); +typedef Widget ErrorBuilder(BuildContext context, Object? error); +typedef Widget ButtonActionBuilder(BuildContext context, dynamic action); +typedef void ButtonActionCallback(dynamic action); + +typedef Widget AnimationTransitionBuilder( + BuildContext context, Widget child, AnimationController? controller); diff --git a/core/lib/presentation/views/typeahead/utils.dart b/core/lib/presentation/views/typeahead/utils.dart new file mode 100644 index 000000000..f8a3062c3 --- /dev/null +++ b/core/lib/presentation/views/typeahead/utils.dart @@ -0,0 +1,5 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +final supportedPlatform = (kIsWeb || Platform.isAndroid || Platform.isIOS); diff --git a/lib/features/caching/recent_search_cache_client.dart b/lib/features/caching/recent_search_cache_client.dart new file mode 100644 index 000000000..63b6bb213 --- /dev/null +++ b/lib/features/caching/recent_search_cache_client.dart @@ -0,0 +1,111 @@ + +import 'package:hive/hive.dart'; +import 'package:tmail_ui_user/features/caching/config/hive_cache_client.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/data/model/recent_search_cache.dart'; + +class RecentSearchCacheClient extends HiveCacheClient { + + @override + String get tableName => 'RecentSearchCache'; + + @override + Future clearAllData() { + return Future.sync(() async { + final boxState = await openBox(); + boxState.clear(); + }).catchError((error) { + throw error; + }); + } + + @override + Future deleteItem(String key) { + return Future.sync(() async { + final boxState = await openBox(); + return boxState.delete(key); + }).catchError((error) { + throw error; + }); + } + + @override + Future deleteMultipleItem(List listKey) { + throw UnimplementedError(); + } + + @override + Future> getAll() { + return Future.sync(() async { + final boxState = await openBox(); + return boxState.values.toList(); + }).catchError((error) { + throw error; + }); + } + + @override + Future getItem(String key) { + throw UnimplementedError(); + } + + @override + Future insertItem(String key, RecentSearchCache newObject) { + return Future.sync(() async { + final boxState = await openBox(); + boxState.put(key, newObject); + }).catchError((error) { + throw error; + }); + } + + @override + Future insertMultipleItem(Map mapObject) { + throw UnimplementedError(); + } + + @override + Future isExistItem(String key) { + return Future.sync(() async { + final boxState = await openBox(); + return boxState.containsKey(key); + }).catchError((error) { + throw error; + }); + } + + @override + Future isExistTable() { + return Future.sync(() async { + return await Hive.boxExists(tableName); + }).catchError((error) { + throw error; + }); + } + + @override + Future> openBox() { + return Future.sync(() async { + if (Hive.isBoxOpen(tableName)) { + return Hive.box(tableName); + } + return await Hive.openBox(tableName); + }).catchError((error) { + throw error; + }); + } + + @override + Future updateItem(String key, RecentSearchCache newObject) { + return Future.sync(() async { + final boxState = await openBox(); + boxState.put(key, newObject); + }).catchError((error) { + throw error; + }); + } + + @override + Future updateMultipleItem(Map mapObject) { + throw UnimplementedError(); + } +} \ No newline at end of file diff --git a/lib/features/caching/utils/caching_constants.dart b/lib/features/caching/utils/caching_constants.dart index 15f29baa9..636083f62 100644 --- a/lib/features/caching/utils/caching_constants.dart +++ b/lib/features/caching/utils/caching_constants.dart @@ -6,4 +6,5 @@ class CachingConstants { static const int STATE_TYPE_IDENTIFY = 4; static const int EMAIL_CACHE_IDENTIFY = 5; static const int EMAIL_ADDRESS_HIVE_CACHE_IDENTIFY = 6; + static const int RECENT_SEARCH_HIVE_CACHE_IDENTIFY = 7; } \ No newline at end of file diff --git a/lib/features/mailbox/presentation/widgets/search_form_widget_builder.dart b/lib/features/mailbox/presentation/widgets/search_form_widget_builder.dart deleted file mode 100644 index 3e44f6a60..000000000 --- a/lib/features/mailbox/presentation/widgets/search_form_widget_builder.dart +++ /dev/null @@ -1,64 +0,0 @@ - -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; -import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; - -typedef OnNewSearchQuery = Function(String); - -class SearchFormWidgetBuilder { - - final _typeAheadController = TextEditingController(); - final BuildContext _context; - final ImagePaths _imagePaths; - - OnNewSearchQuery? _onNewSearchQuery; - - SearchFormWidgetBuilder(this._context, this._imagePaths); - - SearchFormWidgetBuilder onNewSearchQuery(OnNewSearchQuery onNewSearchQuery) { - _onNewSearchQuery = onNewSearchQuery; - return this; - } - - Widget build() { - return Container( - key: const Key('search_folder_form'), - alignment: Alignment.center, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColor.searchBorderColor), - color: AppColor.primaryLightColor), - child: TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: _typeAheadController, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - contentPadding: const EdgeInsets.only(left: 0, top: 15, bottom: 15, right: 15), - hintText: AppLocalizations.of(_context).search_folder, - hintStyle: const TextStyle(color: AppColor.searchHintTextColor, fontSize: 15.0, fontWeight: FontWeight.w500), - icon: Padding( - padding: const EdgeInsets.only(left: 20), - child: SvgPicture.asset(_imagePaths.icSearch, width: 24, height: 24, fit: BoxFit.fill), - ))), - debounceDuration: const Duration(milliseconds: 300), - suggestionsCallback: (pattern) async { - if (_onNewSearchQuery != null) { - _onNewSearchQuery!(pattern); - } - return []; - }, - itemBuilder: (BuildContext context, itemData) => const SizedBox.shrink(), - onSuggestionSelected: (suggestion) {}, - noItemsFoundBuilder: (context) => const SizedBox(), - hideOnEmpty: true, - hideOnError: true, - hideOnLoading: true, - hideSuggestionsOnKeyboardHide: true, - )); - } -} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/data/model/recent_search_cache.dart b/lib/features/mailbox_dashboard/data/model/recent_search_cache.dart new file mode 100644 index 000000000..dba8c07a7 --- /dev/null +++ b/lib/features/mailbox_dashboard/data/model/recent_search_cache.dart @@ -0,0 +1,21 @@ + +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; +import 'package:tmail_ui_user/features/caching/utils/caching_constants.dart'; + +part 'recent_search_cache.g.dart'; + +@HiveType(typeId: CachingConstants.RECENT_SEARCH_HIVE_CACHE_IDENTIFY) +class RecentSearchCache extends HiveObject with EquatableMixin { + + @HiveField(0) + final String value; + + @HiveField(1) + final DateTime creationTime; + + RecentSearchCache(this.value, this.creationTime); + + @override + List get props => [value, creationTime]; +} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/domain/model/recent_search.dart b/lib/features/mailbox_dashboard/domain/model/recent_search.dart new file mode 100644 index 000000000..4d226109b --- /dev/null +++ b/lib/features/mailbox_dashboard/domain/model/recent_search.dart @@ -0,0 +1,12 @@ + +import 'package:equatable/equatable.dart'; + +class RecentSearch with EquatableMixin { + final String value; + final DateTime creationDate; + + RecentSearch(this.value, this.creationDate); + + @override + List get props => [value, creationDate]; +} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart b/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart index 99f197d06..3aae64f5a 100644 --- a/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart +++ b/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart @@ -1,21 +1,24 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:get/get.dart'; import 'package:model/model.dart'; import 'package:tmail_ui_user/features/base/mixin/network_connection_mixin.dart'; import 'package:tmail_ui_user/features/composer/presentation/composer_view_web.dart'; import 'package:tmail_ui_user/features/email/presentation/email_view.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_view_web.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/model/recent_search.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/action/dashboard_action.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/mailbox_dashboard_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/mixin/filter_email_popup_menu_mixin.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/mixin/user_setting_popup_menu_mixin.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/email_quick_search_item_tile_widget.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/recent_search_item_tile_widget.dart'; import 'package:tmail_ui_user/features/manage_account/presentation/model/app_setting.dart'; import 'package:tmail_ui_user/features/manage_account/presentation/model/reading_pane.dart'; import 'package:tmail_ui_user/features/thread/presentation/extensions/filter_message_option_extension.dart'; import 'package:tmail_ui_user/features/thread/presentation/thread_view.dart'; -import 'package:tmail_ui_user/features/thread/presentation/widgets/search_app_bar_widget.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/routes/app_routes.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; @@ -157,7 +160,9 @@ class MailboxDashBoardView extends GetWidget with Ne }), const SizedBox(width: 16), Obx(() => !controller.isSearchActive() ? const Spacer() : const SizedBox.shrink()), - Obx(() => controller.isSearchActive() ? Expanded(child: _buildSearchForm(context)) : const SizedBox.shrink()), + Obx(() => controller.isSearchActive() + ? Expanded(child: _buildSearchForm(context)) + : const SizedBox.shrink()), Obx(() => !controller.isSearchActive() ? (SearchBarView(_imagePaths) ..hintTextSearch(AppLocalizations.of(context).search_emails) @@ -396,24 +401,119 @@ class MailboxDashBoardView extends GetWidget with Ne } Widget _buildSearchForm(BuildContext context) { - return (SearchAppBarWidget(context, _imagePaths, _responsiveUtils, - controller.searchQuery, - controller.searchFocus, - controller.searchInputController, - suggestionSearch: controller.suggestionSearch) - ..addDecoration(const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(10)), - color: AppColor.colorBgSearchBar)) - ..setMargin(const EdgeInsets.only(right: 10)) - ..setHeightSearchBar(45) - ..setHintText(AppLocalizations.of(context).search_mail) - ..addOnCancelSearchPressed(() { - controller.disableSearch(); - controller.dispatchAction(CancelSelectionAllEmailAction()); - }) - ..addOnClearTextSearchAction(() => controller.clearSearchText()) - ..addOnTextChangeSearchAction((query) => controller.addSuggestionSearch(query)) - ..addOnSearchTextAction((query) => controller.searchEmail(context, query))) - .build(); + return Row( + children: [ + buildIconWeb( + icon: SvgPicture.asset(_imagePaths.icBack, color: AppColor.colorTextButton, fit: BoxFit.fill), + onTap: () { + controller.disableSearch(); + controller.dispatchAction(CancelSelectionAllEmailAction()); + }), + Expanded(child: Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(10)), + color: AppColor.colorBgSearchBar), + height: 45, + child: TypeAheadFormFieldQuickSearch( + textFieldConfiguration: QuickSearchTextFieldConfiguration( + controller: controller.searchInputController, + autofocus: true, + focusNode: controller.searchFocus, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + hintText: AppLocalizations.of(context).search_mail, + hintStyle: const TextStyle(color: AppColor.colorHintSearchBar, fontSize: 17.0), + labelStyle: const TextStyle(color: AppColor.colorHintSearchBar, fontSize: 17.0) + ), + leftButton: buildIconWeb( + icon: SvgPicture.asset(_imagePaths.icSearchBar, width: 16, height: 16, fit: BoxFit.fill), + onTap: () => {}), + rightButton: buildIconWeb( + icon: SvgPicture.asset(_imagePaths.icComposerClose, width: 18, height: 18, fit: BoxFit.fill), + onTap: () { + controller.searchInputController.clear(); + controller.clearSearchText(); + }) + ), + suggestionsBoxDecoration: QuickSearchSuggestionsBoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.all(Radius.circular(16)), + constraints: BoxConstraints( + maxWidth: _responsiveUtils.isDesktop(context) ? 556 : double.infinity, + maxHeight: 322), + ), + actionButton: Padding( + padding: const EdgeInsets.only(left: 24, top: 24, right: 24, bottom: 20), + child: Row( + children: [ + (ButtonBuilder(_imagePaths.icAttachmentSB) + ..decoration(BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: AppColor.colorButtonHeaderThread)) + ..paddingIcon(const EdgeInsets.only(right: 5)) + ..padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 8)) + ..radiusSplash(10) + ..size(16) + ..textStyle(const TextStyle(fontSize: 13, color: AppColor.colorTextButtonHeaderThread)) + ..onPressActionClick(() => {}) + ..text(AppLocalizations.of(context).hasAttachment, isVertical: false)) + .build(), + const SizedBox(width: 8), + (ButtonBuilder(_imagePaths.icCalendarSB) + ..decoration(BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: AppColor.colorButtonHeaderThread)) + ..paddingIcon(const EdgeInsets.only(right: 5)) + ..padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 8)) + ..radiusSplash(10) + ..size(16) + ..textStyle(const TextStyle(fontSize: 13, color: AppColor.colorTextButtonHeaderThread)) + ..onPressActionClick(() => {}) + ..text(AppLocalizations.of(context).last7Days, isVertical: false)) + .build(), + const SizedBox(width: 8), + (ButtonBuilder(_imagePaths.icUserSB) + ..decoration(BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: AppColor.colorButtonHeaderThread)) + ..paddingIcon(const EdgeInsets.only(right: 5)) + ..padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 8)) + ..radiusSplash(10) + ..size(16) + ..textStyle(const TextStyle(fontSize: 13, color: AppColor.colorTextButtonHeaderThread)) + ..onPressActionClick(() => {}) + ..text(AppLocalizations.of(context).fromMe, isVertical: false)) + .build(), + ], + ), + ), + titleHeaderRecent: Padding( + padding: const EdgeInsets.only(left: 24, right: 24, bottom: 8), + child: Text(AppLocalizations.of(context).recent, + style: const TextStyle(fontSize: 13.0, + color: AppColor.colorTextButtonHeaderThread, + fontWeight: FontWeight.w500))), + getRecentCallback: (pattern) async { + return []; + }, + itemRecentBuilder: (context, recent) { + return RecentSearchItemTileWidget(recent); + }, + suggestionsCallback: (pattern) async { + return []; + }, + itemBuilder: (context, email) { + return EmailQuickSearchItemTileWidget(email, controller.selectedMailbox.value); + }, + onSuggestionSelected: (autoCompleteResult) async { + }), + )), + const SizedBox(width: 16), + ] + ); } } \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/widgets/email_quick_search_item_tile_widget.dart b/lib/features/mailbox_dashboard/presentation/widgets/email_quick_search_item_tile_widget.dart new file mode 100644 index 000000000..0fb8f94eb --- /dev/null +++ b/lib/features/mailbox_dashboard/presentation/widgets/email_quick_search_item_tile_widget.dart @@ -0,0 +1,92 @@ + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:model/extensions/presentation_email_extension.dart'; +import 'package:model/mailbox/presentation_mailbox.dart'; + +class EmailQuickSearchItemTileWidget extends StatelessWidget { + + final imagePath = Get.find(); + + final PresentationEmail _presentationEmail; + final PresentationMailbox? _presentationMailbox; + + EmailQuickSearchItemTileWidget( + this._presentationEmail, + this._presentationMailbox, + {Key? key} + ) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SvgPicture.asset( + _presentationEmail.hasStarred ? imagePath.icStar : imagePath.icUnStar, + width: 20, height: 20, fit: BoxFit.fill), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Text(_getInformationSender(), + maxLines: 1, + overflow: BuildUtils.isWeb ? null : TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black)), + const SizedBox(width: 16), + Expanded( + child: Text(_presentationEmail.getEmailTitle(), + maxLines: 1, + overflow: BuildUtils.isWeb ? null : TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.normal, + color: Colors.black)), + ), + if (_presentationEmail.hasAttachment == true) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SvgPicture.asset(imagePath.icAttachment, width: 14, height: 14, fit: BoxFit.fill), + ), + Text(_presentationEmail.getReceivedAt(Localizations.localeOf(context).toLanguageTag()), + textAlign: TextAlign.right, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.normal, + color: Colors.black)) + ]), + const SizedBox(height: 3), + Text(_presentationEmail.getPartialContent(), + maxLines: 1, + overflow: BuildUtils.isWeb ? null : TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.normal, + color: AppColor.colorContentEmail)) + ] + ), + ), + ], + ), + ); + } + + String _getInformationSender() { + if (_presentationMailbox?.role == PresentationMailbox.roleSent + || _presentationMailbox?.role == PresentationMailbox.roleDrafts + || _presentationMailbox?.role == PresentationMailbox.roleOutbox) { + return _presentationEmail.recipientsName(); + } + return _presentationEmail.getSenderName(); + } +} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/widgets/recent_search_item_tile_widget.dart b/lib/features/mailbox_dashboard/presentation/widgets/recent_search_item_tile_widget.dart new file mode 100644 index 000000000..bd1314797 --- /dev/null +++ b/lib/features/mailbox_dashboard/presentation/widgets/recent_search_item_tile_widget.dart @@ -0,0 +1,35 @@ + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/model/recent_search.dart'; + +class RecentSearchItemTileWidget extends StatelessWidget { + + final imagePath = Get.find(); + + final RecentSearch recentSearch; + + RecentSearchItemTileWidget(this.recentSearch, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + child: Row( + children: [ + SvgPicture.asset(imagePath.icClockSB), + const SizedBox(width: 8), + Expanded( + child: Text(recentSearch.value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.normal, + color: Colors.black)), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index 17e60be24..2194cf26d 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2022-05-06T16:58:28.388035", + "@@last_modified": "2022-05-23T18:43:32.474820", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -60,6 +60,24 @@ "placeholders_order": [], "placeholders": {} }, + "requiredEmail": "Email is required", + "@requiredEmail": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "requiredPassword": "Password is required", + "@requiredPassword": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "requiredUrl": "Server address is required", + "@requiredUrl": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "jmapBasedMailSolution": "JMAP-based\ncollaborative team mail solution", "@jmapBasedMailSolution": { "type": "text", @@ -1275,5 +1293,29 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "hasAttachment": "Has attachment", + "@hasAttachment": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "last7Days": "Last 7 days", + "@last7Days": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "fromMe": "From me", + "@fromMe": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "recent": "Recent", + "@recent": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 53c5f3c3c..05b464dad 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -1326,4 +1326,28 @@ class AppLocalizations { 'Save', name: 'save'); } + + String get hasAttachment { + return Intl.message( + 'Has attachment', + name: 'hasAttachment'); + } + + String get last7Days { + return Intl.message( + 'Last 7 days', + name: 'last7Days'); + } + + String get fromMe { + return Intl.message( + 'From me', + name: 'fromMe'); + } + + String get recent { + return Intl.message( + 'Recent', + name: 'recent'); + } } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 2fbabd59a..44904e658 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,9 +64,6 @@ dependencies: # flutter_dotenv flutter_dotenv: 5.0.2 - # flutter_typeahead - flutter_typeahead: 3.1.1 - built_collection: 5.1.1 # jmap_dart_client diff --git a/test/features/thread/domain/usecases/get_emails_in_mailbox_interactor_test.mocks.dart b/test/features/thread/domain/usecases/get_emails_in_mailbox_interactor_test.mocks.dart index 08478dca1..75619bd3e 100644 --- a/test/features/thread/domain/usecases/get_emails_in_mailbox_interactor_test.mocks.dart +++ b/test/features/thread/domain/usecases/get_emails_in_mailbox_interactor_test.mocks.dart @@ -58,25 +58,6 @@ class MockThreadRepository extends _i1.Mock implements _i2.ThreadRepository { returnValue: Stream<_i4.EmailsResponse>.empty()) as _i3.Stream<_i4.EmailsResponse>); @override - _i3.Stream<_i4.EmailsResponse> refreshAll(_i5.AccountId? accountId, - {_i6.UnsignedInt? limit, - Set<_i7.Comparator>? sort, - _i8.EmailFilter? emailFilter, - _i9.Properties? propertiesCreated, - _i9.Properties? propertiesUpdated}) => - (super.noSuchMethod( - Invocation.method(#refreshAll, [ - accountId - ], { - #limit: limit, - #sort: sort, - #emailFilter: emailFilter, - #propertiesCreated: propertiesCreated, - #propertiesUpdated: propertiesUpdated - }), - returnValue: Stream<_i4.EmailsResponse>.empty()) - as _i3.Stream<_i4.EmailsResponse>); - @override _i3.Stream<_i4.EmailsResponse> refreshChanges( _i5.AccountId? accountId, _i10.State? currentState, {Set<_i7.Comparator>? sort, diff --git a/test/features/thread/domain/usecases/refresh_changes_emails_in_mailbox_interactor_test.mocks.dart b/test/features/thread/domain/usecases/refresh_changes_emails_in_mailbox_interactor_test.mocks.dart index 2189115d9..0134e9d3e 100644 --- a/test/features/thread/domain/usecases/refresh_changes_emails_in_mailbox_interactor_test.mocks.dart +++ b/test/features/thread/domain/usecases/refresh_changes_emails_in_mailbox_interactor_test.mocks.dart @@ -58,25 +58,6 @@ class MockThreadRepository extends _i1.Mock implements _i2.ThreadRepository { returnValue: Stream<_i4.EmailsResponse>.empty()) as _i3.Stream<_i4.EmailsResponse>); @override - _i3.Stream<_i4.EmailsResponse> refreshAll(_i5.AccountId? accountId, - {_i6.UnsignedInt? limit, - Set<_i7.Comparator>? sort, - _i8.EmailFilter? emailFilter, - _i9.Properties? propertiesCreated, - _i9.Properties? propertiesUpdated}) => - (super.noSuchMethod( - Invocation.method(#refreshAll, [ - accountId - ], { - #limit: limit, - #sort: sort, - #emailFilter: emailFilter, - #propertiesCreated: propertiesCreated, - #propertiesUpdated: propertiesUpdated - }), - returnValue: Stream<_i4.EmailsResponse>.empty()) - as _i3.Stream<_i4.EmailsResponse>); - @override _i3.Stream<_i4.EmailsResponse> refreshChanges( _i5.AccountId? accountId, _i10.State? currentState, {Set<_i7.Comparator>? sort,