2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-07-01 11:20:41 +00:00

feat: add image cropping functionality with custom aspect ratios (#638)

* feat: add image cropping functionality with custom aspect ratios

* Update release notes
This commit is contained in:
Ben Hagen
2025-06-24 15:17:36 +02:00
committed by GitHub
parent 29ccd4ebfa
commit eb30bbb2fa
6 changed files with 350 additions and 22 deletions

View File

@ -6,6 +6,7 @@
- Fix broken documentation link
- Reduce frequency of notification checks
- Updated translations
- Add image cropping functionality
### 0.18.1 - April 2025
---

View File

@ -50,6 +50,18 @@
"allocated": "Allocated",
"@allocated": {},
"aspectRatio16x9": "16:9",
"@aspectRatio16x9": {},
"aspectRatio3x2": "3:2",
"@aspectRatio3x2": {},
"aspectRatio4x3": "4:3",
"@aspectRatio4x3": {},
"aspectRatioSquare": "Square (1:1)",
"@aspectRatioSquare": {},
"allocateStock": "Allocate Stock",
"@allocateStock": {},
@ -259,7 +271,7 @@
"@confirmScan": {},
"confirmScanDetail": "Confirm stock transfer details when scanning barcodes",
"@confirmScan": {},
"@confirmScanDetail": {},
"connectionRefused": "Connection Refused",
"@connectionRefused": {},
@ -277,6 +289,12 @@
"credits": "Credits",
"@credits": {},
"crop": "Crop",
"@crop": {},
"cropImage": "Crop Image",
"@cropImage": {},
"customer": "Customer",
"@customer": {},
@ -333,12 +351,15 @@
"documentation": "Documentation",
"@documentation": {},
"downloadComplete": "Download Complete",
"@downloadComplete": {},
"downloadError": "Error downloading image",
"@downloadError": {},
"downloading": "Downloading File",
"@downloading": {},
"downloadError": "Download Error",
"@downloadError": {},
"edit": "Edit",
"@edit": {
"description": "edit"
@ -504,7 +525,7 @@
},
"home": "Home",
"@homeScreen": {},
"@home": {},
"homeScreen": "Home Screen",
"@homeScreen": {},
@ -759,6 +780,9 @@
"noResults": "No Results",
"@noResults": {},
"noImageAvailable": "No image available",
"@noImageAvailable": {},
"noSubcategories": "No Subcategories",
"@noSubcategories": {},
@ -1040,6 +1064,9 @@
"refresh": "Refresh",
"@refresh": {},
"rotateClockwise": "Rotate 90° clockwise",
"@rotateClockwise": {},
"refreshing": "Refreshing",
"@refreshing": {},
@ -1565,6 +1592,9 @@
"uploadSuccess": "File uploaded",
"@uploadSuccess": {},
"uploadImage": "Upload Image",
"@uploadImage": {},
"usedIn": "Used In",
"@usedIn": {},
@ -1646,5 +1676,14 @@
"@noPricingAvailable": {},
"noPricingDataFound": "No pricing data found for this part",
"@noPricingDataFound": {}
"@noPricingDataFound": {},
"deleteImageConfirmation": "Are you sure you want to delete this image?",
"@deleteImageConfirmation": {},
"deleteImageTooltip": "Delete Image",
"@deleteImageTooltip": {},
"deleteImage": "Delete Image",
"@deleteImage": {}
}

View File

@ -0,0 +1,169 @@
import "dart:typed_data";
import "package:custom_image_crop/custom_image_crop.dart";
import "package:flutter/material.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/l10.dart";
/// Widget for displaying the image cropper UI
class ImageCropperWidget extends StatefulWidget {
const ImageCropperWidget({Key? key, required this.imageBytes})
: super(key: key);
final Uint8List imageBytes;
@override
State<ImageCropperWidget> createState() => _ImageCropperWidgetState();
}
class _ImageCropperWidgetState extends State<ImageCropperWidget> {
final cropController = CustomImageCropController();
// Define fixed ratio objects so they are the same instances for comparison
static final _ratioSquare = Ratio(width: 1, height: 1);
static final _ratio4x3 = Ratio(width: 4, height: 3);
static final _ratio16x9 = Ratio(width: 16, height: 9);
static final _ratio3x2 = Ratio(width: 3, height: 2);
var _aspectRatio = _ratioSquare;
var _isCropping = false;
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DropdownButton<Ratio>(
value: _aspectRatio,
items: [
DropdownMenuItem(
value: _ratioSquare,
child: Text(L10().aspectRatioSquare),
),
DropdownMenuItem(
value: _ratio4x3,
child: Text(L10().aspectRatio4x3),
),
DropdownMenuItem(
value: _ratio16x9,
child: Text(L10().aspectRatio16x9),
),
DropdownMenuItem(
value: _ratio3x2,
child: Text(L10().aspectRatio3x2),
),
],
onChanged: (value) {
if (value != null) {
setState(() {
_aspectRatio = value;
});
}
},
),
// Reset button - returns the image to its default state
IconButton(
icon: Icon(TablerIcons.refresh),
onPressed: () => cropController.reset(),
tooltip: "Reset",
),
// Zoom out button - scales to 75% of current size
IconButton(
icon: Icon(TablerIcons.zoom_out),
onPressed: () =>
cropController.addTransition(CropImageData(scale: 0.75)),
tooltip: "Zoom Out",
),
// Zoom in button - scales to 133% of current size
IconButton(
icon: Icon(TablerIcons.zoom_in),
onPressed: () =>
cropController.addTransition(CropImageData(scale: 1.33)),
tooltip: "Zoom In",
),
// Rotate button
IconButton(
icon: Icon(TablerIcons.rotate),
onPressed: () =>
cropController.addTransition(CropImageData(angle: 90)),
tooltip: L10().rotateClockwise,
),
],
),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CustomImageCrop(
cropController: cropController,
image: MemoryImage(widget.imageBytes),
shape: CustomCropShape.Ratio,
ratio: _aspectRatio,
forceInsideCropArea: true,
overlayColor: Colors.black.withAlpha(128),
backgroundColor: Colors.black.withAlpha(64),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(L10().cancel),
),
ElevatedButton(
onPressed: _isCropping
? null
: () async {
setState(() {
_isCropping = true;
});
try {
// Crop the image
final image = await cropController.onCropImage();
if (!mounted) return;
if (image != null) {
Navigator.of(context).pop(image.bytes);
} else {
setState(() {
_isCropping = false;
});
}
} catch (e) {
if (!mounted) return;
setState(() {
_isCropping = false;
});
}
},
child: _isCropping
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.0,
color: Colors.white,
),
)
: Text(L10().crop),
),
],
),
),
],
);
}
}

