Feat/view map database (#1885)

* refactor: rename structs

* chore: read database id from view

* chore: fix open database error because of create a database view for database id

* chore: fix tests

* chore: rename datbase id to view id in flutter

* refactor: move grid and board to database view folder

* refactor: rename functions

* refactor: move calender to datbase view folder

* refactor: rename app_flowy to appflowy_flutter

* chore: reanming

* chore: fix freeze gen

* chore: remove todos

* refactor: view process events

* chore: add link database test

* chore: just open view if there is opened database
This commit is contained in:
Nathan.fooo
2023-02-26 16:27:17 +08:00
committed by GitHub
parent 6877607c5e
commit 61fd608200
2213 changed files with 43935 additions and 45507 deletions

View File

@ -0,0 +1,68 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/buttons/base_styled_button.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
class DocumentBanner extends StatelessWidget {
final void Function() onRestore;
final void Function() onDelete;
const DocumentBanner(
{required this.onRestore, required this.onDelete, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 60),
child: Container(
width: double.infinity,
color: Theme.of(context).colorScheme.primary,
child: FittedBox(
alignment: Alignment.center,
fit: BoxFit.scaleDown,
child: Row(
children: [
FlowyText.medium(LocaleKeys.deletePagePrompt_text.tr(),
color: Colors.white),
const HSpace(20),
BaseStyledButton(
minWidth: 160,
minHeight: 40,
contentPadding: EdgeInsets.zero,
bgColor: Colors.transparent,
hoverColor: Theme.of(context).colorScheme.primary,
downColor: Theme.of(context).colorScheme.primaryContainer,
outlineColor: Colors.white,
borderRadius: Corners.s8Border,
onPressed: onRestore,
child: FlowyText.medium(
LocaleKeys.deletePagePrompt_restore.tr(),
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 14,
)),
const HSpace(20),
BaseStyledButton(
minWidth: 220,
minHeight: 40,
contentPadding: EdgeInsets.zero,
bgColor: Colors.transparent,
hoverColor: Theme.of(context).colorScheme.primaryContainer,
downColor: Theme.of(context).colorScheme.primary,
outlineColor: Colors.white,
borderRadius: Corners.s8Border,
onPressed: onDelete,
child: FlowyText.medium(
LocaleKeys.deletePagePrompt_deletePermanent.tr(),
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 14,
)),
],
),
),
),
);
}
}

View File

@ -0,0 +1,40 @@
import 'package:bloc/bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _kDocumentAppearenceFontSize = 'kDocumentAppearenceFontSize';
class DocumentAppearance {
const DocumentAppearance({
required this.fontSize,
});
final double fontSize;
// Will be supported...
// final String fontName;
DocumentAppearance copyWith({double? fontSize}) {
return DocumentAppearance(
fontSize: fontSize ?? this.fontSize,
);
}
}
class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
DocumentAppearanceCubit() : super(const DocumentAppearance(fontSize: 14.0));
void fetch() async {
final prefs = await SharedPreferences.getInstance();
final fontSize = prefs.getDouble(_kDocumentAppearenceFontSize) ?? 14.0;
emit(state.copyWith(
fontSize: fontSize,
));
}
void syncFontSize(double fontSize) async {
final prefs = await SharedPreferences.getInstance();
prefs.setDouble(_kDocumentAppearenceFontSize, fontSize);
emit(state.copyWith(
fontSize: fontSize,
));
}
}

View File

