mirror of
https://github.com/inventree/inventree-app.git
synced 2026-07-21 03:33:23 +00:00
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
This commit is contained in:
@@ -7,6 +7,7 @@ import "package:inventree/inventree/model.dart";
|
|||||||
import "package:inventree/inventree/sentry.dart";
|
import "package:inventree/inventree/sentry.dart";
|
||||||
import "package:inventree/l10.dart";
|
import "package:inventree/l10.dart";
|
||||||
import "package:inventree/widget/fields.dart";
|
import "package:inventree/widget/fields.dart";
|
||||||
|
import "package:inventree/widget/image_upload.dart";
|
||||||
import "package:inventree/widget/snacks.dart";
|
import "package:inventree/widget/snacks.dart";
|
||||||
import "package:path/path.dart" as path;
|
import "package:path/path.dart" as path;
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ class InvenTreeAttachment extends InvenTreeModel {
|
|||||||
}) async {
|
}) async {
|
||||||
bool result = false;
|
bool result = false;
|
||||||
|
|
||||||
await FilePickerDialog.pickImageFromCamera().then((File? file) {
|
await FilePickerDialog.pickImageFromCamera().then((File? file) async {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
String dir = path.dirname(file.path);
|
String dir = path.dirname(file.path);
|
||||||
String ext = path.extension(file.path);
|
String ext = path.extension(file.path);
|
||||||
@@ -146,17 +147,30 @@ class InvenTreeAttachment extends InvenTreeModel {
|
|||||||
String filename = "${dir}/${prefix}_image_${now}${ext}";
|
String filename = "${dir}/${prefix}_image_${now}${ext}";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return file.rename(filename).then((File renamed) {
|
final File renamed = await file.rename(filename);
|
||||||
return uploadAttachment(renamed, modelType, modelId).then((
|
final File? processed = await preProcessImage(renamed);
|
||||||
success,
|
|
||||||
) {
|
if (processed == null) {
|
||||||
result = success;
|
// User cancelled the upload - clean up the renamed capture
|
||||||
showSnackIcon(
|
if (await renamed.exists()) {
|
||||||
result ? L10().imageUploadSuccess : L10().imageUploadFailure,
|
await renamed.delete().catchError((_) => renamed);
|
||||||
success: result,
|
}
|
||||||
);
|
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) {
|
} catch (error, stackTrace) {
|
||||||
sentryReportError("uploadImage", error, stackTrace);
|
sentryReportError("uploadImage", error, stackTrace);
|
||||||
showSnackIcon(L10().imageUploadFailure, success: false);
|
showSnackIcon(L10().imageUploadFailure, success: false);
|
||||||
|
|||||||
@@ -388,6 +388,12 @@
|
|||||||
"cropImage": "Crop Image",
|
"cropImage": "Crop Image",
|
||||||
"@cropImage": {},
|
"@cropImage": {},
|
||||||
|
|
||||||
|
"cropImagePrompt": "Do you want to crop this image before uploading?",
|
||||||
|
"@cropImagePrompt": {},
|
||||||
|
|
||||||
|
"useOriginal": "Use Original",
|
||||||
|
"@useOriginal": {},
|
||||||
|
|
||||||
"customer": "Customer",
|
"customer": "Customer",
|
||||||
"@customer": {},
|
"@customer": {},
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import "package:one_context/one_context.dart";
|
|||||||
import "package:inventree/l10.dart";
|
import "package:inventree/l10.dart";
|
||||||
import "package:inventree/app_colors.dart";
|
import "package:inventree/app_colors.dart";
|
||||||
import "package:inventree/widget/fields.dart";
|
import "package:inventree/widget/fields.dart";
|
||||||
|
import "package:inventree/widget/image_upload.dart";
|
||||||
import "package:inventree/widget/progress.dart";
|
import "package:inventree/widget/progress.dart";
|
||||||
import "package:inventree/widget/snacks.dart";
|
import "package:inventree/widget/snacks.dart";
|
||||||
import "package:inventree/widget/refreshable_state.dart";
|
import "package:inventree/widget/refreshable_state.dart";
|
||||||
@@ -80,14 +81,23 @@ class _AttachmentWidgetState extends RefreshableState<AttachmentWidget> {
|
|||||||
Future<void> upload(BuildContext context, File? file) async {
|
Future<void> upload(BuildContext context, File? file) async {
|
||||||
if (file == null) return;
|
if (file == null) return;
|
||||||
|
|
||||||
|
final File? processed = await preProcessImage(file);
|
||||||
|
|
||||||
|
if (processed == null) {
|
||||||
|
// User cancelled the upload
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showLoadingOverlay();
|
showLoadingOverlay();
|
||||||
|
|
||||||
final bool result = await InvenTreeAttachment().uploadAttachment(
|
final bool result = await InvenTreeAttachment().uploadAttachment(
|
||||||
file,
|
processed,
|
||||||
widget.modelType,
|
widget.modelType,
|
||||||
widget.modelId,
|
widget.modelId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await cleanupProcessedImage(file, processed);
|
||||||
|
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
|
|||||||
@@ -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<String> _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<File?> 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<Uint8List>(
|
||||||
|
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<int> 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<int> _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<void> cleanupProcessedImage(File original, File processed) async {
|
||||||
|
if (processed.path == original.path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await processed.exists()) {
|
||||||
|
await processed.delete().catchError((_) => processed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import "dart:io";
|
import "dart:io";
|
||||||
import "dart:typed_data";
|
|
||||||
|
|
||||||
import "package:flutter/material.dart";
|
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:flutter_tabler_icons/flutter_tabler_icons.dart";
|
||||||
import "package:inventree/api.dart";
|
import "package:inventree/api.dart";
|
||||||
import "package:inventree/inventree/part.dart";
|
import "package:inventree/inventree/part.dart";
|
||||||
import "package:inventree/l10.dart";
|
import "package:inventree/l10.dart";
|
||||||
import "package:inventree/widget/fields.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/refreshable_state.dart";
|
||||||
import "package:inventree/widget/snacks.dart";
|
import "package:inventree/widget/snacks.dart";
|
||||||
|
|
||||||
@@ -34,50 +32,18 @@ class _PartImageState extends RefreshableState<PartImageWidget> {
|
|||||||
@override
|
@override
|
||||||
String getAppBarTitle() => part.fullname;
|
String getAppBarTitle() => part.fullname;
|
||||||
|
|
||||||
Future<void> _processImageWithCropping(File imageFile) async {
|
Future<void> _uploadImage(File imageFile) async {
|
||||||
try {
|
try {
|
||||||
Uint8List imageBytes = await imageFile.readAsBytes();
|
final File? processed = await preProcessImage(imageFile);
|
||||||
|
|
||||||
// Show the cropping dialog
|
if (processed == null) {
|
||||||
final Uint8List? croppedBytes = await showDialog<Uint8List>(
|
// User cancelled the upload
|
||||||
context: context,
|
return;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save cropped bytes to a proper temporary file for upload
|
final bool result = await part.uploadImage(processed);
|
||||||
final tempDir = await path_provider.getTemporaryDirectory();
|
|
||||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
|
||||||
final tempFile = File("${tempDir.path}/cropped_image_$timestamp.jpg");
|
|
||||||
await tempFile.writeAsBytes(imageBytes);
|
|
||||||
|
|
||||||
// Upload the cropped file
|
await cleanupProcessedImage(imageFile, processed);
|
||||||
final result = await part.uploadImage(tempFile);
|
|
||||||
|
|
||||||
// Delete temporary file
|
|
||||||
if (await tempFile.exists()) {
|
|
||||||
await tempFile.delete().catchError((_) => tempFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
showSnackIcon(L10().uploadFailed, success: false);
|
showSnackIcon(L10().uploadFailed, success: false);
|
||||||
@@ -152,7 +118,7 @@ class _PartImageState extends RefreshableState<PartImageWidget> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
FilePickerDialog.pickFile(
|
FilePickerDialog.pickFile(
|
||||||
onPicked: (File file) async {
|
onPicked: (File file) async {
|
||||||
await _processImageWithCropping(file);
|
await _uploadImage(file);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
+21
-13
@@ -189,10 +189,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -590,7 +590,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
||||||
@@ -693,6 +693,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.2"
|
version: "0.14.2"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -753,18 +761,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1214,26 +1222,26 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.30.0"
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.7"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.12"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ dependencies:
|
|||||||
flutter_speed_dial: ^6.2.0 # Speed dial / FAB implementation
|
flutter_speed_dial: ^6.2.0 # Speed dial / FAB implementation
|
||||||
flutter_tabler_icons: ^1.43.0 # Tabler icons
|
flutter_tabler_icons: ^1.43.0 # Tabler icons
|
||||||
http: ^1.4.0
|
http: ^1.4.0
|
||||||
|
image: ^4.5.4 # Re-encode/compress images before upload
|
||||||
image_picker: ^1.2.2 # Select or take photos
|
image_picker: ^1.2.2 # Select or take photos
|
||||||
infinite_scroll_pagination: ^4.0.0 # Let the server do all the work!
|
infinite_scroll_pagination: ^4.0.0 # Let the server do all the work!
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
|
|||||||
Reference in New Issue
Block a user