Fix bug && Provide a way to handle the image errors and use custom image provider

pull/1428/head
Ahmed Hnewa 2 years ago
parent 5d30113804
commit ed1975a3de
No known key found for this signature in database
GPG Key ID: C488CC70BBCEF0D1
  1. 99
      flutter_quill_extensions/lib/embeds/builders.dart
  2. 10
      flutter_quill_extensions/lib/embeds/embed_types.dart
  3. 118
      flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart
  4. 51
      flutter_quill_extensions/lib/embeds/toolbar/image_button.dart
  5. 28
      flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart
  6. 9
      flutter_quill_extensions/lib/embeds/toolbar/media_button.dart
  7. 62
      flutter_quill_extensions/lib/embeds/widgets/image.dart
  8. 23
      flutter_quill_extensions/lib/flutter_quill_extensions.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(

@ -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<String?> Function(File file);
typedef OnVideoPickCallback = Future<String?> Function(File file);
@ -52,3 +53,10 @@ typedef ImageEmbedBuilderWillRemoveCallback = Future<bool> Function(
typedef ImageEmbedBuilderOnRemovedCallback = Future<void> Function(
File imageFile,
);
typedef ImageEmbedBuilderProviderBuilder = ImageProvider Function(
String imageUrl,
// {required bool isLocalImage}
);
typedef ImageEmbedBuilderErrorWidgetBuilder = ImageErrorWidgetBuilder;

@ -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<void> _handleCameraButtonTap(
BuildContext context, QuillController controller,
{OnImagePickCallback? onImagePickCallback,
OnVideoPickCallback? onVideoPickCallback,
FilePickImpl? filePickImpl,
WebImagePickImpl? webImagePickImpl}) async {
if (onImagePickCallback != null && onVideoPickCallback != null) {
final selector = cameraPickSettingSelector ??
(context) => showDialog<MediaPickSetting>(
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<MediaPickSetting>(
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',
);
}
}
}

@ -64,20 +64,47 @@ class ImageButton extends StatelessWidget {
}
Future<void> _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(

@ -38,6 +38,10 @@ class LinkDialogState extends State<LinkDialog> {
// 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<LinkDialog> {
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<void> 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<String?> _pickImage(
ImageSource source, OnImagePickCallback onImagePickCallback) async {
ImageSource source,
OnImagePickCallback onImagePickCallback,
) async {
final pickedFile = await ImagePicker().pickImage(source: source);
if (pickedFile == null) {
return null;

@ -118,7 +118,8 @@ class MediaButton extends StatelessWidget {
Future<void> _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,
);
}
}

@ -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<String> 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,

@ -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 {

Loading…
Cancel
Save