Skip to content

Commit

Permalink
feat: add toggle heading in plus menu on mobile (#6784)
Browse files Browse the repository at this point in the history
* chore: add logs in space bloc

* feat: add toggle headings in plus menu

* test: add toggle heading block test

* test: toogle heading 1 block test

* test: add toggle heading selection test

* fix: toggle headings test

* chore: update new toggle heading icons
  • Loading branch information
LucasXu0 authored Nov 14, 2024
1 parent 7600961 commit 555d08e
Show file tree
Hide file tree
Showing 12 changed files with 251 additions and 20 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:integration_test/integration_test.dart';

import 'page_style_test.dart' as page_style_test;
import 'plus_menu_test.dart' as plus_menu_test;
import 'title_test.dart' as title_test;

void main() {
Expand All @@ -9,4 +10,5 @@ void main() {
// Document integration tests
title_test.main();
page_style_test.main();
plus_menu_test.main();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import 'dart:async';

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:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import '../../shared/util.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('document plus menu:', () {
testWidgets('add the toggle heading blocks via plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('toggle heading blocks');

final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();

// open the plus menu and select the toggle heading block
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_toggleHeading1.tr(),
);

// check the block is inserted
final block1 = editorState.getNodeAtPath([0])!;
expect(block1.type, equals(ToggleListBlockKeys.type));
expect(block1.attributes[ToggleListBlockKeys.level], equals(1));

// click the expand button won't cancel the selection
await tester.tapButton(find.byIcon(Icons.arrow_right));
expect(
editorState.selection,
equals(Selection.collapsed(Position(path: [0]))),
);

// focus on the next line
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [1])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();

// open the plus menu and select the toggle heading block
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_toggleHeading2.tr(),
);

// check the block is inserted
final block2 = editorState.getNodeAtPath([1])!;
expect(block2.type, equals(ToggleListBlockKeys.type));
expect(block2.attributes[ToggleListBlockKeys.level], equals(2));

// focus on the next line
await tester.pumpAndSettle();

// open the plus menu and select the toggle heading block
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_toggleHeading3.tr(),
);

// check the block is inserted
final block3 = editorState.getNodeAtPath([2])!;
expect(block3.type, equals(ToggleListBlockKeys.type));
expect(block3.attributes[ToggleListBlockKeys.level], equals(3));

// wait a few milliseconds to ensure the selection is updated
await Future.delayed(const Duration(milliseconds: 100));
// check the selection is collapsed
expect(
editorState.selection,
equals(Selection.collapsed(Position(path: [2]))),
);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart';
Expand Down Expand Up @@ -42,6 +44,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:universal_platform/universal_platform.dart';

import 'emoji.dart';
import 'util.dart';
Expand Down Expand Up @@ -787,6 +790,57 @@ extension CommonOperations on WidgetTester {
await tap(finder);
await pumpAndSettle(const Duration(seconds: 2));
}

/// Create a new document on mobile
Future<void> createNewDocumentOnMobile(String name) async {
final createPageButton = find.byKey(
BottomNavigationBarItemType.add.valueKey,
);
await tapButton(createPageButton);
expect(find.byType(MobileDocumentScreen), findsOneWidget);

final title = editor.findDocumentTitle('');
expect(title, findsOneWidget);
final textField = widget<TextField>(title);
expect(textField.focusNode!.hasFocus, isTrue);

// input new name and press done button
await enterText(title, name);
await testTextInput.receiveAction(TextInputAction.done);
await pumpAndSettle();
final newTitle = editor.findDocumentTitle(name);
expect(newTitle, findsOneWidget);
expect(textField.controller!.text, name);
}

