integrate board plugin into document (#1675)

* fix: cursor doesn't blink when opening selection menu

* feat: add board plugin

* feat: integrate board plugin into document

* feat: add i10n and fix known bugs

* feat: support jump to board page on document

* feat: disable editor scroll only when the board plugin is selected

* chore: dart fix

* chore: remove unused files

* fix: dart lint
This commit is contained in:
Lucas.Xu 2023-01-30 12:22:13 +07:00 committed by GitHub
parent 0d8adaa921
commit 5de3912fe3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 488 additions and 43 deletions

View File

@ -314,12 +314,18 @@
"date": {
"timeHintTextInTwelveHour": "01:00 PM",
"timeHintTextInTwentyFourHour": "13:00"
},
"slashMenu": {
"board": {
"selectABoardToLinkTo": "Select a board to link to"
}
}
},
"board": {
"column": {
"create_new_card": "New"
}
},
"menuName": "Board"
},
"calendar": {
"menuName": "Calendar",

View File

@ -1,8 +1,10 @@
import 'package:app_flowy/generated/locale_keys.g.dart';
import 'package:app_flowy/plugins/util.dart';
import 'package:app_flowy/startup/plugin/plugin.dart';
import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
import 'package:app_flowy/workspace/presentation/widgets/left_bar_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:app_flowy/startup/plugin/plugin.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'presentation/board_page.dart';
@ -18,7 +20,7 @@ class BoardPluginBuilder implements PluginBuilder {
}
@override
String get menuName => "Board";
String get menuName => LocaleKeys.board_menuName.tr();
@override
String get menuIcon => "editor/board";

View File

@ -4,34 +4,40 @@ import 'dart:collection';
import 'package:app_flowy/generated/locale_keys.g.dart';
import 'package:app_flowy/plugins/board/application/card/card_data_controller.dart';
import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
import 'package:app_flowy/plugins/grid/application/field/field_controller.dart';
import 'package:app_flowy/plugins/grid/application/row/row_cache.dart';
import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart';
import 'package:app_flowy/plugins/grid/presentation/widgets/cell/cell_builder.dart';
import 'package:app_flowy/plugins/grid/presentation/widgets/row/row_detail.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/flowy_infra_ui_web.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-grid/field_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-grid/row_entities.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra_ui/flowy_infra_ui_web.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../application/board_bloc.dart';
import 'card/card.dart';
import 'card/card_cell_builder.dart';
import 'toolbar/board_toolbar.dart';
class BoardPage extends StatelessWidget {
final ViewPB view;
BoardPage({
required this.view,
Key? key,
this.onEditStateChanged,
}) : super(key: ValueKey(view.id));
final ViewPB view;
/// Called when edit state changed
final VoidCallback? onEditStateChanged;
@override
Widget build(BuildContext context) {
return BlocProvider(
@ -45,7 +51,9 @@ class BoardPage extends StatelessWidget {
const Center(child: CircularProgressIndicator.adaptive()),
finish: (result) {
return result.successOrFail.fold(
(_) => const BoardContent(),
(_) => BoardContent(
onEditStateChanged: onEditStateChanged,
),
(err) => FlowyErrorPage(err.toString()),
);
},
@ -57,7 +65,12 @@ class BoardPage extends StatelessWidget {
}
class BoardContent extends StatefulWidget {
const BoardContent({Key? key}) : super(key: key);
const BoardContent({
Key? key,
this.onEditStateChanged,
}) : super(key: key);
final VoidCallback? onEditStateChanged;
@override
State<BoardContent> createState() => _BoardContentState();
@ -79,7 +92,10 @@ class _BoardContentState extends State<BoardContent> {
@override
Widget build(BuildContext context) {
return BlocListener<BoardBloc, BoardState>(
listener: (context, state) => _handleEditStateChanged(state, context),
listener: (context, state) {
_handleEditStateChanged(state, context);
widget.onEditStateChanged?.call();
},
child: BlocBuilder<BoardBloc, BoardState>(
buildWhen: (previous, current) => previous.groupIds != current.groupIds,
builder: (context, state) {

View File

@ -1,3 +1,5 @@
import 'package:app_flowy/plugins/document/presentation/plugins/board/board_menu_item.dart';
import 'package:app_flowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@ -97,7 +99,6 @@ class _DocumentPageState extends State<DocumentPage> {
Widget _renderAppFlowyEditor(EditorState editorState) {
final theme = Theme.of(context);
final editorMaxWidth = MediaQuery.of(context).size.width * 0.6;
final editor = AppFlowyEditor(
editorState: editorState,
autoFocus: editorState.document.isEmpty,
@ -108,6 +109,8 @@ class _DocumentPageState extends State<DocumentPage> {
kMathEquationType: MathEquationNodeWidgetBuidler(),
// Code Block
kCodeBlockType: CodeBlockNodeWidgetBuilder(),
// Board
kBoardType: BoardNodeWidgetBuilder(),
// Card
kCalloutType: CalloutNodeWidgetBuilder(),
},
@ -128,6 +131,8 @@ class _DocumentPageState extends State<DocumentPage> {
codeBlockMenuItem,
// Emoji
emojiMenuItem,
// Board
boardMenuItem,
],
themeData: theme.copyWith(extensions: [
...theme.extensions.values,
@ -138,8 +143,8 @@ class _DocumentPageState extends State<DocumentPage> {
return Expanded(
child: Center(
child: Container(
constraints: BoxConstraints(
maxWidth: editorMaxWidth,
constraints: const BoxConstraints(
maxWidth: double.infinity,
),
child: editor,
),

View File

@ -9,7 +9,7 @@ EditorStyle customEditorTheme(BuildContext context) {
? EditorStyle.dark
: EditorStyle.light;
editorStyle = editorStyle.copyWith(
padding: const EdgeInsets.all(0),
padding: const EdgeInsets.symmetric(horizontal: 40),
textStyle: editorStyle.textStyle?.copyWith(
fontFamily: 'poppins',
fontSize: documentStyle.fontSize,

View File

@ -0,0 +1,195 @@
import 'package:app_flowy/generated/locale_keys.g.dart';
import 'package:app_flowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
import 'package:app_flowy/workspace/application/app/app_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/app.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
SelectionMenuItem boardMenuItem = SelectionMenuItem(
name: () => LocaleKeys.board_menuName.tr(),
icon: (editorState, onSelected) {
return svgWidget(
'editor/board',
size: const Size.square(18.0),
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
);
},
keywords: ['board'],
handler: _showLinkToPageMenu,
);
EditorState? _editorState;
OverlayEntry? _linkToPageMenu;
void _dismissLinkToPageMenu() {
_linkToPageMenu?.remove();
_linkToPageMenu = null;
_editorState?.service.selectionService.currentSelection
.removeListener(_dismissLinkToPageMenu);
_editorState = null;
}
void _showLinkToPageMenu(
EditorState editorState,
SelectionMenuService menuService,
BuildContext context,
) {
final aligment = menuService.alignment;
final offset = menuService.offset;
menuService.dismiss();
_editorState = editorState;
_linkToPageMenu?.remove();
_linkToPageMenu = OverlayEntry(builder: (context) {
return Positioned(
top: aligment == Alignment.bottomLeft ? offset.dy : null,
bottom: aligment == Alignment.topLeft ? offset.dy : null,
left: offset.dx,
child: Material(
color: Colors.transparent,
child: LinkToPageMenu(
editorState: editorState,
),
),
);
});
Overlay.of(context)?.insert(_linkToPageMenu!);
editorState.service.selectionService.currentSelection
.addListener(_dismissLinkToPageMenu);
}
class LinkToPageMenu extends StatefulWidget {
final EditorState editorState;
const LinkToPageMenu({
super.key,
required this.editorState,
});
@override
State<LinkToPageMenu> createState() => _LinkToPageMenuState();
}
class _LinkToPageMenuState extends State<LinkToPageMenu> {
EditorStyle get style => widget.editorState.editorStyle;
@override
Widget build(BuildContext context) {
return Container(
color: Colors.transparent,
width: 300,
child: Container(
padding: const EdgeInsets.fromLTRB(10, 6, 10, 6),
decoration: BoxDecoration(
color: style.selectionMenuBackgroundColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: _buildBoardListWidget(context),
),
);
}
Future<List<dartz.Tuple2<AppPB, List<ViewPB>>>> fetchBoards() async {
return AppService().fetchViews(ViewLayoutTypePB.Board);
}
Widget _buildBoardListWidget(BuildContext context) {
return FutureBuilder<List<dartz.Tuple2<AppPB, List<ViewPB>>>>(
builder: (context, snapshot) {
if (snapshot.hasData &&
snapshot.connectionState == ConnectionState.done) {
final apps = snapshot.data;
final children = <Widget>[
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: FlowyText.regular(
LocaleKeys.document_slashMenu_board_selectABoardToLinkTo.tr(),
fontSize: 10,
color: Colors.grey,
),
),
];
if (apps != null && apps.isNotEmpty) {
for (final app in apps) {
if (app.value2.isNotEmpty) {
children.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: FlowyText.regular(
app.value1.name,
),
),
);
for (final board in app.value2) {
children.add(
FlowyButton(
leftIcon: svgWidget(
'editor/board',
color: Theme.of(context).colorScheme.onSurface,
),
text: FlowyText.regular(board.name),
onTap: () => widget.editorState.insertBoard(
app.value1,
board,
),
),
);
}
}
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
future: fetchBoards(),
);
}
}
extension on EditorState {
void insertBoard(AppPB appPB, ViewPB viewPB) {
final selection = service.selectionService.currentSelection.value;
final textNodes =
service.selectionService.currentSelectedNodes.whereType<TextNode>();
if (selection == null || textNodes.isEmpty) {
return;
}
final transaction = this.transaction;
transaction.insertNode(
selection.end.path,
Node(
type: kBoardType,
attributes: {
kAppID: appPB.id,
kBoardID: viewPB.id,
},
),
);
apply(transaction);
}
}

View File

@ -0,0 +1,175 @@
import 'package:app_flowy/plugins/board/presentation/board_page.dart';
import 'package:app_flowy/startup/startup.dart';
import 'package:app_flowy/workspace/application/app/app_service.dart';
import 'package:app_flowy/workspace/application/view/view_ext.dart';
import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
import 'package:app_flowy/workspace/presentation/home/menu/menu.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flutter/material.dart';
const String kBoardType = 'board';
const String kAppID = 'app_id';
const String kBoardID = 'board_id';
class BoardNodeWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _BoardWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return node.attributes[kBoardID] is String &&
node.attributes[kAppID] is String;
};
}
class _BoardWidget extends StatefulWidget {
const _BoardWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_BoardWidget> createState() => _BoardWidgetState();
}
class _BoardWidgetState extends State<_BoardWidget> with SelectableMixin {
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
String get boardID {
return widget.node.attributes[kBoardID];
}
String get appID {
return widget.node.attributes[kAppID];
}
late Future<dartz.Either<ViewPB, FlowyError>> board;
@override
void initState() {
super.initState();
board = _fetchBoard();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<dartz.Either<ViewPB, FlowyError>>(
builder: (context, snapshot) {
if (snapshot.hasData) {
final board = snapshot.data?.getLeftOrNull<ViewPB>();
if (board != null) {
return _buildBoard(context, board);
}
}
return const Center(
child: CircularProgressIndicator(),
);
},
future: board,
);
}
Future<dartz.Either<ViewPB, FlowyError>> _fetchBoard() async {
return AppService().getView(appID, boardID);
}
Widget _buildBoard(BuildContext context, ViewPB viewPB) {
return MouseRegion(
onHover: (event) {
if (widget.node.isSelected(widget.editorState)) {
widget.editorState.service.scrollService?.disable();
}
},
onExit: (event) {
widget.editorState.service.scrollService?.enable();
},
child: SizedBox(
height: 400,
child: Stack(
children: [
Positioned(
top: 0,
left: 20,
child: FlowyTextButton(
viewPB.name,
onPressed: () {
getIt<MenuSharedState>().latestOpenView = viewPB;
getIt<HomeStackManager>().setPlugin(viewPB.plugin());
},
),
),
BoardPage(
key: ValueKey(viewPB.id),
view: viewPB,
onEditStateChanged: () {
/// Clear selection when the edit state changes, otherwise the editor will prevent the keyboard event when the board is in edit mode.
widget.editorState.service.selectionService.clearSelection();
},
),
],
),
),
);
}
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.borderLine;
@override
Position start() {
return Position(path: widget.node.path, offset: 0);
}
@override
Position end() {
return Position(path: widget.node.path, offset: 0);
}
@override
Position getPositionInOffset(Offset start) {
return end();
}
@override
List<Rect> getRectsInSelection(Selection selection) {
return [Offset.zero & _renderBox.size];
}
@override
Rect? getCursorRectInPosition(Position position) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}
@override
Selection getSelectionInRange(Offset start, Offset end) {
return Selection.single(
path: widget.node.path,
startOffset: 0,
endOffset: 0,
);
}
@override
Offset localToGlobal(Offset offset) {
return _renderBox.localToGlobal(offset);
}
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@ -77,4 +78,52 @@ class AppService {
return FolderEventMoveFolderItem(payload).send();
}
Future<List<Tuple2<AppPB, List<ViewPB>>>> fetchViews(
ViewLayoutTypePB layoutType) async {
final result = <Tuple2<AppPB, List<ViewPB>>>[];
return FolderEventReadCurrentWorkspace().send().then((value) async {
final workspaces = value.getLeftOrNull<WorkspaceSettingPB>();
if (workspaces != null) {
final apps = workspaces.workspace.apps.items;
for (var app in apps) {
final views = await getViews(appId: app.id).then(
(value) => value
.getLeftOrNull<List<ViewPB>>()
?.where((e) => e.layout == layoutType)
.toList(),
);
if (views != null && views.isNotEmpty) {
result.add(Tuple2(app, views));
}
}
}
return result;
});
}
Future<Either<ViewPB, FlowyError>> getView(
String appID,
String viewID,
) async {
final payload = AppIdPB.create()..value = appID;
return FolderEventReadApp(payload).send().then((result) {
return result.fold(
(app) => left(
app.belongings.items.firstWhere((e) => e.id == viewID),
),
(error) => right(error),
);
});
}
}
extension AppFlowy on Either {
T? getLeftOrNull<T>() {
if (isLeft()) {
final result = fold<T?>((l) => l, (r) => null);
return result;
}
return null;
}
}

View File

@ -23,6 +23,7 @@
]
},
{ "type": "text", "delta": [] },
{ "type": "board" },
{
"type": "text",
"delta": [

View File

@ -44,3 +44,4 @@ export 'src/plugins/markdown/document_markdown.dart';
export 'src/plugins/quill_delta/delta_document_encoder.dart';
export 'src/commands/text/text_commands.dart';
export 'src/render/toolbar/toolbar_item.dart';
export 'src/extensions/node_extensions.dart';

View File

@ -71,7 +71,7 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
Attributes _attributes;
// Renderable
GlobalKey? key;
final key = GlobalKey();
final layerLink = LayerLink();
Attributes get attributes => {..._attributes};

View File

@ -1,17 +1,18 @@
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:flutter/material.dart';
extension NodeExtensions on Node {
RenderBox? get renderBox =>
key?.currentContext?.findRenderObject()?.unwrapOrNull<RenderBox>();
key.currentContext?.findRenderObject()?.unwrapOrNull<RenderBox>();
BuildContext? get context => key?.currentContext;
BuildContext? get context => key.currentContext;
SelectableMixin? get selectable =>
key?.currentState?.unwrapOrNull<SelectableMixin>();
key.currentState?.unwrapOrNull<SelectableMixin>();
bool inSelection(Selection selection) {
if (selection.start.path <= selection.end.path) {
@ -28,4 +29,11 @@ extension NodeExtensions on Node {
}
return Rect.zero;
}
bool isSelected(EditorState editorState) {
final currentSelectedNodes =
editorState.service.selectionService.currentSelectedNodes;
return currentSelectedNodes.length == 1 &&
currentSelectedNodes.first == this;
}
}

View File

@ -1,5 +1,4 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
import 'package:flutter/material.dart';
ShortcutEventHandler cursorLeftSelect = (editorState, event) {

View File

@ -74,8 +74,6 @@ class AppFlowyRenderPlugin extends AppFlowyRenderPluginService {
node.subtype == null ? node.type : '${node.type}/${node.subtype!}';
final builder = _builders[name];
if (builder != null && builder.nodeValidator(node)) {
final key = GlobalKey(debugLabel: name);
node.key = key;
return _autoUpdateNodeWidget(builder, context);
} else {
// Returns a SizeBox with 0 height if no builder found.

View File

@ -1,28 +1,18 @@
import 'dart:collection';
import 'dart:ui';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
class MockNode extends Mock implements Node {}
void main() {
final mockNode = MockNode();
group('NodeExtensions::', () {
final selection = Selection(
start: Position(path: [0]),
end: Position(path: [1]),
);
test('rect - renderBox is null', () {
when(mockNode.renderBox).thenReturn(null);
final result = mockNode.rect;
expect(result, Rect.zero);
});
test('inSelection', () {
// I use an empty implementation instead of mock, because the mocked
// version throws error trying to access the path.

View File

@ -43,7 +43,7 @@ void main() async {
final selection =
Selection.single(path: [0], startOffset: 0, endOffset: text.length);
var node = editor.nodeAtPath([0]) as TextNode;
var state = node.key?.currentState as DefaultSelectable;
var state = node.key.currentState as DefaultSelectable;
var checkboxWidget = find.byKey(state.iconKey!);
await tester.tap(checkboxWidget);
await tester.pumpAndSettle();
@ -56,7 +56,7 @@ void main() async {
expect(node.allSatisfyStrikethroughInSelection(selection), true);
node = editor.nodeAtPath([0]) as TextNode;
state = node.key?.currentState as DefaultSelectable;
state = node.key.currentState as DefaultSelectable;
await tester.ensureVisible(find.byKey(state.iconKey!));
await tester.tap(find.byKey(state.iconKey!));
await tester.pump();

View File

@ -21,7 +21,7 @@ void main() async {
await editor.startTesting();
final secondTextNode = editor.nodeAtPath([1]);
final finder = find.byKey(secondTextNode!.key!);
final finder = find.byKey(secondTextNode!.key);
final rect = tester.getRect(finder);
// tap at the beginning
@ -48,7 +48,7 @@ void main() async {
await editor.startTesting();
final secondTextNode = editor.nodeAtPath([1]);
final finder = find.byKey(secondTextNode!.key!);
final finder = find.byKey(secondTextNode!.key);
final rect = tester.getRect(finder);
// double tap
@ -70,7 +70,7 @@ void main() async {
await editor.startTesting();
final secondTextNode = editor.nodeAtPath([1]);
final finder = find.byKey(secondTextNode!.key!);
final finder = find.byKey(secondTextNode!.key);
final rect = tester.getRect(finder);
// triple tap
@ -93,7 +93,7 @@ void main() async {
await editor.startTesting();
final secondTextNode = editor.nodeAtPath([1]) as TextNode;
final finder = find.byKey(secondTextNode.key!);
final finder = find.byKey(secondTextNode.key);
final rect = tester.getRect(finder);
// secondary tap

View File

@ -47,7 +47,7 @@ SelectionMenuItem mathEquationMenuItem = SelectionMenuItem(
final mathEquationState = editorState.document
.nodeAtPath(mathEquationNodePath)
?.key
?.currentState;
.currentState;
if (mathEquationState != null &&
mathEquationState is _MathEquationNodeWidgetState) {
mathEquationState.showEditingDialog();

View File

@ -16,7 +16,7 @@ dependencies:
path: ../appflowy_editor
flowy_infra:
path: ../flowy_infra
flowy_infra_ui:
flowy_infra_ui:
path: ../flowy_infra_ui
appflowy_popover:
path: ../appflowy_popover