diff --git a/_visual-editor/lib/home_page.dart b/_visual-editor/lib/home_page.dart new file mode 100644 index 0000000..745363a --- /dev/null +++ b/_visual-editor/lib/home_page.dart @@ -0,0 +1,288 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:app/main.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:visual_editor/document/models/attributes/attributes.model.dart'; +import 'package:visual_editor/visual-editor.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:http/http.dart' as http; +import 'package:mime/mime.dart'; +import 'package:http_parser/http_parser.dart'; + +const quillEditorKey = Key('quillEditorKey'); + +/// Home page with the `flutter-quill` editor +class HomePage extends StatefulWidget { + const HomePage({ + required this.platformService, + super.key, + }); + + final PlatformService platformService; + + @override + HomePageState createState() => HomePageState(); +} + +class HomePageState extends State { + /// `flutter-quill` editor controller + EditorController? _controller; + + /// Focus node used to obtain keyboard focus and events + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _initializeText(); + } + + /// Initializing the [Delta](https://quilljs.com/docs/delta/) document with sample text. + Future _initializeText() async { + // final doc = Document()..insert(0, 'Just a friendly empty text :)'); + final doc = DeltaDocM(); + setState(() { + _controller = EditorController( + document: doc, + ); + }); + } + + @override + Widget build(BuildContext context) { + /// Loading widget if controller's not loaded + if (_controller == null) { + return const Scaffold(body: Center(child: Text('Loading...'))); + } + + /// Returning scaffold with editor as body + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + centerTitle: false, + title: const Text( + 'Visual Editor', + ), + ), + body: _buildEditor(context), + ); + } + + /// Build the `flutter-quill` editor to be shown on screen. + Widget _buildEditor(BuildContext context) { + // Default editor (for mobile devices) + Widget quillEditor = VisualEditor( + controller: _controller!, + scrollController: ScrollController(), + focusNode: _focusNode, + config: EditorConfigM( + scrollable: true, + autoFocus: false, + readOnly: false, + placeholder: 'Write what\'s on your mind.', + enableInteractiveSelection: true, + expands: false, + padding: EdgeInsets.zero, + customStyles: const EditorStylesM( + h1: TextBlockStyleM( + TextStyle( + fontSize: 32, + color: Colors.black, + height: 1.15, + fontWeight: FontWeight.w300, + ), + VerticalSpacing(top: 16, bottom: 0), + VerticalSpacing(top: 0, bottom: 0), + VerticalSpacing(top: 16, bottom: 0), + null, + ), + sizeSmall: TextStyle(fontSize: 9), + ), + ), + ); + + // Alternatively, the web editor version is shown (with the web embeds) + if (widget.platformService.isWebPlatform()) { + quillEditor = VisualEditor( + controller: _controller!, + scrollController: ScrollController(), + focusNode: _focusNode, + config: EditorConfigM( + scrollable: true, + enableInteractiveSelection: false, + autoFocus: false, + readOnly: false, + placeholder: 'Add content', + expands: false, + padding: EdgeInsets.zero, + customStyles: const EditorStylesM( + h1: TextBlockStyleM( + TextStyle( + fontSize: 32, + color: Colors.black, + height: 1.15, + fontWeight: FontWeight.w300, + ), + VerticalSpacing(top: 16, bottom: 0), + VerticalSpacing(top: 0, bottom: 0), + VerticalSpacing(top: 16, bottom: 0), + null, + ), + sizeSmall: TextStyle(fontSize: 9), + ), + ), + ); + } + + // Toolbar definitions + const toolbarIconSize = 18.0; + const toolbarButtonSpacing = 2.5; + + // Instantiating the toolbar + final toolbar = EditorToolbar( + children: [ + HistoryButton( + buttonsSpacing: toolbarButtonSpacing, + icon: Icons.undo_outlined, + iconSize: toolbarIconSize, + controller: _controller!, + isUndo: true, + ), + HistoryButton( + buttonsSpacing: toolbarButtonSpacing, + icon: Icons.redo_outlined, + iconSize: toolbarIconSize, + controller: _controller!, + isUndo: false, + ), + ToggleStyleButton( + buttonsSpacing: toolbarButtonSpacing, + attribute: AttributesM.bold, + icon: Icons.format_bold, + iconSize: toolbarIconSize, + controller: _controller!, + ), + ToggleStyleButton( + buttonsSpacing: toolbarButtonSpacing, + attribute: AttributesM.italic, + icon: Icons.format_italic, + iconSize: toolbarIconSize, + controller: _controller!, + ), + ToggleStyleButton( + buttonsSpacing: toolbarButtonSpacing, + attribute: AttributesM.underline, + icon: Icons.format_underline, + iconSize: toolbarIconSize, + controller: _controller!, + ), + ToggleStyleButton( + buttonsSpacing: toolbarButtonSpacing, + attribute: AttributesM.strikeThrough, + icon: Icons.format_strikethrough, + iconSize: toolbarIconSize, + controller: _controller!, + ), + + // Our embed buttons + ImageButton( + icon: Icons.image, + iconSize: toolbarIconSize, + buttonsSpacing: toolbarButtonSpacing, + controller: _controller!, + onImagePickCallback: _onImagePickCallback, + webImagePickImpl: _webImagePickImpl, + mediaPickSettingSelector: (context) { + return Future.value(MediaPickSettingE.Gallery); + }, + ), + ], + ); + + // Rendering the final editor + toolbar + return SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 15, + child: Container( + key: quillEditorKey, + color: Colors.white, + padding: const EdgeInsets.only(left: 16, right: 16), + child: quillEditor, + ), + ), + Container(child: toolbar), + ], + ), + ); + } + + /// Renders the image picked by imagePicker from local file storage + /// You can also upload the picked image to any server (eg : AWS s3 + /// or Firebase) and then return the uploaded image URL. + /// + /// It's only called on mobile platforms. + Future _onImagePickCallback(File file) async { + final appDocDir = await getApplicationDocumentsDirectory(); + final copiedFile = await file.copy('${appDocDir.path}/${basename(file.path)}'); + return copiedFile.path.toString(); + } + + /// Callback that is called after an image is picked whilst on the web platform. + /// Returns the URL of the image. + /// Returns null if an error occurred uploading the file or the image was not picked. + Future _webImagePickImpl(OnImagePickCallback onImagePickCallback) async { + // Lets the user pick one file; files with any file extension can be selected + final result = await ImageFilePicker().pickImage(); + + // The result will be null, if the user aborted the dialog + if (result == null || result.files.isEmpty) { + return null; + } + + // Read file as bytes (https://github.com/miguelpruivo/flutter_file_picker/wiki/FAQ#q-how-do-i-access-the-path-on-web) + final platformFile = result.files.first; + final bytes = platformFile.bytes; + + if (bytes == null) { + return null; + } + + // Make HTTP request to upload the image to the file + const apiURL = 'https://imgup.fly.dev/api/images'; + final request = http.MultipartRequest('POST', Uri.parse(apiURL)); + + final httpImage = http.MultipartFile.fromBytes( + 'image', + bytes, + contentType: MediaType.parse(lookupMimeType('', headerBytes: bytes)!), + filename: platformFile.name, + ); + request.files.add(httpImage); + + // Check the response and handle accordingly + return http.Client().send(request).then((response) async { + if (response.statusCode != 200) { + return null; + } + + final responseStream = await http.Response.fromStream(response); + final responseData = json.decode(responseStream.body); + return responseData['url']; + }); + } +} + +// coverage:ignore-start +/// Image file picker wrapper class +class ImageFilePicker { + Future pickImage() => FilePicker.platform.pickFiles(type: FileType.image); +} +// coverage:ignore-end diff --git a/_visual-editor/lib/main.dart b/_visual-editor/lib/main.dart new file mode 100644 index 0000000..89798c9 --- /dev/null +++ b/_visual-editor/lib/main.dart @@ -0,0 +1,53 @@ +import 'package:app/home_page.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:responsive_framework/responsive_framework.dart'; + +// coverage:ignore-start +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp( + App( + platformService: PlatformService(), + ), + ); +} +// coverage:ignore-end + +/// Entry gateway to the application. +/// Defining the MaterialApp attributes and Responsive Framework breakpoints. +class App extends StatelessWidget { + const App({required this.platformService, super.key}); + + final PlatformService platformService; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Editor Demo', + builder: (context, child) => ResponsiveBreakpoints.builder( + child: child!, + breakpoints: [ + const Breakpoint(start: 0, end: 425, name: MOBILE), + const Breakpoint(start: 426, end: 768, name: TABLET), + const Breakpoint(start: 769, end: 1440, name: DESKTOP), + const Breakpoint(start: 1441, end: double.infinity, name: '4K'), + ], + ), + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.white), + useMaterial3: true, + ), + home: HomePage(platformService: platformService), + ); + } +} + +// coverage:ignore-start +/// Platform service class that tells if the platform is web-based or not +class PlatformService { + bool isWebPlatform() { + return kIsWeb; + } +} +// coverage:ignore-end diff --git a/_visual-editor/test/sample.jpeg b/_visual-editor/test/sample.jpeg new file mode 100644 index 0000000..895f21a Binary files /dev/null and b/_visual-editor/test/sample.jpeg differ diff --git a/_visual-editor/test/widget_test.dart b/_visual-editor/test/widget_test.dart new file mode 100644 index 0000000..9195832 --- /dev/null +++ b/_visual-editor/test/widget_test.dart @@ -0,0 +1,134 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:cross_file/cross_file.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:visual_editor/visual-editor.dart'; + +import 'package:app/main.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:flutter/services.dart'; + +// importing mocks +import 'widget_test.mocks.dart'; + +/// This attempts to override the `image_picker` plugin inside `flutter-quill`, +/// but is currently failing. +/// +/// Please check the links below for more context. +/// https://stackoverflow.com/questions/76586920/mocking-imagepicker-in-flutter-integration-tests-not-working +/// and https://stackoverflow.com/questions/52028969/testing-flutter-code-that-uses-a-plugin-and-platform-channel +/// and https://docs.flutter.dev/testing/plugins-in-tests#mock-the-platform-channel +/// +/// This is currently commented because it crashes with a `PlatformException` stating +/// the `XFile` instance is an invalid argument (it does the same with `File`). +/// +/// `XFile` would make sense since the line that calls `image-picker` +/// is in https://github.com/singerdmx/flutter-quill/blob/36d72c1987f0cb8d6c689c12542600364c07e20f/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart#L147. +void mockImagePicker(WidgetTester tester) { + const channel = MethodChannel('plugins.flutter.io/image_picker'); + + Future handler(MethodCall methodCall) async { + if (methodCall.method == 'pickImage') { + final file = XFile('test/sample.jpeg'); + return file; + } else { + return null; + } + } + + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (message) { + return handler(message); + }); +} + +@GenerateMocks([PlatformService]) +void main() { + /// Check for context: https://stackoverflow.com/questions/60671728/unable-to-load-assets-in-flutter-tests + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + testWidgets('Normal setup', (WidgetTester tester) async { + final platformServiceMock = MockPlatformService(); + // Platform is mobile + when(platformServiceMock.isWebPlatform()).thenAnswer((_) => false); + + // Build our app and trigger a frame. + await tester.pumpWidget( + App( + platformService: platformServiceMock, + ), + ); + await tester.pumpAndSettle(); + + // Expect to find the normal page setup + expect(find.text('Flutter Quill'), findsOneWidget); + + // Enter 'hi' into Quill Editor. + await tester.tap(find.byType(VisualEditor)); + await tester.enterText(find.byType(VisualEditor), 'hi\n'); + await tester.pumpAndSettle(); + }); + + testWidgets('Select image', (WidgetTester tester) async { + final platformServiceMock = MockPlatformService(); + + // Platform is mobile + when(platformServiceMock.isWebPlatform()).thenAnswer((_) => false); + + // Mock image + // mockImagePicker(tester); + + // Build our app and trigger a frame. + await tester.pumpWidget( + App( + platformService: platformServiceMock, + ), + ); + await tester.pumpAndSettle(); + + // Expect to find the normal page setup + expect(find.text('Flutter Quill'), findsOneWidget); + + // Enter 'hi' into Quill Editor. + await tester.tap(find.byType(VisualEditor)); + await tester.enterText(find.byType(VisualEditor), 'hi\n'); + await tester.pumpAndSettle(); + + final imageButton = find.byType(ImageButton); + await tester.tap(imageButton); + await tester.pumpAndSettle(); + }); + + testWidgets('Normal setup (web version)', (WidgetTester tester) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1.0; + + final platformServiceMock = MockPlatformService(); + // Platform is desktop + when(platformServiceMock.isWebPlatform()).thenAnswer((_) => true); + + // Build our app and trigger a frame. + await tester.pumpWidget( + App( + platformService: platformServiceMock, + ), + ); + await tester.pumpAndSettle(); + + // Expect to find the normal page setup + expect(find.text('Flutter Quill'), findsOneWidget); + + // Enter 'hi' into Quill Editor. + await tester.tap(find.byType(VisualEditor)); + await tester.enterText(find.byType(VisualEditor), 'hi\n'); + await tester.pumpAndSettle(); + }); +} diff --git a/_visual-editor/test/widget_test.mocks.dart b/_visual-editor/test/widget_test.mocks.dart new file mode 100644 index 0000000..fbf6d12 --- /dev/null +++ b/_visual-editor/test/widget_test.mocks.dart @@ -0,0 +1,36 @@ +// Mocks generated by Mockito 5.4.2 from annotations +// in app/test/widget_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:app/main.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [PlatformService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockPlatformService extends _i1.Mock implements _i2.PlatformService { + MockPlatformService() { + _i1.throwOnMissingStub(this); + } + + @override + bool isWebPlatform() => (super.noSuchMethod( + Invocation.method( + #isWebPlatform, + [], + ), + returnValue: false, + ) as bool); +}