@ -0,0 +1,69 @@
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:tuple/tuple.dart';
import 'package:easy_localization/easy_localization.dart';
class FontSizeSwitcher extends StatefulWidget {
const FontSizeSwitcher({
super.key,
});
@override
State<FontSizeSwitcher> createState() => _FontSizeSwitcherState();
}
class _FontSizeSwitcherState extends State<FontSizeSwitcher> {
final List<Tuple3<String, double, bool>> _fontSizes = [
Tuple3(LocaleKeys.moreAction_small.tr(), 12.0, false),
Tuple3(LocaleKeys.moreAction_medium.tr(), 14.0, true),
Tuple3(LocaleKeys.moreAction_large.tr(), 18.0, false),
];
@override
Widget build(BuildContext context) {
return BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText.semibold(
LocaleKeys.moreAction_fontSize.tr(),
fontSize: 12,
),
const SizedBox(
height: 5,
),
ToggleButtons(
isSelected:
_fontSizes.map((e) => e.item2 == state.fontSize).toList(),
onPressed: (int index) {
_updateSelectedFontSize(_fontSizes[index].item2);
},
borderRadius: const BorderRadius.all(Radius.circular(5)),
selectedBorderColor: Theme.of(context).colorScheme.primaryContainer,
selectedColor: Theme.of(context).colorScheme.onSurface,
fillColor: Theme.of(context).colorScheme.primaryContainer,
color: Theme.of(context).hintColor,
constraints: const BoxConstraints(
minHeight: 40.0,
minWidth: 80.0,
),
children: _fontSizes
.map((e) => Text(
e.item1,
style: TextStyle(fontSize: e.item2),
))
.toList(),
),
],
);
});
}
void _updateSelectedFontSize(double fontSize) {
context.read<DocumentAppearanceCubit>().syncFontSize(fontSize);
}
}

View File

@ -0,0 +1,35 @@
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy/plugins/document/presentation/more/font_size_switcher.dart';
import 'package:flowy_infra/image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class DocumentMoreButton extends StatelessWidget {
const DocumentMoreButton({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return PopupMenuButton<int>(
offset: const Offset(0, 30),
itemBuilder: (context) {
return [
PopupMenuItem(
value: 1,
enabled: false,
child: BlocProvider.value(
value: context.read<DocumentAppearanceCubit>(),
child: const FontSizeSwitcher(),
),
),
];
},
child: svgWidget(
'editor/details',
size: const Size(18, 18),
color: Theme.of(context).colorScheme.onSurface,
),
);
}
}

View File

@ -0,0 +1,193 @@
import 'package:appflowy/plugins/document/presentation/plugins/base/insert_page_command.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/app/app_service.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.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/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
class BuiltInPageWidget extends StatefulWidget {
const BuiltInPageWidget({
Key? key,
required this.node,
required this.editorState,
required this.builder,
}) : super(key: key);
final Node node;
final EditorState editorState;
final Widget Function(ViewPB viewPB) builder;
@override
State<BuiltInPageWidget> createState() => _BuiltInPageWidgetState();
}
class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
final focusNode = FocusNode();
String get gridID {
return widget.node.attributes[kViewID];
}
String get appID {
return widget.node.attributes[kAppID];
}
@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 _build(context, board);
}
}
return const Center(
child: CircularProgressIndicator(),
);
},
future: AppService().getView(appID, gridID),
);
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
Widget _build(BuildContext context, ViewPB viewPB) {
return MouseRegion(
onEnter: (event) {
widget.editorState.service.scrollService?.disable();
},
onExit: (event) {
widget.editorState.service.scrollService?.enable();
},
child: SizedBox(
height: 400,
child: Stack(
children: [
_buildMenu(context, viewPB),
_buildGrid(context, viewPB),
],
),
),
);
}
Widget _buildGrid(BuildContext context, ViewPB viewPB) {
return Focus(
focusNode: focusNode,
onFocusChange: (value) {
if (value) {
widget.editorState.service.selectionService.clearSelection();
}
},
child: widget.builder(viewPB),
);
}
Widget _buildMenu(BuildContext context, ViewPB viewPB) {
return Positioned(
top: 5,
left: 5,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// information
FlowyIconButton(
tooltipText: LocaleKeys.tooltip_referencePage.tr(namedArgs: {
'name': viewPB.layout.name,
}),
width: 24,
height: 24,
iconPadding: const EdgeInsets.all(3),
icon: svgWidget(
'common/information',
color: Theme.of(context).colorScheme.onSurface,
),
),
// Name
const Space(7, 0),
FlowyText.medium(
viewPB.name,
fontSize: 16.0,
),
// setting
const Space(7, 0),
PopoverActionList<_ActionWrapper>(
direction: PopoverDirection.bottomWithCenterAligned,
actions: _ActionType.values
.map((action) => _ActionWrapper(action))
.toList(),
buildChild: (controller) {
return FlowyIconButton(
tooltipText: LocaleKeys.tooltip_openMenu.tr(),
width: 24,
height: 24,
iconPadding: const EdgeInsets.all(3),
icon: svgWidget(
'common/settings',
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: () => controller.show(),
);
},
onSelected: (action, controller) async {
switch (action.inner) {
case _ActionType.viewDatabase:
getIt<MenuSharedState>().latestOpenView = viewPB;
getIt<HomeStackManager>().setPlugin(viewPB.plugin());
break;
case _ActionType.delete:
final transaction = widget.editorState.transaction;
transaction.deleteNode(widget.node);
widget.editorState.apply(transaction);
break;
}
controller.close();
},
)
],
),
);
}
}
enum _ActionType {
viewDatabase,
delete,
}
class _ActionWrapper extends ActionCell {
final _ActionType inner;
_ActionWrapper(this.inner);
Widget? icon(Color iconColor) => null;
@override
String get name {
switch (inner) {
case _ActionType.viewDatabase:
return LocaleKeys.tooltip_viewDataBase.tr();
case _ActionType.delete:
return LocaleKeys.disclosureAction_delete.tr();
}
}
}

