Skip to content
Open
114 changes: 114 additions & 0 deletions packages/smooth_app/lib/database/dao_autocomplete.dart
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That's typically why I asked you to create 2 separate files. Which for some reason you ignored, and I'm a bit puzzled with that.
When I review, the whole file is considered as changed, and I have to read again ALL the file, when you may have simply edited one class.

Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import 'dart:async';
import 'dart:convert';

import 'package:smooth_app/database/abstract_sql_dao.dart';
import 'package:smooth_app/database/local_database.dart';
import 'package:sqflite/sqflite.dart';

class DaoNamespace extends AbstractSqlDao {
DaoNamespace(super.localDatabase);

static const String _TABLE_NAMESPACE = 'autocomplete_namespace';
static const String _TABLE_NAMESPACE_COLUMN_ID = 'id';
static const String _TABLE_NAMESPACE_COLUMN_NAMESPACE = 'namespace';

static FutureOr<void> onUpgrade(
final Database db,
final int oldVersion,
final int newVersion,
) async {
if (oldVersion < 10) {
await db.execute(
'create table $_TABLE_NAMESPACE('
'$_TABLE_NAMESPACE_COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT'
',$_TABLE_NAMESPACE_COLUMN_NAMESPACE TEXT NOT NULL UNIQUE'
')',
);
}
}

/// Returns the namespace id for the given [namespace] string.
Future<int> getOrCreateId(final String namespace) async {
final List<Map<String, dynamic>> rows = await localDatabase.database.query(
_TABLE_NAMESPACE,
columns: <String>[_TABLE_NAMESPACE_COLUMN_ID],
where: '$_TABLE_NAMESPACE_COLUMN_NAMESPACE = ?',
whereArgs: <String>[namespace],
);
if (rows.isNotEmpty) {
return rows.first[_TABLE_NAMESPACE_COLUMN_ID] as int;
}
return localDatabase.database.insert(_TABLE_NAMESPACE, <String, dynamic>{
_TABLE_NAMESPACE_COLUMN_NAMESPACE: namespace,
});
}
}

