From 73b8de565a6ec8a959ff4bb3b2160576fde89940 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 14 Jul 2026 15:59:37 +1000 Subject: [PATCH] Img upload (#858) * Refactor existing code for image upload * re-encode cropped image to jpg * Fix UX * prevent crop from blocking UI * change default action --- lib/inventree/attachment.dart | 38 ++++--- lib/l10n/app_en.arb | 6 ++ lib/widget/attachment_widget.dart | 12 ++- lib/widget/image_upload.dart | 136 +++++++++++++++++++++++++ lib/widget/part/part_image_widget.dart | 52 ++-------- pubspec.lock | 34 ++++--- pubspec.yaml | 1 + 7 files changed, 210 insertions(+), 69 deletions(-) create mode 100644 lib/widget/image_upload.dart diff --git a/lib/inventree/attachment.dart b/lib/inventree/attachment.dart index 3f1b9130..f89670e3 100644 --- a/lib/inventree/attachment.dart +++ b/lib/inventree/attachment.dart @@ -7,6 +7,7 @@ import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/sentry.dart"; import "package:inventree/l10.dart"; import "package:inventree/widget/fields.dart"; +import "package:inventree/widget/image_upload.dart"; import "package:inventree/widget/snacks.dart"; import "package:path/path.dart" as path; @@ -136,7 +137,7 @@ class InvenTreeAttachment extends InvenTreeModel { }) async { bool result = false; - await FilePickerDialog.pickImageFromCamera().then((File? file) { + await FilePickerDialog.pickImageFromCamera().then((File? file) async { if (file != null) { String dir = path.dirname(file.path); String ext = path.extension(file.path); @@ -146,17 +147,30 @@ class InvenTreeAttachment extends InvenTreeModel { String filename = "${dir}/${prefix}_image_${now}${ext}"; try { - return file.rename(filename).then((File renamed) { - return uploadAttachment(renamed, modelType, modelId).then(( - success, - ) { - result = success; - showSnackIcon( - result ? L10().imageUploadSuccess : L10().imageUploadFailure, - success: result, - ); - }); - }); + final File renamed = await file.rename(filename); + final File? processed = await preProcessImage(renamed); + + if (processed == null) { + // User cancelled the upload - clean up the renamed capture + if (await renamed.exists()) { + await renamed.delete().catchError((_) => renamed); + } + return; + } + + final bool success = await uploadAttachment( + processed, + modelType, + modelId, + ); + + await cleanupProcessedImage(renamed, processed); + + result = success; + showSnackIcon( + result ? L10().imageUploadSuccess : L10().imageUploadFailure, + success: result, + ); } catch (error, stackTrace) { sentryReportError("uploadImage", error, stackTrace); showSnackIcon(L10().imageUploadFailure, success: false); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2055c42b..b333be89 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -388,6 +388,12 @@ "cropImage": "Crop Image", "@cropImage": {}, + "cropImagePrompt": "Do you want to crop this image before uploading?", + "@cropImagePrompt": {}, + + "useOriginal": "Use Original", + "@useOriginal": {}, + "customer": "Customer", "@customer": {}, diff --git a/lib/widget/attachment_widget.dart b/lib/widget/attachment_widget.dart index f5899b54..7ed39f2e 100644 --- a/lib/widget/attachment_widget.dart +++ b/lib/widget/attachment_widget.dart @@ -10,6 +10,7 @@ import "package:one_context/one_context.dart"; import "package:inventree/l10.dart"; import "package:inventree/app_colors.dart"; import "package:inventree/widget/fields.dart"; +import "package:inventree/widget/image_upload.dart"; import "package:inventree/widget/progress.dart"; import "package:inventree/widget/snacks.dart"; import "package:inventree/widget/refreshable_state.dart"; @@ -80,14 +81,23 @@ class _AttachmentWidgetState extends RefreshableState { Future upload(BuildContext context, File? file) async { if (file == null) return; + final File? processed = await preProcessImage(file); + + if (processed == null) { + // User cancelled the upload + return; + } + showLoadingOverlay(); final bool result = await InvenTreeAttachment().uploadAttachment( - file, + processed, widget.modelType, widget.modelId, ); + await cleanupProcessedImage(file, processed); + hideLoadingOverlay(); if (result) { diff --git a/lib/widget/image_upload.dart b/lib/widget/image_upload.dart new file mode 100644 index 00000000..c1a88083 --- /dev/null +++ b/lib/widget/image_upload.dart @@ -0,0 +1,136 @@ +import "dart:io"; +import "dart:typed_data"; + +import "package:flutter/foundation.dart" show compute; +import "package:flutter/material.dart"; +import "package:image/image.dart" as img; +import "package:one_context/one_context.dart"; +import "package:path_provider/path_provider.dart" as path_provider; + +import "package:inventree/l10.dart"; +import "package:inventree/widget/part/image_cropper.dart"; + +const List _kImageExtensions = [ + ".jpg", + ".jpeg", + ".png", + ".bmp", + ".gif", + ".webp", +]; + +/// Return true if the provided file appears to be an image, based on its extension +bool isImageFile(File file) { + final String path = file.path.toLowerCase(); + return _kImageExtensions.any((ext) => path.endsWith(ext)); +} + +enum _ImageProcessChoice { crop, useOriginal } + +/* + * Common "pre-processing" step for any image file, applied before upload + * (regardless of whether the image is destined for a Part, an Attachment, or elsewhere). + * + * - Files which are not images are returned unchanged. + * - Image files prompt the user to either crop the image, or upload it as-is. + * - If the user cancels at any point (the initial prompt, or the crop screen itself), + * this returns null, and the caller must abort the upload entirely. + */ +Future preProcessImage(File imageFile) async { + if (!isImageFile(imageFile)) { + return imageFile; + } + + final _ImageProcessChoice? choice = await OneContext() + .showDialog<_ImageProcessChoice>( + barrierDismissible: false, + builder: (context) => AlertDialog( + title: Text(L10().cropImage), + content: Text(L10().cropImagePrompt), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(L10().cancel), + ), + TextButton( + onPressed: () => + Navigator.of(context).pop(_ImageProcessChoice.crop), + child: Text(L10().crop), + ), + ElevatedButton( + onPressed: () => + Navigator.of(context).pop(_ImageProcessChoice.useOriginal), + child: Text(L10().useOriginal), + ), + ], + ), + ); + + if (choice == null) { + // User cancelled - abort the upload entirely + return null; + } + + if (choice == _ImageProcessChoice.useOriginal) { + return imageFile; + } + + final Uint8List imageBytes = await imageFile.readAsBytes(); + + final Uint8List? croppedBytes = await OneContext().showDialog( + barrierDismissible: false, + builder: (context) => Dialog( + insetPadding: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + L10().cropImage, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Expanded(child: ImageCropperWidget(imageBytes: imageBytes)), + ], + ), + ), + ), + ); + + if (croppedBytes == null) { + // User cancelled the crop screen - abort the upload entirely + return null; + } + + // The crop widget always hands back uncompressed PNG data, + // regardless of the source format - re-encode as JPEG so we + // don't upload a file many times larger than the original photo. + // Run this on a background isolate: decode/encode are synchronous, + // CPU-heavy calls that would otherwise freeze the UI thread. + final List jpegBytes = await compute(_encodeAsJpeg, croppedBytes); + + final tempDir = await path_provider.getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final File tempFile = File("${tempDir.path}/cropped_image_$timestamp.jpg"); + await tempFile.writeAsBytes(jpegBytes); + + return tempFile; +} + +/// Decode [pngBytes] and re-encode as JPEG. Runs on a background isolate via [compute]. +List _encodeAsJpeg(Uint8List pngBytes) { + final img.Image? decoded = img.decodeImage(pngBytes); + return decoded != null ? img.encodeJpg(decoded, quality: 90) : pngBytes; +} + +/// Delete [processed] if it is a distinct temporary file created by [preProcessImage] +Future cleanupProcessedImage(File original, File processed) async { + if (processed.path == original.path) { + return; + } + + if (await processed.exists()) { + await processed.delete().catchError((_) => processed); + } +} diff --git a/lib/widget/part/part_image_widget.dart b/lib/widget/part/part_image_widget.dart index b40353cb..962fc151 100644 --- a/lib/widget/part/part_image_widget.dart +++ b/lib/widget/part/part_image_widget.dart @@ -1,14 +1,12 @@ import "dart:io"; -import "dart:typed_data"; import "package:flutter/material.dart"; -import "package:path_provider/path_provider.dart" as path_provider; import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; import "package:inventree/api.dart"; import "package:inventree/inventree/part.dart"; import "package:inventree/l10.dart"; import "package:inventree/widget/fields.dart"; -import "package:inventree/widget/part/image_cropper.dart"; +import "package:inventree/widget/image_upload.dart"; import "package:inventree/widget/refreshable_state.dart"; import "package:inventree/widget/snacks.dart"; @@ -34,50 +32,18 @@ class _PartImageState extends RefreshableState { @override String getAppBarTitle() => part.fullname; - Future _processImageWithCropping(File imageFile) async { + Future _uploadImage(File imageFile) async { try { - Uint8List imageBytes = await imageFile.readAsBytes(); + final File? processed = await preProcessImage(imageFile); - // Show the cropping dialog - final Uint8List? croppedBytes = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => Dialog( - insetPadding: const EdgeInsets.all(16), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - L10().cropImage, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Expanded(child: ImageCropperWidget(imageBytes: imageBytes)), - ], - ), - ), - ), - ); - - if (croppedBytes != null) { - imageBytes = croppedBytes; + if (processed == null) { + // User cancelled the upload + return; } - // Save cropped bytes to a proper temporary file for upload - final tempDir = await path_provider.getTemporaryDirectory(); - final timestamp = DateTime.now().millisecondsSinceEpoch; - final tempFile = File("${tempDir.path}/cropped_image_$timestamp.jpg"); - await tempFile.writeAsBytes(imageBytes); + final bool result = await part.uploadImage(processed); - // Upload the cropped file - final result = await part.uploadImage(tempFile); - - // Delete temporary file - if (await tempFile.exists()) { - await tempFile.delete().catchError((_) => tempFile); - } + await cleanupProcessedImage(imageFile, processed); if (!result) { showSnackIcon(L10().uploadFailed, success: false); @@ -152,7 +118,7 @@ class _PartImageState extends RefreshableState { onPressed: () async { FilePickerDialog.pickFile( onPicked: (File file) async { - await _processImageWithCropping(file); + await _uploadImage(file); }, ); }, diff --git a/pubspec.lock b/pubspec.lock index dfdb383a..5eab3225 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -189,10 +189,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -590,7 +590,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" @@ -693,6 +693,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.2" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -753,18 +761,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1214,26 +1222,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a1635ea0..36f6b5ee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: flutter_speed_dial: ^6.2.0 # Speed dial / FAB implementation flutter_tabler_icons: ^1.43.0 # Tabler icons http: ^1.4.0 + image: ^4.5.4 # Re-encode/compress images before upload image_picker: ^1.2.2 # Select or take photos infinite_scroll_pagination: ^4.0.0 # Let the server do all the work! intl: ^0.20.2