feat: mobile tool bar basic features (#161)

* refactor: refactor mobile toolbar

* feat: add text decoration icon in AFMobileIcons

* chore: update mobile parameter

* feat: add text decoration basic feature

* chore: replace text by AppFlowyEditorLocalizations

* feat: add code toolbar item

* chore: refactor code
- add hasMenu in MToolbarItem
- add const size

* feat: add line input menu

* chore: create toolbar items draft file

* refactor: textDecorationMToolbarItem refactor

* fix: add key to ensures MToolbarWidget is rebuilt whenever the selection changes

* feat: add link toolbar item and the way to close item menu

* chore: replace m by mobile

* chore:replace BuiltInAttributeKey by FlowyRichTextKeys

* chore: replace m by mobile

* refactor: refactor MobileToolbarWidget

* refactor: add MobileToolbarStyle to control all cosmetic parameters

* refactor: refactor MobileToolbarItemMenuBtn to stateless widget

* feat: add heading menu

* fix: fix open the wrong menu

* feat: add heading icon

* feat: add todo list and list toolbar item

* feat: add quote toolbar item

* fix: fix selection service error

* test: add mobile toolbar basic elements test

* test: add item menu button test and helper widgets

* test: add text decoration mobile toolbar item test

* test: add code mobile toolbar item test

* test: add heading mobile toolbar item test

* test: add list mobile toolbar item test

* chore: fix typo

* test: add quote mobile toolbar item test

* test: add todo list mobile toolbar item test

* test: add link mobile toolbar item test

* chore: update name and add Material widget

* fix: flutter analyze

* fix: flutter analyze in test mobile toolbar

* chore: dart format test code

* refactor: improve code

* fix: fix refactor error

* chore: move all the tests in mobile to test/mobile

* chore: move toolbar/items to toolbar/desktop/items

* chore: remove the mobileToolbarItemMenuStateKey

* chore: add some comments

* test: update testing

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
Yijing Huang 2023-06-13 01:01:08 -05:00 • committed by GitHub
commit 6a4a8ae818
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1574 additions and 125 deletions

View file

@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
void main() {
group('MobileToolbarItem', () {
test('action item should not have a menu', () {
final item = MobileToolbarItem.action(
itemIcon: const Icon(Icons.format_bold),
actionHandler: (editorState, selection) {},
);
expect(item.hasMenu, false);
});
test('menu item should have a menu', () {
final item = MobileToolbarItem.withMenu(
itemIcon: const Icon(Icons.format_color_text),
itemMenuBuilder: (editorState, selection, _) {
return Container();
},
);
expect(item.hasMenu, true);
});
});
}

View file

@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/src/editor/toolbar/mobile/mobile_toolbar_style.dart';
void main() {
testWidgets('MobileToolbarStyle should have correct values',
(WidgetTester tester) async {
const backgroundColor = Colors.white;
const foregroundColor = Colors.black;
const itemHighlightColor = Colors.blue;
const itemOutlineColor = Colors.grey;
const toolbarHeight = 50.0;
const borderRadius = 10.0;
await tester.pumpWidget(
const MobileToolbarStyle(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
itemHighlightColor: itemHighlightColor,
itemOutlineColor: itemOutlineColor,
toolbarHeight: toolbarHeight,
borderRadius: borderRadius,
child: SizedBox(),
),
);
final mobileToolbarStyle =
MobileToolbarStyle.of(tester.element(find.byType(MobileToolbarStyle)));
expect(mobileToolbarStyle.backgroundColor, equals(backgroundColor));
expect(mobileToolbarStyle.foregroundColor, equals(foregroundColor));
expect(mobileToolbarStyle.itemHighlightColor, equals(itemHighlightColor));
expect(mobileToolbarStyle.itemOutlineColor, equals(itemOutlineColor));
expect(mobileToolbarStyle.toolbarHeight, equals(toolbarHeight));
expect(mobileToolbarStyle.borderRadius, equals(borderRadius));
});
}

View file

@ -0,0 +1,53 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../new/infra/testable_editor.dart';
import 'test_helpers/mobile_app_with_toolbar_widget.dart';
void main() {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('MobileToolbar', () {
testWidgets('return SizeBox when it is no selection', (tester) async {
final editor = tester.editor;
await editor.startTesting();
final toolbarItems = [
textDecorationMobileToolbarItem,
linkMobileToolbarItem,
];
await tester.pumpWidget(
MaterialApp(
home: MobileToolbar(
editorState: editor.editorState,
toolbarItems: toolbarItems,
),
),
);
expect(find.byType(SizedBox), findsOneWidget);
});
testWidgets('show MobileToolbarWidget when some text is selected ',
(tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor..addParagraphs(3, initialText: text);
await editor.startTesting();
var selection = Selection.single(
path: [1],
startOffset: 2,
endOffset: text.length - 2,
);
await editor.updateSelection(selection);
await tester.pumpWidget(
MobileAppWithToolbarWidget(editorState: editor.editorState),
);
expect(find.byType(MobileToolbarWidget), findsOneWidget);
});
});
}

View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
/// Used in testing mobile app with toolbar
class MobileAppWithToolbarWidget extends StatelessWidget {
const MobileAppWithToolbarWidget({
required this.editorState,
this.toolbarItems,
super.key,
});
final EditorState editorState;
final List<MobileToolbarItem>? toolbarItems;
@override
Widget build(BuildContext context) {
final scrollController = ScrollController();
final localToolbarItems = toolbarItems ??
[
textDecorationMobileToolbarItem,
headingMobileToolbarItem,
todoListMobileToolbarItem,
listMobileToolbarItem,
linkMobileToolbarItem,
quoteMobileToolbarItem,
codeMobileToolbarItem,
];
return MaterialApp(
home: Column(
children: [
Expanded(
child: AppFlowyEditor.standard(
editorStyle: const EditorStyle.mobile(),
editorState: editorState,
scrollController: scrollController,
),
),
MobileToolbar(
editorState: editorState,
toolbarItems: localToolbarItems,
),
],
),
);
}
}

View file

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
/// Used in testing mobile app with toolbar
class MobileToolbarStyleTestWidget extends StatelessWidget {
const MobileToolbarStyleTestWidget({
required this.child,
super.key,
this.backgroundColor = Colors.white,
this.foregroundColor = Colors.black,
this.itemHighlightColor = Colors.blue,
this.itemOutlineColor = Colors.grey,
this.toolbarHeight = 50.0,
this.borderRadius = 10.0,
});
final Widget child;
final Color backgroundColor;
final Color foregroundColor;
final Color itemHighlightColor;
final Color itemOutlineColor;
final double toolbarHeight;
final double borderRadius;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: MobileToolbarStyle(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
itemHighlightColor: itemHighlightColor,
itemOutlineColor: itemOutlineColor,
toolbarHeight: toolbarHeight,
borderRadius: borderRadius,
child: child,
),
);
}
}

