From ed1975a3de3dac084ead9f056d60a6140bacd3b2 Mon Sep 17 00:00:00 2001 From: Ahmed Hnewa <73608287+freshtechtips@users.noreply.github.com> Date: Tue, 17 Oct 2023 22:21:15 +0300 Subject: [PATCH] Fix bug && Provide a way to handle the image errors and use custom image provider --- .../lib/embeds/builders.dart | 99 ++++++++++----- .../lib/embeds/embed_types.dart | 10 +- .../lib/embeds/toolbar/camera_button.dart | 118 ++++++++++++------ .../lib/embeds/toolbar/image_button.dart | 51 ++++++-- .../lib/embeds/toolbar/image_video_utils.dart | 28 +++-- .../lib/embeds/toolbar/media_button.dart | 9 +- .../lib/embeds/widgets/image.dart | 62 +++++++-- .../lib/flutter_quill_extensions.dart | 23 +++- 8 files changed, 290 insertions(+), 110 deletions(-) diff --git a/flutter_quill_extensions/lib/embeds/builders.dart b/flutter_quill_extensions/lib/embeds/builders.dart index e9f8d753..6e5ba5f9 100644 --- a/flutter_quill_extensions/lib/embeds/builders.dart +++ b/flutter_quill_extensions/lib/embeds/builders.dart @@ -21,13 +21,17 @@ import 'widgets/youtube_video_app.dart'; class ImageEmbedBuilder extends EmbedBuilder { ImageEmbedBuilder({ - this.onImageRemovedCallback, - this.shouldRemoveImageCallback, + required this.imageProviderBuilder, + required this.imageErrorWidgetBuilder, + required this.onImageRemovedCallback, + required this.shouldRemoveImageCallback, this.forceUseMobileOptionMenu = false, }); final ImageEmbedBuilderOnRemovedCallback? onImageRemovedCallback; final ImageEmbedBuilderWillRemoveCallback? shouldRemoveImageCallback; final bool forceUseMobileOptionMenu; + final ImageEmbedBuilderProviderBuilder? imageProviderBuilder; + final ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder; @override String get key => BlockEmbed.imageType; @@ -71,12 +75,23 @@ class ImageEmbedBuilder extends EmbedBuilder { final a = base.getAlignment(attrs[Attribute.mobileAlignment]); image = Padding( padding: EdgeInsets.all(m), - child: imageByUrl(imageUrl, width: w, height: h, alignment: a)); + child: imageByUrl( + imageUrl, + width: w, + height: h, + alignment: a, + imageProviderBuilder: imageProviderBuilder, + imageErrorWidgetBuilder: imageErrorWidgetBuilder, + )); } } if (imageSize == null) { - image = imageByUrl(imageUrl); + image = imageByUrl( + imageUrl, + imageProviderBuilder: imageProviderBuilder, + imageErrorWidgetBuilder: imageErrorWidgetBuilder, + ); imageSize = OptionalSize((image as Image).width, image.height); } @@ -97,20 +112,21 @@ class ImageEmbedBuilder extends EmbedBuilder { 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); + 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, + ); }); }, ); @@ -161,7 +177,10 @@ class ImageEmbedBuilder extends EmbedBuilder { padding: const EdgeInsets.fromLTRB(50, 0, 50, 0), child: SimpleDialog( shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10))), + borderRadius: BorderRadius.all( + Radius.circular(10), + ), + ), children: [ if (base.isMobile()) resizeOption, copyOption, @@ -179,9 +198,11 @@ class ImageEmbedBuilder extends EmbedBuilder { // and that is up to the developer if (!base.isMobile() && forceUseMobileOptionMenu) { return _menuOptionsForReadonlyImage( - context, - imageUrl, - image, + context: context, + imageUrl: imageUrl, + image: image, + imageProviderBuilder: imageProviderBuilder, + imageErrorWidgetBuilder: imageErrorWidgetBuilder, ); } return image; @@ -189,9 +210,11 @@ class ImageEmbedBuilder extends EmbedBuilder { // We provide option menu for mobile platform excluding base64 image return _menuOptionsForReadonlyImage( - context, - imageUrl, - image, + context: context, + imageUrl: imageUrl, + image: image, + imageProviderBuilder: imageProviderBuilder, + imageErrorWidgetBuilder: imageErrorWidgetBuilder, ); } } @@ -299,8 +322,13 @@ class FormulaEmbedBuilder extends EmbedBuilder { } } -Widget _menuOptionsForReadonlyImage( - BuildContext context, String imageUrl, Widget image) { +Widget _menuOptionsForReadonlyImage({ + required BuildContext context, + required String imageUrl, + required Widget image, + required ImageEmbedBuilderProviderBuilder? imageProviderBuilder, + required ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder, +}) { return GestureDetector( onTap: () { showDialog( @@ -351,10 +379,19 @@ Widget _menuOptionsForReadonlyImage( text: 'Zoom'.i18n, onPressed: () { Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => - ImageTapWrapper(imageUrl: imageUrl))); + context, + // TODO: Consider add support for other theme system + // like Cupertino or at least add the option to by + // by using PageRoute as option so dev can ovveride this + // this change should be done in all places if you want to + MaterialPageRoute( + builder: (context) => ImageTapWrapper( + imageUrl: imageUrl, + imageProviderBuilder: imageProviderBuilder, + imageErrorWidgetBuilder: imageErrorWidgetBuilder, + ), + ), + ); }, ); return Padding( diff --git a/flutter_quill_extensions/lib/embeds/embed_types.dart b/flutter_quill_extensions/lib/embeds/embed_types.dart index 46cc1563..9fa4184a 100644 --- a/flutter_quill_extensions/lib/embeds/embed_types.dart +++ b/flutter_quill_extensions/lib/embeds/embed_types.dart @@ -1,7 +1,8 @@ import 'dart:io' show File; import 'dart:typed_data'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' + show ImageErrorWidgetBuilder, BuildContext, ImageProvider; typedef OnImagePickCallback = Future Function(File file); typedef OnVideoPickCallback = Future Function(File file); @@ -52,3 +53,10 @@ typedef ImageEmbedBuilderWillRemoveCallback = Future Function( typedef ImageEmbedBuilderOnRemovedCallback = Future Function( File imageFile, ); + +typedef ImageEmbedBuilderProviderBuilder = ImageProvider Function( + String imageUrl, + // {required bool isLocalImage} +); + +typedef ImageEmbedBuilderErrorWidgetBuilder = ImageErrorWidgetBuilder; diff --git a/flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart index 5ec8e28e..fbc2dfe6 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart @@ -61,30 +61,49 @@ class CameraButton extends StatelessWidget { size: iconSize * 1.77, fillColor: iconFillColor, borderRadius: iconTheme?.borderRadius ?? 2, - onPressed: () => _handleCameraButtonTap(context, controller, - onImagePickCallback: onImagePickCallback, - onVideoPickCallback: onVideoPickCallback, - filePickImpl: filePickImpl, - webImagePickImpl: webImagePickImpl), + onPressed: () => _handleCameraButtonTap( + context, + controller, + onImagePickCallback: onImagePickCallback, + onVideoPickCallback: onVideoPickCallback, + filePickImpl: filePickImpl, + webImagePickImpl: webImagePickImpl, + ), ); } Future _handleCameraButtonTap( - BuildContext context, QuillController controller, - {OnImagePickCallback? onImagePickCallback, - OnVideoPickCallback? onVideoPickCallback, - FilePickImpl? filePickImpl, - WebImagePickImpl? webImagePickImpl}) async { - if (onImagePickCallback != null && onVideoPickCallback != null) { - final selector = cameraPickSettingSelector ?? - (context) => showDialog( - context: context, - builder: (ctx) => AlertDialog( - contentPadding: EdgeInsets.zero, - backgroundColor: Colors.transparent, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ + BuildContext context, + QuillController controller, { + OnImagePickCallback? onImagePickCallback, + OnVideoPickCallback? onVideoPickCallback, + FilePickImpl? filePickImpl, + WebImagePickImpl? webImagePickImpl, + }) async { + if (onVideoPickCallback == null && onImagePickCallback == null) { + throw ArgumentError( + 'onImagePickCallback and onVideoPickCallback are both null', + ); + } + + var shouldShowPickPhotoByCamera = false; + var shouldShowRecordVideoByCamera = false; + if (onImagePickCallback != null) { + shouldShowPickPhotoByCamera = true; + } + if (onVideoPickCallback != null) { + shouldShowRecordVideoByCamera = true; + } + final selector = cameraPickSettingSelector ?? + (context) => showDialog( + context: context, + builder: (ctx) => AlertDialog( + contentPadding: EdgeInsets.zero, + backgroundColor: Colors.transparent, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (shouldShowPickPhotoByCamera) TextButton.icon( icon: const Icon( Icons.camera, @@ -94,6 +113,7 @@ class CameraButton extends StatelessWidget { onPressed: () => Navigator.pop(ctx, MediaPickSetting.Camera), ), + if (shouldShowRecordVideoByCamera) TextButton.icon( icon: const Icon( Icons.video_call, @@ -103,28 +123,44 @@ class CameraButton extends StatelessWidget { onPressed: () => Navigator.pop(ctx, MediaPickSetting.Video), ) - ], - ), + ], ), - ); - - final source = await selector(context); - if (source != null) { - switch (source) { - case MediaPickSetting.Camera: - await ImageVideoUtils.handleImageButtonTap( - context, controller, ImageSource.camera, onImagePickCallback, - filePickImpl: filePickImpl, webImagePickImpl: webImagePickImpl); - break; - case MediaPickSetting.Video: - await ImageVideoUtils.handleVideoButtonTap( - context, controller, ImageSource.camera, onVideoPickCallback, - filePickImpl: filePickImpl, webVideoPickImpl: webVideoPickImpl); - break; - default: - throw ArgumentError('Invalid MediaSetting'); - } - } + ), + ); + + final source = await selector(context); + if (source == null) { + return; + } + switch (source) { + case MediaPickSetting.Camera: + await ImageVideoUtils.handleImageButtonTap( + context, + controller, + ImageSource.camera, + onImagePickCallback!, + filePickImpl: filePickImpl, + webImagePickImpl: webImagePickImpl, + ); + break; + case MediaPickSetting.Video: + await ImageVideoUtils.handleVideoButtonTap( + context, + controller, + ImageSource.camera, + onVideoPickCallback!, + filePickImpl: filePickImpl, + webVideoPickImpl: webVideoPickImpl, + ); + break; + case MediaPickSetting.Gallery: + throw ArgumentError( + 'Invalid MediaSetting for the camera button', + ); + case MediaPickSetting.Link: + throw ArgumentError( + 'Invalid MediaSetting for the camera button', + ); } } } diff --git a/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart index dffbb387..2cc4781a 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart @@ -64,20 +64,47 @@ class ImageButton extends StatelessWidget { } Future _onPressedHandler(BuildContext context) async { - if (onImagePickCallback != null) { - final selector = - mediaPickSettingSelector ?? ImageVideoUtils.selectMediaPickSetting; - final source = await selector(context); - if (source != null) { - if (source == MediaPickSetting.Gallery) { - _pickImage(context); - } else { - _typeLink(context); - } - } - } else { + final onImagePickCallbackRef = onImagePickCallback; + if (onImagePickCallbackRef == null) { _typeLink(context); + return; } + final selector = + mediaPickSettingSelector ?? ImageVideoUtils.selectMediaPickSetting; + final source = await selector(context); + if (source == null) { + return; + } + switch (source) { + case MediaPickSetting.Gallery: + _pickImage(context); + break; + case MediaPickSetting.Link: + _typeLink(context); + break; + case MediaPickSetting.Camera: + await ImageVideoUtils.handleImageButtonTap( + context, + controller, + ImageSource.camera, + onImagePickCallbackRef, + filePickImpl: filePickImpl, + webImagePickImpl: webImagePickImpl, + ); + break; + case MediaPickSetting.Video: + throw ArgumentError( + 'Sorry but this is the Image button and not the video one', + ); + } + + // This will not work for the pick image using camera (bug fix) + // if (source != null) { + // if (source == MediaPickSetting.Gallery) { + + // } else { + // _typeLink(context); + // } } void _pickImage(BuildContext context) => ImageVideoUtils.handleImageButtonTap( 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 beb68ce3..f7739ce9 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart @@ -38,6 +38,10 @@ class LinkDialogState extends State { // 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 + // TODO: Consider use one of those as default or provide a + // way to custmize the check, that are not based on RegExp, + // I already implemented one so tell me if you are interested + // 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; @@ -53,9 +57,10 @@ class LinkDialogState extends State { maxLines: null, style: widget.dialogTheme?.inputTextStyle, decoration: InputDecoration( - labelText: 'Paste a link'.i18n, - labelStyle: widget.dialogTheme?.labelTextStyle, - floatingLabelStyle: widget.dialogTheme?.labelTextStyle), + labelText: 'Paste a link'.i18n, + labelStyle: widget.dialogTheme?.labelTextStyle, + floatingLabelStyle: widget.dialogTheme?.labelTextStyle, + ), autofocus: true, onChanged: _linkChanged, controller: _controller, @@ -119,12 +124,13 @@ class ImageVideoUtils { /// For image picking logic static Future handleImageButtonTap( - BuildContext context, - QuillController controller, - ImageSource imageSource, - OnImagePickCallback onImagePickCallback, - {FilePickImpl? filePickImpl, - WebImagePickImpl? webImagePickImpl}) async { + BuildContext context, + QuillController controller, + ImageSource imageSource, + OnImagePickCallback onImagePickCallback, { + FilePickImpl? filePickImpl, + WebImagePickImpl? webImagePickImpl, + }) async { final index = controller.selection.baseOffset; final length = controller.selection.extentOffset - index; @@ -149,7 +155,9 @@ class ImageVideoUtils { } static Future _pickImage( - ImageSource source, OnImagePickCallback onImagePickCallback) async { + ImageSource source, + OnImagePickCallback onImagePickCallback, + ) async { final pickedFile = await ImagePicker().pickImage(source: source); if (pickedFile == null) { return null; diff --git a/flutter_quill_extensions/lib/embeds/toolbar/media_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/media_button.dart index 2eaeec60..9df32acc 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/media_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/media_button.dart @@ -118,7 +118,8 @@ class MediaButton extends StatelessWidget { Future _pickImage() async { if (!(kIsWeb || isMobile() || isDesktop())) { throw UnsupportedError( - 'Unsupported target platform: ${defaultTargetPlatform.name}'); + 'Unsupported target platform: ${defaultTargetPlatform.name}', + ); } final mediaFileUrl = await _pickMediaFileUrl(); @@ -127,7 +128,11 @@ class MediaButton extends StatelessWidget { final index = controller.selection.baseOffset; final length = controller.selection.extentOffset - index; controller.replaceText( - index, length, BlockEmbed.image(mediaFileUrl), null); + index, + length, + BlockEmbed.image(mediaFileUrl), + null, + ); } } diff --git a/flutter_quill_extensions/lib/embeds/widgets/image.dart b/flutter_quill_extensions/lib/embeds/widgets/image.dart index 2679dac5..bfff7919 100644 --- a/flutter_quill_extensions/lib/embeds/widgets/image.dart +++ b/flutter_quill_extensions/lib/embeds/widgets/image.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'package:photo_view/photo_view.dart'; +import '../embed_types.dart'; import '../utils.dart'; const List imageFileExtensions = [ @@ -27,21 +28,44 @@ String getImageStyleString(QuillController controller) { return s ?? ''; } -Image imageByUrl(String imageUrl, - {double? width, - double? height, - AlignmentGeometry alignment = Alignment.center}) { +Image imageByUrl( + String imageUrl, { + required ImageEmbedBuilderProviderBuilder? imageProviderBuilder, + required ImageErrorWidgetBuilder? imageErrorWidgetBuilder, + double? width, + double? height, + AlignmentGeometry alignment = Alignment.center, +}) { if (isImageBase64(imageUrl)) { return Image.memory(base64.decode(imageUrl), width: width, height: height, alignment: alignment); } - if (imageUrl.startsWith('http')) { - return Image.network(imageUrl, - width: width, height: height, alignment: alignment); + if (imageProviderBuilder != null) { + return Image( + image: imageProviderBuilder(imageUrl), + width: width, + height: height, + alignment: alignment, + errorBuilder: imageErrorWidgetBuilder, + ); + } + if (isHttpBasedUrl(imageUrl)) { + return Image.network( + imageUrl, + width: width, + height: height, + alignment: alignment, + errorBuilder: imageErrorWidgetBuilder, + ); } - return Image.file(File(imageUrl), - width: width, height: height, alignment: alignment); + return Image.file( + File(imageUrl), + width: width, + height: height, + alignment: alignment, + errorBuilder: imageErrorWidgetBuilder, + ); } String standardizeImageUrl(String url) { @@ -73,12 +97,22 @@ String appendFileExtensionToImageUrl(String url) { class ImageTapWrapper extends StatelessWidget { const ImageTapWrapper({ required this.imageUrl, + required this.imageProviderBuilder, + required this.imageErrorWidgetBuilder, }); final String imageUrl; + final ImageEmbedBuilderProviderBuilder? imageProviderBuilder; + final ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder; - ImageProvider _imageProviderByUrl(String imageUrl) { - if (imageUrl.startsWith('http')) { + ImageProvider _imageProviderByUrl( + String imageUrl, { + required ImageEmbedBuilderProviderBuilder? customImageProviderBuilder, + }) { + if (customImageProviderBuilder != null) { + return customImageProviderBuilder(imageUrl); + } + if (isHttpBasedUrl(imageUrl)) { return NetworkImage(imageUrl); } @@ -95,7 +129,11 @@ class ImageTapWrapper extends StatelessWidget { child: Stack( children: [ PhotoView( - imageProvider: _imageProviderByUrl(imageUrl), + imageProvider: _imageProviderByUrl( + imageUrl, + customImageProviderBuilder: imageProviderBuilder, + ), + errorBuilder: imageErrorWidgetBuilder, loadingBuilder: (context, event) { return Container( color: Colors.black, diff --git a/flutter_quill_extensions/lib/flutter_quill_extensions.dart b/flutter_quill_extensions/lib/flutter_quill_extensions.dart index afb49cce..2d9f7525 100644 --- a/flutter_quill_extensions/lib/flutter_quill_extensions.dart +++ b/flutter_quill_extensions/lib/flutter_quill_extensions.dart @@ -72,11 +72,28 @@ class FlutterQuillEmbeds { /// ), /// ); /// - /// // Return `true` to allow image removal if the user confirms, otherwise `false` + /// // Return `true` to allow image removal if the user confirms, otherwise + /// `false` /// return isShouldRemove; /// } /// ``` /// + /// [imageProviderBuilder] if you want to use custom image provider, please + /// pass a value to this property + /// By default we will use [NetworkImage] provider if the image url/path + /// is using http/https, if not then we will use [FileImage] provider + /// If you ovveride this make sure to handle the case where if the [imageUrl] + /// is in the local storage or it does exists in the system file + /// or use the same way we did it + /// + /// Example of [imageProviderBuilder] customization: + /// ```dart + /// imageProviderBuilder: (imageUrl) async { + /// // Example of using cached_network_image package + /// return CachedNetworkImageProvider(imageUrl); + /// } + /// ``` + /// /// [forceUseMobileOptionMenuForImageClick] is a boolean /// flag that, when set to `true`, /// enforces the use of the mobile-specific option menu for image clicks in @@ -107,10 +124,14 @@ class FlutterQuillEmbeds { void Function(GlobalKey videoContainerKey)? onVideoInit, ImageEmbedBuilderOnRemovedCallback? onImageRemovedCallback, ImageEmbedBuilderWillRemoveCallback? shouldRemoveImageCallback, + ImageEmbedBuilderProviderBuilder? imageProviderBuilder, + ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder, bool forceUseMobileOptionMenuForImageClick = false, }) => [ ImageEmbedBuilder( + imageErrorWidgetBuilder: imageErrorWidgetBuilder, + imageProviderBuilder: imageProviderBuilder, forceUseMobileOptionMenu: forceUseMobileOptionMenuForImageClick, onImageRemovedCallback: onImageRemovedCallback ?? (imageFile) async {