View File

@ -1,14 +1,16 @@
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/refreshable_state.dart";
import "package:inventree/widget/snacks.dart";
import "package:inventree/l10.dart";
class PartImageWidget extends StatefulWidget {
const PartImageWidget(this.part, {Key? key}) : super(key: key);
@ -32,37 +34,137 @@ class _PartImageState extends RefreshableState<PartImageWidget> {
@override
String getAppBarTitle() => part.fullname;
@override
List<Widget> appBarActions(BuildContext context) {
List<Widget> actions = [];
Future<void> _processImageWithCropping(File imageFile) async {
try {
Uint8List imageBytes = await imageFile.readAsBytes();
if (part.canEdit) {
// File upload
actions.add(
IconButton(
icon: Icon(TablerIcons.file_upload),
onPressed: () async {
FilePickerDialog.pickFile(
onPicked: (File file) async {
final result = await part.uploadImage(file);
// Show the cropping dialog
final Uint8List? croppedBytes = await showDialog<Uint8List>(
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;
}
// 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);
// Upload the cropped file
final result = await part.uploadImage(tempFile);
// Delete temporary file
if (await tempFile.exists()) {
await tempFile.delete().catchError((_) => tempFile);
}
if (!result) {
showSnackIcon(L10().uploadFailed, success: false);
} else {
showSnackIcon(L10().uploadSuccess, success: true);
}
refresh(context);
} catch (e) {
showSnackIcon("${L10().error}: $e", success: false);
}
}
// Delete the current part image
Future<void> _deleteImage() async {
// Confirm deletion with user
final bool confirm =
await showDialog(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text(L10().deleteImage),
content: Text(L10().deleteImageConfirmation),
actions: [
TextButton(
child: Text(L10().cancel),
onPressed: () => Navigator.of(context).pop(false),
),
TextButton(
child: Text(L10().delete),
onPressed: () => Navigator.of(context).pop(true),
),
],
),
) ??
false;
if (confirm) {
final APIResponse response = await InvenTreeAPI().patch(
part.url,
body: {"image": null},
);
if (response.successful()) {
showSnackIcon(L10().deleteSuccess, success: true);
} else {
showSnackIcon(
"${L10().deleteFailed}: ${response.error}",
success: false,
);
}
refresh(context);
}
}
@override
List<Widget> appBarActions(BuildContext context) {
List<Widget> actions = [
if (part.canEdit) ...[
// Delete image button
if (part.jsondata["image"] != null)
IconButton(
icon: Icon(TablerIcons.trash),
tooltip: L10().deleteImageTooltip,
onPressed: _deleteImage,
),
// File upload with cropping
IconButton(
icon: Icon(TablerIcons.file_upload),
tooltip: L10().uploadImage,
onPressed: () async {
FilePickerDialog.pickFile(
onPicked: (File file) async {
await _processImageWithCropping(file);
},
);
},
),
);
}
],
];
return actions;
}
@override
Widget getBody(BuildContext context) {
return InvenTreeAPI().getImage(part.image);
return Center(child: InvenTreeAPI().getImage(part.image));
}
}

View File

@ -281,6 +281,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
custom_image_crop:
dependency: "direct main"
description:
name: custom_image_crop
sha256: d352ebe734677c391d77a1234dcc64a4e1a0ec5a35f8248d7274655f723edda4
url: "https://pub.dev"
source: hosted
version: "0.1.1"
datetime_picker_formfield:
dependency: "direct main"
description:
@ -493,6 +501,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
gesture_x_detector:
dependency: transitive
description:
name: gesture_x_detector
sha256: "777855ee4e1fa4d677c40d6a44b9106696ef6745879027c8871e334cee8cde1e"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
glob:
dependency: transitive
description:

View File

@ -14,6 +14,7 @@ dependencies:
camera: ^0.11.1 # Camera
cupertino_icons: ^1.0.8
currency_formatter: ^2.2.1 # Currency formatting
custom_image_crop: ^0.1.1 # Crop selected images
datetime_picker_formfield: ^2.0.1 # Date / time picker
device_info_plus: ^11.4.0 # Information about the device
dropdown_search: ^5.0.6 # Dropdown autocomplete form fields