View file

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import '../../../../new/infra/testable_editor.dart';
import '../test_helpers/mobile_app_with_toolbar_widget.dart';
void main() {
testWidgets('codeMobileToolbarItem', (WidgetTester tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor..addParagraphs(3, initialText: text);
await editor.startTesting();
var selection = Selection.single(
path: [1],
startOffset: 2,
endOffset: text.length - 2,
);
await editor.updateSelection(selection);
await tester.pumpWidget(
MobileAppWithToolbarWidget(
editorState: editor.editorState,
toolbarItems: [
codeMobileToolbarItem,
],
),
);
// Tap code toolbar item
final codeBtn = find.byType(IconButton).first;
await tester.tap(codeBtn);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
// Check if the text becomes code format
final node = editor.editorState.getNodeAtPath([1]);
expect(
node?.allSatisfyInSelection(selection, (delta) {
return delta.whereType<TextInsert>().every(
(element) => element.attributes?[FlowyRichTextKeys.code] == true,
);
}),
true,
);
});
}

View file

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import '../../../../new/infra/testable_editor.dart';
import '../test_helpers/mobile_app_with_toolbar_widget.dart';
void main() {
testWidgets('headingMobileToolbarItem', (WidgetTester tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor..addParagraphs(3, initialText: text);
await editor.startTesting();
var selection = Selection.single(
path: [1],
startOffset: 2,
endOffset: text.length - 2,
);
await editor.updateSelection(selection);
await tester.pumpWidget(
MobileAppWithToolbarWidget(
editorState: editor.editorState,
toolbarItems: [
headingMobileToolbarItem,
],
),
);
// Tap text decoration toolbar item
await tester.tap(find.byType(IconButton).first);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
// Show its menu and it has 3 buttons
expect(find.byType(MobileToolbarItemMenu), findsOneWidget);
expect(find.text('Heading 1'), findsOneWidget);
expect(find.text('Heading 2'), findsOneWidget);
expect(find.text('Heading 3'), findsOneWidget);
// Test Heading 1 button
await tester
.tap(find.widgetWithText(MobileToolbarItemMenuBtn, 'Heading 1'));
var node = editor.editorState.getNodeAtPath([1]);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(
node?.type == 'heading' && node?.attributes['level'] == 1,
true,
);
// Test Heading 2 button
await tester
.tap(find.widgetWithText(MobileToolbarItemMenuBtn, 'Heading 2'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
//Get updated node
node = editor.editorState.getNodeAtPath([1]);
expect(
node?.type == 'heading' && node?.attributes['level'] == 2,
true,
);
// Test Heading 3 button
await tester
.tap(find.widgetWithText(MobileToolbarItemMenuBtn, 'Heading 3'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
//Get updated node
node = editor.editorState.getNodeAtPath([1]);
expect(
node?.type == 'heading' && node?.attributes['level'] == 3,
true,
);
});
}

View file

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import '../../../../new/infra/testable_editor.dart';
import '../test_helpers/mobile_app_with_toolbar_widget.dart';
void main() {
testWidgets('linkMobileToolbarItem', (WidgetTester tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor..addParagraphs(3, initialText: text);
await editor.startTesting();
var selection = Selection.single(
path: [1],
startOffset: 2,
endOffset: text.length - 2,
);
await editor.updateSelection(selection);
await tester.pumpWidget(
MobileAppWithToolbarWidget(
editorState: editor.editorState,
toolbarItems: [
linkMobileToolbarItem,
],
),
);
// Tap link toolbar item
await tester.tap(find.byType(IconButton).first);
await tester.pumpAndSettle();
// Show its menu
expect(find.byType(MobileToolbarItemMenu), findsOneWidget);
// Enter link address
const linkAddress = 'www.google.com';
final textField = find.byType(TextField);
await tester.enterText(textField, linkAddress);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
// Check if the text becomes a link
final node = editor.editorState.getNodeAtPath([1]);
expect(
node?.allSatisfyInSelection(selection, (delta) {
return delta.whereType<TextInsert>().every(
(element) =>
element.attributes?[FlowyRichTextKeys.href] == linkAddress,
);
}),
true,
);
});
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import '../../../../new/infra/testable_editor.dart';
import '../test_helpers/mobile_app_with_toolbar_widget.dart';
void main() {
testWidgets('listMobileToolbarItem', (WidgetTester tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor..addParagraphs(3, initialText: text);
await editor.startTesting();
var selection = Selection.single(
path: [1],
startOffset: 2,
endOffset: text.length - 2,
);
await editor.updateSelection(selection);
await tester.pumpWidget(
MobileAppWithToolbarWidget(
editorState: editor.editorState,
toolbarItems: [
listMobileToolbarItem,
],
),
);
// Tap text decoration toolbar item
await tester.tap(find.byType(IconButton).first);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
// Show its menu and it has 2 buttons
expect(find.byType(MobileToolbarItemMenu), findsOneWidget);
expect(find.text('Bulleted List'), findsOneWidget);
expect(find.text('Numbered List'), findsOneWidget);
// Test Bulleted List button
await tester
.tap(find.widgetWithText(MobileToolbarItemMenuBtn, 'Bulleted List'));
var node = editor.editorState.getNodeAtPath([1]);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(
node?.type == 'bulleted_list',
true,
);
// Test Numbered List button
await tester
.tap(find.widgetWithText(MobileToolbarItemMenuBtn, 'Numbered List'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
//Get updated node
node = editor.editorState.getNodeAtPath([1]);
expect(
node?.type == 'numbered_list',
true,
);
});
}

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import '../../../../new/infra/testable_editor.dart';
import '../test_helpers/mobile_app_with_toolbar_widget.dart';
void main() {
testWidgets('quoteMobileToolbarItem', (WidgetTester tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor..addParagraphs(3, initialText: text);
await editor.startTesting();
var selection = Selection.single(
path: [1],
startOffset: 2,
endOffset: text.length - 2,
);
await editor.updateSelection(selection);
await tester.pumpWidget(
MobileAppWithToolbarWidget(
editorState: editor.editorState,
toolbarItems: [
quoteMobileToolbarItem,
],
),
);
// Tap quote toolbar item
final quoteBtn = find.byType(IconButton).first;
await tester.tap(quoteBtn);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
// Check if the text becomes quote node
final node = editor.editorState.getNodeAtPath([1]);
expect(
node?.type == 'quote',
true,
);
});
}

View file

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import '../../../../new/infra/testable_editor.dart';
import '../test_helpers/mobile_app_with_toolbar_widget.dart';
void main() {
testWidgets('textDecorationMobileToolbarItem', (WidgetTester tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor..addParagraphs(3, initialText: text);
await editor.startTesting();
var selection = Selection.single(
path: [1],
startOffset: 2,
endOffset: text.length - 2,
);
await editor.updateSelection(selection);
await tester.pumpWidget(
MobileAppWithToolbarWidget(
editorState: editor.editorState,
toolbarItems: [
textDecorationMobileToolbarItem,
],
),
);
// Tap text decoration toolbar item
await tester.tap(find.byType(IconButton).first);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
// Show its menu and it has 4 buttons
expect(find.byType(MobileToolbarItemMenu), findsOneWidget);
expect(find.text('Bold'), findsOneWidget);
expect(find.text('Italic'), findsOneWidget);
expect(find.text('Underline'), findsOneWidget);
expect(find.text('Strikethrough'), findsOneWidget);
// Test bold button
await tester.tap(find.widgetWithText(MobileToolbarItemMenuBtn, 'Bold'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
final node = editor.editorState.getNodeAtPath([1]);
expect(
node?.allSatisfyInSelection(selection, (delta) {
return delta.whereType<TextInsert>().every(
(element) => element.attributes?[FlowyRichTextKeys.bold] == true,
);
}),
true,
);
// Test Italic button
await tester.tap(find.widgetWithText(MobileToolbarItemMenuBtn, 'Italic'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(
node?.allSatisfyInSelection(selection, (delta) {
return delta.whereType<TextInsert>().every(
(element) =>
element.attributes?[FlowyRichTextKeys.italic] == true,
);
}),
true,
);
// Test Underline button
await tester
.tap(find.widgetWithText(MobileToolbarItemMenuBtn, 'Underline'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(
node?.allSatisfyInSelection(selection, (delta) {
return delta.whereType<TextInsert>().every(
(element) =>
element.attributes?[FlowyRichTextKeys.underline] == true,
);
}),
true,
);
// Test Strikethrough button
await tester
.tap(find.widgetWithText(MobileToolbarItemMenuBtn, 'Strikethrough'));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(
node?.allSatisfyInSelection(selection, (delta) {
return delta.whereType<TextInsert>().every(
(element) =>
element.attributes?[FlowyRichTextKeys.strikethrough] == true,
);
}),
true,
);
});
}

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import '../../../../new/infra/testable_editor.dart';
import '../test_helpers/mobile_app_with_toolbar_widget.dart';
void main() {
testWidgets('todoListMobileToolbarItem', (WidgetTester tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor..addParagraphs(3, initialText: text);
await editor.startTesting();
var selection = Selection.single(
path: [1],
startOffset: 2,
endOffset: text.length - 2,
);
await editor.updateSelection(selection);
await tester.pumpWidget(
MobileAppWithToolbarWidget(
editorState: editor.editorState,
toolbarItems: [
todoListMobileToolbarItem,
],
),
);
// Tap todoList toolbar item
final todoListBtn = find.byType(IconButton).first;
await tester.tap(todoListBtn);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
// Check if the text becomes quote node
final node = editor.editorState.getNodeAtPath([1]);
expect(
node?.type == 'todo_list',
true,
);
});
}

View file

@ -0,0 +1,25 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../test_helpers/mobile_toolbar_style_test_widget.dart';
void main() {
testWidgets('MobileToolbarItemMenuBtn should display label and icon',
(WidgetTester tester) async {
const icon = Icon(Icons.add);
const label = 'Add';
await tester.pumpWidget(
MobileToolbarStyleTestWidget(
child: MobileToolbarItemMenuBtn(
onPressed: () {},
icon: icon,
label: label,
isSelected: false,
),
),
);
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.text('Add'), findsOneWidget);
});
}