View File

@ -0,0 +1,42 @@
import 'package:appflowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/grid/grid_node_widget.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';
const String kAppID = 'app_id';
const String kViewID = 'view_id';
extension InsertPage on EditorState {
void insertPage(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: _convertPageType(viewPB),
attributes: {
kAppID: appPB.id,
kViewID: viewPB.id,
},
),
);
apply(transaction);
}
String _convertPageType(ViewPB viewPB) {
switch (viewPB.layout) {
case ViewLayoutTypePB.Grid:
return kGridType;
case ViewLayoutTypePB.Board:
return kBoardType;
default:
throw Exception('Unknown layout type');
}
}
}

View File

@ -0,0 +1,186 @@
import 'package:appflowy/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: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';
import 'insert_page_command.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
EditorState? _editorState;
OverlayEntry? _linkToPageMenu;
void showLinkToPageMenu(
EditorState editorState,
SelectionMenuService menuService,
BuildContext context,
ViewLayoutTypePB pageType,
) {
final aligment = menuService.alignment;
final offset = menuService.offset;
menuService.dismiss();
_editorState = editorState;
String hintText = '';
switch (pageType) {
case ViewLayoutTypePB.Grid:
hintText = LocaleKeys.document_slashMenu_grid_selectAGridToLinkTo.tr();
break;
case ViewLayoutTypePB.Board:
hintText = LocaleKeys.document_slashMenu_board_selectABoardToLinkTo.tr();
break;
default:
throw Exception('Unknown layout type');
}
_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,
layoutType: pageType,
hintText: hintText,
onSelected: (appPB, viewPB) {
editorState.insertPage(appPB, viewPB);
},
),
),
);
});
Overlay.of(context)?.insert(_linkToPageMenu!);
editorState.service.selectionService.currentSelection
.addListener(dismissLinkToPageMenu);
}
void dismissLinkToPageMenu() {
_linkToPageMenu?.remove();
_linkToPageMenu = null;
_editorState?.service.selectionService.currentSelection
.removeListener(dismissLinkToPageMenu);
_editorState = null;
}
class LinkToPageMenu extends StatefulWidget {
const LinkToPageMenu({
super.key,
required this.editorState,
required this.layoutType,
required this.hintText,
required this.onSelected,
});
final EditorState editorState;
final ViewLayoutTypePB layoutType;
final String hintText;
final void Function(AppPB appPB, ViewPB viewPB) onSelected;
@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: _buildListWidget(context),
),
);
}
Widget _buildListWidget(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(
widget.hintText,
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 value in app.value2) {
children.add(
FlowyButton(
leftIcon: svgWidget(
_iconName(value),
color: Theme.of(context).colorScheme.onSurface,
),
text: FlowyText.regular(value.name),
onTap: () => widget.onSelected(app.value1, value),
),
);
}
}
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
future: AppService().fetchViews(widget.layoutType),
);
}
String _iconName(ViewPB viewPB) {
switch (viewPB.layout) {
case ViewLayoutTypePB.Grid:
return 'editor/grid';
case ViewLayoutTypePB.Board:
return 'editor/board';
default:
throw Exception('Unknown layout type');
}
}
}

View File