/// Open the plus menu
Future<void> openPlusMenuAndClickButton(String buttonName) async {
assert(
UniversalPlatform.isMobile,
'This method is only supported on mobile platforms',
);

final plusMenuButton = find.byKey(addBlockToolbarItemKey);
final addMenuItem = find.byType(AddBlockMenu);
await tapButton(plusMenuButton);
await pumpUntilFound(addMenuItem);

final toggleHeading1 = find.byWidgetPredicate(
(widget) =>
widget is TypeOptionMenuItem && widget.value.text == buttonName,
);
final scrollable = find.ancestor(
of: find.byType(TypeOptionGridView),
matching: find.byType(Scrollable),
);
await scrollUntilVisible(
toggleHeading1,
100,
scrollable: scrollable,
);
await tapButton(toggleHeading1);
await pumpUntilNotFound(addMenuItem);
}
}

extension SettingsFinder on CommonFinders {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@ class TypeOptionMenuItemValue<T> {
required this.text,
required this.backgroundColor,
required this.onTap,
this.iconPadding,
});

final T value;
final FlowySvgData icon;
final String text;
final Color backgroundColor;
final EdgeInsets? iconPadding;
final void Function(BuildContext context, T value) onTap;
}

class TypeOptionMenu<T> extends StatelessWidget {
const TypeOptionMenu({
super.key,
required this.values,
this.width = 94,
this.width = 98,
this.iconWidth = 72,
this.scaleFactor = 1.0,
this.maxAxisSpacing = 18,
Expand All @@ -39,36 +41,40 @@ class TypeOptionMenu<T> extends StatelessWidget {

@override
Widget build(BuildContext context) {
return _GridView(
return TypeOptionGridView(
crossAxisCount: crossAxisCount,
mainAxisSpacing: maxAxisSpacing * scaleFactor,
itemWidth: width * scaleFactor,
children: values
.map(
(value) => _TypeOptionMenuItem<T>(
(value) => TypeOptionMenuItem<T>(
value: value,
width: width,
iconWidth: iconWidth,
scaleFactor: scaleFactor,
iconPadding: value.iconPadding,
),
)
.toList(),
);
}
}

class _TypeOptionMenuItem<T> extends StatelessWidget {
const _TypeOptionMenuItem({
class TypeOptionMenuItem<T> extends StatelessWidget {
const TypeOptionMenuItem({
super.key,
required this.value,
this.width = 94,
this.iconWidth = 72,
this.scaleFactor = 1.0,
this.iconPadding,
});

final TypeOptionMenuItemValue<T> value;
final double iconWidth;
final double width;
final double scaleFactor;
final EdgeInsets? iconPadding;

double get scaledIconWidth => iconWidth * scaleFactor;
double get scaledWidth => width * scaleFactor;
Expand All @@ -88,7 +94,8 @@ class _TypeOptionMenuItem<T> extends StatelessWidget {
borderRadius: BorderRadius.circular(24 * scaleFactor),
),
),
padding: EdgeInsets.all(21 * scaleFactor),
padding: EdgeInsets.all(21 * scaleFactor) +
(iconPadding ?? EdgeInsets.zero),
child: FlowySvg(
value.icon,
),
Expand All @@ -113,8 +120,9 @@ class _TypeOptionMenuItem<T> extends StatelessWidget {
}
}

class _GridView extends StatelessWidget {
const _GridView({
class TypeOptionGridView extends StatelessWidget {
const TypeOptionGridView({
super.key,
required this.children,
required this.crossAxisCount,
required this.mainAxisSpacing,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,11 @@ class _TurnInfoButton extends StatelessWidget {
} else if (type == ToggleListBlockKeys.type) {
switch (level) {
case 1:
return FlowySvgs.slash_menu_icon_h1_s;
return FlowySvgs.toggle_heading1_s;
case 2:
return FlowySvgs.slash_menu_icon_h2_s;
return FlowySvgs.toggle_heading2_s;
case 3:
return FlowySvgs.slash_menu_icon_h3_s;
return FlowySvgs.toggle_heading3_s;
default:
return FlowySvgs.slash_menu_icon_toggle_s;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import 'dart:async';

import 'package:flutter/material.dart';

import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart';
Expand All @@ -19,11 +17,16 @@ import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

@visibleForTesting
const addBlockToolbarItemKey = ValueKey('add_block_toolbar_item');

final addBlockToolbarItem = AppFlowyMobileToolbarItem(
itemBuilder: (context, editorState, service, __, onAction) {
return AppFlowyMobileToolbarIconItem(
key: addBlockToolbarItemKey,
editorState: editorState,
icon: FlowySvgs.m_toolbar_add_m,
onTap: () {
Expand Down Expand Up @@ -75,12 +78,13 @@ Future<bool?> showAddBlockMenu(
enableDraggableScrollable: true,
builder: (_) => Padding(
padding: EdgeInsets.all(16 * context.scale),
child: _AddBlockMenu(selection: selection, editorState: editorState),
child: AddBlockMenu(selection: selection, editorState: editorState),
),
);

class _AddBlockMenu extends StatelessWidget {
const _AddBlockMenu({
class AddBlockMenu extends StatelessWidget {
const AddBlockMenu({
super.key,
required this.selection,
required this.editorState,
});
Expand All @@ -100,7 +104,32 @@ class _AddBlockMenu extends StatelessWidget {
AppGlobals.rootNavKey.currentContext?.pop(true);
Future.delayed(
const Duration(milliseconds: 100),
() => editorState.insertBlockAfterCurrentSelection(selection, node),
() async {
// if current selected block is a empty paragraph block, replace it with the new block.
if (selection.isCollapsed) {
final currentNode = editorState.getNodeAtPath(selection.end.path);
final text = currentNode?.delta?.toPlainText();
if (currentNode != null &&
currentNode.type == ParagraphBlockKeys.type &&
text != null &&
text.isEmpty) {
final transaction = editorState.transaction;
transaction.insertNode(
selection.end.path.next,
node,
);
transaction.deleteNode(currentNode);
transaction.afterSelection = Selection.collapsed(
Position(path: selection.end.path),
);
transaction.selectionExtraInfo = {};
await editorState.apply(transaction);
return;
}
}

await editorState.insertBlockAfterCurrentSelection(selection, node);
},
);
}

Expand Down Expand Up @@ -182,6 +211,32 @@ class _AddBlockMenu extends StatelessWidget {
onTap: (_, __) => _insertBlock(toggleListBlockNode()),
),

// toggle headings
TypeOptionMenuItemValue(
value: ToggleListBlockKeys.type,
backgroundColor: colorMap[ToggleListBlockKeys.type]!,
text: LocaleKeys.document_slashMenu_name_toggleHeading1.tr(),
icon: FlowySvgs.toggle_heading1_s,
iconPadding: const EdgeInsets.all(3),
onTap: (_, __) => _insertBlock(toggleHeadingNode()),
),
TypeOptionMenuItemValue(
value: ToggleListBlockKeys.type,
backgroundColor: colorMap[ToggleListBlockKeys.type]!,
text: LocaleKeys.document_slashMenu_name_toggleHeading2.tr(),
icon: FlowySvgs.toggle_heading2_s,
iconPadding: const EdgeInsets.all(3),
onTap: (_, __) => _insertBlock(toggleHeadingNode(level: 2)),
),
TypeOptionMenuItemValue(
value: ToggleListBlockKeys.type,
backgroundColor: colorMap[ToggleListBlockKeys.type]!,
text: LocaleKeys.document_slashMenu_name_toggleHeading3.tr(),
icon: FlowySvgs.toggle_heading3_s,
iconPadding: const EdgeInsets.all(3),
onTap: (_, __) => _insertBlock(toggleHeadingNode(level: 3)),
),

// image
TypeOptionMenuItemValue(
value: ImageBlockKeys.type,
Expand Down
Loading

0 comments on commit 555d08e

Please sign in to comment.