diff --git a/packages/smooth_app/lib/database/dao_autocomplete.dart b/packages/smooth_app/lib/database/dao_autocomplete.dart new file mode 100644 index 000000000000..99ac90936050 --- /dev/null +++ b/packages/smooth_app/lib/database/dao_autocomplete.dart @@ -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 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 getOrCreateId(final String namespace) async { + final List> rows = await localDatabase.database.query( + _TABLE_NAMESPACE, + columns: [_TABLE_NAMESPACE_COLUMN_ID], + where: '$_TABLE_NAMESPACE_COLUMN_NAMESPACE = ?', + whereArgs: [namespace], + ); + if (rows.isNotEmpty) { + return rows.first[_TABLE_NAMESPACE_COLUMN_ID] as int; + } + return localDatabase.database.insert(_TABLE_NAMESPACE, { + _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 _columnsCache = [ + _TABLE_CACHE_COLUMN_NAMESPACE_ID, + _TABLE_CACHE_COLUMN_QUERY, + _TABLE_CACHE_COLUMN_RESULTS, + _TABLE_CACHE_COLUMN_LAST_UPDATE, + ]; + + static FutureOr 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?> get(final int namespaceId, final String query) async { + final List> rows = await localDatabase.database.query( + _TABLE_CACHE, + columns: _columnsCache, + where: + '$_TABLE_CACHE_COLUMN_NAMESPACE_ID = ?' + ' AND $_TABLE_CACHE_COLUMN_QUERY = ?', + whereArgs: [namespaceId, query], + ); + if (rows.isEmpty) { + return null; + } + final String json = rows.first[_TABLE_CACHE_COLUMN_RESULTS] as String; + return (jsonDecode(json) as List).cast(); + } + + /// Stores [results] for [namespaceId] and [query]. + Future put( + final int namespaceId, + final String query, + final List results, + ) async { + await localDatabase.database.insert(_TABLE_CACHE, { + _TABLE_CACHE_COLUMN_NAMESPACE_ID: namespaceId, + _TABLE_CACHE_COLUMN_QUERY: query, + _TABLE_CACHE_COLUMN_RESULTS: jsonEncode(results), + _TABLE_CACHE_COLUMN_LAST_UPDATE: LocalDatabase.nowInMillis(), + }); + } +} diff --git a/packages/smooth_app/lib/database/local_database.dart b/packages/smooth_app/lib/database/local_database.dart index 1faa40865e67..1593429ce83a 100644 --- a/packages/smooth_app/lib/database/local_database.dart +++ b/packages/smooth_app/lib/database/local_database.dart @@ -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'; @@ -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, ); @@ -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); } } diff --git a/packages/smooth_app/lib/pages/input/server_suggestion.dart b/packages/smooth_app/lib/pages/input/server_suggestion.dart new file mode 100644 index 000000000000..cdba3cfd90d1 --- /dev/null +++ b/packages/smooth_app/lib/pages/input/server_suggestion.dart @@ -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> 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> 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 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> 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> 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> getSuggestionsFromServer(String soFar) async { + final FolksonomyValuesAutocompleter autocompleter = + FolksonomyValuesAutocompleter(keyProvider: _keyProvider, limit: 10); + return autocompleter.getSuggestions(soFar); + } +} 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 6d1dd8cab8c6..4770e8beebe3 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 @@ -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. @@ -28,6 +31,7 @@ class SmoothAutocompleteTextField extends StatefulWidget { this.textStyle, this.textCapitalization, this.onSelected, + this.suggestionCache, }); final FocusNode focusNode; @@ -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; @@ -206,12 +211,9 @@ 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]!; } @@ -219,22 +221,27 @@ class _SmoothAutocompleteTextFieldState _setLoading(true); try { - _suggestions[search] = _SearchResults( - await widget.manager!.getSuggestions(search), - ); - } catch (_) {} + final List 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 = []; + } - 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(); } } diff --git a/packages/smooth_app/lib/pages/input/suggestion_cache.dart b/packages/smooth_app/lib/pages/input/suggestion_cache.dart new file mode 100644 index 000000000000..c7b38e4dcba5 --- /dev/null +++ b/packages/smooth_app/lib/pages/input/suggestion_cache.dart @@ -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 _namespaceIdCache = {}; + static final Map> _resultsCache = + >{}; + + Future _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> getSuggestions(final String soFar) async { + final String namespace = serverSuggestion.getNamespace(); + final int namespaceId = await _getNamespaceId(namespace); + final String cacheKey = '$namespaceId|$soFar'; + + // 1. Static + final List? static_ = _resultsCache[cacheKey]; + if (static_ != null) { + return static_; + } + + final DaoAutocompleteCache daoCache = DaoAutocompleteCache(localDatabase); + + // 2. DB + final List? db = await daoCache.get(namespaceId, soFar); + if (db != null) { + return _resultsCache[cacheKey] = db; + } + + // 3. Server + final List results = await serverSuggestion + .getSuggestionsFromServer(soFar); + if (results.isNotEmpty) { + await daoCache.put(namespaceId, soFar, results); + _resultsCache[cacheKey] = results; + } + return results; + } +} diff --git a/packages/smooth_app/pubspec.lock b/packages/smooth_app/pubspec.lock index 420d8eea24d5..fa73e58f6be0 100644 --- a/packages/smooth_app/pubspec.lock +++ b/packages/smooth_app/pubspec.lock @@ -273,10 +273,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -1052,18 +1052,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" matomo_tracker: dependency: "direct main" description: @@ -1679,10 +1679,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" torch_light: dependency: "direct main" description: diff --git a/packages/smooth_app/test/pages/autocomplete_test.dart b/packages/smooth_app/test/pages/autocomplete_test.dart new file mode 100644 index 000000000000..c03c16fa7b5b --- /dev/null +++ b/packages/smooth_app/test/pages/autocomplete_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; + +/// Mock autocompleter that simulates slow/failing network +class _MockAutocompleter implements Autocompleter { + _MockAutocompleter({this.delay = Duration.zero, this.shouldFail = false}); + + final Duration delay; + final bool shouldFail; + int callCount = 0; + + @override + Future> getSuggestions(String input) async { + callCount++; + await Future.delayed(delay); + if (shouldFail) { + throw Exception('Network error'); + } + return ['$input-result1', '$input-result2']; + } +} + +void main() { + group('AutocompleteManager', () { + test('cache hit returns immediately without server call', () async { + final _MockAutocompleter mock = _MockAutocompleter(); + final AutocompleteManager manager = AutocompleteManager(mock); + + // First call hits server + final List first = await manager.getSuggestions('bo'); + expect(first, contains('bo-result1')); + expect(mock.callCount, 1); + + // Second call should hit cache — no new server call + final List second = await manager.getSuggestions('bo'); + expect(second, contains('bo-result1')); + expect(mock.callCount, 1); // still 1 — cache served it + }); + + test('returns most recent cached result when out of order', () async { + final _MockAutocompleter slowMock = _MockAutocompleter( + delay: const Duration(milliseconds: 100), + ); + final AutocompleteManager manager = AutocompleteManager(slowMock); + + // Fire both requests simultaneously + final Future> boFuture = manager.getSuggestions('bo'); + final Future> botFuture = manager.getSuggestions('bot'); + + final List boResult = await boFuture; + final List botResult = await botFuture; + + // Both should have cached their own results + expect(boResult, isNotEmpty); + expect(botResult, isNotEmpty); + }); + + test('network failure throws exception', () async { + final _MockAutocompleter failMock = _MockAutocompleter(shouldFail: true); + final AutocompleteManager manager = AutocompleteManager(failMock); + + // Should throw — smooth-app catch block must handle this + expect(() => manager.getSuggestions('bo'), throwsException); + }); + }); +}