feat: add openai service (#1858)

* feat: add openai service

* feat: add openai auto completion plugin

* feat: add visible icon for open ai input field

* chore: optimize user experience

* feat: add auto completion node plugin

* feat: support keep and discard the auto generated text

* fix: can't delete the auto completion node

* feat: disable ai plugins if open ai key is null

* fix: wrong auto completion node card color

* fix: make sure the previous text node is pure when using auto generator
This commit is contained in:
Lucas.Xu 2023-02-16 10:17:08 +08:00 committed by GitHub
parent 2f9823d12a
commit 7c3a823078
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 777 additions and 18 deletions

View File

@ -131,7 +131,12 @@
"signIn": "Sign In",
"signOut": "Sign Out",
"complete": "Complete",
"save": "Save"
"save": "Save",
"generate": "Generate",
"esc": "ESC",
"keep": "Keep",
"tryAgain": "Try again",
"discard": "Discard"
},
"label": {
"welcome": "Welcome!",
@ -334,7 +339,14 @@
},
"plugins": {
"referencedBoard": "Referenced Board",
"referencedGrid": "Referenced Grid"
"referencedGrid": "Referenced Grid",
"autoCompletionMenuItemName": "Auto Completion",
"autoGeneratorMenuItemName": "Auto Generator",
"autoGeneratorTitleName": "Open AI: Auto Generator",
"autoGeneratorLearnMore": "Learn more",
"autoGeneratorGenerate": "Generate",
"autoGeneratorHintText": "Tell us what you want to generate by OpenAI ...",
"autoGeneratorCantGetOpenAIKey": "Can't get OpenAI key"
}
},
"board": {

View File

@ -1,7 +1,9 @@
import 'dart:convert';
import 'package:app_flowy/plugins/trash/application/trash_service.dart';
import 'package:app_flowy/user/application/user_service.dart';
import 'package:app_flowy/workspace/application/view/view_listener.dart';
import 'package:app_flowy/plugins/document/application/doc_service.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
show EditorState, Document, Transaction;
import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart';
@ -12,6 +14,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:dartz/dartz.dart';
import 'dart:async';
import 'package:app_flowy/util/either_extension.dart';
part 'doc_bloc.freezed.dart';
@ -73,6 +76,16 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
Future<void> _initial(Initial value, Emitter<DocumentState> emit) async {
final userProfile = await UserService.getCurrentUserProfile();
if (userProfile.isRight()) {
emit(
state.copyWith(
loadingState:
DocumentLoadingState.finish(right(userProfile.asRight())),
),
);
return;
}
final result = await _documentService.openDocument(view: view);
result.fold(
(documentData) {
@ -82,6 +95,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
emit(
state.copyWith(
loadingState: DocumentLoadingState.finish(left(unit)),
userProfilePB: userProfile.asLeft(),
),
);
},
@ -142,12 +156,14 @@ class DocumentState with _$DocumentState {
required DocumentLoadingState loadingState,
required bool isDeleted,
required bool forceClose,
UserProfilePB? userProfilePB,
}) = _DocumentState;
factory DocumentState.initial() => const DocumentState(
loadingState: _Loading(),
isDeleted: false,
forceClose: false,
userProfilePB: null,
);
}

View File

@ -2,6 +2,8 @@ import 'package:app_flowy/plugins/document/presentation/plugins/board/board_menu
import 'package:app_flowy/plugins/document/presentation/plugins/board/board_node_widget.dart';
import 'package:app_flowy/plugins/document/presentation/plugins/grid/grid_menu_item.dart';
import 'package:app_flowy/plugins/document/presentation/plugins/grid/grid_node_widget.dart';
import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_node_widget.dart';
import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/auto_completion_plugins.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';
@ -83,6 +85,7 @@ class _DocumentPageState extends State<DocumentPage> {
if (state.isDeleted) _renderBanner(context),
// AppFlowy Editor
_renderAppFlowyEditor(
context,
context.read<DocumentBloc>().editorState,
),
],
@ -99,7 +102,11 @@ class _DocumentPageState extends State<DocumentPage> {
);
}
Widget _renderAppFlowyEditor(EditorState editorState) {
Widget _renderAppFlowyEditor(BuildContext context, EditorState editorState) {
// enable open ai features if needed.
final userProfilePB = context.read<DocumentBloc>().state.userProfilePB;
final openAIKey = userProfilePB?.openaiKey;
final theme = Theme.of(context);
final editor = AppFlowyEditor(
editorState: editorState,
@ -117,6 +124,8 @@ class _DocumentPageState extends State<DocumentPage> {
kGridType: GridNodeWidgetBuilder(),
// Card
kCalloutType: CalloutNodeWidgetBuilder(),
// Auto Generator,
kAutoCompletionInputType: AutoCompletionInputBuilder(),
},
shortcutEvents: [
// Divider
@ -141,6 +150,10 @@ class _DocumentPageState extends State<DocumentPage> {
gridMenuItem,
// Callout
calloutMenuItem,
// AI
if (openAIKey != null && openAIKey.isNotEmpty) ...[
autoGeneratorMenuItem,
]
],
themeData: theme.copyWith(extensions: [
...theme.extensions.values,

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,44 @@
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) 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;
}
// insert new line
if (lines.length > 1) {
await insertNewLineAtCurrentSelection();
}
}
}
}

View File

@ -0,0 +1,344 @@
import 'dart:convert';
import 'package:app_flowy/plugins/document/presentation/plugins/openai/service/openai_client.dart';
import 'package:app_flowy/plugins/document/presentation/plugins/openai/widgets/loading.dart';
import 'package:app_flowy/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:app_flowy/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 UserService.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();
await widget.editorState.autoInsertText(
textCompletion.choices.first.text,
);
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,
);
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:app_flowy/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