class DaoAutocompleteCache extends AbstractSqlDao {
DaoAutocompleteCache(super.localDatabase);

static const String _TABLE_CACHE = 'autocomplete_cache';
static const String _TABLE_CACHE_COLUMN_NAMESPACE_ID = 'namespace_id';
static const String _TABLE_CACHE_COLUMN_QUERY = 'query';
static const String _TABLE_CACHE_COLUMN_RESULTS = 'results';
static const String _TABLE_CACHE_COLUMN_LAST_UPDATE = 'last_update';

static const List<String> _columnsCache = <String>[
_TABLE_CACHE_COLUMN_NAMESPACE_ID,
_TABLE_CACHE_COLUMN_QUERY,
_TABLE_CACHE_COLUMN_RESULTS,
_TABLE_CACHE_COLUMN_LAST_UPDATE,
];

static FutureOr<void> onUpgrade(
final Database db,
final int oldVersion,
final int newVersion,
) async {
if (oldVersion < 10) {
await db.execute(
'create table $_TABLE_CACHE('
'$_TABLE_CACHE_COLUMN_NAMESPACE_ID INTEGER NOT NULL'
',$_TABLE_CACHE_COLUMN_QUERY TEXT NOT NULL'
',$_TABLE_CACHE_COLUMN_RESULTS TEXT NOT NULL'
',$_TABLE_CACHE_COLUMN_LAST_UPDATE INTEGER NOT NULL'
',PRIMARY KEY'
'($_TABLE_CACHE_COLUMN_NAMESPACE_ID'
',$_TABLE_CACHE_COLUMN_QUERY) ON CONFLICT REPLACE'
')',
);
}
}

/// Returns cached results for [namespaceId] and [query].
/// Returns null if not found.
Future<List<String>?> get(final int namespaceId, final String query) async {
final List<Map<String, dynamic>> rows = await localDatabase.database.query(
_TABLE_CACHE,
columns: _columnsCache,
where:
'$_TABLE_CACHE_COLUMN_NAMESPACE_ID = ?'
' AND $_TABLE_CACHE_COLUMN_QUERY = ?',
whereArgs: <dynamic>[namespaceId, query],
);
if (rows.isEmpty) {
return null;
}
final String json = rows.first[_TABLE_CACHE_COLUMN_RESULTS] as String;
return (jsonDecode(json) as List<dynamic>).cast<String>();
}

/// Stores [results] for [namespaceId] and [query].
Future<void> put(
final int namespaceId,
final String query,
final List<String> results,
) async {
await localDatabase.database.insert(_TABLE_CACHE, <String, dynamic>{
_TABLE_CACHE_COLUMN_NAMESPACE_ID: namespaceId,
_TABLE_CACHE_COLUMN_QUERY: query,
_TABLE_CACHE_COLUMN_RESULTS: jsonEncode(results),
_TABLE_CACHE_COLUMN_LAST_UPDATE: LocalDatabase.nowInMillis(),
});
}
}
5 changes: 4 additions & 1 deletion packages/smooth_app/lib/database/local_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:smooth_app/background/background_task_manager.dart';
import 'package:smooth_app/data_models/up_to_date_product_list_provider.dart';
import 'package:smooth_app/data_models/up_to_date_product_provider.dart';
import 'package:smooth_app/database/abstract_dao.dart';
import 'package:smooth_app/database/dao_autocomplete.dart';
import 'package:smooth_app/database/dao_folksonomy.dart';
import 'package:smooth_app/database/dao_hive_product.dart';
import 'package:smooth_app/database/dao_instant_string.dart';
Expand Down Expand Up @@ -78,7 +79,7 @@ class LocalDatabase extends ChangeNotifier {
final String databasePath = join(databasesRootPath, 'smoothie.db');
final Database database = await openDatabase(
databasePath,
version: 9,
version: 10,
singleInstance: true,
onUpgrade: _onUpgrade,
);
Expand Down Expand Up @@ -125,5 +126,7 @@ class LocalDatabase extends ChangeNotifier {
await DaoProductLastAccess.onUpgrade(db, oldVersion, newVersion);
await DaoOsmLocation.onUpgrade(db, oldVersion, newVersion);
await DaoFolksonomy.onUpgrade(db, oldVersion, newVersion);
await DaoNamespace.onUpgrade(db, oldVersion, newVersion);
await DaoAutocompleteCache.onUpgrade(db, oldVersion, newVersion);
}
}
114 changes: 114 additions & 0 deletions packages/smooth_app/lib/pages/input/server_suggestion.dart
Comment thread
Sherley-Sonali marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import 'package:openfoodfacts/openfoodfacts.dart';
import 'package:smooth_app/pages/folksonomy/folksonomy_autocompleter.dart';
import 'package:smooth_app/query/product_query.dart';

/// Abstract interface for server-backed autocomplete with cache namespace support.
abstract class ServerSuggestion {
String getNamespace();
Future<List<String>> getSuggestionsFromServer(String soFar);
}

/// Implementation for TagType autocompleters (categories, labels, origins, etc.)
class TagTypeServerSuggestion implements ServerSuggestion {
TagTypeServerSuggestion({required this.tagType, required this.productType});

final TagType tagType;
final ProductType productType;

OpenFoodFactsLanguage get _language => ProductQuery.getLanguage();
OpenFoodFactsCountry? get _country => ProductQuery.getCountry();
UriProductHelper get _uriHelper =>
ProductQuery.getUriProductHelper(productType: productType);

@override
String getNamespace() {
return '${_uriHelper.domain}|tagtype|${tagType.offTag}|${_language.offTag}|${_country?.offTag ?? ''}';
}

@override
Future<List<String>> getSuggestionsFromServer(String soFar) async {
final TagTypeAutocompleter autocompleter = TagTypeAutocompleter(
tagType: tagType,
language: _language,
country: _country,
uriHelper: _uriHelper,
limit: 15,
);
return autocompleter.getSuggestions(soFar);
}
}

