mirror of
https://github.com/inventree/inventree-app.git
synced 2025-07-01 19:30:44 +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
|
- Fix broken documentation link
|
||||||
- Reduce frequency of notification checks
|
- Reduce frequency of notification checks
|
||||||
- Updated translations
|
- Updated translations
|
||||||
|
- Add image cropping functionality
|
||||||
|
|
||||||
### 0.18.1 - April 2025
|
### 0.18.1 - April 2025
|
||||||
---
|
---
|
||||||
|
@ -50,6 +50,18 @@
|
|||||||
"allocated": "Allocated",
|
"allocated": "Allocated",
|
||||||
"@allocated": {},
|
"@allocated": {},
|
||||||
|
|
||||||
|
"aspectRatio16x9": "16:9",
|
||||||
|
"@aspectRatio16x9": {},
|
||||||
|
|
||||||
|
"aspectRatio3x2": "3:2",
|
||||||
|
"@aspectRatio3x2": {},
|
||||||
|
|
||||||
|
"aspectRatio4x3": "4:3",
|
||||||
|
"@aspectRatio4x3": {},
|
||||||
|
|
||||||
|
"aspectRatioSquare": "Square (1:1)",
|
||||||
|
"@aspectRatioSquare": {},
|
||||||
|
|
||||||
"allocateStock": "Allocate Stock",
|
"allocateStock": "Allocate Stock",
|
||||||
"@allocateStock": {},
|
"@allocateStock": {},
|
||||||
|
|
||||||
@ -259,7 +271,7 @@
|
|||||||
"@confirmScan": {},
|
"@confirmScan": {},
|
||||||
|
|
||||||
"confirmScanDetail": "Confirm stock transfer details when scanning barcodes",
|
"confirmScanDetail": "Confirm stock transfer details when scanning barcodes",
|
||||||
"@confirmScan": {},
|
"@confirmScanDetail": {},
|
||||||
|
|
||||||
"connectionRefused": "Connection Refused",
|
"connectionRefused": "Connection Refused",
|
||||||
"@connectionRefused": {},
|
"@connectionRefused": {},
|
||||||
@ -277,6 +289,12 @@
|
|||||||
"credits": "Credits",
|
"credits": "Credits",
|
||||||
"@credits": {},
|
"@credits": {},
|
||||||
|
|
||||||
|
"crop": "Crop",
|
||||||
|
"@crop": {},
|
||||||
|
|
||||||
|
"cropImage": "Crop Image",
|
||||||
|
"@cropImage": {},
|
||||||
|
|
||||||
"customer": "Customer",
|
"customer": "Customer",
|
||||||
"@customer": {},
|
"@customer": {},
|
||||||
|
|
||||||
@ -333,12 +351,15 @@
|
|||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
"@documentation": {},
|
"@documentation": {},
|
||||||
|
|
||||||
|
"downloadComplete": "Download Complete",
|
||||||
|
"@downloadComplete": {},
|
||||||
|
|
||||||
|
"downloadError": "Error downloading image",
|
||||||
|
"@downloadError": {},
|
||||||
|
|
||||||
"downloading": "Downloading File",
|
"downloading": "Downloading File",
|
||||||
"@downloading": {},
|
"@downloading": {},
|
||||||
|
|
||||||
"downloadError": "Download Error",
|
|
||||||
"@downloadError": {},
|
|
||||||
|
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"@edit": {
|
"@edit": {
|
||||||
"description": "edit"
|
"description": "edit"
|
||||||
@ -504,7 +525,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"@homeScreen": {},
|
"@home": {},
|
||||||
|
|
||||||
"homeScreen": "Home Screen",
|
"homeScreen": "Home Screen",
|
||||||
"@homeScreen": {},
|
"@homeScreen": {},
|
||||||
@ -759,6 +780,9 @@
|
|||||||
"noResults": "No Results",
|
"noResults": "No Results",
|
||||||
"@noResults": {},
|
"@noResults": {},
|
||||||
|
|
||||||
|
"noImageAvailable": "No image available",
|
||||||
|
"@noImageAvailable": {},
|
||||||
|
|
||||||
"noSubcategories": "No Subcategories",
|
"noSubcategories": "No Subcategories",
|
||||||
"@noSubcategories": {},
|
"@noSubcategories": {},
|
||||||
|
|
||||||
@ -1040,6 +1064,9 @@
|
|||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"@refresh": {},
|
"@refresh": {},
|
||||||
|
|
||||||
|
"rotateClockwise": "Rotate 90° clockwise",
|
||||||
|
"@rotateClockwise": {},
|
||||||
|
|
||||||
"refreshing": "Refreshing",
|
"refreshing": "Refreshing",
|
||||||
"@refreshing": {},
|
"@refreshing": {},
|
||||||
|
|
||||||
@ -1565,6 +1592,9 @@
|
|||||||
"uploadSuccess": "File uploaded",
|
"uploadSuccess": "File uploaded",
|
||||||
"@uploadSuccess": {},
|
"@uploadSuccess": {},
|
||||||
|
|
||||||
|
"uploadImage": "Upload Image",
|
||||||
|
"@uploadImage": {},
|
||||||
|
|
||||||
"usedIn": "Used In",
|
"usedIn": "Used In",
|
||||||
"@usedIn": {},
|
"@usedIn": {},
|
||||||
|
|
||||||
@ -1646,5 +1676,14 @@
|
|||||||
"@noPricingAvailable": {},
|
"@noPricingAvailable": {},
|
||||||
|
|
||||||
"noPricingDataFound": "No pricing data found for this part",
|
"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: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/widget/fields.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/refreshable_state.dart";
|
||||||
import "package:inventree/widget/snacks.dart";
|
import "package:inventree/widget/snacks.dart";
|
||||||
import "package:inventree/l10.dart";
|
|
||||||
|
|
||||||
class PartImageWidget extends StatefulWidget {
|
class PartImageWidget extends StatefulWidget {
|
||||||
const PartImageWidget(this.part, {Key? key}) : super(key: key);
|
const PartImageWidget(this.part, {Key? key}) : super(key: key);
|
||||||
@ -32,37 +34,137 @@ class _PartImageState extends RefreshableState<PartImageWidget> {
|
|||||||
@override
|
@override
|
||||||
String getAppBarTitle() => part.fullname;
|
String getAppBarTitle() => part.fullname;
|
||||||
|
|
||||||
@override
|
Future<void> _processImageWithCropping(File imageFile) async {
|
||||||
List<Widget> appBarActions(BuildContext context) {
|
try {
|
||||||
List<Widget> actions = [];
|
Uint8List imageBytes = await imageFile.readAsBytes();
|
||||||
|
|
||||||
if (part.canEdit) {
|
// Show the cropping dialog
|
||||||
// File upload
|
final Uint8List? croppedBytes = await showDialog<Uint8List>(
|
||||||
actions.add(
|
context: context,
|
||||||
IconButton(
|
barrierDismissible: false,
|
||||||
icon: Icon(TablerIcons.file_upload),
|
builder: (context) => Dialog(
|
||||||
onPressed: () async {
|
insetPadding: const EdgeInsets.all(16),
|
||||||
FilePickerDialog.pickFile(
|
child: Padding(
|
||||||
onPicked: (File file) async {
|
padding: const EdgeInsets.all(8.0),
|
||||||
final result = await part.uploadImage(file);
|
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) {
|
if (!result) {
|
||||||
showSnackIcon(L10().uploadFailed, success: false);
|
showSnackIcon(L10().uploadFailed, success: false);
|
||||||
|
} else {
|
||||||
|
showSnackIcon(L10().uploadSuccess, success: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh(context);
|
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;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget getBody(BuildContext context) {
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
datetime_picker_formfield:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -493,6 +501,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
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:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -14,6 +14,7 @@ dependencies:
|
|||||||
camera: ^0.11.1 # Camera
|
camera: ^0.11.1 # Camera
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
currency_formatter: ^2.2.1 # Currency formatting
|
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
|
datetime_picker_formfield: ^2.0.1 # Date / time picker
|
||||||
device_info_plus: ^11.4.0 # Information about the device
|
device_info_plus: ^11.4.0 # Information about the device
|
||||||
dropdown_search: ^5.0.6 # Dropdown autocomplete form fields
|
dropdown_search: ^5.0.6 # Dropdown autocomplete form fields
|
||||||
|
Reference in New Issue
Block a user