mirror of
https://github.com/inventree/inventree-app.git
synced 2025-07-01 03:10:46 +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:
@ -6,6 +6,7 @@
|
||||
- Fix broken documentation link
|
||||
- Reduce frequency of notification checks
|
||||
- Updated translations
|
||||
- Add image cropping functionality
|
||||
|
||||
### 0.18.1 - April 2025
|
||||
---
|
||||
|
@ -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": {}
|
||||
}
|
||||
|
169
lib/widget/part/image_cropper.dart
Normal file
169
lib/widget/part/image_cropper.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
||||
Future<void> _processImageWithCropping(File imageFile) async {
|
||||
try {
|
||||
Uint8List imageBytes = await imageFile.readAsBytes();
|
||||
|
||||
// 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 = [];
|
||||
List<Widget> actions = [
|
||||
if (part.canEdit) ...[
|
||||
// Delete image button
|
||||
if (part.jsondata["image"] != null)
|
||||
IconButton(
|
||||
icon: Icon(TablerIcons.trash),
|
||||
tooltip: L10().deleteImageTooltip,
|
||||
onPressed: _deleteImage,
|
||||
),
|
||||
|
||||
if (part.canEdit) {
|
||||
// File upload
|
||||
actions.add(
|
||||
// File upload with cropping
|
||||
IconButton(
|
||||
icon: Icon(TablerIcons.file_upload),
|
||||
tooltip: L10().uploadImage,
|
||||
onPressed: () async {
|
||||
FilePickerDialog.pickFile(
|
||||
onPicked: (File file) async {
|
||||
final result = await part.uploadImage(file);
|
||||
|
||||
if (!result) {
|
||||
showSnackIcon(L10().uploadFailed, success: false);
|
||||
}
|
||||
|
||||
refresh(context);
|
||||
await _processImageWithCropping(file);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
],
|
||||
];
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget getBody(BuildContext context) {
|
||||
return InvenTreeAPI().getImage(part.image);
|
||||
return Center(child: InvenTreeAPI().getImage(part.image));
|
||||
}
|
||||
}
|
||||
|
16
pubspec.lock
16
pubspec.lock
@ -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:
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user