@ -0,0 +1,29 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/link_to_page_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flutter/material.dart';
SelectionMenuItem boardMenuItem = SelectionMenuItem(
name: () => LocaleKeys.document_plugins_referencedBoard.tr(),
icon: (editorState, onSelected) {
return svgWidget(
'editor/board',
size: const Size.square(18.0),
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
);
},
keywords: ['board', 'kanban'],
handler: (editorState, menuService, context) {
showLinkToPageMenu(
editorState,
menuService,
context,
ViewLayoutTypePB.Board,
);
},
);

View File

@ -0,0 +1,54 @@
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/built_in_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/insert_page_command.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
const String kBoardType = 'board';
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[kViewID] 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> {
@override
Widget build(BuildContext context) {
return BuiltInPageWidget(
node: widget.node,
editorState: widget.editorState,
builder: (viewPB) {
return BoardPage(
key: ValueKey(viewPB.id),
view: viewPB,
);
},
);
}
}

View File

@ -0,0 +1,29 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/link_to_page_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flutter/material.dart';
SelectionMenuItem gridMenuItem = SelectionMenuItem(
name: () => LocaleKeys.document_plugins_referencedGrid.tr(),
icon: (editorState, onSelected) {
return svgWidget(
'editor/grid',
size: const Size.square(18.0),
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
);
},
keywords: ['grid'],
handler: (editorState, menuService, context) {
showLinkToPageMenu(
editorState,
menuService,
context,
ViewLayoutTypePB.Grid,
);
},
);

View File

@ -0,0 +1,54 @@
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/built_in_page_widget.dart';
import 'package:appflowy/plugins/document/presentation/plugins/base/insert_page_command.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
const String kGridType = 'grid';
class GridNodeWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _GridWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return node.attributes[kAppID] is String &&
node.attributes[kViewID] is String;
};
}
class _GridWidget extends StatefulWidget {
const _GridWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_GridWidget> createState() => _GridWidgetState();
}
class _GridWidgetState extends State<_GridWidget> {
@override
Widget build(BuildContext context) {
return BuiltInPageWidget(
node: widget.node,
editorState: widget.editorState,
builder: (viewPB) {
return GridPage(
key: ValueKey(viewPB.id),
view: viewPB,
);
},
);
}
}

View File

@ -0,0 +1,168 @@
import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
ShortcutEvent insertHorizontalRule = ShortcutEvent(
key: 'Horizontal rule',
command: 'Minus',
handler: _insertHorzaontalRule,
);
ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (textNodes.length != 1 || selection == null) {
return KeyEventResult.ignored;
}
final textNode = textNodes.first;
if (textNode.toPlainText() == '--') {
final transaction = editorState.transaction
..deleteText(textNode, 0, 2)
..insertNode(
textNode.path,
Node(
type: 'horizontal_rule',
children: LinkedList(),
attributes: {},
),
)
..afterSelection =
Selection.single(path: textNode.path.next, startOffset: 0);
editorState.apply(transaction);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
name: () => 'Horizontal rule',
icon: (editorState, onSelected) => Icon(
Icons.horizontal_rule,
color: onSelected
? editorState.editorStyle.selectionMenuItemSelectedIconColor
: editorState.editorStyle.selectionMenuItemIconColor,
size: 18.0,
),
keywords: ['horizontal rule'],
handler: (editorState, _, __) {
final selection =
editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (selection == null || textNodes.isEmpty) {
return;
}
final textNode = textNodes.first;
if (textNode.toPlainText().isEmpty) {
final transaction = editorState.transaction
..insertNode(
textNode.path,
Node(
type: 'horizontal_rule',
children: LinkedList(),
attributes: {},
),
)
..afterSelection =
Selection.single(path: textNode.path.next, startOffset: 0);
editorState.apply(transaction);
} else {
final transaction = editorState.transaction
..insertNode(
selection.end.path.next,
TextNode(
children: LinkedList(),
attributes: {
'subtype': 'horizontal_rule',
},
delta: Delta()..insert('---'),
),
)
..afterSelection = selection;
editorState.apply(transaction);
}
},
);
class HorizontalRuleWidgetBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return _HorizontalRuleWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return true;
};
}
class _HorizontalRuleWidget extends StatefulWidget {
const _HorizontalRuleWidget({
Key? key,
required this.node,
required this.editorState,
}) : super(key: key);
final Node node;
final EditorState editorState;
@override
State<_HorizontalRuleWidget> createState() => __HorizontalRuleWidgetState();
}
class __HorizontalRuleWidgetState extends State<_HorizontalRuleWidget>
with SelectableMixin {
RenderBox get _renderBox => context.findRenderObject() as RenderBox;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Container(
height: 1,
color: Colors.grey,
),
);
}
@override
Position start() => Position(path: widget.node.path, offset: 0);
@override
Position end() => Position(path: widget.node.path, offset: 1);
@override
Position getPositionInOffset(Offset start) => end();
@override
bool get shouldCursorBlink => false;
@override
CursorStyle get cursorStyle => CursorStyle.borderLine;
@override
Rect? getCursorRectInPosition(Position position) {
final size = _renderBox.size;
return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
}
@override
List<Rect> getRectsInSelection(Selection selection) =>
[Offset.zero & _renderBox.size];
@override
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
path: widget.node.path,
startOffset: 0,
endOffset: 1,
);
@override
Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
}

