From c844f2fba0fc8c9bb4e73b51311844ec90823a29 Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 25 May 2022 11:03:16 +0700 Subject: [PATCH] Create QuickSearchInputForm widget --- .../quick_search/quick_search_input_form.dart | 1859 +++++++++++++++++ core/pubspec.yaml | 2 + 2 files changed, 1861 insertions(+) create mode 100644 core/lib/presentation/views/quick_search/quick_search_input_form.dart diff --git a/core/lib/presentation/views/quick_search/quick_search_input_form.dart b/core/lib/presentation/views/quick_search/quick_search_input_form.dart new file mode 100644 index 000000000..b5bff63a1 --- /dev/null +++ b/core/lib/presentation/views/quick_search/quick_search_input_form.dart @@ -0,0 +1,1859 @@ + +import 'dart:async'; +import 'dart:math'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +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'; + +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); + +final supportedPlatform = (kIsWeb || Platform.isAndroid || Platform.isIOS); + +/// 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 QuickSearchInputForm 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 [QuickSearchInputForm] + QuickSearchInputForm( + {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 + QuickSearchInputForm get widget => super.widget as QuickSearchInputForm; + + @override + void initState() { + super.initState(); + if (widget.textFieldConfiguration.controller == null) { + _controller = TextEditingController(text: widget.initialValue); + } else { + widget.textFieldConfiguration.controller! + .addListener(_handleControllerChanged); + } + } + + @override + void didUpdateWidget(QuickSearchInputForm 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: +/// +/// * [QuickSearchInputForm], 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(); + } +} \ No newline at end of file diff --git a/core/pubspec.yaml b/core/pubspec.yaml index 59d8e669b..95b1a48ce 100644 --- a/core/pubspec.yaml +++ b/core/pubspec.yaml @@ -76,6 +76,8 @@ dependencies: pointer_interceptor: 0.9.1 + flutter_keyboard_visibility: 5.2.0 + dev_dependencies: flutter_test: sdk: flutter