Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 4068 - autocomplete for brands #4871

Merged
merged 9 commits into from
Dec 21, 2023
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't a factory be a nicer API.
It would allow to hide the complexity of the implementation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@g123k I honestly cannot see how a factory would help, but I would gladly see what you would write instead.
Btw I wouldn't call the code of this file "complex" (one class, 28 lines of code, 2 fields, 1 method).

: 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) {
g123k marked this conversation as resolved.
Show resolved Hide resolved
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(
g123k marked this conversation as resolved.
Show resolved Hide resolved
onTap: () {
final FocusScopeNode currentFocus = FocusScope.of(context);
if (!currentFocus.hasPrimaryFocus) {
currentFocus.unfocus();
}
},
child: child,
);
}
Loading