From 529fe8f5e1e18e9b52782e66e6dd8072f2fed11b Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Thu, 30 May 2024 14:34:35 +0200 Subject: [PATCH] feat: 5301 - price proofs can be cropped and will be displayed (#5305) * feat: 5301 - price proofs can be cropped and will be displayed New files: * `crop_helper.dart`: Crop Helper for images in crop page: process to run when cropping an image. * `crop_parameters.dart`: Parameters of the crop operation. * `product_crop_helper.dart`: Crop Helpers for product images. * `proof_crop_helper.dart`: Crop Helper for proof images. Impacted files * `add_new_product_page.dart`: minor refactoring * `background_task.dart`: new method `isDeduplicable` * `background_task_add_price.dart`: * `background_task_image.dart`: made some methods `static` and public to be reused * `background_task_manager.dart`: now using new method `isDeduplicable` * `background_task_upload.dart`: new method `isDeduplicable` * `crop_page.dart`: refactored using `CropHelper` * `image_crop_page.dart`: minor refactoring * `may_exit_page_helper.dart`: minor refactoring * `price_model.dart`: minor refactoring * `price_proof_card.dart`: now we may crop the image and we also display the result * `product_image_crop_button.dart`: refactored using `CropHelper` * `uploaded_image_gallery.dart`: minor refactoring * feat: 5301 - fixed WillPopScope2 Impacted files * `crop_page.dart`: fixed `WillPopScope2` * `product_price_add_page.dart`: added a `TODO` * `price_proof_card.dart`: minor refactoring * Unrelated - fixed `getUrl` bug (was always PROD, never TEST) New file: * `signalconso.png`: new asset Impacted files * `product_cards_helper.dart`: fixed `getUrl` bug (was always PROD, never TEST) * `product_image_crop_button.dart`: fixed `getUrl` bug (was always PROD, never TEST) * `product_image_gallery_other_view.dart`: fixed `getUrl` bug (was always PROD, never TEST) * `product_image_other_page.dart`: fixed `getUrl` bug (was always PROD, never TEST) * `uploaded_image_gallery.dart`: fixed `getUrl` bug (was always PROD, never TEST) --- .../smooth_app/assets/cache/signalconso.png | Bin 0 -> 23111 bytes .../lib/background/background_task.dart | 3 + .../background/background_task_add_price.dart | 154 ++++++---- .../lib/background/background_task_image.dart | 103 ++++--- .../background/background_task_manager.dart | 6 +- .../background/background_task_upload.dart | 7 + .../lib/helpers/product_cards_helper.dart | 1 + .../smooth_app/lib/pages/crop_helper.dart | 74 +++++ packages/smooth_app/lib/pages/crop_page.dart | 283 ++++++------------ .../smooth_app/lib/pages/crop_parameters.dart | 26 ++ .../product_image_gallery_other_view.dart | 8 +- .../pages/image/product_image_other_page.dart | 6 +- .../pages/image/uploaded_image_gallery.dart | 22 +- .../smooth_app/lib/pages/image_crop_page.dart | 37 ++- .../lib/pages/prices/price_model.dart | 22 +- .../lib/pages/prices/price_proof_card.dart | 34 ++- .../pages/prices/product_price_add_page.dart | 1 + .../pages/product/add_new_product_page.dart | 8 +- .../pages/product/may_exit_page_helper.dart | 7 +- .../product/product_image_crop_button.dart | 85 +++--- .../lib/pages/product_crop_helper.dart | 227 ++++++++++++++ .../lib/pages/proof_crop_helper.dart | 68 +++++ 22 files changed, 805 insertions(+), 377 deletions(-) create mode 100644 packages/smooth_app/assets/cache/signalconso.png create mode 100644 packages/smooth_app/lib/pages/crop_helper.dart create mode 100644 packages/smooth_app/lib/pages/crop_parameters.dart create mode 100644 packages/smooth_app/lib/pages/product_crop_helper.dart create mode 100644 packages/smooth_app/lib/pages/proof_crop_helper.dart diff --git a/packages/smooth_app/assets/cache/signalconso.png b/packages/smooth_app/assets/cache/signalconso.png new file mode 100644 index 0000000000000000000000000000000000000000..8c9cc70bf6b8aea1d4400570a034dc148ee41009 GIT binary patch literal 23111 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 z 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 literal 0 HcmV?d00001 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, + ); + } +}