diff --git a/packages/smooth_app/assets/cache/signalconso.png b/packages/smooth_app/assets/cache/signalconso.png new file mode 100644 index 00000000000..8c9cc70bf6b Binary files /dev/null and b/packages/smooth_app/assets/cache/signalconso.png differ diff --git a/packages/smooth_app/lib/background/background_task.dart b/packages/smooth_app/lib/background/background_task.dart index 66d76536fc4..068fa317d2c 100644 --- a/packages/smooth_app/lib/background/background_task.dart +++ b/packages/smooth_app/lib/background/background_task.dart @@ -184,4 +184,7 @@ abstract class BackgroundTask { // TODO(monsieurtanuki): store the uriProductHelper as well @protected UriProductHelper get uriProductHelper => ProductQuery.uriProductHelper; + + /// Returns true if tasks with the same stamp would overwrite each-other. + bool isDeduplicable() => true; } diff --git a/packages/smooth_app/lib/background/background_task_add_price.dart b/packages/smooth_app/lib/background/background_task_add_price.dart index ac0a3826574..816abfa9f9b 100644 --- a/packages/smooth_app/lib/background/background_task_add_price.dart +++ b/packages/smooth_app/lib/background/background_task_add_price.dart @@ -1,121 +1,146 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:http_parser/http_parser.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; -import 'package:smooth_app/background/background_task_barcode.dart'; +import 'package:smooth_app/background/background_task.dart'; +import 'package:smooth_app/background/background_task_image.dart'; import 'package:smooth_app/background/background_task_upload.dart'; import 'package:smooth_app/background/operation_type.dart'; import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/pages/crop_parameters.dart'; // TODO(monsieurtanuki): use transient file, in order to have instant access to proof image? -// TODO(monsieurtanuki): add crop -// TODO(monsieurtanuki): check "is picture big enough?" // TODO(monsieurtanuki): add source // TODO(monsieurtanuki): make it work for several products /// Background task about adding a product price. -class BackgroundTaskAddPrice extends BackgroundTaskBarcode { +class BackgroundTaskAddPrice extends BackgroundTask { BackgroundTaskAddPrice._({ required super.processName, required super.uniqueId, - required super.barcode, required super.stamp, // single required this.fullPath, + required this.rotationDegrees, + required this.cropX1, + required this.cropY1, + required this.cropX2, + required this.cropY2, required this.proofType, required this.date, required this.currency, + required this.locationOSMId, + required this.locationOSMType, // multi + required this.barcode, required this.priceIsDiscounted, required this.price, required this.priceWithoutDiscount, - required this.locationOSMId, - required this.locationOSMType, }); BackgroundTaskAddPrice.fromJson(Map json) : fullPath = json[_jsonTagImagePath] as String, + rotationDegrees = json[_jsonTagRotation] as int? ?? 0, + cropX1 = json[_jsonTagX1] as int? ?? 0, + cropY1 = json[_jsonTagY1] as int? ?? 0, + cropX2 = json[_jsonTagX2] as int? ?? 0, + cropY2 = json[_jsonTagY2] as int? ?? 0, proofType = getProofTypeFromOffTag(json[_jsonTagProofType] as String)!, date = JsonHelper.stringTimestampToDate(json[_jsonTagDate] as String), currency = getCurrencyFromName(json[_jsonTagCurrency] as String)!, - priceIsDiscounted = json[_jsonTagIsDiscounted] as bool, - price = json[_jsonTagPrice] as double, - priceWithoutDiscount = json[_jsonTagPriceWithoutDiscount] as double?, locationOSMId = json[_jsonTagOSMId] as int, locationOSMType = LocationOSMType.fromOffTag(json[_jsonTagOSMType] as String)!, + barcode = json[_jsonTagBarcode] as String, + priceIsDiscounted = json[_jsonTagIsDiscounted] as bool, + price = json[_jsonTagPrice] as double, + priceWithoutDiscount = json[_jsonTagPriceWithoutDiscount] as double?, super.fromJson(json); static const String _jsonTagImagePath = 'imagePath'; + static const String _jsonTagRotation = 'rotation'; + static const String _jsonTagX1 = 'x1'; + static const String _jsonTagY1 = 'y1'; + static const String _jsonTagX2 = 'x2'; + static const String _jsonTagY2 = 'y2'; static const String _jsonTagProofType = 'proofType'; static const String _jsonTagDate = 'date'; static const String _jsonTagCurrency = 'currency'; + static const String _jsonTagOSMId = 'osmId'; + static const String _jsonTagOSMType = 'osmType'; + static const String _jsonTagBarcode = 'barcode'; static const String _jsonTagIsDiscounted = 'isDiscounted'; static const String _jsonTagPrice = 'price'; static const String _jsonTagPriceWithoutDiscount = 'priceWithoutDiscount'; - static const String _jsonTagOSMId = 'osmId'; - static const String _jsonTagOSMType = 'osmType'; static const OperationType _operationType = OperationType.addPrice; final String fullPath; + final int rotationDegrees; + final int cropX1; + final int cropY1; + final int cropX2; + final int cropY2; final ProofType proofType; final DateTime date; final Currency currency; + final int locationOSMId; + final LocationOSMType locationOSMType; + final String barcode; final bool priceIsDiscounted; final double price; final double? priceWithoutDiscount; - final int locationOSMId; - final LocationOSMType locationOSMType; @override Map toJson() { final Map result = super.toJson(); result[_jsonTagImagePath] = fullPath; + result[_jsonTagRotation] = rotationDegrees; + result[_jsonTagX1] = cropX1; + result[_jsonTagY1] = cropY1; + result[_jsonTagX2] = cropX2; + result[_jsonTagY2] = cropY2; result[_jsonTagProofType] = proofType.offTag; result[_jsonTagDate] = date.toIso8601String(); result[_jsonTagCurrency] = currency.name; + result[_jsonTagOSMId] = locationOSMId; + result[_jsonTagOSMType] = locationOSMType.offTag; + result[_jsonTagBarcode] = barcode; result[_jsonTagIsDiscounted] = priceIsDiscounted; result[_jsonTagPrice] = price; result[_jsonTagPriceWithoutDiscount] = priceWithoutDiscount; - result[_jsonTagOSMId] = locationOSMId; - result[_jsonTagOSMType] = locationOSMType.offTag; return result; } /// Adds the background task about uploading a product image. - static Future addTask( - final String barcode, { - required final File fullFile, + static Future addTask({ + required final CropParameters cropObject, required final ProofType proofType, required final DateTime date, required final Currency currency, + required final int locationOSMId, + required final LocationOSMType locationOSMType, + required final String barcode, required final bool priceIsDiscounted, required final double price, required final double? priceWithoutDiscount, - required final int locationOSMId, - required final LocationOSMType locationOSMType, required final BuildContext context, }) async { final LocalDatabase localDatabase = context.read(); - final String uniqueId = await _operationType.getNewKey( - localDatabase, - barcode: barcode, - ); - final BackgroundTaskBarcode task = _getNewTask( - barcode, - fullFile: fullFile, + final String uniqueId = await _operationType.getNewKey(localDatabase); + final BackgroundTask task = _getNewTask( + cropObject: cropObject, proofType: proofType, date: date, currency: currency, + locationOSMId: locationOSMId, + locationOSMType: locationOSMType, + barcode: barcode, priceIsDiscounted: priceIsDiscounted, price: price, priceWithoutDiscount: priceWithoutDiscount, - locationOSMId: locationOSMId, - locationOSMType: locationOSMType, uniqueId: uniqueId, ); if (!context.mounted) { @@ -133,34 +158,38 @@ class BackgroundTaskAddPrice extends BackgroundTaskBarcode { ); /// Returns a new background task about changing a product. - static BackgroundTaskAddPrice _getNewTask( - final String barcode, { - required final File fullFile, + static BackgroundTaskAddPrice _getNewTask({ + required final CropParameters cropObject, required final ProofType proofType, required final DateTime date, required final Currency currency, + required final int locationOSMId, + required final LocationOSMType locationOSMType, + required final String barcode, required final bool priceIsDiscounted, required final double price, required final double? priceWithoutDiscount, - required final int locationOSMId, - required final LocationOSMType locationOSMType, required final String uniqueId, }) => BackgroundTaskAddPrice._( uniqueId: uniqueId, - barcode: barcode, processName: _operationType.processName, - fullPath: fullFile.path, + fullPath: cropObject.fullFile!.path, + rotationDegrees: cropObject.rotation, + cropX1: cropObject.x1, + cropY1: cropObject.y1, + cropX2: cropObject.x2, + cropY2: cropObject.y2, proofType: proofType, date: date, currency: currency, + locationOSMId: locationOSMId, + locationOSMType: locationOSMType, + barcode: barcode, priceIsDiscounted: priceIsDiscounted, price: price, priceWithoutDiscount: priceWithoutDiscount, - locationOSMId: locationOSMId, - locationOSMType: locationOSMType, stamp: _getStamp( - barcode: barcode, date: date, locationOSMId: locationOSMId, locationOSMType: locationOSMType, @@ -168,12 +197,11 @@ class BackgroundTaskAddPrice extends BackgroundTaskBarcode { ); static String _getStamp({ - required final String barcode, required final DateTime date, required final int locationOSMId, required final LocationOSMType locationOSMType, }) => - '$barcode;price;$date;$locationOSMId;$locationOSMType'; + 'no_barcode;price;$date;$locationOSMId;$locationOSMType'; @override Future postExecute( @@ -186,27 +214,43 @@ class BackgroundTaskAddPrice extends BackgroundTaskBarcode { } catch (e) { // not likely, but let's not spoil the task for that either. } + try { + (await BackgroundTaskUpload.getFile( + BackgroundTaskImage.getCroppedPath(fullPath))) + .deleteSync(); + } catch (e) { + // possible, but let's not spoil the task for that either. + } } @override Future preExecute(final LocalDatabase localDatabase) async {} - // Here we don't need the product refresh @override - Future execute(final LocalDatabase localDatabase) async => upload(); - - /// Sends the product price to the server - @override - Future upload() async { + Future execute(final LocalDatabase localDatabase) async { final Price newPrice = Price() ..date = date ..currency = currency + ..locationOSMId = locationOSMId + ..locationOSMType = locationOSMType ..priceIsDiscounted = priceIsDiscounted ..price = price ..priceWithoutDiscount = priceWithoutDiscount - ..productCode = barcode - ..locationOSMId = locationOSMId - ..locationOSMType = locationOSMType; + ..productCode = barcode; + + final String? path = await BackgroundTaskImage.cropIfNeeded( + fullPath: fullPath, + croppedPath: BackgroundTaskImage.getCroppedPath(fullPath), + rotationDegrees: rotationDegrees, + cropX1: cropX1, + cropY1: cropY1, + cropX2: cropX2, + cropY2: cropY2, + ); + if (path == null) { + // TODO(monsieurtanuki): maybe something more refined when we dismiss the picture, like alerting the user, though it's not supposed to happen anymore from upstream. + return; + } // authentication final User user = getUser(); @@ -225,8 +269,7 @@ class BackgroundTaskAddPrice extends BackgroundTaskBarcode { final String bearerToken = token.value; // proof upload - final File file = File(fullPath); - final Uri initialImageUri = Uri.parse(file.path); + final Uri initialImageUri = Uri.parse(path); final MediaType initialMediaType = HttpHelper().imagineMediaType(initialImageUri.path)!; final MaybeError uploadProof = await OpenPricesAPIClient.uploadProof( @@ -288,4 +331,7 @@ class BackgroundTaskAddPrice extends BackgroundTaskBarcode { } return null; } + + @override + bool isDeduplicable() => false; } diff --git a/packages/smooth_app/lib/background/background_task_image.dart b/packages/smooth_app/lib/background/background_task_image.dart index 6aa84108000..82236cdbc81 100644 --- a/packages/smooth_app/lib/background/background_task_image.dart +++ b/packages/smooth_app/lib/background/background_task_image.dart @@ -135,12 +135,6 @@ class BackgroundTaskImage extends BackgroundTaskUpload { ), ); - /// Returns true if the stamp is an "image/OTHER" stamp. - /// - /// That's important because "image/OTHER" task are never duplicates. - static bool isOtherStamp(final String stamp) => - stamp.contains(';image;${ImageField.OTHER.offTag};'); - /// Returns a fake value that means: "remove the previous value when merging". /// /// If we use this task, it means that we took a brand new picture. Therefore, @@ -172,7 +166,8 @@ class BackgroundTaskImage extends BackgroundTaskUpload { // not likely, but let's not spoil the task for that either. } try { - (await BackgroundTaskUpload.getFile(_getCroppedPath())).deleteSync(); + (await BackgroundTaskUpload.getFile(getCroppedPath(fullPath))) + .deleteSync(); } catch (e) { // possible, but let's not spoil the task for that either. } @@ -204,38 +199,58 @@ class BackgroundTaskImage extends BackgroundTaskUpload { source.bottom * factor, ); + static Rect getUpsizedRect(final Rect source) => + getResizedRect(source, _cropConversionFactor); + + static Rect _getDownsizedRect( + final int cropX1, + final int cropY1, + final int cropX2, + final int cropY2, + ) => + getResizedRect( + Rect.fromLTRB( + cropX1.toDouble(), + cropY1.toDouble(), + cropX2.toDouble(), + cropY2.toDouble(), + ), + 1 / _cropConversionFactor, + ); + /// Conversion factor to `int` from / to UI / background task. - static const int cropConversionFactor = 1000000; + static const int _cropConversionFactor = 1000000; - /// Returns true if a crop operation is needed - after having performed it. + /// Returns the file path of a crop operation. /// - /// Returns false if no crop operation is needed. + /// Returns directly the original [fullPath] if no crop operation was needed. + /// Returns the path of the cropped file if relevant. /// Returns null if the image (cropped or not) is too small. - Future _crop(final File file) async { + static Future cropIfNeeded({ + required final String fullPath, + required final String croppedPath, + required final int rotationDegrees, + required final int cropX1, + required final int cropY1, + required final int cropX2, + required final int cropY2, + }) async { final ui.Image full = await loadUiImage( await (await BackgroundTaskUpload.getFile(fullPath)).readAsBytes()); if (cropX1 == 0 && cropY1 == 0 && - cropX2 == cropConversionFactor && - cropY2 == cropConversionFactor && + cropX2 == _cropConversionFactor && + cropY2 == _cropConversionFactor && rotationDegrees == 0) { if (!isPictureBigEnough(full.width, full.height)) { return null; } // in that case, no need to crop - return false; + return fullPath; } Size getCroppedSize() { - final Rect cropRect = getResizedRect( - Rect.fromLTRB( - cropX1.toDouble(), - cropY1.toDouble(), - cropX2.toDouble(), - cropY2.toDouble(), - ), - 1 / cropConversionFactor, - ); + final Rect cropRect = _getDownsizedRect(cropX1, cropY1, cropX2, cropY2); switch (CropRotationExtension.fromDegrees(rotationDegrees)!) { case CropRotation.up: case CropRotation.down: @@ -257,44 +272,38 @@ class BackgroundTaskImage extends BackgroundTaskUpload { return null; } final ui.Image cropped = await CropController.getCroppedBitmap( - crop: getResizedRect( - Rect.fromLTRB( - cropX1.toDouble(), - cropY1.toDouble(), - cropX2.toDouble(), - cropY2.toDouble(), - ), - 1 / cropConversionFactor, - ), + crop: _getDownsizedRect(cropX1, cropY1, cropX2, cropY2), rotation: CropRotationExtension.fromDegrees(rotationDegrees)!, image: full, maxSize: null, quality: FilterQuality.high, ); - await saveJpeg(file: file, source: cropped); - return true; + await saveJpeg( + file: await BackgroundTaskUpload.getFile(croppedPath), + source: cropped, + ); + return croppedPath; } - /// Returns the path of the locally computed cropped path (if relevant). - String _getCroppedPath() => '$fullPath.cropped.jpg'; + static String getCroppedPath(final String fullPath) => + '$fullPath.cropped.jpg'; /// Uploads the product image. @override Future upload() async { - final String path; - final String croppedPath = _getCroppedPath(); - final bool? neededCrop = - await _crop(await BackgroundTaskUpload.getFile(croppedPath)); - if (neededCrop == null) { + final String? path = await cropIfNeeded( + fullPath: fullPath, + croppedPath: getCroppedPath(fullPath), + rotationDegrees: rotationDegrees, + cropX1: cropX1, + cropY1: cropY1, + cropX2: cropX2, + cropY2: cropY2, + ); + if (path == null) { // TODO(monsieurtanuki): maybe something more refined when we dismiss the picture, like alerting the user, though it's not supposed to happen anymore from upstream. return; } - if (neededCrop) { - path = croppedPath; - } else { - path = fullPath; - } - final ImageField imageField = ImageField.fromOffTag(this.imageField)!; final OpenFoodFactsLanguage language = getLanguage(); final User user = getUser(); diff --git a/packages/smooth_app/lib/background/background_task_manager.dart b/packages/smooth_app/lib/background/background_task_manager.dart index 0fa9d5694a9..66704385daf 100644 --- a/packages/smooth_app/lib/background/background_task_manager.dart +++ b/packages/smooth_app/lib/background/background_task_manager.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:flutter/rendering.dart'; import 'package:smooth_app/background/background_task.dart'; -import 'package:smooth_app/background/background_task_image.dart'; import 'package:smooth_app/background/background_task_refresh_later.dart'; import 'package:smooth_app/background/operation_type.dart'; import 'package:smooth_app/data_models/login_result.dart'; @@ -275,8 +274,7 @@ class BackgroundTaskManager { // now let's get rid of stamp duplicates. final String stamp = task.stamp; _debugPrint('task $taskId, stamp: $stamp'); - // for image/OTHER we don't remove duplicates (they are NOT duplicates) - if (!BackgroundTaskImage.isOtherStamp(stamp)) { + if (task.isDeduplicable()) { int? removeMe; for (int i = 0; i < result.length; i++) { // it's the same stamp, we can remove the previous task. @@ -293,7 +291,7 @@ class BackgroundTaskManager { result.removeAt(removeMe); } } else { - _debugPrint('is "other" stamp!'); + _debugPrint('is "not deduplicable" task!'); } result.add(task); } diff --git a/packages/smooth_app/lib/background/background_task_upload.dart b/packages/smooth_app/lib/background/background_task_upload.dart index 87cd0898d8c..7d069f0f71c 100644 --- a/packages/smooth_app/lib/background/background_task_upload.dart +++ b/packages/smooth_app/lib/background/background_task_upload.dart @@ -138,4 +138,11 @@ abstract class BackgroundTaskUpload extends BackgroundTaskBarcode /// /// cf. [UpToDateChanges._overwrite] regarding `images` field. ProductImage getProductImageChange(); + + /// Returns true only if it's not a "image/OTHER" task. + /// + /// That's important because "image/OTHER" task are never duplicates. + @override + bool isDeduplicable() => + !stamp.contains(';image;${ImageField.OTHER.offTag};'); } diff --git a/packages/smooth_app/lib/helpers/product_cards_helper.dart b/packages/smooth_app/lib/helpers/product_cards_helper.dart index b26a228a7fe..cf8553433f3 100644 --- a/packages/smooth_app/lib/helpers/product_cards_helper.dart +++ b/packages/smooth_app/lib/helpers/product_cards_helper.dart @@ -270,6 +270,7 @@ ProductImageData getProductImageData( imageUrl: productImage.getUrl( product.barcode!, imageSize: ImageSize.DISPLAY, + uriHelper: ProductQuery.uriProductHelper, ), language: language, ); diff --git a/packages/smooth_app/lib/pages/crop_helper.dart b/packages/smooth_app/lib/pages/crop_helper.dart new file mode 100644 index 00000000000..d97ef407cda --- /dev/null +++ b/packages/smooth_app/lib/pages/crop_helper.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:crop_image/crop_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:smooth_app/background/background_task_image.dart'; +import 'package:smooth_app/pages/crop_parameters.dart'; + +/// Crop Helper for images in crop page: process to run when cropping an image. +abstract class CropHelper { + /// Is that a new image, or an already cropped one? + bool isNewImage(); + + /// Page title of the crop page. + String getPageTitle(final AppLocalizations appLocalizations); + + /// Icon of the "process!" button. + IconData getProcessIcon(); + + /// Label of the "process!" button. + String getProcessLabel(final AppLocalizations appLocalizations); + + /// Processes the crop operation. + Future process({ + required final BuildContext context, + required final CropController controller, + required final ui.Image image, + required final File inputFile, + required final File smallCroppedFile, + required final Directory directory, + required final int sequenceNumber, + }); + + /// Returns the crop rect according to local cropping method * factor. + @protected + Rect getLocalCropRect(final CropController controller) => + BackgroundTaskImage.getUpsizedRect(controller.crop); + + @protected + CropParameters getCropParameters({ + required final CropController controller, + required final File? fullFile, + required final File smallCroppedFile, + }) { + final Rect cropRect = getLocalCropRect(controller); + return CropParameters( + fullFile: fullFile, + smallCroppedFile: smallCroppedFile, + rotation: controller.rotation.degrees, + x1: cropRect.left.ceil(), + y1: cropRect.top.ceil(), + x2: cropRect.right.floor(), + y2: cropRect.bottom.floor(), + ); + } + + /// Returns a copy of a file with the full image (no cropping here). + /// + /// To be sent to the server, as well as the crop parameters and the rotation. + /// It's faster for us to let the server do the actual cropping full size. + @protected + Future copyFullImageFile( + final Directory directory, + final int sequenceNumber, + final File inputFile, + ) async { + final File result; + final String fullPath = '${directory.path}/full_image_$sequenceNumber.jpeg'; + result = inputFile.copySync(fullPath); + return result; + } +} diff --git a/packages/smooth_app/lib/pages/crop_page.dart b/packages/smooth_app/lib/pages/crop_page.dart index df4fb4bcf62..9ec78cbf69b 100644 --- a/packages/smooth_app/lib/pages/crop_page.dart +++ b/packages/smooth_app/lib/pages/crop_page.dart @@ -8,10 +8,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; -import 'package:smooth_app/background/background_task_crop.dart'; import 'package:smooth_app/background/background_task_image.dart'; import 'package:smooth_app/background/background_task_upload.dart'; -import 'package:smooth_app/data_models/continuous_scan_model.dart'; import 'package:smooth_app/database/dao_int.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; @@ -20,7 +18,8 @@ import 'package:smooth_app/generic_lib/loading_dialog.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/database_helper.dart'; import 'package:smooth_app/helpers/image_compute_container.dart'; -import 'package:smooth_app/helpers/image_field_extension.dart'; +import 'package:smooth_app/pages/crop_helper.dart'; +import 'package:smooth_app/pages/crop_parameters.dart'; import 'package:smooth_app/pages/product/common/product_refresher.dart'; import 'package:smooth_app/pages/product/edit_image_button.dart'; import 'package:smooth_app/pages/product/may_exit_page_helper.dart'; @@ -32,12 +31,9 @@ import 'package:smooth_app/widgets/will_pop_scope.dart'; class CropPage extends StatefulWidget { const CropPage({ required this.inputFile, - required this.barcode, - required this.imageField, - required this.language, required this.initiallyDifferent, + required this.cropHelper, required this.isLoggedInMandatory, - this.imageId, this.initialCropRect, this.initialRotation, }); @@ -45,22 +41,17 @@ class CropPage extends StatefulWidget { /// The initial input file we start with. final File inputFile; - final ImageField imageField; - final String barcode; - final OpenFoodFactsLanguage language; - /// Is the full picture initially different from the current selection? final bool initiallyDifferent; - /// Only makes sense when we deal with an "already existing" image. - final int? imageId; - final Rect? initialCropRect; final CropRotation? initialRotation; final bool isLoggedInMandatory; + final CropHelper cropHelper; + @override State createState() => _CropPageState(); } @@ -153,13 +144,13 @@ class _CropPageState extends State { _screenSize = MediaQuery.of(context).size; final AppLocalizations appLocalizations = AppLocalizations.of(context); return WillPopScope2( - onWillPop: () async => (await _mayExitPage(saving: false), null), + onWillPop: _onWillPop, child: SmoothScaffold( appBar: SmoothAppBar( centerTitle: false, titleSpacing: 0.0, title: Text( - widget.imageField.getImagePageTitle(appLocalizations), + widget.cropHelper.getPageTitle(appLocalizations), maxLines: 2, ), ), @@ -206,9 +197,10 @@ class _CropPageState extends State { ), Center( child: EditImageButton( - iconData: Icons.send, - label: appLocalizations.send_image_button_label, - onPressed: () async => _mayExitPage(saving: true), + iconData: widget.cropHelper.getProcessIcon(), + label: + widget.cropHelper.getProcessLabel(appLocalizations), + onPressed: () async => _saveImageAndPop(), ), ), ], @@ -218,24 +210,10 @@ class _CropPageState extends State { ); } - /// Returns a file with the full image (no cropping here). - /// - /// To be sent to the server, as well as the crop parameters and the rotation. - /// It's faster for us to let the server do the actual cropping full size. - Future _getFullImageFile( - final Directory directory, - final int sequenceNumber, - ) async { - final File result; - final String fullPath = '${directory.path}/full_image_$sequenceNumber.jpeg'; - result = widget.inputFile.copySync(fullPath); - return result; - } - /// Returns a small file with the cropped image, for the transient image. /// /// Here we use BMP format as it's faster to encode. - Future _getCroppedImageFile( + Future _getSmallCroppedImageFile( final Directory directory, final int sequenceNumber, ) async { @@ -259,11 +237,11 @@ class _CropPageState extends State { return result; } - Future _saveFileAndExitTry() async { + Future _saveImageAndExitTry() async { final AppLocalizations appLocalizations = AppLocalizations.of(context); // only for new image upload we have to check the minimum size. - if (widget.imageId == null) { + if (widget.cropHelper.isNewImage()) { // Returns the size of the resulting cropped image. Size getCroppedSize() { switch (_controller.rotation) { @@ -321,7 +299,7 @@ class _CropPageState extends State { await getNextSequenceNumber(daoInt, _CROP_PAGE_SEQUENCE_KEY); final Directory directory = await BackgroundTaskUpload.getDirectory(); - final File croppedFile = await _getCroppedImageFile( + final File smallCroppedFile = await _getSmallCroppedImageFile( directory, sequenceNumber, ); @@ -329,200 +307,113 @@ class _CropPageState extends State { setState( () => _progress = appLocalizations.crop_page_action_server, ); - if (widget.imageId == null) { - // in this case, it's a brand new picture, with crop parameters. - // for performance reasons, we do not crop the image full-size here, - // but in the background task. - // for privacy reasons, we won't send the full image to the server and - // let it crop it: we'll send the cropped image directly. - final File fullFile = await _getFullImageFile( - directory, - sequenceNumber, - ); - final Rect cropRect = _getLocalCropRect(); - if (mounted) { - await BackgroundTaskImage.addTask( - widget.barcode, - language: widget.language, - imageField: widget.imageField, - fullFile: fullFile, - croppedFile: croppedFile, - rotation: _controller.rotation.degrees, - x1: cropRect.left.ceil(), - y1: cropRect.top.ceil(), - x2: cropRect.right.floor(), - y2: cropRect.bottom.floor(), - context: context, - ); - } - } else { - // in this case, it's an existing picture, with crop parameters. - // we let the server do everything: better performance, and no privacy - // issue here (we're cropping from an allegedly already privacy compliant - // picture). - final Rect cropRect = _getServerCropRect(); - if (mounted) { - await BackgroundTaskCrop.addTask( - widget.barcode, - language: widget.language, - imageField: widget.imageField, - imageId: widget.imageId!, - croppedFile: croppedFile, - rotation: _controller.rotation.degrees, - x1: cropRect.left.ceil(), - y1: cropRect.top.ceil(), - x2: cropRect.right.floor(), - y2: cropRect.bottom.floor(), - context: context, - ); - } - } - localDatabase.notifyListeners(); if (!mounted) { - return croppedFile; + return null; } - final ContinuousScanModel model = context.read(); - await model - .onCreateProduct(widget.barcode); // TODO(monsieurtanuki): a bit fishy - - return croppedFile; + return widget.cropHelper.process( + context: context, + controller: _controller, + image: _image, + smallCroppedFile: smallCroppedFile, + directory: directory, + inputFile: widget.inputFile, + sequenceNumber: sequenceNumber, + ); } - Future _saveFileAndExit() async { + Future _saveImage() async { if (!await ProductRefresher().checkIfLoggedIn( context, isLoggedInMandatory: widget.isLoggedInMandatory, )) { - return false; + return null; } setState( () => _progress = AppLocalizations.of(context).crop_page_action_saving, ); try { - final File? file = await _saveFileAndExitTry(); + final CropParameters? cropParameters = await _saveImageAndExitTry(); _progress = null; - if (file == null) { - if (mounted) { - setState(() {}); - } - return false; - } else { - if (mounted) { - Navigator.of(context).pop(file); - } - return true; + if (mounted) { + setState(() {}); } + return cropParameters; } catch (e) { - _showErrorDialog(); - return false; + await _showErrorDialog(); + return null; } finally { _progress = null; } } - /// Returns the crop rect according to local cropping method * factor. - Rect _getLocalCropRect() => BackgroundTaskImage.getResizedRect( - _controller.crop, BackgroundTaskImage.cropConversionFactor); - - Offset _getRotatedOffsetForOff(final Offset offset) => - _getRotatedOffsetForOffHelper( - _controller.rotation, - offset, - _image.width.toDouble(), - _image.height.toDouble(), - ); + static const String _CROP_PAGE_SEQUENCE_KEY = 'crop_page_sequence'; - /// Returns the offset as rotated, for the OFF-dart rotation/crop tool. - Offset _getRotatedOffsetForOffHelper( - final CropRotation rotation, - final Offset offset01, - final double noonWidth, - final double noonHeight, - ) { - switch (rotation) { - case CropRotation.up: - case CropRotation.down: - return Offset( - noonWidth * offset01.dx, - noonHeight * offset01.dy, - ); - case CropRotation.right: - case CropRotation.left: - return Offset( - noonHeight * offset01.dx, - noonWidth * offset01.dy, - ); + /// Saves the image if relevant after a user click, and pops the result. + Future _saveImageAndPop() async { + if (_nothingHasChanged()) { + // nothing has changed, let's leave + Navigator.of(context).pop(); + return; } - } - /// Returns the crop rect according to server cropping method. - Rect _getServerCropRect() { - final Offset center = _getRotatedOffsetForOff(_controller.crop.center); - final Offset topLeft = _getRotatedOffsetForOff(_controller.crop.topLeft); - double width = 2 * (center.dx - topLeft.dx); - if (width < 0) { - width = -width; - } - double height = 2 * (center.dy - topLeft.dy); - if (height < 0) { - height = -height; + try { + final CropParameters? cropParameters = await _saveImage(); + if (cropParameters != null) { + if (mounted) { + Navigator.of(context).pop(cropParameters); + } + } + } catch (e) { + await _showExceptionDialog(e); } - final Rect rect = Rect.fromCenter( - center: center, - width: width, - height: height, - ); - return rect; } - static const String _CROP_PAGE_SEQUENCE_KEY = 'crop_page_sequence'; + bool _nothingHasChanged() => + _controller.value.rotation == _initialRotation && + _controller.value.crop == _initialCrop && + !widget.initiallyDifferent; - /// Returns `true` if we should really exit the page. - /// - /// Parameter [saving] tells about the context: are we leaving the page, - /// or have we clicked on the "save" button? - Future _mayExitPage({required final bool saving}) async { - if (_controller.value.rotation == _initialRotation && - _controller.value.crop == _initialCrop && - !widget.initiallyDifferent) { + Future<(bool, CropParameters?)> _onWillPop() async { + if (_nothingHasChanged()) { // nothing has changed, let's leave - if (saving) { - Navigator.of(context).pop(); - } - return true; + return (true, null); } // the cropped image has changed, but the user went back without saving - if (!saving) { - final bool? pleaseSave = - await MayExitPageHelper().openSaveBeforeLeavingDialog(context); - if (pleaseSave == null) { - return false; - } - if (pleaseSave == false) { - return true; - } - if (!mounted) { - return false; - } + final bool? pleaseSave = + await MayExitPageHelper().openSaveBeforeLeavingDialog( + context, + title: widget.cropHelper.getPageTitle(AppLocalizations.of(context)), + ); + if (pleaseSave == null) { + return (false, null); + } + if (pleaseSave == false) { + return (true, null); + } + if (!mounted) { + return (false, null); } try { - return _saveFileAndExit(); - } catch (e) { - if (mounted) { - // not likely to happen, but you never know... - await LoadingDialog.error( - context: context, - title: 'Could not prepare picture with exception $e', - ); + final CropParameters? cropParameters = await _saveImage(); + if (cropParameters != null) { + if (mounted) { + return (true, cropParameters); + } } - return false; + } catch (e) { + await _showExceptionDialog(e); } + + return (false, null); } - Future _showErrorDialog() { + Future _showErrorDialog() async { + if (!mounted) { + return; + } final AppLocalizations appLocalizations = AppLocalizations.of(context); return showDialog( @@ -535,6 +426,16 @@ class _CropPageState extends State { }, ); } + + Future _showExceptionDialog(final Object e) async { + if (mounted) { + // not likely to happen, but you never know... + return LoadingDialog.error( + context: context, + title: 'Could not prepare picture with exception $e', + ); + } + } } /// Standard icon button for this page. diff --git a/packages/smooth_app/lib/pages/crop_parameters.dart b/packages/smooth_app/lib/pages/crop_parameters.dart new file mode 100644 index 00000000000..d250b07fa18 --- /dev/null +++ b/packages/smooth_app/lib/pages/crop_parameters.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +/// Parameters of the crop operation. +class CropParameters { + const CropParameters({ + this.fullFile, + required this.smallCroppedFile, + required this.rotation, + required this.x1, + required this.y1, + required this.x2, + required this.y2, + }); + + /// File of the full image. + final File? fullFile; + + /// File of the cropped image, resized according to the screen. + final File smallCroppedFile; + + final int rotation; + final int x1; + final int y1; + final int x2; + final int y2; +} diff --git a/packages/smooth_app/lib/pages/image/product_image_gallery_other_view.dart b/packages/smooth_app/lib/pages/image/product_image_gallery_other_view.dart index 2b7b787428e..f09712597eb 100644 --- a/packages/smooth_app/lib/pages/image/product_image_gallery_other_view.dart +++ b/packages/smooth_app/lib/pages/image/product_image_gallery_other_view.dart @@ -11,6 +11,7 @@ import 'package:smooth_app/generic_lib/widgets/images/smooth_image.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/pages/image/product_image_other_page.dart'; import 'package:smooth_app/pages/product/common/product_refresher.dart'; +import 'package:smooth_app/query/product_query.dart'; /// Number of columns for the grid. const int _columns = 3; @@ -135,7 +136,12 @@ class _RawGridGallery extends StatelessWidget { final Widget image = SmoothImage( width: squareSize, height: squareSize, - imageProvider: NetworkImage(productImage.getUrl(product.barcode!)), + imageProvider: NetworkImage( + productImage.getUrl( + product.barcode!, + uriHelper: ProductQuery.uriProductHelper, + ), + ), rounded: false, ); return InkWell( diff --git a/packages/smooth_app/lib/pages/image/product_image_other_page.dart b/packages/smooth_app/lib/pages/image/product_image_other_page.dart index 8557c5fc9ce..d8722384468 100644 --- a/packages/smooth_app/lib/pages/image/product_image_other_page.dart +++ b/packages/smooth_app/lib/pages/image/product_image_other_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; /// Full page display of a raw product image. @@ -28,7 +29,10 @@ class ProductImageOtherPage extends StatelessWidget { ProductImage.raw( imgid: imageId.toString(), size: ImageSize.ORIGINAL, - ).getUrl(product.barcode!), + ).getUrl( + product.barcode!, + uriHelper: ProductQuery.uriProductHelper, + ), ), fit: BoxFit.cover, ), diff --git a/packages/smooth_app/lib/pages/image/uploaded_image_gallery.dart b/packages/smooth_app/lib/pages/image/uploaded_image_gallery.dart index a7e461787f8..3b5094fbb65 100644 --- a/packages/smooth_app/lib/pages/image/uploaded_image_gallery.dart +++ b/packages/smooth_app/lib/pages/image/uploaded_image_gallery.dart @@ -10,7 +10,10 @@ import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/widgets/images/smooth_image.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_back_button.dart'; import 'package:smooth_app/pages/crop_page.dart'; +import 'package:smooth_app/pages/crop_parameters.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; +import 'package:smooth_app/pages/product_crop_helper.dart'; +import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/widgets/smooth_app_bar.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; @@ -63,6 +66,7 @@ class UploadedImageGallery extends StatelessWidget { final String url = rawImage.getUrl( barcode, imageSize: ImageSize.DISPLAY, + uriHelper: ProductQuery.uriProductHelper, ); return GestureDetector( onTap: () async { @@ -73,27 +77,31 @@ class UploadedImageGallery extends StatelessWidget { rawImage.getUrl( barcode, imageSize: ImageSize.ORIGINAL, + uriHelper: ProductQuery.uriProductHelper, ), DaoInt(localDatabase), ); if (imageFile == null) { return; } - final File? croppedFile = await navigatorState.push( - MaterialPageRoute( + final CropParameters? parameters = + await navigatorState.push( + MaterialPageRoute( builder: (BuildContext context) => CropPage( - barcode: barcode, - imageField: imageField, inputFile: imageFile, - imageId: int.parse(rawImage.imgid!), initiallyDifferent: true, - language: language, isLoggedInMandatory: isLoggedInMandatory, + cropHelper: ProductCropAgainHelper( + barcode: barcode, + imageField: imageField, + imageId: int.parse(rawImage.imgid!), + language: language, + ), ), fullscreenDialog: true, ), ); - if (croppedFile != null) { + if (parameters != null) { navigatorState.pop(); } }, diff --git a/packages/smooth_app/lib/pages/image_crop_page.dart b/packages/smooth_app/lib/pages/image_crop_page.dart index 20f365eaa66..614b7431c0a 100644 --- a/packages/smooth_app/lib/pages/image_crop_page.dart +++ b/packages/smooth_app/lib/pages/image_crop_page.dart @@ -18,7 +18,10 @@ 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/camera_helper.dart'; import 'package:smooth_app/helpers/database_helper.dart'; +import 'package:smooth_app/pages/crop_helper.dart'; import 'package:smooth_app/pages/crop_page.dart'; +import 'package:smooth_app/pages/crop_parameters.dart'; +import 'package:smooth_app/pages/product_crop_helper.dart'; /// Safely picks an image file from gallery or camera, regarding access denied. Future pickImageFile(final BuildContext context) async { @@ -249,31 +252,45 @@ class _ImageSourceButton extends StatelessWidget { } } -/// Lets the user pick a picture, crop it, and save it. -Future confirmAndUploadNewPicture( +/// Lets the user pick a new product picture, crop it, and save it. +Future confirmAndUploadNewPicture( final BuildContext context, { required final ImageField imageField, required final String barcode, required final OpenFoodFactsLanguage language, required final bool isLoggedInMandatory, +}) async => + confirmAndUploadNewImage( + context, + cropHelper: ProductCropNewHelper( + imageField: imageField, + language: language, + barcode: barcode, + ), + isLoggedInMandatory: isLoggedInMandatory, + ); + +/// Lets the user pick a picture, crop it, and save it. +Future confirmAndUploadNewImage( + final BuildContext context, { + required final CropHelper cropHelper, + required final bool isLoggedInMandatory, }) async { - final XFile? croppedPhoto = await pickImageFile(context); - if (croppedPhoto == null) { + final XFile? fullPhoto = await pickImageFile(context); + if (fullPhoto == null) { return null; } if (!context.mounted) { return null; } - return Navigator.push( + return Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (BuildContext context) => CropPage( - barcode: barcode, - imageField: imageField, - inputFile: File(croppedPhoto.path), + inputFile: File(fullPhoto.path), initiallyDifferent: true, - language: language, isLoggedInMandatory: isLoggedInMandatory, + cropHelper: cropHelper, ), fullscreenDialog: true, ), diff --git a/packages/smooth_app/lib/pages/prices/price_model.dart b/packages/smooth_app/lib/pages/prices/price_model.dart index decd8298b8a..c96dd7b77c4 100644 --- a/packages/smooth_app/lib/pages/prices/price_model.dart +++ b/packages/smooth_app/lib/pages/prices/price_model.dart @@ -1,12 +1,10 @@ -import 'dart:io'; - -import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/background/background_task_add_price.dart'; import 'package:smooth_app/data_models/preferences/user_preferences.dart'; +import 'package:smooth_app/pages/crop_parameters.dart'; import 'package:smooth_app/pages/locations/osm_location.dart'; import 'package:smooth_app/pages/onboarding/currency_selector_helper.dart'; @@ -22,12 +20,12 @@ class PriceModel with ChangeNotifier { final String barcode; - XFile? _xFile; + CropParameters? _cropParameters; - XFile? get xFile => _xFile; + CropParameters? get cropParameters => _cropParameters; - set xFile(final XFile? xFile) { - _xFile = xFile; + set cropParameters(final CropParameters? value) { + _cropParameters = value; notifyListeners(); } @@ -86,7 +84,7 @@ class PriceModel with ChangeNotifier { Future addPrice(final BuildContext context) async { final AppLocalizations appLocalizations = AppLocalizations.of(context); - if (xFile == null) { + if (cropParameters == null) { return appLocalizations.prices_proof_mandatory; } @@ -110,16 +108,16 @@ class PriceModel with ChangeNotifier { } await BackgroundTaskAddPrice.addTask( - barcode, - fullFile: File(xFile!.path), + cropObject: cropParameters!, + locationOSMId: location!.osmId, + locationOSMType: location!.osmType, date: date, proofType: proofType, currency: currency, + barcode: barcode, priceIsDiscounted: promo, price: paidPrice, priceWithoutDiscount: priceWithoutDiscount, - locationOSMId: location!.osmId, - locationOSMType: location!.osmType, context: context, ); return null; diff --git a/packages/smooth_app/lib/pages/prices/price_proof_card.dart b/packages/smooth_app/lib/pages/prices/price_proof_card.dart index 166fe12d336..d972ae2932a 100644 --- a/packages/smooth_app/lib/pages/prices/price_proof_card.dart +++ b/packages/smooth_app/lib/pages/prices/price_proof_card.dart @@ -1,14 +1,16 @@ -import 'package:camera/camera.dart'; +import 'dart:io'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/generic_lib/buttons/smooth_large_button_with_icon.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; +import 'package:smooth_app/pages/crop_parameters.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; import 'package:smooth_app/pages/prices/price_model.dart'; +import 'package:smooth_app/pages/proof_crop_helper.dart'; /// Card that displays the proof for price adding. class PriceProofCard extends StatelessWidget { @@ -25,18 +27,34 @@ class PriceProofCard extends StatelessWidget { child: Column( children: [ Text(appLocalizations.prices_proof_subtitle), + if (model.cropParameters != null) + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) => + Image( + image: FileImage( + File(model.cropParameters!.smallCroppedFile.path), + ), + width: constraints.maxWidth, + height: constraints.maxWidth, + ), + ), + //Text(model.cropParameters!.smallCroppedFile.path), SmoothLargeButtonWithIcon( - text: model.xFile == null + text: model.cropParameters == null ? appLocalizations.prices_proof_find : model.proofType == ProofType.receipt ? appLocalizations.prices_proof_receipt : appLocalizations.prices_proof_price_tag, - icon: model.xFile == null ? _iconTodo : _iconDone, + icon: model.cropParameters == null ? _iconTodo : _iconDone, onPressed: () async { - // TODO(monsieurtanuki): add the crop feature - final XFile? xFile = await pickImageFile(context); - if (xFile != null) { - model.xFile = xFile; + final CropParameters? cropParameters = + await confirmAndUploadNewImage( + context, + cropHelper: ProofCropHelper(model: model), + isLoggedInMandatory: true, + ); + if (cropParameters != null) { + model.cropParameters = cropParameters; } }, ), diff --git a/packages/smooth_app/lib/pages/prices/product_price_add_page.dart b/packages/smooth_app/lib/pages/prices/product_price_add_page.dart index 4937af23060..34db62acc7e 100644 --- a/packages/smooth_app/lib/pages/prices/product_price_add_page.dart +++ b/packages/smooth_app/lib/pages/prices/product_price_add_page.dart @@ -74,6 +74,7 @@ class _ProductPriceAddPageState extends State { @override Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); + // TODO(monsieurtanuki): add WillPopScope2 return ChangeNotifierProvider( create: (_) => _model, child: Form( 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 db87a83d568..2b6aa487e87 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 @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_animation_progress_bar/flutter_animation_progress_bar.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -17,6 +15,7 @@ import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/image_field_extension.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/pages/crop_parameters.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; import 'package:smooth_app/pages/preferences/user_preferences_widgets.dart'; import 'package:smooth_app/pages/product/add_new_product_helper.dart'; @@ -591,14 +590,15 @@ class _AddNewProductPageState extends State ? AddNewProductButton.doneIconData : AddNewProductButton.cameraIconData, () async { - final File? finalPhoto = await confirmAndUploadNewPicture( + final CropParameters? cropParameters = + await confirmAndUploadNewPicture( context, barcode: barcode, imageField: ImageField.OTHER, language: ProductQuery.getLanguage(), isLoggedInMandatory: widget.isLoggedInMandatory, ); - if (finalPhoto != null) { + if (cropParameters != null) { setState(() => ++_otherCount); } }, diff --git a/packages/smooth_app/lib/pages/product/may_exit_page_helper.dart b/packages/smooth_app/lib/pages/product/may_exit_page_helper.dart index cae566a60d7..7fd27780312 100644 --- a/packages/smooth_app/lib/pages/product/may_exit_page_helper.dart +++ b/packages/smooth_app/lib/pages/product/may_exit_page_helper.dart @@ -10,7 +10,10 @@ class MayExitPageHelper { /// * `null` means the user's dismissed the dialog and doesn't want to leave. /// * `true` means the user wants to save the changes and leave. /// * `false` means the user wants to ignore the changes and leave. - Future openSaveBeforeLeavingDialog(final BuildContext context) async => + Future openSaveBeforeLeavingDialog( + final BuildContext context, { + final String? title, + }) async => showDialog( context: context, builder: (final BuildContext context) { @@ -21,7 +24,7 @@ class MayExitPageHelper { actionsAxis: Axis.vertical, body: Text(appLocalizations.edit_product_form_item_exit_confirmation), - title: appLocalizations.edit_product_label, + title: title ?? appLocalizations.edit_product_label, negativeAction: SmoothActionButton( text: appLocalizations .edit_product_form_item_exit_confirmation_negative_button, diff --git a/packages/smooth_app/lib/pages/product/product_image_crop_button.dart b/packages/smooth_app/lib/pages/product/product_image_crop_button.dart index 15e4126e82b..165a07c4485 100644 --- a/packages/smooth_app/lib/pages/product/product_image_crop_button.dart +++ b/packages/smooth_app/lib/pages/product/product_image_crop_button.dart @@ -11,9 +11,12 @@ import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/database/transient_file.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/pages/crop_page.dart'; +import 'package:smooth_app/pages/crop_parameters.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/product_image_button.dart'; +import 'package:smooth_app/pages/product_crop_helper.dart'; +import 'package:smooth_app/query/product_query.dart'; /// Product Image Button editing the current image. class ProductImageCropButton extends ProductImageButton { @@ -50,7 +53,7 @@ class ProductImageCropButton extends ProductImageButton { if (productImage != null) { final int? imageId = int.tryParse(productImage.imgid!); if (imageId != null) { - await _openEditCroppedImage(context, imageId, productImage); + await _openCropAgainPage(context, imageId, productImage); return; } } @@ -58,7 +61,7 @@ class ProductImageCropButton extends ProductImageButton { // alternate option: use the transient file. File? imageFile = _transientFile.getImage(); if (imageFile != null) { - await _openCropPage(navigatorState, imageFile); + await _openCropNewPage(navigatorState, imageFile); return; } @@ -72,36 +75,12 @@ class ProductImageCropButton extends ProductImageButton { ); } if (imageFile != null) { - await _openCropPage(navigatorState, imageFile); + await _openCropNewPage(navigatorState, imageFile); return; } } - Future _openCropPage( - final NavigatorState navigatorState, - final File imageFile, { - final int? imageId, - final Rect? initialCropRect, - final CropRotation? initialRotation, - }) async => - navigatorState.push( - MaterialPageRoute( - builder: (BuildContext context) => CropPage( - language: language, - barcode: barcode, - imageField: _imageData.imageField, - inputFile: imageFile, - imageId: imageId, - initiallyDifferent: false, - initialCropRect: initialCropRect, - initialRotation: initialRotation, - isLoggedInMandatory: isLoggedInMandatory, - ), - fullscreenDialog: true, - ), - ); - - Future _openEditCroppedImage( + Future _openCropAgainPage( final BuildContext context, final int imageId, final ProductImage productImage, @@ -113,23 +92,57 @@ class ProductImageCropButton extends ProductImageButton { ProductImage.raw( imgid: imageId.toString(), size: ImageSize.ORIGINAL, - ).getUrl(barcode), + ).getUrl( + barcode, + uriHelper: ProductQuery.uriProductHelper, + ), DaoInt(localDatabase), ); if (imageFile == null) { return null; } - return _openCropPage( - navigatorState, - imageFile, - imageId: imageId, - initialCropRect: _getCropRect(productImage), - initialRotation: CropRotationExtension.fromDegrees( - productImage.angle?.degree ?? 0, + return navigatorState.push( + MaterialPageRoute( + builder: (BuildContext context) => CropPage( + inputFile: imageFile, + initiallyDifferent: false, + initialCropRect: _getCropRect(productImage), + initialRotation: CropRotationExtension.fromDegrees( + productImage.angle?.degree ?? 0, + ), + isLoggedInMandatory: isLoggedInMandatory, + cropHelper: ProductCropAgainHelper( + language: language, + barcode: barcode, + imageField: _imageData.imageField, + imageId: imageId, + ), + ), + fullscreenDialog: true, ), ); } + Future _openCropNewPage( + final NavigatorState navigatorState, + final File imageFile, + ) async => + navigatorState.push( + MaterialPageRoute( + builder: (BuildContext context) => CropPage( + inputFile: imageFile, + initiallyDifferent: false, + isLoggedInMandatory: isLoggedInMandatory, + cropHelper: ProductCropNewHelper( + language: language, + barcode: barcode, + imageField: _imageData.imageField, + ), + ), + fullscreenDialog: true, + ), + ); + ProductImage? _getBestProductImage() { if (product.images == null) { return null; diff --git a/packages/smooth_app/lib/pages/product_crop_helper.dart b/packages/smooth_app/lib/pages/product_crop_helper.dart new file mode 100644 index 00000000000..34701b62e2d --- /dev/null +++ b/packages/smooth_app/lib/pages/product_crop_helper.dart @@ -0,0 +1,227 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:crop_image/crop_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/background/background_task_crop.dart'; +import 'package:smooth_app/background/background_task_image.dart'; +import 'package:smooth_app/data_models/continuous_scan_model.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/helpers/image_field_extension.dart'; +import 'package:smooth_app/pages/crop_helper.dart'; +import 'package:smooth_app/pages/crop_parameters.dart'; + +/// Crop Helper for product images. +abstract class ProductCropHelper extends CropHelper { + ProductCropHelper({ + required this.imageField, + required this.language, + required this.barcode, + }); + + final ImageField imageField; + final OpenFoodFactsLanguage language; + final String barcode; + + @override + String getPageTitle(final AppLocalizations appLocalizations) => + imageField.getImagePageTitle(appLocalizations); + + @override + IconData getProcessIcon() => Icons.send; + + @override + String getProcessLabel(final AppLocalizations appLocalizations) => + appLocalizations.send_image_button_label; + + @protected + Future refresh(final BuildContext context) async { + final LocalDatabase localDatabase = context.read(); + localDatabase.notifyListeners(); + final ContinuousScanModel model = context.read(); + await model.onCreateProduct(barcode); // TODO(monsieurtanuki): a bit fishy + } +} + +/// Crop Helper for product images: brand new image. +class ProductCropNewHelper extends ProductCropHelper { + ProductCropNewHelper({ + required super.imageField, + required super.language, + required super.barcode, + }); + + @override + bool isNewImage() => true; + + @override + Future process({ + required final BuildContext context, + required final CropController controller, + required final ui.Image image, + required final File inputFile, + required final File smallCroppedFile, + required final Directory directory, + required final int sequenceNumber, + }) async { + // in this case, it's a brand new picture, with crop parameters. + // for performance reasons, we do not crop the image full-size here, + // but in the background task. + // for privacy reasons, we won't send the full image to the server and + // let it crop it: we'll send the cropped image directly. + final File fullFile = await copyFullImageFile( + directory, + sequenceNumber, + inputFile, + ); + final Rect cropRect = getLocalCropRect(controller); + if (!context.mounted) { + return null; + } + await BackgroundTaskImage.addTask( + barcode, + language: language, + imageField: imageField, + fullFile: fullFile, + croppedFile: smallCroppedFile, + rotation: controller.rotation.degrees, + x1: cropRect.left.ceil(), + y1: cropRect.top.ceil(), + x2: cropRect.right.floor(), + y2: cropRect.bottom.floor(), + context: context, + ); + + if (context.mounted) { + await refresh(context); + } + return getCropParameters( + controller: controller, + fullFile: fullFile, + smallCroppedFile: smallCroppedFile, + ); + } +} + +/// Crop Helper for product images: from an existing image. +class ProductCropAgainHelper extends ProductCropHelper { + ProductCropAgainHelper({ + required super.imageField, + required super.language, + required super.barcode, + required this.imageId, + }); + + final int imageId; + + @override + bool isNewImage() => false; + + @override + Future process({ + required final BuildContext context, + required final CropController controller, + required final ui.Image image, + required final File inputFile, + required final File smallCroppedFile, + required final Directory directory, + required final int sequenceNumber, + }) async { + // in this case, it's an existing picture, with crop parameters. + // we let the server do everything: better performance, and no privacy + // issue here (we're cropping from an allegedly already privacy compliant + // picture). + final Rect cropRect = _getServerCropRect(controller, image); + await BackgroundTaskCrop.addTask( + barcode, + language: language, + imageField: imageField, + imageId: imageId, + croppedFile: smallCroppedFile, + rotation: controller.rotation.degrees, + x1: cropRect.left.ceil(), + y1: cropRect.top.ceil(), + x2: cropRect.right.floor(), + y2: cropRect.bottom.floor(), + context: context, + ); + if (context.mounted) { + await refresh(context); + } + return getCropParameters( + controller: controller, + fullFile: null, + smallCroppedFile: smallCroppedFile, + ); + } + + /// Returns the crop rect according to server cropping method. + Rect _getServerCropRect( + final CropController controller, + final ui.Image image, + ) { + final Offset center = _getRotatedOffsetForOff( + controller.crop.center, + controller, + image, + ); + final Offset topLeft = _getRotatedOffsetForOff( + controller.crop.topLeft, + controller, + image, + ); + double width = 2 * (center.dx - topLeft.dx); + if (width < 0) { + width = -width; + } + double height = 2 * (center.dy - topLeft.dy); + if (height < 0) { + height = -height; + } + final Rect rect = Rect.fromCenter( + center: center, + width: width, + height: height, + ); + return rect; + } + + Offset _getRotatedOffsetForOff( + final Offset offset, + final CropController controller, + final ui.Image image, + ) => + _getRotatedOffsetForOffHelper( + controller.rotation, + offset, + image.width.toDouble(), + image.height.toDouble(), + ); + + /// Returns the offset as rotated, for the OFF-dart rotation/crop tool. + Offset _getRotatedOffsetForOffHelper( + final CropRotation rotation, + final Offset offset01, + final double noonWidth, + final double noonHeight, + ) { + switch (rotation) { + case CropRotation.up: + case CropRotation.down: + return Offset( + noonWidth * offset01.dx, + noonHeight * offset01.dy, + ); + case CropRotation.right: + case CropRotation.left: + return Offset( + noonHeight * offset01.dx, + noonWidth * offset01.dy, + ); + } + } +} diff --git a/packages/smooth_app/lib/pages/proof_crop_helper.dart b/packages/smooth_app/lib/pages/proof_crop_helper.dart new file mode 100644 index 00000000000..da9e88c6eab --- /dev/null +++ b/packages/smooth_app/lib/pages/proof_crop_helper.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:crop_image/crop_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:smooth_app/pages/crop_helper.dart'; +import 'package:smooth_app/pages/crop_parameters.dart'; +import 'package:smooth_app/pages/prices/price_model.dart'; + +/// Crop Helper for proof images: brand new image. +class ProofCropHelper extends CropHelper { + ProofCropHelper({ + required this.model, + }); + + final PriceModel model; + + @override + bool isNewImage() => true; + + @override + String getPageTitle(final AppLocalizations appLocalizations) => + switch (model.proofType) { + ProofType.receipt => appLocalizations.prices_proof_receipt, + ProofType.priceTag => appLocalizations.prices_proof_price_tag, + _ => 'unexpected' + }; + + @override + IconData getProcessIcon() => Icons.check; + + @override + String getProcessLabel(final AppLocalizations appLocalizations) => + appLocalizations.okay; + + @override + Future process({ + required final BuildContext context, + required final CropController controller, + required final ui.Image image, + required final File inputFile, + required final File smallCroppedFile, + required final Directory directory, + required final int sequenceNumber, + }) async { + // It's a brand new picture, with crop parameters. + // For performance reasons, we do not crop the image full-size here, + // but in the background task. + // For privacy reasons, we won't send the full image to the server and + // let it crop it: we'll send the cropped image directly. + final File fullFile = await copyFullImageFile( + directory, + sequenceNumber, + inputFile, + ); + if (!context.mounted) { + return null; + } + return getCropParameters( + controller: controller, + fullFile: fullFile, + smallCroppedFile: smallCroppedFile, + ); + } +}