@ -7,12 +7,13 @@ import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
class UserService {
final String userId;
UserService({
required this.userId,
});
Future<Either<UserProfilePB, FlowyError>> getUserProfile(
{required String userId}) {
final String userId;
static Future<Either<UserProfilePB, FlowyError>> getCurrentUserProfile() {
return UserEventGetUserProfile().send();
}

View File

@ -0,0 +1,6 @@
import 'package:dartz/dartz.dart';
extension EitherX<L, R> on Either<L, R> {
R asRight() => (this as Right).value;
L asLeft() => (this as Left).value;
}

View File

@ -43,7 +43,7 @@ class SettingsUserViewBloc extends Bloc<SettingsUserEvent, SettingsUserState> {
);
});
},
updateUserOpenaiKey: (openAIKey) {
updateUserOpenAIKey: (openAIKey) {
_userService.updateUserProfile(openAIKey: openAIKey).then((result) {
result.fold(
(l) => null,
@ -81,7 +81,7 @@ class SettingsUserEvent with _$SettingsUserEvent {
const factory SettingsUserEvent.updateUserName(String name) = _UpdateUserName;
const factory SettingsUserEvent.updateUserIcon(String iconUrl) =
_UpdateUserIcon;
const factory SettingsUserEvent.updateUserOpenaiKey(String openAIKey) =
const factory SettingsUserEvent.updateUserOpenAIKey(String openAIKey) =
_UpdateUserOpenaiKey;
const factory SettingsUserEvent.didReceiveUserProfile(
UserProfilePB newUserProfile) = _DidReceiveUserProfile;

View File

@ -85,26 +85,46 @@ class UserNameInput extends StatelessWidget {
}
}
class _OpenaiKeyInput extends StatelessWidget {
class _OpenaiKeyInput extends StatefulWidget {
final String openAIKey;
const _OpenaiKeyInput(
this.openAIKey, {
Key? key,
}) : super(key: key);
@override
State<_OpenaiKeyInput> createState() => _OpenaiKeyInputState();
}
class _OpenaiKeyInputState extends State<_OpenaiKeyInput> {
bool visible = false;
@override
Widget build(BuildContext context) {
return TextField(
controller: TextEditingController()..text = openAIKey,
controller: TextEditingController()..text = widget.openAIKey,
obscureText: !visible,
decoration: InputDecoration(
labelText: 'Openai Key',
labelText: 'OpenAI Key',
hintText: LocaleKeys.settings_user_pleaseInputYourOpenAIKey.tr(),
suffixIcon: IconButton(
iconSize: 15.0,
icon: Icon(visible ? Icons.visibility : Icons.visibility_off),
padding: EdgeInsets.zero,
hoverColor: Colors.transparent,
splashColor: Colors.transparent,
onPressed: () {
setState(() {
visible = !visible;
});
},
),
),
onSubmitted: (val) {
// TODO: validate key
context
.read<SettingsUserViewBloc>()
.add(SettingsUserEvent.updateUserOpenaiKey(val));
.add(SettingsUserEvent.updateUserOpenAIKey(val));
},
);
}

View File

@ -47,3 +47,4 @@ export 'src/render/toolbar/toolbar_item.dart';
export 'src/extensions/node_extensions.dart';
export 'src/render/action_menu/action_menu.dart';
export 'src/render/action_menu/action_menu_item.dart';
export 'src/core/document/node_iterator.dart';

View File

@ -9,6 +9,15 @@ class Position {
this.offset = 0,
});
factory Position.fromJson(Map<String, dynamic> json) {
final path = Path.from(json['path'] as List);
final offset = json['offset'];
return Position(
path: path,
offset: offset ?? 0,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;

View File

@ -15,6 +15,13 @@ class Selection {
required this.end,
});
factory Selection.fromJson(Map<String, dynamic> json) {
return Selection(
start: Position.fromJson(json['start']),
end: Position.fromJson(json['end']),
);
}
/// Create a selection with [Path], [startOffset] and [endOffset].
///
/// The [endOffset] is optional.

View File

@ -1,7 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/infra/clipboard.dart';
import 'package:appflowy_editor/src/infra/html_converter.dart';
import 'package:appflowy_editor/src/core/document/node_iterator.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
import 'package:flutter/material.dart';

View File

@ -1,5 +1,4 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/core/document/node_iterator.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {

View File

@ -203,3 +203,93 @@ class FlowyTextButton extends StatelessWidget {
return child;
}
}
class FlowyRichTextButton extends StatelessWidget {
final InlineSpan text;
final TextOverflow overflow;
final VoidCallback? onPressed;
final EdgeInsets padding;
final Widget? heading;
final Color? hoverColor;
final Color? fillColor;
final BorderRadius? radius;
final MainAxisAlignment mainAxisAlignment;
final String? tooltip;
final BoxConstraints constraints;
final TextDecoration? decoration;
// final HoverDisplayConfig? hoverDisplay;
const FlowyRichTextButton(
this.text, {
Key? key,
this.onPressed,
this.overflow = TextOverflow.ellipsis,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
this.hoverColor,
this.fillColor,
this.heading,
this.radius,
this.mainAxisAlignment = MainAxisAlignment.start,
this.tooltip,
this.constraints = const BoxConstraints(minWidth: 58.0, minHeight: 30.0),
this.decoration,
}) : super(key: key);
@override
Widget build(BuildContext context) {
List<Widget> children = [];
if (heading != null) {
children.add(heading!);
children.add(const HSpace(6));
}
children.add(
RichText(
text: text,
overflow: overflow,
textAlign: TextAlign.center,
),
);
Widget child = Padding(
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: mainAxisAlignment,
children: children,
),
);
child = RawMaterialButton(
visualDensity: VisualDensity.compact,
hoverElevation: 0,
highlightElevation: 0,
shape: RoundedRectangleBorder(borderRadius: radius ?? Corners.s6Border),
fillColor: fillColor ?? Theme.of(context).colorScheme.secondaryContainer,
hoverColor: hoverColor ?? Theme.of(context).colorScheme.secondary,
focusColor: Colors.transparent,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
elevation: 0,
constraints: constraints,
onPressed: () {},
child: child,
);
child = IgnoreParentGestureWidget(
onPress: onPressed,
child: child,
);
if (tooltip != null) {
child = Tooltip(
message: tooltip!,
textStyle: AFThemeExtension.of(context).caption.textColor(Colors.white),
child: child,
);
}
return child;
}
}

View File

@ -20,6 +20,7 @@ class FlowyTextField extends StatefulWidget {
final bool submitOnLeave;
final Duration? debounceDuration;
final String? errorText;
final int maxLines;
const FlowyTextField({
this.hintText = "",
@ -36,6 +37,7 @@ class FlowyTextField extends StatefulWidget {
this.submitOnLeave = false,
this.debounceDuration,
this.errorText,
this.maxLines = 1,
Key? key,
}) : super(key: key);
@ -103,7 +105,7 @@ class FlowyTextFieldState extends State<FlowyTextField> {
},
onSubmitted: (text) => _onSubmitted(text),
onEditingComplete: widget.onEditingComplete,
maxLines: 1,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
style: Theme.of(context).textTheme.bodyMedium,

View File

@ -601,7 +601,7 @@ packages:
source: hosted
version: "0.15.1"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
url: "https://pub.dartlang.org"
@ -662,12 +662,19 @@ packages:
source: hosted
version: "0.6.4"
json_annotation:
dependency: transitive
dependency: "direct main"
description:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.8.0"
version: "4.7.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
url: "https://pub.dartlang.org"
source: hosted
version: "6.5.4"
linked_scroll_controller:
dependency: "direct main"
description:
@ -1128,6 +1135,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.6"
source_helper:
dependency: transitive
description:
name: source_helper
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.3"
source_map_stack_trace:
dependency: transitive
description:

View File

@ -93,6 +93,8 @@ dependencies:
path: packages/appflowy_editor_plugins
calendar_view: ^1.0.1
window_manager: ^0.3.0
http: ^0.13.5
json_annotation: ^4.7.0
dev_dependencies:
flutter_lints: ^2.0.1
@ -104,6 +106,7 @@ dev_dependencies:
build_runner: ^2.2.0
freezed: ^2.1.0+1
bloc_test: ^9.0.2
json_serializable: ^6.5.4
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is