View File

@ -0,0 +1,14 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'error.freezed.dart';
part 'error.g.dart';
@freezed
class OpenAIError with _$OpenAIError {
const factory OpenAIError({
String? code,
required String message,
}) = _OpenAIError;
factory OpenAIError.fromJson(Map<String, Object?> json) =>
_$OpenAIErrorFromJson(json);
}

View File

@ -0,0 +1,85 @@
import 'dart:convert';
import 'text_completion.dart';
import 'package:dartz/dartz.dart';
import 'dart:async';
import 'error.dart';
import 'package:http/http.dart' as http;
// Please fill in your own API key
const apiKey = '';
enum OpenAIRequestType {
textCompletion,
textEdit;
Uri get uri {
switch (this) {
case OpenAIRequestType.textCompletion:
return Uri.parse('https://api.openai.com/v1/completions');
case OpenAIRequestType.textEdit:
return Uri.parse('https://api.openai.com/v1/edits');
}
}
}
abstract class OpenAIRepository {
/// Get completions from GPT-3
///
/// [prompt] is the prompt text
/// [suffix] is the suffix text
/// [maxTokens] is the maximum number of tokens to generate
/// [temperature] is the temperature of the model
///
Future<Either<OpenAIError, TextCompletionResponse>> getCompletions({
required String prompt,
String? suffix,
int maxTokens = 50,
double temperature = .3,
});
}
class HttpOpenAIRepository implements OpenAIRepository {
const HttpOpenAIRepository({
required this.client,
required this.apiKey,
});
final http.Client client;
final String apiKey;
Map<String, String> get headers => {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
};
@override
Future<Either<OpenAIError, TextCompletionResponse>> getCompletions({
required String prompt,
String? suffix,
int maxTokens = 50,
double temperature = 0.3,
}) async {
final parameters = {
'model': 'text-davinci-003',
'prompt': prompt,
'suffix': suffix,
'max_tokens': maxTokens,
'temperature': temperature,
'stream': false,
};
final response = await http.post(
OpenAIRequestType.textCompletion.uri,
headers: headers,
body: json.encode(parameters),
);
if (response.statusCode == 200) {
return Right(TextCompletionResponse.fromJson(json.decode(response.body)));
} else {
return Left(OpenAIError.fromJson(json.decode(response.body)['error']));
}
}
}

View File

@ -0,0 +1,26 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'text_completion.freezed.dart';
part 'text_completion.g.dart';
@freezed
class TextCompletionChoice with _$TextCompletionChoice {
factory TextCompletionChoice({
required String text,
required int index,
// ignore: invalid_annotation_target
@JsonKey(name: 'finish_reason') required String finishReason,
}) = _TextCompletionChoice;
factory TextCompletionChoice.fromJson(Map<String, Object?> json) =>
_$TextCompletionChoiceFromJson(json);
}
@freezed
class TextCompletionResponse with _$TextCompletionResponse {
const factory TextCompletionResponse({
required List<TextCompletionChoice> choices,
}) = _TextCompletionResponse;
factory TextCompletionResponse.fromJson(Map<String, Object?> json) =>
_$TextCompletionResponseFromJson(json);
}

