Fix jmap request to server is called when query string is empty in quick search web (#3276)
This commit is contained in:
@@ -240,6 +240,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
debounce_throttle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: debounce_throttle
|
||||
sha256: c95cf47afda975fc507794a52040a16756fb2f31ad3027d4e691c41862ff5692
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1017,6 +1025,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
simple_observable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: simple_observable
|
||||
sha256: b392795c48f8b5f301b4c8f73e15f56e38fe70f42278c649d8325e859a783301
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core/presentation/views/quick_search/quick_search_suggestion_list.dart';
|
||||
import 'package:core/utils/platform_info.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef SuggestionsCallback<T> = FutureOr<List<T>> Function(String pattern);
|
||||
|
||||
typedef FetchRecentActionCallback<R> = FutureOr<List<R>> Function(
|
||||
String pattern,
|
||||
);
|
||||
|
||||
typedef ItemBuilder<T> = Widget Function(BuildContext context, T itemData);
|
||||
|
||||
typedef SuggestionSelectionCallback<T> = void Function(T suggestion);
|
||||
|
||||
typedef RecentSelectionCallback<R> = void Function(R recent);
|
||||
|
||||
typedef ErrorBuilder = Widget Function(BuildContext context, Object? error);
|
||||
|
||||
typedef ButtonActionBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
dynamic action,
|
||||
QuickSearchSuggestionListState suggestionsListState,
|
||||
);
|
||||
|
||||
typedef ButtonActionCallback = void Function(dynamic action);
|
||||
|
||||
typedef AnimationTransitionBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
AnimationController? controller,
|
||||
);
|
||||
|
||||
final supportedPlatform =
|
||||
(PlatformInfo.isWeb || PlatformInfo.isAndroid || PlatformInfo.isIOS);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
import 'package:core/presentation/views/quick_search/type_ahead_field_quick_search.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class QuickSearchSuggestionsBox {
|
||||
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;
|
||||
|
||||
QuickSearchSuggestionsBox(
|
||||
this.context,
|
||||
this.direction,
|
||||
this.autoFlipDirection,
|
||||
this.hideSuggestionBox,
|
||||
) : desiredDirection = direction;
|
||||
|
||||
void open() {
|
||||
if (hideSuggestionBox) return;
|
||||
if (isOpened) return;
|
||||
assert(overlayEntry != null);
|
||||
resize();
|
||||
Overlay.of(context).insert(overlayEntry!);
|
||||
isOpened = true;
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (!isOpened) return;
|
||||
assert(overlayEntry != null);
|
||||
overlayEntry!.remove();
|
||||
isOpened = false;
|
||||
}
|
||||
|
||||
void toggle() {
|
||||
if (isOpened) {
|
||||
close();
|
||||
} else {
|
||||
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<bool> _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<void>.delayed(const Duration(milliseconds: 170));
|
||||
timer += 170;
|
||||
|
||||
if (widgetMounted &&
|
||||
context.mounted &&
|
||||
(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<void> onChangeMetrics() async {
|
||||
if (await _waitChangeMetrics()) {
|
||||
resize();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:core/presentation/views/quick_search/quick_search_suggestion_box.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Supply an instance of this class to the [TypeAhead.suggestionsBoxController]
|
||||
/// property to manually control the suggestions box
|
||||
class QuickSearchSuggestionsBoxController {
|
||||
QuickSearchSuggestionsBox? _suggestionsBox;
|
||||
FocusNode? _effectiveFocusNode;
|
||||
|
||||
set suggestionsBox(QuickSearchSuggestionsBox? value) {
|
||||
_suggestionsBox = value;
|
||||
}
|
||||
|
||||
set effectiveFocusNode(FocusNode? value) {
|
||||
_effectiveFocusNode = value;
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:core/presentation/views/quick_search/quick_search_action_define.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_suggestion_box.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_suggestion_box_decoration.dart';
|
||||
import 'package:core/utils/app_logger.dart';
|
||||
import 'package:debounce_throttle/debounce_throttle.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pointer_interceptor/pointer_interceptor.dart';
|
||||
|
||||
class QuickSearchSuggestionList<T, P, R> extends StatefulWidget {
|
||||
final QuickSearchSuggestionsBox? suggestionsBox;
|
||||
final TextEditingController? controller;
|
||||
final bool getImmediateSuggestions;
|
||||
final SuggestionSelectionCallback<T>? onSuggestionSelected;
|
||||
final SuggestionsCallback<T>? suggestionsCallback;
|
||||
final ItemBuilder<T>? 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<dynamic>? listActionButton;
|
||||
final ButtonActionBuilder? actionButtonBuilder;
|
||||
final ButtonActionCallback? buttonActionCallback;
|
||||
final ButtonActionBuilder? buttonShowAllResult;
|
||||
final Widget? titleHeaderRecent;
|
||||
final ItemBuilder<R>? itemRecentBuilder;
|
||||
final FetchRecentActionCallback<R>? fetchRecentActionCallback;
|
||||
final RecentSelectionCallback<R>? onRecentSelected;
|
||||
final EdgeInsets? listActionPadding;
|
||||
final bool hideSuggestionsBox;
|
||||
final bool isDirectionRTL;
|
||||
final int? minInputLengthAutocomplete;
|
||||
final ItemBuilder<P>? contactSuggestionBuilder;
|
||||
final SuggestionsCallback<P>? contactSuggestionCallback;
|
||||
final SuggestionSelectionCallback<P>? onContactSuggestionSelected;
|
||||
|
||||
const QuickSearchSuggestionList({
|
||||
super.key,
|
||||
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,
|
||||
this.isDirectionRTL = false,
|
||||
this.minInputLengthAutocomplete,
|
||||
this.contactSuggestionBuilder,
|
||||
this.contactSuggestionCallback,
|
||||
this.onContactSuggestionSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
QuickSearchSuggestionListState<T, P, R> createState() =>
|
||||
QuickSearchSuggestionListState<T, P, R>();
|
||||
}
|
||||
|
||||
class QuickSearchSuggestionListState<T, P, R>
|
||||
extends State<QuickSearchSuggestionList<T, P, R>>
|
||||
with SingleTickerProviderStateMixin {
|
||||
Iterable<T>? _suggestions;
|
||||
Iterable<R>? _recentItems;
|
||||
Iterable<P>? _contacts;
|
||||
late bool _suggestionsValid;
|
||||
bool? _isLoading;
|
||||
AnimationController? _animationController;
|
||||
String? _lastTextValue;
|
||||
late final ScrollController _scrollController =
|
||||
widget.scrollController ?? ScrollController();
|
||||
late final Debouncer<String> _deBouncerSuggestion;
|
||||
late final StreamSubscription<String> _deBouncerSuggestionStreamSubscriptions;
|
||||
|
||||
Future<Iterable<R>?> _getListRecent(String queryString) async {
|
||||
try {
|
||||
return await widget.fetchRecentActionCallback?.call(queryString);
|
||||
} catch (e) {
|
||||
logError('SuggestionsListState::_getRecent:Exception = $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Iterable<P>?> _getListContact(String queryString) async {
|
||||
try {
|
||||
return await widget.contactSuggestionCallback?.call(queryString);
|
||||
} catch (e) {
|
||||
logError('SuggestionsListState::_getListContact:Exception = $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Iterable<T>?> _getListSuggestion(String queryString) async {
|
||||
try {
|
||||
return await widget.suggestionsCallback?.call(queryString);
|
||||
} catch (e) {
|
||||
logError('SuggestionsListState::_getListSuggestion:Exception = $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleDebounceTimeListener(String queryString) async {
|
||||
log('QuickSearchSuggestionListState::_handleDebounceTimeListener:queryString = $queryString | minCharsForSuggestions = ${widget.minCharsForSuggestions}');
|
||||
if (!mounted) return;
|
||||
|
||||
if (_isLoading == true) return;
|
||||
|
||||
if (widget.minCharsForSuggestions != null &&
|
||||
queryString.length <= widget.minCharsForSuggestions!) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
final recentItems = await _getListRecent(queryString);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_suggestions = null;
|
||||
_contacts = null;
|
||||
_recentItems = recentItems;
|
||||
_suggestionsValid = true;
|
||||
});
|
||||
} else {
|
||||
await invalidateSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
void _textInputControllerListener() {
|
||||
// If we came here because of a change in selected text, not because of
|
||||
// actual change in text
|
||||
final queryString = widget.controller!.text.trim();
|
||||
log('QuickSearchSuggestionListState::_textInputControllerListener:queryString = $queryString | _lastTextValue = $_lastTextValue');
|
||||
if (queryString == _lastTextValue) return;
|
||||
|
||||
_lastTextValue = queryString;
|
||||
_deBouncerSuggestion.value = queryString;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(QuickSearchSuggestionList<T, P, R> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
widget.controller!.addListener(_textInputControllerListener);
|
||||
_getSuggestions();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_getSuggestions();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_deBouncerSuggestion = Debouncer(
|
||||
widget.debounceDuration ?? const Duration(milliseconds: 300),
|
||||
initialValue: '',
|
||||
);
|
||||
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.animationDuration,
|
||||
);
|
||||
|
||||
_suggestionsValid = widget.minCharsForSuggestions! > 0 ? true : false;
|
||||
_isLoading = false;
|
||||
_lastTextValue = widget.controller!.text;
|
||||
|
||||
if (widget.getImmediateSuggestions) {
|
||||
_getSuggestions();
|
||||
}
|
||||
|
||||
widget.controller!.addListener(_textInputControllerListener);
|
||||
_deBouncerSuggestionStreamSubscriptions =
|
||||
_deBouncerSuggestion.values.listen(_handleDebounceTimeListener);
|
||||
}
|
||||
|
||||
Future<void> invalidateSuggestions() async {
|
||||
if (widget.hideSuggestionsBox) return;
|
||||
_suggestionsValid = false;
|
||||
await _getSuggestions();
|
||||
}
|
||||
|
||||
Future<void> _getSuggestions() async {
|
||||
if (_suggestionsValid || widget.hideSuggestionsBox) return;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
_suggestionsValid = true;
|
||||
|
||||
final queryString = widget.controller!.text.trim();
|
||||
|
||||
setState(() {
|
||||
_animationController!.forward(from: 1.0);
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
if (queryString.isEmpty) {
|
||||
final recentItems = await _getListRecent(queryString);
|
||||
|
||||
setState(() {
|
||||
_animationController!.forward(from: widget.animationStart);
|
||||
_isLoading = false;
|
||||
_suggestions = null;
|
||||
_recentItems = recentItems;
|
||||
_contacts = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Iterable<T>? suggestions;
|
||||
Iterable<R>? recentItems;
|
||||
Iterable<P>? contacts;
|
||||
|
||||
final tupleListItems = await Future.wait([
|
||||
_getListSuggestion(queryString),
|
||||
if (queryString.length >= (widget.minInputLengthAutocomplete ?? 0))
|
||||
_getListContact(queryString),
|
||||
]);
|
||||
|
||||
if (tupleListItems.isEmpty ||
|
||||
tupleListItems.every((item) => item == null || item.isEmpty)) {
|
||||
recentItems = await _getListRecent(queryString);
|
||||
} else {
|
||||
suggestions = tupleListItems[0] as Iterable<T>?;
|
||||
contacts =
|
||||
tupleListItems.length == 2 ? tupleListItems[1] as Iterable<P>? : null;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
final animationStart =
|
||||
suggestions?.isNotEmpty == true ? widget.animationStart : 1.0;
|
||||
_animationController!.forward(from: animationStart);
|
||||
_isLoading = false;
|
||||
_suggestions = suggestions;
|
||||
_recentItems = recentItems;
|
||||
_contacts = contacts;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController!.dispose();
|
||||
_deBouncerSuggestionStreamSubscriptions.cancel();
|
||||
_deBouncerSuggestion.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.hideSuggestionsBox) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget child;
|
||||
|
||||
if ((_suggestions?.isNotEmpty == true || _contacts?.isNotEmpty == true) &&
|
||||
widget.controller?.text.isNotEmpty == true) {
|
||||
child = createSuggestionsWidget();
|
||||
} else {
|
||||
child = createRecentWidget();
|
||||
}
|
||||
|
||||
final animationChild = widget.transitionBuilder != null
|
||||
? widget.transitionBuilder!(context, child, _animationController)
|
||||
: SizeTransition(
|
||||
axisAlignment: -1.0,
|
||||
sizeFactor: CurvedAnimation(
|
||||
parent: _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.suggestionsBox?.isOpened == true
|
||||
? 1
|
||||
: widget.decoration!.elevation,
|
||||
color: widget.decoration!.color,
|
||||
shape: widget.decoration!.shape,
|
||||
borderRadius: widget.suggestionsBox?.isOpened == true
|
||||
? const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16), bottomRight: Radius.circular(16))
|
||||
: 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 = _buildListViewSuggestionWidget();
|
||||
final loadingWidget = _buildLoadingBarWidget();
|
||||
final listActionWidget = _buildListActionWidget();
|
||||
final listItemContactWidget = _buildListViewContactWidget();
|
||||
|
||||
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 (listActionWidget != null) listActionWidget,
|
||||
if (widget.buttonShowAllResult != null &&
|
||||
widget.controller?.text.isNotEmpty == true)
|
||||
widget.buttonShowAllResult!(context, widget.controller?.text, this),
|
||||
if (loadingWidget != null) loadingWidget,
|
||||
if (listItemContactWidget.isNotEmpty) ...listItemContactWidget,
|
||||
if (listItemContactWidget.isNotEmpty &&
|
||||
listItemSuggestionWidget.isNotEmpty)
|
||||
const Divider(),
|
||||
if (listItemSuggestionWidget.isNotEmpty) ...[
|
||||
...listItemSuggestionWidget,
|
||||
const SizedBox(height: 16)
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (widget.decoration!.hasScrollbar) {
|
||||
child = Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget createRecentWidget() {
|
||||
final listItemRecent = _buildListViewRecentWidget();
|
||||
final loadingWidget = _buildLoadingBarWidget();
|
||||
final listActionWidget = _buildListActionWidget();
|
||||
|
||||
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 (listActionWidget != null) listActionWidget,
|
||||
if (widget.buttonShowAllResult != null &&
|
||||
widget.controller?.text.isNotEmpty == true)
|
||||
widget.buttonShowAllResult!(context, widget.controller?.text, this),
|
||||
if (_recentItems?.isNotEmpty == true &&
|
||||
widget.itemRecentBuilder != null &&
|
||||
widget.titleHeaderRecent != null)
|
||||
widget.titleHeaderRecent!,
|
||||
if (listItemRecent.isNotEmpty) ...listItemRecent,
|
||||
if (loadingWidget != null)
|
||||
loadingWidget
|
||||
else if (listItemRecent.isNotEmpty)
|
||||
const SizedBox(height: 16)
|
||||
],
|
||||
);
|
||||
|
||||
if (widget.decoration!.hasScrollbar) {
|
||||
child = Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: Container(child: child),
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget? _buildListActionWidget() {
|
||||
if (widget.listActionButton?.isNotEmpty != true ||
|
||||
widget.actionButtonBuilder == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final listActionWidget = Wrap(
|
||||
children: widget.listActionButton!.map((action) {
|
||||
if (widget.buttonActionCallback != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8, bottom: 8),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
onTap: () {
|
||||
widget.buttonActionCallback?.call(action);
|
||||
invalidateSuggestions();
|
||||
},
|
||||
child: widget.actionButtonBuilder!(context, action, this),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8, bottom: 8),
|
||||
child: widget.actionButtonBuilder!(context, action, this));
|
||||
}
|
||||
}).toList());
|
||||
|
||||
if (widget.listActionPadding != null) {
|
||||
return Padding(
|
||||
padding: widget.listActionPadding!, child: listActionWidget);
|
||||
} else {
|
||||
return listActionWidget;
|
||||
}
|
||||
}
|
||||
|
||||
Widget? _buildLoadingBarWidget() {
|
||||
if (_isLoading != true ||
|
||||
widget.hideOnLoading != false ||
|
||||
widget.keepSuggestionsOnLoading != false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (widget.loadingBuilder != null) {
|
||||
return widget.loadingBuilder!(context);
|
||||
} else {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildListViewRecentWidget() {
|
||||
if (_recentItems?.isNotEmpty != true || widget.itemRecentBuilder == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _recentItems!.map((recent) {
|
||||
if (widget.onRecentSelected != null) {
|
||||
return InkWell(
|
||||
child: widget.itemRecentBuilder!(context, recent),
|
||||
onTap: () => widget.onRecentSelected!(recent),
|
||||
);
|
||||
} else {
|
||||
return widget.itemRecentBuilder!(context, recent);
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<Widget> _buildListViewSuggestionWidget() {
|
||||
if (_suggestions?.isNotEmpty != true || widget.itemBuilder == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _suggestions!.map((suggestion) {
|
||||
if (widget.onSuggestionSelected != null) {
|
||||
return InkWell(
|
||||
child: widget.itemBuilder!(context, suggestion),
|
||||
onTap: () => widget.onSuggestionSelected?.call(suggestion),
|
||||
);
|
||||
} else {
|
||||
return widget.itemBuilder!(context, suggestion);
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<Widget> _buildListViewContactWidget() {
|
||||
if (_contacts?.isNotEmpty != true ||
|
||||
widget.contactSuggestionBuilder == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _contacts!.map((contact) {
|
||||
if (widget.onContactSuggestionSelected != null) {
|
||||
return InkWell(
|
||||
child: widget.contactSuggestionBuilder!(context, contact),
|
||||
onTap: () => widget.onContactSuggestionSelected?.call(contact),
|
||||
);
|
||||
} else {
|
||||
return widget.contactSuggestionBuilder!(context, contact);
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 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<TextInputFormatter>? 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<String>? 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<String>? 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<String>? onChanged,
|
||||
ValueChanged<String>? onSubmitted,
|
||||
bool? obscureText,
|
||||
MaxLengthEnforcement? maxLengthEnforcement,
|
||||
int? maxLength,
|
||||
int? maxLines,
|
||||
int? minLines,
|
||||
bool? autocorrect,
|
||||
List<TextInputFormatter>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:core/presentation/extensions/color_extension.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_action_define.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_suggestion_box.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_suggestion_box_controller.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_suggestion_box_decoration.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_suggestion_list.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_text_field_configuration.dart';
|
||||
import 'package:core/utils/direction_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
|
||||
/// 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<T, P, R> 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<T> 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<T> 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<T> 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<dynamic>? 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<R>? itemRecentBuilder;
|
||||
|
||||
/// Get all recent callback
|
||||
final FetchRecentActionCallback<R>? fetchRecentActionCallback;
|
||||
|
||||
/// On listen select recent
|
||||
final RecentSelectionCallback<R>? onRecentSelected;
|
||||
|
||||
/// Padding button action
|
||||
final EdgeInsets? listActionPadding;
|
||||
final bool hideSuggestionsBox;
|
||||
|
||||
/// Box decoration search input
|
||||
final BoxDecoration? decoration;
|
||||
|
||||
/// Max height search input
|
||||
final double? maxHeight;
|
||||
|
||||
/// Check direction text input
|
||||
final bool isDirectionRTL;
|
||||
|
||||
/// Widget contact item
|
||||
final ItemBuilder<P>? contactItemBuilder;
|
||||
|
||||
/// Get all contact callback
|
||||
final SuggestionsCallback<P>? contactSuggestionsCallback;
|
||||
|
||||
/// On listen select contact
|
||||
final SuggestionSelectionCallback<P>? onContactSuggestionSelected;
|
||||
|
||||
/// Min input length to start autocomplete
|
||||
final int? minInputLengthAutocomplete;
|
||||
|
||||
/// Creates a [TypeAheadFieldQuickSearch]
|
||||
const 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 = false,
|
||||
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,
|
||||
this.decoration,
|
||||
this.maxHeight,
|
||||
this.isDirectionRTL = false,
|
||||
this.minInputLengthAutocomplete,
|
||||
this.contactItemBuilder,
|
||||
this.contactSuggestionsCallback,
|
||||
this.onContactSuggestionSelected,
|
||||
}) : assert(animationStart >= 0.0 && animationStart <= 1.0),
|
||||
assert(
|
||||
direction == AxisDirection.down || direction == AxisDirection.up),
|
||||
assert(minCharsForSuggestions >= 0),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
State<TypeAheadFieldQuickSearch<T, P, R>> createState() =>
|
||||
_TypeAheadFieldQuickSearchState<T, P, R>();
|
||||
}
|
||||
|
||||
class _TypeAheadFieldQuickSearchState<T, P, R>
|
||||
extends State<TypeAheadFieldQuickSearch<T, P, R>>
|
||||
with WidgetsBindingObserver {
|
||||
FocusNode? _focusNode;
|
||||
TextEditingController? _textEditingController;
|
||||
QuickSearchSuggestionsBox? _suggestionsBox;
|
||||
late TextDirection _textDirection;
|
||||
|
||||
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<bool>? _keyboardVisibility =
|
||||
(supportedPlatform) ? KeyboardVisibilityController().onChange : null;
|
||||
late StreamSubscription<bool>? _keyboardVisibilitySubscription;
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
// Catch keyboard event and orientation change; resize suggestions list
|
||||
_suggestionsBox!.onChangeMetrics();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_suggestionsBox!.close();
|
||||
_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);
|
||||
|
||||
_textDirection =
|
||||
widget.textFieldConfiguration.textDirection ?? TextDirection.ltr;
|
||||
|
||||
if (widget.textFieldConfiguration.controller == null) {
|
||||
_textEditingController = TextEditingController();
|
||||
}
|
||||
|
||||
if (widget.textFieldConfiguration.focusNode == null) {
|
||||
_focusNode = FocusNode();
|
||||
}
|
||||
|
||||
_suggestionsBox = QuickSearchSuggestionsBox(
|
||||
context,
|
||||
widget.direction,
|
||||
widget.autoFlipDirection,
|
||||
widget.hideSuggestionsBox,
|
||||
);
|
||||
widget.suggestionsBoxController?.suggestionsBox = _suggestionsBox;
|
||||
widget.suggestionsBoxController?.effectiveFocusNode = _effectiveFocusNode;
|
||||
|
||||
_focusNodeListener = () {
|
||||
if (_effectiveFocusNode!.hasFocus) {
|
||||
_suggestionsBox!.open();
|
||||
} else if (widget.hideSuggestionsOnKeyboardHide) {
|
||||
_suggestionsBox!.close();
|
||||
}
|
||||
setState(() {});
|
||||
};
|
||||
|
||||
_effectiveFocusNode!.addListener(_focusNodeListener);
|
||||
|
||||
// hide suggestions box on keyboard closed
|
||||
_keyboardVisibilitySubscription =
|
||||
_keyboardVisibility?.listen((bool isVisible) {
|
||||
if (widget.hideSuggestionsOnKeyboardHide && !isVisible) {
|
||||
_effectiveFocusNode!.unfocus();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((duration) {
|
||||
if (mounted) {
|
||||
_initOverlayEntry();
|
||||
// calculate initial suggestions list size
|
||||
_suggestionsBox!.resize();
|
||||
|
||||
// in case we already missed the focus event
|
||||
if (_effectiveFocusNode!.hasFocus) {
|
||||
_suggestionsBox!.open();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final scrollableState = Scrollable.maybeOf(context);
|
||||
if (scrollableState != null) {
|
||||
_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() {
|
||||
_suggestionsBox!.overlayEntry = OverlayEntry(builder: (context) {
|
||||
final suggestionsList = QuickSearchSuggestionList<T, P, R>(
|
||||
suggestionsBox: _suggestionsBox,
|
||||
decoration: widget.suggestionsBoxDecoration,
|
||||
debounceDuration: widget.debounceDuration,
|
||||
controller: _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) {
|
||||
_effectiveFocusNode!.unfocus();
|
||||
_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) {
|
||||
_effectiveFocusNode!.unfocus();
|
||||
_suggestionsBox!.close();
|
||||
}
|
||||
if (widget.onRecentSelected != null) {
|
||||
widget.onRecentSelected!(selection);
|
||||
}
|
||||
},
|
||||
listActionPadding: widget.listActionPadding,
|
||||
hideSuggestionsBox: widget.hideSuggestionsBox,
|
||||
isDirectionRTL: widget.isDirectionRTL,
|
||||
minInputLengthAutocomplete: widget.minInputLengthAutocomplete,
|
||||
contactSuggestionBuilder: widget.contactItemBuilder,
|
||||
contactSuggestionCallback: widget.contactSuggestionsCallback,
|
||||
onContactSuggestionSelected: (P selection) {
|
||||
if (!widget.keepSuggestionsOnSuggestionSelected) {
|
||||
_effectiveFocusNode!.unfocus();
|
||||
_suggestionsBox!.close();
|
||||
}
|
||||
widget.onContactSuggestionSelected?.call(selection);
|
||||
},
|
||||
);
|
||||
|
||||
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: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: Offset(
|
||||
widget.suggestionsBoxDecoration.offsetX,
|
||||
_suggestionsBox!.direction == AxisDirection.down
|
||||
? _suggestionsBox!.textBoxHeight +
|
||||
widget.suggestionsBoxVerticalOffset
|
||||
: _suggestionsBox!.directionUpOffset,
|
||||
),
|
||||
child: TextFieldTapRegion(
|
||||
child: _suggestionsBox!.direction == AxisDirection.down
|
||||
? suggestionsList
|
||||
: FractionalTranslation(
|
||||
translation: const Offset(0.0, -1.0),
|
||||
// visually flips list to go up
|
||||
child: suggestionsList,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onTextChange(String text) {
|
||||
final trimmedText = text.trim();
|
||||
widget.textFieldConfiguration.onChanged?.call(trimmedText);
|
||||
|
||||
if (trimmedText.isNotEmpty) {
|
||||
_updateTextDirection(trimmedText);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateTextDirection(String text) {
|
||||
final directionByText = DirectionUtils.getDirectionByEndsText(text);
|
||||
if (directionByText != _textDirection) {
|
||||
setState(() {
|
||||
_textDirection = directionByText;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: Container(
|
||||
decoration: _suggestionsBox?.isOpened == true
|
||||
? const BoxDecoration(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(16),
|
||||
topLeft: Radius.circular(16),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColor.colorShadowComposer,
|
||||
blurRadius: 32,
|
||||
),
|
||||
BoxShadow(
|
||||
color: AppColor.colorShadowComposer,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
color: Colors.white,
|
||||
)
|
||||
: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
color: AppColor.colorBgSearchBar,
|
||||
),
|
||||
height: widget.maxHeight,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (widget.textFieldConfiguration.leftButton != null)
|
||||
widget.textFieldConfiguration.leftButton!,
|
||||
Expanded(
|
||||
child: TextField(
|
||||
focusNode: _effectiveFocusNode,
|
||||
controller: _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: _onTextChange,
|
||||
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: _textDirection,
|
||||
enableInteractiveSelection:
|
||||
widget.textFieldConfiguration.enableInteractiveSelection,
|
||||
readOnly: widget.hideKeyboard,
|
||||
),
|
||||
),
|
||||
if (widget.textFieldConfiguration.clearTextButton != null &&
|
||||
_effectiveController?.text.isNotEmpty == true)
|
||||
widget.textFieldConfiguration.clearTextButton!,
|
||||
if (widget.textFieldConfiguration.rightButton != null)
|
||||
widget.textFieldConfiguration.rightButton!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:core/presentation/extensions/color_extension.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_input_form.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_action_define.dart';
|
||||
import 'package:core/utils/direction_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
|
||||
@@ -233,6 +233,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
debounce_throttle:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: debounce_throttle
|
||||
sha256: c95cf47afda975fc507794a52040a16756fb2f31ad3027d4e691c41862ff5692
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -970,6 +978,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
simple_observable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: simple_observable
|
||||
sha256: b392795c48f8b5f301b4c8f73e15f56e38fe70f42278c649d8325e859a783301
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
||||
@@ -98,6 +98,8 @@ dependencies:
|
||||
|
||||
flutter_charset_detector: 3.0.0
|
||||
|
||||
debounce_throttle: 2.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import 'package:core/presentation/views/quick_search/quick_search_input_form.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_action_define.dart';
|
||||
import 'package:core/presentation/views/text/type_ahead_form_field_builder.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:tmail_ui_user/features/base/widget/recent_item_tile_widget.dart';
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'package:core/presentation/resources/image_paths.dart';
|
||||
import 'package:core/presentation/utils/responsive_utils.dart';
|
||||
import 'package:core/presentation/views/button/icon_button_web.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_input_form.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_suggestion_box_decoration.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_suggestion_list.dart';
|
||||
import 'package:core/presentation/views/quick_search/quick_search_text_field_configuration.dart';
|
||||
import 'package:core/utils/app_logger.dart';
|
||||
import 'package:core/utils/direction_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -26,6 +29,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/qu
|
||||
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/quick_search/recent_search_item_tile_widget.dart';
|
||||
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/search_filters/search_filter_button.dart';
|
||||
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
|
||||
import 'package:tmail_ui_user/main/utils/app_config.dart';
|
||||
|
||||
class SearchInputFormWidget extends StatelessWidget with AppLoaderMixin {
|
||||
final _searchController = Get.find<search.SearchController>();
|
||||
@@ -62,6 +66,7 @@ class SearchInputFormWidget extends StatelessWidget with AppLoaderMixin {
|
||||
child: QuickSearchInputForm<PresentationEmail, EmailAddress, RecentSearch>(
|
||||
maxHeight: 52,
|
||||
suggestionsBoxVerticalOffset: 0.0,
|
||||
minInputLengthAutocomplete: AppConfig.limitCharToStartSearch,
|
||||
textFieldConfiguration: _createConfiguration(context),
|
||||
suggestionsBoxDecoration: const QuickSearchSuggestionsBoxDecoration(
|
||||
color: Colors.white,
|
||||
@@ -232,7 +237,7 @@ class SearchInputFormWidget extends StatelessWidget with AppLoaderMixin {
|
||||
Widget buildListButtonForQuickSearchForm(
|
||||
BuildContext context,
|
||||
QuickSearchFilter searchFilter,
|
||||
SuggestionsListState suggestionsListState
|
||||
QuickSearchSuggestionListState suggestionsListState
|
||||
) {
|
||||
return Obx(() {
|
||||
final isSelected = searchFilter.isApplied(_searchController.listFilterOnSuggestionForm);
|
||||
|
||||
@@ -240,6 +240,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
debounce_throttle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: debounce_throttle
|
||||
sha256: c95cf47afda975fc507794a52040a16756fb2f31ad3027d4e691c41862ff5692
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -994,6 +1002,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
simple_observable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: simple_observable
|
||||
sha256: b392795c48f8b5f301b4c8f73e15f56e38fe70f42278c649d8325e859a783301
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
||||
Reference in New Issue
Block a user