diff --git a/assets/images/screenshot_1.png b/assets/images/screenshot_1.png new file mode 100644 index 0000000..9f16e95 Binary files /dev/null and b/assets/images/screenshot_1.png differ diff --git a/assets/images/screenshot_2.png b/assets/images/screenshot_2.png new file mode 100644 index 0000000..0ecb7bf Binary files /dev/null and b/assets/images/screenshot_2.png differ diff --git a/assets/images/screenshot_3.png b/assets/images/screenshot_3.png new file mode 100644 index 0000000..450f0ac Binary files /dev/null and b/assets/images/screenshot_3.png differ diff --git a/assets/images/screenshot_4.png b/assets/images/screenshot_4.png new file mode 100644 index 0000000..8b90452 Binary files /dev/null and b/assets/images/screenshot_4.png differ diff --git a/lib/Screens/myTemplates/assets.dart b/lib/Screens/myTemplates/assets.dart new file mode 100644 index 0000000..d5b4d63 --- /dev/null +++ b/lib/Screens/myTemplates/assets.dart @@ -0,0 +1,4 @@ +const kScreenshot1 = 'assets/images/screenshot_1.png'; +const kScreenshot2 = 'assets/images/screenshot_2.png'; +const kScreenshot3 = 'assets/images/screenshot_3.png'; +const kScreenshot4 = 'assets/images/screenshot_4.png'; \ No newline at end of file diff --git a/lib/Screens/myTemplates/custom_toolbar.dart b/lib/Screens/myTemplates/custom_toolbar.dart new file mode 100644 index 0000000..924c09d --- /dev/null +++ b/lib/Screens/myTemplates/custom_toolbar.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; + +/// Custom toolbar that uses the buttons of [`flutter_quill`](https://pub.dev/packages/flutter_quill). +/// +/// See also: [Custom toolbar](https://github.com/singerdmx/flutter-quill/blob/master/doc/custom_toolbar.md). +class CustomToolbar extends StatelessWidget { + const CustomToolbar({super.key, required this.controller}); + + final QuillController controller; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Wrap( + children: [ + QuillToolbarHistoryButton( + isUndo: true, + controller: controller, + ), + QuillToolbarHistoryButton( + isUndo: false, + controller: controller, + ), + QuillToolbarToggleStyleButton( + options: const QuillToolbarToggleStyleButtonOptions(), + controller: controller, + attribute: Attribute.bold, + ), + QuillToolbarToggleStyleButton( + options: const QuillToolbarToggleStyleButtonOptions(), + controller: controller, + attribute: Attribute.italic, + ), + QuillToolbarToggleStyleButton( + controller: controller, + attribute: Attribute.underline, + ), + QuillToolbarClearFormatButton( + controller: controller, + ), + const VerticalDivider(), + QuillToolbarImageButton( + controller: controller, + ), + QuillToolbarCameraButton( + controller: controller, + ), + QuillToolbarVideoButton( + controller: controller, + ), + const VerticalDivider(), + QuillToolbarColorButton( + controller: controller, + isBackground: false, + ), + QuillToolbarColorButton( + controller: controller, + isBackground: true, + ), + const VerticalDivider(), + QuillToolbarSelectHeaderStyleDropdownButton( + controller: controller, + ), + const VerticalDivider(), + QuillToolbarSelectLineHeightStyleDropdownButton( + controller: controller, + ), + const VerticalDivider(), + QuillToolbarToggleCheckListButton( + controller: controller, + ), + QuillToolbarToggleStyleButton( + controller: controller, + attribute: Attribute.ol, + ), + QuillToolbarToggleStyleButton( + controller: controller, + attribute: Attribute.ul, + ), + QuillToolbarToggleStyleButton( + controller: controller, + attribute: Attribute.inlineCode, + ), + QuillToolbarToggleStyleButton( + controller: controller, + attribute: Attribute.blockQuote, + ), + QuillToolbarIndentButton( + controller: controller, + isIncrease: true, + ), + QuillToolbarIndentButton( + controller: controller, + isIncrease: false, + ), + const VerticalDivider(), + QuillToolbarLinkStyleButton(controller: controller), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/Screens/myTemplates/flutter_quill_extensions.dart b/lib/Screens/myTemplates/flutter_quill_extensions.dart new file mode 100644 index 0000000..d68b6cf --- /dev/null +++ b/lib/Screens/myTemplates/flutter_quill_extensions.dart @@ -0,0 +1,21 @@ +library; + +export 'src/common/extensions/controller_ext.dart'; +export 'src/editor/image/config/image_config.dart'; +export 'src/editor/image/config/image_web_config.dart'; +export 'src/editor/image/image_embed.dart'; +export 'src/editor/image/image_embed_types.dart'; +export 'src/editor/image/image_web_embed.dart'; +export 'src/editor/video/config/video_config.dart'; +export 'src/editor/video/config/video_web_config.dart'; +export 'src/editor/video/video_embed.dart'; +export 'src/editor/video/video_web_embed.dart'; +export 'src/flutter_quill_embeds.dart'; +export 'src/toolbar/camera/camera_button.dart'; +export 'src/toolbar/camera/camera_types.dart'; +export 'src/toolbar/camera/config/camera_config.dart'; +export 'src/toolbar/image/config/image_config.dart'; +export 'src/toolbar/image/image_button.dart'; +export 'src/toolbar/video/config/video.dart'; +export 'src/toolbar/video/config/video_config.dart'; +export 'src/toolbar/video/video_button.dart'; \ No newline at end of file diff --git a/lib/Screens/myTemplates/quill_delta_sample.dart b/lib/Screens/myTemplates/quill_delta_sample.dart new file mode 100644 index 0000000..dbe86ea --- /dev/null +++ b/lib/Screens/myTemplates/quill_delta_sample.dart @@ -0,0 +1,295 @@ +import 'assets.dart'; + +const kQuillDefaultSample = [ + { + 'insert': {'image': kScreenshot2}, + 'attributes': { + 'width': '100', + 'height': '100', + 'style': 'width:500px; height:350px;' + } + }, + {'insert': 'Flutter Quill'}, + { + 'attributes': {'header': 1}, + 'insert': '\n' + }, + { + 'insert': { + 'video': + 'https://www.youtube.com/watch?v=V4hgdKhIqtc&list=PLbhaS_83B97s78HsDTtplRTEhcFsqSqIK&index=1' + } + }, + { + 'insert': { + 'video': + 'https://user-images.githubusercontent.com/122956/126238875-22e42501-ad41-4266-b1d6-3f89b5e3b79b.mp4' + } + }, + {'insert': '\nRich text editor for Flutter'}, + { + 'attributes': {'header': 2}, + 'insert': '\n' + }, + {'insert': 'Quill component for Flutter'}, + { + 'attributes': {'header': 3}, + 'insert': '\n' + }, + { + 'attributes': {'link': 'https://bulletjournal.us/home/index.html'}, + 'insert': 'Bullet Journal' + }, + { + 'insert': + ':\nTrack personal and group journals (ToDo, Note, Ledger) from multiple views with timely reminders' + }, + { + 'attributes': {'list': 'ordered'}, + 'insert': '\n' + }, + { + 'insert': + 'Share your tasks and notes with teammates, and see changes as they happen in real-time, across all devices' + }, + { + 'attributes': {'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'Check out what you and your teammates are working on each day'}, + { + 'attributes': {'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': '\nSplitting bills with friends can never be easier.'}, + { + 'attributes': {'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'Start creating a group and invite your friends to join.'}, + { + 'attributes': {'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'Create a BuJo of Ledger type to see expense or balance summary.'}, + { + 'attributes': {'list': 'bullet'}, + 'insert': '\n' + }, + { + 'insert': + '\nAttach one or multiple labels to tasks, notes or transactions. Later you can track them just using the label(s).' + }, + { + 'attributes': {'blockquote': true}, + 'insert': '\n' + }, + {'insert': "\nvar BuJo = 'Bullet' + 'Journal'"}, + { + 'attributes': {'code-block': true}, + 'insert': '\n' + }, + {'insert': '\nStart tracking in your browser'}, + { + 'attributes': {'indent': 1}, + 'insert': '\n' + }, + {'insert': 'Stop the timer on your phone'}, + { + 'attributes': {'indent': 1}, + 'insert': '\n' + }, + {'insert': 'All your time entries are synced'}, + { + 'attributes': {'indent': 2}, + 'insert': '\n' + }, + {'insert': 'between the phone apps'}, + { + 'attributes': {'indent': 2}, + 'insert': '\n' + }, + {'insert': 'and the website.'}, + { + 'attributes': {'indent': 3}, + 'insert': '\n' + }, + {'insert': '\n'}, + {'insert': '\nCenter Align'}, + { + 'attributes': {'align': 'center'}, + 'insert': '\n' + }, + {'insert': 'Right Align'}, + { + 'attributes': {'align': 'right'}, + 'insert': '\n' + }, + {'insert': 'Justify Align'}, + { + 'attributes': {'align': 'justify'}, + 'insert': '\n' + }, + {'insert': 'Have trouble finding things? '}, + { + 'attributes': {'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'Just type in the search bar'}, + { + 'attributes': {'indent': 1, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'and easily find contents'}, + { + 'attributes': {'indent': 2, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'across projects or folders.'}, + { + 'attributes': {'indent': 2, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'It matches text in your note or task.'}, + { + 'attributes': {'indent': 1, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'Enable reminders so that you will get notified by'}, + { + 'attributes': {'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'email'}, + { + 'attributes': {'indent': 1, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'message on your phone'}, + { + 'attributes': {'indent': 1, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'popup on the web site'}, + { + 'attributes': {'indent': 1, 'list': 'ordered'}, + 'insert': '\n' + }, + {'insert': 'Create a BuJo serving as project or folder'}, + { + 'attributes': {'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'Organize your'}, + { + 'attributes': {'indent': 1, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'tasks'}, + { + 'attributes': {'indent': 2, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'notes'}, + { + 'attributes': {'indent': 2, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'transactions'}, + { + 'attributes': {'indent': 2, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'under BuJo '}, + { + 'attributes': {'indent': 3, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'See them in Calendar'}, + { + 'attributes': {'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'or hierarchical view'}, + { + 'attributes': {'indent': 1, 'list': 'bullet'}, + 'insert': '\n' + }, + {'insert': 'this is a check list'}, + { + 'attributes': {'list': 'checked'}, + 'insert': '\n' + }, + {'insert': 'this is a uncheck list'}, + { + 'attributes': {'list': 'unchecked'}, + 'insert': '\n' + }, + {'insert': 'Font '}, + { + 'attributes': {'font': 'sans-serif'}, + 'insert': 'Sans Serif' + }, + {'insert': ' '}, + { + 'attributes': {'font': 'serif'}, + 'insert': 'Serif' + }, + {'insert': ' '}, + { + 'attributes': {'font': 'monospace'}, + 'insert': 'Monospace' + }, + {'insert': ' Size '}, + { + 'attributes': {'size': 'small'}, + 'insert': 'Small' + }, + {'insert': ' '}, + { + 'attributes': {'size': 'large'}, + 'insert': 'Large' + }, + {'insert': ' '}, + { + 'attributes': {'size': 'huge'}, + 'insert': 'Huge' + }, + { + 'attributes': {'size': '15.0'}, + 'insert': 'font size 15' + }, + {'insert': ' '}, + { + 'attributes': {'size': '35'}, + 'insert': 'font size 35' + }, + {'insert': ' '}, + { + 'attributes': {'size': '20'}, + 'insert': 'font size 20' + }, + { + 'attributes': {'token': 'built_in'}, + 'insert': ' diff' + }, + { + 'attributes': {'token': 'operator'}, + 'insert': '-match' + }, + { + 'attributes': {'token': 'literal'}, + 'insert': '-patch' + }, + { + 'insert': { + 'image': + 'https://flutter.github.io/assets-for-api-docs/assets/widgets/owl.jpg' + }, + 'attributes': { + 'width': '230', + 'style': 'display: block; margin: auto; width: 500px;' + } + }, + {'insert': '\n'} +]; \ No newline at end of file diff --git a/lib/Screens/myTemplates/src/common/default_image_insert.dart b/lib/Screens/myTemplates/src/common/default_image_insert.dart new file mode 100644 index 0000000..bec31d9 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/default_image_insert.dart @@ -0,0 +1,30 @@ +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:meta/meta.dart'; + +import '../editor/image/image_embed_types.dart'; +import 'extensions/controller_ext.dart'; + +OnImageInsertCallback _defaultOnImageInsert() { + return (imageUrl, controller) async { + controller + ..skipRequestKeyboard = true + // ignore: deprecated_member_use_from_same_package + ..insertImageBlock(imageSource: imageUrl); + }; +} + +@internal +Future handleImageInsert( + String imageUrl, { + required QuillController controller, + required OnImageInsertCallback? onImageInsertCallback, + required OnImageInsertedCallback? onImageInsertedCallback, +}) async { + final customOnImageInsert = onImageInsertCallback; + if (customOnImageInsert != null) { + await customOnImageInsert.call(imageUrl, controller); + } else { + await _defaultOnImageInsert().call(imageUrl, controller); + } + await onImageInsertedCallback?.call(imageUrl); +} diff --git a/lib/Screens/myTemplates/src/common/default_video_insert.dart b/lib/Screens/myTemplates/src/common/default_video_insert.dart new file mode 100644 index 0000000..e92d3ce --- /dev/null +++ b/lib/Screens/myTemplates/src/common/default_video_insert.dart @@ -0,0 +1,30 @@ +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:meta/meta.dart'; + +import '../toolbar/video/config/video.dart'; +import 'extensions/controller_ext.dart'; + +OnVideoInsertCallback _defaultOnVideoInsert() { + return (imageUrl, controller) async { + controller + ..skipRequestKeyboard = true + // ignore: deprecated_member_use_from_same_package + ..insertVideoBlock(videoUrl: imageUrl); + }; +} + +@internal +Future handleVideoInsert( + String videoUrl, { + required QuillController controller, + required OnVideoInsertCallback? onVideoInsertCallback, + required OnVideoInsertedCallback? onVideoInsertedCallback, +}) async { + final customOnVideoInsert = onVideoInsertCallback; + if (customOnVideoInsert != null) { + await customOnVideoInsert.call(videoUrl, controller); + } else { + await _defaultOnVideoInsert().call(videoUrl, controller); + } + await onVideoInsertedCallback?.call(videoUrl); +} diff --git a/lib/Screens/myTemplates/src/common/extensions/attribute.dart b/lib/Screens/myTemplates/src/common/extensions/attribute.dart new file mode 100644 index 0000000..5331b55 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/extensions/attribute.dart @@ -0,0 +1,12 @@ +import 'package:flutter_quill/flutter_quill.dart' + show Attribute, AttributeScope; + +class FlutterAlignmentAttribute extends Attribute { + const FlutterAlignmentAttribute(String? val) + : super('flutterAlignment', AttributeScope.ignore, val); +} + +extension AttributeExt on Attribute { + static const FlutterAlignmentAttribute flutterAlignment = + FlutterAlignmentAttribute(null); +} diff --git a/lib/Screens/myTemplates/src/common/extensions/controller_ext.dart b/lib/Screens/myTemplates/src/common/extensions/controller_ext.dart new file mode 100644 index 0000000..45443c4 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/extensions/controller_ext.dart @@ -0,0 +1,36 @@ +import 'package:flutter_quill/flutter_quill.dart'; + +@Deprecated('Invalid extension') +extension QuillControllerExt on QuillController { + @Deprecated( + 'Invalid extension property and will be removed, use selection.baseOffset instead') + int get index => selection.baseOffset; + @Deprecated( + 'Invalid extension property and will be removed, use selection.extentOffset - selection.baseOffset instead') + int get length => selection.extentOffset - index; + + @Deprecated('Invalid extension method and will be removed.') + void insertImageBlock({ + required String imageSource, + }) { + this + ..skipRequestKeyboard = true + ..replaceText( + index, + length, + BlockEmbed.image(imageSource), + null, + ) + ..moveCursorToPosition(index + 1); + } + + @Deprecated('Invalid extension method and will be removed.') + void insertVideoBlock({ + required String videoUrl, + }) { + this + ..skipRequestKeyboard = true + ..replaceText(index, length, BlockEmbed.video(videoUrl), null) + ..moveCursorToPosition(index + 1); + } +} diff --git a/lib/Screens/myTemplates/src/common/image_video_utils.dart b/lib/Screens/myTemplates/src/common/image_video_utils.dart new file mode 100644 index 0000000..2165665 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/image_video_utils.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' show QuillDialogTheme; +import 'package:flutter_quill/internal.dart'; + +import 'utils/patterns.dart'; + +enum LinkType { + video, + image, +} + +class TypeLinkDialog extends StatefulWidget { + const TypeLinkDialog({ + required this.linkType, + this.dialogTheme, + this.link, + this.linkRegExp, + super.key, + }); + + final QuillDialogTheme? dialogTheme; + final String? link; + final RegExp? linkRegExp; + final LinkType linkType; + + @override + TypeLinkDialogState createState() => TypeLinkDialogState(); +} + +class TypeLinkDialogState extends State { + late String _link; + late TextEditingController _controller; + RegExp? _linkRegExp; + + @override + void initState() { + super.initState(); + _link = widget.link ?? ''; + _controller = TextEditingController(text: _link); + + _linkRegExp = widget.linkRegExp; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: widget.dialogTheme?.dialogBackgroundColor, + content: TextField( + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + maxLines: null, + style: widget.dialogTheme?.inputTextStyle, + decoration: InputDecoration( + labelText: context.loc.pasteLink, + hintText: widget.linkType == LinkType.image + ? context.loc.pleaseEnterAValidImageURL + : context.loc.pleaseEnterAValidVideoURL, + labelStyle: widget.dialogTheme?.labelTextStyle, + floatingLabelStyle: widget.dialogTheme?.labelTextStyle, + ), + autofocus: true, + onChanged: _linkChanged, + controller: _controller, + onEditingComplete: () { + if (!_canPress()) { + return; + } + _applyLink(); + }, + ), + actions: [ + TextButton( + onPressed: _canPress() ? _applyLink : null, + child: Text( + context.loc.ok, + style: widget.dialogTheme?.labelTextStyle, + ), + ), + ], + ); + } + + void _linkChanged(String value) { + setState(() { + _link = value; + }); + } + + void _applyLink() { + Navigator.pop(context, _link.trim()); + } + + RegExp get linkRegExp { + final customRegExp = _linkRegExp; + if (customRegExp != null) { + return customRegExp; + } + switch (widget.linkType) { + case LinkType.video: + if (youtubeRegExp.hasMatch(_link)) { + return youtubeRegExp; + } + return videoRegExp; + case LinkType.image: + return imageRegExp; + } + } + + bool _canPress() { + if (_link.isEmpty) { + return false; + } + if (widget.linkType == LinkType.image) {} + return _link.isNotEmpty && linkRegExp.hasMatch(_link); + } +} diff --git a/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_fake.dart b/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_fake.dart new file mode 100644 index 0000000..c42e2eb --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_fake.dart @@ -0,0 +1,43 @@ +// import 'package:universal_html/html.dart' as html; + +// Fake interface for the logic that this package needs from (web-only) dart:ui. +// This is conditionally exported so the analyzer sees these methods as +// available. + +// typedef PlatroformViewFactory = html.Element Function(int viewId); + +// /// Shim for web_ui engine.PlatformViewRegistry +// /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L62 +// class PlatformViewRegistry { +// /// Shim for registerViewFactory +// /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L72 +// static dynamic registerViewFactory( +// String viewTypeId, PlatroformViewFactory viewFactory) {} +// } + +// /// Shim for web_ui engine.AssetManager +// /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/src/engine/assets.dart#L12 +// class WebOnlyAssetManager { +// static dynamic getAssetUrl(String asset) {} +// } + +class PlatformViewRegistry { + /// Register [viewType] as being created by the given [viewFactory]. + /// + /// [viewFactory] can be any function that takes an integer and optional + /// `params` and returns an `HTMLElement` DOM object. + bool registerViewFactory( + String viewType, + Function viewFactory, { + bool isVisible = true, + }) { + return false; + } + + /// Returns the view previously created for [viewId]. + /// + /// Throws if no view has been created for [viewId]. + Object getViewById(int viewId) { + return ''; + } +} diff --git a/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_real.dart b/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_real.dart new file mode 100644 index 0000000..63c4e48 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/dart_ui/dart_ui_real.dart @@ -0,0 +1 @@ +export 'dart:ui' if (dart.library.js_interop) 'dart:ui_web'; diff --git a/lib/Screens/myTemplates/src/common/utils/element_utils/element_shared_utils.dart b/lib/Screens/myTemplates/src/common/utils/element_utils/element_shared_utils.dart new file mode 100644 index 0000000..c5a7d1f --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/element_utils/element_shared_utils.dart @@ -0,0 +1,84 @@ +import 'package:flutter/widgets.dart' show BuildContext, MediaQuery; + +Map parseCssString(String cssString) { + final result = {}; + final declarations = cssString.split(';'); + + for (final declaration in declarations) { + final parts = declaration.split(':'); + if (parts.length == 2) { + final property = parts[0].trim(); + final value = parts[1].trim(); + result[property] = value; + } + } + + return result; +} + +enum _CssUnit { + px('px'), + percentage('%'), + viewportWidth('vw'), + viewportHeight('vh'), + em('em'), + rem('rem'), + invalid('invalid'); + + const _CssUnit(this.cssName); + + final String cssName; +} + +double? parseCssPropertyAsDouble( + String value, { + required BuildContext context, +}) { + if (value.trim().isEmpty) { + return null; + } + + // Try to parse it in case it's a valid double already + var doubleValue = double.tryParse(value); + + if (doubleValue != null) { + return doubleValue; + } + + // If not then if it's a css numberic value then we will try to parse it + final unit = _CssUnit.values + .where((element) => value.endsWith(element.cssName)) + .firstOrNull; + if (unit == null) { + return null; + } + value = value.replaceFirst(unit.cssName, ''); + doubleValue = double.tryParse(value); + if (doubleValue != null) { + switch (unit) { + case _CssUnit.px: + // Do nothing + break; + case _CssUnit.percentage: + // Not supported yet + doubleValue = null; + break; + case _CssUnit.viewportWidth: + doubleValue = (doubleValue / 100) * MediaQuery.sizeOf(context).width; + break; + case _CssUnit.viewportHeight: + doubleValue = (doubleValue / 100) * MediaQuery.sizeOf(context).height; + break; + case _CssUnit.em: + doubleValue = MediaQuery.textScalerOf(context).scale(doubleValue); + break; + case _CssUnit.rem: + doubleValue = MediaQuery.textScalerOf(context).scale(doubleValue); + break; + case _CssUnit.invalid: + doubleValue = null; + break; + } + } + return doubleValue; +} diff --git a/lib/Screens/myTemplates/src/common/utils/element_utils/element_utils.dart b/lib/Screens/myTemplates/src/common/utils/element_utils/element_utils.dart new file mode 100644 index 0000000..86a95f4 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/element_utils/element_utils.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart' show immutable; +import 'package:flutter/widgets.dart' show Alignment, BuildContext; +import 'package:flutter_quill/flutter_quill.dart' show Attribute, Node; +import 'package:flutter_quill/internal.dart'; + +import 'element_shared_utils.dart'; + +/// Theses properties are not officialy supported by quill js +/// but they are only used in all platforms other than web +/// and they will be stored in css style property so quill js ignore them +enum ExtraElementProperties { + deletable, +} + +( + ElementSize elementSize, + double? margin, + Alignment alignment, +) getElementAttributes( + Node node, + BuildContext context, +) { + var elementSize = const ElementSize(null, null); + var elementAlignment = Alignment.center; + double? elementMargin; + + final heightValue = parseCssPropertyAsDouble( + node.style.attributes[Attribute.height.key]?.value.toString() ?? '', + context: context, + ); + final widthValue = parseCssPropertyAsDouble( + node.style.attributes[Attribute.width.key]?.value.toString() ?? '', + context: context, + ); + + if (heightValue != null) { + elementSize = elementSize.copyWith( + height: heightValue, + ); + } + if (widthValue != null) { + elementSize = elementSize.copyWith( + width: widthValue, + ); + } + + final cssStyle = node.style.attributes['style']; + + if (cssStyle != null) { + // It css value as string but we will try to support it anyway + + final cssAttrs = parseCssString(cssStyle.value.toString()); + + final cssHeightValue = parseCssPropertyAsDouble( + (cssAttrs[Attribute.height.key]) ?? '', + context: context, + ); + final cssWidthValue = parseCssPropertyAsDouble( + (cssAttrs[Attribute.width.key]) ?? '', + context: context, + ); + + // cssHeightValue != null && elementSize.height == null + if (cssHeightValue != null) { + elementSize = elementSize.copyWith(height: cssHeightValue); + } + if (cssWidthValue != null) { + elementSize = elementSize.copyWith(width: cssWidthValue); + } + + elementAlignment = getAlignment(cssAttrs['alignment']); + + final margin = double.tryParse('margin'); + if (margin != null) { + elementMargin = margin; + } + } + + return (elementSize, elementMargin, elementAlignment); +} + +@immutable +class ElementSize { + const ElementSize( + this.width, + this.height, + ); + + /// If non-null, requires the child to have exactly this width. + /// If null, the child is free to choose its own width. + final double? width; + + /// If non-null, requires the child to have exactly this height. + /// If null, the child is free to choose its own height. + final double? height; + + ElementSize copyWith({ + double? width, + double? height, + }) { + return ElementSize( + width ?? this.width, + height ?? this.height, + ); + } +} diff --git a/lib/Screens/myTemplates/src/common/utils/element_utils/element_web_utils.dart b/lib/Screens/myTemplates/src/common/utils/element_utils/element_web_utils.dart new file mode 100644 index 0000000..091f1c3 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/element_utils/element_web_utils.dart @@ -0,0 +1,60 @@ +import 'package:flutter_quill/flutter_quill.dart' show Attribute, Node; + +import 'element_shared_utils.dart'; + +/// Prefer the width, and height from the css style attribute if exits +/// it can be `auto` or `100px` so it's specific to HTML && CSS +/// if not, we will use the one from attributes which is usually just an double +( + String height, + String width, + String margin, + String alignment, +) getWebElementAttributes( + Node node, +) { + var height = 'auto'; + var width = 'auto'; + // TODO(): Add support for margin and alignment + var margin = 'auto'; + const alignment = 'center'; + + final cssStyle = node.style.attributes['style']; + + final heightValue = node.style.attributes[Attribute.height.key]?.value; + final widthValue = node.style.attributes[Attribute.width.key]?.value; + + if (cssStyle != null) { + final attrs = parseCssString(cssStyle.value.toString()); + + final cssHeightValue = attrs[Attribute.height.key]; + + if (cssHeightValue != null) { + height = cssHeightValue; + } else { + height = '${heightValue}px'; + } + final cssWidthValue = attrs[Attribute.width.key]; + if (cssWidthValue != null) { + width = cssWidthValue; + } else if (widthValue != null) { + width = '${widthValue}px'; + } + + final cssMarginValue = attrs['margin']; + if (cssMarginValue != null) { + margin = cssMarginValue; + } + + return (height, width, margin, alignment); + } + + if (heightValue != null) { + height = '${heightValue}px'; + } + if (widthValue != null) { + width = '${widthValue}px'; + } + + return (height, width, margin, alignment); +} diff --git a/lib/Screens/myTemplates/src/common/utils/patterns.dart b/lib/Screens/myTemplates/src/common/utils/patterns.dart new file mode 100644 index 0000000..abb7517 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/patterns.dart @@ -0,0 +1,17 @@ +RegExp base64RegExp = RegExp( + r'^(?:[A-Za-z0-9+\/][A-Za-z0-9+\/][A-Za-z0-9+\/][A-Za-z0-9+\/])*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$', +); + +final imageRegExp = RegExp( + r'https?://.*?\.(?:png|jpe?g|gif|bmp|webp|tiff?)', + caseSensitive: false, +); + +final videoRegExp = RegExp( + r'\bhttps?://\S+\.(mp4|mov|avi|mkv|flv|wmv|webm)\b', + caseSensitive: false, +); +final youtubeRegExp = RegExp( + r'^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(-nocookie)?\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|live\/|v\/)?)([\w\-]+)(\S+)?$', + caseSensitive: false, +); diff --git a/lib/Screens/myTemplates/src/common/utils/string.dart b/lib/Screens/myTemplates/src/common/utils/string.dart new file mode 100644 index 0000000..a13cd34 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/string.dart @@ -0,0 +1,30 @@ +import 'package:flutter_quill/flutter_quill.dart' show Attribute; + +String replaceStyleStringWithSize( + String cssStyle, { + required double width, + required double height, +}) { + final result = {}; + final pairs = cssStyle.split(';'); + for (final pair in pairs) { + final index = pair.indexOf(':'); + if (index < 0) { + continue; + } + final key = pair.substring(0, index).trim(); + result[key] = pair.substring(index + 1).trim(); + } + + result[Attribute.width.key] = width.toString(); + result[Attribute.height.key] = height.toString(); + final sb = StringBuffer(); + for (final pair in result.entries) { + sb + ..write(pair.key) + ..write(': ') + ..write(pair.value) + ..write('; '); + } + return sb.toString(); +} diff --git a/lib/Screens/myTemplates/src/common/utils/utils.dart b/lib/Screens/myTemplates/src/common/utils/utils.dart new file mode 100644 index 0000000..13d7540 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/utils.dart @@ -0,0 +1,30 @@ +import 'patterns.dart'; + +bool isBase64(String str) { + return base64RegExp.hasMatch(str); +} + +bool isHttpUrl(String url) { + try { + final uri = Uri.parse(url.trim()); + return uri.isScheme('HTTP') || uri.isScheme('HTTPS'); + } catch (_) { + return false; + } +} + +bool isImageBase64(String imageUrl) { + return !isHttpUrl(imageUrl) && isBase64(imageUrl); +} + +bool isYouTubeUrl(String videoUrl) { + try { + final uri = Uri.parse(videoUrl); + return uri.host == 'www.youtube.com' || + uri.host == 'youtube.com' || + uri.host == 'youtu.be' || + uri.host == 'www.youtu.be'; + } catch (_) { + return false; + } +} diff --git a/lib/Screens/myTemplates/src/common/utils/web/web.dart b/lib/Screens/myTemplates/src/common/utils/web/web.dart new file mode 100644 index 0000000..d16e54b --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/web/web.dart @@ -0,0 +1 @@ +export './web_stub.dart' if (dart.library.js_interop) './web_real.dart'; diff --git a/lib/Screens/myTemplates/src/common/utils/web/web_real.dart b/lib/Screens/myTemplates/src/common/utils/web/web_real.dart new file mode 100644 index 0000000..da3119b --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/web/web_real.dart @@ -0,0 +1,46 @@ +import 'package:web/web.dart'; +import '../dart_ui/dart_ui_fake.dart' + if (dart.library.js_interop) '../dart_ui/dart_ui_real.dart' as ui; + +void main(List args) { + HTMLImageElement; +} + +void createHtmlImageElement({ + required String src, + required String height, + required String width, + required String margin, + required String alignSelf, +}) { + ui.PlatformViewRegistry().registerViewFactory(src, (viewId) { + return createHtmlImageElement( + src: src, + alignSelf: alignSelf, + width: width, + height: height, + margin: margin, + ); + }); +} + +void createHtmlIFrameElement({ + required String src, + required String height, + required String width, + required String margin, + required String alignSelf, +}) { + ui.PlatformViewRegistry().registerViewFactory( + src, + (id) { + return HTMLIFrameElement() + ..style.width = width + ..style.height = height + ..src = src + ..style.border = 'none' + ..style.margin = margin + ..style.alignSelf = alignSelf; + }, + ); +} diff --git a/lib/Screens/myTemplates/src/common/utils/web/web_stub.dart b/lib/Screens/myTemplates/src/common/utils/web/web_stub.dart new file mode 100644 index 0000000..dac6a97 --- /dev/null +++ b/lib/Screens/myTemplates/src/common/utils/web/web_stub.dart @@ -0,0 +1,19 @@ +void createHtmlImageElement({ + required String src, + required String height, + required String width, + required String margin, + required String alignSelf, +}) => + throw UnimplementedError( + 'A stub method is called, createHtmlImageElement is for web platforms only.'); + +void createHtmlIFrameElement({ + required String src, + required String height, + required String width, + required String margin, + required String alignSelf, +}) => + throw UnimplementedError( + 'A stub method is called, createHtmlIFrameElement is for web platforms only.'); diff --git a/lib/Screens/myTemplates/src/editor/image/config/image_config.dart b/lib/Screens/myTemplates/src/editor/image/config/image_config.dart new file mode 100644 index 0000000..3b8f1c1 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/config/image_config.dart @@ -0,0 +1,165 @@ +import 'dart:io' show File; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_quill/internal.dart'; + +import '../image_embed_types.dart'; + +/// [QuillEditorImageEmbedConfig] for desktop, mobile and +/// other platforms +/// excluding web, it's configurations that is needed for the editor +/// +@immutable +class QuillEditorImageEmbedConfig { + const QuillEditorImageEmbedConfig({ + ImageEmbedBuilderOnRemovedCallback? onImageRemovedCallback, + this.shouldRemoveImageCallback, + this.imageProviderBuilder, + this.imageErrorWidgetBuilder, + this.onImageClicked, + }) : _onImageRemovedCallback = onImageRemovedCallback; + + /// [onImageRemovedCallback] is called when an image is + /// removed from the editor. + /// By default, [onImageRemovedCallback] deletes the + /// temporary image file if + /// the platform is mobile and if it still exists. You + /// can customize this behavior + /// by passing your own function that handles the removal process. + /// + /// Example of [onImageRemovedCallback] customization: + /// ```dart + /// afterRemoveImageFromEditor: (imageFile) async { + /// // Your custom logic here + /// // or leave it empty to do nothing + /// } + /// ``` + /// + /// Default value if the passed value is null: + /// [QuillEditorImageEmbedConfig.defaultOnImageRemovedCallback] + /// + /// so if you want to do nothing make sure to pass a empty callback + /// instead of passing null as value + final ImageEmbedBuilderOnRemovedCallback? _onImageRemovedCallback; + + ImageEmbedBuilderOnRemovedCallback get onImageRemovedCallback { + return _onImageRemovedCallback ?? + QuillEditorImageEmbedConfig.defaultOnImageRemovedCallback; + } + + /// [shouldRemoveImageCallback] is a callback + /// function that is invoked when the + /// user attempts to remove an image from the editor. It allows you to control + /// whether the image should be removed based on your custom logic. + /// + /// Example of [shouldRemoveImageCallback] customization: + /// ```dart + /// shouldRemoveImageFromEditor: (imageFile) async { + /// // Show a confirmation dialog before removing the image + /// final isShouldRemove = await showYesCancelDialog( + /// context: context, + /// options: const YesOrCancelDialogOptions( + /// title: 'Deleting an image', + /// message: 'Are you sure you want' ' to delete this + /// image from the editor?', + /// ), + /// ); + /// + /// // Return `true` to allow image removal if the user confirms, otherwise + /// `false` + /// return isShouldRemove; + /// } + /// ``` + /// + final ImageEmbedBuilderWillRemoveCallback? shouldRemoveImageCallback; + + /// Allows to override the default handling and fallback to the default if `null` was returned. + /// + /// Example of [imageProviderBuilder] customization: + /// ```dart + /// imageProviderBuilder: (imageUrl) async { + /// if (imageUrl.startsWith('assets/')) { + /// // Supports Image assets + /// return AssetImage(imageUrl); + /// } + /// if (imageUrl.startsWith('http')) { + /// // Use https://pub.dev/packages/cached_network_image + /// // for network images to cache them. + /// return CachedNetworkImageProvider(imageUrl); + /// } + /// + /// // Return null to fallback to default handling + /// return null; + /// } + /// ``` + /// + final ImageEmbedBuilderProviderBuilder? imageProviderBuilder; + + /// [imageErrorWidgetBuilder] if you want to show a custom widget based on the + /// exception that happen while loading the image, if it network image or + /// local one, and it will get called on all the images even in the photo + /// preview widget and not just in the quill editor + /// by default the default error from flutter framework will thrown + /// + final ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder; + + /// What should happen when the image is pressed? + /// + /// By default will show `ImageOptionsMenu` dialog. If you want to handle what happens + /// to the image when it's clicked, you can pass a callback to this property. + final void Function(String imageSource)? onImageClicked; + + static ImageEmbedBuilderOnRemovedCallback get defaultOnImageRemovedCallback { + return (imageUrl) async { + if (kIsWeb) { + return; + } + + final mobile = isMobileApp; + // If the platform is not mobile, return void; + // Since the mobile OS gives us a copy of the image + + // Note: We should remove the image on Flutter web + // since the behavior is similar to how it is on mobile, + // but since this builder is not for web, we will ignore it + if (!mobile) { + return; + } + + // On mobile OS (Android, iOS), the system will not give us + // direct access to the image; instead, + // it will give us the image + // in the temp directory of the application. So, we want to + // remove it when we no longer need it. + + // but on desktop we don't want to touch user files + // especially on macOS, where we can't even delete + // it without + // permission + + final dartIoImageFile = File(imageUrl); + + final isFileExists = await dartIoImageFile.exists(); + if (isFileExists) { + await dartIoImageFile.delete(); + } + }; + } + + QuillEditorImageEmbedConfig copyWith({ + ImageEmbedBuilderOnRemovedCallback? onImageRemovedCallback, + ImageEmbedBuilderWillRemoveCallback? shouldRemoveImageCallback, + ImageEmbedBuilderProviderBuilder? imageProviderBuilder, + ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder, + bool? forceUseMobileOptionMenuForImageClick, + }) { + return QuillEditorImageEmbedConfig( + onImageRemovedCallback: onImageRemovedCallback ?? _onImageRemovedCallback, + shouldRemoveImageCallback: + shouldRemoveImageCallback ?? this.shouldRemoveImageCallback, + imageProviderBuilder: imageProviderBuilder ?? this.imageProviderBuilder, + imageErrorWidgetBuilder: + imageErrorWidgetBuilder ?? this.imageErrorWidgetBuilder, + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/config/image_web_config.dart b/lib/Screens/myTemplates/src/editor/image/config/image_web_config.dart new file mode 100644 index 0000000..b1b60b0 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/config/image_web_config.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart' show BoxConstraints; +import 'package:meta/meta.dart' show immutable; + +@immutable +class QuillEditorWebImageEmbedConfig { + const QuillEditorWebImageEmbedConfig({ + this.constraints, + }); + + final BoxConstraints? constraints; +} diff --git a/lib/Screens/myTemplates/src/editor/image/image_embed.dart b/lib/Screens/myTemplates/src/editor/image/image_embed.dart new file mode 100644 index 0000000..eb2c497 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_embed.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; + +import '../../common/utils/element_utils/element_utils.dart'; +import 'config/image_config.dart'; +import 'image_menu.dart'; +import 'widgets/image.dart'; + +class QuillEditorImageEmbedBuilder extends EmbedBuilder { + QuillEditorImageEmbedBuilder({ + required this.config, + }); + final QuillEditorImageEmbedConfig config; + + @override + String get key => BlockEmbed.imageType; + + @override + bool get expanded => false; + + @override + Widget build( + BuildContext context, + EmbedContext embedContext, + ) { + final imageSource = standardizeImageUrl(embedContext.node.value.data); + final ((imageSize), margin, alignment) = getElementAttributes( + embedContext.node, + context, + ); + + final width = imageSize.width; + final height = imageSize.height; + + final imageWidget = getImageWidgetByImageSource( + context: context, + imageSource, + imageProviderBuilder: config.imageProviderBuilder, + imageErrorWidgetBuilder: config.imageErrorWidgetBuilder, + alignment: alignment, + height: height, + width: width, + ); + + return GestureDetector( + onTap: () { + final onImageClicked = config.onImageClicked; + if (onImageClicked != null) { + onImageClicked(imageSource); + return; + } + showDialog( + context: context, + builder: (_) => ImageOptionsMenu( + controller: embedContext.controller, + config: config, + imageSource: imageSource, + imageSize: imageSize, + readOnly: embedContext.readOnly, + imageProvider: imageWidget.image, + ), + ); + }, + child: Builder( + builder: (context) { + if (margin != null) { + return Padding( + padding: EdgeInsets.all(margin), + child: imageWidget, + ); + } + return imageWidget; + }, + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/image_embed_types.dart b/lib/Screens/myTemplates/src/editor/image/image_embed_types.dart new file mode 100644 index 0000000..0ca6369 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_embed_types.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.dart' + show ImageErrorWidgetBuilder, ImageProvider; +import 'package:flutter/widgets.dart' show BuildContext; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:meta/meta.dart' show immutable; + +/// When request picking an image, for example when the image button toolbar +/// clicked, it should be null in case the user didn't choose any image or +/// any other reasons, and it should be the image file path as string that is +/// exists in case the user picked the image successfully +/// +/// by default we already have a default implementation that show a dialog +/// request the source for picking the image, from gallery, link or camera +typedef OnRequestPickImage = Future Function( + BuildContext context, +); + +/// A callback will called when inserting a image in the editor +/// it have the logic that will insert the image block using the controller +typedef OnImageInsertCallback = Future Function( + String image, + QuillController controller, +); + +/// When a new image picked this callback will called and you might want to +/// do some logic depending on your use case +typedef OnImageInsertedCallback = Future Function( + String image, +); + +enum InsertImageSource { + gallery, + camera, + link, +} + +/// Configurations for dealing with images, on insert a image +/// on request picking a image +@immutable +class QuillToolbarImageConfig { + const QuillToolbarImageConfig({ + this.onRequestPickImage, + this.onImageInsertedCallback, + this.onImageInsertCallback, + }); + + final OnRequestPickImage? onRequestPickImage; + + final OnImageInsertedCallback? onImageInsertedCallback; + + final OnImageInsertCallback? onImageInsertCallback; +} + +typedef ImageEmbedBuilderWillRemoveCallback = Future Function( + String imageUrl, +); + +typedef ImageEmbedBuilderOnRemovedCallback = Future Function( + String imageUrl, +); + +typedef ImageEmbedBuilderProviderBuilder = ImageProvider? Function( + BuildContext context, + String imageUrl, +); + +typedef ImageEmbedBuilderErrorWidgetBuilder = ImageErrorWidgetBuilder; diff --git a/lib/Screens/myTemplates/src/editor/image/image_load_utils.dart b/lib/Screens/myTemplates/src/editor/image/image_load_utils.dart new file mode 100644 index 0000000..a30af95 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_load_utils.dart @@ -0,0 +1,36 @@ +import 'dart:async' show Completer; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +class ImageLoader { + static ImageLoader _instance = ImageLoader(); + + static ImageLoader get instance => _instance; + + /// Allows overriding the instance for testing + @visibleForTesting + static set instance(ImageLoader newInstance) => _instance = newInstance; + + // TODO(performance): This will load the image again. In case +// this is a network image, then this will be inefficient. + Future loadImageBytesFromImageProvider({ + required ImageProvider imageProvider, + }) async { + final stream = imageProvider.resolve(ImageConfiguration.empty); + final completer = Completer(); + + ImageStreamListener? listener; + listener = ImageStreamListener((info, _) { + completer.complete(info.image); + stream.removeListener(listener!); + }); + + stream.addListener(listener); + + final image = await completer.future; + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + return byteData?.buffer.asUint8List(); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/image_menu.dart b/lib/Screens/myTemplates/src/editor/image/image_menu.dart new file mode 100644 index 0000000..814f79f --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_menu.dart @@ -0,0 +1,246 @@ +import 'package:flutter/cupertino.dart' show showCupertinoModalPopup; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' + show ImageUrl, QuillController, StyleAttribute, getEmbedNode; +import 'package:flutter_quill/internal.dart'; +import 'package:path/path.dart' as p; +import 'package:url_launcher/url_launcher.dart'; + +import '../../common/utils/element_utils/element_utils.dart'; +import '../../common/utils/string.dart'; +import 'config/image_config.dart'; +import 'image_load_utils.dart'; +import 'image_save_utils.dart'; +import 'widgets/image.dart' show ImageTapWrapper, getImageStyleString; +import 'widgets/image_resizer.dart' show ImageResizer; + +class ImageOptionsMenu extends StatelessWidget { + const ImageOptionsMenu({ + required this.controller, + required this.config, + required this.imageSource, + required this.imageSize, + required this.readOnly, + required this.imageProvider, + this.prefersGallerySave = true, + super.key, + }); + + final QuillController controller; + final QuillEditorImageEmbedConfig config; + final String imageSource; + final ElementSize imageSize; + final bool readOnly; + final ImageProvider imageProvider; + + // TODO(quill_native_bridge): Update this doc comment once saveImageToGallery() + // is supported on Windows too (will be applicable like macOS). See https://pub.dev/packages/quill_native_bridge#-features + /// Determines if the image should be saved to the gallery instead of using the + /// system file save dialog for platforms that support both. + /// + /// Currently, the only platform where this applies is macOS. + /// + /// This is silently ignored on platforms that only support gallery save (Android and iOS) + /// or only image save. + /// + /// For more details, refer to [quill_native_bridge Saving images](https://pub.dev/packages/quill_native_bridge#-saving-images). + final bool prefersGallerySave; + + @override + Widget build(BuildContext context) { + final materialTheme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(50, 0, 50, 0), + child: SimpleDialog( + title: Text(context.loc.image), + children: [ + if (!readOnly) + ListTile( + title: Text(context.loc.resize), + leading: const Icon(Icons.settings_outlined), + onTap: () { + Navigator.pop(context); + showCupertinoModalPopup( + context: context, + builder: (modalContext) { + final screenSize = MediaQuery.sizeOf(modalContext); + return ImageResizer( + onImageResize: (width, height) { + final res = getEmbedNode( + controller, + controller.selection.start, + ); + + final attr = replaceStyleStringWithSize( + getImageStyleString(controller), + width: width, + height: height, + ); + controller + ..skipRequestKeyboard = true + ..formatText( + res.offset, + 1, + StyleAttribute(attr), + ); + }, + imageWidth: imageSize.width, + imageHeight: imageSize.height, + maxWidth: screenSize.width, + maxHeight: screenSize.height, + ); + }, + ); + }, + ), + ListTile( + leading: const Icon(Icons.copy_all_outlined), + title: Text(context.loc.copy), + onTap: () async { + Navigator.of(context).pop(); + controller.copiedImageUrl = ImageUrl( + imageSource, + getImageStyleString(controller), + ); + + final imageBytes = await ImageLoader.instance + .loadImageBytesFromImageProvider( + imageProvider: imageProvider); + if (imageBytes != null) { + await ClipboardServiceProvider.instance.copyImage(imageBytes); + } + }, + ), + if (!readOnly) + ListTile( + leading: Icon( + Icons.delete_forever_outlined, + color: materialTheme.colorScheme.error, + ), + title: Text(context.loc.remove), + onTap: () async { + Navigator.of(context).pop(); + + // Call the remove check callback if set + if (await config.shouldRemoveImageCallback?.call(imageSource) == + false) { + return; + } + + final offset = getEmbedNode( + controller, + controller.selection.start, + ).offset; + controller.replaceText( + offset, + 1, + '', + TextSelection.collapsed(offset: offset), + ); + // Call the post remove callback if set + await config.onImageRemovedCallback.call(imageSource); + }, + ), + ListTile( + leading: const Icon(Icons.save), + title: Text(context.loc.save), + onTap: () async { + final messenger = ScaffoldMessenger.of(context); + final localizations = context.loc; + Navigator.of(context).pop(); + + SaveImageResult? result; + try { + result = await ImageSaver.instance.saveImage( + imageUrl: imageSource, + imageProvider: imageProvider, + prefersGallerySave: prefersGallerySave, + ); + } on GalleryImageSaveAccessDeniedException { + messenger.showSnackBar(SnackBar( + content: Text( + localizations.saveImagePermissionDenied, + ))); + return; + } + + if (result == null) { + messenger.showSnackBar(SnackBar( + content: Text( + localizations.errorUnexpectedSavingImage, + ))); + return; + } + + if (kIsWeb) { + messenger.showSnackBar(SnackBar( + content: Text(localizations.successImageDownloaded))); + return; + } + + if (result.isGallerySave) { + messenger.showSnackBar(SnackBar( + content: Text(localizations.successImageSavedGallery), + action: SnackBarAction( + label: localizations.openGallery, + onPressed: () => + QuillNativeProvider.instance.openGalleryApp(), + ), + )); + return; + } + + if (isDesktopApp) { + final imageFilePath = result.imageFilePath; + if (imageFilePath == null) { + // User canceled the system save dialog. + return; + } + + messenger.showSnackBar( + SnackBar( + content: Text(localizations.successImageSaved), + // On macOS the app only has access to the picked file from the system save + // dialog and not the directory where it was saved. + // Opening the directory of that file requires entitlements on macOS + // See https://pub.dev/packages/url_launcher#macos-file-access-configuration + // Open the saved image file instead of the directory + action: defaultTargetPlatform == TargetPlatform.macOS + ? SnackBarAction( + label: localizations.openFile, + onPressed: () => launchUrl(Uri.file(imageFilePath)), + ) + : SnackBarAction( + label: localizations.openFileLocation, + onPressed: () => launchUrl( + Uri.directory(p.dirname(imageFilePath))), + ), + ), + ); + + return; + } + + throw StateError( + 'Image save result is not handled on $defaultTargetPlatform'); + }, + ), + ListTile( + leading: const Icon(Icons.zoom_in), + title: Text(context.loc.zoom), + onTap: () => Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => ImageTapWrapper( + imageUrl: imageSource, + config: config, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/image_save_utils.dart b/lib/Screens/myTemplates/src/editor/image/image_save_utils.dart new file mode 100644 index 0000000..5203c99 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_save_utils.dart @@ -0,0 +1,254 @@ +@internal +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_quill/internal.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; + +import 'image_load_utils.dart'; + +const defaultImageFileExtension = 'png'; + +// The [imageSourcePath] could be file, asset path or HTTP image URL. +String extractImageFileExtensionFromImageSource(String? imageSourcePath) { + if (imageSourcePath == null || imageSourcePath.isEmpty) { + return defaultImageFileExtension; + } + + if (!imageSourcePath.contains('.')) { + return defaultImageFileExtension; + } + + return p.extension(imageSourcePath).replaceFirst('.', ''); +} + +// The [imageSourcePath] could be file, asset path or HTTP image URL. +String? extractImageNameFromImageSource(String? imageSourcePath) { + if (imageSourcePath == null || imageSourcePath.isEmpty) { + return null; + } + final uri = Uri.parse(imageSourcePath); + final pathWithoutQuery = uri.path; + + final imageName = p.basenameWithoutExtension(pathWithoutQuery); + if (imageName.isEmpty) { + return null; + } + return imageName; +} + +class SaveImageResult { + const SaveImageResult({ + required this.imageFilePath, + required this.isGallerySave, + }); + + /// Returns `null` on web platforms, if [isGallerySave] is `true` + /// or in case the user cancels the save operation on desktop platforms. + final String? imageFilePath; + + final bool isGallerySave; + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other is! SaveImageResult) return false; + return other.imageFilePath == imageFilePath && + other.isGallerySave == isGallerySave; + } + + @override + int get hashCode => Object.hash(imageFilePath, isGallerySave); + + @override + String toString() => + 'SaveImageResult(imageFilePath: $imageFilePath, isGallerySave: $isGallerySave)'; +} + +const String defaultImageFileNamePrefix = 'IMG'; + +String getDefaultImageFileName({required bool isGallerySave}) { + if (kIsWeb) { + // The browser handles name conflicts. + return defaultImageFileNamePrefix; + } + if (isGallerySave) { + // The gallery app handles name conflicts. + return defaultImageFileNamePrefix; + } + if (defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows) { + // Windows and macOS system native save dialog prompts the user to confirm file overwrite. + return defaultImageFileNamePrefix; + } + final uniqueFileName = + '${defaultImageFileNamePrefix}_${DateTime.now().toIso8601String()}'; + if (defaultTargetPlatform == TargetPlatform.linux) { + // IMPORTANT: On Linux, it depends on the desktop environment + // and name conflicts may not be handled. Always provide a unique image file name. + return uniqueFileName; + } + + return uniqueFileName; +} + +Future shouldSaveToGallery({required bool prefersGallerySave}) async { + final supportsGallerySave = await QuillNativeProvider.instance + .isSupported(QuillNativeBridgeFeature.saveImageToGallery); + if (!supportsGallerySave) { + return false; + } + final supportsImageSave = await QuillNativeProvider.instance + .isSupported(QuillNativeBridgeFeature.saveImage); + if (!supportsImageSave) { + return true; + } + + return supportsGallerySave && prefersGallerySave; +} + +/// Thrown when the gallery image save operation is denied +/// due to insufficient or denied permissions. +class GalleryImageSaveAccessDeniedException implements Exception { + GalleryImageSaveAccessDeniedException([this.message]); + + final String? message; + + @override + String toString() => + message ?? + 'Permission to save the image to the gallery was denied or insufficient.'; +} + +class ImageSaver { + ImageSaver._(); + + static ImageSaver _instance = ImageSaver._(); + + static ImageSaver get instance => _instance; + + /// Allows overriding the instance for testing + @visibleForTesting + static set instance(ImageSaver newInstance) => _instance = newInstance; + + /// Saves an image to the user's device based on the platform: + /// + /// - **Web**: Downloads the image using the browser's download functionality. + /// - **Desktop**: Prompts the user to choose a location for the image using + /// native save dialog, defaulting to the user's `Pictures` directory. Or + /// saves the image to the gallery in case [prefersGallerySave] is `true` and + // TODO(quill_native_bridge): Update this doc comment once saveImageToGallery() + // is supported on Windows too (will be applicable like macOS). See https://pub.dev/packages/quill_native_bridge#-features + /// the gallery is supported (currently only macOS is applicable). + /// - **Mobile**: Saves the image to the gallery, requesting permission if needed. + /// + /// The [imageUrl] could be file or network image URL and is used to extract + /// image file extension and the image name. + /// + /// The [imageProvider] is used to load the image bytes from using [ImageLoader]. + /// + /// Returns `null` on failure. + /// + /// Throws [GalleryImageSaveAccessDeniedException] in case permission was denied or insuffeicnet. + Future saveImage({ + required String imageUrl, + required ImageProvider imageProvider, + required bool prefersGallerySave, + }) async { + assert(() { + if (imageUrl.isEmpty) { + throw ArgumentError.value(imageUrl, 'imageUrl', 'cannot be empty'); + } + return true; + }()); + + final imageFileExtension = + extractImageFileExtensionFromImageSource(imageUrl); + final imageName = extractImageNameFromImageSource(imageUrl); + + final imageBytes = await ImageLoader.instance + .loadImageBytesFromImageProvider(imageProvider: imageProvider); + if (imageBytes == null || imageBytes.isEmpty) { + return null; + } + + if (kIsWeb) { + await QuillNativeProvider.instance.saveImage( + imageBytes, + options: ImageSaveOptions( + name: imageName ?? getDefaultImageFileName(isGallerySave: false), + fileExtension: imageFileExtension), + ); + return const SaveImageResult( + imageFilePath: null, + isGallerySave: false, + ); + } + + if (await shouldSaveToGallery(prefersGallerySave: prefersGallerySave)) { + try { + await QuillNativeProvider.instance.saveImageToGallery( + imageBytes, + options: GalleryImageSaveOptions( + name: imageName ?? getDefaultImageFileName(isGallerySave: true), + fileExtension: imageFileExtension, + // Specifying the album name requires read-write permission + // on iOS and macOS on all versions. Pass null to request add-only on + // supported versions (previous versions still use read-write). + albumName: null, + ), + ); + + return const SaveImageResult( + imageFilePath: null, + isGallerySave: true, + ); + } on PlatformException catch (e) { + // TODO(save-image): Part of https://github.com/FlutterQuill/quill-native-bridge/issues/2 + + // Permission request is required only on iOS, macOS and Android API 28 and earlier. + if (e.code == 'PERMISSION_DENIED') { + // macOS imposes security restrictions when running the app + // on sources other than Xcode or the macOS terminal, such as Android Studio or VS Code. + // This is not an issue in production. Throwing [GalleryImageSaveAccessDeniedException] will indicate + // that the user denied the permission, even though it will always deny the permission even if granted. + // Make sure we don't handle that error (it has details) during development to avoid confusion. + // For more details, see https://github.com/flutter/flutter/issues/134191#issuecomment-2506248266 + // and https://pub.dev/packages/quill_native_bridge#-saving-images-to-the-gallery + + final possiblePermissionIssueDuringDevelopmentOnMacOS = + kDebugMode && defaultTargetPlatform == TargetPlatform.macOS; + if (possiblePermissionIssueDuringDevelopmentOnMacOS) { + rethrow; + } + + throw GalleryImageSaveAccessDeniedException(e.toString()); + } + rethrow; + } + } + + if (await QuillNativeProvider.instance + .isSupported(QuillNativeBridgeFeature.saveImage)) { + assert(!isMobileApp, + 'Mobile platforms support saving images to the gallery only'); + + final result = await QuillNativeProvider.instance.saveImage( + imageBytes, + options: ImageSaveOptions( + name: imageName ?? getDefaultImageFileName(isGallerySave: false), + fileExtension: imageFileExtension, + ), + ); + return SaveImageResult( + imageFilePath: result.filePath, + isGallerySave: false, + ); + } + + throw StateError('Image save is not handled on $defaultTargetPlatform'); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/image_web_embed.dart b/lib/Screens/myTemplates/src/editor/image/image_web_embed.dart new file mode 100644 index 0000000..1b21022 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/image_web_embed.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/widgets.dart'; +import 'package:flutter_quill/flutter_quill.dart'; + +import '../../common/utils/element_utils/element_web_utils.dart'; +import '../../common/utils/utils.dart'; +import '../../common/utils/web/web.dart'; +import 'config/image_web_config.dart'; + +class QuillEditorWebImageEmbedBuilder extends EmbedBuilder { + const QuillEditorWebImageEmbedBuilder({ + required this.config, + }); + + final QuillEditorWebImageEmbedConfig config; + + @override + String get key => BlockEmbed.imageType; + + @override + bool get expanded => false; + + @override + Widget build( + BuildContext context, + EmbedContext embedContext, + ) { + assert(kIsWeb, 'ImageEmbedBuilderWeb is only for web platform'); + + final (height, width, margin, alignment) = + getWebElementAttributes(embedContext.node); + + var imageSource = embedContext.node.value.data.toString(); + + // This logic make sure if the image is imageBase64 then + // it make sure if the pattern is like + // data:image/png;base64, [base64 encoded image string here] + // if not then it will add the data:image/png;base64, at the first + if (isImageBase64(imageSource)) { + // Sometimes the image base 64 for some reasons + // doesn't displayed with the 'data:image/png;base64' + if (!(imageSource.startsWith('data:image/') && + imageSource.contains('base64'))) { + imageSource = 'data:image/png;base64, $imageSource'; + } + } + + createHtmlImageElement( + src: imageSource, + alignSelf: alignment, + width: width, + height: height, + margin: margin, + ); + + return ConstrainedBox( + constraints: + config.constraints ?? BoxConstraints.loose(const Size(200, 200)), + child: HtmlElementView( + viewType: imageSource, + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/widgets/image.dart b/lib/Screens/myTemplates/src/editor/image/widgets/image.dart new file mode 100644 index 0000000..a1b0dc0 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/widgets/image.dart @@ -0,0 +1,186 @@ +import 'dart:convert' show base64; +import 'dart:io' show File; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:photo_view/photo_view.dart'; + +import '../../../common/utils/utils.dart'; +import '../config/image_config.dart'; +import '../image_embed_types.dart'; + +String getImageStyleString(QuillController controller) { + final String? s = controller + .getAllSelectionStyles() + .firstWhere((s) => s.attributes.containsKey(Attribute.style.key), + orElse: Style.new) + .attributes[Attribute.style.key] + ?.value; + return s ?? ''; +} + +/// [imageProviderBuilder] To override the return value pass value to it +/// [imageSource] The source of the image in the quill delta json document +/// It could be http, file, network, asset, or base 64 image +ImageProvider getImageProviderByImageSource( + String imageSource, { + required ImageEmbedBuilderProviderBuilder? imageProviderBuilder, + required BuildContext context, +}) { + if (imageProviderBuilder != null) { + final imageProvider = imageProviderBuilder(context, imageSource); + if (imageProvider != null) { + return imageProvider; + } + } + + if (isImageBase64(imageSource)) { + return MemoryImage(base64.decode(imageSource)); + } + + if (isHttpUrl(imageSource)) { + return NetworkImage(imageSource); + } + + // File image + if (kIsWeb) { + return NetworkImage(imageSource); + } + return FileImage(File(imageSource)); +} + +Image getImageWidgetByImageSource( + String imageSource, { + required BuildContext context, + required ImageEmbedBuilderProviderBuilder? imageProviderBuilder, + required ImageErrorWidgetBuilder? imageErrorWidgetBuilder, + double? width, + double? height, + AlignmentGeometry alignment = Alignment.center, +}) { + return Image( + image: getImageProviderByImageSource( + context: context, + imageSource, + imageProviderBuilder: imageProviderBuilder, + ), + width: width, + height: height, + alignment: alignment, + errorBuilder: imageErrorWidgetBuilder, + ); +} + +String standardizeImageUrl(String url) { + if (url.contains('base64')) { + return url.split(',')[1]; + } + return url; +} + +const List _imageFileExtensions = [ + '.jpeg', + '.png', + '.jpg', + '.gif', + '.webp', + '.tif', + '.heic' +]; + +/// This is a bug of Gallery Saver Package. +/// It can not save image that's filename does not end with it's file extension +/// like below. +// "https://firebasestorage.googleapis.com/v0/b/eventat-4ba96.appspot.com/o/2019-Metrology-Events.jpg?alt=media&token=bfc47032-5173-4b3f-86bb-9659f46b362a" +/// If imageUrl does not end with it's file extension, +/// file extension is added to image url for saving. +String appendFileExtensionToImageUrl(String url) { + final endsWithImageFileExtension = _imageFileExtensions + .firstWhere((s) => url.toLowerCase().endsWith(s), orElse: () => ''); + if (endsWithImageFileExtension.isNotEmpty) { + return url; + } + + final imageFileExtension = _imageFileExtensions + .firstWhere((s) => url.toLowerCase().contains(s), orElse: () => ''); + + return url + imageFileExtension; +} + +class ImageTapWrapper extends StatelessWidget { + const ImageTapWrapper({ + required this.imageUrl, + required this.config, + super.key, + }); + + final String imageUrl; + final QuillEditorImageEmbedConfig config; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + constraints: BoxConstraints.expand( + height: MediaQuery.sizeOf(context).height, + ), + child: Stack( + children: [ + PhotoView( + imageProvider: getImageProviderByImageSource( + context: context, + imageUrl, + imageProviderBuilder: config.imageProviderBuilder, + ), + errorBuilder: config.imageErrorWidgetBuilder, + loadingBuilder: (context, event) { + return Container( + color: Colors.black, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + }, + ), + Positioned( + right: 10, + top: MediaQuery.paddingOf(context).top + 10.0, + child: InkWell( + onTap: () { + Navigator.pop(context); + }, + child: Stack( + children: [ + Opacity( + opacity: 0.2, + child: Container( + height: 30, + width: 30, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.black87, + ), + ), + ), + Positioned( + top: 0, + bottom: 0, + left: 0, + right: 0, + child: Icon( + Icons.close, + color: Colors.grey[400], + size: 28, + ), + ) + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/image/widgets/image_resizer.dart b/lib/Screens/myTemplates/src/editor/image/widgets/image_resizer.dart new file mode 100644 index 0000000..5f481f5 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/image/widgets/image_resizer.dart @@ -0,0 +1,126 @@ +import 'package:flutter/cupertino.dart' + show CupertinoActionSheet, CupertinoActionSheetAction; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart' show SchedulerBinding; +import 'package:flutter_quill/internal.dart'; + +class ImageResizer extends StatefulWidget { + const ImageResizer({ + required this.imageWidth, + required this.imageHeight, + required this.maxWidth, + required this.maxHeight, + required this.onImageResize, + super.key, + }); + + final double? imageWidth; + final double? imageHeight; + final double maxWidth; + final double maxHeight; + final Function(double width, double height) onImageResize; + + @override + ImageResizerState createState() => ImageResizerState(); +} + +class ImageResizerState extends State { + late double _width; + late double _height; + + @override + void initState() { + super.initState(); + _width = widget.imageWidth ?? widget.maxWidth; + _height = widget.imageHeight ?? widget.maxHeight; + } + + @override + Widget build(BuildContext context) { + if (Theme.of(context).isCupertino) { + return _showCupertinoMenu(); + } + return _showMaterialMenu(); + } + + Widget _showMaterialMenu() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _widthSlider(), + _heightSlider(), + ], + ); + } + + Widget _showCupertinoMenu() { + return CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () {}, + child: _widthSlider(), + ), + CupertinoActionSheetAction( + onPressed: () {}, + child: _heightSlider(), + ) + ], + ); + } + + Widget _slider({ + required bool isWidth, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + child: Slider.adaptive( + value: isWidth ? _width : _height, + max: isWidth ? widget.maxWidth : widget.maxHeight, + divisions: 1000, + // Might need to be changed + label: isWidth ? context.loc.width : context.loc.height, + onChanged: (val) { + setState(() { + onChanged(val); + _resizeImage(); + }); + }, + ), + ), + ); + } + + Widget _heightSlider() { + return _slider( + isWidth: false, + onChanged: (value) { + _height = value; + }, + ); + } + + Widget _widthSlider() { + return _slider( + isWidth: true, + onChanged: (value) { + _width = value; + }, + ); + } + + bool _scheduled = false; + + void _resizeImage() { + if (_scheduled) { + return; + } + + _scheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + widget.onImageResize(_width, _height); + _scheduled = false; + }); + } +} diff --git a/lib/Screens/myTemplates/src/editor/video/config/video_config.dart b/lib/Screens/myTemplates/src/editor/video/config/video_config.dart new file mode 100644 index 0000000..876efec --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/config/video_config.dart @@ -0,0 +1,46 @@ +import 'package:flutter/widgets.dart' show GlobalKey, Widget; +import 'package:meta/meta.dart' show experimental, immutable; + +@immutable +class QuillEditorVideoEmbedConfig { + const QuillEditorVideoEmbedConfig({ + this.onVideoInit, + this.customVideoBuilder, + }); + + /// [onVideoInit] is a callback function that gets triggered when + /// a video is initialized. + /// You can use this to perform actions or setup configurations related + /// to video embedding. + /// + /// + /// Example usage: + /// ```dart + /// onVideoInit: (videoContainerKey) { + /// // Custom video initialization logic + /// }, + /// // Customize other callback functions as needed + /// ``` + final void Function(GlobalKey videoContainerKey)? onVideoInit; + + /// [customVideoBuilder] is a callback function that receives the + /// video URL and a read-only flag. This allows users to define + /// their own logic for rendering video widgets, enabling support + /// for various video platforms, such as YouTube. + /// + /// Example usage: + /// ```dart + /// customVideoBuilder: (videoUrl, readOnly) { + /// // Return `null` to fallback to defualt logic of QuillEditorVideoEmbedBuilder + /// + /// // Return a custom video widget based on the videoUrl + /// return CustomVideoWidget(videoUrl: videoUrl, readOnly: readOnly); + /// }, + /// ``` + /// + /// It's a quick solution as response to https://github.com/singerdmx/flutter-quill/issues/2284 + /// + /// **Might be removed or changed in future releases.** + @experimental + final Widget? Function(String videoUrl, bool readOnly)? customVideoBuilder; +} diff --git a/lib/Screens/myTemplates/src/editor/video/config/video_web_config.dart b/lib/Screens/myTemplates/src/editor/video/config/video_web_config.dart new file mode 100644 index 0000000..0eb6e14 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/config/video_web_config.dart @@ -0,0 +1,6 @@ +import 'package:meta/meta.dart' show immutable; + +@immutable +class QuillEditorWebVideoEmbedConfig { + const QuillEditorWebVideoEmbedConfig(); +} diff --git a/lib/Screens/myTemplates/src/editor/video/video_embed.dart b/lib/Screens/myTemplates/src/editor/video/video_embed.dart new file mode 100644 index 0000000..bc55b23 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/video_embed.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; + +import '../../common/utils/element_utils/element_utils.dart'; +import 'config/video_config.dart'; +import 'widgets/video_app.dart'; + +class QuillEditorVideoEmbedBuilder extends EmbedBuilder { + const QuillEditorVideoEmbedBuilder({ + required this.config, + }); + + final QuillEditorVideoEmbedConfig config; + + @override + String get key => BlockEmbed.videoType; + + @override + bool get expanded => false; + + @override + Widget build( + BuildContext context, + EmbedContext embedContext, + ) { + final videoUrl = embedContext.node.value.data; + + final customVideoBuilder = config.customVideoBuilder; + if (customVideoBuilder != null) { + final videoWidget = customVideoBuilder(videoUrl, embedContext.readOnly); + if (videoWidget != null) { + return videoWidget; + } + } + + final ((elementSize), margin, alignment) = getElementAttributes( + embedContext.node, + context, + ); + + final width = elementSize.width; + final height = elementSize.height; + return Container( + width: width, + height: height, + margin: EdgeInsets.all(margin ?? 0.0), + alignment: alignment, + child: VideoApp( + videoUrl: videoUrl, + readOnly: embedContext.readOnly, + onVideoInit: config.onVideoInit, + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/video/video_web_embed.dart b/lib/Screens/myTemplates/src/editor/video/video_web_embed.dart new file mode 100644 index 0000000..f38ceae --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/video_web_embed.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_quill/flutter_quill.dart'; + +import '../../common/utils/element_utils/element_web_utils.dart'; +import '../../common/utils/utils.dart'; +import '../../common/utils/web/web.dart'; +import 'config/video_web_config.dart'; +import 'youtube_video_url.dart'; + +class QuillEditorWebVideoEmbedBuilder extends EmbedBuilder { + const QuillEditorWebVideoEmbedBuilder({ + required this.config, + }); + + final QuillEditorWebVideoEmbedConfig config; + + @override + String get key => BlockEmbed.videoType; + + @override + bool get expanded => false; + + @override + Widget build( + BuildContext context, + EmbedContext embedContext, + ) { + var videoUrl = embedContext.node.value.data; + if (isYouTubeUrl(videoUrl)) { + // ignore: deprecated_member_use_from_same_package + final youtubeID = convertVideoUrlToId(videoUrl); + if (youtubeID != null) { + videoUrl = 'https://www.youtube.com/embed/$youtubeID'; + } + } + + final (height, width, margin, alignment) = + getWebElementAttributes(embedContext.node); + + createHtmlIFrameElement( + src: videoUrl, + width: width, + height: height, + margin: margin, + alignSelf: alignment, + ); + + return SizedBox( + height: 500, + child: HtmlElementView( + viewType: videoUrl, + ), + ); + } +} diff --git a/lib/Screens/myTemplates/src/editor/video/widgets/video_app.dart b/lib/Screens/myTemplates/src/editor/video/widgets/video_app.dart new file mode 100644 index 0000000..6e86cb4 --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/widgets/video_app.dart @@ -0,0 +1,122 @@ +import 'dart:io' show File; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../common/utils/utils.dart'; + +/// Widget for playing back video +/// Refer to https://github.com/flutter/plugins/tree/master/packages/video_player/video_player +class VideoApp extends StatefulWidget { + const VideoApp({ + required this.videoUrl, + required this.readOnly, + super.key, + this.onVideoInit, + }); + + final String videoUrl; + final bool readOnly; + final void Function(GlobalKey videoContainerKey)? onVideoInit; + + @override + VideoAppState createState() => VideoAppState(); +} + +class VideoAppState extends State { + late VideoPlayerController _controller; + GlobalKey videoContainerKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + _controller = isHttpUrl(widget.videoUrl) + ? VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)) + : VideoPlayerController.file(File(widget.videoUrl)) + ..initialize().then((_) { + // Ensure the first frame is shown after the video is initialized, + // even before the play button has been pressed. + setState(() {}); + if (widget.onVideoInit != null) { + widget.onVideoInit?.call(videoContainerKey); + } + }).catchError((error) { + setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + final defaultStyles = DefaultStyles.getInstance(context); + if (_controller.value.hasError) { + if (widget.readOnly) { + return RichText( + text: TextSpan( + text: widget.videoUrl, + style: defaultStyles.link, + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl( + Uri.parse(widget.videoUrl), + ), + ), + ); + } + + return RichText( + text: TextSpan( + text: widget.videoUrl, + style: defaultStyles.link, + ), + ); + } else if (!_controller.value.isInitialized) { + return VideoProgressIndicator( + _controller, + allowScrubbing: true, + colors: const VideoProgressColors(playedColor: Colors.blue), + ); + } + + return Container( + key: videoContainerKey, + child: InkWell( + onTap: () { + setState(() { + _controller.value.isPlaying + ? _controller.pause() + : _controller.play(); + }); + }, + child: Stack( + alignment: Alignment.center, + children: [ + Center( + child: AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + )), + _controller.value.isPlaying + ? const SizedBox.shrink() + : Container( + color: const Color(0xfff5f5f5), + child: const Icon( + Icons.play_arrow, + size: 60, + color: Colors.blueGrey, + ), + ) + ], + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/Screens/myTemplates/src/editor/video/youtube_video_url.dart b/lib/Screens/myTemplates/src/editor/video/youtube_video_url.dart new file mode 100644 index 0000000..379f9fd --- /dev/null +++ b/lib/Screens/myTemplates/src/editor/video/youtube_video_url.dart @@ -0,0 +1,32 @@ +import 'package:meta/meta.dart'; + +/// Function copied from https://github.com/sarbagyastha/youtube_player_flutter/blob/f8e1e79991066bcc70f0a7c93941ca0d54b7370e/packages/youtube_player_flutter/lib/src/player/youtube_player.dart#L154 +/// and is not written as part of this project. +/// +/// Used as quick response for https://github.com/singerdmx/flutter-quill/issues/2284 +@experimental +@internal +@Deprecated( + 'Will be removed in future releases, for now included as quick response to https://github.com/singerdmx/flutter-quill/issues/2284', +) +String? convertVideoUrlToId(String url, {bool trimWhitespaces = true}) { + if (!url.contains('http') && (url.length == 11)) return url; + if (trimWhitespaces) url = url.trim(); + + for (final exp in [ + RegExp( + r'^https:\/\/(?:www\.|m\.)?youtube\.com\/watch\?v=([_\-a-zA-Z0-9]{11}).*$'), + RegExp( + r'^https:\/\/(?:music\.)?youtube\.com\/watch\?v=([_\-a-zA-Z0-9]{11}).*$'), + RegExp( + r'^https:\/\/(?:www\.|m\.)?youtube\.com\/shorts\/([_\-a-zA-Z0-9]{11}).*$'), + RegExp( + r'^https:\/\/(?:www\.|m\.)?youtube(?:-nocookie)?\.com\/embed\/([_\-a-zA-Z0-9]{11}).*$'), + RegExp(r'^https:\/\/youtu\.be\/([_\-a-zA-Z0-9]{11}).*$') + ]) { + final Match? match = exp.firstMatch(url); + if (match != null && match.groupCount >= 1) return match.group(1); + } + + return null; +} diff --git a/lib/Screens/myTemplates/src/flutter_quill_embeds.dart b/lib/Screens/myTemplates/src/flutter_quill_embeds.dart new file mode 100644 index 0000000..bd46aa7 --- /dev/null +++ b/lib/Screens/myTemplates/src/flutter_quill_embeds.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_quill/flutter_quill.dart'; + +import 'editor/image/config/image_config.dart'; +import 'editor/image/image_embed.dart'; +import 'editor/video/config/video_config.dart'; +import 'editor/video/config/video_web_config.dart'; +import 'editor/video/video_embed.dart'; +import 'editor/video/video_web_embed.dart'; +import 'toolbar/camera/camera_button.dart'; +import 'toolbar/camera/config/camera_config.dart'; +import 'toolbar/image/config/image_config.dart'; +import 'toolbar/image/image_button.dart'; +import 'toolbar/video/config/video_config.dart'; +import 'toolbar/video/video_button.dart'; + +abstract final class FlutterQuillEmbeds { + /// Returns a list of embed builders for [QuillEditor] + /// to provide basic support for loading images and videos. + /// + static List editorBuilders({ + QuillEditorImageEmbedConfig? imageEmbedConfig = + const QuillEditorImageEmbedConfig(), + QuillEditorVideoEmbedConfig? videoEmbedConfig = + const QuillEditorVideoEmbedConfig(), + }) { + return [ + if (imageEmbedConfig != null) + QuillEditorImageEmbedBuilder( + config: imageEmbedConfig, + ), + if (videoEmbedConfig != null) + QuillEditorVideoEmbedBuilder( + config: videoEmbedConfig, + ), + ]; + } + + /// Returns a list of embed builders specifically designed for web support + /// to load images and videos. + /// + static List editorWebBuilders({ + QuillEditorImageEmbedConfig? imageEmbedConfig = + const QuillEditorImageEmbedConfig(), + QuillEditorWebVideoEmbedConfig? videoEmbedConfig = + const QuillEditorWebVideoEmbedConfig(), + }) { + if (!kIsWeb) { + throw UnsupportedError( + 'The ${FlutterQuillEmbeds.editorWebBuilders} is for web, use ${FlutterQuillEmbeds.editorBuilders} ' + 'instead for non-web platforms', + ); + } + return [ + if (imageEmbedConfig != null) + QuillEditorImageEmbedBuilder( + config: imageEmbedConfig, + ), + if (videoEmbedConfig != null) + QuillEditorWebVideoEmbedBuilder( + config: videoEmbedConfig, + ), + ]; + } + + /// Returns a list of embed builders for [QuillEditor]. + /// + /// It will use [editorWebBuilders] for web and [editorBuilders] for non-web platforms. + static List defaultEditorBuilders() { + return kIsWeb ? editorWebBuilders() : editorBuilders(); + } + + /// Returns a list of embed button builders to support images and videos. + /// + /// Pass `null` to options of a button to not show it. + static List toolbarButtons({ + QuillToolbarImageButtonOptions? imageButtonOptions = + const QuillToolbarImageButtonOptions(), + QuillToolbarVideoButtonOptions? videoButtonOptions = + const QuillToolbarVideoButtonOptions(), + QuillToolbarCameraButtonOptions? cameraButtonOptions, + }) => + [ + if (imageButtonOptions != null) + (context, embedContext) => QuillToolbarImageButton( + controller: embedContext.controller, + options: imageButtonOptions, + // ignore: invalid_use_of_internal_member + baseOptions: embedContext.baseButtonOptions, + ), + if (videoButtonOptions != null) + (context, embedContext) => QuillToolbarVideoButton( + controller: embedContext.controller, + options: videoButtonOptions, + // ignore: invalid_use_of_internal_member + baseOptions: embedContext.baseButtonOptions, + ), + if (cameraButtonOptions != null) + (context, embedContext) => QuillToolbarCameraButton( + controller: embedContext.controller, + options: cameraButtonOptions, + // ignore: invalid_use_of_internal_member + baseOptions: embedContext.baseButtonOptions, + ), + ]; +} diff --git a/lib/Screens/myTemplates/src/toolbar/camera/camera_button.dart b/lib/Screens/myTemplates/src/toolbar/camera/camera_button.dart new file mode 100644 index 0000000..e9b18ab --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/camera/camera_button.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill/internal.dart'; + +import 'package:image_picker/image_picker.dart'; +import '../../common/default_image_insert.dart'; +import '../../common/default_video_insert.dart'; +import '../quill_simple_toolbar_api.dart'; +import 'camera_types.dart'; +import 'config/camera_config.dart'; +import 'select_camera_action.dart'; + +// ignore: invalid_use_of_internal_member +class QuillToolbarCameraButton extends QuillToolbarBaseButtonStateless { + const QuillToolbarCameraButton({ + required super.controller, + QuillToolbarCameraButtonOptions? options, + + /// Shares common options between all buttons, prefer the [options] + /// over the [baseOptions]. + super.baseOptions, + super.key, + }) : _options = options, + super(options: options); + + final QuillToolbarCameraButtonOptions? _options; + + @override + QuillToolbarCameraButtonOptions? get options => _options; + + void _sharedOnPressed(BuildContext context) { + _onPressedHandler( + context, + controller, + ); + afterButtonPressed(context); + } + + Future _getCameraAction(BuildContext context) async { + final customCallback = options?.cameraConfig?.onRequestCameraActionCallback; + if (customCallback != null) { + return await customCallback(context); + } + final cameraAction = await showSelectCameraActionDialog( + context: context, + ); + + return cameraAction; + } + + Future _onPressedHandler( + BuildContext context, + QuillController controller, + ) async { + final cameraAction = await _getCameraAction(context); + + if (cameraAction == null) { + return; + } + + switch (cameraAction) { + case CameraAction.video: + final videoFile = + await ImagePicker().pickVideo(source: ImageSource.camera); + if (videoFile == null) { + return; + } + await handleVideoInsert( + videoFile.path, + controller: controller, + onVideoInsertCallback: options?.cameraConfig?.onVideoInsertCallback, + onVideoInsertedCallback: + options?.cameraConfig?.onVideoInsertedCallback, + ); + case CameraAction.image: + final imageFile = + await ImagePicker().pickImage(source: ImageSource.camera); + if (imageFile == null) { + return; + } + await handleImageInsert( + imageFile.path, + controller: controller, + onImageInsertCallback: options?.cameraConfig?.onImageInsertCallback, + onImageInsertedCallback: + options?.cameraConfig?.onImageInsertedCallback, + ); + } + } + + @override + Widget buildButton(BuildContext context) { + return QuillToolbarIconButton( + icon: Icon( + iconData(context), + size: iconButtonFactor(context) * iconSize(context), + ), + tooltip: tooltip(context), + isSelected: false, + onPressed: () => _sharedOnPressed(context), + iconTheme: iconTheme(context), + ); + } + + @override + Widget? buildCustomChildBuilder(BuildContext context) { + return childBuilder?.call( + QuillToolbarCameraButtonOptions( + afterButtonPressed: afterButtonPressed(context), + iconData: iconData(context), + iconSize: iconSize(context), + iconButtonFactor: iconButtonFactor(context), + iconTheme: options?.iconTheme, + tooltip: tooltip(context), + cameraConfig: options?.cameraConfig, + ), + QuillToolbarCameraButtonExtraOptions( + controller: controller, + context: context, + onPressed: () => _sharedOnPressed(context), + ), + ); + } + + @override + IconData Function(BuildContext context) get getDefaultIconData => + (context) => Icons.photo_camera; + + @override + String Function(BuildContext context) get getDefaultTooltip => + (context) => context.loc.camera; +} diff --git a/lib/Screens/myTemplates/src/toolbar/camera/camera_types.dart b/lib/Screens/myTemplates/src/toolbar/camera/camera_types.dart new file mode 100644 index 0000000..04822fd --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/camera/camera_types.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart' show BuildContext; +import 'package:meta/meta.dart' show immutable; + +import '../../editor/image/image_embed_types.dart'; +import '../video/config/video.dart'; + +enum CameraAction { + video, + image, +} + +/// When the user click the camera button, should we take a photo or record +/// a video using the camera +/// +/// by default will show a dialog that ask the user which option he/she wants +typedef OnRequestCameraActionCallback = Future Function( + BuildContext context, +); + +@immutable +class QuillToolbarCameraConfig { + const QuillToolbarCameraConfig({ + this.onRequestCameraActionCallback, + this.onImageInsertCallback, + this.onImageInsertedCallback, + this.onVideoInsertedCallback, + this.onVideoInsertCallback, + }); + + final OnRequestCameraActionCallback? onRequestCameraActionCallback; + + final OnImageInsertedCallback? onImageInsertedCallback; + + final OnImageInsertCallback? onImageInsertCallback; + + final OnVideoInsertedCallback? onVideoInsertedCallback; + + final OnVideoInsertCallback? onVideoInsertCallback; +} diff --git a/lib/Screens/myTemplates/src/toolbar/camera/config/camera_config.dart b/lib/Screens/myTemplates/src/toolbar/camera/config/camera_config.dart new file mode 100644 index 0000000..e498446 --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/camera/config/camera_config.dart @@ -0,0 +1,28 @@ +import 'package:flutter_quill/flutter_quill.dart'; + +import '../camera_types.dart'; + +class QuillToolbarCameraButtonExtraOptions + extends QuillToolbarBaseButtonExtraOptions { + const QuillToolbarCameraButtonExtraOptions({ + required super.controller, + required super.context, + required super.onPressed, + }); +} + +class QuillToolbarCameraButtonOptions extends QuillToolbarBaseButtonOptions< + QuillToolbarCameraButtonOptions, QuillToolbarCameraButtonExtraOptions> { + const QuillToolbarCameraButtonOptions({ + this.cameraConfig, + super.iconSize, + super.iconButtonFactor, + super.iconData, + super.afterButtonPressed, + super.tooltip, + super.iconTheme, + super.childBuilder, + }); + + final QuillToolbarCameraConfig? cameraConfig; +} diff --git a/lib/Screens/myTemplates/src/toolbar/camera/select_camera_action.dart b/lib/Screens/myTemplates/src/toolbar/camera/select_camera_action.dart new file mode 100644 index 0000000..2c0c3f8 --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/camera/select_camera_action.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/internal.dart'; + +import 'camera_types.dart'; + +class SelectCameraActionDialog extends StatelessWidget { + const SelectCameraActionDialog({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 150, + width: double.infinity, + child: SingleChildScrollView( + child: Column( + children: [ + ListTile( + title: Text(context.loc.photo), + subtitle: Text( + context.loc.takeAPhotoUsingYourCamera, + ), + leading: const Icon(Icons.photo_sharp), + enabled: !isDesktopApp, + onTap: () => Navigator.of(context).pop(CameraAction.image), + ), + ListTile( + title: Text(context.loc.video), + subtitle: Text( + context.loc.recordAVideoUsingYourCamera, + ), + leading: const Icon(Icons.camera), + enabled: !isDesktopApp, + onTap: () => Navigator.of(context).pop(CameraAction.video), + ), + ], + ), + ), + ); + } +} + +Future showSelectCameraActionDialog({ + required BuildContext context, +}) async { + final imageSource = await showModalBottomSheet( + showDragHandle: true, + context: context, + constraints: const BoxConstraints(maxWidth: 640), + builder: (context) => const SelectCameraActionDialog(), + ); + return imageSource; +} diff --git a/lib/Screens/myTemplates/src/toolbar/image/config/image_config.dart b/lib/Screens/myTemplates/src/toolbar/image/config/image_config.dart new file mode 100644 index 0000000..06b3103 --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/image/config/image_config.dart @@ -0,0 +1,39 @@ +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:meta/meta.dart' show immutable; + +import '../../../editor/image/image_embed_types.dart'; + +class QuillToolbarImageButtonExtraOptions + extends QuillToolbarBaseButtonExtraOptions { + const QuillToolbarImageButtonExtraOptions({ + required super.controller, + required super.context, + required super.onPressed, + }); +} + +@immutable +class QuillToolbarImageButtonOptions extends QuillToolbarBaseButtonOptions< + QuillToolbarImageButtonOptions, QuillToolbarImageButtonExtraOptions> { + const QuillToolbarImageButtonOptions({ + super.iconData, + super.iconSize, + super.iconButtonFactor, + + /// specifies the tooltip text for the image button. + super.tooltip, + super.afterButtonPressed, + super.childBuilder, + super.iconTheme, + this.dialogTheme, + this.linkRegExp, + this.imageButtonConfig = const QuillToolbarImageConfig(), + }); + + final QuillDialogTheme? dialogTheme; + + /// [imageLinkRegExp] is a regular expression to identify image links. + final RegExp? linkRegExp; + + final QuillToolbarImageConfig? imageButtonConfig; +} diff --git a/lib/Screens/myTemplates/src/toolbar/image/image_button.dart b/lib/Screens/myTemplates/src/toolbar/image/image_button.dart new file mode 100644 index 0000000..65a6510 --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/image/image_button.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill/internal.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../common/default_image_insert.dart'; +import '../../common/image_video_utils.dart'; +import '../../editor/image/image_embed_types.dart'; +import '../quill_simple_toolbar_api.dart'; +import 'config/image_config.dart'; +import 'select_image_source.dart'; + +// ignore: invalid_use_of_internal_member +class QuillToolbarImageButton extends QuillToolbarBaseButtonStateless { + const QuillToolbarImageButton({ + required super.controller, + QuillToolbarImageButtonOptions? options, + + /// Shares common options between all buttons, prefer the [options] + /// over the [baseOptions]. + super.baseOptions, + super.key, + }) : _options = options, + super(options: options); + + final QuillToolbarImageButtonOptions? _options; + + @override + QuillToolbarImageButtonOptions? get options => _options; + + void _sharedOnPressed(BuildContext context) { + _onPressedHandler(context); + afterButtonPressed(context); + } + + Future _handleImageInsert(String imageUrl) async { + await handleImageInsert( + imageUrl, + controller: controller, + onImageInsertCallback: options?.imageButtonConfig?.onImageInsertCallback, + onImageInsertedCallback: + options?.imageButtonConfig?.onImageInsertedCallback, + ); + } + + Future _onPressedHandler(BuildContext context) async { + final onRequestPickImage = options?.imageButtonConfig?.onRequestPickImage; + if (onRequestPickImage != null) { + final imageUrl = await onRequestPickImage( + context, + ); + if (imageUrl != null) { + await _handleImageInsert(imageUrl); + } + return; + } + final source = await showSelectImageSourceDialog( + context: context, + ); + if (source == null) { + return; + } + + final imageUrl = switch (source) { + InsertImageSource.gallery => + (await ImagePicker().pickImage(source: ImageSource.gallery))?.path, + InsertImageSource.link => + context.mounted ? await _typeLink(context) : null, + InsertImageSource.camera => + (await ImagePicker().pickImage(source: ImageSource.camera))?.path, + }; + if (imageUrl == null) { + return; + } + if (imageUrl.trim().isNotEmpty) { + await _handleImageInsert(imageUrl); + } + } + + Future _typeLink(BuildContext context) async { + final value = await showDialog( + context: context, + builder: (_) => TypeLinkDialog( + dialogTheme: options?.dialogTheme, + linkRegExp: options?.linkRegExp, + linkType: LinkType.image, + ), + ); + return value; + } + + @override + Widget buildButton(BuildContext context) { + return QuillToolbarIconButton( + icon: Icon( + iconData(context), + size: iconButtonFactor(context) * iconSize(context), + ), + tooltip: tooltip(context), + isSelected: false, + onPressed: () => _sharedOnPressed(context), + iconTheme: iconTheme(context), + ); + } + + @override + Widget? buildCustomChildBuilder(BuildContext context) { + return childBuilder?.call( + QuillToolbarImageButtonOptions( + afterButtonPressed: afterButtonPressed(context), + iconData: iconData(context), + iconSize: iconSize(context), + iconButtonFactor: iconButtonFactor(context), + dialogTheme: options?.dialogTheme, + iconTheme: options?.iconTheme, + linkRegExp: options?.linkRegExp, + tooltip: tooltip(context), + imageButtonConfig: options?.imageButtonConfig, + ), + QuillToolbarImageButtonExtraOptions( + context: context, + controller: controller, + onPressed: () => _sharedOnPressed(context), + ), + ); + } + + @override + IconData Function(BuildContext context) get getDefaultIconData => + (context) => Icons.image; + + @override + String Function(BuildContext context) get getDefaultTooltip => + (context) => context.loc.insertImage; +} diff --git a/lib/Screens/myTemplates/src/toolbar/image/select_image_source.dart b/lib/Screens/myTemplates/src/toolbar/image/select_image_source.dart new file mode 100644 index 0000000..80f846a --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/image/select_image_source.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/internal.dart'; + +import '../../editor/image/image_embed_types.dart'; + +class SelectImageSourceDialog extends StatelessWidget { + const SelectImageSourceDialog({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minHeight: 200), + width: double.infinity, + child: SingleChildScrollView( + child: Column( + children: [ + ListTile( + title: Text(context.loc.gallery), + subtitle: Text( + context.loc.pickAPhotoFromYourGallery, + ), + leading: const Icon(Icons.photo_sharp), + onTap: () => Navigator.of(context).pop(InsertImageSource.gallery), + ), + ListTile( + title: Text(context.loc.camera), + subtitle: Text( + context.loc.takeAPhotoUsingYourCamera, + ), + leading: const Icon(Icons.camera), + enabled: !isDesktopApp, + onTap: () => Navigator.of(context).pop(InsertImageSource.camera), + ), + ListTile( + title: Text(context.loc.link), + subtitle: Text( + context.loc.pasteAPhotoUsingALink, + ), + leading: const Icon(Icons.link), + onTap: () => Navigator.of(context).pop(InsertImageSource.link), + ), + ], + ), + ), + ); + } +} + +Future showSelectImageSourceDialog({ + required BuildContext context, +}) async { + final imageSource = await showModalBottomSheet( + showDragHandle: true, + context: context, + constraints: const BoxConstraints(maxWidth: 640), + builder: (_) => const SelectImageSourceDialog(), + ); + return imageSource; +} diff --git a/lib/Screens/myTemplates/src/toolbar/quill_simple_toolbar_api.dart b/lib/Screens/myTemplates/src/toolbar/quill_simple_toolbar_api.dart new file mode 100644 index 0000000..3f9083d --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/quill_simple_toolbar_api.dart @@ -0,0 +1,12 @@ +/// APIs that are meant to be used by the `flutter_quil_extensions` only. +/// +/// Breaking changes can be introduced from `flutter_quill` in minor versions, +/// the `flutter_quill_extensions` will be updated and published at the same time. +/// +/// Update both packages and use the same version for compatibility by running `flutter pub upgrade`. +@internal +library; + +import 'package:meta/meta.dart'; + +export 'package:flutter_quill/src/toolbar/base_button/stateless_base_button.dart'; diff --git a/lib/Screens/myTemplates/src/toolbar/video/config/video.dart b/lib/Screens/myTemplates/src/toolbar/video/config/video.dart new file mode 100644 index 0000000..0a3c2bf --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/video/config/video.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart' show BuildContext; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:meta/meta.dart' show immutable; + +/// When request picking an video, for example when the video button toolbar +/// clicked, it should be null in case the user didn't choose any video or +/// any other reasons, and it should be the video file path as string that is +/// exists in case the user picked the video successfully +/// +/// by default we already have a default implementation that show a dialog +/// request the source for picking the video, from gallery, link or camera +typedef OnRequestPickVideo = Future Function( + BuildContext context, +); + +/// A callback will called when inserting a video in the editor +/// it have the logic that will insert the video block using the controller +typedef OnVideoInsertCallback = Future Function( + String video, + QuillController controller, +); + +/// When a new video picked this callback will called and you might want to +/// do some logic depending on your use case +typedef OnVideoInsertedCallback = Future Function( + String video, +); + +enum InsertVideoSource { + gallery, + camera, + link, +} + +/// Configurations for dealing with videos, on insert a video +/// on request picking a video +@immutable +class QuillToolbarVideoConfig { + const QuillToolbarVideoConfig({ + this.onRequestPickVideo, + this.onVideoInsertedCallback, + this.onVideoInsertCallback, + }); + + final OnRequestPickVideo? onRequestPickVideo; + + final OnVideoInsertedCallback? onVideoInsertedCallback; + + final OnVideoInsertCallback? onVideoInsertCallback; +} diff --git a/lib/Screens/myTemplates/src/toolbar/video/config/video_config.dart b/lib/Screens/myTemplates/src/toolbar/video/config/video_config.dart new file mode 100644 index 0000000..268a4bd --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/video/config/video_config.dart @@ -0,0 +1,32 @@ +import 'package:flutter_quill/flutter_quill.dart'; + +import 'video.dart'; + +class QuillToolbarVideoButtonExtraOptions + extends QuillToolbarBaseButtonExtraOptions { + const QuillToolbarVideoButtonExtraOptions({ + required super.controller, + required super.context, + required super.onPressed, + }); +} + +class QuillToolbarVideoButtonOptions extends QuillToolbarBaseButtonOptions< + QuillToolbarVideoButtonOptions, QuillToolbarVideoButtonExtraOptions> { + const QuillToolbarVideoButtonOptions({ + this.linkRegExp, + this.dialogTheme, + super.iconSize, + super.iconButtonFactor, + super.iconData, + super.afterButtonPressed, + super.tooltip, + super.iconTheme, + super.childBuilder, + this.videoConfig, + }); + + final RegExp? linkRegExp; + final QuillDialogTheme? dialogTheme; + final QuillToolbarVideoConfig? videoConfig; +} diff --git a/lib/Screens/myTemplates/src/toolbar/video/select_video_source.dart b/lib/Screens/myTemplates/src/toolbar/video/select_video_source.dart new file mode 100644 index 0000000..4020fc8 --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/video/select_video_source.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/internal.dart'; + +import 'config/video.dart'; + +class SelectVideoSourceDialog extends StatelessWidget { + const SelectVideoSourceDialog({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minHeight: 200), + width: double.infinity, + child: SingleChildScrollView( + child: Column( + children: [ + ListTile( + title: Text(context.loc.gallery), + subtitle: Text( + context.loc.pickAVideoFromYourGallery, + ), + leading: const Icon(Icons.photo_sharp), + onTap: () => Navigator.of(context).pop(InsertVideoSource.gallery), + ), + ListTile( + title: Text(context.loc.camera), + subtitle: Text(context.loc.recordAVideoUsingYourCamera), + leading: const Icon(Icons.camera), + enabled: !isDesktopApp, + onTap: () => Navigator.of(context).pop(InsertVideoSource.camera), + ), + ListTile( + title: Text(context.loc.link), + subtitle: Text( + context.loc.pasteAVideoUsingALink, + ), + leading: const Icon(Icons.link), + onTap: () => Navigator.of(context).pop(InsertVideoSource.link), + ), + ], + ), + ), + ); + } +} + +Future showSelectVideoSourceDialog({ + required BuildContext context, +}) async { + final imageSource = await showModalBottomSheet( + showDragHandle: true, + context: context, + constraints: const BoxConstraints(maxWidth: 640), + builder: (context) => const SelectVideoSourceDialog(), + ); + return imageSource; +} diff --git a/lib/Screens/myTemplates/src/toolbar/video/video_button.dart b/lib/Screens/myTemplates/src/toolbar/video/video_button.dart new file mode 100644 index 0000000..33d160b --- /dev/null +++ b/lib/Screens/myTemplates/src/toolbar/video/video_button.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill/internal.dart'; + +import 'package:image_picker/image_picker.dart'; + +import '../../common/default_video_insert.dart'; +import '../../common/image_video_utils.dart'; +import '../quill_simple_toolbar_api.dart'; + +import 'config/video.dart'; +import 'config/video_config.dart'; +import 'select_video_source.dart'; + +// ignore: invalid_use_of_internal_member +class QuillToolbarVideoButton extends QuillToolbarBaseButtonStateless { + const QuillToolbarVideoButton({ + required super.controller, + QuillToolbarVideoButtonOptions? options, + + /// Shares common options between all buttons, prefer the [options] + /// over the [baseOptions]. + super.baseOptions, + super.key, + }) : _options = options, + super(options: options); + + final QuillToolbarVideoButtonOptions? _options; + + @override + QuillToolbarVideoButtonOptions? get options => _options; + + void _sharedOnPressed(BuildContext context) { + _onPressedHandler(context); + afterButtonPressed(context); + } + + Future _handleVideoInsert(String videoUrl) async { + await handleVideoInsert( + videoUrl, + controller: controller, + onVideoInsertCallback: options?.videoConfig?.onVideoInsertCallback, + onVideoInsertedCallback: options?.videoConfig?.onVideoInsertedCallback, + ); + } + + Future _onPressedHandler(BuildContext context) async { + final onRequestPickVideo = options?.videoConfig?.onRequestPickVideo; + if (onRequestPickVideo != null) { + final videoUrl = await onRequestPickVideo(context); + if (videoUrl != null) { + await _handleVideoInsert(videoUrl); + } + return; + } + + final imageSource = await showSelectVideoSourceDialog(context: context); + + if (imageSource == null) { + return; + } + + final videoUrl = switch (imageSource) { + InsertVideoSource.gallery => + (await ImagePicker().pickVideo(source: ImageSource.gallery))?.path, + InsertVideoSource.camera => + (await ImagePicker().pickVideo(source: ImageSource.camera))?.path, + InsertVideoSource.link => + context.mounted ? await _typeLink(context) : null, + }; + if (videoUrl == null) { + return; + } + + if (videoUrl.trim().isNotEmpty) { + _handleVideoInsert(videoUrl); + } + } + + Future _typeLink(BuildContext context) async { + final value = await showDialog( + context: context, + builder: (_) => TypeLinkDialog( + dialogTheme: options?.dialogTheme, + linkType: LinkType.video, + ), + ); + return value; + } + + @override + Widget buildButton(BuildContext context) { + return QuillToolbarIconButton( + icon: Icon( + iconData(context), + size: iconSize(context) * iconButtonFactor(context), + ), + tooltip: tooltip(context), + isSelected: false, + onPressed: () => _sharedOnPressed(context), + iconTheme: iconTheme(context), + ); + } + + @override + Widget? buildCustomChildBuilder(BuildContext context) { + return childBuilder?.call( + QuillToolbarVideoButtonOptions( + afterButtonPressed: afterButtonPressed(context), + iconData: iconData(context), + dialogTheme: options?.dialogTheme, + iconSize: iconSize(context), + iconButtonFactor: iconButtonFactor(context), + linkRegExp: options?.linkRegExp, + tooltip: tooltip(context), + iconTheme: options?.iconTheme, + videoConfig: options?.videoConfig, + ), + QuillToolbarVideoButtonExtraOptions( + context: context, + controller: controller, + onPressed: () => _sharedOnPressed(context), + ), + ); + } + + @override + IconData Function(BuildContext context) get getDefaultIconData => + (context) => Icons.movie_creation; + + @override + String Function(BuildContext context) get getDefaultTooltip => + (context) => context.loc.insertVideo; +} diff --git a/lib/Screens/myTemplates/template.dart b/lib/Screens/myTemplates/template.dart index 5352189..31cecb7 100644 --- a/lib/Screens/myTemplates/template.dart +++ b/lib/Screens/myTemplates/template.dart @@ -1,19 +1,23 @@ import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:frontend/utils/auth_utils.dart'; -import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:http/http.dart' as http; -import 'package:responsive_builder/responsive_builder.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:io' as io show Directory, File; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_quill/quill_delta.dart'; +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; +import 'package:path/path.dart' as path; +import 'package:responsive_builder/responsive_builder.dart'; -import '../../config/apiUrl.dart'; import '../../routes/custom_appBar.dart'; import '../../routes/custom_drawer.dart'; import '../../services/apiService.dart'; -import '../../utils/pagination.dart'; -import '../../widgets/custom_popup.dart'; -import '../../widgets/popup_userList_action.dart'; + +/// Custom Embed Definition +class TimeStampEmbed { + static const String type = 'timeStamp'; +} class Template extends StatefulWidget { @override @@ -22,10 +26,21 @@ class Template extends StatefulWidget { class TemplateState extends State