From 6f608fa1a219f108f32c2ae5976a9a0fc4f4a048 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Mon, 4 Dec 2023 11:03:26 +0100 Subject: [PATCH 1/3] feat: 4068 - autocomplete for brands New files: * `agnostic_suggestion_manager.dart`: Suggestion manager for "old" and elastic search taxonomies. * `brand_suggestion_manager.dart`: Manager that returns the elastic suggestions for the latest brand input. * `smooth_autocomplete_text_field.dart`: Autocomplete text field. Code largely moved from `simple_input_text_field.dart`. * `unfocus_when_tap_outside.dart`: Allows to unfocus TextField (and dismiss the keyboard). Code moved from `simple_input_text_field.dart`. Impacted files: * `add_basic_details_page.dart`: added elastic autocomplete for brands * `edit_new_packagings.dart`: minor refactoring * `paged_to_be_completed_product_query.dart`: minor refactoring * `paged_user_product_query.dart`: minor refactoring * `pubspec.lock`: wtf * `pubspec.yaml`: ugraded to openfoodfacts 3.2.1 * `simple_input_page.dart`: minor refactoring * `simple_input_text_field.dart`: moved most of the code to new class `SmoothAutocompleteTextField` in order to deal also with elastic search autocompletion. --- .../input/agnostic_suggestion_manager.dart | 28 ++ .../pages/input/brand_suggestion_manager.dart | 62 +++++ .../input/smooth_autocomplete_text_field.dart | 236 ++++++++++++++++ .../pages/input/unfocus_when_tap_outside.dart | 20 ++ .../pages/product/add_basic_details_page.dart | 162 ++++++----- .../pages/product/edit_new_packagings.dart | 2 +- .../lib/pages/product/simple_input_page.dart | 2 +- .../product/simple_input_text_field.dart | 254 ++---------------- .../paged_to_be_completed_product_query.dart | 2 +- .../lib/query/paged_user_product_query.dart | 2 +- packages/smooth_app/pubspec.lock | 4 +- packages/smooth_app/pubspec.yaml | 2 +- 12 files changed, 465 insertions(+), 311 deletions(-) create mode 100644 packages/smooth_app/lib/pages/input/agnostic_suggestion_manager.dart create mode 100644 packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart create mode 100644 packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart create mode 100644 packages/smooth_app/lib/pages/input/unfocus_when_tap_outside.dart diff --git a/packages/smooth_app/lib/pages/input/agnostic_suggestion_manager.dart b/packages/smooth_app/lib/pages/input/agnostic_suggestion_manager.dart new file mode 100644 index 00000000000..1ddae32d38d --- /dev/null +++ b/packages/smooth_app/lib/pages/input/agnostic_suggestion_manager.dart @@ -0,0 +1,28 @@ +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:smooth_app/pages/input/brand_suggestion_manager.dart'; + +// TODO(monsieurtanuki): there's probably a more elegant way to do it. +/// Suggestion manager for "old" taxonomies and elastic search taxonomies. +class AgnosticSuggestionManager { + AgnosticSuggestionManager.tagType(this.tagTypeSuggestionManager) + : brandSuggestionManager = null; + + AgnosticSuggestionManager.brand() + : brandSuggestionManager = BrandSuggestionManager(), + tagTypeSuggestionManager = null; + + final SuggestionManager? tagTypeSuggestionManager; + final BrandSuggestionManager? brandSuggestionManager; + + Future> getSuggestions( + final String input, + ) async { + if (tagTypeSuggestionManager != null) { + return tagTypeSuggestionManager!.getSuggestions(input); + } + if (brandSuggestionManager != null) { + return brandSuggestionManager!.getSuggestions(input); + } + return []; + } +} diff --git a/packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart b/packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart new file mode 100644 index 00000000000..714a3b68282 --- /dev/null +++ b/packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:smooth_app/query/product_query.dart'; + +/// Manager that returns the elastic suggestions for the latest brand input. +/// +/// See also: [SuggestionManager]. +class BrandSuggestionManager { + BrandSuggestionManager({ + this.limit = 25, + this.user, + }); + + final int limit; + final User? user; + + final List _inputs = []; + final Map> _cache = >{}; + + /// Returns suggestions about the latest input. + Future> getSuggestions( + final String input, + ) async { + _inputs.add(input); + final List? cached = _cache[input]; + if (cached != null) { + return cached; + } + final AutocompleteSearchResult result = + await OpenFoodSearchAPIClient.autocomplete( + query: input, + taxonomyNames: [TaxonomyName.brand], + // for brands, language must be English + language: OpenFoodFactsLanguage.ENGLISH, + // for the moment, only TEST is available but it's good enough + uriHelper: uriHelperFoodTest, + user: ProductQuery.getUser(), + size: limit, + fuzziness: Fuzziness.none, + ); + final List tmp = []; + if (result.options != null) { + for (final AutocompleteSingleResult option in result.options!) { + final String text = option.text; + if (!tmp.contains(text)) { + tmp.add(text); + } + } + } + _cache[input] = tmp; + // meanwhile there might have been some calls to this method, adding inputs. + for (final String latestInput in _inputs.reversed) { + final List? cached = _cache[latestInput]; + if (cached != null) { + return cached; + } + } + // not supposed to happen, as we should have downloaded for "input". + return []; + } +} diff --git a/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart b/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart new file mode 100644 index 00000000000..11948f3cb6b --- /dev/null +++ b/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart @@ -0,0 +1,236 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/pages/input/agnostic_suggestion_manager.dart'; +import 'package:smooth_app/pages/product/autocomplete.dart'; + +/// Autocomplete text field. +class SmoothAutocompleteTextField extends StatefulWidget { + const SmoothAutocompleteTextField({ + required this.focusNode, + required this.controller, + required this.autocompleteKey, + required this.hintText, + required this.constraints, + required this.manager, + this.minLengthForSuggestions = 1, + }); + + final FocusNode focusNode; + final TextEditingController controller; + final Key autocompleteKey; + final String hintText; + final BoxConstraints constraints; + final int minLengthForSuggestions; + final AgnosticSuggestionManager? manager; + + @override + State createState() => + _SmoothAutocompleteTextFieldState(); +} + +class _SmoothAutocompleteTextFieldState + extends State { + final Map _suggestions = {}; + bool _loading = false; + + late _DebouncedTextEditingController _debouncedController; + + @override + void initState() { + super.initState(); + _debouncedController = _DebouncedTextEditingController(widget.controller); + } + + @override + void dispose() { + _debouncedController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(final SmoothAutocompleteTextField oldWidget) { + super.didUpdateWidget(oldWidget); + _debouncedController.replaceWith(widget.controller); + } + + @override + Widget build(BuildContext context) { + return RawAutocomplete( + 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: widget.controller, + decoration: InputDecoration( + filled: true, + border: const OutlineInputBorder( + borderRadius: ANGULAR_BORDER_RADIUS, + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: SMALL_SPACE, + vertical: SMALL_SPACE, + ), + 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, + focusNode: focusNode, + ), + optionsViewBuilder: ( + BuildContext lContext, + AutocompleteOnSelected onSelected, + 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) { + _setLoading(false); + } + + return AutocompleteOptions( + displayStringForOption: RawAutocomplete.defaultStringForOption, + onSelected: onSelected, + options: options, + // Width = Row width - horizontal padding + maxOptionsWidth: widget.constraints.maxWidth - (LARGE_SPACE * 2), + maxOptionsHeight: screenHeight / 3, + search: input, + ); + }, + ); + } + + String get _searchInput => widget.controller.text.trim(); + + void _setLoading(bool loading) { + if (_loading != loading) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => setState(() => _loading = loading), + ); + } + } + + Future<_SearchResults> _getSuggestions(String search) async { + final DateTime start = DateTime.now(); + + if (_suggestions[search] != null) { + return _suggestions[search]!; + } else if (widget.manager == null || + search.length < widget.minLengthForSuggestions) { + _suggestions[search] = _SearchResults.empty(); + return _suggestions[search]!; + } + + _setLoading(true); + + try { + _suggestions[search] = + _SearchResults(await widget.manager!.getSuggestions(search)); + } catch (_) {} + + if (_suggestions[search]?.isEmpty ?? true && search == _searchInput) { + _setLoading(false); + } + + 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] ?? _SearchResults.empty(); + } + } +} + +@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; +} + +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(); + } +} diff --git a/packages/smooth_app/lib/pages/input/unfocus_when_tap_outside.dart b/packages/smooth_app/lib/pages/input/unfocus_when_tap_outside.dart new file mode 100644 index 00000000000..045ba87d4a8 --- /dev/null +++ b/packages/smooth_app/lib/pages/input/unfocus_when_tap_outside.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +/// Allows to unfocus TextField (and dismiss the keyboard) when user tap outside the TextField and inside this widget. +/// Therefore, this widget should be put before the Scaffold to make the TextField unfocus when tapping anywhere. +class UnfocusWhenTapOutside extends StatelessWidget { + const UnfocusWhenTapOutside({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: () { + final FocusScopeNode currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus) { + currentFocus.unfocus(); + } + }, + child: child, + ); +} diff --git a/packages/smooth_app/lib/pages/product/add_basic_details_page.dart b/packages/smooth_app/lib/pages/product/add_basic_details_page.dart index 3e745dfa4d4..b9f7759ef12 100644 --- a/packages/smooth_app/lib/pages/product/add_basic_details_page.dart +++ b/packages/smooth_app/lib/pages/product/add_basic_details_page.dart @@ -7,6 +7,9 @@ import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_text_form_field.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/pages/input/agnostic_suggestion_manager.dart'; +import 'package:smooth_app/pages/input/smooth_autocomplete_text_field.dart'; +import 'package:smooth_app/pages/input/unfocus_when_tap_outside.dart'; import 'package:smooth_app/pages/product/common/product_buttons.dart'; import 'package:smooth_app/pages/product/common/product_refresher.dart'; import 'package:smooth_app/pages/product/may_exit_page_helper.dart'; @@ -42,6 +45,8 @@ class _AddBasicDetailsPageState extends State { late final Product _product; late final MultilingualHelper _multilingualHelper; + final Key _autocompleteKey = UniqueKey(); + late final FocusNode _focusNode; @override void initState() { @@ -61,6 +66,7 @@ class _AddBasicDetailsPageState extends State { monolingualText: _product.productName, productLanguage: _product.lang, ); + _focusNode = FocusNode(); } @override @@ -68,6 +74,7 @@ class _AddBasicDetailsPageState extends State { _productNameController.dispose(); _weightController.dispose(); _brandNameController.dispose(); + _focusNode.dispose(); super.dispose(); } @@ -81,84 +88,99 @@ class _AddBasicDetailsPageState extends State { final AppLocalizations appLocalizations = AppLocalizations.of(context); return WillPopScope( onWillPop: () async => _mayExitPage(saving: false), - child: SmoothScaffold( - fixKeyboard: true, - appBar: SmoothAppBar( - centerTitle: false, - title: Text(appLocalizations.basic_details), - subTitle: buildProductTitle(widget.product, appLocalizations)), - body: Form( - key: _formKey, - child: Scrollbar( - child: ListView( - children: [ - Align( - alignment: AlignmentDirectional.topStart, - child: ProductImageCarousel( - _product, - height: size.height * 0.20, + child: UnfocusWhenTapOutside( + child: SmoothScaffold( + fixKeyboard: true, + appBar: SmoothAppBar( + centerTitle: false, + title: Text(appLocalizations.basic_details), + subTitle: buildProductTitle(widget.product, appLocalizations)), + body: Form( + key: _formKey, + child: Scrollbar( + child: ListView( + children: [ + Align( + alignment: AlignmentDirectional.topStart, + child: ProductImageCarousel( + _product, + height: size.height * 0.20, + ), ), - ), - SizedBox(height: _heightSpace), - Padding( - padding: EdgeInsets.symmetric(horizontal: size.width * 0.05), - child: Column( - children: [ - Text( - appLocalizations.barcode_barcode(_product.barcode!), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, + SizedBox(height: _heightSpace), + Padding( + padding: + EdgeInsets.symmetric(horizontal: size.width * 0.05), + child: Column( + children: [ + Text( + appLocalizations.barcode_barcode(_product.barcode!), + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: _heightSpace), + if (_multilingualHelper.isMonolingual()) + SmoothTextFormField( + controller: _productNameController, + type: TextFieldTypes.PLAIN_TEXT, + hintText: appLocalizations.product_name, + ) + else + Card( + child: Column( + children: [ + _multilingualHelper + .getLanguageSelector(setState), + Padding( + padding: const EdgeInsets.all(8.0), + child: SmoothTextFormField( + controller: _productNameController, + type: TextFieldTypes.PLAIN_TEXT, + hintText: appLocalizations.product_name, + ), + ), + ], ), - ), - SizedBox(height: _heightSpace), - if (_multilingualHelper.isMonolingual()) + ), + SizedBox(height: _heightSpace), + LayoutBuilder( + builder: ( + final BuildContext context, + final BoxConstraints constraints, + ) => + SmoothAutocompleteTextField( + focusNode: _focusNode, + controller: _brandNameController, + autocompleteKey: _autocompleteKey, + hintText: appLocalizations.brand_name, + constraints: constraints, + manager: AgnosticSuggestionManager.brand(), + ), + ), + SizedBox(height: _heightSpace), SmoothTextFormField( - controller: _productNameController, + controller: _weightController, type: TextFieldTypes.PLAIN_TEXT, - hintText: appLocalizations.product_name, - ) - else - Card( - child: Column( - children: [ - _multilingualHelper.getLanguageSelector(setState), - Padding( - padding: const EdgeInsets.all(8.0), - child: SmoothTextFormField( - controller: _productNameController, - type: TextFieldTypes.PLAIN_TEXT, - hintText: appLocalizations.product_name, - ), - ), - ], - ), + hintText: appLocalizations.quantity, ), - SizedBox(height: _heightSpace), - SmoothTextFormField( - controller: _brandNameController, - type: TextFieldTypes.PLAIN_TEXT, - hintText: appLocalizations.brand_name, - ), - SizedBox(height: _heightSpace), - SmoothTextFormField( - controller: _weightController, - type: TextFieldTypes.PLAIN_TEXT, - hintText: appLocalizations.quantity, - ), - SizedBox(height: _heightSpace), - ], + // in order to be able to scroll suggestions + SizedBox(height: MediaQuery.of(context).size.height), + ], + ), ), - ), - ], + ], + ), ), ), - ), - bottomNavigationBar: ProductBottomButtonsBar( - onSave: () async => _exitPage( - await _mayExitPage(saving: true), - ), - onCancel: () async => _exitPage( - await _mayExitPage(saving: false), + bottomNavigationBar: ProductBottomButtonsBar( + onSave: () async => _exitPage( + await _mayExitPage(saving: true), + ), + onCancel: () async => _exitPage( + await _mayExitPage(saving: false), + ), ), ), ), diff --git a/packages/smooth_app/lib/pages/product/edit_new_packagings.dart b/packages/smooth_app/lib/pages/product/edit_new_packagings.dart index 2f68aeb3680..4f909505c5e 100644 --- a/packages/smooth_app/lib/pages/product/edit_new_packagings.dart +++ b/packages/smooth_app/lib/pages/product/edit_new_packagings.dart @@ -12,12 +12,12 @@ import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/image_field_extension.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; +import 'package:smooth_app/pages/input/unfocus_when_tap_outside.dart'; import 'package:smooth_app/pages/product/common/product_buttons.dart'; import 'package:smooth_app/pages/product/edit_new_packagings_component.dart'; import 'package:smooth_app/pages/product/edit_new_packagings_helper.dart'; import 'package:smooth_app/pages/product/may_exit_page_helper.dart'; import 'package:smooth_app/pages/product/simple_input_number_field.dart'; -import 'package:smooth_app/pages/product/simple_input_text_field.dart'; import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/themes/color_schemes.dart'; import 'package:smooth_app/widgets/smooth_app_bar.dart'; diff --git a/packages/smooth_app/lib/pages/product/simple_input_page.dart b/packages/smooth_app/lib/pages/product/simple_input_page.dart index d982e62810c..0b552674f3c 100644 --- a/packages/smooth_app/lib/pages/product/simple_input_page.dart +++ b/packages/smooth_app/lib/pages/product/simple_input_page.dart @@ -8,10 +8,10 @@ import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/collections_helper.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/pages/input/unfocus_when_tap_outside.dart'; import 'package:smooth_app/pages/product/common/product_buttons.dart'; import 'package:smooth_app/pages/product/may_exit_page_helper.dart'; import 'package:smooth_app/pages/product/simple_input_page_helpers.dart'; -import 'package:smooth_app/pages/product/simple_input_text_field.dart'; import 'package:smooth_app/pages/product/simple_input_widget.dart'; import 'package:smooth_app/widgets/smooth_app_bar.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; 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 3093c2b88eb..2ce89edef5b 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,10 +1,8 @@ -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'; -import 'package:smooth_app/pages/product/autocomplete.dart'; +import 'package:smooth_app/pages/input/agnostic_suggestion_manager.dart'; +import 'package:smooth_app/pages/input/smooth_autocomplete_text_field.dart'; import 'package:smooth_app/query/product_query.dart'; /// Simple input text field, with autocompletion. @@ -40,69 +38,27 @@ class SimpleInputTextField extends StatefulWidget { } class _SimpleInputTextFieldState extends State { - final Map _suggestions = {}; - bool _loading = false; - - late _DebouncedTextEditingController _debouncedController; - late SuggestionManager? _manager; + late final AgnosticSuggestionManager? _manager; @override void initState() { super.initState(); - - _debouncedController = _DebouncedTextEditingController(widget.controller); - _manager = widget.tagType == null ? null - : SuggestionManager( - widget.tagType!, - language: ProductQuery.getLanguage(), - country: ProductQuery.getCountry(), - 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, + : AgnosticSuggestionManager.tagType( + SuggestionManager( + widget.tagType!, + language: ProductQuery.getLanguage(), + country: ProductQuery.getCountry(), + 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) { - return _suggestions[search]!; - } else if (_manager == null || - search.length < widget.minLengthForSuggestions) { - _suggestions[search] = _SearchResults.empty(); - return _suggestions[search]!; - } - - _setLoading(true); - - try { - _suggestions[search] = - _SearchResults(await _manager!.getSuggestions(search)); - } catch (_) {} - - if (_suggestions[search]?.isEmpty ?? true && search == _searchInput) { - _setLoading(false); - } - - 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] ?? _SearchResults.empty(); - } - } - @override Widget build(BuildContext context) { return Padding( @@ -114,80 +70,13 @@ class _SimpleInputTextFieldState extends State { mainAxisSize: MainAxisSize.max, children: [ Expanded( - child: RawAutocomplete( - key: widget.autocompleteKey, + child: SmoothAutocompleteTextField( focusNode: widget.focusNode, - textEditingController: _debouncedController, - optionsBuilder: (final TextEditingValue value) { - return _getSuggestions(value.text); - }, - fieldViewBuilder: (BuildContext context, - TextEditingController textEditingController, - FocusNode focusNode, - VoidCallback onFieldSubmitted) => - TextField( - controller: widget.controller, - decoration: InputDecoration( - filled: true, - border: const OutlineInputBorder( - borderRadius: ANGULAR_BORDER_RADIUS, - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: SMALL_SPACE, - vertical: SMALL_SPACE, - ), - 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, - focusNode: focusNode, - ), - optionsViewBuilder: ( - BuildContext lContext, - AutocompleteOnSelected onSelected, - 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) { - _setLoading(false); - } - - return AutocompleteOptions( - displayStringForOption: - RawAutocomplete.defaultStringForOption, - onSelected: onSelected, - options: options, - // Width = Row width - horizontal padding - maxOptionsWidth: - widget.constraints.maxWidth - (LARGE_SPACE * 2), - maxOptionsHeight: screenHeight / 3, - search: input, - ); - }, + controller: widget.controller, + autocompleteKey: widget.autocompleteKey, + hintText: widget.hintText, + constraints: widget.constraints, + manager: _manager, ), ), if (widget.withClearButton) @@ -199,107 +88,4 @@ class _SimpleInputTextFieldState extends State { ), ); } - - String get _searchInput => widget.controller.text.trim(); - - void _setLoading(bool loading) { - if (_loading != loading) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => setState(() => _loading = loading), - ); - } - } - - @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. -/// Therefore, this widget should be put before the Scaffold to make the TextField unfocus when tapping anywhere. -class UnfocusWhenTapOutside extends StatelessWidget { - const UnfocusWhenTapOutside({Key? key, required this.child}) - : super(key: key); - - final Widget child; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - final FocusScopeNode currentFocus = FocusScope.of(context); - - if (!currentFocus.hasPrimaryFocus) { - currentFocus.unfocus(); - } - }, - child: child, - ); - } -} - -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(); - } } diff --git a/packages/smooth_app/lib/query/paged_to_be_completed_product_query.dart b/packages/smooth_app/lib/query/paged_to_be_completed_product_query.dart index b0aed2e23a1..574b519abea 100644 --- a/packages/smooth_app/lib/query/paged_to_be_completed_product_query.dart +++ b/packages/smooth_app/lib/query/paged_to_be_completed_product_query.dart @@ -16,7 +16,7 @@ class PagedToBeCompletedProductQuery extends PagedProductQuery { parametersList: [ PageSize(size: pageSize), PageNumber(page: pageNumber), - StatesTagsParameter( + const StatesTagsParameter( map: { ProductState.CATEGORIES_COMPLETED: false, }, diff --git a/packages/smooth_app/lib/query/paged_user_product_query.dart b/packages/smooth_app/lib/query/paged_user_product_query.dart index 8eef0be1643..fe9e22b0bf0 100644 --- a/packages/smooth_app/lib/query/paged_user_product_query.dart +++ b/packages/smooth_app/lib/query/paged_user_product_query.dart @@ -38,7 +38,7 @@ enum UserSearchType { PageSize(size: pageSize), PageNumber(page: pageNumber), if (toBeCompleted) - StatesTagsParameter( + const StatesTagsParameter( map: { ProductState.COMPLETED: false, }, diff --git a/packages/smooth_app/pubspec.lock b/packages/smooth_app/pubspec.lock index f6e1060812b..a9b607c1709 100644 --- a/packages/smooth_app/pubspec.lock +++ b/packages/smooth_app/pubspec.lock @@ -1076,10 +1076,10 @@ packages: dependency: "direct main" description: name: openfoodfacts - sha256: "4a3a331ad0abc3bbea189ced88a426a4a9955a59e28df5f773d0697bef06ccfc" + sha256: "47436d0d647700327b8df45d02658a5e6e8d89b328e0b3d95c3fc3c34d5aebb7" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.1" openfoodfacts_flutter_lints: dependency: "direct dev" description: diff --git a/packages/smooth_app/pubspec.yaml b/packages/smooth_app/pubspec.yaml index 6f5f3e61248..3c5038add50 100644 --- a/packages/smooth_app/pubspec.yaml +++ b/packages/smooth_app/pubspec.yaml @@ -97,7 +97,7 @@ dependencies: path: ../scanner/zxing - openfoodfacts: 3.1.0 + openfoodfacts: 3.2.1 # openfoodfacts: # path: ../../../openfoodfacts-dart From 15bc8edeb9d7cbe8a8f28999e020051312f6f229 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Mon, 4 Dec 2023 17:26:36 +0100 Subject: [PATCH 2/3] feat: 4068 - minor fixes after review --- .../input/smooth_autocomplete_text_field.dart | 2 +- .../pages/product/add_basic_details_page.dart | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart b/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart index 11948f3cb6b..45b320bc651 100644 --- a/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart +++ b/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart @@ -102,7 +102,7 @@ class _SmoothAutocompleteTextFieldState AutocompleteOnSelected onSelected, Iterable options, ) { - final double screenHeight = MediaQuery.of(context).size.height; + final double screenHeight = MediaQuery.sizeOf(context).height; String input = ''; for (final String key in _suggestions.keys) { diff --git a/packages/smooth_app/lib/pages/product/add_basic_details_page.dart b/packages/smooth_app/lib/pages/product/add_basic_details_page.dart index b9f7759ef12..6ae6e36c6ce 100644 --- a/packages/smooth_app/lib/pages/product/add_basic_details_page.dart +++ b/packages/smooth_app/lib/pages/product/add_basic_details_page.dart @@ -84,7 +84,7 @@ class _AddBasicDetailsPageState extends State { @override Widget build(BuildContext context) { - final Size size = MediaQuery.of(context).size; + final Size size = MediaQuery.sizeOf(context); final AppLocalizations appLocalizations = AppLocalizations.of(context); return WillPopScope( onWillPop: () async => _mayExitPage(saving: false), @@ -92,9 +92,10 @@ class _AddBasicDetailsPageState extends State { child: SmoothScaffold( fixKeyboard: true, appBar: SmoothAppBar( - centerTitle: false, - title: Text(appLocalizations.basic_details), - subTitle: buildProductTitle(widget.product, appLocalizations)), + centerTitle: false, + title: Text(appLocalizations.basic_details), + subTitle: buildProductTitle(widget.product, appLocalizations), + ), body: Form( key: _formKey, child: Scrollbar( @@ -109,8 +110,9 @@ class _AddBasicDetailsPageState extends State { ), SizedBox(height: _heightSpace), Padding( - padding: - EdgeInsets.symmetric(horizontal: size.width * 0.05), + padding: EdgeInsets.symmetric( + horizontal: size.width * 0.05, + ), child: Column( children: [ Text( @@ -166,7 +168,7 @@ class _AddBasicDetailsPageState extends State { hintText: appLocalizations.quantity, ), // in order to be able to scroll suggestions - SizedBox(height: MediaQuery.of(context).size.height), + SizedBox(height: MediaQuery.sizeOf(context).height), ], ), ), From eda2253c09a54ba3667275f4b84d6c06b13c8892 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Tue, 12 Dec 2023 17:49:36 +0100 Subject: [PATCH 3/3] Update packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart --- .../smooth_app/lib/pages/input/brand_suggestion_manager.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart b/packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart index 714a3b68282..c20eb2213e4 100644 --- a/packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart +++ b/packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart @@ -33,8 +33,6 @@ class BrandSuggestionManager { taxonomyNames: [TaxonomyName.brand], // for brands, language must be English language: OpenFoodFactsLanguage.ENGLISH, - // for the moment, only TEST is available but it's good enough - uriHelper: uriHelperFoodTest, user: ProductQuery.getUser(), size: limit, fuzziness: Fuzziness.none,