From 3e0ed1212aa350f2c5a4a3094a5ca456828d0d09 Mon Sep 17 00:00:00 2001 From: Ahmed Hnewa <73608287+ahmedhnewa@users.noreply.github.com> Date: Fri, 29 Sep 2023 17:17:44 +0300 Subject: [PATCH] Add a event that triggers after removing the image from the editor && delete unused dependencies and upgrade all packages and plugins and remove gallery_saver which has not been updated for more than 23 months, it was a great plugin but it old now, and I also add some simple documentation and other minor improvements (#1413) --- .../lib/embeds/builders.dart | 239 ++++++++++-------- .../lib/embeds/embed_types.dart | 3 + .../lib/embeds/toolbar/image_video_utils.dart | 6 + .../lib/embeds/utils.dart | 81 +++++- .../lib/flutter_quill_extensions.dart | 29 ++- flutter_quill_extensions/pubspec.yaml | 21 +- 6 files changed, 259 insertions(+), 120 deletions(-) diff --git a/flutter_quill_extensions/lib/embeds/builders.dart b/flutter_quill_extensions/lib/embeds/builders.dart index 9eee93499..e78b3e80b 100644 --- a/flutter_quill_extensions/lib/embeds/builders.dart +++ b/flutter_quill_extensions/lib/embeds/builders.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'dart:io' show File; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -7,13 +7,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_quill/extensions.dart' as base; import 'package:flutter_quill/flutter_quill.dart' hide Text; import 'package:flutter_quill/translations.dart'; -import 'package:gal/gal.dart'; -import 'package:http/http.dart' as http; import 'package:math_keyboard/math_keyboard.dart'; import 'package:universal_html/html.dart' as html; import '../shims/dart_ui_fake.dart' - if (dart.library.html) '../shims/dart_ui_real.dart' as ui; + if (dart.library.html) 'package:flutter_quill_extensions/shims/dart_ui_real.dart' + as ui; +import 'embed_types.dart'; import 'utils.dart'; import 'widgets/image.dart'; import 'widgets/image_resizer.dart'; @@ -21,6 +21,9 @@ import 'widgets/video_app.dart'; import 'widgets/youtube_video_app.dart'; class ImageEmbedBuilder extends EmbedBuilder { + ImageEmbedBuilder({required this.afterRemoveImageFromEditor}); + final ImageEmbedBuilderAfterRemoveImageFromEditor afterRemoveImageFromEditor; + @override String get key => BlockEmbed.imageType; @@ -38,112 +41,118 @@ class ImageEmbedBuilder extends EmbedBuilder { ) { assert(!kIsWeb, 'Please provide image EmbedBuilder for Web'); - var image; + Widget image = const SizedBox.shrink(); final imageUrl = standardizeImageUrl(node.value.data); - OptionalSize? _imageSize; + OptionalSize? imageSize; final style = node.style.attributes['style']; if (base.isMobile() && style != null) { - final _attrs = base.parseKeyValuePairs(style.value.toString(), { + final attrs = base.parseKeyValuePairs(style.value.toString(), { Attribute.mobileWidth, Attribute.mobileHeight, Attribute.mobileMargin, Attribute.mobileAlignment }); - if (_attrs.isNotEmpty) { + if (attrs.isNotEmpty) { assert( - _attrs[Attribute.mobileWidth] != null && - _attrs[Attribute.mobileHeight] != null, + attrs[Attribute.mobileWidth] != null && + attrs[Attribute.mobileHeight] != null, 'mobileWidth and mobileHeight must be specified'); - final w = double.parse(_attrs[Attribute.mobileWidth]!); - final h = double.parse(_attrs[Attribute.mobileHeight]!); - _imageSize = OptionalSize(w, h); - final m = _attrs[Attribute.mobileMargin] == null + final w = double.parse(attrs[Attribute.mobileWidth]!); + final h = double.parse(attrs[Attribute.mobileHeight]!); + imageSize = OptionalSize(w, h); + final m = attrs[Attribute.mobileMargin] == null ? 0.0 - : double.parse(_attrs[Attribute.mobileMargin]!); - final a = base.getAlignment(_attrs[Attribute.mobileAlignment]); + : double.parse(attrs[Attribute.mobileMargin]!); + final a = base.getAlignment(attrs[Attribute.mobileAlignment]); image = Padding( padding: EdgeInsets.all(m), child: imageByUrl(imageUrl, width: w, height: h, alignment: a)); } } - if (_imageSize == null) { + if (imageSize == null) { image = imageByUrl(imageUrl); - _imageSize = OptionalSize((image as Image).width, image.height); + imageSize = OptionalSize((image as Image).width, image.height); } if (!readOnly && base.isMobile()) { return GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (context) { - final resizeOption = _SimpleDialogItem( - icon: Icons.settings_outlined, - color: Colors.lightBlueAccent, - text: 'Resize'.i18n, - onPressed: () { - Navigator.pop(context); - showCupertinoModalPopup( - context: context, - builder: (context) { - final _screenSize = MediaQuery.of(context).size; - return ImageResizer( - onImageResize: (w, h) { - final res = getEmbedNode( - controller, controller.selection.start); - final attr = base.replaceStyleString( - getImageStyleString(controller), w, h); - controller - ..skipRequestKeyboard = true - ..formatText( - res.offset, 1, StyleAttribute(attr)); - }, - imageWidth: _imageSize?.width, - imageHeight: _imageSize?.height, - maxWidth: _screenSize.width, - maxHeight: _screenSize.height); - }); - }, - ); - final copyOption = _SimpleDialogItem( - icon: Icons.copy_all_outlined, - color: Colors.cyanAccent, - text: 'Copy'.i18n, - onPressed: () { - final imageNode = - getEmbedNode(controller, controller.selection.start) - .value; - final imageUrl = imageNode.value.data; - controller.copiedImageUrl = - ImageUrl(imageUrl, getImageStyleString(controller)); - Navigator.pop(context); - }, - ); - final removeOption = _SimpleDialogItem( - icon: Icons.delete_forever_outlined, - color: Colors.red.shade200, - text: 'Remove'.i18n, - onPressed: () { - final offset = - getEmbedNode(controller, controller.selection.start) - .offset; - controller.replaceText(offset, 1, '', - TextSelection.collapsed(offset: offset)); - Navigator.pop(context); - }, - ); - return Padding( - padding: const EdgeInsets.fromLTRB(50, 0, 50, 0), - child: SimpleDialog( - shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.all(Radius.circular(10))), - children: [resizeOption, copyOption, removeOption]), - ); - }); - }, - child: image); + onTap: () { + showDialog( + context: context, + builder: (context) { + final resizeOption = _SimpleDialogItem( + icon: Icons.settings_outlined, + color: Colors.lightBlueAccent, + text: 'Resize'.i18n, + onPressed: () { + Navigator.pop(context); + showCupertinoModalPopup( + context: context, + builder: (context) { + final screenSize = MediaQuery.of(context).size; + return ImageResizer( + onImageResize: (w, h) { + final res = getEmbedNode( + controller, controller.selection.start); + final attr = base.replaceStyleString( + getImageStyleString(controller), w, h); + controller + ..skipRequestKeyboard = true + ..formatText( + res.offset, 1, StyleAttribute(attr)); + }, + imageWidth: imageSize?.width, + imageHeight: imageSize?.height, + maxWidth: screenSize.width, + maxHeight: screenSize.height); + }); + }, + ); + final copyOption = _SimpleDialogItem( + icon: Icons.copy_all_outlined, + color: Colors.cyanAccent, + text: 'Copy'.i18n, + onPressed: () { + final imageNode = + getEmbedNode(controller, controller.selection.start) + .value; + final imageUrl = imageNode.value.data; + controller.copiedImageUrl = + ImageUrl(imageUrl, getImageStyleString(controller)); + Navigator.pop(context); + }, + ); + final removeOption = _SimpleDialogItem( + icon: Icons.delete_forever_outlined, + color: Colors.red.shade200, + text: 'Remove'.i18n, + onPressed: () async { + final navigator = Navigator.of(context); + final offset = + getEmbedNode(controller, controller.selection.start) + .offset; + controller.replaceText( + offset, + 1, + '', + TextSelection.collapsed(offset: offset), + ); + navigator.pop(); + await afterRemoveImageFromEditor(File(imageUrl)); + }, + ); + return Padding( + padding: const EdgeInsets.fromLTRB(50, 0, 50, 0), + child: SimpleDialog( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10))), + children: [resizeOption, copyOption, removeOption]), + ); + }); + }, + child: image, + ); } if (!readOnly || !base.isMobile() || isImageBase64(imageUrl)) { @@ -151,7 +160,11 @@ class ImageEmbedBuilder extends EmbedBuilder { } // We provide option menu for mobile platform excluding base64 image - return _menuOptionsForReadonlyImage(context, imageUrl, image); + return _menuOptionsForReadonlyImage( + context, + imageUrl, + image, + ); } } @@ -271,29 +284,39 @@ Widget _menuOptionsForReadonlyImage( text: 'Save'.i18n, onPressed: () async { imageUrl = appendFileExtensionToImageUrl(imageUrl); + final messenger = ScaffoldMessenger.of(context); + Navigator.of(context).pop(); - // Download image - final uri = Uri.parse(imageUrl); - final response = await http.get(uri); - if (response.statusCode != 200) { - throw Exception( - 'failed to download image: ${response.statusCode}', - ); - } + final saveImageResult = await saveImage(imageUrl); + final imageSavedSuccessfully = saveImageResult.isSuccess; + + messenger.clearSnackBars(); - // Save image to a temporary path - final fileName = uri.pathSegments.isEmpty ? 'image.jpg' - : uri.pathSegments.last; - final imagePath = '${Directory.systemTemp.path}/menu-opt-$fileName'; - final imageFile = File(imagePath); - await imageFile.writeAsBytes(response.bodyBytes); + if (!imageSavedSuccessfully) { + // TODO: Please translate this + messenger.showSnackBar(const SnackBar( + content: Text( + 'Error while saveing the image', + ))); + return; + } - // Save image to gallery - await Gal.putImage(imagePath); + var message = 'Saved'.i18n; + switch (saveImageResult.method) { + // TODO: Please translate this too + case SaveImageResultMethod.network: + message += ' using the network.'; + break; + case SaveImageResultMethod.localStorage: + message += ' using the local storage.'; + break; + } - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Saved'.i18n))); - Navigator.pop(context); + messenger.showSnackBar( + SnackBar( + content: Text(message), + ), + ); }, ); final zoomOption = _SimpleDialogItem( diff --git a/flutter_quill_extensions/lib/embeds/embed_types.dart b/flutter_quill_extensions/lib/embeds/embed_types.dart index 6a48f066a..8e54954eb 100644 --- a/flutter_quill_extensions/lib/embeds/embed_types.dart +++ b/flutter_quill_extensions/lib/embeds/embed_types.dart @@ -44,3 +44,6 @@ class QuillFile { final String path; final Uint8List bytes; } + +typedef ImageEmbedBuilderAfterRemoveImageFromEditor = Future Function( + File imageFile); diff --git a/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart b/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart index 77b1eb600..497822ae2 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart @@ -35,6 +35,12 @@ class LinkDialogState extends State { super.initState(); _link = widget.link ?? ''; _controller = TextEditingController(text: _link); + // TODO: Consider replace the default Regex with this one + // Since that is not the reason I sent the changes then I will not edit it + + // final defaultLinkNonSecureRegExp = RegExp(r'https?://.*?\.(?:png|jpe?g|gif|bmp|webp|tiff?)'); // Not secure + // final defaultLinkRegExp = RegExp(r'https://.*?\.(?:png|jpe?g|gif|bmp|webp|tiff?)'); // Secure + // _linkRegExp = widget.linkRegExp ?? defaultLinkRegExp; _linkRegExp = widget.linkRegExp ?? AutoFormatMultipleLinksRule.linkRegExp; } diff --git a/flutter_quill_extensions/lib/embeds/utils.dart b/flutter_quill_extensions/lib/embeds/utils.dart index 360fb5d53..d4b937a37 100644 --- a/flutter_quill_extensions/lib/embeds/utils.dart +++ b/flutter_quill_extensions/lib/embeds/utils.dart @@ -1,5 +1,84 @@ -import 'package:string_validator/string_validator.dart'; +import 'dart:io'; + +import 'package:flutter/foundation.dart' show Uint8List; +import 'package:http/http.dart' as http; +import 'package:image_gallery_saver/image_gallery_saver.dart'; + +// I would like to orgnize the project structure and the code more +// but here I don't want to change too much since that is a community project + +RegExp _base64 = RegExp( + r'^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$', +); + +bool isBase64(String str) { + return _base64.hasMatch(str); +} bool isImageBase64(String imageUrl) { return !imageUrl.startsWith('http') && isBase64(imageUrl); } + +enum SaveImageResultMethod { network, localStorage } + +class _SaveImageResult { + const _SaveImageResult({required this.isSuccess, required this.method}); + + final bool isSuccess; + final SaveImageResultMethod method; +} + +Future<_SaveImageResult> saveImage(String imageUrl) async { + final imageFile = File(imageUrl); + final imageExistsLocally = await imageFile.exists(); + if (!imageExistsLocally) { + final success = await _saveNetworkImageToLocal(imageUrl); + return _SaveImageResult( + isSuccess: success, + method: SaveImageResultMethod.network, + ); + } + final success = await _saveImageLocally(imageFile); + return _SaveImageResult( + isSuccess: success, + method: SaveImageResultMethod.localStorage, + ); +} + +Future _saveNetworkImageToLocal(String imageUrl) async { + try { + final response = await http.get( + Uri.parse(imageUrl), + ); + if (response.statusCode != 200) { + return false; + } + final imageBytes = response.bodyBytes; + final result = await ImageGallerySaver.saveImage( + Uint8List.fromList(imageBytes), + ); + return result['isSuccess']; + } catch (e) { + return false; + } +} + +Future _convertFileToUint8List(File file) async { + try { + final uint8list = await file.readAsBytes(); + return uint8list; + } catch (e) { + return Uint8List(0); + } +} + +Future _saveImageLocally(File imageFile) async { + try { + final imageBytes = await _convertFileToUint8List(imageFile); + final result = await ImageGallerySaver.saveImage(imageBytes); + + return result['isSuccess']; + } catch (e) { + return false; + } +} diff --git a/flutter_quill_extensions/lib/flutter_quill_extensions.dart b/flutter_quill_extensions/lib/flutter_quill_extensions.dart index a1945ab71..f2f56fb83 100644 --- a/flutter_quill_extensions/lib/flutter_quill_extensions.dart +++ b/flutter_quill_extensions/lib/flutter_quill_extensions.dart @@ -20,11 +20,36 @@ export 'embeds/toolbar/video_button.dart'; export 'embeds/utils.dart'; class FlutterQuillEmbeds { + /// Returns a list of embed builders for Quill editors. + /// + /// [onVideoInit] is called when a video is initialized. + /// [onRemoveImage] is called when an image is removed from the editor. + /// By default, [onRemoveImage] deletes the cached image if it still exists. + /// If you want to customize + /// the behavior, pass your own function that handles the removal. + /// + /// Example of [onRemoveImage] customization: + /// ```dart + /// onRemoveImage: (imageFile) async { + /// // Your custom logic here + /// // or leave it empty to do nothing + /// } + /// ``` static List builders({ void Function(GlobalKey videoContainerKey)? onVideoInit, + ImageEmbedBuilderAfterRemoveImageFromEditor? afterRemoveImageFromEditor, }) => [ - ImageEmbedBuilder(), + ImageEmbedBuilder( + afterRemoveImageFromEditor: afterRemoveImageFromEditor ?? + (imageFile) async { + // TODO: Please change this default code + final fileExists = await imageFile.exists(); + if (fileExists) { + await imageFile.delete(); + } + }, + ), VideoEmbedBuilder(onVideoInit: onVideoInit), FormulaEmbedBuilder(), ]; @@ -80,7 +105,7 @@ class FlutterQuillEmbeds { iconTheme: iconTheme, dialogTheme: dialogTheme, linkRegExp: videoLinkRegExp, - ), + ), if ((onImagePickCallback != null || onVideoPickCallback != null) && showCameraButton) (controller, toolbarIconSize, iconTheme, dialogTheme) => CameraButton( diff --git a/flutter_quill_extensions/pubspec.yaml b/flutter_quill_extensions/pubspec.yaml index 45c97270e..9216304d8 100644 --- a/flutter_quill_extensions/pubspec.yaml +++ b/flutter_quill_extensions/pubspec.yaml @@ -12,18 +12,21 @@ dependencies: flutter: sdk: flutter - flutter_quill: ^7.2.19 + flutter_quill: ^7.4.7 http: ^1.1.0 - image_picker: ">=0.8.5 <2.0.0" + image_picker: ">=1.0.4" photo_view: ^0.14.0 - video_player: ^2.7.0 - youtube_player_flutter: ^8.1.1 - gal: ^2.1.1 - math_keyboard: ">=0.1.8 <0.3.0" - string_validator: ^1.0.0 - universal_html: ^2.2.1 - url_launcher: ^6.1.9 + video_player: ^2.7.2 + youtube_player_flutter: ^8.1.2 + # gallery_saver: ^2.1.1 + math_keyboard: ">=0.2.1" + # string_validator: ^1.0.0 + universal_html: ^2.2.4 + # url_launcher: ^6.1.14 + # dio: ^5.3.3 + + image_gallery_saver: ^2.0.3 dev_dependencies: flutter_test: