Skip to content

Commit

Permalink
feat: 4068 - autocomplete for brands (#4871)
Browse files Browse the repository at this point in the history
* 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.

* feat: 4068 - minor fixes after review

* Update packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart

---------

Co-authored-by: Pierre Slamich <pierre@openfoodfacts.org>
  • Loading branch information
monsieurtanuki and teolemon committed Dec 21, 2023
1 parent 28262ca commit 2554848
Show file tree
Hide file tree
Showing 8 changed files with 459 additions and 305 deletions.
Original file line number Diff line number Diff line change
@@ -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<List<String>> getSuggestions(
final String input,
) async {
if (tagTypeSuggestionManager != null) {
return tagTypeSuggestionManager!.getSuggestions(input);
}
if (brandSuggestionManager != null) {
return brandSuggestionManager!.getSuggestions(input);
}
return <String>[];
}
}
60 changes: 60 additions & 0 deletions packages/smooth_app/lib/pages/input/brand_suggestion_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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<String> _inputs = <String>[];
final Map<String, List<String>> _cache = <String, List<String>>{};

/// Returns suggestions about the latest input.
Future<List<String>> getSuggestions(
final String input,
) async {
_inputs.add(input);
final List<String>? cached = _cache[input];
if (cached != null) {
return cached;
}
final AutocompleteSearchResult result =
await OpenFoodSearchAPIClient.autocomplete(
query: input,
taxonomyNames: <TaxonomyName>[TaxonomyName.brand],
// for brands, language must be English
language: OpenFoodFactsLanguage.ENGLISH,
user: ProductQuery.getUser(),
size: limit,
fuzziness: Fuzziness.none,
);
final List<String> tmp = <String>[];
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<String>? cached = _cache[latestInput];
if (cached != null) {
return cached;
}
}
// not supposed to happen, as we should have downloaded for "input".
return <String>[];
}
}
Original file line number Diff line number Diff line change
@@ -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<SmoothAutocompleteTextField> createState() =>
_SmoothAutocompleteTextFieldState();
}

class _SmoothAutocompleteTextFieldState
extends State<SmoothAutocompleteTextField> {
final Map<String, _SearchResults> _suggestions = <String, _SearchResults>{};
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<String>(
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<String> onSelected,
Iterable<String> options,
) {
final double screenHeight = MediaQuery.sizeOf(context).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<String>(
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<String> {
_SearchResults(List<String>? results) : super(results ?? <String>[]);

_SearchResults.empty() : super(<String>[]);
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();
}
}
20 changes: 20 additions & 0 deletions packages/smooth_app/lib/pages/input/unfocus_when_tap_outside.dart
Original file line number Diff line number Diff line change
@@ -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,
);
}

0 comments on commit 2554848

Please sign in to comment.