From 0cce8bea005295ecedeea8a057e3484bf1b0ad08 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Mon, 21 Nov 2022 20:12:22 +0100 Subject: [PATCH] feature: 3263 - new BackgroundTaskManager that always works Deleted file: * `background_task_helper.dart` New files: * `background_task_manager.dart`: Management of background tasks: single thread, block, restart, display. * `dao_instant_string.dart`: Where we store strings that need INSTANT access (= not lazy, no await). Impacted fles: * `abstract_background_task.dart`: refactored * `background_task_details.dart`: refactored around the changes in `AbstractBackgroundTask` * `background_task_image.dart`: refactored around the changes in `AbstractBackgroundTask` * `dao_string_list.dart`: refactoring around now managing several lists; removed unnecessary `await` for a non-lazy dao * `local_database.dart`: added the new class `DaoInstantString`; relaunch the background task manager at every refresh * `main.dart`: minor refactoring * `new_crop_page.dart`: unrelated bug fix * `offline_tasks_page.dart`: refactored around the new `BackgroundTaskManager` * `operation_type.dart`: added helper methods * `product_image_gallery_view.dart`: minor refactoring * `product_image_viewer.dart`: unrelated bug fix - the product was not refreshed, and so wasn't the image even after a successful download * `pubspec.lock`: wtf * `pubspec.yaml`: removed `flutter_task_manager` * `search_history_view.dart`: minor refactoring now that we have several lists in `DaoStringList` * `search_page.dart`: minor refactoring now that we have several lists in `DaoStringList` * `up_to_date_changes.dart`: minor refactoring * `up_to_date_product_provider.dart`: minor refactoring --- .../background/abstract_background_task.dart | 81 +++---- .../background/background_task_details.dart | 58 ++--- .../lib/background/background_task_image.dart | 88 ++++---- .../background/background_task_manager.dart | 144 ++++++++++++ .../lib/data_models/operation_type.dart | 16 ++ .../lib/data_models/up_to_date_changes.dart | 10 +- .../up_to_date_product_provider.dart | 7 +- .../lib/database/dao_instant_string.dart | 23 ++ .../lib/database/dao_string_list.dart | 39 ++-- .../lib/database/local_database.dart | 8 +- .../lib/helpers/background_task_helper.dart | 23 -- packages/smooth_app/lib/main.dart | 4 +- .../lib/pages/offline_tasks_page.dart | 207 ++++-------------- .../product/product_image_gallery_view.dart | 4 +- .../pages/product/product_image_viewer.dart | 59 +++-- .../lib/pages/scan/search_history_view.dart | 8 +- .../lib/pages/scan/search_page.dart | 2 +- .../lib/tmp_crop_image/new_crop_page.dart | 3 + packages/smooth_app/pubspec.lock | 74 +------ packages/smooth_app/pubspec.yaml | 3 - 20 files changed, 429 insertions(+), 432 deletions(-) create mode 100644 packages/smooth_app/lib/background/background_task_manager.dart create mode 100644 packages/smooth_app/lib/database/dao_instant_string.dart delete mode 100644 packages/smooth_app/lib/helpers/background_task_helper.dart diff --git a/packages/smooth_app/lib/background/abstract_background_task.dart b/packages/smooth_app/lib/background/abstract_background_task.dart index 1de17385bef9..e41bb24a3472 100644 --- a/packages/smooth_app/lib/background/abstract_background_task.dart +++ b/packages/smooth_app/lib/background/abstract_background_task.dart @@ -1,14 +1,17 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:openfoodfacts/utils/CountryHelper.dart'; +import 'package:provider/provider.dart'; import 'package:smooth_app/background/background_task_details.dart'; import 'package:smooth_app/background/background_task_image.dart'; +import 'package:smooth_app/background/background_task_manager.dart'; import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/query/product_query.dart'; -import 'package:task_manager/task_manager.dart'; /// Abstract background task. abstract class AbstractBackgroundTask { @@ -34,29 +37,61 @@ abstract class AbstractBackgroundTask { final String user; final String country; - @protected Map toJson(); /// Returns the deserialized background task if possible, or null. - static AbstractBackgroundTask? fromTask(final Task task) => - BackgroundTaskDetails.fromTask(task) ?? - BackgroundTaskImage.fromTask(task); + static AbstractBackgroundTask? fromJson(final Map map) => + BackgroundTaskDetails.fromJson(map) ?? BackgroundTaskImage.fromJson(map); /// Response code sent by the server in case of a success. @protected static const int SUCCESS_CODE = 1; /// Executes the background task: upload, download, update locally. - Future execute(final LocalDatabase localDatabase) async { + Future execute(final LocalDatabase localDatabase) async { await upload(); await _downloadAndRefresh(localDatabase); - return TaskResult.success; } + /// Runs _instantly_ temporary code in order to "fake" the background task. + /// + /// For instance, here we can pretend that we've changed the product name + /// by doing it locally, but the background task that talks to the server + /// is not even started. + Future preExecute(final LocalDatabase localDatabase); + + /// Cleans the temporary data changes performed in [preExecute]. + Future postExecute(final LocalDatabase localDatabase); + /// Uploads data changes. @protected Future upload(); + /// SnackBar message when we add the task, like "Added to the task queue!" + @protected + String getSnackBarMessage(final AppLocalizations appLocalizations); + + /// Adds this task to the [BackgroundTaskManager]. + @protected + Future addToManager(final State widget) async { + if (!widget.mounted) { + return; + } + final LocalDatabase localDatabase = widget.context.read(); + await BackgroundTaskManager(localDatabase).add(this); + if (!widget.mounted) { + return; + } + ScaffoldMessenger.of(widget.context).showSnackBar( + SnackBar( + content: Text( + getSnackBarMessage(AppLocalizations.of(widget.context)), + ), + duration: SnackBarDuration.medium, + ), + ); + } + @protected OpenFoodFactsLanguage getLanguage() => LanguageHelper.fromJson(languageCode); @@ -84,37 +119,9 @@ abstract class AbstractBackgroundTask { await daoProduct.put(product); localDatabase.upToDate.setLatestDownloadedProduct(product); localDatabase.notifyListeners(); + return; } } - } - - /// Generates a unique id for the background task. - /// - /// This ensures that the background task is unique and also - /// ensures that in case of conflicts, the background task is replaced. - /// Example: 8901072002478_B_en_in_username - @protected - static String generateUniqueId( - String barcode, - String processIdentifier, { - final bool appendTimestamp = false, - }) { - final StringBuffer stringBuffer = StringBuffer(); - stringBuffer - ..write(barcode) - ..write('_') - ..write(processIdentifier) - ..write('_') - ..write(ProductQuery.getLanguage().code) - ..write('_') - ..write(ProductQuery.getCountry()!.iso2Code) - ..write('_') - ..write(ProductQuery.getUser().userId); - if (appendTimestamp) { - stringBuffer - ..write('_') - ..write(DateTime.now().millisecondsSinceEpoch); - } - return stringBuffer.toString(); + throw Exception('Could not download product!'); } } diff --git a/packages/smooth_app/lib/background/background_task_details.dart b/packages/smooth_app/lib/background/background_task_details.dart index d7d57aca961e..56744d4f427e 100644 --- a/packages/smooth_app/lib/background/background_task_details.dart +++ b/packages/smooth_app/lib/background/background_task_details.dart @@ -5,10 +5,9 @@ import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:openfoodfacts/utils/CountryHelper.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/background/abstract_background_task.dart'; +import 'package:smooth_app/data_models/operation_type.dart'; import 'package:smooth_app/database/local_database.dart'; -import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/query/product_query.dart'; -import 'package:task_manager/task_manager.dart'; /// Background task that changes product details (data, but no image upload). class BackgroundTaskDetails extends AbstractBackgroundTask { @@ -36,6 +35,8 @@ class BackgroundTaskDetails extends AbstractBackgroundTask { /// Task ID. static const String _PROCESS_NAME = 'PRODUCT_EDIT'; + static const OperationType _operationType = OperationType.details; + /// Serialized product. final String inputMap; @@ -51,10 +52,9 @@ class BackgroundTaskDetails extends AbstractBackgroundTask { }; /// Returns the deserialized background task if possible, or null. - static AbstractBackgroundTask? fromTask(final Task task) { + static BackgroundTaskDetails? fromJson(final Map map) { try { - final AbstractBackgroundTask result = - BackgroundTaskDetails._fromJson(task.data!); + final BackgroundTaskDetails result = BackgroundTaskDetails._fromJson(map); if (result.processName == _PROCESS_NAME) { return result; } @@ -65,16 +65,12 @@ class BackgroundTaskDetails extends AbstractBackgroundTask { } @override - Future execute(final LocalDatabase localDatabase) async { - try { - await super.execute(localDatabase); - } catch (e) { - // - } finally { + Future preExecute(final LocalDatabase localDatabase) async => + localDatabase.upToDate.addChange(uniqueId, _product); + + @override + Future postExecute(final LocalDatabase localDatabase) async => localDatabase.upToDate.terminate(uniqueId); - } - return TaskResult.success; - } /// Adds the background task about changing a product. static Future addTask( @@ -82,27 +78,21 @@ class BackgroundTaskDetails extends AbstractBackgroundTask { required final State widget, }) async { final LocalDatabase localDatabase = widget.context.read(); - final String uniqueId = - await localDatabase.upToDate.addChange(minimalistProduct); - final BackgroundTaskDetails backgroundTask = _getNewTask( + final String uniqueId = await _operationType.getNewKey( + localDatabase, + minimalistProduct.barcode!, + ); + final AbstractBackgroundTask task = _getNewTask( minimalistProduct, uniqueId, ); - // TODO(monsieurtanuki): currently we run the task immediately and just once - if it fails we rollback the changes. - backgroundTask.execute(localDatabase); // async - if (!widget.mounted) { - return; - } - ScaffoldMessenger.of(widget.context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(widget.context).product_task_background_schedule, - ), - duration: SnackBarDuration.medium, - ), - ); + await task.addToManager(widget); } + @override + String getSnackBarMessage(final AppLocalizations appLocalizations) => + appLocalizations.product_task_background_schedule; + /// Returns a new background task about changing a product. static BackgroundTaskDetails _getNewTask( final Product minimalistProduct, @@ -118,16 +108,16 @@ class BackgroundTaskDetails extends AbstractBackgroundTask { country: ProductQuery.getCountry()!.iso2Code, ); + Product get _product => + Product.fromJson(json.decode(inputMap) as Map); + /// Uploads the product changes. @override Future upload() async { - final Map productMap = - json.decode(inputMap) as Map; - // TODO(AshAman999): check returned Status await OpenFoodAPIClient.saveProduct( getUser(), - Product.fromJson(productMap), + _product, language: getLanguage(), country: getCountry(), ); diff --git a/packages/smooth_app/lib/background/background_task_image.dart b/packages/smooth_app/lib/background/background_task_image.dart index c620f854f274..3bdf8ca8c757 100644 --- a/packages/smooth_app/lib/background/background_task_image.dart +++ b/packages/smooth_app/lib/background/background_task_image.dart @@ -7,11 +7,10 @@ import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:openfoodfacts/utils/CountryHelper.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/background/abstract_background_task.dart'; +import 'package:smooth_app/data_models/operation_type.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/database/transient_file.dart'; -import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/query/product_query.dart'; -import 'package:task_manager/task_manager.dart'; /// Background task about product image upload. class BackgroundTaskImage extends AbstractBackgroundTask { @@ -41,6 +40,8 @@ class BackgroundTaskImage extends AbstractBackgroundTask { /// Task ID. static const String _PROCESS_NAME = 'IMAGE_UPLOAD'; + static const OperationType _operationType = OperationType.image; + final String imageField; final String imagePath; @@ -57,10 +58,9 @@ class BackgroundTaskImage extends AbstractBackgroundTask { }; /// Returns the deserialized background task if possible, or null. - static AbstractBackgroundTask? fromTask(final Task task) { + static AbstractBackgroundTask? fromJson(final Map map) { try { - final AbstractBackgroundTask result = - BackgroundTaskImage._fromJson(task.data!); + final AbstractBackgroundTask result = BackgroundTaskImage._fromJson(map); if (result.processName == _PROCESS_NAME) { return result; } @@ -78,55 +78,57 @@ class BackgroundTaskImage extends AbstractBackgroundTask { required final State widget, }) async { final LocalDatabase localDatabase = widget.context.read(); - // For "OTHER" images we randomize the id with timestamp - // so that it runs separately. - final String uniqueId = AbstractBackgroundTask.generateUniqueId( + final String uniqueId = await _operationType.getNewKey( + localDatabase, barcode, - imageField.value, - appendTimestamp: imageField == ImageField.OTHER, - ); - TransientFile.putImage(imageField, barcode, localDatabase, imageFile); - final BackgroundTaskImage backgroundTask = BackgroundTaskImage._( - uniqueId: uniqueId, - barcode: barcode, - processName: _PROCESS_NAME, - imageField: imageField.value, - imagePath: imageFile.path, - languageCode: ProductQuery.getLanguage().code, - user: jsonEncode(ProductQuery.getUser().toJson()), - country: ProductQuery.getCountry()!.iso2Code, ); - // TODO(monsieurtanuki): currently we run the task immediately and just once - if it fails we rollback the changes. - backgroundTask.execute(localDatabase); // async - if (!widget.mounted) { - return; - } - ScaffoldMessenger.of(widget.context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(widget.context).image_upload_queued, - ), - duration: SnackBarDuration.medium, - ), + final AbstractBackgroundTask task = _getNewTask( + barcode, + imageField, + imageFile, + uniqueId, ); + await task.addToManager(widget); } @override - Future execute(final LocalDatabase localDatabase) async { - try { - await super.execute(localDatabase); - } catch (e) { - // - } finally { + String getSnackBarMessage(final AppLocalizations appLocalizations) => + appLocalizations.image_upload_queued; + + /// Returns a new background task about changing a product. + static BackgroundTaskImage _getNewTask( + final String barcode, + final ImageField imageField, + final File imageFile, + final String uniqueId, + ) => + BackgroundTaskImage._( + uniqueId: uniqueId, + barcode: barcode, + processName: _PROCESS_NAME, + imageField: imageField.value, + imagePath: imageFile.path, + languageCode: ProductQuery.getLanguage().code, + user: jsonEncode(ProductQuery.getUser().toJson()), + country: ProductQuery.getCountry()!.iso2Code, + ); + + @override + Future preExecute(final LocalDatabase localDatabase) async => + TransientFile.putImage( + ImageFieldExtension.getType(imageField), + barcode, + localDatabase, + File(imagePath), + ); + + @override + Future postExecute(final LocalDatabase localDatabase) async => TransientFile.removeImage( ImageFieldExtension.getType(imageField), barcode, localDatabase, ); - localDatabase.notifyListeners(); - } - return TaskResult.success; - } /// Uploads the product image. @override diff --git a/packages/smooth_app/lib/background/background_task_manager.dart b/packages/smooth_app/lib/background/background_task_manager.dart new file mode 100644 index 000000000000..147a37b91b0c --- /dev/null +++ b/packages/smooth_app/lib/background/background_task_manager.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:smooth_app/background/abstract_background_task.dart'; +import 'package:smooth_app/database/dao_instant_string.dart'; +import 'package:smooth_app/database/dao_int.dart'; +import 'package:smooth_app/database/dao_string_list.dart'; +import 'package:smooth_app/database/local_database.dart'; + +/// Management of background tasks: single thread, block, restart, display. +class BackgroundTaskManager { + BackgroundTaskManager(this.localDatabase); + + final LocalDatabase localDatabase; + + /// [DaoInstantString] key for "Should we block the background tasks?". + /// + /// Value is null for "No we shouldn't". + /// It's probably just a temporary debug use-case. Will we keep it? + static const String _daoInstantStringBlockKey = 'taskManager/block'; + + /// Returns [DaoInstantString] key for tasks. + static String _taskIdToDaoInstantStringKey(final String taskId) => + 'task:$taskId'; + + /// [DaoStringList] key for the list of tasks. + static const String _daoStringListKey = DaoStringList.keyTasks; + + /// Adds a task to the pending task list. + Future add(final AbstractBackgroundTask task) async { + final String taskId = task.uniqueId; + await DaoInstantString(localDatabase).put( + _taskIdToDaoInstantStringKey(taskId), + jsonEncode(task.toJson()), + ); + await DaoStringList(localDatabase).add(_daoStringListKey, taskId); + await task.preExecute(localDatabase); + run(); // no await + } + + /// Removes a task from the pending task list + Future _remove(final String taskId) async { + await DaoStringList(localDatabase).remove(_daoStringListKey, taskId); + await DaoInstantString(localDatabase) + .put(_taskIdToDaoInstantStringKey(taskId), null); + } + + /// Returns the related task, or null but that is unexpected. + Future _get(final String taskId) async { + try { + final String? json = DaoInstantString(localDatabase) + .get(_taskIdToDaoInstantStringKey(taskId)); + if (json == null) { + // unexpected + return null; + } + final Map map = jsonDecode(json) as Map; + return AbstractBackgroundTask.fromJson(map); + } catch (e) { + // unexpected + return null; + } + } + + /// [DaoInt] key we use to store the latest start timestamp. + static const String _lastStartTimestampKey = 'taskLastStartTimestamp'; + + /// Duration in millis after which we can imagine the previous run failed. + static const int _aLongEnoughTimeInMilliseconds = 3600 * 1000; + + /// Returns true if we can run now. + /// + /// Will also set the "latest start timestamp". + /// With this, we can detect a run that went wrong. + /// Like, still running 1 hour later. + bool _canStartNow() { + final DaoInt daoInt = DaoInt(localDatabase); + final int now = LocalDatabase.nowInMillis(); + final int? latestRunStart = daoInt.get(_lastStartTimestampKey); + // TODO(monsieurtanuki): add minimum duration between runs, like 5 minutes? + if (latestRunStart == null || + latestRunStart + _aLongEnoughTimeInMilliseconds < now) { + daoInt.put(_lastStartTimestampKey, now); // no await, it's ok + return true; + } + return false; + } + + /// Signals we've just finished working and that we're ready for a new run. + void _justFinished() => + DaoInt(localDatabase).put(_lastStartTimestampKey, null); + + bool get blocked => + DaoInstantString(localDatabase).get(_daoInstantStringBlockKey) != null; + + set blocked(final bool block) => DaoInstantString(localDatabase).put( + _daoInstantStringBlockKey, + block ? '' : null, + ); + + /// Runs all the pending tasks, until it crashes. + Future run() async { + if (!_canStartNow()) { + return; + } + String? nextTaskId; + try { + while ((nextTaskId = _getNextTaskId()) != null) { + if (blocked) { + return; + } + await _runTask(nextTaskId!); + } + } catch (e) { + return; + } finally { + _justFinished(); + } + } + + /// Runs a single task. Possible exception. + Future _runTask(final String taskId) async { + final AbstractBackgroundTask? task = await _get(taskId); + if (task == null) { + await _remove(taskId); + return; + } + await task.execute(localDatabase); + await task.postExecute(localDatabase); + await _remove(taskId); + } + + /// Returns the next task id + String? _getNextTaskId() { + final List list = getAllTaskIds(); + if (list.isEmpty) { + return null; + } + return list.first; + } + + /// Returns all the task ids. + List getAllTaskIds() => + DaoStringList(localDatabase).getAll(_daoStringListKey); +} diff --git a/packages/smooth_app/lib/data_models/operation_type.dart b/packages/smooth_app/lib/data_models/operation_type.dart index 4692ccdbb27c..68d6bd0006f4 100644 --- a/packages/smooth_app/lib/data_models/operation_type.dart +++ b/packages/smooth_app/lib/data_models/operation_type.dart @@ -42,6 +42,22 @@ enum OperationType { return int.parse(keyItems[1]); } + static String getBarcode(final String key) { + final List keyItems = key.split(_transientHeaderSeparator); + return keyItems[2]; + } + + static OperationType? getOperationType(final String key) { + final List keyItems = key.split(_transientHeaderSeparator); + final String find = keyItems[0]; + for (final OperationType operationType in OperationType.values) { + if (operationType.header == find) { + return operationType; + } + } + return null; + } + static int sort( final TransientOperation operationA, final TransientOperation operationB, diff --git a/packages/smooth_app/lib/data_models/up_to_date_changes.dart b/packages/smooth_app/lib/data_models/up_to_date_changes.dart index 25789afaaba8..39ccb22a9bff 100644 --- a/packages/smooth_app/lib/data_models/up_to_date_changes.dart +++ b/packages/smooth_app/lib/data_models/up_to_date_changes.dart @@ -55,14 +55,8 @@ class UpToDateChanges { return product; } - Future add(final Product product) async { - final String key = await taskActionable.getNewKey( - localDatabase, - product.barcode!, - ); - _daoTransientProduct.put(key, product); - return key; - } + Future add(final String key, final Product product) async => + _daoTransientProduct.put(key, product); /// Returns true if some actions have not been terminated. bool hasNotTerminatedOperations(final String barcode) { diff --git a/packages/smooth_app/lib/data_models/up_to_date_product_provider.dart b/packages/smooth_app/lib/data_models/up_to_date_product_provider.dart index fc1bc91982a7..971706dce7c8 100644 --- a/packages/smooth_app/lib/data_models/up_to_date_product_provider.dart +++ b/packages/smooth_app/lib/data_models/up_to_date_product_provider.dart @@ -116,9 +116,12 @@ class UpToDateProductProvider { /// * the method creates a new minimalist change /// * that change has a (new) key /// * after creating the change, the method returns the key - Future addChange(final Product minimalistProduct) async { + Future addChange( + final String key, + final Product minimalistProduct, + ) async { final String barcode = minimalistProduct.barcode!; - final String key = await _changes.add(minimalistProduct); + await _changes.add(key, minimalistProduct); _timestamps[barcode] = LocalDatabase.nowInMillis(); localDatabase.notifyListeners(); return key; diff --git a/packages/smooth_app/lib/database/dao_instant_string.dart b/packages/smooth_app/lib/database/dao_instant_string.dart new file mode 100644 index 000000000000..caf08b367958 --- /dev/null +++ b/packages/smooth_app/lib/database/dao_instant_string.dart @@ -0,0 +1,23 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:smooth_app/database/abstract_dao.dart'; +import 'package:smooth_app/database/local_database.dart'; + +/// Where we store strings that need INSTANT access (= not lazy, no await). +class DaoInstantString extends AbstractDao { + DaoInstantString(final LocalDatabase localDatabase) : super(localDatabase); + + static const String _hiveBoxName = 'instantString'; + + @override + Future init() async => Hive.openBox(_hiveBoxName); + + @override + void registerAdapter() {} + + Box _getBox() => Hive.box(_hiveBoxName); + + String? get(final String key) => _getBox().get(key); + + Future put(final String key, final String? value) async => + value == null ? _getBox().delete(key) : _getBox().put(key, value); +} diff --git a/packages/smooth_app/lib/database/dao_string_list.dart b/packages/smooth_app/lib/database/dao_string_list.dart index c38ec95d84f5..28673e99ea2e 100644 --- a/packages/smooth_app/lib/database/dao_string_list.dart +++ b/packages/smooth_app/lib/database/dao_string_list.dart @@ -2,15 +2,20 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:smooth_app/database/abstract_dao.dart'; import 'package:smooth_app/database/local_database.dart'; -/// Where we store string lists +/// Where we store string lists with unique items. class DaoStringList extends AbstractDao { DaoStringList(final LocalDatabase localDatabase) : super(localDatabase); static const String _hiveBoxName = 'stringList'; - // for the moment we use only one string list - static const String _key = 'searchHistory'; - static const int _max = 10; + static const String keySearchHistory = 'searchHistory'; + static const String keyTasks = 'tasks'; + + /// Max lengths of each key (null means no limit). + static const Map _maxLengths = { + keySearchHistory: 10, + keyTasks: null, + }; @override Future init() async => Hive.openBox>(_hiveBoxName); @@ -20,24 +25,28 @@ class DaoStringList extends AbstractDao { Box> _getBox() => Hive.box>(_hiveBoxName); - Future> getAll() async => - _getBox().get(_key, defaultValue: [])!; + List getAll(final String key) => + _getBox().get(key, defaultValue: [])!; - Future add(final String string) async { - final List value = await getAll(); + /// Adds a unique value to the end. Removes is before if it pre-existed. + Future add(final String key, final String string) async { + final List value = getAll(key); value.remove(string); value.add(string); - while (value.length > _max) { - value.removeAt(0); + final int? maxLength = _maxLengths[key]; + if (maxLength != null) { + while (value.length > maxLength) { + value.removeAt(0); + } } - await _getBox().put(_key, value); + await _getBox().put(key, value); localDatabase.notifyListeners(); } - Future remove(final String string) async { - final List value = await getAll(); - if (value.remove(string)) { - await _getBox().put(_key, value); + Future remove(final String key, final String item) async { + final List list = getAll(key); + if (list.remove(item)) { + await _getBox().put(key, list); localDatabase.notifyListeners(); return true; } diff --git a/packages/smooth_app/lib/database/local_database.dart b/packages/smooth_app/lib/database/local_database.dart index a0ffbf291ad4..08c4cf704bf6 100644 --- a/packages/smooth_app/lib/database/local_database.dart +++ b/packages/smooth_app/lib/database/local_database.dart @@ -5,9 +5,11 @@ import 'package:flutter/foundation.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:smooth_app/background/background_task_manager.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_hive_product.dart'; +import 'package:smooth_app/database/dao_instant_string.dart'; import 'package:smooth_app/database/dao_int.dart'; import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/dao_product_list.dart'; @@ -36,7 +38,10 @@ class LocalDatabase extends ChangeNotifier { /// For the record, we need to override the method /// because the parent's is protected @override - void notifyListeners() => super.notifyListeners(); + void notifyListeners() { + BackgroundTaskManager(this).run(); // no await + super.notifyListeners(); + } static Future getLocalDatabase() async { // sql from there @@ -65,6 +70,7 @@ class LocalDatabase extends ChangeNotifier { DaoProductList(localDatabase), DaoStringList(localDatabase), DaoString(localDatabase), + DaoInstantString(localDatabase), DaoInt(localDatabase), DaoStringListMap(localDatabase), DaoTransientOperation(localDatabase), diff --git a/packages/smooth_app/lib/helpers/background_task_helper.dart b/packages/smooth_app/lib/helpers/background_task_helper.dart deleted file mode 100644 index af65138966a0..000000000000 --- a/packages/smooth_app/lib/helpers/background_task_helper.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:smooth_app/background/abstract_background_task.dart'; -import 'package:smooth_app/database/local_database.dart'; -import 'package:task_manager/task_manager.dart'; - -/// Runs whenever a task is started in the background. -/// Whatever invoked with TaskManager.addTask() will be run in this method. -/// Gets automatically invoked when there is a task added to the queue and the network conditions are favorable. -Future callbackDispatcher( - LocalDatabase localDatabase, -) async { - await TaskManager().init( - runTasksInIsolates: false, - executor: (Task inputData) async { - final AbstractBackgroundTask? taskData = - AbstractBackgroundTask.fromTask(inputData); - if (taskData == null) { - return TaskResult.success; - } - return taskData.execute(localDatabase); - }, - ); - return TaskResult.success; -} diff --git a/packages/smooth_app/lib/main.dart b/packages/smooth_app/lib/main.dart index 2ae39287a1f6..475bc6daff52 100644 --- a/packages/smooth_app/lib/main.dart +++ b/packages/smooth_app/lib/main.dart @@ -12,6 +12,7 @@ import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; import 'package:scanner_shared/scanner_shared.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:smooth_app/background/background_task_manager.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; import 'package:smooth_app/data_models/product_preferences.dart'; import 'package:smooth_app/data_models/user_management_provider.dart'; @@ -19,7 +20,6 @@ import 'package:smooth_app/data_models/user_preferences.dart'; import 'package:smooth_app/database/dao_string.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; -import 'package:smooth_app/helpers/background_task_helper.dart'; import 'package:smooth_app/helpers/camera_helper.dart'; import 'package:smooth_app/helpers/data_importer/smooth_app_data_importer.dart'; import 'package:smooth_app/helpers/network_config.dart'; @@ -104,7 +104,7 @@ Future _init1() async { ), daoString: DaoString(_localDatabase), ); - await callbackDispatcher(_localDatabase); + BackgroundTaskManager(_localDatabase).run(); UserManagementProvider().checkUserLoginValidity(); AnalyticsHelper.setCrashReports(_userPreferences.crashReports); diff --git a/packages/smooth_app/lib/pages/offline_tasks_page.dart b/packages/smooth_app/lib/pages/offline_tasks_page.dart index 6abbe2fa8940..f34a07e06639 100644 --- a/packages/smooth_app/lib/pages/offline_tasks_page.dart +++ b/packages/smooth_app/lib/pages/offline_tasks_page.dart @@ -1,196 +1,63 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/background/background_task_manager.dart'; +import 'package:smooth_app/data_models/operation_type.dart'; +import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/duration_constants.dart'; -import 'package:task_manager/task_manager.dart'; - -// TODO(ashaman999): add the translations later -const int POPUP_MENU_FIRST_ITEM = 0; class OfflineTaskPage extends StatefulWidget { - const OfflineTaskPage({ - super.key, - }); + const OfflineTaskPage(); @override State createState() => _OfflineTaskState(); } class _OfflineTaskState extends State { - Future> _fetchListItems() async { - return TaskManager().listPendingTasks().then( - (Iterable value) => value.toList( - growable: false, - ), - ); - } - @override Widget build(BuildContext context) { + final LocalDatabase localDatabase = context.watch(); + final BackgroundTaskManager manager = BackgroundTaskManager(localDatabase); + final bool blocked = manager.blocked; + final List taskIds = manager.getAllTaskIds(); return Scaffold( appBar: AppBar( title: const Text('Pending Background Tasks'), actions: [ - PopupMenuButton( - onSelected: (_) async { - await _cancelAllTask(); + IconButton( + icon: Icon(blocked ? Icons.toggle_on : Icons.toggle_off), + onPressed: () async { + manager.blocked = !blocked; + setState(() {}); + manager.run(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Background Tasks are now ${manager.blocked ? 'blocked' : 'NOT blocked'}', + ), + duration: SnackBarDuration.medium, + ), + ); }, - itemBuilder: (BuildContext context) => >[ - const PopupMenuItem( - value: POPUP_MENU_FIRST_ITEM, child: Text('Cancel all')), - ], - ), + ) ], - leading: BackButton( - onPressed: () => Navigator.pop(context), - ), ), - body: RefreshIndicator( - onRefresh: () async { - setState(() {}); - }, - child: Center( - child: FutureBuilder>( - future: _fetchListItems(), - builder: - (BuildContext context, AsyncSnapshot> snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator.adaptive(), + body: taskIds.isEmpty + ? const Center(child: EmptyScreen()) + : ListView.builder( + itemCount: taskIds.length, + itemBuilder: (final BuildContext context, final int index) { + final String taskId = taskIds[index]; + return ListTile( + title: Text(OperationType.getBarcode(taskId)), + subtitle: Text( + OperationType.getOperationType(taskId)?.toString() ?? + 'unknown operation type', + ), ); - } - if (snapshot.data!.isEmpty) { - return const EmptyScreen(); - } else { - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (BuildContext context, int index) { - if (snapshot.data!.isEmpty) { - return const Center( - child: Text( - 'No data', - style: TextStyle(color: Colors.white, fontSize: 30), - ), - ); - } - return TaskListTile( - index, - snapshot.data![index].uniqueId, - snapshot.data![index].data!['processName'].toString(), - snapshot.data![index].data!['barcode'].toString(), - ); - }, - ); - } - }, - ), - ), - ), - ); - } - - Future _cancelAllTask() async { - String status = 'All tasks Cancelled'; - try { - await TaskManager().cancelTasks(); - } catch (e) { - status = 'Something went wrong'; - } - setState(() {}); - final SnackBar snackBar = SnackBar( - content: Text( - status, - ), - duration: SnackBarDuration.medium, - ); - setState(() {}); - if (!mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar(snackBar); - } -} - -class TaskListTile extends StatefulWidget { - const TaskListTile( - this.index, - this.uniqueId, - this.processName, - this.barcode, - ) : assert(index >= 0), - assert(uniqueId.length > 0), - assert(barcode.length > 0), - assert(processName.length > 0); - - final int index; - final String uniqueId; - final String processName; - final String barcode; - - @override - State createState() => _TaskListTileState(); -} - -class _TaskListTileState extends State { - @override - Widget build(BuildContext context) { - return ListTile( - leading: Text((widget.index + 1).toString()), - title: Text(widget.barcode), - subtitle: Text(widget.processName), - trailing: Wrap( - children: [ - IconButton( - onPressed: () { - String status = 'Retrying'; - try { - TaskManager().runTask(widget.uniqueId); - } catch (e) { - status = 'Error: $e'; - } - final SnackBar snackBar = SnackBar( - content: Text(status), - duration: SnackBarDuration.medium, - ); - if (!mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar(snackBar); - setState(() {}); - }, - icon: const Icon(Icons.refresh)), - IconButton( - onPressed: () async { - await _cancelTask(widget.uniqueId); - - setState(() {}); }, - icon: const Icon(Icons.cancel)) - ], - ), + ), ); } - - Future _cancelTask(String uniqueId) async { - try { - await TaskManager().cancelTask(uniqueId); - const SnackBar snackBar = SnackBar( - content: Text('Cancelled'), - duration: SnackBarDuration.medium, - ); - if (!mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar(snackBar); - } catch (e) { - final SnackBar snackBar = SnackBar( - content: Text('Error: $e'), - duration: SnackBarDuration.medium, - ); - if (!mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar(snackBar); - } - } } class EmptyScreen extends StatelessWidget { diff --git a/packages/smooth_app/lib/pages/product/product_image_gallery_view.dart b/packages/smooth_app/lib/pages/product/product_image_gallery_view.dart index 13c5c56f1388..4e1ec55c7759 100644 --- a/packages/smooth_app/lib/pages/product/product_image_gallery_view.dart +++ b/packages/smooth_app/lib/pages/product/product_image_gallery_view.dart @@ -125,8 +125,8 @@ class _ProductImageGalleryViewState extends State { context, MaterialPageRoute( builder: (_) => ProductImageViewer( - barcode: _barcode, - imageData: imageData, + product: _product, + imageField: imageData.imageField, ), ), ); diff --git a/packages/smooth_app/lib/pages/product/product_image_viewer.dart b/packages/smooth_app/lib/pages/product/product_image_viewer.dart index c4ee7dda474c..6d2a80d936b6 100644 --- a/packages/smooth_app/lib/pages/product/product_image_viewer.dart +++ b/packages/smooth_app/lib/pages/product/product_image_viewer.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:http/http.dart' as http; +import 'package:openfoodfacts/model/Product.dart'; import 'package:openfoodfacts/model/ProductImage.dart'; import 'package:path_provider/path_provider.dart'; import 'package:photo_view/photo_view.dart'; @@ -15,6 +16,7 @@ import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/loading_dialog.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_back_button.dart'; import 'package:smooth_app/helpers/database_helper.dart'; +import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; import 'package:smooth_app/pages/product/confirm_and_upload_picture.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; @@ -22,25 +24,52 @@ import 'package:smooth_app/widgets/smooth_scaffold.dart'; /// Displays a full-screen image with an "edit" floating button. class ProductImageViewer extends StatefulWidget { const ProductImageViewer({ - required this.barcode, - required this.imageData, + required this.product, + required this.imageField, }); - final String barcode; - final ProductImageData imageData; + final Product product; + final ImageField imageField; @override State createState() => _ProductImageViewerState(); } class _ProductImageViewerState extends State { + late Product _product; + late final Product _initialProduct; + late final LocalDatabase _localDatabase; + late ProductImageData _imageData; + + String get _barcode => _initialProduct.barcode!; + + @override + void initState() { + super.initState(); + _initialProduct = widget.product; + _localDatabase = context.read(); + _localDatabase.upToDate.showInterest(_barcode); + } + + @override + void dispose() { + _localDatabase.upToDate.loseInterest(_barcode); + super.dispose(); + } + @override Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); context.watch(); + _product = _localDatabase.upToDate.getLocalUpToDate(_initialProduct); + _imageData = getProductImageData( + _product, + appLocalizations, + widget.imageField, + ); final ImageProvider? imageProvider = TransientFile.getImageProvider( - widget.imageData, - widget.barcode, + _imageData, + _barcode, ); return SmoothScaffold( extendBodyBehindAppBar: true, @@ -55,7 +84,7 @@ class _ProductImageViewerState extends State { backgroundColor: Colors.black, foregroundColor: WHITE_COLOR, elevation: 0, - title: Text(widget.imageData.title), + title: Text(_imageData.title), leading: SmoothBackButton( iconColor: Colors.white, onPressed: () => Navigator.maybePop(context), @@ -88,25 +117,25 @@ class _ProductImageViewerState extends State { Future _editImage() async { // we have no image at all here: we need to create one. - if (!TransientFile.isImageAvailable(widget.imageData, widget.barcode)) { + if (!TransientFile.isImageAvailable(_imageData, _barcode)) { await confirmAndUploadNewPicture( this, - imageField: widget.imageData.imageField, - barcode: widget.barcode, + imageField: _imageData.imageField, + barcode: _barcode, ); return; } // best option: use the transient file. File? imageFile = TransientFile.getImage( - widget.imageData.imageField, - widget.barcode, + _imageData.imageField, + _barcode, ); // but if not possible, get the best picture from the server. if (imageFile == null) { final AppLocalizations appLocalizations = AppLocalizations.of(context); - final String? imageUrl = widget.imageData.getImageUrl(ImageSize.ORIGINAL); + final String? imageUrl = _imageData.getImageUrl(ImageSize.ORIGINAL); if (imageUrl == null) { await LoadingDialog.error( context: context, @@ -138,8 +167,8 @@ class _ProductImageViewerState extends State { context, MaterialPageRoute( builder: (BuildContext context) => ConfirmAndUploadPicture( - barcode: widget.barcode, - imageField: widget.imageData.imageField, + barcode: _barcode, + imageField: _imageData.imageField, initialPhoto: imageFile!, ), ), diff --git a/packages/smooth_app/lib/pages/scan/search_history_view.dart b/packages/smooth_app/lib/pages/scan/search_history_view.dart index 578dd4c4c027..780f2da49ad6 100644 --- a/packages/smooth_app/lib/pages/scan/search_history_view.dart +++ b/packages/smooth_app/lib/pages/scan/search_history_view.dart @@ -26,9 +26,10 @@ class _SearchHistoryViewState extends State { _fetchQueries(); } - Future _fetchQueries() async { + void _fetchQueries() { final LocalDatabase localDatabase = context.watch(); - final List queries = await DaoStringList(localDatabase).getAll(); + final List queries = + DaoStringList(localDatabase).getAll(DaoStringList.keySearchHistory); setState(() => _queries = queries.reversed.toList()); } @@ -92,7 +93,8 @@ class _SearchHistoryViewState extends State { _queries.remove(query); // and we need to impact the database too final LocalDatabase localDatabase = context.read(); - await DaoStringList(localDatabase).remove(query); + await DaoStringList(localDatabase) + .remove(DaoStringList.keySearchHistory, query); setState(() {}); } } diff --git a/packages/smooth_app/lib/pages/scan/search_page.dart b/packages/smooth_app/lib/pages/scan/search_page.dart index d0fc0015f6dc..37bd8806c3f6 100644 --- a/packages/smooth_app/lib/pages/scan/search_page.dart +++ b/packages/smooth_app/lib/pages/scan/search_page.dart @@ -25,7 +25,7 @@ void _performSearch( } final LocalDatabase localDatabase = context.read(); - DaoStringList(localDatabase).add(query); + DaoStringList(localDatabase).add(DaoStringList.keySearchHistory, query); if (int.tryParse(query) != null) { _onSubmittedBarcode( diff --git a/packages/smooth_app/lib/tmp_crop_image/new_crop_page.dart b/packages/smooth_app/lib/tmp_crop_image/new_crop_page.dart index 13246d38753f..12a4ef6db4bf 100644 --- a/packages/smooth_app/lib/tmp_crop_image/new_crop_page.dart +++ b/packages/smooth_app/lib/tmp_crop_image/new_crop_page.dart @@ -42,6 +42,9 @@ class _CropPageState extends State { Future _load() async { _image = await loadUiImage(); + if (!mounted) { + return; + } setState(() {}); } diff --git a/packages/smooth_app/pubspec.lock b/packages/smooth_app/pubspec.lock index 10998619a69f..c22874e6956f 100644 --- a/packages/smooth_app/pubspec.lock +++ b/packages/smooth_app/pubspec.lock @@ -217,48 +217,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.16.0" - connectivity_plus: - dependency: transitive - description: - name: connectivity_plus - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.9" - connectivity_plus_linux: - dependency: transitive - description: - name: connectivity_plus_linux - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - connectivity_plus_macos: - dependency: transitive - description: - name: connectivity_plus_macos - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.6" - connectivity_plus_platform_interface: - dependency: transitive - description: - name: connectivity_plus_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.3" - connectivity_plus_web: - dependency: transitive - description: - name: connectivity_plus_web - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.5" - connectivity_plus_windows: - dependency: transitive - description: - name: connectivity_plus_windows - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.2" convert: dependency: transitive description: @@ -315,13 +273,6 @@ packages: relative: true source: path version: "0.0.0" - dbus: - dependency: transitive - description: - name: dbus - url: "https://pub.dartlang.org" - source: hosted - version: "0.7.8" device_frame: dependency: transitive description: @@ -805,13 +756,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" - nm: - dependency: transitive - description: - name: nm - url: "https://pub.dartlang.org" - source: hosted - version: "0.5.0" openfoodfacts: dependency: "direct main" description: @@ -1281,15 +1225,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.0+3" - task_manager: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "29e009e760c973359adc99fb86e861dd948daa26" - url: "https://github.com/g123k/flutter_task_manager.git" - source: git - version: "0.0.1" term_glyph: dependency: transitive description: @@ -1451,13 +1386,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" - workmanager: - dependency: transitive - description: - name: workmanager - url: "https://pub.dartlang.org" - source: hosted - version: "0.5.0" xdg_directories: dependency: transitive description: @@ -1480,5 +1408,5 @@ packages: source: hosted version: "3.1.1" sdks: - dart: ">=2.17.6 <3.0.0" + dart: ">=2.17.0 <3.0.0" flutter: ">=3.0.0" diff --git a/packages/smooth_app/pubspec.yaml b/packages/smooth_app/pubspec.yaml index 63b138da331b..1bdc59f8ce38 100644 --- a/packages/smooth_app/pubspec.yaml +++ b/packages/smooth_app/pubspec.yaml @@ -57,9 +57,6 @@ dependencies: path: "packages/camera/camera_platform_interface" scanner_shared: path: ../scanner/shared - task_manager: - git: - url: "https://github.com/g123k/flutter_task_manager.git" audioplayers: 1.0.0 percent_indicator: 4.2.2