feat: adjust toggle list, callout, quote and divider on mobile (#3894)

* feat: adjust toggle list block

* feat: show block actions when tapping divider

* feat: add toggle list and callout to toolbar

* feat: refactor the emoji picker button

* fix: toggle list integration tests
This commit is contained in:
Lucas.Xu 2023-11-08 21:10:29 +08:00 committed by GitHub
parent 663f9d3423
commit afc6473582
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 308 additions and 108 deletions

View File

@ -15,14 +15,23 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('toggle list in document', () {
Finder findToggleListIcon({
required bool isExpanded,
}) {
final turns = isExpanded ? 0.25 : 0.0;
return find.byWidgetPredicate(
(widget) => widget is AnimatedRotation && widget.turns == turns,
);
}
void expectToggleListOpened() {
expect(find.byIcon(Icons.arrow_drop_down), findsOneWidget);
expect(find.byIcon(Icons.arrow_right), findsNothing);
expect(findToggleListIcon(isExpanded: true), findsOneWidget);
expect(findToggleListIcon(isExpanded: false), findsNothing);
}
void expectToggleListClosed() {
expect(find.byIcon(Icons.arrow_drop_down), findsNothing);
expect(find.byIcon(Icons.arrow_right), findsOneWidget);
expect(findToggleListIcon(isExpanded: false), findsOneWidget);
expect(findToggleListIcon(isExpanded: true), findsNothing);
}
testWidgets('convert > to toggle list, and click the icon to close it',
@ -63,7 +72,7 @@ void main() {
expect(find.text(text2, findRichText: true), findsOneWidget);
// Click the toggle list icon to close it
final toggleListIcon = find.byIcon(Icons.arrow_drop_down);
final toggleListIcon = find.byIcon(Icons.arrow_right);
await tester.tapButton(toggleListIcon);
// expect the toggle list to be closed
@ -88,7 +97,7 @@ void main() {
await tester.ime.insertText('> $text');
// Click the toggle list icon to close it
final toggleListIcon = find.byIcon(Icons.arrow_drop_down);
final toggleListIcon = find.byIcon(Icons.arrow_right);
await tester.tapButton(toggleListIcon);
// Press the enter key
@ -164,7 +173,7 @@ void main() {
// Press the enter key
// Click the toggle list icon to close it
final toggleListIcon = find.byIcon(Icons.arrow_drop_down);
final toggleListIcon = find.byIcon(Icons.arrow_right);
await tester.tapButton(toggleListIcon);
await tester.editor.updateSelection(

View File

@ -1,22 +1,21 @@
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/plugins/base/icon/icon_picker_page.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class MobileEmojiPickerScreen extends StatelessWidget {
static const routeName = '/emoji_picker';
static const viewId = 'id';
const MobileEmojiPickerScreen({
super.key,
required this.id,
});
/// view id
final String id;
@override
Widget build(BuildContext context) {
return IconPickerPage(
id: id,
onSelected: (result) {
context.pop<EmojiPickerResult>(result);
},
);
}
}

View File

@ -12,13 +12,23 @@ enum FlowyIconType {
custom;
}
class EmojiPickerResult {
const EmojiPickerResult(
this.type,
this.emoji,
);
final FlowyIconType type;
final String emoji;
}
class FlowyIconPicker extends StatefulWidget {
const FlowyIconPicker({
super.key,
required this.onSelected,
});
final void Function(FlowyIconType type, String value) onSelected;
final void Function(EmojiPickerResult result) onSelected;
@override
State<FlowyIconPicker> createState() => _FlowyIconPickerState();
@ -45,7 +55,12 @@ class _FlowyIconPickerState extends State<FlowyIconPicker>
const Spacer(),
_RemoveIconButton(
onTap: () {
widget.onSelected(FlowyIconType.icon, '');
widget.onSelected(
const EmojiPickerResult(
FlowyIconType.icon,
'',
),
);
},
),
],
@ -58,7 +73,12 @@ class _FlowyIconPickerState extends State<FlowyIconPicker>
children: [
FlowyEmojiPicker(
onEmojiSelected: (_, emoji) {
widget.onSelected(FlowyIconType.emoji, emoji);
widget.onSelected(
EmojiPickerResult(
FlowyIconType.emoji,
emoji,
),
);
},
),
],

View File

@ -1,6 +1,5 @@
import 'package:appflowy/mobile/presentation/base/app_bar_actions.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@ -8,11 +7,10 @@ import 'package:go_router/go_router.dart';
class IconPickerPage extends StatefulWidget {
const IconPickerPage({
super.key,
required this.id,
required this.onSelected,
});
/// view id
final String id;
final void Function(EmojiPickerResult) onSelected;
@override
State<IconPickerPage> createState() => _IconPickerPageState();
@ -34,13 +32,7 @@ class _IconPickerPageState extends State<IconPickerPage> {
),
body: SafeArea(
child: FlowyIconPicker(
onSelected: (_, emoji) {
ViewBackendService.updateViewIcon(
viewId: widget.id,
viewIcon: emoji,
);
context.pop();
},
onSelected: widget.onSelected,
),
),
);

View File

@ -1,3 +1,4 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
@ -26,6 +27,9 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
final configuration = BlockComponentConfiguration(
padding: (_) => const EdgeInsets.symmetric(vertical: 5.0),
indentPadding: (node, textDirection) => textDirection == TextDirection.ltr
? const EdgeInsets.only(left: 26.0)
: const EdgeInsets.only(right: 26.0),
);
final customBlockComponentBuilderMap = {
@ -119,6 +123,14 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
DividerBlockKeys.type: DividerBlockComponentBuilder(
configuration: configuration,
height: 28.0,
wrapper: (context, node, child) {
return MobileBlockActionButtons(
showThreeDots: false,
node: node,
editorState: editorState,
child: child,
);
},
),
MathEquationBlockKeys.type: MathEquationBlockComponentBuilder(
configuration: configuration,
@ -146,9 +158,7 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
),
),
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
),
configuration: configuration,
),
};

View File

@ -286,10 +286,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
textDecorationMobileToolbarItem,
buildTextAndBackgroundColorMobileToolbarItem(),
headingMobileToolbarItem,
todoListMobileToolbarItem,
listMobileToolbarItem,
customListMobileToolbarItem,
linkMobileToolbarItem,
quoteMobileToolbarItem,
dividerMobileToolbarItem,
imageMobileToolbarItem,
mathEquationMobileToolbarItem,

View File

@ -21,6 +21,7 @@ class MobileBlockActionButtons extends StatelessWidget {
const MobileBlockActionButtons({
super.key,
this.extendActionWidgets = const [],
this.showThreeDots = true,
required this.node,
required this.editorState,
required this.child,
@ -30,6 +31,7 @@ class MobileBlockActionButtons extends StatelessWidget {
final EditorState editorState;
final List<Widget> extendActionWidgets;
final Widget child;
final bool showThreeDots;
@override
Widget build(BuildContext context) {
@ -37,7 +39,15 @@ class MobileBlockActionButtons extends StatelessWidget {
return child;
}
const padding = 5.0;
if (!showThreeDots) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showBottomSheet(context),
child: child,
);
}
const padding = 10.0;
return Stack(
children: [
child,

View File

@ -1,7 +1,11 @@
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class EmojiPickerButton extends StatelessWidget {
EmojiPickerButton({
@ -15,11 +19,12 @@ class EmojiPickerButton extends StatelessWidget {
final String emoji;
final double emojiSize;
final Size emojiPickerSize;
final void Function(String emoji, PopoverController controller) onSubmitted;
final void Function(String emoji, PopoverController? controller) onSubmitted;
final PopoverController popoverController = PopoverController();
@override
Widget build(BuildContext context) {
if (PlatformExtension.isDesktopOrWeb) {
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.click,
@ -27,7 +32,15 @@ class EmojiPickerButton extends StatelessWidget {
width: emojiPickerSize.width,
height: emojiPickerSize.height,
),
popupBuilder: (context) => _buildEmojiPicker(),
popupBuilder: (context) => Container(
width: emojiPickerSize.width,
height: emojiPickerSize.height,
padding: const EdgeInsets.all(4.0),
child: EmojiSelectionMenu(
onSubmitted: (emoji) => onSubmitted(emoji, popoverController),
onExit: () {},
),
),
child: FlowyTextButton(
emoji,
overflow: TextOverflow.visible,
@ -41,17 +54,27 @@ class EmojiPickerButton extends StatelessWidget {
},
),
);
}
Widget _buildEmojiPicker() {
return Container(
width: emojiPickerSize.width,
height: emojiPickerSize.height,
padding: const EdgeInsets.all(4.0),
child: EmojiSelectionMenu(
onSubmitted: (emoji) => onSubmitted(emoji, popoverController),
onExit: () {},
),
} else {
return FlowyTextButton(
emoji,
overflow: TextOverflow.visible,
fontSize: emojiSize,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 35.0),
fillColor: Colors.transparent,
mainAxisAlignment: MainAxisAlignment.center,
onPressed: () async {
final result = await context.push<EmojiPickerResult>(
MobileEmojiPickerScreen.routeName,
);
if (result != null) {
onSubmitted(
result.emoji,
null,
);
}
},
);
}
}
}

View File

@ -187,7 +187,7 @@ class _CalloutBlockComponentWidgetState
emoji: emoji,
onSubmitted: (emoji, controller) {
setEmoji(emoji);
controller.close();
controller?.close();
},
),
),

View File

@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
@ -8,6 +9,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ErrorBlockComponentBuilder extends BlockComponentBuilder {
ErrorBlockComponentBuilder({
@ -72,12 +74,16 @@ class _DividerBlockComponentWidgetState extends State<ErrorBlockComponentWidget>
ClipboardServiceData(plainText: jsonEncode(node.toJson())),
);
},
text: Container(
height: 48,
alignment: Alignment.center,
child: FlowyText(
text: SizedBox(
height: 52,
child: Row(
children: [
const HSpace(4),
FlowyText(
LocaleKeys.document_errorBlock_theBlockIsNotSupported.tr(),
),
],
),
),
),
);
@ -95,6 +101,14 @@ class _DividerBlockComponentWidgetState extends State<ErrorBlockComponentWidget>
);
}
if (PlatformExtension.isMobile) {
child = MobileBlockActionButtons(
node: node,
editorState: context.read<EditorState>(),
child: child,
);
}
return child;
}
}

View File

@ -6,7 +6,6 @@ import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -16,7 +15,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'cover_editor.dart';
@ -303,15 +301,14 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
),
onTap: PlatformExtension.isDesktop
? null
: () => context.push(
Uri(
path: MobileEmojiPickerScreen.routeName,
queryParameters: {
MobileEmojiPickerScreen.viewId:
context.read<ViewBloc>().state.view.id,
: () async {
final result = await context.push<EmojiPickerResult>(
MobileEmojiPickerScreen.routeName,
);
if (result != null) {
widget.onCoverChanged(icon: result.emoji);
}
},
).toString(),
),
);
if (PlatformExtension.isDesktop) {
@ -325,8 +322,8 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
popupBuilder: (BuildContext popoverContext) {
isPopoverOpen = true;
return FlowyIconPicker(
onSelected: (type, value) {
widget.onCoverChanged(icon: value);
onSelected: (result) {
widget.onCoverChanged(icon: result.emoji);
_popoverController.close();
},
);
@ -532,8 +529,8 @@ class _DocumentIconState extends State<DocumentIcon> {
child: child,
popupBuilder: (BuildContext popoverContext) {
return FlowyIconPicker(
onSelected: (type, value) {
widget.onIconChanged(value);
onSelected: (result) {
widget.onIconChanged(result.emoji);
_popoverController.close();
},
);
@ -542,15 +539,14 @@ class _DocumentIconState extends State<DocumentIcon> {
} else {
child = GestureDetector(
child: child,
onTap: () => context.push(
Uri(
path: MobileEmojiPickerScreen.routeName,
queryParameters: {
MobileEmojiPickerScreen.viewId:
context.read<ViewBloc>().state.view.id,
onTap: () async {
final result = await context.push<EmojiPickerResult>(
MobileEmojiPickerScreen.routeName,
);
if (result != null) {
widget.onIconChanged(result.emoji);
}
},
).toString(),
),
);
}

View File

@ -0,0 +1,121 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
final customListMobileToolbarItem = MobileToolbarItem.withMenu(
itemIcon: const AFMobileIcon(afMobileIcons: AFMobileIcons.list),
itemMenuBuilder: (editorState, selection, _) {
return _MobileListMenu(
editorState: editorState,
selection: selection,
);
},
);
class _MobileListMenu extends StatelessWidget {
const _MobileListMenu({
required this.editorState,
required this.selection,
});
final Selection selection;
final EditorState editorState;
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 5,
shrinkWrap: true,
children: [
// bulleted list, numbered list
_buildListButton(
context,
BulletedListBlockKeys.type,
const AFMobileIcon(afMobileIcons: AFMobileIcons.bulletedList),
LocaleKeys.document_plugins_bulletedList.tr(),
),
_buildListButton(
context,
NumberedListBlockKeys.type,
const AFMobileIcon(afMobileIcons: AFMobileIcons.numberedList),
LocaleKeys.document_plugins_numberedList.tr(),
),
// todo list, quote list
_buildListButton(
context,
TodoListBlockKeys.type,
const AFMobileIcon(afMobileIcons: AFMobileIcons.checkbox),
LocaleKeys.document_plugins_todoList.tr(),
),
_buildListButton(
context,
QuoteBlockKeys.type,
const AFMobileIcon(afMobileIcons: AFMobileIcons.quote),
LocaleKeys.document_plugins_quoteList.tr(),
),
// toggle list, callout
_buildListButton(
context,
ToggleListBlockKeys.type,
const FlowySvg(
FlowySvgs.toggle_list_s,
size: Size.square(24),
),
LocaleKeys.document_plugins_toggleList.tr(),
),
_buildListButton(
context,
CalloutBlockKeys.type,
const Icon(Icons.note_rounded),
LocaleKeys.document_plugins_callout.tr(),
),
],
);
}
Widget _buildListButton(
BuildContext context,
String listBlockType,
Widget icon,
String label,
) {
final node = editorState.getNodeAtPath(selection.start.path);
final type = node?.type;
if (node == null || type == null) {
const SizedBox.shrink();
}
final isSelected = type == listBlockType;
return MobileToolbarItemMenuBtn(
icon: icon,
label: FlowyText(label),
isSelected: isSelected,
onPressed: () async {
await editorState.formatNode(
selection,
(node) {
final attributes = {
ParagraphBlockKeys.delta: (node.delta ?? Delta()).toJson(),
if (listBlockType == TodoListBlockKeys.type)
TodoListBlockKeys.checked: false,
if (listBlockType == CalloutBlockKeys.type)
CalloutBlockKeys.icon: '📌',
};
return node.copyWith(
type: isSelected ? ParagraphBlockKeys.type : listBlockType,
attributes: attributes,
);
},
);
},
);
}
}

View File

@ -25,7 +25,8 @@ export 'image/mobile_image_toolbar_item.dart';
export 'inline_math_equation/inline_math_equation.dart';
export 'inline_math_equation/inline_math_equation_toolbar_item.dart';
export 'math_equation/math_equation_block_component.dart';
export 'math_equation/mobile_math_eqaution_toolbar_item.dart';
export 'math_equation/mobile_math_equation_toolbar_item.dart';
export 'mobile_toolbar_item/list_mobile_toolbar_item.dart';
export 'openai/widgets/auto_completion_node_widget.dart';
export 'openai/widgets/smart_edit_node_widget.dart';
export 'openai/widgets/smart_edit_toolbar_item.dart';

View File

@ -166,15 +166,19 @@ class _ToggleListBlockComponentWidgetState
Container(
constraints: const BoxConstraints(minWidth: 26, minHeight: 22),
padding: const EdgeInsets.only(right: 4.0),
child: AnimatedRotation(
turns: collapsed ? 0.0 : 0.25,
duration: const Duration(milliseconds: 200),
child: FlowyIconButton(
width: 18.0,
icon: Icon(
collapsed ? Icons.arrow_right : Icons.arrow_drop_down,
icon: const Icon(
Icons.arrow_right,
size: 18.0,
),
onPressed: onCollapsed,
),
),
),
Flexible(
child: AppFlowyRichText(

View File

@ -209,11 +209,8 @@ GoRoute _mobileEmojiPickerPageRoute() {
parentNavigatorKey: AppGlobals.rootNavKey,
path: MobileEmojiPickerScreen.routeName,
pageBuilder: (context, state) {
final id = state.uri.queryParameters[MobileEmojiPickerScreen.viewId]!;
return MaterialPage(
child: MobileEmojiPickerScreen(
id: id,
),
return const MaterialPage(
child: MobileEmojiPickerScreen(),
);
},
);

View File

@ -361,10 +361,10 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
popupBuilder: (context) {
isIconPickerOpened = true;
return FlowyIconPicker(
onSelected: (_, emoji) {
onSelected: (result) {
ViewBackendService.updateViewIcon(
viewId: widget.view.id,
viewIcon: emoji,
viewIcon: result.emoji,
);
controller.close();
},

View File

@ -54,8 +54,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: a47fc6f
resolved-ref: a47fc6fc712b06991f578ae2ab314cbe23034e96
ref: "50117b6"
resolved-ref: "50117b6900e4b239603ee48f6f3e7b7bc603c865"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "1.5.2"

View File

@ -47,7 +47,8 @@ dependencies:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: a47fc6f
ref: 50117b6
appflowy_popover:
path: packages/appflowy_popover

View File

@ -616,7 +616,12 @@
"smartEditDisabled": "Connect OpenAI in Settings",
"discardResponse": "Do you want to discard the AI responses?",
"createInlineMathEquation": "Create equation",
"toggleList": "Toggle List",
"toggleList": "Toggle list",
"quoteList":"Quote list",
"numberedList":"Numbered list",
"bulletedList":"Bulleted list",
"todoList": "Todo List",
"callout": "Callout",
"cover": {
"changeCover": "Change Cover",
"colors": "Colors",