Skip to content

Commit

Permalink
feat: Adding files for visual editor folder. #3
Browse files Browse the repository at this point in the history
LuchoTurtle committed Sep 20, 2023
1 parent 93975e9 commit cc2f104
Showing 5 changed files with 511 additions and 0 deletions.
288 changes: 288 additions & 0 deletions _visual-editor/lib/home_page.dart
Original file line number Diff line number Diff line change
@@ -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<HomePage> {
/// `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<void> _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: <Widget>[
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<String> _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<String?> _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<FilePickerResult?> pickImage() => FilePicker.platform.pickFiles(type: FileType.image);
}
// coverage:ignore-end
53 changes: 53 additions & 0 deletions _visual-editor/lib/main.dart
Original file line number Diff line number Diff line change
@@ -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
Binary file added _visual-editor/test/sample.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
134 changes: 134 additions & 0 deletions _visual-editor/test/widget_test.dart
Original file line number Diff line number Diff line change
@@ -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<XFile?> 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();
});
}
36 changes: 36 additions & 0 deletions _visual-editor/test/widget_test.mocks.dart
Original file line number Diff line number Diff line change
@@ -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);
}

0 comments on commit cc2f104

Please sign in to comment.