diff --git a/packages/smooth_app/lib/pages/product/autocomplete.dart b/packages/smooth_app/lib/pages/product/autocomplete.dart index 73180f391a1..b3f5d69f7c7 100644 --- a/packages/smooth_app/lib/pages/product/autocomplete.dart +++ b/packages/smooth_app/lib/pages/product/autocomplete.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/widgets/smooth_text.dart'; /// The default Material-style Autocomplete options. /// @@ -15,6 +16,7 @@ class AutocompleteOptions extends StatelessWidget { required this.options, required this.maxOptionsHeight, required this.maxOptionsWidth, + this.search, }) : assert(maxOptionsHeight >= 0), assert(maxOptionsWidth >= 0), super(key: key); @@ -25,6 +27,7 @@ class AutocompleteOptions extends StatelessWidget { final Iterable options; final double maxOptionsWidth; final double maxOptionsHeight; + final String? search; @override Widget build(BuildContext context) { @@ -51,6 +54,7 @@ class AutocompleteOptions extends StatelessWidget { return _AutocompleteOptionsItem( key: Key(index.toString()), option: option, + search: search, highlight: highlightedOption == index, onSelected: onSelected, displayStringForOption: displayStringForOption, @@ -73,10 +77,12 @@ class _AutocompleteOptionsItem extends StatelessWidget { required this.highlight, required this.displayStringForOption, required this.onSelected, + this.search, Key? key, }) : super(key: key); final T option; + final String? search; final bool highlight; final AutocompleteOptionToString displayStringForOption; final AutocompleteOnSelected onSelected; @@ -89,15 +95,17 @@ class _AutocompleteOptionsItem extends StatelessWidget { }); } - return InkWell( - onTap: () { - onSelected(option); - }, - child: Container( - color: highlight ? Theme.of(context).focusColor : null, - padding: const EdgeInsets.all(LARGE_SPACE), - child: Text( - displayStringForOption(option), + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => onSelected(option), + child: Ink( + color: highlight ? Theme.of(context).focusColor : null, + padding: const EdgeInsets.all(LARGE_SPACE), + child: TextHighlighter( + text: displayStringForOption(option), + filter: search ?? '', + ), ), ), ); diff --git a/packages/smooth_app/lib/pages/product/simple_input_text_field.dart b/packages/smooth_app/lib/pages/product/simple_input_text_field.dart index e159e4c1774..d2fb0654493 100644 --- a/packages/smooth_app/lib/pages/product/simple_input_text_field.dart +++ b/packages/smooth_app/lib/pages/product/simple_input_text_field.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; @@ -5,7 +8,7 @@ import 'package:smooth_app/pages/product/autocomplete.dart'; import 'package:smooth_app/query/product_query.dart'; /// Simple input text field, with autocompletion. -class SimpleInputTextField extends StatelessWidget { +class SimpleInputTextField extends StatefulWidget { const SimpleInputTextField({ required this.focusNode, required this.autocompleteKey, @@ -31,20 +34,71 @@ class SimpleInputTextField extends StatelessWidget { final String? Function()? shapeProvider; @override - Widget build(BuildContext context) { - final SuggestionManager? manager = tagType == null + State createState() => _SimpleInputTextFieldState(); +} + +class _SimpleInputTextFieldState extends State { + final Map _suggestions = {}; + bool _loading = false; + + late _DebouncedTextEditingController _debouncedController; + late SuggestionManager? _manager; + + @override + void initState() { + super.initState(); + + _debouncedController = _DebouncedTextEditingController(widget.controller); + + _manager = widget.tagType == null ? null : SuggestionManager( - tagType!, + widget.tagType!, language: ProductQuery.getLanguage(), country: ProductQuery.getCountry(), - categories: categories, - shape: shapeProvider?.call(), + categories: widget.categories, + shape: widget.shapeProvider?.call(), user: ProductQuery.getUser(), // number of suggestions the user can scroll through: compromise between quantity and readability of the suggestions limit: 15, ); + } + + @override + void didUpdateWidget(SimpleInputTextField oldWidget) { + super.didUpdateWidget(oldWidget); + _debouncedController.replaceWith(widget.controller); + } + + Future<_SearchResults> _getSuggestions(String search) async { + final DateTime start = DateTime.now(); + + if (_suggestions[search] == null) { + if (_manager == null || search.length < widget.minLengthForSuggestions) { + _suggestions[search] = _SearchResults.empty(); + } else { + try { + _suggestions[search] = + _SearchResults(await _manager!.getSuggestions(search)); + } catch (_) {} + } + } + + if (_suggestions[search]?.isEmpty == true && search == _searchInput) { + _hideLoading(); + } + if (_searchInput != search && + start.difference(DateTime.now()).inSeconds > 5) { + // Ignore this request, it's too long and this is not even the current search + return _SearchResults.empty(); + } else { + return _suggestions[search]!; + } + } + + @override + Widget build(BuildContext context) { return Padding( padding: const EdgeInsetsDirectional.only(start: LARGE_SPACE), child: Row( @@ -54,27 +108,19 @@ class SimpleInputTextField extends StatelessWidget { children: [ Expanded( child: RawAutocomplete( - key: autocompleteKey, - focusNode: focusNode, - textEditingController: controller, - optionsBuilder: (final TextEditingValue value) async { - if (tagType == null) { - return []; - } - - final String input = value.text.trim(); - if (input.length < minLengthForSuggestions) { - return []; - } - - return manager!.getSuggestions(input); + key: widget.autocompleteKey, + focusNode: widget.focusNode, + textEditingController: _debouncedController, + optionsBuilder: (final TextEditingValue value) { + return _getSuggestions(value.text); }, fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) => TextField( - controller: textEditingController, + controller: widget.controller, + onChanged: (_) => setState(() => _loading = true), decoration: InputDecoration( filled: true, border: const OutlineInputBorder( @@ -85,7 +131,21 @@ class SimpleInputTextField extends StatelessWidget { horizontal: SMALL_SPACE, vertical: SMALL_SPACE, ), - hintText: hintText, + hintText: widget.hintText, + suffix: Offstage( + offstage: !_loading, + child: SizedBox( + width: + Theme.of(context).textTheme.titleMedium?.fontSize ?? + 15, + height: + Theme.of(context).textTheme.titleMedium?.fontSize ?? + 15, + child: const CircularProgressIndicator.adaptive( + strokeWidth: 1.0, + ), + ), + ), ), // a lot of confusion if set to `true` autofocus: false, @@ -97,6 +157,18 @@ class SimpleInputTextField extends StatelessWidget { Iterable options, ) { final double screenHeight = MediaQuery.of(context).size.height; + String input = ''; + + for (final String key in _suggestions.keys) { + if (_suggestions[key].hashCode == options.hashCode) { + input = key; + break; + } + } + + if (input == _searchInput) { + _hideLoading(); + } return AutocompleteOptions( displayStringForOption: @@ -104,21 +176,57 @@ class SimpleInputTextField extends StatelessWidget { onSelected: onSelected, options: options, // Width = Row width - horizontal padding - maxOptionsWidth: constraints.maxWidth - (LARGE_SPACE * 2), + maxOptionsWidth: + widget.constraints.maxWidth - (LARGE_SPACE * 2), maxOptionsHeight: screenHeight / 3, + search: input, ); }, ), ), - if (withClearButton) + if (widget.withClearButton) IconButton( icon: const Icon(Icons.clear), - onPressed: () => controller.text = '', + onPressed: () => widget.controller.text = '', ), ], ), ); } + + String get _searchInput => widget.controller.text.trim(); + + void _hideLoading() { + if (_loading) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => setState(() => _loading = false), + ); + } + } + + @override + void dispose() { + _debouncedController.dispose(); + super.dispose(); + } +} + +@immutable +class _SearchResults extends DelegatingList { + _SearchResults(List? results) : super(results ?? []); + + _SearchResults.empty() : super([]); + final int _uniqueId = DateTime.now().millisecondsSinceEpoch; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _SearchResults && + runtimeType == other.runtimeType && + _uniqueId == other._uniqueId; + + @override + int get hashCode => _uniqueId; } /// Allows to unfocus TextField (and dismiss the keyboard) when user tap outside the TextField and inside this widget. @@ -143,3 +251,49 @@ class UnfocusWhenTapOutside extends StatelessWidget { ); } } + +class _DebouncedTextEditingController extends TextEditingController { + _DebouncedTextEditingController(TextEditingController controller) { + replaceWith(controller); + } + + TextEditingController? _controller; + Timer? _debounce; + + void replaceWith(TextEditingController controller) { + _controller?.removeListener(_onWrappedTextEditingControllerChanged); + _controller = controller; + _controller?.addListener(_onWrappedTextEditingControllerChanged); + } + + void _onWrappedTextEditingControllerChanged() { + if (_debounce?.isActive == true) { + _debounce!.cancel(); + } + + _debounce = Timer(const Duration(milliseconds: 500), () { + super.notifyListeners(); + }); + } + + @override + set text(String newText) => _controller?.value = value; + + @override + String get text => _controller?.text ?? ''; + + @override + TextEditingValue get value => _controller?.value ?? TextEditingValue.empty; + + @override + set value(TextEditingValue newValue) => _controller?.value = newValue; + + @override + void clear() => _controller?.clear(); + + @override + void dispose() { + _debounce?.cancel(); + super.dispose(); + } +}