diff --git a/packages/smooth_app/ios/Runner.xcodeproj/project.pbxproj b/packages/smooth_app/ios/Runner.xcodeproj/project.pbxproj index 7151bd5c0f..bf2b4585c0 100644 --- a/packages/smooth_app/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/smooth_app/ios/Runner.xcodeproj/project.pbxproj @@ -566,4 +566,4 @@ /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; -} +} \ No newline at end of file diff --git a/packages/smooth_app/ios/Runner/Info.plist b/packages/smooth_app/ios/Runner/Info.plist index 926161b854..f38ae585b2 100644 --- a/packages/smooth_app/ios/Runner/Info.plist +++ b/packages/smooth_app/ios/Runner/Info.plist @@ -1,59 +1,59 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - OpenFoodFacts - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - OpenFoodFacts - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - UIViewControllerBasedStatusBarAppearance - - UIStatusBarHidden - - NSPhotoLibraryUsageDescription - This app needs to access photo library for product photos uploads - NSCameraUsageDescription - This app needs Camera Usage for scanning barcodes and cropping photos - LSApplicationQueriesSchemes - - https - http - mailto - - UIRequiresFullScreen - - CADisableMinimumFrameDurationOnPhone + + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenFoodFacts + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + OpenFoodFacts + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSApplicationQueriesSchemes + + https + http + mailto + + LSRequiresIPhoneOS + + NSCameraUsageDescription + This app needs Camera Usage for scanning barcodes and cropping photos + NSPhotoLibraryUsageDescription + This app needs to access photo library for product photos uploads + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiresFullScreen + + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIViewControllerBasedStatusBarAppearance + diff --git a/packages/smooth_app/lib/cards/data_cards/image_upload_card.dart b/packages/smooth_app/lib/cards/data_cards/image_upload_card.dart index b6824fb3d7..0bc11c59b4 100644 --- a/packages/smooth_app/lib/cards/data_cards/image_upload_card.dart +++ b/packages/smooth_app/lib/cards/data_cards/image_upload_card.dart @@ -1,5 +1,4 @@ import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; @@ -46,35 +45,17 @@ class _ImageUploadCardState extends State { if (!mounted) { return; } - final bool isUploaded = await uploadCapturedPicture( + await uploadCapturedPicture( context, barcode: widget.product .barcode!, //Probably throws an error, but this is not a big problem when we got a product without a barcode imageField: widget.productImageData.imageField, imageUri: croppedImageFile.uri, ); - croppedImageFile.delete(); + if (!mounted) { return; } - if (isUploaded) { - if (widget.productImageData.imageField == ImageField.OTHER) { - final AppLocalizations appLocalizations = - AppLocalizations.of(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(appLocalizations.other_photo_uploaded), - duration: const Duration(seconds: 3), - action: SnackBarAction( - label: appLocalizations.more_photos, - onPressed: _getImage, - ), - ), - ); - } else { - await widget.onUpload(context); - } - } } } diff --git a/packages/smooth_app/lib/data_models/continuous_scan_model.dart b/packages/smooth_app/lib/data_models/continuous_scan_model.dart index 457c3ab117..8b0a8a781b 100644 --- a/packages/smooth_app/lib/data_models/continuous_scan_model.dart +++ b/packages/smooth_app/lib/data_models/continuous_scan_model.dart @@ -8,6 +8,7 @@ import 'package:smooth_app/data_models/product_list.dart'; import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/query/barcode_product_query.dart'; import 'package:smooth_app/services/smooth_services.dart'; @@ -170,7 +171,7 @@ class ContinuousScanModel with ChangeNotifier { try { // We try to load the fresh copy of product from the server final FetchedProduct fetchedProduct = - await _queryBarcode(barcode).timeout(const Duration(seconds: 5)); + await _queryBarcode(barcode).timeout(SnackBarDuration.long); if (fetchedProduct.product != null) { _addProduct(barcode, ScannedProductState.CACHED); return true; diff --git a/packages/smooth_app/lib/data_models/onboarding_data_product.dart b/packages/smooth_app/lib/data_models/onboarding_data_product.dart index 9b8cad5941..35e7123afd 100644 --- a/packages/smooth_app/lib/data_models/onboarding_data_product.dart +++ b/packages/smooth_app/lib/data_models/onboarding_data_product.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:smooth_app/data_models/abstract_onboarding_data.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'; /// Helper around a product we download, store and reuse at onboarding. @@ -42,7 +43,7 @@ class OnboardingDataProduct extends AbstractOnboardingData { language: ProductQuery.getLanguage(), country: ProductQuery.getCountry(), ), - ).timeout(const Duration(seconds: 5)); + ).timeout(SnackBarDuration.long); @override String getAssetPath() => assetPath; diff --git a/packages/smooth_app/lib/data_models/onboarding_loader.dart b/packages/smooth_app/lib/data_models/onboarding_loader.dart index 04d1ae9417..63715b0149 100644 --- a/packages/smooth_app/lib/data_models/onboarding_loader.dart +++ b/packages/smooth_app/lib/data_models/onboarding_loader.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:smooth_app/data_models/onboarding_data_product.dart'; import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/generic_lib/loading_dialog.dart'; import 'package:smooth_app/pages/onboarding/onboarding_flow_navigator.dart'; @@ -33,7 +34,7 @@ class OnboardingLoader { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(appLocalizations.onboarding_welcome_loading_error), - duration: const Duration(seconds: 2), + duration: SnackBarDuration.short, behavior: SnackBarBehavior.floating, elevation: 0, ), diff --git a/packages/smooth_app/lib/generic_lib/duration_constants.dart b/packages/smooth_app/lib/generic_lib/duration_constants.dart index 48e0e64df7..4df9e451ad 100644 --- a/packages/smooth_app/lib/generic_lib/duration_constants.dart +++ b/packages/smooth_app/lib/generic_lib/duration_constants.dart @@ -6,3 +6,12 @@ class SmoothAnimationsDuration { static const Duration medium = Duration(milliseconds: 400); static const Duration long = Duration(milliseconds: 500); } + +class SnackBarDuration { + const SnackBarDuration._(); + + static const Duration brief = Duration(seconds: 1); + static const Duration short = Duration(seconds: 2); + static const Duration medium = Duration(seconds: 3); + static const Duration long = Duration(seconds: 5); +} diff --git a/packages/smooth_app/lib/helpers/background_task_helper.dart b/packages/smooth_app/lib/helpers/background_task_helper.dart new file mode 100644 index 0000000000..1be43e4d4b --- /dev/null +++ b/packages/smooth_app/lib/helpers/background_task_helper.dart @@ -0,0 +1,249 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:openfoodfacts/utils/CountryHelper.dart'; +import 'package:smooth_app/database/dao_product.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/query/product_query.dart'; +import 'package:task_manager/task_manager.dart'; + +/// Task Manager IDs and extras +/// Broadly classify the tasks into 2 categories +/// Task ID for image uploads +const String IMAGE_UPLOAD_TASK = 'IMAGE_UPLOAD'; + +/// Task ID for product edition +const String PRODUCT_EDIT_TASK = 'PRODUCT_EDIT'; + +/// Extra used for product edition +/// Constant for ingredient edit task +const String INGREDIENT_EDIT = 'INGREDIENTS_EDIT'; + +/// Constant for nutrition edit task +const String NUTRITION_EDIT = 'NUTRITION_EDIT'; + +/// Constant for basic details edit task +const String BASIC_DETAILS = 'BASIC_DETAILS'; + +/// Response code sent by the server in case of a success +const int SUCESS_CODE = 1; + +/// 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 String processName = inputData.data!['processName'] as String; + switch (processName) { + case IMAGE_UPLOAD_TASK: + return uploadImage(inputData.data!, localDatabase); + + case PRODUCT_EDIT_TASK: + return otherDetails(inputData.data!, localDatabase); + + default: + return TaskResult.success; + } + }, + ); + return TaskResult.success; +} + +/// This takes the product JSON and uploads the data to OpenFoodFacts server +/// and queries the updated [Product], then it updates the product in the local database +Future otherDetails( + Map inputData, + LocalDatabase localDatabase, +) async { + final BackgroundOtherDetailsInput inputTask = + BackgroundOtherDetailsInput.fromJson(inputData); + final Map productMap = + json.decode(inputTask.inputMap) as Map; + final User user = + User.fromJson(jsonDecode(inputTask.user) as Map); + + await OpenFoodAPIClient.saveProduct( + user, + Product.fromJson(productMap), + language: LanguageHelper.fromJson(inputTask.languageCode), + country: CountryHelper.fromJson(inputTask.country), + ); + + final DaoProduct daoProduct = DaoProduct(localDatabase); + final ProductQueryConfiguration configuration = ProductQueryConfiguration( + inputTask.barcode, + fields: ProductQuery.fields, + language: LanguageHelper.fromJson(inputTask.languageCode), + country: CountryHelper.fromJson(inputTask.country), + ); + + final ProductResult queryResult = + await OpenFoodAPIClient.getProduct(configuration); + + if (queryResult.status == SUCESS_CODE) { + final Product? product = queryResult.product; + if (product != null) { + await daoProduct.put(product); + localDatabase.notifyListeners(); + } + } + + // Returns true to let platform know that the task is completed + return TaskResult.success; +} + +/// This takes an image and uploads it to OpenFoodFacts servers +/// and queries the updated [Product]. +/// Then it updates the product in the local database. +Future uploadImage( + Map inputData, + LocalDatabase localDatabase, +) async { + final BackgroundImageInputData inputTask = + BackgroundImageInputData.fromJson(inputData); + final User user = + User.fromJson(jsonDecode(inputTask.user) as Map); + + final SendImage image = SendImage( + lang: LanguageHelper.fromJson(inputTask.languageCode), + barcode: inputTask.barcode, + imageField: ImageFieldExtension.getType(inputTask.imageField), + imageUri: Uri.parse(inputTask.imagePath), + ); + + await OpenFoodAPIClient.addProductImage(user, image); + + // Go to the file system and delete the file that was uploaded + File(inputTask.imagePath).deleteSync(); + final DaoProduct daoProduct = DaoProduct(localDatabase); + final ProductQueryConfiguration configuration = ProductQueryConfiguration( + inputTask.barcode, + fields: ProductQuery.fields, + language: LanguageHelper.fromJson(inputTask.languageCode), + country: CountryHelper.fromJson(inputTask.country), + ); + + final ProductResult queryResult = + await OpenFoodAPIClient.getProduct(configuration); + if (queryResult.status == SUCESS_CODE) { + final Product? product = queryResult.product; + if (product != null) { + await daoProduct.put(product); + localDatabase.notifyListeners(); + } + } + + return TaskResult.success; +} + +/// Helper class for serialization and deserialization of data used by the task manager +class BackgroundImageInputData { + BackgroundImageInputData({ + required this.processName, + required this.uniqueId, + required this.barcode, + required this.imageField, + required this.imagePath, + required this.languageCode, + required this.user, + required this.country, + }); + + BackgroundImageInputData.fromJson(Map json) + : processName = json['processName'] as String, + uniqueId = json['uniqueId'] as String, + barcode = json['barcode'] as String, + imageField = json['imageField'] as String, + imagePath = json['imagePath'] as String, + languageCode = json['languageCode'] as String, + user = json['user'] as String, + country = json['country'] as String; + + final String processName; + final String uniqueId; + final String barcode; + final String imageField; + final String imagePath; + final String languageCode; + final String user; + final String country; + + Map toJson() => { + 'processName': processName, + 'uniqueId': uniqueId, + 'barcode': barcode, + 'imageField': imageField, + 'imagePath': imagePath, + 'languageCode': languageCode, + 'user': user, + 'country': country, + }; +} + +class BackgroundOtherDetailsInput { + BackgroundOtherDetailsInput({ + required this.processName, + required this.uniqueId, + required this.barcode, + required this.languageCode, + required this.inputMap, + required this.user, + required this.country, + }); + + BackgroundOtherDetailsInput.fromJson(Map json) + : processName = json['processName'] as String, + uniqueId = json['uniqueId'] as String, + barcode = json['barcode'] as String, + languageCode = json['languageCode'] as String, + inputMap = json['inputMap'] as String, + user = json['user'] as String, + country = json['country'] as String; + + final String processName; + final String uniqueId; + final String barcode; + final String languageCode; + final String inputMap; + final String user; + final String country; + + Map toJson() => { + 'processName': processName, + 'uniqueId': uniqueId, + 'barcode': barcode, + 'languageCode': languageCode, + 'inputMap': inputMap, + 'user': user, + 'country': country, + }; +} + +/// 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: 00000000_BASIC_DETAILS_en_us_"random_user_id" +class UniqueIdGenerator { + const UniqueIdGenerator._(); + static String generateUniqueId( + String barcode, + String processIdentifier, + ) { + 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); + return stringBuffer.toString(); + } +} diff --git a/packages/smooth_app/lib/helpers/picture_capture_helper.dart b/packages/smooth_app/lib/helpers/picture_capture_helper.dart index e4661caece..9437a6eee7 100644 --- a/packages/smooth_app/lib/helpers/picture_capture_helper.dart +++ b/packages/smooth_app/lib/helpers/picture_capture_helper.dart @@ -1,10 +1,16 @@ +import 'dart:convert'; +import 'dart:io'; 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/data_models/continuous_scan_model.dart'; -import 'package:smooth_app/generic_lib/loading_dialog.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; +import 'package:smooth_app/helpers/background_task_helper.dart'; import 'package:smooth_app/query/product_query.dart'; +import 'package:task_manager/task_manager.dart'; Future uploadCapturedPicture( BuildContext context, { @@ -13,32 +19,68 @@ Future uploadCapturedPicture( required Uri imageUri, }) async { final AppLocalizations appLocalizations = AppLocalizations.of(context); - final SendImage image = SendImage( - lang: ProductQuery.getLanguage(), + final LocalDatabase localDatabase = context.read(); + final String uniqueId = _getUniqueId(imageField, barcode); + final BackgroundImageInputData backgroundImageInputData = + BackgroundImageInputData( + processName: IMAGE_UPLOAD_TASK, + uniqueId: uniqueId, barcode: barcode, - imageField: imageField, - imageUri: imageUri, + imageField: imageField.value, + imagePath: File(imageUri.path).path, + languageCode: ProductQuery.getLanguage().code, + user: jsonEncode(ProductQuery.getUser().toJson()), + country: ProductQuery.getCountry()!.iso2Code, ); - final Status? result = await LoadingDialog.run( - context: context, - future: OpenFoodAPIClient.addProductImage( - ProductQuery.getUser(), - image, + await TaskManager().addTask( + Task( + data: backgroundImageInputData.toJson(), + uniqueId: uniqueId, + ), + ); + + localDatabase.notifyListeners(); + // ignore: use_build_context_synchronously + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + appLocalizations.image_upload_queued, + ), + duration: SnackBarDuration.medium, ), - title: appLocalizations.uploading_image, ); - if (result == null || result.error != null || result.status != 'status ok') { - await LoadingDialog.error( - context: context, - title: appLocalizations.error_occurred, - ); - return false; - } //ignore: use_build_context_synchronously await _updateContinuousScanModel(context, barcode); return true; } +/// Generates a unique id for the task,in case of tasks with the same name, +/// it gets replaced with the new one,also for other images we randomize the +/// id with date time so that it runs separately. +/// Example: 00000000_front_en_us_"random_user_id" or +/// 00000000_other_en_us_"random_user_id"_1661677638662 +String _getUniqueId(ImageField imageField, String barcode) { +// Use String buffer to concatenate strings + final StringBuffer stringBuffer = StringBuffer(); + stringBuffer + ..write(barcode) + ..write('_') + ..write(imageField.value) + ..write('_') + ..write(ProductQuery.getLanguage().code) + ..write('_') + ..write(ProductQuery.getCountry()!.iso2Code) + ..write('_') + ..write(ProductQuery.getUser().userId); + if (imageField != ImageField.OTHER) { + return stringBuffer.toString(); + } + stringBuffer + ..write('_') + ..write(DateTime.now().millisecondsSinceEpoch); + return stringBuffer.toString(); +} + Future _updateContinuousScanModel( BuildContext context, String barcode) async { final ContinuousScanModel model = context.read(); diff --git a/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_page.dart b/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_page.dart index a591d6d5f8..2a6142ef4b 100644 --- a/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_page.dart +++ b/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_page.dart @@ -8,6 +8,7 @@ import 'package:openfoodfacts/model/Product.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/duration_constants.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; import 'package:smooth_app/knowledge_panel/knowledge_panels/knowledge_panel_expanded_card.dart'; import 'package:smooth_app/pages/inherited_data_manager.dart'; @@ -93,7 +94,7 @@ class _KnowledgePanelPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(appLocalizations.product_refreshed), - duration: const Duration(seconds: 2), + duration: SnackBarDuration.short, ), ); } diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index d383d294ce..4cb954b758 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -1573,6 +1573,10 @@ "@choose_from_gallery": { "description": "Button label for choosing a photo from gallery" }, + "image_upload_queued": "The image will be uploaded in the background as soon as possible.", + "@image_upload_queued": { + "description": "Message when a photo is queued for upload" + }, "expand_nutrition_facts": "Expand nutrition facts table", "@expand_nutrition_facts": { "description": "Label for expanding nutrition facts table in application setting" @@ -1614,6 +1618,10 @@ "@help_with_openfoodfacts": { "description": "Label for the email title" }, + "product_task_background_schedule": "The product will be updated in the background as soon as possible.", + "@product_task_background_schedule": { + "description": "Message when a product is scheduled for background update" + }, "no_email_client_available_dialog_title": "No email application!", "@no_email_client_available_dialog_title": { "description": "Title for the dialog when no email client is installed on the device" diff --git a/packages/smooth_app/lib/main.dart b/packages/smooth_app/lib/main.dart index a61ba52ab4..fe199f2290 100644 --- a/packages/smooth_app/lib/main.dart +++ b/packages/smooth_app/lib/main.dart @@ -18,6 +18,7 @@ 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'; @@ -41,7 +42,6 @@ Future main({final bool screenshots = false}) async { final WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); - if (kReleaseMode) { await AnalyticsHelper.initSentry( appRunner: () => runApp(const SmoothApp()), @@ -99,6 +99,7 @@ Future _init1() async { ), daoString: DaoString(_localDatabase), ); + await callbackDispatcher(_localDatabase); AnalyticsHelper.setCrashReports(_userPreferences.crashReports); ProductQuery.setCountry(_userPreferences.userCountryCode); diff --git a/packages/smooth_app/lib/pages/offline_tasks_page.dart b/packages/smooth_app/lib/pages/offline_tasks_page.dart new file mode 100644 index 0000000000..6abbe2fa89 --- /dev/null +++ b/packages/smooth_app/lib/pages/offline_tasks_page.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.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, + }); + + @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) { + return Scaffold( + appBar: AppBar( + title: const Text('Pending Background Tasks'), + actions: [ + PopupMenuButton( + onSelected: (_) async { + await _cancelAllTask(); + }, + 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(), + ); + } + 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 { + const EmptyScreen({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + return const Center(child: Text('No Pending Tasks')); + } +} diff --git a/packages/smooth_app/lib/pages/personalized_ranking_page.dart b/packages/smooth_app/lib/pages/personalized_ranking_page.dart index 9577a450d9..b25ac86713 100644 --- a/packages/smooth_app/lib/pages/personalized_ranking_page.dart +++ b/packages/smooth_app/lib/pages/personalized_ranking_page.dart @@ -10,6 +10,7 @@ import 'package:smooth_app/data_models/up_to_date_product_provider.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/buttons/smooth_large_button_with_icon.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/product_compatibility_helper.dart'; import 'package:smooth_app/pages/product/common/product_list_item_simple.dart'; @@ -191,7 +192,7 @@ class _PersonalizedRankingPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(appLocalizations.product_removed_comparison), - duration: const Duration(seconds: 3), + duration: SnackBarDuration.medium, ), ); }, diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart index cac11f9088..254a0ce725 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart @@ -13,6 +13,7 @@ import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; import 'package:smooth_app/generic_lib/loading_dialog.dart'; import 'package:smooth_app/helpers/data_importer/product_list_import_export.dart'; import 'package:smooth_app/helpers/data_importer/smooth_app_data_importer.dart'; +import 'package:smooth_app/pages/offline_tasks_page.dart'; import 'package:smooth_app/pages/onboarding/onboarding_flow_navigator.dart'; import 'package:smooth_app/pages/preferences/abstract_user_preferences.dart'; import 'package:smooth_app/pages/preferences/user_preferences_dialog_editor.dart'; @@ -269,6 +270,17 @@ class UserPreferencesDevMode extends AbstractUserPreferences { }, ), _dataImporterTile(), + ListTile( + title: const Text('Pending Tasks'), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => const OfflineTaskPage(), + ), + ); + }, + ), ListTile( title: Text( appLocalizations.dev_preferences_import_history_title, diff --git a/packages/smooth_app/lib/pages/product/add_basic_details_page.dart b/packages/smooth_app/lib/pages/product/add_basic_details_page.dart index 5af93b40a7..fe9295e9f2 100644 --- a/packages/smooth_app/lib/pages/product/add_basic_details_page.dart +++ b/packages/smooth_app/lib/pages/product/add_basic_details_page.dart @@ -1,14 +1,20 @@ +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/cards/product_cards/product_image_carousel.dart'; +import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_text_form_field.dart'; -import 'package:smooth_app/pages/product/common/product_refresher.dart'; +import 'package:smooth_app/helpers/background_task_helper.dart'; +import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; +import 'package:task_manager/task_manager.dart'; class AddBasicDetailsPage extends StatefulWidget { const AddBasicDetailsPage( @@ -129,31 +135,67 @@ class _AddBasicDetailsPageState extends State { if (!_formKey.currentState!.validate()) { return; } - final bool savedAndRefreshed = - await ProductRefresher().saveAndRefresh( - context: context, - localDatabase: localDatabase, - product: Product( - productName: _productNameController.text, - quantity: _weightController.text, - brands: _brandNameController.text, - barcode: _product.barcode, - ), - isLoggedInMandatory: widget.isLoggedInMandatory, + final Product inputProduct = Product( + productName: _productNameController.text, + quantity: _weightController.text, + brands: _brandNameController.text, + barcode: _product.barcode, ); - if (!savedAndRefreshed) { - return; + final String uniqueId = UniqueIdGenerator.generateUniqueId( + _product.barcode!, BASIC_DETAILS); + final BackgroundOtherDetailsInput + backgroundBasicDetailsInput = + BackgroundOtherDetailsInput( + processName: PRODUCT_EDIT_TASK, + uniqueId: uniqueId, + barcode: _product.barcode!, + inputMap: jsonEncode(inputProduct.toJson()), + languageCode: ProductQuery.getLanguage().code, + user: jsonEncode(ProductQuery.getUser().toJson()), + country: ProductQuery.getCountry()!.iso2Code, + ); + await TaskManager().addTask( + Task( + data: backgroundBasicDetailsInput.toJson(), + uniqueId: uniqueId, + ), + ); + + final DaoProduct daoProduct = DaoProduct(localDatabase); + final Product? product = await daoProduct.get( + _product.barcode!, + ); + // We go and chek in the local database if the product is + // already in the database. If it is, we update the fields of the product. + //And if it is not, we create a new product with the fields of the _product + // and we insert it in the database. (Giving the user an immediate feedback) + if (product == null) { + daoProduct.put(Product( + barcode: _product.barcode, + productName: _productNameController.text, + brands: _brandNameController.text, + quantity: _weightController.text, + lang: ProductQuery.getLanguage(), + )); + } else { + product.productName = _productNameController.text; + product.brands = _brandNameController.text; + product.quantity = _weightController.text; + daoProduct.put(product); } + localDatabase.notifyListeners(); if (!mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: - Text(appLocalizations.basic_details_add_success), + content: Text( + appLocalizations.basic_details_add_success, + ), + duration: SnackBarDuration.medium, ), ); - Navigator.pop(context); + Navigator.pop(context, product); }, ), ), diff --git a/packages/smooth_app/lib/pages/product/add_new_product_page.dart b/packages/smooth_app/lib/pages/product/add_new_product_page.dart index 58989f8b0a..1b9b4d7bfb 100644 --- a/packages/smooth_app/lib/pages/product/add_new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/add_new_product_page.dart @@ -4,6 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/model/Product.dart'; import 'package:openfoodfacts/model/ProductImage.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/database/dao_product.dart'; +import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/buttons/smooth_large_button_with_icon.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; @@ -82,8 +85,22 @@ class _AddNewProductPageState extends State { action: SmoothActionButton( text: appLocalizations.finish, onPressed: () async { - await Navigator.maybePop( - context, _isProductLoaded ? widget.barcode : null); + final LocalDatabase localDatabase = + context.read(); + final DaoProduct daoProduct = DaoProduct(localDatabase); + final Product? product = + await daoProduct.get(widget.barcode); + if (product == null) { + final Product product = Product( + barcode: widget.barcode, + ); + daoProduct.put(product); + localDatabase.notifyListeners(); + } + if (mounted) { + await Navigator.maybePop( + context, _isProductLoaded ? widget.barcode : null); + } }, ), ), @@ -159,7 +176,6 @@ class _AddNewProductPageState extends State { _isProductLoaded = true; }); } - initialPhoto.delete(); }, ), ); @@ -167,16 +183,23 @@ class _AddNewProductPageState extends State { Widget _buildImageUploadedRow( BuildContext context, ImageField imageType, File image) { + final ThemeData themeData = Theme.of(context); return Padding( padding: _ROW_PADDING_TOP, child: Row( - mainAxisAlignment: MainAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox(height: 50, child: Image.file(image, fit: BoxFit.cover)), Expanded( - child: Center( - child: Text(_getPhotoUploadedLabelText(context, imageType), - style: Theme.of(context).textTheme.bodyText1))), + child: Center( + child: Text(_getAddPhotoButtonText(context, imageType), + style: themeData.textTheme.bodyText1), + ), + ), + Icon( + Icons.check_box, + color: themeData.bottomNavigationBarTheme.selectedItemColor, + ) ], ), ); @@ -198,23 +221,6 @@ class _AddNewProductPageState extends State { } } - String _getPhotoUploadedLabelText( - BuildContext context, ImageField imageType) { - final AppLocalizations appLocalizations = AppLocalizations.of(context); - switch (imageType) { - case ImageField.FRONT: - return appLocalizations.front_photo_uploaded; - case ImageField.INGREDIENTS: - return appLocalizations.ingredients_photo_uploaded; - case ImageField.NUTRITION: - return appLocalizations.nutritional_facts_photo_uploaded; - case ImageField.PACKAGING: - return appLocalizations.recycling_photo_uploaded; - case ImageField.OTHER: - return appLocalizations.other_photo_uploaded; - } - } - bool _isImageUploadedForType(ImageField imageType) { return (_uploadedImages[imageType] ?? []).isNotEmpty; } @@ -222,27 +228,30 @@ class _AddNewProductPageState extends State { Widget _buildNutritionInputButton() { if (_nutritionFactsAdded) { return Padding( - padding: _ROW_PADDING_TOP, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const SizedBox( - width: 50.0, - child: Icon( - Icons.check, - color: Colors.greenAccent, - ), + padding: _ROW_PADDING_TOP, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + width: 50.0, + child: Icon( + Icons.check, + color: Theme.of(context) + .bottomNavigationBarTheme + .selectedItemColor, ), - Expanded( - child: Center( - child: Text( - AppLocalizations.of(context).nutritional_facts_added, - style: Theme.of(context).textTheme.bodyText1), - ), + ), + Expanded( + child: Center( + child: Text( + AppLocalizations.of(context).nutritional_facts_added, + style: Theme.of(context).textTheme.bodyText1), ), - ], - )); + ), + ], + ), + ); } return Padding( @@ -293,17 +302,18 @@ class _AddNewProductPageState extends State { Widget _buildaddInputDetailsButton() { if (_basicDetailsAdded) { + final ThemeData themeData = Theme.of(context); return Padding( padding: _ROW_PADDING_TOP, child: Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.start, children: [ - const SizedBox( + SizedBox( width: 50.0, child: Icon( Icons.check, - color: Colors.greenAccent, + color: themeData.bottomNavigationBarTheme.selectedItemColor, ), ), Expanded( diff --git a/packages/smooth_app/lib/pages/product/common/product_list_page.dart b/packages/smooth_app/lib/pages/product/common/product_list_page.dart index 3edf28edec..8dfcba636d 100644 --- a/packages/smooth_app/lib/pages/product/common/product_list_page.dart +++ b/packages/smooth_app/lib/pages/product/common/product_list_page.dart @@ -12,6 +12,7 @@ import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/generic_lib/loading_dialog.dart'; import 'package:smooth_app/helpers/robotoff_insight_helper.dart'; import 'package:smooth_app/pages/inherited_data_manager.dart'; @@ -308,7 +309,7 @@ class _ProductListPageState extends State ? appLocalizations.product_removed_history : appLocalizations.product_could_not_remove, ), - duration: const Duration(seconds: 3), + duration: SnackBarDuration.medium, ), ); // TODO(monsieurtanuki): add a snackbar ("put back the food") @@ -349,7 +350,7 @@ class _ProductListPageState extends State products.length, ), ), - duration: const Duration(seconds: 2), + duration: SnackBarDuration.short, ), ); setState(() {}); diff --git a/packages/smooth_app/lib/pages/product/common/product_query_page.dart b/packages/smooth_app/lib/pages/product/common/product_query_page.dart index 373ca823dd..e599bbd21e 100644 --- a/packages/smooth_app/lib/pages/product/common/product_query_page.dart +++ b/packages/smooth_app/lib/pages/product/common/product_query_page.dart @@ -428,7 +428,7 @@ class _ProductQueryPageState extends State void _scrollToTop() { _scrollController.animateTo( 0, - duration: const Duration(seconds: 3), + duration: SnackBarDuration.medium, curve: Curves.linear, ); } diff --git a/packages/smooth_app/lib/pages/product/confirm_and_upload_picture.dart b/packages/smooth_app/lib/pages/product/confirm_and_upload_picture.dart index 1acfbf71c6..3a9d2a0598 100644 --- a/packages/smooth_app/lib/pages/product/confirm_and_upload_picture.dart +++ b/packages/smooth_app/lib/pages/product/confirm_and_upload_picture.dart @@ -176,8 +176,7 @@ class _ConfirmAndUploadPictureState extends State { ), ), onPressed: () async { - final bool isPhotoUploaded = - await uploadCapturedPicture( + uploadCapturedPicture( context, barcode: widget.barcode, imageField: widget.imageType, @@ -186,10 +185,9 @@ class _ConfirmAndUploadPictureState extends State { if (!mounted) { return; } - retakenPhoto?.delete(); Navigator.pop( context, - isPhotoUploaded ? photo : null, + photo, ); }, label: Text( diff --git a/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart b/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart index c0327ecbe3..cf5c3fa046 100644 --- a/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart +++ b/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart @@ -1,20 +1,26 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:ui' show ImageFilter; 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/data_models/up_to_date_product_provider.dart'; +import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; +import 'package:smooth_app/helpers/background_task_helper.dart'; import 'package:smooth_app/helpers/picture_capture_helper.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; -import 'package:smooth_app/pages/product/common/product_refresher.dart'; import 'package:smooth_app/pages/product/explanation_widget.dart'; import 'package:smooth_app/pages/product/ocr_helper.dart'; +import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; +import 'package:task_manager/task_manager.dart'; /// Editing with OCR a product field and the corresponding image. /// @@ -79,7 +85,7 @@ class _EditOcrPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(error), - duration: const Duration(seconds: 3), + duration: SnackBarDuration.medium, ), ); } @@ -90,7 +96,6 @@ class _EditOcrPageState extends State { // Returns a Future that resolves successfully only if everything succeeds, // otherwise it will resolve with the relevant error. Future _getImage(bool isNewImage) async { - bool isUploaded = true; if (isNewImage) { final File? croppedImageFile = await startImageCropping(context, showOptionDialog: true); @@ -105,18 +110,12 @@ class _EditOcrPageState extends State { if (!mounted) { return; } - isUploaded = await uploadCapturedPicture( + await uploadCapturedPicture( context, barcode: _product.barcode!, imageField: _helper.getImageField(), imageUri: croppedImageFile.uri, ); - - croppedImageFile.delete(); - } - - if (!isUploaded) { - throw Exception('Image could not be uploaded.'); } final String? extractedText = await _helper.getExtractedText(_product); @@ -131,12 +130,56 @@ class _EditOcrPageState extends State { } Future _updateText(final String text) async { - final LocalDatabase localDatabase = context.read(); - return ProductRefresher().saveAndRefresh( - context: context, - localDatabase: localDatabase, - product: _helper.getMinimalistProduct(_product, text), + final Product minimalistProduct = + _helper.getMinimalistProduct(_product, text); + final String uniqueId = + UniqueIdGenerator.generateUniqueId(_product.barcode!, INGREDIENT_EDIT); + final BackgroundOtherDetailsInput backgroundOtherDetailsInput = + BackgroundOtherDetailsInput( + processName: PRODUCT_EDIT_TASK, + uniqueId: uniqueId, + barcode: minimalistProduct.barcode!, + languageCode: ProductQuery.getLanguage().code, + inputMap: jsonEncode(minimalistProduct.toJson()), + user: jsonEncode(ProductQuery.getUser().toJson()), + country: ProductQuery.getCountry()!.iso2Code, ); + await TaskManager().addTask( + Task( + data: backgroundOtherDetailsInput.toJson(), + uniqueId: uniqueId, + ), + ); + + // ignore: use_build_context_synchronously + final LocalDatabase localDatabase = context.read(); + final DaoProduct daoProduct = DaoProduct(localDatabase); + final Product? localProduct = + await daoProduct.get(minimalistProduct.barcode!); + // We go and chek in the local database if the product is + // already in the database. If it is, we update the fields of the product. + //And if it is not, we create a new product with the fields of the minimalistProduct + // and we insert it in the database. (Giving the user an immediate feedback) + if (localProduct != null) { + localProduct.ingredientsText = minimalistProduct.ingredientsText; + await daoProduct.put(localProduct); + } else { + await daoProduct.put(minimalistProduct); + } + + localDatabase.notifyListeners(); + if (!mounted) { + return false; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).product_task_background_schedule, + ), + duration: SnackBarDuration.medium, + ), + ); + return true; } @override diff --git a/packages/smooth_app/lib/pages/product/edit_product_page.dart b/packages/smooth_app/lib/pages/product/edit_product_page.dart index 2d96eb45fe..eabf60a515 100644 --- a/packages/smooth_app/lib/pages/product/edit_product_page.dart +++ b/packages/smooth_app/lib/pages/product/edit_product_page.dart @@ -10,6 +10,7 @@ import 'package:smooth_app/data_models/product_image_data.dart'; import 'package:smooth_app/data_models/up_to_date_product_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/duration_constants.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/pages/product/add_basic_details_page.dart'; @@ -328,7 +329,7 @@ class _EditProductPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(appLocalizations.product_refreshed), - duration: const Duration(seconds: 2), + duration: SnackBarDuration.short, ), ); } diff --git a/packages/smooth_app/lib/pages/product/new_product_page.dart b/packages/smooth_app/lib/pages/product/new_product_page.dart index 79f8ed6465..efcd63c9b0 100644 --- a/packages/smooth_app/lib/pages/product/new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/new_product_page.dart @@ -169,7 +169,7 @@ class _ProductPageState extends State with TraceableClientMixin { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(appLocalizations.product_refreshed), - duration: const Duration(seconds: 2), + duration: SnackBarDuration.short, ), ); } diff --git a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart index b4c158dc21..3e6051f853 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -6,17 +8,20 @@ import 'package:intl/intl.dart'; import 'package:openfoodfacts/model/OrderedNutrient.dart'; import 'package:openfoodfacts/model/OrderedNutrients.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:openfoodfacts/utils/CountryHelper.dart'; import 'package:openfoodfacts/utils/UnitHelper.dart'; import 'package:provider/provider.dart'; +import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; +import 'package:smooth_app/helpers/background_task_helper.dart'; import 'package:smooth_app/helpers/text_input_formatters_helper.dart'; -import 'package:smooth_app/pages/product/common/product_refresher.dart'; import 'package:smooth_app/pages/product/nutrition_container.dart'; import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; +import 'package:task_manager/task_manager.dart'; /// Actual nutrition page, with data already loaded. class NutritionPageLoaded extends StatefulWidget { @@ -526,11 +531,45 @@ class _NutritionPageLoadedState extends State { return false; } // if it fails, we stay on the same page - return ProductRefresher().saveAndRefresh( - context: context, - localDatabase: localDatabase, - product: changedProduct, - isLoggedInMandatory: widget.isLoggedInMandatory, + final String uniqueId = + UniqueIdGenerator.generateUniqueId(_product.barcode!, NUTRITION_EDIT); + final BackgroundOtherDetailsInput nutritonInputData = + BackgroundOtherDetailsInput( + processName: PRODUCT_EDIT_TASK, + uniqueId: uniqueId, + barcode: _product.barcode!, + languageCode: ProductQuery.getLanguage().code, + inputMap: jsonEncode(changedProduct.toJson()), + user: jsonEncode(ProductQuery.getUser().toJson()), + country: ProductQuery.getCountry()!.iso2Code, ); + await TaskManager().addTask( + Task( + data: nutritonInputData.toJson(), + uniqueId: uniqueId, + ), + ); + final DaoProduct daoProduct = DaoProduct(localDatabase); + final Product? product = await daoProduct.get( + _product.barcode!, + ); + // We go and chek in the local database if the product is + // already in the database. If it is, we update the fields of the product. + //And if it is not, we create a new product with the fields of the _product + // and we insert it in the database. (Giving the user an immediate feedback) + if (product != null) { + product.servingSize = changedProduct.servingSize; + product.nutriments = changedProduct.nutriments; + await daoProduct.put(product); + } + localDatabase.notifyListeners(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(appLocalizations.product_task_background_schedule), + ), + ); + } + return true; } } 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 cbddded424..ba62cdbac0 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 @@ -10,6 +10,7 @@ import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; import 'package:smooth_app/data_models/product_image_data.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/generic_lib/loading_dialog.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_gauge.dart'; import 'package:smooth_app/helpers/picture_capture_helper.dart'; @@ -42,7 +43,6 @@ class _ProductImageGalleryViewState extends State { final List allProductImageProviders = []; late String title; bool _hasPhoto = true; - bool _isRefreshed = false; late ProductImageData _productImageDataCurrent; int _currentIndex = 0; @@ -98,7 +98,7 @@ class _ProductImageGalleryViewState extends State { leading: IconButton( icon: Icon(ConstantIcons.instance.getBackIcon()), onPressed: () { - Navigator.maybePop(context, _isRefreshed); + Navigator.maybePop(context); }, )), backgroundColor: Colors.black, @@ -139,30 +139,14 @@ class _ProductImageGalleryViewState extends State { if (!mounted) { return; } - final bool isUploaded = - await uploadCapturedPicture( + await uploadCapturedPicture( context, barcode: widget.barcode!, imageField: _productImageDataCurrent.imageField, imageUri: croppedImageFile.uri, ); - - if (isUploaded) { - _isRefreshed = true; - if (!mounted) { - return; - } - final AppLocalizations appLocalizations = - AppLocalizations.of(context); - final String message = getImageUploadedMessage( - _productImageDataCurrent.imageField, - appLocalizations); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - duration: const Duration(seconds: 3), - ), - ); + if (!mounted) { + return; } } } @@ -275,29 +259,28 @@ class _ProductImageGalleryViewState extends State { if (!mounted) { return; } - final bool isUploaded = await uploadCapturedPicture( + await uploadCapturedPicture( context, barcode: widget.barcode!, imageField: _productImageDataCurrent.imageField, imageUri: croppedImageFile.uri, ); - if (isUploaded) { - _isRefreshed = true; - if (!mounted) { - return; - } - final AppLocalizations appLocalizations = - AppLocalizations.of(context); - final String message = getImageUploadedMessage( - _productImageDataCurrent.imageField, appLocalizations); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - duration: const Duration(seconds: 3), - ), - ); + if (!mounted) { + return; } + final AppLocalizations appLocalizations = + AppLocalizations.of(context); + final String message = appLocalizations.image_upload_queued; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: SnackBarDuration.medium, + ), + ); + Navigator.pop( + context, + ); } } }, @@ -344,7 +327,6 @@ class _ProductImageGalleryViewState extends State { ), ); if (photoUploaded != null) { - _isRefreshed = true; if (!mounted) { return; } diff --git a/packages/smooth_app/lib/pages/product/simple_input_page.dart b/packages/smooth_app/lib/pages/product/simple_input_page.dart index cabb5f4726..521e2b2886 100644 --- a/packages/smooth_app/lib/pages/product/simple_input_page.dart +++ b/packages/smooth_app/lib/pages/product/simple_input_page.dart @@ -1,18 +1,23 @@ +import 'dart:convert'; import 'package:auto_size_text/auto_size_text.dart'; 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/database/local_database.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; +import 'package:smooth_app/helpers/background_task_helper.dart'; import 'package:smooth_app/helpers/collections_helper.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; -import 'package:smooth_app/pages/product/common/product_refresher.dart'; import 'package:smooth_app/pages/product/simple_input_page_helpers.dart'; import 'package:smooth_app/pages/product/simple_input_widget.dart'; +import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; +import 'package:task_manager/task_manager.dart'; /// Simple input page: we have a list of terms, we add, we remove, we save. class SimpleInputPage extends StatefulWidget { @@ -141,6 +146,7 @@ class _SimpleInputPageState extends State { final Product changedProduct = Product(barcode: widget.product.barcode); bool changed = false; bool added = false; + String pageName = ''; for (int i = 0; i < widget.helpers.length; i++) { if (widget.helpers[i].addItemsFromController(_controllers[i])) { added = true; @@ -148,6 +154,7 @@ class _SimpleInputPageState extends State { if (widget.helpers[i].getChangedProduct(changedProduct)) { changed = true; } + pageName = widget.helpers[i].getTitle(AppLocalizations.of(context)); } if (added) { setState(() {}); @@ -183,12 +190,37 @@ class _SimpleInputPageState extends State { return true; } } - // if it fails, we stay on the same page - return ProductRefresher().saveAndRefresh( - context: context, - localDatabase: localDatabase, - product: changedProduct, + final String uniqueId = + UniqueIdGenerator.generateUniqueId(changedProduct.barcode!, pageName); + final BackgroundOtherDetailsInput backgroundOtherDetailsInput = + BackgroundOtherDetailsInput( + processName: PRODUCT_EDIT_TASK, + uniqueId: uniqueId, + barcode: changedProduct.barcode!, + languageCode: ProductQuery.getLanguage().code, + inputMap: jsonEncode(changedProduct.toJson()), + user: jsonEncode(ProductQuery.getUser().toJson()), + country: ProductQuery.getCountry()!.iso2Code, ); + await TaskManager().addTask( + Task( + data: backgroundOtherDetailsInput.toJson(), + uniqueId: uniqueId, + ), + ); + localDatabase.notifyListeners(); + if (!mounted) { + return false; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + appLocalizations.product_task_background_schedule, + ), + duration: SnackBarDuration.medium, + ), + ); + return true; } @override diff --git a/packages/smooth_app/pubspec.lock b/packages/smooth_app/pubspec.lock index 78744e884d..7d4cd3b837 100644 --- a/packages/smooth_app/pubspec.lock +++ b/packages/smooth_app/pubspec.lock @@ -1388,4 +1388,4 @@ packages: version: "3.1.1" sdks: dart: ">=2.17.0 <3.0.0" - flutter: ">=3.0.0" + flutter: ">=3.0.0" \ No newline at end of file diff --git a/packages/smooth_app/pubspec.yaml b/packages/smooth_app/pubspec.yaml index b8091dc273..684383faa8 100644 --- a/packages/smooth_app/pubspec.yaml +++ b/packages/smooth_app/pubspec.yaml @@ -47,15 +47,18 @@ dependencies: # Camera (custom implementation for Android) camera: git: - url: 'https://github.com/g123k/plugins.git' - ref: 'smooth_camera' - path: 'packages/camera/camera' + url: "https://github.com/g123k/plugins.git" + ref: "smooth_camera" + path: "packages/camera/camera" camera_platform_interface: git: - url: 'https://github.com/g123k/plugins.git' - ref: 'smooth_camera' - path: 'packages/camera/camera_platform_interface' - + url: "https://github.com/g123k/plugins.git" + ref: "smooth_camera" + path: "packages/camera/camera_platform_interface" + task_manager: + git: + url: "https://github.com/g123k/flutter_task_manager.git" + audioplayers: ^1.0.0 percent_indicator: ^4.2.2 flutter_email_sender: ^5.1.0 @@ -122,8 +125,6 @@ flutter_native_splash: branding: assets/app/splashscreen_branding.png branding_dark: assets/app/splashscreen_branding_dark.png - - # https://developer.android.com/guide/topics/ui/splash-screen android_12: # Please note that the splash screen will be clipped to a circle on the center of the screen.