diff --git a/assets/release_notes.md b/assets/release_notes.md index e80308a6..0d68aa03 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -6,6 +6,7 @@ - Fix broken documentation link - Reduce frequency of notification checks - Updated translations +- Add image cropping functionality ### 0.18.1 - April 2025 --- diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 11a61b74..ff6fa80f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": {} } diff --git a/lib/widget/part/image_cropper.dart b/lib/widget/part/image_cropper.dart new file mode 100644 index 00000000..a3236d2a --- /dev/null +++ b/lib/widget/part/image_cropper.dart @@ -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 createState() => _ImageCropperWidgetState(); +} + +class _ImageCropperWidgetState extends State { + 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( + 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), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widget/part/part_image_widget.dart b/lib/widget/part/part_image_widget.dart index 885f7bf0..b40353cb 100644 --- a/lib/widget/part/part_image_widget.dart +++ b/lib/widget/part/part_image_widget.dart @@ -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 { @override String getAppBarTitle() => part.fullname; + Future _processImageWithCropping(File imageFile) async { + try { + Uint8List imageBytes = await imageFile.readAsBytes(); + + // 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; + } + + // 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 _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 appBarActions(BuildContext context) { - List actions = []; + List 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)); } } diff --git a/pubspec.lock b/pubspec.lock index 9520e7e5..ed42dec7 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index 5f1db579..91f5b162 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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