Skip to content

Commit

Permalink
feat: Better suggestions in product edition (UI/UX) + debounce feature (
Browse files Browse the repository at this point in the history
#4351)

* Suggestions for product edition: highlighter + cache results + smarter way of displaying results

* Useless print call

* Debounce feature for AutoComplete

* Fix a typo
  • Loading branch information
g123k authored Jul 29, 2023
1 parent 7abc0ef commit 5fdbc12
Show file tree
Hide file tree
Showing 2 changed files with 196 additions and 34 deletions.
26 changes: 17 additions & 9 deletions packages/smooth_app/lib/pages/product/autocomplete.dart
Original file line number Diff line number Diff line change
@@ -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.
///
Expand All @@ -15,6 +16,7 @@ class AutocompleteOptions<T extends Object> extends StatelessWidget {
required this.options,
required this.maxOptionsHeight,
required this.maxOptionsWidth,
this.search,
}) : assert(maxOptionsHeight >= 0),
assert(maxOptionsWidth >= 0),
super(key: key);
Expand All @@ -25,6 +27,7 @@ class AutocompleteOptions<T extends Object> extends StatelessWidget {
final Iterable<T> options;
final double maxOptionsWidth;
final double maxOptionsHeight;
final String? search;

@override
Widget build(BuildContext context) {
Expand All @@ -51,6 +54,7 @@ class AutocompleteOptions<T extends Object> extends StatelessWidget {
return _AutocompleteOptionsItem<T>(
key: Key(index.toString()),
option: option,
search: search,
highlight: highlightedOption == index,
onSelected: onSelected,
displayStringForOption: displayStringForOption,
Expand All @@ -73,10 +77,12 @@ class _AutocompleteOptionsItem<T extends Object> 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<T> displayStringForOption;
final AutocompleteOnSelected<T> onSelected;
Expand All @@ -89,15 +95,17 @@ class _AutocompleteOptionsItem<T extends Object> 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 ?? '',
),
),
),
);
Expand Down
204 changes: 179 additions & 25 deletions packages/smooth_app/lib/pages/product/simple_input_text_field.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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/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,
Expand All @@ -31,20 +34,71 @@ class SimpleInputTextField extends StatelessWidget {
final String? Function()? shapeProvider;

@override
Widget build(BuildContext context) {
final SuggestionManager? manager = tagType == null
State<SimpleInputTextField> createState() => _SimpleInputTextFieldState();
}

class _SimpleInputTextFieldState extends State<SimpleInputTextField> {
final Map<String, _SearchResults> _suggestions = <String, _SearchResults>{};
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(
Expand All @@ -54,27 +108,19 @@ class SimpleInputTextField extends StatelessWidget {
children: <Widget>[
Expanded(
child: RawAutocomplete<String>(
key: autocompleteKey,
focusNode: focusNode,
textEditingController: controller,
optionsBuilder: (final TextEditingValue value) async {
if (tagType == null) {
return <String>[];
}

final String input = value.text.trim();
if (input.length < minLengthForSuggestions) {
return <String>[];
}

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(
Expand All @@ -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,
Expand All @@ -97,28 +157,76 @@ class SimpleInputTextField extends StatelessWidget {
Iterable<String> 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<String>(
displayStringForOption:
RawAutocomplete.defaultStringForOption,
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<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;
}

/// Allows to unfocus TextField (and dismiss the keyboard) when user tap outside the TextField and inside this widget.
Expand All @@ -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();
}
}

0 comments on commit 5fdbc12

Please sign in to comment.