/// Implementation for TaxonomyName autocompleters (brands)
class TaxonomyServerSuggestion implements ServerSuggestion {
TaxonomyServerSuggestion({
required this.taxonomyNames,
required this.productType,
});

final List<TaxonomyName> taxonomyNames;
final ProductType productType;

OpenFoodFactsLanguage get _language => OpenFoodFactsLanguage.ENGLISH;
UriProductHelper get _uriHelper =>
ProductQuery.getUriProductHelper(productType: productType);

@override
String getNamespace() {
return '${_uriHelper.domain}|taxonomy|${taxonomyNames.map((final TaxonomyName t) => t.offTag).join(',')}|${_language.offTag}';
}

@override
Future<List<String>> getSuggestionsFromServer(String soFar) async {
final TaxonomyNameAutocompleter autocompleter = TaxonomyNameAutocompleter(
taxonomyNames: taxonomyNames,
language: _language,
uriHelper: _uriHelper,
limit: 25,
fuzziness: Fuzziness.none,
user: ProductQuery.getReadUser(),
);
return autocompleter.getSuggestions(soFar);
}
}

/// Implementation for folksonomy keys autocompleter
class FolksonomyKeysServerSuggestion implements ServerSuggestion {
const FolksonomyKeysServerSuggestion();

UriHelper get _uriHelper => ProductQuery.uriFolksonomyHelper;

@override
String getNamespace() {
return '${_uriHelper.host}|folksonomy|keys';
}

@override
Future<List<String>> getSuggestionsFromServer(String soFar) async {
const FolksonomyKeysAutocompleter autocompleter =
FolksonomyKeysAutocompleter(limit: 10);
return autocompleter.getSuggestions(soFar);
}
}

/// Implementation for folksonomy values autocompleter
class FolksonomyValuesServerSuggestion implements ServerSuggestion {
FolksonomyValuesServerSuggestion({required String Function() keyProvider})
: _keyProvider = keyProvider;

final String Function() _keyProvider;

UriHelper get _uriHelper => ProductQuery.uriFolksonomyHelper;

@override
String getNamespace() {
final String key = _keyProvider().trim();
return '${_uriHelper.host}|folksonomy|values|$key';
}

@override
Future<List<String>> getSuggestionsFromServer(String soFar) async {
final FolksonomyValuesAutocompleter autocompleter =
FolksonomyValuesAutocompleter(keyProvider: _keyProvider, limit: 10);
return autocompleter.getSuggestions(soFar);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:openfoodfacts/openfoodfacts.dart';
import 'package:provider/provider.dart';
import 'package:smooth_app/database/local_database.dart';
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/strings_helper.dart';
import 'package:smooth_app/pages/input/debounced_text_editing_controller.dart';
import 'package:smooth_app/pages/input/suggestion_cache.dart';
import 'package:smooth_app/pages/product/autocomplete.dart';

/// Autocomplete text field.
Expand All @@ -28,6 +31,7 @@ class SmoothAutocompleteTextField extends StatefulWidget {
this.textStyle,
this.textCapitalization,
this.onSelected,
this.suggestionCache,
});

final FocusNode focusNode;
Expand All @@ -44,6 +48,7 @@ class SmoothAutocompleteTextField extends StatefulWidget {
final EdgeInsetsGeometry? padding;
final TextStyle? textStyle;
final TextCapitalization? textCapitalization;
final SuggestionCache? suggestionCache;

/// Additional specific action when a suggested item is selected.
final Function(String)? onSelected;
Expand Down Expand Up @@ -206,35 +211,37 @@ class _SmoothAutocompleteTextFieldState
return _SearchResults.empty();
}

final DateTime start = DateTime.now();

if (_suggestions[search] != null) {
return _suggestions[search]!;
} else if (widget.manager == null ||
search.length < widget.minLengthForSuggestions) {
} else if (search.length < widget.minLengthForSuggestions) {
_suggestions[search] = _SearchResults.empty();
return _suggestions[search]!;
}

_setLoading(true);

try {
_suggestions[search] = _SearchResults(
await widget.manager!.getSuggestions(search),
);
} catch (_) {}
final List<String> results;

if (_suggestions[search]?.isEmpty ?? true && search == _searchInput) {
_setLoading(false);
}
if (widget.suggestionCache != null) {
results = await widget.suggestionCache!.getSuggestions(search);
} else if (widget.manager != null) {
results = await widget.manager!.getSuggestions(search);
} else {
results = <String>[];
}

if (_searchInput != search &&
start.difference(DateTime.now()).inSeconds > 5) {
// Ignore this request, it's too long and this is not even the current search
_suggestions[search] = _SearchResults(results);
} catch (_) {
return _SearchResults.empty();
} else {
return _suggestions[search] ?? _SearchResults.empty();
} finally {
_setLoading(false);
}
// if (_suggestions[search]?.isEmpty ?? true && search == _searchInput) {
// _setLoading(false);
// }

return _suggestions[search] ?? _SearchResults.empty();
}
}