View File

@ -0,0 +1,42 @@
import 'package:appflowy_editor/appflowy_editor.dart';
enum TextRobotInputType {
character,
word,
}
extension TextRobot on EditorState {
Future<void> autoInsertText(
String text, {
TextRobotInputType inputType = TextRobotInputType.word,
Duration delay = const Duration(milliseconds: 10),
}) async {
final lines = text.split('\n');
for (final line in lines) {
if (line.isEmpty) {
await insertNewLineAtCurrentSelection();
continue;
}
switch (inputType) {
case TextRobotInputType.character:
final iterator = line.runes.iterator;
while (iterator.moveNext()) {
await insertTextAtCurrentSelection(
iterator.currentAsString,
);
await Future.delayed(delay, () {});
}
break;
case TextRobotInputType.word:
final words = line.split(' ').map((e) => '$e ');
for (final word in words) {
await insertTextAtCurrentSelection(
word,
);
await Future.delayed(delay, () {});
}
break;
}
}
}
}

View File

@ -0,0 +1,349 @@
import 'dart:convert';
import 'package:appflowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/loading.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import '../util/editor_extension.dart';
const String kAutoCompletionInputType = 'auto_completion_input';
const String kAutoCompletionInputString = 'auto_completion_input_string';
const String kAutoCompletionInputStartSelection =
'auto_completion_input_start_selection';
class AutoCompletionInputBuilder extends NodeWidgetBuilder<Node> {
@override
NodeValidator<Node> get nodeValidator => (node) {
return node.attributes[kAutoCompletionInputString] is String;
};
@override
Widget build(NodeWidgetContext<Node> context) {
return _AutoCompletionInput(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
}
class _AutoCompletionInput extends StatefulWidget {
final Node node;
final EditorState editorState;
const _AutoCompletionInput({
Key? key,
required this.node,
required this.editorState,
});
@override
State<_AutoCompletionInput> createState() => _AutoCompletionInputState();
}
class _AutoCompletionInputState extends State<_AutoCompletionInput> {
String get text => widget.node.attributes[kAutoCompletionInputString];
final controller = TextEditingController();
final focusNode = FocusNode();
final textFieldFocusNode = FocusNode();
@override
void initState() {
super.initState();
focusNode.addListener(() {
if (focusNode.hasFocus) {
widget.editorState.service.selectionService.clearSelection();
} else {
widget.editorState.service.keyboardService?.enable();
}
});
textFieldFocusNode.requestFocus();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
elevation: 5,
color: Theme.of(context).colorScheme.surface,
child: Container(
margin: const EdgeInsets.all(10),
child: _buildAutoGeneratorPanel(context),
),
);
}
Widget _buildAutoGeneratorPanel(BuildContext context) {
if (text.isEmpty) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeaderWidget(context),
const Space(0, 10),
_buildInputWidget(context),
const Space(0, 10),
_buildInputFooterWidget(context),
],
);
} else {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeaderWidget(context),
const Space(0, 10),
_buildFooterWidget(context),
],
);
}
}
Widget _buildHeaderWidget(BuildContext context) {
return Row(
children: [
FlowyText.medium(
LocaleKeys.document_plugins_autoGeneratorTitleName.tr(),
fontSize: 14,
),
const Spacer(),
FlowyText.regular(
LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(),
),
],
);
}
Widget _buildInputWidget(BuildContext context) {
return RawKeyboardListener(
focusNode: focusNode,
onKey: (RawKeyEvent event) async {
if (event is! RawKeyDownEvent) return;
if (event.logicalKey == LogicalKeyboardKey.enter) {
if (controller.text.isNotEmpty) {
textFieldFocusNode.unfocus();
await _onGenerate();
}
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
await _onExit();
}
},
child: FlowyTextField(
hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(),
controller: controller,
maxLines: 3,
focusNode: textFieldFocusNode,
autoFocus: false,
),
);
}
Widget _buildInputFooterWidget(BuildContext context) {
return Row(
children: [
FlowyRichTextButton(
TextSpan(
children: [
TextSpan(
text: '${LocaleKeys.button_generate.tr()} ',
style: Theme.of(context).textTheme.bodyMedium,
),
TextSpan(
text: '',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
), // FIXME: color
),
],
),
onPressed: () async => await _onGenerate(),
),
const Space(10, 0),
FlowyRichTextButton(
TextSpan(
children: [
TextSpan(
text: '${LocaleKeys.button_Cancel.tr()} ',
style: Theme.of(context).textTheme.bodyMedium,
),
TextSpan(
text: LocaleKeys.button_esc.tr(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
), // FIXME: color
),
],
),
onPressed: () async => await _onExit(),
),
],
);
}
Widget _buildFooterWidget(BuildContext context) {
return Row(
children: [
// FIXME: l10n
FlowyRichTextButton(
TextSpan(
children: [
TextSpan(
text: '${LocaleKeys.button_keep.tr()} ',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
onPressed: () => _onExit(),
),
const Space(10, 0),
FlowyRichTextButton(
TextSpan(
children: [
TextSpan(
text: '${LocaleKeys.button_discard.tr()} ',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
onPressed: () => _onDiscard(),
),
],
);
}
Future<void> _onExit() async {
final transaction = widget.editorState.transaction;
transaction.deleteNode(widget.node);
await widget.editorState.apply(
transaction,
options: const ApplyOptions(
recordRedo: false,
recordUndo: false,
),
);
}
Future<void> _onGenerate() async {
final loading = Loading(context);
loading.start();
await _updateEditingText();
final result = await UserBackendService.getCurrentUserProfile();
result.fold((userProfile) async {
final openAIRepository = HttpOpenAIRepository(
client: http.Client(),
apiKey: userProfile.openaiKey,
);
final completions = await openAIRepository.getCompletions(
prompt: controller.text,
);
completions.fold((error) async {
loading.stop();
await _showError(error.message);
}, (textCompletion) async {
loading.stop();
await _makeSurePreviousNodeIsEmptyTextNode();
// Open AI result uses two '\n' as the begin syntax.
var texts = textCompletion.choices.first.text.split('\n');
if (texts.length > 2) {
texts.removeRange(0, 2);
await widget.editorState.autoInsertText(
texts.join('\n'),
);
}
focusNode.requestFocus();
});
}, (error) async {
loading.stop();
await _showError(
LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(),
);
});
}
Future<void> _onDiscard() async {
final selection =
widget.node.attributes[kAutoCompletionInputStartSelection];
if (selection != null) {
final start = Selection.fromJson(json.decode(selection)).start.path;
final end = widget.node.previous?.path;
if (end != null) {
final transaction = widget.editorState.transaction;
transaction.deleteNodesAtPath(
start,
end.last - start.last + 1,
);
await widget.editorState.apply(transaction);
}
}
_onExit();
}
Future<void> _updateEditingText() async {
final transaction = widget.editorState.transaction;
transaction.updateNode(
widget.node,
{
kAutoCompletionInputString: controller.text,
},
);
await widget.editorState.apply(transaction);
}
Future<void> _makeSurePreviousNodeIsEmptyTextNode() async {
// make sure the previous node is a empty text node without any styles.
final transaction = widget.editorState.transaction;
final Selection selection;
if (widget.node.previous is! TextNode ||
(widget.node.previous as TextNode).toPlainText().isNotEmpty ||
(widget.node.previous as TextNode).subtype != null) {
transaction.insertNode(
widget.node.path,
TextNode.empty(),
);
selection = Selection.single(
path: widget.node.path,
startOffset: 0,
);
transaction.afterSelection = selection;
} else {
selection = Selection.single(
path: widget.node.path.previous,
startOffset: 0,
);
transaction.afterSelection = selection;
}
transaction.updateNode(widget.node, {
kAutoCompletionInputStartSelection: jsonEncode(selection.toJson()),
});
await widget.editorState.apply(transaction);
}
Future<void> _showError(String message) async {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
action: SnackBarAction(
label: LocaleKeys.button_Cancel.tr(),
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
content: FlowyText(message),
),
);
}
}

View File

@ -0,0 +1,20 @@
import 'package:appflowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
SelectionMenuItem autoGeneratorMenuItem = SelectionMenuItem.node(
name: 'Auto Generator',
iconData: Icons.generating_tokens,
keywords: ['autogenerator', 'auto generator'],
nodeBuilder: (editorState) {
final node = Node(
type: kAutoCompletionInputType,
attributes: {
kAutoCompletionInputString: '',
},
);
return node;
},
replace: (_, textNode) => textNode.toPlainText().isEmpty,
updateSelection: null,
);

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class Loading {
Loading(
this.context,
);
late BuildContext loadingContext;
final BuildContext context;
Future<void> start() async {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
loadingContext = context;
return const SimpleDialog(
elevation: 0.0,
backgroundColor:
Colors.transparent, // can change this to your prefered color
children: <Widget>[
Center(
child: CircularProgressIndicator(),
)
],
);
},
);
}
Future<void> stop() async {
return Navigator.of(loadingContext).pop();
}
}

View File

@ -0,0 +1,13 @@
import 'package:appflowy_editor/appflowy_editor.dart';
class DividerNodeParser extends NodeParser {
const DividerNodeParser();
@override
String get id => 'divider';
@override
String transform(Node node) {
return '---\n';
}
}

View File

@ -0,0 +1,13 @@
import 'package:appflowy_editor/appflowy_editor.dart';
class MathEquationNodeParser extends NodeParser {
const MathEquationNodeParser();
@override
String get id => 'math_equation';
@override
String transform(Node node) {
return '\$\$${node.attributes[id]}\$\$';
}
}

View File

@ -0,0 +1,139 @@
library document_plugin;
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/plugins/document/application/share_bloc.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:clipboard/clipboard.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class DocumentShareButton extends StatelessWidget {
final ViewPB view;
DocumentShareButton({Key? key, required this.view})
: super(key: ValueKey(view.hashCode));
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<DocShareBloc>(param1: view),
child: BlocListener<DocShareBloc, DocShareState>(
listener: (context, state) {
state.map(
initial: (_) {},
loading: (_) {},
finish: (state) {
state.successOrFail.fold(
_handleExportData,
_handleExportError,
);
},
);
},
child: BlocBuilder<DocShareBloc, DocShareState>(
builder: (context, state) => ConstrainedBox(
constraints: const BoxConstraints.expand(
height: 30,
width: 100,
),
child: ShareActionList(view: view),
),
),
),
);
}
void _handleExportData(ExportDataPB exportData) {
switch (exportData.exportType) {
case ExportType.Link:
break;
case ExportType.Markdown:
FlutterClipboard.copy(exportData.data)
.then((value) => Log.info('copied to clipboard'));
break;
case ExportType.Text:
break;
}
}
void _handleExportError(FlowyError error) {}
}
class ShareActionList extends StatelessWidget {
const ShareActionList({
Key? key,
required this.view,
}) : super(key: key);
final ViewPB view;
@override
Widget build(BuildContext context) {
final docShareBloc = context.read<DocShareBloc>();
return PopoverActionList<ShareActionWrapper>(
direction: PopoverDirection.bottomWithCenterAligned,
actions: ShareAction.values
.map((action) => ShareActionWrapper(action))
.toList(),
buildChild: (controller) {
return RoundedTextButton(
title: LocaleKeys.shareAction_buttonText.tr(),
onPressed: () => controller.show(),
);
},
onSelected: (action, controller) async {
switch (action.inner) {
case ShareAction.markdown:
final exportPath = await FilePicker.platform.saveFile(
dialogTitle: '',
fileName: '${view.name}.md',
);
if (exportPath != null) {
docShareBloc.add(DocShareEvent.shareMarkdown(exportPath));
showMessageToast('Exported to: $exportPath');
}
break;
case ShareAction.copyLink:
NavigatorAlertDialog(
title: LocaleKeys.shareAction_workInProgress.tr(),
).show(context);
break;
}
controller.close();
},
);
}
}
enum ShareAction {
markdown,
copyLink,
}
class ShareActionWrapper extends ActionCell {
final ShareAction inner;
ShareActionWrapper(this.inner);
Widget? icon(Color iconColor) => null;
@override
String get name {
switch (inner) {
case ShareAction.markdown:
return LocaleKeys.shareAction_markdown.tr();
case ShareAction.copyLink:
return LocaleKeys.shareAction_copyLink.tr();
}
}
}