Expand Down
55 changes: 55 additions & 0 deletions packages/smooth_app/lib/pages/input/suggestion_cache.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'package:smooth_app/database/dao_autocomplete.dart';
import 'package:smooth_app/database/local_database.dart';
import 'package:smooth_app/pages/input/server_suggestion.dart';

class SuggestionCache {
SuggestionCache({
required this.serverSuggestion,
required this.localDatabase,
});

final ServerSuggestion serverSuggestion;
final LocalDatabase localDatabase;

static final Map<String, int> _namespaceIdCache = <String, int>{};
static final Map<String, List<String>> _resultsCache =
<String, List<String>>{};
Comment on lines +15 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
static final Map<String, List<String>> _resultsCache =
<String, List<String>>{};
static final Map<int, Map<String, List<String>>> _resultsCache =
<int, Map<String, List<String>>>{};

That would be cleaner, instead of using your final String cacheKey = '$namespaceId|$soFar';
Just putting whatever is related to the namespace in its specific Map<String, List<String>>.
Then we'd be able to say: just clean that namespace.


Future<int> _getNamespaceId(final String namespace) async {
final int? cached = _namespaceIdCache[namespace];
if (cached != null) {
return cached;
}
final int id = await DaoNamespace(localDatabase).getOrCreateId(namespace);
return _namespaceIdCache[namespace] = id;
}

Future<List<String>> getSuggestions(final String soFar) async {
final String namespace = serverSuggestion.getNamespace();
final int namespaceId = await _getNamespaceId(namespace);
Comment on lines +26 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
Future<List<String>> getSuggestions(final String soFar) async {
final String namespace = serverSuggestion.getNamespace();
final int namespaceId = await _getNamespaceId(namespace);
int? _namespaceId;
Future<List<String>> getSuggestions(final String soFar) async {
final String namespace = serverSuggestion.getNamespace();
_namespaceId ??= await _getNamespaceId(namespace);

Just a suggestion.

final String cacheKey = '$namespaceId|$soFar';

// 1. Static
final List<String>? static_ = _resultsCache[cacheKey];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Lousy naming with the ending underscore.
What about staticCache, dbCache and serverData?

if (static_ != null) {
return static_;
}

final DaoAutocompleteCache daoCache = DaoAutocompleteCache(localDatabase);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
final DaoAutocompleteCache daoCache = DaoAutocompleteCache(localDatabase);
_daoCache ??= DaoAutocompleteCache(localDatabase);

cf. _namespaceId: we don't have to recreate an object all the time.
In this case, could even be done in the constructor.


// 2. DB
final List<String>? db = await daoCache.get(namespaceId, soFar);
if (db != null) {
return _resultsCache[cacheKey] = db;
}

// 3. Server
final List<String> results = await serverSuggestion
.getSuggestionsFromServer(soFar);
if (results.isNotEmpty) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't agree at all: knowing that there's no result is important.
Imagine looking for gqehbjvlesqmbnqvhd again and again.
If we know there are no results, let's cache that there's no result.

await daoCache.put(namespaceId, soFar, results);
_resultsCache[cacheKey] = results;
}
return results;
}
}
Loading
Loading