Merge branch 'main' into main

This commit is contained in:
Simon 2024-08-13 13:07:20 +02:00 committed by GitHub
commit 9213c233ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
280 changed files with 5445 additions and 2885 deletions

View File

@ -1,4 +1,15 @@
# Release Notes
## Version 0.6.7 - 13/08/2024
### New Features
- Redesigned the icon picker design on Desktop.
- Redesigned the notification page on Mobile.
### Bug Fixes
- Enhance the toolbar tooltip functionality on Desktop.
- Enhance the slash menu user experience on Desktop.
- Fixed the issue where list style overrides occurred during text pasting.
- Fixed the issue where linking multiple databases in the same document could cause random loss of focus.
## Version 0.6.6 - 30/07/2024
### New Features
- Upgrade your workspace to a premium plan to unlock more features and storage.

View File

View File

@ -1,10 +1,10 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -25,6 +25,7 @@ void main() {
const lines = 3;
final text = List.generate(lines, (index) => 'line $index').join('\n');
AppFlowyClipboard.mockSetData(AppFlowyClipboardData(text: text));
ClipboardService.mockSetData(ClipboardServiceData(plainText: text));
await insertCodeBlockInDocument(tester);
@ -51,7 +52,9 @@ Future<void> insertCodeBlockInDocument(WidgetTester tester) async {
// open the actions menu and insert the codeBlock
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_selectionMenu_codeBlock.tr(),
LocaleKeys.document_slashMenu_name_code.tr(),
offset: 150,
);
// wait for the codeBlock to be inserted
await tester.pumpAndSettle();
}

View File

@ -165,6 +165,44 @@ void main() {
});
});
testWidgets('paste text on part of bullet list', (tester) async {
const plainText = 'test';
await tester.pasteContent(
plainText: plainText,
beforeTest: (editorState) async {
final transaction = editorState.transaction;
transaction.insertNodes(
[0],
[
Node(
type: BulletedListBlockKeys.type,
attributes: {
'delta': [
{"insert": "bullet list"},
],
},
),
],
);
// Set the selection to the second numbered list node (which has empty delta)
transaction.afterSelection = Selection(
start: Position(path: [0], offset: 7),
end: Position(path: [0], offset: 11),
);
await editorState.apply(transaction);
await tester.pumpAndSettle();
},
(editorState) {
final node = editorState.getNodeAtPath([0]);
expect(node?.delta?.toPlainText(), 'bullet test');
expect(node?.type, BulletedListBlockKeys.type);
},
);
});
testWidgets('paste image(png) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.png');
final bytes = image.buffer.asUint8List();

View File

@ -1,4 +1,3 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
@ -7,7 +6,6 @@ import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.d
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -22,7 +20,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertReferenceDatabase(tester, ViewLayoutPB.Grid);
await insertLinkedDatabase(tester, ViewLayoutPB.Grid);
// validate the referenced grid is inserted
expect(
@ -50,7 +48,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertReferenceDatabase(tester, ViewLayoutPB.Board);
await insertLinkedDatabase(tester, ViewLayoutPB.Board);
// validate the referenced board is inserted
expect(
@ -66,7 +64,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertReferenceDatabase(tester, ViewLayoutPB.Calendar);
await insertLinkedDatabase(tester, ViewLayoutPB.Calendar);
// validate the referenced grid is inserted
expect(
@ -129,7 +127,7 @@ void main() {
}
/// Insert a referenced database of [layout] into the document
Future<void> insertReferenceDatabase(
Future<void> insertLinkedDatabase(
WidgetTester tester,
ViewLayoutPB layout,
) async {
@ -150,7 +148,7 @@ Future<void> insertReferenceDatabase(
// insert a referenced view
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
layout.referencedMenuName,
layout.slashMenuLinkedName,
);
final linkToPageMenu = find.byType(InlineActionsHandler);
@ -176,16 +174,8 @@ Future<void> createInlineDatabase(
await tester.editor.tapLineOfEditorAt(0);
// insert a referenced view
await tester.editor.showSlashMenu();
final name = switch (layout) {
ViewLayoutPB.Grid => LocaleKeys.document_slashMenu_grid_createANewGrid.tr(),
ViewLayoutPB.Board =>
LocaleKeys.document_slashMenu_board_createANewBoard.tr(),
ViewLayoutPB.Calendar =>
LocaleKeys.document_slashMenu_calendar_createANewCalendar.tr(),
_ => '',
};
await tester.editor.tapSlashMenuItemWithName(
name,
layout.slashMenuName,
);
await tester.pumpAndSettle();

View File

@ -1,7 +1,5 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
@ -10,6 +8,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da
import 'package:appflowy/startup/startup.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
@ -33,7 +32,9 @@ void main() {
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('File');
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_file.tr(),
);
expect(find.byType(FileBlockComponent), findsOneWidget);
await tester.tap(find.byType(FileBlockComponent));
@ -111,7 +112,9 @@ void main() {
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('File');
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_file.tr(),
);
expect(find.byType(FileBlockComponent), findsOneWidget);
await tester.tap(find.byType(FileBlockComponent));

View File

@ -1,8 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
@ -17,6 +14,8 @@ import 'package:appflowy_editor/appflowy_editor.dart'
hide UploadImageMenu, ResizableImage;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
@ -43,7 +42,9 @@ void main() {
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('Image');
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
@ -91,7 +92,9 @@ void main() {
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('Image');
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
@ -144,7 +147,9 @@ void main() {
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('Image');
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
@ -175,7 +180,9 @@ void main() {
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('Image');
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(

View File

@ -1,9 +1,5 @@
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@ -20,6 +16,9 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart';
import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
@ -49,7 +48,10 @@ void main() {
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('Photo gallery');
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 100,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
@ -144,7 +146,10 @@ void main() {
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName('Photo gallery');
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 100,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);

View File

@ -171,7 +171,8 @@ Future<void> insertOutlineInDocument(WidgetTester tester) async {
// open the actions menu and insert the outline block
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_selectionMenu_outline.tr(),
LocaleKeys.document_slashMenu_name_outline.tr(),
offset: 100,
);
await tester.pumpAndSettle();
}

View File

@ -662,4 +662,34 @@ extension ViewLayoutPBTest on ViewLayoutPB {
throw UnsupportedError('Unsupported layout: $this');
}
}
String get slashMenuName {
switch (this) {
case ViewLayoutPB.Grid:
return LocaleKeys.document_slashMenu_name_grid.tr();
case ViewLayoutPB.Board:
return LocaleKeys.document_slashMenu_name_kanban.tr();
case ViewLayoutPB.Document:
return LocaleKeys.document_slashMenu_name_doc.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.document_slashMenu_name_calendar.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
}
String get slashMenuLinkedName {
switch (this) {
case ViewLayoutPB.Grid:
return LocaleKeys.document_slashMenu_name_linkedGrid.tr();
case ViewLayoutPB.Board:
return LocaleKeys.document_slashMenu_name_linkedKanban.tr();
case ViewLayoutPB.Document:
return LocaleKeys.document_slashMenu_name_linkedDoc.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.document_slashMenu_name_linkedCalendar.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
}
}

View File

@ -3,7 +3,6 @@ import 'dart:ui';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
@ -11,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/doc
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
@ -170,7 +170,10 @@ class EditorOperations {
/// Tap the slash menu item with [name]
///
/// Must call [showSlashMenu] first.
Future<void> tapSlashMenuItemWithName(String name) async {
Future<void> tapSlashMenuItemWithName(
String name, {
double offset = 200,
}) async {
final slashMenu = find
.ancestor(
of: find.byType(SelectionMenuItemWidget),
@ -180,8 +183,13 @@ class EditorOperations {
)
.first;
final slashMenuItem = find.text(name, findRichText: true);
await tester.scrollUntilVisible(slashMenuItem, 200, scrollable: slashMenu);
// await tester.ensureVisible(slashMenuItem);
await tester.scrollUntilVisible(
slashMenuItem,
offset,
scrollable: slashMenu,
duration: const Duration(milliseconds: 250),
);
assert(slashMenuItem.hasFound);
await tester.tapButton(slashMenuItem);
}

View File

@ -102,6 +102,7 @@ class _TypeOptionMenuItem<T> extends StatelessWidget {
value.text,
fontSize: 14.0,
maxLines: 2,
lineHeight: 1.0,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),

View File

@ -133,7 +133,7 @@ enum DatabaseViewSettings {
filter => FlowySvgs.filter_s,
sort => FlowySvgs.sort_ascending_s,
board => FlowySvgs.board_s,
calendar => FlowySvgs.date_s,
calendar => FlowySvgs.calendar_s,
duplicate => FlowySvgs.copy_s,
delete => FlowySvgs.delete_s,
};
@ -176,6 +176,7 @@ class DatabaseViewSettingTile extends StatelessWidget {
return Row(
children: [
FlowyText(
lineHeight: 1.0,
databaseLayoutFromViewLayout(view.layout).layoutName,
color: Theme.of(context).hintColor,
),

View File

@ -4,6 +4,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
import 'package:appflowy/mobile/presentation/widgets/navigation_bar_button.dart';
import 'package:appflowy/shared/red_dot.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/util/theme_extension.dart';
@ -162,7 +163,7 @@ class _NotificationNavigationBarItemIcon extends StatelessWidget {
const Positioned(
top: 2,
right: 4,
child: _RedDot(),
child: NotificationRedDot(),
),
],
);
@ -172,25 +173,6 @@ class _NotificationNavigationBarItemIcon extends StatelessWidget {
}
}
class _RedDot extends StatelessWidget {
const _RedDot();
@override
Widget build(BuildContext context) {
return Container(
width: 6,
height: 6,
clipBehavior: Clip.antiAlias,
decoration: ShapeDecoration(
color: const Color(0xFFFF2214),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
);
}
}
class _HomePageNavigationBar extends StatelessWidget {
const _HomePageNavigationBar({
required this.navigationShell,
@ -230,11 +212,13 @@ class _HomePageNavigationBar extends StatelessWidget {
/// Navigate to the current location of the branch at the provided index when
/// tapping an item in the BottomNavigationBar.
void _onTap(BuildContext context, int bottomBarIndex) {
if (_items[bottomBarIndex].label == _addLabel) {
final label = _items[bottomBarIndex].label;
if (label == _addLabel) {
// show an add dialog
mobileCreateNewPageNotifier.value = ViewLayoutPB.Document;
return;
} else if (label == _notificationLabel) {
getIt<ReminderBloc>().add(const ReminderEvent.refresh());
}
// When navigating to a new branch, it's recommended to use the goBranch
// method, as doing so makes sure the last navigation state of the

View File

@ -29,6 +29,7 @@ class FontSetting extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
lineHeight: 1.0,
name,
color: theme.colorScheme.onSurface,
),

View File

@ -38,6 +38,7 @@ class _LanguageSettingGroupState extends State<LanguageSettingGroup> {
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
lineHeight: 1.0,
languageFromLocale(locale),
color: theme.colorScheme.onSurface,
),

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
@ -8,18 +9,22 @@ import 'package:fixnum/fixnum.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_message_service.dart';
part 'chat_ai_message_bloc.freezed.dart';
class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
ChatAIMessageBloc({
dynamic message,
String? metadata,
String? refSourceJsonString,
required this.chatId,
required this.questionId,
}) : super(ChatAIMessageState.initial(
message,
chatMessageMetadataFromString(metadata),
),) {
}) : super(
ChatAIMessageState.initial(
message,
messageReferenceSource(refSourceJsonString),
),
) {
if (state.stream != null) {
state.stream!.listen(
onData: (text) {
@ -37,9 +42,9 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
add(const ChatAIMessageEvent.onAIResponseLimit());
}
},
onMetadata: (metadata) {
onMetadata: (sources) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveMetadata(metadata));
add(ChatAIMessageEvent.receiveSources(sources));
}
},
);
@ -112,10 +117,10 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
),
);
},
receiveMetadata: (List<ChatMessageMetadata> metadata) {
receiveSources: (List<ChatMessageRefSource> sources) {
emit(
state.copyWith(
metadata: metadata,
sources: sources,
),
);
},
@ -136,8 +141,8 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.retry() = _Retry;
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit;
const factory ChatAIMessageEvent.receiveMetadata(
List<ChatMessageMetadata> data,
const factory ChatAIMessageEvent.receiveSources(
List<ChatMessageRefSource> sources,
) = _ReceiveMetadata;
}
@ -147,16 +152,18 @@ class ChatAIMessageState with _$ChatAIMessageState {
AnswerStream? stream,
required String text,
required MessageState messageState,
required List<ChatMessageMetadata> metadata,
required List<ChatMessageRefSource> sources,
}) = _ChatAIMessageState;
factory ChatAIMessageState.initial(
dynamic text, List<ChatMessageMetadata> metadata,) {
dynamic text,
List<ChatMessageRefSource> sources,
) {
return ChatAIMessageState(
text: text is String ? text : "",
stream: text is AnswerStream ? text : null,
messageState: const MessageState.ready(),
metadata: metadata,
sources: sources,
);
}
}

View File

@ -1,14 +1,11 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
@ -19,16 +16,12 @@ import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:nanoid/nanoid.dart';
import 'chat_entity.dart';
import 'chat_message_listener.dart';
import 'chat_message_service.dart';
part 'chat_bloc.g.dart';
part 'chat_bloc.freezed.dart';
const sendMessageErrorKey = "sendMessageError";
const systemUserId = "system";
const aiResponseUserId = "0";
class ChatBloc extends Bloc<ChatEvent, ChatState> {
ChatBloc({
required ViewPB view,
@ -46,7 +39,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final String chatId;
/// The last streaming message id
String lastStreamMessageId = '';
String answerStreamMessageId = '';
String questionStreamMessageId = '';
/// Using a temporary map to associate the real message ID with the last streaming message ID.
///
@ -94,12 +88,18 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Int64? beforeMessageId;
final oldestMessage = _getOlderstMessage();
if (oldestMessage != null) {
beforeMessageId = Int64.parseInt(oldestMessage.id);
try {
beforeMessageId = Int64.parseInt(oldestMessage.id);
} catch (e) {
Log.error(
"Failed to parse message id: $e, messaeg_id: ${oldestMessage.id}",
);
}
}
_loadPrevMessage(beforeMessageId);
emit(
state.copyWith(
loadingPreviousStatus: const LoadingState.loading(),
loadingPreviousStatus: const ChatLoadingState.loading(),
),
);
},
@ -115,7 +115,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
emit(
state.copyWith(
messages: uniqueMessages,
loadingPreviousStatus: const LoadingState.finish(),
loadingPreviousStatus: const ChatLoadingState.finish(),
hasMorePrevMessage: hasMore,
),
);
@ -129,26 +129,16 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
emit(
state.copyWith(
messages: uniqueMessages,
initialLoadingStatus: const LoadingState.finish(),
initialLoadingStatus: const ChatLoadingState.finish(),
),
);
},
// streaming message
streaming: (Message message) {
final allMessages = _perminentMessages();
allMessages.insert(0, message);
emit(
state.copyWith(
messages: allMessages,
streamingState: const StreamingState.streaming(),
canSendMessage: false,
),
);
},
finishStreaming: () {
finishAnswerStreaming: () {
emit(
state.copyWith(
streamingState: const StreamingState.done(),
acceptRelatedQuestion: true,
canSendMessage:
state.sendingState == const SendMessageState.done(),
),
@ -169,9 +159,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
// If the streaming is not started, remove the message from the list
if (!state.answerStream!.hasStarted) {
allMessages.removeWhere(
(element) => element.id == lastStreamMessageId,
(element) => element.id == answerStreamMessageId,
);
lastStreamMessageId = "";
answerStreamMessageId = "";
}
// when stop stream, we will set the answer stream to null. Which means the streaming
@ -196,6 +186,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
startAnswerStreaming: (Message message) {
final allMessages = _perminentMessages();
allMessages.insert(0, message);
emit(
state.copyWith(
messages: allMessages,
streamingState: const StreamingState.streaming(),
canSendMessage: false,
),
);
},
sendMessage: (String message, Map<String, dynamic>? metadata) async {
unawaited(_startStreamingMessage(message, metadata, emit));
final allMessages = _perminentMessages();
@ -204,6 +205,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
lastSentMessage: null,
messages: allMessages,
relatedQuestions: [],
acceptRelatedQuestion: false,
sendingState: const SendMessageState.sending(),
canSendMessage: false,
),
@ -256,10 +258,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatMessageCallback: (pb) {
if (!isClosed) {
// 3 mean message response from AI
if (pb.authorType == 3 && lastStreamMessageId.isNotEmpty) {
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
lastStreamMessageId;
lastStreamMessageId = "";
answerStreamMessageId;
answerStreamMessageId = "";
}
// 1 mean message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
questionStreamMessageId;
questionStreamMessageId = "";
}
final message = _createTextMessage(pb);
@ -269,7 +278,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatErrorMessageCallback: (err) {
if (!isClosed) {
Log.error("chat error: ${err.errorMessage}");
add(const ChatEvent.finishStreaming());
add(const ChatEvent.finishAnswerStreaming());
}
},
latestMessageCallback: (list) {
@ -286,7 +295,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
finishStreamingCallback: () {
if (!isClosed) {
add(const ChatEvent.finishStreaming());
add(const ChatEvent.finishAnswerStreaming());
// The answer strema will bet set to null after the streaming is finished or canceled.
// so if the answer stream is null, we will not get related question.
if (state.lastSentMessage != null && state.answerStream != null) {
@ -299,7 +308,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
if (!isClosed) {
result.fold(
(list) {
add(ChatEvent.didReceiveRelatedQuestion(list.items));
if (state.acceptRelatedQuestion) {
add(ChatEvent.didReceiveRelatedQuestion(list.items));
}
},
(err) {
Log.error("Failed to get related question: $err");
@ -357,16 +368,24 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
final answerStream = AnswerStream();
final questionStream = QuestionStream();
add(ChatEvent.didUpdateAnswerStream(answerStream));
final payload = StreamChatPayloadPB(
chatId: state.view.id,
message: message,
messageType: ChatMessageTypePB.User,
textStreamPort: Int64(answerStream.nativePort),
questionStreamPort: Int64(questionStream.nativePort),
answerStreamPort: Int64(answerStream.nativePort),
metadata: await metadataPBFromMetadata(metadata),
);
final questionStreamMessage = _createQuestionStreamMessage(
questionStream,
metadata,
);
add(ChatEvent.receveMessage(questionStreamMessage));
// Stream message to the server
final result = await AIEventStreamMessage(payload).send();
result.fold(
@ -374,13 +393,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
if (!isClosed) {
add(ChatEvent.finishSending(question));
final questionMessageId = question.messageId;
final message = _createTextMessage(question);
add(ChatEvent.receveMessage(message));
// final message = _createTextMessage(question);
// add(ChatEvent.receveMessage(message));
final streamAnswer =
_createStreamMessage(answerStream, questionMessageId);
add(ChatEvent.streaming(streamAnswer));
_createAnswerStreamMessage(answerStream, question.messageId);
add(ChatEvent.startAnswerStreaming(streamAnswer));
}
},
(err) {
@ -403,16 +421,18 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
Message _createStreamMessage(AnswerStream stream, Int64 questionMessageId) {
Message _createAnswerStreamMessage(
AnswerStream stream,
Int64 questionMessageId,
) {
final streamMessageId = (questionMessageId + 1).toString();
lastStreamMessageId = streamMessageId;
answerStreamMessageId = streamMessageId;
return TextMessage(
author: User(id: "streamId:${nanoid()}"),
metadata: {
"$AnswerStream": stream,
"question": questionMessageId,
messageQuestionIdKey: questionMessageId,
"chatId": chatId,
},
id: streamMessageId,
@ -421,6 +441,32 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
Message _createQuestionStreamMessage(
QuestionStream stream,
Map<String, dynamic>? sentMetadata,
) {
final now = DateTime.now();
final timestamp = now.millisecondsSinceEpoch;
questionStreamMessageId = timestamp.toString();
final Map<String, dynamic> metadata = {};
// if (sentMetadata != null) {
// metadata[messageMetadataJsonStringKey] = sentMetadata;
// }
metadata["$QuestionStream"] = stream;
metadata["chatId"] = chatId;
metadata[messageChatFileListKey] =
chatFilesFromMessageMetadata(sentMetadata);
return TextMessage(
author: User(id: state.userProfile.id.toString()),
metadata: metadata,
id: questionStreamMessageId,
createdAt: DateTime.now().millisecondsSinceEpoch,
text: '',
);
}
Message _createTextMessage(ChatMessagePB message) {
String messageId = message.messageId.toString();
@ -435,7 +481,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
text: message.content,
createdAt: message.createdAt.toInt() * 1000,
metadata: {
"metadata": message.metadata,
messageRefSourceJsonStringKey: message.metadata,
},
);
}
@ -454,9 +500,10 @@ class ChatEvent with _$ChatEvent {
_FinishSendMessage;
// receive message
const factory ChatEvent.streaming(Message message) = _StreamingMessage;
const factory ChatEvent.startAnswerStreaming(Message message) =
_StartAnswerStreaming;
const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.finishStreaming() = _FinishStreamingMessage;
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming;
// loading messages
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
@ -487,10 +534,10 @@ class ChatState with _$ChatState {
required UserProfilePB userProfile,
// When opening the chat, the initial loading status will be set as loading.
//After the initial loading is done, the status will be set as finished.
required LoadingState initialLoadingStatus,
required ChatLoadingState initialLoadingStatus,
// When loading previous messages, the status will be set as loading.
// After the loading is done, the status will be set as finished.
required LoadingState loadingPreviousStatus,
required ChatLoadingState loadingPreviousStatus,
// When sending a user message, the status will be set as loading.
// After the message is sent, the status will be set as finished.
required StreamingState streamingState,
@ -499,6 +546,7 @@ class ChatState with _$ChatState {
required bool hasMorePrevMessage,
// The related questions that are received after the user message is sent.
required List<RelatedQuestionPB> relatedQuestions,
@Default(false) bool acceptRelatedQuestion,
// The last user message that is sent to the server.
ChatMessagePB? lastSentMessage,
AnswerStream? answerStream,
@ -510,8 +558,8 @@ class ChatState with _$ChatState {
view: view,
messages: [],
userProfile: userProfile,
initialLoadingStatus: const LoadingState.finish(),
loadingPreviousStatus: const LoadingState.finish(),
initialLoadingStatus: const ChatLoadingState.finish(),
loadingPreviousStatus: const ChatLoadingState.finish(),
streamingState: const StreamingState.done(),
sendingState: const SendMessageState.done(),
hasMorePrevMessage: true,
@ -524,202 +572,3 @@ bool isOtherUserMessage(Message message) {
message.author.id != systemUserId &&
!message.author.id.startsWith("streamId:");
}
@freezed
class LoadingState with _$LoadingState {
const factory LoadingState.loading() = _Loading;
const factory LoadingState.finish({FlowyError? error}) = _Finish;
}
enum OnetimeShotType {
unknown,
sendingMessage,
relatedQuestion,
invalidSendMesssage,
}
const onetimeShotType = "OnetimeShotType";
extension OnetimeMessageTypeExtension on OnetimeShotType {
static OnetimeShotType fromString(String value) {
switch (value) {
case 'OnetimeShotType.relatedQuestion':
return OnetimeShotType.relatedQuestion;
case 'OnetimeShotType.invalidSendMesssage':
return OnetimeShotType.invalidSendMesssage;
default:
Log.error('Unknown OnetimeShotType: $value');
return OnetimeShotType.unknown;
}
}
Map<String, dynamic> toMap() {
return {
onetimeShotType: toString(),
};
}
}
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
if (metadata == null) {
return null;
}
for (final entry in metadata.entries) {
if (entry.key == onetimeShotType) {
return OnetimeMessageTypeExtension.fromString(entry.value as String);
}
}
return null;
}
class AnswerStream {
AnswerStream() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) {
if (event.startsWith("data:")) {
_hasStarted = true;
final newText = event.substring(5);
_text += newText;
if (_onData != null) {
_onData!(_text);
}
} else if (event.startsWith("error:")) {
_error = event.substring(5);
if (_onError != null) {
_onError!(_error!);
}
} else if (event.startsWith("metadata:")) {
if (_onMetadata != null) {
final s = event.substring(9);
_onMetadata!(chatMessageMetadataFromString(s));
}
} else if (event == "AI_RESPONSE_LIMIT") {
if (_onAIResponseLimit != null) {
_onAIResponseLimit!();
}
}
},
onDone: () {
if (_onEnd != null) {
_onEnd!();
}
},
onError: (error) {
if (_onError != null) {
_onError!(error.toString());
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
bool _hasStarted = false;
String? _error;
String _text = "";
// Callbacks
void Function(String text)? _onData;
void Function()? _onStart;
void Function()? _onEnd;
void Function(String error)? _onError;
void Function()? _onAIResponseLimit;
void Function(List<ChatMessageMetadata> metadata)? _onMetadata;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
String get text => _text;
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
void listen({
void Function(String text)? onData,
void Function()? onStart,
void Function()? onEnd,
void Function(String error)? onError,
void Function()? onAIResponseLimit,
void Function(List<ChatMessageMetadata> metadata)? onMetadata,
}) {
_onData = onData;
_onStart = onStart;
_onEnd = onEnd;
_onError = onError;
_onAIResponseLimit = onAIResponseLimit;
_onMetadata = onMetadata;
if (_onStart != null) {
_onStart!();
}
}
}
List<ChatMessageMetadata> chatMessageMetadataFromString(String? s) {
if (s == null || s.isEmpty || s == "null") {
return [];
}
final List<ChatMessageMetadata> metadata = [];
try {
final metadataJson = jsonDecode(s);
if (metadataJson == null) {
Log.warn("metadata is null");
return [];
}
if (metadataJson is Map<String, dynamic>) {
if (metadataJson.isNotEmpty) {
metadata.add(ChatMessageMetadata.fromJson(metadataJson));
}
} else if (metadataJson is List) {
metadata.addAll(
metadataJson.map(
(e) => ChatMessageMetadata.fromJson(e as Map<String, dynamic>),
),
);
} else {
Log.error("Invalid metadata: $metadataJson");
}
} catch (e) {
Log.error("Failed to parse metadata: $e");
}
return metadata;
}
@JsonSerializable()
class ChatMessageMetadata {
ChatMessageMetadata({
required this.id,
required this.name,
required this.source,
});
factory ChatMessageMetadata.fromJson(Map<String, dynamic> json) =>
_$ChatMessageMetadataFromJson(json);
final String id;
final String name;
final String source;
Map<String, dynamic> toJson() => _$ChatMessageMetadataToJson(this);
}
@freezed
class StreamingState with _$StreamingState {
const factory StreamingState.streaming() = _Streaming;
const factory StreamingState.done({FlowyError? error}) = _StreamDone;
}
@freezed
class SendMessageState with _$SendMessageState {
const factory SendMessageState.sending() = _Sending;
const factory SendMessageState.done({FlowyError? error}) = _SendDone;
}

View File

@ -0,0 +1,182 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:path/path.dart' as path;
part 'chat_entity.g.dart';
part 'chat_entity.freezed.dart';
const sendMessageErrorKey = "sendMessageError";
const systemUserId = "system";
const aiResponseUserId = "0";
/// `messageRefSourceJsonStringKey` is the key used for metadata that contains the reference source of a message.
/// Each message may include this information.
/// - When used in a sent message, it indicates that the message includes an attachment.
/// - When used in a received message, it indicates the AI reference sources used to answer a question.
const messageRefSourceJsonStringKey = "ref_source_json_string";
const messageChatFileListKey = "chat_files";
const messageQuestionIdKey = "question_id";
@JsonSerializable()
class ChatMessageRefSource {
ChatMessageRefSource({
required this.id,
required this.name,
required this.source,
});
factory ChatMessageRefSource.fromJson(Map<String, dynamic> json) =>
_$ChatMessageRefSourceFromJson(json);
final String id;
final String name;
final String source;
Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this);
}
@freezed
class StreamingState with _$StreamingState {
const factory StreamingState.streaming() = _Streaming;
const factory StreamingState.done({FlowyError? error}) = _StreamDone;
}
@freezed
class SendMessageState with _$SendMessageState {
const factory SendMessageState.sending() = _Sending;
const factory SendMessageState.done({FlowyError? error}) = _SendDone;
}
class ChatFile extends Equatable {
const ChatFile({
required this.filePath,
required this.fileName,
required this.fileType,
});
static ChatFile? fromFilePath(String filePath) {
final file = File(filePath);
if (!file.existsSync()) {
return null;
}
final fileName = path.basename(filePath);
final extension = path.extension(filePath).toLowerCase();
ChatMessageMetaTypePB fileType;
switch (extension) {
case '.pdf':
fileType = ChatMessageMetaTypePB.PDF;
break;
case '.txt':
fileType = ChatMessageMetaTypePB.Txt;
break;
case '.md':
fileType = ChatMessageMetaTypePB.Markdown;
break;
default:
fileType = ChatMessageMetaTypePB.UnknownMetaType;
}
return ChatFile(
filePath: filePath,
fileName: fileName,
fileType: fileType,
);
}
final String filePath;
final String fileName;
final ChatMessageMetaTypePB fileType;
@override
List<Object?> get props => [filePath];
}
extension ChatFileTypeExtension on ChatMessageMetaTypePB {
Widget get icon {
switch (this) {
case ChatMessageMetaTypePB.PDF:
return const FlowySvg(
FlowySvgs.file_pdf_s,
color: Color(0xff00BCF0),
);
case ChatMessageMetaTypePB.Txt:
return const FlowySvg(
FlowySvgs.file_txt_s,
color: Color(0xff00BCF0),
);
case ChatMessageMetaTypePB.Markdown:
return const FlowySvg(
FlowySvgs.file_md_s,
color: Color(0xff00BCF0),
);
default:
return const FlowySvg(FlowySvgs.file_unknown_s);
}
}
}
typedef ChatInputFileMetadata = Map<String, ChatFile>;
@freezed
class ChatLoadingState with _$ChatLoadingState {
const factory ChatLoadingState.loading() = _Loading;
const factory ChatLoadingState.finish({FlowyError? error}) = _Finish;
}
extension ChatLoadingStateExtension on ChatLoadingState {
bool get isLoading => this is _Loading;
bool get isFinish => this is _Finish;
}
enum OnetimeShotType {
unknown,
sendingMessage,
relatedQuestion,
invalidSendMesssage,
}
const onetimeShotType = "OnetimeShotType";
extension OnetimeMessageTypeExtension on OnetimeShotType {
static OnetimeShotType fromString(String value) {
switch (value) {
case 'OnetimeShotType.sendingMessage':
return OnetimeShotType.sendingMessage;
case 'OnetimeShotType.relatedQuestion':
return OnetimeShotType.relatedQuestion;
case 'OnetimeShotType.invalidSendMesssage':
return OnetimeShotType.invalidSendMesssage;
default:
Log.error('Unknown OnetimeShotType: $value');
return OnetimeShotType.unknown;
}
}
Map<String, dynamic> toMap() {
return {
onetimeShotType: toString(),
};
}
}
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
if (metadata == null) {
return null;
}
for (final entry in metadata.entries) {
if (entry.key == onetimeShotType) {
return OnetimeMessageTypeExtension.fromString(entry.value as String);
}
}
return null;
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
@ -12,9 +13,8 @@ import 'chat_input_bloc.dart';
part 'chat_file_bloc.freezed.dart';
class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
ChatFileBloc({
required String chatId,
}) : listener = LocalLLMListener(),
ChatFileBloc()
: listener = LocalLLMListener(),
super(const ChatFileState()) {
listener.start(
stateCallback: (pluginState) {
@ -49,38 +49,15 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
},
newFile: (String filePath, String fileName) async {
final files = List<ChatFile>.from(state.uploadFiles);
files.add(ChatFile(filePath: filePath, fileName: fileName));
emit(
state.copyWith(
uploadFiles: files,
),
);
emit(
state.copyWith(
uploadFileIndicator: UploadFileIndicator.uploading(fileName),
),
);
final payload = ChatFilePB(filePath: filePath, chatId: chatId);
unawaited(
AIEventChatWithFile(payload).send().then((result) {
if (!isClosed) {
result.fold((_) {
add(
ChatFileEvent.updateUploadState(
UploadFileIndicator.finish(fileName),
),
);
}, (err) {
add(
ChatFileEvent.updateUploadState(
UploadFileIndicator.error(err.msg),
),
);
});
}
}),
);
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
files.add(newFile);
emit(
state.copyWith(
uploadFiles: files,
),
);
}
},
updateChatState: (LocalAIChatPB chatState) {
// Only user enable chat with file and the plugin is already running
@ -109,6 +86,15 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
),
);
},
deleteFile: (file) {
final files = List<ChatFile>.from(state.uploadFiles);
files.remove(file);
emit(
state.copyWith(
uploadFiles: files,
),
);
},
clear: () {
emit(
state.copyWith(
@ -116,14 +102,24 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
),
);
},
updateUploadState: (UploadFileIndicator indicator) {
emit(state.copyWith(uploadFileIndicator: indicator));
},
);
},
);
}
ChatInputFileMetadata consumeMetaData() {
final metadata = state.uploadFiles.fold(
<String, ChatFile>{},
(map, file) => map..putIfAbsent(file.filePath, () => file),
);
if (metadata.isNotEmpty) {
add(const ChatFileEvent.clear());
}
return metadata;
}
final LocalLLMListener listener;
@override
@ -138,9 +134,8 @@ class ChatFileEvent with _$ChatFileEvent {
const factory ChatFileEvent.initial() = Initial;
const factory ChatFileEvent.newFile(String filePath, String fileName) =
_NewFile;
const factory ChatFileEvent.deleteFile(ChatFile file) = _DeleteFile;
const factory ChatFileEvent.clear() = _ClearFile;
const factory ChatFileEvent.updateUploadState(UploadFileIndicator indicator) =
_UpdateUploadState;
const factory ChatFileEvent.updateChatState(LocalAIChatPB chatState) =
_UpdateChatState;
const factory ChatFileEvent.updatePluginState(
@ -152,26 +147,8 @@ class ChatFileEvent with _$ChatFileEvent {
class ChatFileState with _$ChatFileState {
const factory ChatFileState({
@Default(false) bool supportChatWithFile,
UploadFileIndicator? uploadFileIndicator,
LocalAIChatPB? chatState,
@Default([]) List<ChatFile> uploadFiles,
@Default(AIType.appflowyAI()) AIType aiType,
}) = _ChatFileState;
}
@freezed
class UploadFileIndicator with _$UploadFileIndicator {
const factory UploadFileIndicator.finish(String fileName) = _Finish;
const factory UploadFileIndicator.uploading(String fileName) = _Uploading;
const factory UploadFileIndicator.error(String error) = _Error;
}
class ChatFile {
ChatFile({
required this.filePath,
required this.fileName,
});
final String filePath;
final String fileName;
}

View File

@ -81,7 +81,7 @@ class ChatInputActionBloc
),
);
},
addPage: (ChatInputActionPage page) {
addPage: (ChatInputMention page) {
if (!state.selectedPages.any((p) => p.pageId == page.pageId)) {
final List<ViewActionPage> pages = _filterPages(
state.views,
@ -97,7 +97,7 @@ class ChatInputActionBloc
}
},
removePage: (String text) {
final List<ChatInputActionPage> selectedPages =
final List<ChatInputMention> selectedPages =
List.from(state.selectedPages);
selectedPages.retainWhere((t) => !text.contains(t.title));
@ -128,7 +128,7 @@ class ChatInputActionBloc
List<ViewActionPage> _filterPages(
List<ViewPB> views,
List<ChatInputActionPage> selectedPages,
List<ChatInputMention> selectedPages,
String filter,
) {
final pages = views
@ -152,7 +152,7 @@ List<ViewActionPage> _filterPages(
.toList();
}
class ViewActionPage extends ChatInputActionPage {
class ViewActionPage extends ChatInputMention {
ViewActionPage({required this.view});
final ViewPB view;
@ -182,8 +182,7 @@ class ChatInputActionEvent with _$ChatInputActionEvent {
const factory ChatInputActionEvent.handleKeyEvent(
PhysicalKeyboardKey keyboardKey,
) = _HandleKeyEvent;
const factory ChatInputActionEvent.addPage(ChatInputActionPage page) =
_AddPage;
const factory ChatInputActionEvent.addPage(ChatInputMention page) = _AddPage;
const factory ChatInputActionEvent.removePage(String text) = _RemovePage;
const factory ChatInputActionEvent.clear() = _Clear;
}
@ -192,8 +191,8 @@ class ChatInputActionEvent with _$ChatInputActionEvent {
class ChatInputActionState with _$ChatInputActionState {
const factory ChatInputActionState({
@Default([]) List<ViewPB> views,
@Default([]) List<ChatInputActionPage> pages,
@Default([]) List<ChatInputActionPage> selectedPages,
@Default([]) List<ChatInputMention> pages,
@Default([]) List<ChatInputMention> selectedPages,
@Default("") String filter,
ChatInputKeyboardEvent? keyboardKey,
@Default(ChatActionMenuIndicator.loading())

View File

@ -5,14 +5,15 @@ import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
abstract class ChatInputActionPage extends Equatable {
abstract class ChatInputMention extends Equatable {
String get title;
String get pageId;
dynamic get page;
Widget get icon;
}
typedef ChatInputMetadata = Map<String, ChatInputActionPage>;
/// Key: the key is the pageId
typedef ChatInputMentionMetadata = Map<String, ChatInputMention>;
class ChatInputActionControl extends ChatActionHandler {
ChatInputActionControl({
@ -35,9 +36,9 @@ class ChatInputActionControl extends ChatActionHandler {
List<String> get tags =>
_commandBloc.state.selectedPages.map((e) => e.title).toList();
ChatInputMetadata consumeMetaData() {
ChatInputMentionMetadata consumeMetaData() {
final metadata = _commandBloc.state.selectedPages.fold(
<String, ChatInputActionPage>{},
<String, ChatInputMention>{},
(map, page) => map..putIfAbsent(page.pageId, () => page),
);
@ -70,7 +71,7 @@ class ChatInputActionControl extends ChatActionHandler {
}
@override
void onSelected(ChatInputActionPage page) {
void onSelected(ChatInputMention page) {
_commandBloc.add(ChatInputActionEvent.addPage(page));
textController.text = "$_showMenuText${page.title}";

View File

@ -81,3 +81,7 @@ class AIType with _$AIType {
const factory AIType.appflowyAI() = _AppFlowyAI;
const factory AIType.localAI() = _LocalAI;
}
extension AITypeX on AIType {
bool isLocalAI() => this is _LocalAI;
}

View File

@ -0,0 +1,49 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_input_file_bloc.freezed.dart';
class ChatInputFileBloc extends Bloc<ChatInputFileEvent, ChatInputFileState> {
ChatInputFileBloc({
// ignore: avoid_unused_constructor_parameters
required String chatId,
required this.file,
}) : super(const ChatInputFileState()) {
on<ChatInputFileEvent>(
(event, emit) async {
await event.when(
initial: () async {},
updateUploadState: (UploadFileIndicator indicator) {
emit(state.copyWith(uploadFileIndicator: indicator));
},
);
},
);
}
final ChatFile file;
}
@freezed
class ChatInputFileEvent with _$ChatInputFileEvent {
const factory ChatInputFileEvent.initial() = Initial;
const factory ChatInputFileEvent.updateUploadState(
UploadFileIndicator indicator,
) = _UpdateUploadState;
}
@freezed
class ChatInputFileState with _$ChatInputFileState {
const factory ChatInputFileState({
UploadFileIndicator? uploadFileIndicator,
}) = _ChatInputFileState;
}
@freezed
class UploadFileIndicator with _$UploadFileIndicator {
const factory UploadFileIndicator.finish() = _Finish;
const factory UploadFileIndicator.uploading() = _Uploading;
const factory UploadFileIndicator.error(String error) = _Error;
}

View File

@ -1,3 +1,6 @@
import 'dart:convert';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
@ -5,6 +8,97 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:nanoid/nanoid.dart';
/// Indicate file source from appflowy document
const appflowySoruce = "appflowy";
List<ChatFile> fileListFromMessageMetadata(
Map<String, dynamic>? map,
) {
final List<ChatFile> metadata = [];
if (map != null) {
for (final entry in map.entries) {
if (entry.value is ChatFile) {
metadata.add(entry.value);
}
}
}
return metadata;
}
List<ChatFile> chatFilesFromMetadataString(String? s) {
if (s == null || s.isEmpty || s == "null") {
return [];
}
final metadataJson = jsonDecode(s);
if (metadataJson is Map<String, dynamic>) {
final file = chatFileFromMap(metadataJson);
if (file != null) {
return [file];
} else {
return [];
}
} else if (metadataJson is List) {
return metadataJson
.map((e) => e as Map<String, dynamic>)
.map(chatFileFromMap)
.where((file) => file != null)
.cast<ChatFile>()
.toList();
} else {
Log.error("Invalid metadata: $metadataJson");
return [];
}
}
ChatFile? chatFileFromMap(Map<String, dynamic>? map) {
if (map == null) return null;
final filePath = map['source'] as String?;
final fileName = map['name'] as String?;
if (filePath == null || fileName == null) {
return null;
}
return ChatFile.fromFilePath(filePath);
}
List<ChatMessageRefSource> messageReferenceSource(String? s) {
if (s == null || s.isEmpty || s == "null") {
return [];
}
final List<ChatMessageRefSource> metadata = [];
try {
final metadataJson = jsonDecode(s);
if (metadataJson == null) {
Log.warn("metadata is null");
return [];
}
// [{"id":null,"name":"The Five Dysfunctions of a Team.pdf","source":"/Users/weidongfu/Desktop/The Five Dysfunctions of a Team.pdf"}]
if (metadataJson is Map<String, dynamic>) {
if (metadataJson.isNotEmpty) {
metadata.add(ChatMessageRefSource.fromJson(metadataJson));
}
} else if (metadataJson is List) {
metadata.addAll(
metadataJson.map(
(e) => ChatMessageRefSource.fromJson(e as Map<String, dynamic>),
),
);
} else {
Log.error("Invalid metadata: $metadataJson");
}
} catch (e) {
Log.error("Failed to parse metadata: $e");
}
return metadata;
}
Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
Map<String, dynamic>? map,
@ -24,7 +118,8 @@ Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
id: view.id,
name: view.name,
data: pb.text,
source: "appflowy document",
dataType: ChatMessageMetaTypePB.Txt,
source: appflowySoruce,
),
);
}, (err) {
@ -32,6 +127,31 @@ Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
});
}
}
} else if (entry.value is ChatFile) {
metadata.add(
ChatMessageMetaPB(
id: nanoid(8),
name: entry.value.fileName,
data: entry.value.filePath,
dataType: entry.value.fileType,
source: entry.value.filePath,
),
);
}
}
}
return metadata;
}
List<ChatFile> chatFilesFromMessageMetadata(
Map<String, dynamic>? map,
) {
final List<ChatFile> metadata = [];
if (map != null) {
for (final entry in map.entries) {
if (entry.value is ChatFile) {
metadata.add(entry.value);
}
}
}

View File

@ -0,0 +1,193 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
class AnswerStream {
AnswerStream() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) {
if (event.startsWith("data:")) {
_hasStarted = true;
final newText = event.substring(5);
_text += newText;
if (_onData != null) {
_onData!(_text);
}
} else if (event.startsWith("error:")) {
_error = event.substring(5);
if (_onError != null) {
_onError!(_error!);
}
} else if (event.startsWith("metadata:")) {
if (_onMetadata != null) {
final s = event.substring(9);
_onMetadata!(messageReferenceSource(s));
}
} else if (event == "AI_RESPONSE_LIMIT") {
if (_onAIResponseLimit != null) {
_onAIResponseLimit!();
}
}
},
onDone: () {
if (_onEnd != null) {
_onEnd!();
}
},
onError: (error) {
if (_onError != null) {
_onError!(error.toString());
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
bool _hasStarted = false;
String? _error;
String _text = "";
// Callbacks
void Function(String text)? _onData;
void Function()? _onStart;
void Function()? _onEnd;
void Function(String error)? _onError;
void Function()? _onAIResponseLimit;
void Function(List<ChatMessageRefSource> metadata)? _onMetadata;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
String get text => _text;
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
void listen({
void Function(String text)? onData,
void Function()? onStart,
void Function()? onEnd,
void Function(String error)? onError,
void Function()? onAIResponseLimit,
void Function(List<ChatMessageRefSource> metadata)? onMetadata,
}) {
_onData = onData;
_onStart = onStart;
_onEnd = onEnd;
_onError = onError;
_onAIResponseLimit = onAIResponseLimit;
_onMetadata = onMetadata;
if (_onStart != null) {
_onStart!();
}
}
}
class QuestionStream {
QuestionStream() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) {
if (event.startsWith("data:")) {
_hasStarted = true;
final newText = event.substring(5);
_text += newText;
if (_onData != null) {
_onData!(_text);
}
} else if (event.startsWith("message_id:")) {
final messageId = event.substring(11);
_onMessageId?.call(messageId);
} else if (event.startsWith("start_index_file:")) {
final indexName = event.substring(17);
_onFileIndexStart?.call(indexName);
} else if (event.startsWith("end_index_file:")) {
final indexName = event.substring(10);
_onFileIndexEnd?.call(indexName);
} else if (event.startsWith("index_file_error:")) {
final indexName = event.substring(16);
_onFileIndexError?.call(indexName);
} else if (event.startsWith("index_start:")) {
_onIndexStart?.call();
} else if (event.startsWith("index_end:")) {
_onIndexEnd?.call();
} else if (event.startsWith("done:")) {
_onDone?.call();
} else if (event.startsWith("error:")) {
_error = event.substring(5);
if (_onError != null) {
_onError!(_error!);
}
}
},
onError: (error) {
if (_onError != null) {
_onError!(error.toString());
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
bool _hasStarted = false;
String? _error;
String _text = "";
// Callbacks
void Function(String text)? _onData;
void Function(String error)? _onError;
void Function(String messageId)? _onMessageId;
void Function(String indexName)? _onFileIndexStart;
void Function(String indexName)? _onFileIndexEnd;
void Function(String indexName)? _onFileIndexError;
void Function()? _onIndexStart;
void Function()? _onIndexEnd;
void Function()? _onDone;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
String get text => _text;
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
void listen({
void Function(String text)? onData,
void Function(String error)? onError,
void Function(String messageId)? onMessageId,
void Function(String indexName)? onFileIndexStart,
void Function(String indexName)? onFileIndexEnd,
void Function(String indexName)? onFileIndexFail,
void Function()? onIndexStart,
void Function()? onIndexEnd,
void Function()? onDone,
}) {
_onData = onData;
_onError = onError;
_onMessageId = onMessageId;
_onFileIndexStart = onFileIndexStart;
_onFileIndexEnd = onFileIndexEnd;
_onFileIndexError = onFileIndexFail;
_onIndexStart = onIndexStart;
_onIndexEnd = onIndexEnd;
_onDone = onDone;
}
}

View File

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@ -19,7 +19,7 @@ class ChatSidePannelBloc
on<ChatSidePannelEvent>(
(event, emit) async {
await event.when(
selectedMetadata: (ChatMessageMetadata metadata) async {
selectedMetadata: (ChatMessageRefSource metadata) async {
emit(
state.copyWith(
metadata: metadata,
@ -62,7 +62,7 @@ class ChatSidePannelBloc
@freezed
class ChatSidePannelEvent with _$ChatSidePannelEvent {
const factory ChatSidePannelEvent.selectedMetadata(
ChatMessageMetadata metadata,
ChatMessageRefSource metadata,
) = _SelectedMetadata;
const factory ChatSidePannelEvent.close() = _Close;
const factory ChatSidePannelEvent.open(ViewPB view) = _Open;
@ -71,7 +71,7 @@ class ChatSidePannelEvent with _$ChatSidePannelEvent {
@freezed
class ChatSidePannelState with _$ChatSidePannelState {
const factory ChatSidePannelState({
ChatMessageMetadata? metadata,
ChatMessageRefSource? metadata,
@Default(ChatSidePannelIndicator.loading())
ChatSidePannelIndicator indicator,
@Default(false) bool isShowPannel,

View File

@ -1,6 +1,6 @@
import 'package:appflowy/plugins/ai_chat/application/chat_member_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy_backend/log.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_user_message_bloc.freezed.dart';
@ -8,15 +8,85 @@ part 'chat_user_message_bloc.freezed.dart';
class ChatUserMessageBloc
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
ChatUserMessageBloc({
required Message message,
required ChatMember? member,
}) : super(ChatUserMessageState.initial(message, member)) {
required dynamic message,
}) : super(
ChatUserMessageState.initial(
message,
),
) {
on<ChatUserMessageEvent>(
(event, emit) async {
(event, emit) {
event.when(
initial: () {},
refreshMember: (ChatMember member) {
emit(state.copyWith(member: member));
initial: () {
if (state.stream != null) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(state.stream!.text));
}
}
state.stream?.listen(
onData: (text) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(text));
}
},
onMessageId: (messageId) {
if (!isClosed) {
add(ChatUserMessageEvent.updateMessageId(messageId));
}
},
onError: (error) {
if (!isClosed) {
add(ChatUserMessageEvent.receiveError(error.toString()));
}
},
onFileIndexStart: (indexName) {
Log.debug("index start: $indexName");
},
onFileIndexEnd: (indexName) {
Log.info("index end: $indexName");
},
onFileIndexFail: (indexName) {
Log.debug("index fail: $indexName");
},
onIndexStart: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexStart(),
),
);
}
},
onIndexEnd: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexEnd(),
),
);
}
},
onDone: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.finish(),
),
);
}
},
);
},
updateText: (String text) {
emit(state.copyWith(text: text));
},
updateMessageId: (String messageId) {
emit(state.copyWith(messageId: messageId));
},
receiveError: (String error) {},
updateQuestionState: (QuestionMessageState newState) {
emit(state.copyWith(messageState: newState));
},
);
},
@ -27,20 +97,47 @@ class ChatUserMessageBloc
@freezed
class ChatUserMessageEvent with _$ChatUserMessageEvent {
const factory ChatUserMessageEvent.initial() = Initial;
const factory ChatUserMessageEvent.refreshMember(ChatMember member) =
_MemberInfo;
const factory ChatUserMessageEvent.updateText(String text) = _UpdateText;
const factory ChatUserMessageEvent.updateQuestionState(
QuestionMessageState newState,
) = _UpdateQuestionState;
const factory ChatUserMessageEvent.updateMessageId(String messageId) =
_UpdateMessageId;
const factory ChatUserMessageEvent.receiveError(String error) = _ReceiveError;
}
@freezed
class ChatUserMessageState with _$ChatUserMessageState {
const factory ChatUserMessageState({
required Message message,
ChatMember? member,
required String text,
QuestionStream? stream,
String? messageId,
@Default(QuestionMessageState.finish()) QuestionMessageState messageState,
}) = _ChatUserMessageState;
factory ChatUserMessageState.initial(
Message message,
ChatMember? member,
dynamic message,
) =>
ChatUserMessageState(message: message, member: member);
ChatUserMessageState(
text: message is String ? message : "",
stream: message is QuestionStream ? message : null,
);
}
@freezed
class QuestionMessageState with _$QuestionMessageState {
const factory QuestionMessageState.indexFileStart(String fileName) =
_IndexFileStart;
const factory QuestionMessageState.indexFileEnd(String fileName) =
_IndexFileEnd;
const factory QuestionMessageState.indexFileFail(String fileName) =
_IndexFileFail;
const factory QuestionMessageState.indexStart() = _IndexStart;
const factory QuestionMessageState.indexEnd() = _IndexEnd;
const factory QuestionMessageState.finish() = _Finish;
}
extension QuestionMessageStateX on QuestionMessageState {
bool get isFinish => this is _Finish;
}

View File

@ -0,0 +1,60 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_message_service.dart';
part 'chat_user_message_bubble_bloc.freezed.dart';
class ChatUserMessageBubbleBloc
extends Bloc<ChatUserMessageBubbleEvent, ChatUserMessageBubbleState> {
ChatUserMessageBubbleBloc({
required Message message,
}) : super(
ChatUserMessageBubbleState.initial(
message,
_getFiles(message.metadata),
),
) {
on<ChatUserMessageBubbleEvent>(
(event, emit) async {
event.when(
initial: () {},
);
},
);
}
}
List<ChatFile> _getFiles(Map<String, dynamic>? metadata) {
if (metadata == null) {
return [];
}
final refSourceMetadata = metadata[messageRefSourceJsonStringKey] as String?;
final files = metadata[messageChatFileListKey] as List<ChatFile>?;
if (refSourceMetadata != null) {
return chatFilesFromMetadataString(refSourceMetadata);
}
return files ?? [];
}
@freezed
class ChatUserMessageBubbleEvent with _$ChatUserMessageBubbleEvent {
const factory ChatUserMessageBubbleEvent.initial() = Initial;
}
@freezed
class ChatUserMessageBubbleState with _$ChatUserMessageBubbleState {
const factory ChatUserMessageBubbleState({
required Message message,
required List<ChatFile> files,
}) = _ChatUserMessageBubbleState;
factory ChatUserMessageBubbleState.initial(
Message message,
List<ChatFile> files,
) =>
ChatUserMessageBubbleState(message: message, files: files);
}

View File

@ -2,15 +2,14 @@ import 'dart:math';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_message_bubble.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/other_user_message_bubble.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/user_message_bubble.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:desktop_drop/desktop_drop.dart';
@ -19,7 +18,6 @@ import 'package:flowy_infra/platform_extension.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_types/flutter_chat_types.dart';
@ -29,7 +27,6 @@ import 'package:styled_widget/styled_widget.dart';
import 'application/chat_member_bloc.dart';
import 'application/chat_side_pannel_bloc.dart';
import 'presentation/chat_input/chat_input.dart';
import 'presentation/chat_popmenu.dart';
import 'presentation/chat_side_pannel.dart';
import 'presentation/chat_theme.dart';
import 'presentation/chat_user_invalid_message.dart';
@ -88,8 +85,7 @@ class AIChatPage extends StatelessWidget {
/// [ChatFileBloc] is used to handle file indexing as a chat context
BlocProvider(
create: (_) => ChatFileBloc(chatId: view.id)
..add(const ChatFileEvent.initial()),
create: (_) => ChatFileBloc()..add(const ChatFileEvent.initial()),
),
/// [ChatInputStateBloc] is used to handle chat input text field state
@ -100,40 +96,24 @@ class AIChatPage extends StatelessWidget {
BlocProvider(create: (_) => ChatSidePannelBloc(chatId: view.id)),
BlocProvider(create: (_) => ChatMemberBloc()),
],
child: BlocListener<ChatFileBloc, ChatFileState>(
listenWhen: (previous, current) =>
previous.uploadFileIndicator != current.uploadFileIndicator,
listener: (context, state) {
_handleIndexIndicator(state.uploadFileIndicator, context);
},
child: BlocBuilder<ChatFileBloc, ChatFileState>(
builder: (context, state) {
return DropTarget(
onDragDone: (DropDoneDetails detail) async {
if (state.supportChatWithFile) {
await showConfirmDialog(
context: context,
style: ConfirmPopupStyle.cancelAndOk,
title: LocaleKeys.chat_chatWithFilePrompt.tr(),
confirmLabel: LocaleKeys.button_confirm.tr(),
onConfirm: () {
for (final file in detail.files) {
context
.read<ChatFileBloc>()
.add(ChatFileEvent.newFile(file.path, file.name));
}
},
description: '',
);
child: BlocBuilder<ChatFileBloc, ChatFileState>(
builder: (context, state) {
return DropTarget(
onDragDone: (DropDoneDetails detail) async {
if (state.supportChatWithFile) {
for (final file in detail.files) {
context
.read<ChatFileBloc>()
.add(ChatFileEvent.newFile(file.path, file.name));
}
},
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
);
},
),
}
},
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
);
},
),
);
}
@ -145,35 +125,6 @@ class AIChatPage extends StatelessWidget {
),
);
}
void _handleIndexIndicator(
UploadFileIndicator? indicator,
BuildContext context,
) {
if (indicator != null) {
indicator.when(
finish: (fileName) {
showSnackBarMessage(
context,
LocaleKeys.chat_indexFileSuccess.tr(args: [fileName]),
);
},
uploading: (fileName) {
showSnackBarMessage(
context,
LocaleKeys.chat_indexingFile.tr(args: [fileName]),
duration: const Duration(seconds: 2),
);
},
error: (err) {
showSnackBarMessage(
context,
err,
);
},
);
}
}
}
class _ChatContentPage extends StatefulWidget {
@ -302,31 +253,31 @@ class _ChatContentPageState extends State<_ChatContentPage> {
// We use custom bottom widget for chat input, so
// do not need to handle this event.
},
customBottomWidget: buildBottom(blocContext),
customBottomWidget: _buildBottom(blocContext),
user: _user,
theme: buildTheme(context),
onEndReached: () async {
if (state.hasMorePrevMessage &&
state.loadingPreviousStatus != const LoadingState.loading()) {
state.loadingPreviousStatus.isFinish) {
blocContext
.read<ChatBloc>()
.add(const ChatEvent.startLoadingPrevMessage());
}
},
emptyState: BlocBuilder<ChatBloc, ChatState>(
builder: (_, state) =>
state.initialLoadingStatus == const LoadingState.finish()
? Padding(
padding: AIChatUILayout.welcomePagePadding,
child: ChatWelcomePage(
onSelectedQuestion: (question) => blocContext
.read<ChatBloc>()
.add(ChatEvent.sendMessage(message: question)),
),
)
: const Center(
child: CircularProgressIndicator.adaptive(),
),
builder: (_, state) => state.initialLoadingStatus.isFinish
? Padding(
padding: AIChatUILayout.welcomePagePadding,
child: ChatWelcomePage(
userProfile: widget.userProfile,
onSelectedQuestion: (question) => blocContext
.read<ChatBloc>()
.add(ChatEvent.sendMessage(message: question)),
),
)
: const Center(
child: CircularProgressIndicator.adaptive(),
),
),
messageWidthRatio: AIChatUILayout.messageWidthRatio,
textMessageBuilder: (
@ -339,45 +290,55 @@ class _ChatContentPageState extends State<_ChatContentPage> {
child, {
required message,
required nextMessageInGroup,
}) {
if (message.author.id == _user.id) {
return ChatUserMessageBubble(
message: message,
child: child,
);
} else if (isOtherUserMessage(message)) {
return OtherUserMessageBubble(
message: message,
child: child,
);
} else {
return _buildAIBubble(message, blocContext, state, child);
}
},
}) =>
_buildBubble(blocContext, message, child, state),
),
);
}
Widget _buildBubble(
BuildContext blocContext,
Message message,
Widget child,
ChatState state,
) {
if (message.author.id == _user.id) {
return ChatUserMessageBubble(
message: message,
child: child,
);
} else if (isOtherUserMessage(message)) {
return OtherUserMessageBubble(
message: message,
child: child,
);
} else {
return _buildAIBubble(message, blocContext, state, child);
}
}
Widget _buildTextMessage(BuildContext context, TextMessage message) {
if (message.author.id == _user.id) {
return ChatTextMessageWidget(
final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageWidget(
key: ValueKey(message.id),
user: message.author,
messageUserId: message.id,
text: message.text,
message: stream is QuestionStream ? stream : message.text,
);
} else {
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?["question"];
final metadata = message.metadata?["metadata"] as String?;
return ChatAITextMessageWidget(
final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
text: stream is AnswerStream ? stream : message.text,
message: stream is AnswerStream ? stream : message.text,
key: ValueKey(message.id),
questionId: questionId,
chatId: widget.view.id,
metadata: metadata,
onSelectedMetadata: (ChatMessageMetadata metadata) {
refSourceJsonString: refSourceJsonString,
onSelectedMetadata: (ChatMessageRefSource metadata) {
context.read<ChatSidePannelBloc>().add(
ChatSidePannelEvent.selectedMetadata(metadata),
);
@ -424,68 +385,7 @@ class _ChatContentPageState extends State<_ChatContentPage> {
);
}
Widget buildBubble(Message message, Widget child) {
final isAuthor = message.author.id == _user.id;
const borderRadius = BorderRadius.all(Radius.circular(6));
final childWithPadding = isAuthor
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: child,
)
: Padding(
padding: const EdgeInsets.all(8),
child: child,
);
// If the message is from the author, we will decorate it with a different color
final decoratedChild = isAuthor
? DecoratedBox(
decoration: BoxDecoration(
borderRadius: borderRadius,
color: !isAuthor || message.type == types.MessageType.image
? AFThemeExtension.of(context).tint1
: Theme.of(context).colorScheme.secondary,
),
child: childWithPadding,
)
: childWithPadding;
// If the message is from the author, no further actions are needed
if (isAuthor) {
return ClipRRect(
borderRadius: borderRadius,
child: decoratedChild,
);
} else {
if (isMobile) {
return ChatPopupMenu(
onAction: (action) {
switch (action) {
case ChatMessageAction.copy:
if (message is TextMessage) {
Clipboard.setData(ClipboardData(text: message.text));
showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
}
break;
}
},
builder: (context) =>
ClipRRect(borderRadius: borderRadius, child: decoratedChild),
);
} else {
// Show hover effect only on desktop
return ClipRRect(
borderRadius: borderRadius,
child: ChatAIMessageHover(
message: message,
child: decoratedChild,
),
);
}
}
}
Widget buildBottom(BuildContext context) {
Widget _buildBottom(BuildContext context) {
return ClipRect(
child: Padding(
padding: AIChatUILayout.safeAreaInsets(context),

View File

@ -17,10 +17,11 @@ class ChatInputAtButton extends StatelessWidget {
message: LocaleKeys.chat_clickToMention.tr(),
child: FlowyIconButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
icon: const FlowySvg(
FlowySvgs.mention_s,
size: Size.square(20),
radius: BorderRadius.circular(6),
icon: FlowySvg(
FlowySvgs.chat_at_s,
size: const Size.square(20),
color: Colors.grey.shade600,
),
onPressed: onTap,
),

View File

@ -1,7 +1,9 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input_file.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart';
import 'package:appflowy/startup/startup.dart';
@ -18,9 +20,10 @@ import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'chat_at_button.dart';
import 'chat_attachment.dart';
import 'chat_input_span.dart';
import 'chat_input_attachment.dart';
import 'chat_send_button.dart';
import 'chat_input_span.dart';
import 'layout_define.dart';
class ChatInput extends StatefulWidget {
/// Creates [ChatInput] widget.
@ -105,16 +108,6 @@ class _ChatInputState extends State<ChatInput> {
@override
Widget build(BuildContext context) {
const buttonPadding = EdgeInsets.symmetric(horizontal: 2);
const inputPadding = EdgeInsets.all(6);
final textPadding = isMobile
? const EdgeInsets.only(left: 8.0, right: 4.0)
: const EdgeInsets.symmetric(horizontal: 16);
final borderRadius = BorderRadius.circular(isMobile ? 10 : 30);
final color = isMobile
? Colors.transparent
: Theme.of(context).colorScheme.surfaceContainerHighest;
return Padding(
padding: inputPadding,
// ignore: use_decorated_box
@ -123,7 +116,7 @@ class _ChatInputState extends State<ChatInput> {
border: Border.all(
color: _inputFocusNode.hasFocus && !isMobile
? Theme.of(context).colorScheme.primary.withOpacity(0.6)
: Colors.transparent,
: Theme.of(context).colorScheme.secondary,
),
borderRadius: borderRadius,
),
@ -132,17 +125,50 @@ class _ChatInputState extends State<ChatInput> {
color: color,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// TODO(lucas): support mobile
if (PlatformExtension.isDesktop &&
widget.aiType == const AIType.localAI())
_attachmentButton(buttonPadding),
Expanded(child: _inputTextField(context, textPadding)),
if (context.read<ChatFileBloc>().state.uploadFiles.isNotEmpty)
Padding(
padding: EdgeInsets.only(
top: 12,
bottom: 12,
left: textPadding.left + sendButtonSize,
right: textPadding.right,
),
child: BlocBuilder<ChatFileBloc, ChatFileState>(
builder: (context, state) {
return ChatInputFile(
chatId: widget.chatId,
files: state.uploadFiles,
onDeleted: (file) => context.read<ChatFileBloc>().add(
ChatFileEvent.deleteFile(file),
),
);
},
),
),
if (widget.aiType == const AIType.appflowyAI())
_atButton(buttonPadding),
_sendButton(buttonPadding),
//
Row(
children: [
// TODO(lucas): support mobile
if (PlatformExtension.isDesktop &&
widget.aiType.isLocalAI())
_attachmentButton(buttonPadding),
// text field
Expanded(child: _inputTextField(context, textPadding)),
// mention button
// TODO(lucas): support mobile
if (PlatformExtension.isDesktop)
_mentionButton(buttonPadding),
// send button
_sendButton(buttonPadding),
],
),
],
),
),
@ -161,9 +187,20 @@ class _ChatInputState extends State<ChatInput> {
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
// consume metadata
final ChatInputMentionMetadata mentionPageMetadata =
_inputActionControl.consumeMetaData();
final ChatInputFileMetadata fileMetadata =
context.read<ChatFileBloc>().consumeMetaData();
// combine metadata
final Map<String, dynamic> metadata = {}
..addAll(mentionPageMetadata)
..addAll(fileMetadata);
final partialText = types.PartialText(
text: trimmedText,
metadata: _inputActionControl.consumeMetaData(),
metadata: metadata,
);
widget.onSendPressed(partialText);
_textController.clear();
@ -206,37 +243,13 @@ class _ChatInputState extends State<ChatInput> {
}
InputDecoration _buildInputDecoration(BuildContext context) {
if (!isMobile) {
return InputDecoration(
border: InputBorder.none,
hintText: widget.hintText,
focusedBorder: InputBorder.none,
hintStyle: TextStyle(
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
),
);
}
final borderRadius = BorderRadius.circular(10);
return InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
border: InputBorder.none,
hintText: widget.hintText,
focusedBorder: InputBorder.none,
hintStyle: TextStyle(
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
),
enabledBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 1.2,
),
),
);
}
@ -255,10 +268,6 @@ class _ChatInputState extends State<ChatInput> {
}
Future<void> _handleOnTextChange(BuildContext context, String text) async {
if (widget.aiType != const AIType.appflowyAI()) {
return;
}
if (!_inputActionControl.onTextChanged(text)) {
return;
}
@ -293,7 +302,7 @@ class _ChatInputState extends State<ChatInput> {
return Padding(
padding: buttonPadding,
child: SizedBox.square(
dimension: 26,
dimension: sendButtonSize,
child: ChatInputSendButton(
onSendPressed: () {
if (!_sendButtonEnabled) {
@ -317,7 +326,7 @@ class _ChatInputState extends State<ChatInput> {
return Padding(
padding: buttonPadding,
child: SizedBox.square(
dimension: 26,
dimension: attachButtonSize,
child: ChatInputAttachment(
onTap: () async {
final path = await getIt<FilePickerService>().pickFiles(
@ -344,11 +353,11 @@ class _ChatInputState extends State<ChatInput> {
);
}
Widget _atButton(EdgeInsets buttonPadding) {
Widget _mentionButton(EdgeInsets buttonPadding) {
return Padding(
padding: buttonPadding,
child: SizedBox.square(
dimension: 26,
dimension: attachButtonSize,
child: ChatInputAtButton(
onTap: () {
_textController.text += '@';

View File

@ -17,11 +17,11 @@ class ChatInputAttachment extends StatelessWidget {
message: LocaleKeys.chat_uploadFile.tr(),
child: FlowyIconButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
radius: BorderRadius.circular(6),
icon: FlowySvg(
FlowySvgs.ai_attachment_s,
size: const Size.square(20),
color: Theme.of(context).colorScheme.primary,
color: Colors.grey.shade600,
),
onPressed: onTap,
),

View File

@ -0,0 +1,130 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_file_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:styled_widget/styled_widget.dart';
class ChatInputFile extends StatelessWidget {
const ChatInputFile({
required this.chatId,
required this.files,
required this.onDeleted,
super.key,
});
final List<ChatFile> files;
final String chatId;
final Function(ChatFile) onDeleted;
@override
Widget build(BuildContext context) {
final List<Widget> children = files
.map(
(file) => ChatFilePreview(
chatId: chatId,
file: file,
onDeleted: onDeleted,
),
)
.toList();
return Wrap(
spacing: 6,
runSpacing: 6,
children: children,
);
}
}
class ChatFilePreview extends StatelessWidget {
const ChatFilePreview({
required this.chatId,
required this.file,
required this.onDeleted,
super.key,
});
final String chatId;
final ChatFile file;
final Function(ChatFile) onDeleted;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ChatInputFileBloc(chatId: chatId, file: file)
..add(const ChatInputFileEvent.initial()),
child: BlocBuilder<ChatInputFileBloc, ChatInputFileState>(
builder: (context, state) {
return FlowyHover(
builder: (context, onHover) {
return ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 260,
),
child: DecoratedBox(
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10.0,
vertical: 14,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
file.fileType.icon,
const HSpace(6),
Flexible(
child: FlowyText(
file.fileName,
fontSize: 12,
maxLines: 6,
),
),
],
),
),
if (onHover)
_CloseButton(
onPressed: () => onDeleted(file),
).positioned(top: -6, right: -6),
],
),
),
);
},
);
},
),
);
}
}
class _CloseButton extends StatelessWidget {
const _CloseButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return FlowyIconButton(
width: 24,
height: 24,
isSelected: true,
radius: BorderRadius.circular(12),
fillColor: Theme.of(context).colorScheme.surfaceContainer,
icon: const FlowySvg(
FlowySvgs.close_s,
size: Size.square(20),
),
onPressed: onPressed,
);
}
}

View File

@ -39,7 +39,9 @@ class ChatInputSendButton extends StatelessWidget {
icon: FlowySvg(
FlowySvgs.send_s,
size: const Size.square(14),
color: enabled ? Theme.of(context).colorScheme.primary : null,
color: enabled
? Theme.of(context).colorScheme.primary
: Colors.grey.shade600,
),
onPressed: onSendPressed,
);

View File

@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'chat_input.dart';
const double sendButtonSize = 26;
const double attachButtonSize = 26;
const buttonPadding = EdgeInsets.symmetric(horizontal: 2);
const inputPadding = EdgeInsets.all(6);
final textPadding = isMobile
? const EdgeInsets.only(left: 8.0, right: 4.0)
: const EdgeInsets.symmetric(horizontal: 16);
final borderRadius = BorderRadius.circular(30);
const color = Colors.transparent;

View File

@ -13,7 +13,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
abstract class ChatActionHandler {
void onEnter();
void onSelected(ChatInputActionPage page);
void onSelected(ChatInputMention page);
void onExit();
ChatInputActionBloc get commandBloc;
void onFilter(String filter);
@ -136,7 +136,7 @@ class _ActionItem extends StatelessWidget {
required this.isSelected,
});
final ChatInputActionPage item;
final ChatInputMention item;
final VoidCallback? onTap;
final bool isSelected;
@ -156,6 +156,7 @@ class _ActionItem extends StatelessWidget {
margin: const EdgeInsets.symmetric(horizontal: 6),
iconPadding: 10.0,
text: FlowyText.regular(
lineHeight: 1.0,
item.title,
),
onTap: onTap,
@ -175,7 +176,7 @@ class ActionList extends StatefulWidget {
final ChatActionHandler handler;
final VoidCallback? onDismiss;
final List<ChatInputActionPage> pages;
final List<ChatInputMention> pages;
final bool isLoading;
@override
@ -257,7 +258,7 @@ class _ActionListState extends State<ActionList> {
return widget.pages.asMap().entries.map((entry) {
final index = entry.key;
final ChatInputActionPage item = entry.value;
final ChatInputMention item = entry.value;
return AutoScrollTag(
key: ValueKey(item.pageId),
index: index,

View File

@ -1,4 +1,4 @@
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';

View File

@ -1,66 +1,109 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'chat_input/chat_input.dart';
class WelcomeQuestion {
WelcomeQuestion({
required this.text,
required this.iconData,
});
final String text;
final FlowySvgData iconData;
}
class ChatWelcomePage extends StatelessWidget {
ChatWelcomePage({required this.onSelectedQuestion, super.key});
ChatWelcomePage({
required this.userProfile,
required this.onSelectedQuestion,
super.key,
});
final void Function(String) onSelectedQuestion;
final UserProfilePB userProfile;
final List<String> items = [
LocaleKeys.chat_question1.tr(),
LocaleKeys.chat_question2.tr(),
LocaleKeys.chat_question3.tr(),
LocaleKeys.chat_question4.tr(),
final List<WelcomeQuestion> items = [
WelcomeQuestion(
text: LocaleKeys.chat_question1.tr(),
iconData: FlowySvgs.chat_lightbulb_s,
),
WelcomeQuestion(
text: LocaleKeys.chat_question2.tr(),
iconData: FlowySvgs.chat_scholar_s,
),
WelcomeQuestion(
text: LocaleKeys.chat_question3.tr(),
iconData: FlowySvgs.chat_question_s,
),
WelcomeQuestion(
text: LocaleKeys.chat_question4.tr(),
iconData: FlowySvgs.chat_feather_s,
),
];
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
opacity: 1.0,
duration: const Duration(seconds: 3),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlowySvg(
FlowySvgs.flowy_ai_chat_logo_s,
size: Size.square(44),
),
const SizedBox(height: 40),
Wrap(
children: items
.map(
(i) => WelcomeQuestion(
question: i,
onSelected: onSelectedQuestion,
),
)
.toList(),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(),
Opacity(
opacity: 0.8,
child: FlowyText(
fontSize: 15,
LocaleKeys.chat_questionDetail.tr(args: [userProfile.name]),
),
),
const VSpace(18),
Opacity(
opacity: 0.6,
child: FlowyText(
LocaleKeys.chat_questionTitle.tr(),
),
),
const VSpace(8),
Wrap(
direction: Axis.vertical,
children: items
.map(
(i) => WelcomeQuestionWidget(
question: i,
onSelected: onSelectedQuestion,
),
)
.toList(),
),
const VSpace(20),
],
),
),
);
}
}
class WelcomeQuestion extends StatelessWidget {
const WelcomeQuestion({
class WelcomeQuestionWidget extends StatelessWidget {
const WelcomeQuestionWidget({
required this.question,
required this.onSelected,
super.key,
});
final void Function(String) onSelected;
final String question;
final WelcomeQuestion question;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => onSelected(question),
onTap: () => onSelected(question.text),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
child: FlowyHover(
@ -70,12 +113,18 @@ class WelcomeQuestion extends StatelessWidget {
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FlowySvg(
question.iconData,
size: const Size.square(18),
blendMode: null,
),
const HSpace(16),
FlowyText(
question,
question.text,
maxLines: null,
),
],

View File

@ -124,6 +124,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
editorScrollController: scrollController,
blockComponentBuilders: blockBuilders,
commandShortcutEvents: [customCopyCommand],
disableAutoScroll: true,
editorState: editorState,
),
);

View File

@ -2,7 +2,7 @@ import 'dart:convert';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_popmenu.dart';

View File

@ -1,5 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
@ -8,23 +9,23 @@ import 'package:flutter/material.dart';
class AIMessageMetadata extends StatelessWidget {
const AIMessageMetadata({
required this.metadata,
required this.sources,
required this.onSelectedMetadata,
super.key,
});
final List<ChatMessageMetadata> metadata;
final Function(ChatMessageMetadata metadata) onSelectedMetadata;
final List<ChatMessageRefSource> sources;
final Function(ChatMessageRefSource metadata) onSelectedMetadata;
@override
Widget build(BuildContext context) {
final title = metadata.length == 1
? LocaleKeys.chat_referenceSource.tr(args: [metadata.length.toString()])
final title = sources.length == 1
? LocaleKeys.chat_referenceSource.tr(args: [sources.length.toString()])
: LocaleKeys.chat_referenceSources
.tr(args: [metadata.length.toString()]);
.tr(args: [sources.length.toString()]);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (metadata.isNotEmpty)
if (sources.isNotEmpty)
Opacity(
opacity: 0.5,
child: FlowyText(title, fontSize: 12),
@ -33,7 +34,7 @@ class AIMessageMetadata extends StatelessWidget {
Wrap(
spacing: 8.0,
runSpacing: 4.0,
children: metadata
children: sources
.map(
(m) => SizedBox(
height: 24,
@ -49,9 +50,17 @@ class AIMessageMetadata extends StatelessWidget {
child: FlowyText(
m.name,
fontSize: 14,
lineHeight: 1.0,
overflow: TextOverflow.ellipsis,
),
),
onTap: () => onSelectedMetadata(m),
disable: m.source != appflowySoruce,
onTap: () {
if (m.source != appflowySoruce) {
return;
}
onSelectedMetadata(m);
},
),
),
)

View File

@ -1,6 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_loading.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
import 'package:easy_localization/easy_localization.dart';
@ -14,32 +14,34 @@ import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'ai_metadata.dart';
class ChatAITextMessageWidget extends StatelessWidget {
const ChatAITextMessageWidget({
class ChatAIMessageWidget extends StatelessWidget {
const ChatAIMessageWidget({
super.key,
required this.user,
required this.messageUserId,
required this.text,
required this.message,
required this.questionId,
required this.chatId,
required this.metadata,
required this.refSourceJsonString,
required this.onSelectedMetadata,
});
final User user;
final String messageUserId;
final dynamic text;
/// message can be a striing or Stream<String>
final dynamic message;
final Int64? questionId;
final String chatId;
final String? metadata;
final void Function(ChatMessageMetadata metadata) onSelectedMetadata;
final String? refSourceJsonString;
final void Function(ChatMessageRefSource metadata) onSelectedMetadata;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ChatAIMessageBloc(
message: text,
metadata: metadata,
message: message,
refSourceJsonString: refSourceJsonString,
chatId: chatId,
questionId: questionId,
)..add(const ChatAIMessageEvent.initial()),
@ -58,8 +60,8 @@ class ChatAITextMessageWidget extends StatelessWidget {
onAIResponseLimit: () {
return FlowyText(
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
maxLines: 10,
lineHeight: 1.5,
maxLines: 10,
);
},
ready: () {
@ -71,7 +73,7 @@ class ChatAITextMessageWidget extends StatelessWidget {
children: [
AIMarkdownText(markdown: state.text),
AIMessageMetadata(
metadata: state.metadata,
sources: state.sources,
onSelectedMetadata: onSelectedMetadata,
),
],

View File

@ -1,16 +1,12 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_member_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bubble_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:styled_widget/styled_widget.dart';
class ChatUserMessageBubble extends StatelessWidget {
const ChatUserMessageBubble({
@ -34,140 +30,127 @@ class ChatUserMessageBubble extends StatelessWidget {
.add(ChatMemberEvent.getMemberInfo(message.author.id));
}
return BlocConsumer<ChatMemberBloc, ChatMemberState>(
listenWhen: (previous, current) {
return previous.members[message.author.id] !=
current.members[message.author.id];
},
listener: (context, state) {},
builder: (context, state) {
final member = state.members[message.author.id];
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// _wrapHover(
Flexible(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: borderRadius,
color: backgroundColor,
return BlocProvider(
create: (context) => ChatUserMessageBubbleBloc(
message: message,
),
child: BlocBuilder<ChatUserMessageBubbleBloc, ChatUserMessageBubbleState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (state.files.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(right: defaultAvatarSize + 32),
child: _MessageFileList(files: state.files),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
const VSpace(6),
],
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: borderRadius,
color: backgroundColor,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: child,
),
),
),
child: child,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: BlocConsumer<ChatMemberBloc, ChatMemberState>(
listenWhen: (previous, current) =>
previous.members[message.author.id] !=
current.members[message.author.id],
listener: (context, state) {},
builder: (context, state) {
final member = state.members[message.author.id];
return ChatUserAvatar(
iconUrl: member?.info.avatarUrl ?? "",
name: member?.info.name ?? "",
);
},
),
),
],
),
),
// ),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ChatUserAvatar(
iconUrl: member?.info.avatarUrl ?? "",
name: member?.info.name ?? "",
],
);
},
),
);
}
}
class _MessageFileList extends StatelessWidget {
const _MessageFileList({required this.files});
final List<ChatFile> files;
@override
Widget build(BuildContext context) {
final List<Widget> children = files
.map(
(file) => _MessageFile(
file: file,
),
)
.toList();
return Wrap(
direction: Axis.vertical,
crossAxisAlignment: WrapCrossAlignment.end,
spacing: 6,
runSpacing: 6,
children: children,
);
}
}
class _MessageFile extends StatelessWidget {
const _MessageFile({required this.file});
final ChatFile file;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(context).colorScheme.secondary,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(dimension: 16, child: file.fileType.icon),
const HSpace(6),
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: FlowyText(
file.fileName,
fontSize: 12,
maxLines: 6,
),
),
),
],
);
},
);
}
}
class ChatUserMessageHover extends StatefulWidget {
const ChatUserMessageHover({
super.key,
required this.child,
required this.message,
});
final Widget child;
final Message message;
final bool autoShowHover = true;
@override
State<ChatUserMessageHover> createState() => _ChatUserMessageHoverState();
}
class _ChatUserMessageHoverState extends State<ChatUserMessageHover> {
bool _isHover = false;
@override
void initState() {
super.initState();
_isHover = widget.autoShowHover ? false : true;
}
@override
Widget build(BuildContext context) {
final List<Widget> children = [
DecoratedBox(
decoration: const BoxDecoration(
color: Colors.transparent,
borderRadius: Corners.s6Border,
),
child: Padding(
padding: const EdgeInsets.only(bottom: 30),
child: widget.child,
),
),
];
if (_isHover) {
if (widget.message is TextMessage) {
children.add(
EditButton(
textMessage: widget.message as TextMessage,
).positioned(right: 0, bottom: 0),
);
}
}
return MouseRegion(
cursor: SystemMouseCursors.click,
opaque: false,
onEnter: (p) => setState(() {
if (widget.autoShowHover) {
_isHover = true;
}
}),
onExit: (p) => setState(() {
if (widget.autoShowHover) {
_isHover = false;
}
}),
child: Stack(
alignment: AlignmentDirectional.centerStart,
children: children,
),
);
}
}
class EditButton extends StatelessWidget {
const EditButton({
super.key,
required this.textMessage,
});
final TextMessage textMessage;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopy.tr(),
child: FlowyIconButton(
width: 24,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
fillColor: Theme.of(context).cardColor,
icon: FlowySvg(
FlowySvgs.ai_copy_s,
size: const Size.square(14),
color: Theme.of(context).colorScheme.primary,
),
onPressed: () {},
),
);
}

View File

@ -1,37 +1,48 @@
import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
class ChatTextMessageWidget extends StatelessWidget {
const ChatTextMessageWidget({
class ChatUserMessageWidget extends StatelessWidget {
const ChatUserMessageWidget({
super.key,
required this.user,
required this.messageUserId,
required this.text,
required this.message,
});
final User user;
final String messageUserId;
final String text;
final dynamic message;
@override
Widget build(BuildContext context) {
return _textWidgetBuilder(user, context, text);
}
return BlocProvider(
create: (context) => ChatUserMessageBloc(message: message)
..add(const ChatUserMessageEvent.initial()),
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
builder: (context, state) {
final List<Widget> children = [];
children.add(
Flexible(
child: TextMessageText(
text: state.text,
),
),
);
Widget _textWidgetBuilder(
User user,
BuildContext context,
String text,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextMessageText(
text: text,
),
],
if (!state.messageState.isFinish) {
children.add(const HSpace(6));
children.add(const CircularProgressIndicator.adaptive());
}
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: children,
);
},
),
);
}
}

View File

@ -237,6 +237,7 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
leftIcon: FlowySvg(action.icon),
text: FlowyText.medium(
action.text,
lineHeight: 1.0,
overflow: TextOverflow.ellipsis,
),
onTap: () {

View File

@ -173,12 +173,15 @@ class LayoutDateField extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(fieldInfo.name),
text: FlowyText.medium(
fieldInfo.name,
lineHeight: 1.0,
),
onTap: () {
onUpdated(fieldInfo.id);
popoverMutex.close();
},
leftIcon: const FlowySvg(FlowySvgs.grid_s),
leftIcon: const FlowySvg(FlowySvgs.date_s),
rightIcon: fieldInfo.id == fieldId
? const FlowySvg(FlowySvgs.check_s)
: null,
@ -206,6 +209,7 @@ class LayoutDateField extends StatelessWidget {
child: FlowyButton(
margin: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0),
text: FlowyText.medium(
lineHeight: 1.0,
LocaleKeys.calendar_settings_layoutDateField.tr(),
),
),
@ -307,6 +311,7 @@ class FirstDayOfWeek extends StatelessWidget {
child: FlowyButton(
margin: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0),
text: FlowyText.medium(
lineHeight: 1.0,
LocaleKeys.calendar_settings_firstDayOfWeek.tr(),
),
),
@ -367,7 +372,10 @@ class StartFromButton extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(title),
text: FlowyText.medium(
title,
lineHeight: 1.0,
),
onTap: () => onTap(dayIndex),
rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null,
),

View File

@ -15,6 +15,7 @@ class GridSize {
static double get popoverItemHeight => 26 * scale;
static double get typeOptionSeparatorHeight => 4 * scale;
static double get newPropertyButtonWidth => 140 * scale;
static double get mobileNewPropertyButtonWidth => 200 * scale;
static EdgeInsets get cellContentInsets => EdgeInsets.symmetric(
horizontal: GridSize.cellHPadding,

View File

@ -167,6 +167,7 @@ class _CalculateCellState extends State<CalculateCell> {
mainAxisAlignment: MainAxisAlignment.end,
children: [
FlowyText(
lineHeight: 1.0,
widget.calculation!.calculationType.shortLabel
.toUpperCase(),
color: Theme.of(context).hintColor,
@ -175,6 +176,7 @@ class _CalculateCellState extends State<CalculateCell> {
if (widget.calculation!.value.isNotEmpty) ...[
const HSpace(8),
FlowyText(
lineHeight: 1.0,
calculateValue,
color: AFThemeExtension.of(context).textColor,
overflow: TextOverflow.ellipsis,

View File

@ -22,7 +22,11 @@ class CalculationTypeItem extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(type.label, overflow: TextOverflow.ellipsis),
text: FlowyText.medium(
type.label,
overflow: TextOverflow.ellipsis,
lineHeight: 1.0,
),
onTap: () {
onTap();
PopoverContainer.of(context).close();

View File

@ -39,6 +39,7 @@ class ChoiceChipButton extends StatelessWidget {
decoration: decoration,
useIntrinsicWidth: true,
text: FlowyText(
lineHeight: 1.0,
filterInfo.fieldInfo.field.name,
color: AFThemeExtension.of(context).textColor,
),

View File

@ -31,6 +31,7 @@ class ConditionButton extends StatelessWidget {
child: FlowyButton(
useIntrinsicWidth: true,
text: FlowyText(
lineHeight: 1.0,
conditionName,
fontSize: 10,
color: AFThemeExtension.of(context).textColor,

View File

@ -163,6 +163,7 @@ class GridFilterPropertyCell extends StatelessWidget {
return FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
lineHeight: 1.0,
fieldInfo.field.name,
color: AFThemeExtension.of(context).textColor,
),

View File

@ -87,6 +87,7 @@ class _AddFilterButtonState extends State<AddFilterButton> {
height: 28,
child: FlowyButton(
text: FlowyText(
lineHeight: 1.0,
LocaleKeys.grid_settings_addFilter.tr(),
color: AFThemeExtension.of(context).textColor,
),

View File

@ -23,6 +23,7 @@ class GridAddRowButton extends StatelessWidget {
),
),
text: FlowyText(
lineHeight: 1.0,
LocaleKeys.grid_row_newRow.tr(),
color: Theme.of(context).hintColor,
),

View File

@ -229,6 +229,7 @@ class FieldCellButton extends StatelessWidget {
radius: radius,
text: FlowyText.medium(
field.name,
lineHeight: 1.0,
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).textColor,

View File

@ -195,6 +195,7 @@ class _CreateFieldButtonState extends State<CreateFieldButton> {
margin: GridSize.cellContentInsets,
radius: BorderRadius.zero,
text: FlowyText(
lineHeight: 1.0,
LocaleKeys.grid_field_newProperty.tr(),
overflow: TextOverflow.ellipsis,
),

View File

@ -197,7 +197,7 @@ class _CreateFieldButtonState extends State<CreateFieldButton> {
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxWidth: GridSize.newPropertyButtonWidth,
maxWidth: GridSize.mobileNewPropertyButtonWidth,
minHeight: GridSize.headerHeight,
),
decoration: _getDecoration(context),

View File

@ -51,7 +51,11 @@ class RowActionMenu extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(action.text, overflow: TextOverflow.ellipsis),
text: FlowyText.medium(
action.text,
overflow: TextOverflow.ellipsis,
lineHeight: 1.0,
),
onTap: () {
if (action == RowAction.delete) {
NavigatorOkCancelDialog(
@ -82,7 +86,7 @@ enum RowAction {
return switch (this) {
insertAbove => FlowySvgs.arrow_s,
insertBelow => FlowySvgs.add_s,
duplicate => FlowySvgs.copy_s,
duplicate => FlowySvgs.duplicate_s,
delete => FlowySvgs.delete_s,
};
}

View File

@ -120,6 +120,7 @@ class GridSortPropertyCell extends StatelessWidget {
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
fieldInfo.name,
lineHeight: 1.0,
color: AFThemeExtension.of(context).textColor,
),
onTap: onTap,

View File

@ -34,6 +34,7 @@ class SortChoiceButton extends StatelessWidget {
useIntrinsicWidth: true,
text: FlowyText(
text,
lineHeight: 1.0,
color: AFThemeExtension.of(context).textColor,
overflow: TextOverflow.ellipsis,
),

View File

@ -222,6 +222,7 @@ class TabBarItemButton extends StatelessWidget {
),
text: FlowyText(
view.name,
lineHeight: 1.0,
fontSize: FontSizes.s11,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
@ -290,4 +291,9 @@ enum TabBarViewAction implements ActionCell {
@override
Widget? rightIcon(Color iconColor) => null;
@override
Color? textColor(BuildContext context) {
return null;
}
}

View File

@ -26,7 +26,7 @@ extension DatabaseLayoutExtension on DatabaseLayoutPB {
FlowySvgData get icon {
return switch (this) {
DatabaseLayoutPB.Board => FlowySvgs.board_s,
DatabaseLayoutPB.Calendar => FlowySvgs.date_s,
DatabaseLayoutPB.Calendar => FlowySvgs.calendar_s,
DatabaseLayoutPB.Grid => FlowySvgs.grid_s,
_ => throw UnimplementedError(),
};

View File

@ -158,6 +158,7 @@ class _EditFieldButton extends StatelessWidget {
child: FlowyButton(
leftIcon: const FlowySvg(FlowySvgs.edit_s),
text: FlowyText.medium(
lineHeight: 1.0,
LocaleKeys.grid_field_editProperty.tr(),
),
onTap: onTap,
@ -193,6 +194,7 @@ class FieldActionCell extends StatelessWidget {
disable: !enable,
text: FlowyText.medium(
action.title(fieldInfo),
lineHeight: 1.0,
color: enable ? null : Theme.of(context).disabledColor,
),
onHover: (_) => popoverMutex?.close(),
@ -613,6 +615,7 @@ class _SwitchFieldButtonState extends State<SwitchFieldButton> {
},
text: FlowyText.medium(
state.field.fieldType.i18n,
lineHeight: 1.0,
color: isPrimary ? Theme.of(context).disabledColor : null,
),
leftIcon: FlowySvg(

View File

@ -75,9 +75,7 @@ class FieldTypeCell extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(
fieldType.i18n,
),
text: FlowyText.medium(fieldType.i18n, lineHeight: 1.0),
onTap: () => onSelectField(fieldType),
leftIcon: FlowySvg(
fieldType.svgData,

View File

@ -23,7 +23,10 @@ class DateFormatButton extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(LocaleKeys.grid_field_dateFormat.tr()),
text: FlowyText.medium(
LocaleKeys.grid_field_dateFormat.tr(),
lineHeight: 1.0,
),
onTap: onTap,
onHover: onHover,
rightIcon: const FlowySvg(FlowySvgs.more_s),
@ -47,7 +50,10 @@ class TimeFormatButton extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(LocaleKeys.grid_field_timeFormat.tr()),
text: FlowyText.medium(
LocaleKeys.grid_field_timeFormat.tr(),
lineHeight: 1.0,
),
onTap: onTap,
onHover: onHover,
rightIcon: const FlowySvg(FlowySvgs.more_s),
@ -114,7 +120,10 @@ class DateFormatCell extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(dateFormat.title()),
text: FlowyText.medium(
dateFormat.title(),
lineHeight: 1.0,
),
rightIcon: checkmark,
onTap: () => onSelected(dateFormat),
),
@ -199,7 +208,10 @@ class TimeFormatCell extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(timeFormat.title()),
text: FlowyText.medium(
timeFormat.title(),
lineHeight: 1.0,
),
rightIcon: checkmark,
onTap: () => onSelected(timeFormat),
),

View File

@ -32,6 +32,7 @@ class NumberTypeOptionEditorFactory implements TypeOptionEditorFactory {
child: FlowyButton(
rightIcon: const FlowySvg(FlowySvgs.more_s),
text: FlowyText.medium(
lineHeight: 1.0,
typeOption.format.title(),
),
),
@ -167,7 +168,10 @@ class NumberFormatCell extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(format.title()),
text: FlowyText.medium(
format.title(),
lineHeight: 1.0,
),
onTap: () => onSelected(format),
rightIcon: checkmark,
),

View File

@ -61,6 +61,7 @@ class RelationTypeOptionEditorFactory implements TypeOptionEditorFactory {
(meta) => meta.databaseId == typeOption.databaseId,
);
return FlowyText(
lineHeight: 1.0,
databaseMeta == null
? LocaleKeys
.grid_relation_relatedDatabasePlaceholder
@ -134,6 +135,7 @@ class _DatabaseList extends StatelessWidget {
child: FlowyButton(
onTap: () => onSelectDatabase(meta.databaseId),
text: FlowyText.medium(
lineHeight: 1.0,
meta.databaseName,
overflow: TextOverflow.ellipsis,
),

View File

@ -181,6 +181,7 @@ class _AddOptionButton extends StatelessWidget {
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(
lineHeight: 1.0,
LocaleKeys.grid_field_addSelectOption.tr(),
),
onTap: () {

View File

@ -107,6 +107,7 @@ class _DeleteTag extends StatelessWidget {
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(
lineHeight: 1.0,
LocaleKeys.grid_selectOption_deleteTag.tr(),
),
leftIcon: const FlowySvg(FlowySvgs.delete_s),
@ -230,6 +231,7 @@ class _SelectOptionColorCell extends StatelessWidget {
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
lineHeight: 1.0,
color.colorName(),
color: AFThemeExtension.of(context).textColor,
),

View File

@ -97,7 +97,12 @@ class SelectLanguageButton extends StatelessWidget {
Widget build(BuildContext context) {
return SizedBox(
height: 30,
child: FlowyButton(text: FlowyText(language)),
child: FlowyButton(
text: FlowyText(
language,
lineHeight: 1.0,
),
),
);
}
}
@ -159,7 +164,10 @@ class LanguageCell extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(languageTypeToLanguage(languageType)),
text: FlowyText.medium(
languageTypeToLanguage(languageType),
lineHeight: 1.0,
),
rightIcon: checkmark,
onTap: () => onSelected(languageType),
),

View File

@ -193,6 +193,7 @@ class _GridGroupCell extends StatelessWidget {
text: FlowyText.medium(
name,
color: AFThemeExtension.of(context).textColor,
lineHeight: 1.0,
),
leftIcon: icon != null
? FlowySvg(

View File

@ -50,7 +50,10 @@ class RowDetailPageDeleteButton extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()),
text: FlowyText.regular(
LocaleKeys.grid_row_delete.tr(),
lineHeight: 1.0,
),
leftIcon: const FlowySvg(FlowySvgs.trash_m),
onTap: () {
RowBackendService.deleteRows(viewId, [rowId]);
@ -76,7 +79,10 @@ class RowDetailPageDuplicateButton extends StatelessWidget {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.regular(LocaleKeys.grid_row_duplicate.tr()),
text: FlowyText.regular(
LocaleKeys.grid_row_duplicate.tr(),
lineHeight: 1.0,
),
leftIcon: const FlowySvg(FlowySvgs.copy_s),
onTap: () {
RowBackendService.duplicateRow(viewId, rowId);

View File

@ -220,6 +220,7 @@ class AddEmojiButton extends StatelessWidget {
child: FlowyButton(
useIntrinsicWidth: true,
text: FlowyText.medium(
lineHeight: 1.0,
LocaleKeys.document_plugins_cover_addIcon.tr(),
),
leftIcon: const FlowySvg(FlowySvgs.emoji_s),
@ -242,6 +243,7 @@ class RemoveEmojiButton extends StatelessWidget {
child: FlowyButton(
useIntrinsicWidth: true,
text: FlowyText.medium(
lineHeight: 1.0,
LocaleKeys.document_plugins_cover_removeIcon.tr(),
),
leftIcon: const FlowySvg(FlowySvgs.emoji_s),

View File

@ -294,7 +294,11 @@ class ToggleHiddenFieldsVisibilityButton extends StatelessWidget {
return SizedBox(
height: 30,
child: FlowyButton(
text: FlowyText.medium(text, color: Theme.of(context).hintColor),
text: FlowyText.medium(
text,
lineHeight: 1.0,
color: Theme.of(context).hintColor,
),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
leftIcon: RotatedBox(
quarterTurns: quarterTurns,
@ -381,6 +385,7 @@ class _CreateRowFieldButtonState extends State<CreateRowFieldButton> {
child: FlowyButton(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
text: FlowyText.medium(
lineHeight: 1.0,
LocaleKeys.grid_field_newProperty.tr(),
color: Theme.of(context).hintColor,
),

View File

@ -80,6 +80,7 @@ class DatabaseViewLayoutCell extends StatelessWidget {
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
lineHeight: 1.0,
databaseLayout.layoutName,
color: AFThemeExtension.of(context).textColor,
),

View File

@ -23,7 +23,7 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction {
FlowySvgData iconData() {
switch (this) {
case DatabaseSettingAction.showProperties:
return FlowySvgs.properties_s;
return FlowySvgs.multiselect_s;
case DatabaseSettingAction.showLayout:
return FlowySvgs.database_layout_m;
case DatabaseSettingAction.showGroup:
@ -82,6 +82,7 @@ extension DatabaseSettingActionExtension on DatabaseSettingAction {
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
title(),
lineHeight: 1.0,
color: AFThemeExtension.of(context).textColor,
),
leftIcon: FlowySvg(

View File

@ -150,6 +150,7 @@ class _DatabasePropertyCellState extends State<DatabasePropertyCell> {
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText.medium(
lineHeight: 1.0,
widget.fieldInfo.name,
color: AFThemeExtension.of(context).textColor,
),

View File

@ -189,7 +189,10 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
),
),
CalloutBlockKeys.type: CalloutBlockComponentBuilder(
configuration: configuration.copyWith(),
configuration: configuration.copyWith(
padding: (node) => const EdgeInsets.symmetric(vertical: 10),
),
inlinePadding: const EdgeInsets.symmetric(vertical: 8.0),
defaultColor: calloutBGColor,
),
DividerBlockKeys.type: DividerBlockComponentBuilder(

View File

@ -9,7 +9,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/forma
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart';
@ -146,7 +145,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
customizeFontToolbarItem,
];
late final List<SelectionMenuItem> slashMenuItems;
late List<SelectionMenuItem> slashMenuItems;
List<CharacterShortcutEvent> get characterShortcutEvents => [
// code block
@ -155,6 +154,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
// callout block
insertNewLineInCalloutBlock,
// quote block
insertNewLineInQuoteBlock,
// toggle list
formatGreaterToToggleList,
insertChildNodeInsideToggleList,
@ -282,9 +284,17 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
focusManager = currFocusManager;
focusManager?.loseFocusNotifier.addListener(_loseFocus);
}
super.didChangeDependencies();
}
@override
void reassemble() {
super.reassemble();
slashMenuItems = _customSlashMenuItems();
}
@override
void dispose() {
focusManager?.loseFocusNotifier.removeListener(_loseFocus);
@ -387,42 +397,45 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
editorState: editorState,
editorScrollController: editorScrollController,
textDirection: textDirection,
tooltipBuilder: (context, id, message, child) => widget.styleCustomizer
.buildToolbarItemTooltip(context, id, message, child,),
tooltipBuilder: (context, id, message, child) =>
widget.styleCustomizer.buildToolbarItemTooltip(
context,
id,
message,
child,
),
child: editor,
),
);
}
List<SelectionMenuItem> _customSlashMenuItems() {
final items = [...standardSelectionMenuItems];
final imageItem = items
.firstWhereOrNull((e) => e.name == AppFlowyEditorL10n.current.image);
if (imageItem != null) {
final imageItemIndex = items.indexOf(imageItem);
if (imageItemIndex != -1) {
items[imageItemIndex] = customImageMenuItem;
}
}
return [
...items,
inlineGridMenuItem(documentBloc),
referencedGridMenuItem,
inlineBoardMenuItem(documentBloc),
referencedBoardMenuItem,
inlineCalendarMenuItem(documentBloc),
referencedCalendarMenuItem,
referencedDocumentMenuItem,
calloutItem,
outlineItem,
mathEquationItem,
codeBlockItem(LocaleKeys.document_selectionMenu_codeBlock.tr()),
toggleListBlockItem,
emojiMenuItem,
autoGeneratorMenuItem,
dateMenuItem,
multiImageMenuItem,
fileMenuItem,
aiWriterSlashMenuItem,
textSlashMenuItem,
heading1SlashMenuItem,
heading2SlashMenuItem,
heading3SlashMenuItem,
imageSlashMenuItem,
bulletedListSlashMenuItem,
numberedListSlashMenuItem,
quoteSlashMenuItem,
referencedDocSlashMenuItem,
gridSlashMenuItem(documentBloc),
referencedGridSlashMenuItem,
kanbanSlashMenuItem(documentBloc),
referencedKanbanSlashMenuItem,
calendarSlashMenuItem(documentBloc),
referencedCalendarSlashMenuItem,
calloutSlashMenuItem,
outlineSlashMenuItem,
mathEquationSlashMenuItem,
codeBlockSlashMenuItem,
toggleListSlashMenuItem,
emojiSlashMenuItem,
dateOrReminderSlashMenuItem,
photoGallerySlashMenuItem,
fileSlashMenuItem,
];
}

View File

@ -20,6 +20,7 @@ class EmojiPickerButton extends StatelessWidget {
this.title,
this.showBorder = true,
this.enable = true,
this.margin,
});
final String emoji;
@ -33,6 +34,7 @@ class EmojiPickerButton extends StatelessWidget {
final String? title;
final bool showBorder;
final bool enable;
final EdgeInsets? margin;
@override
Widget build(BuildContext context) {
@ -44,6 +46,7 @@ class EmojiPickerButton extends StatelessWidget {
height: emojiPickerSize.height,
),
offset: offset,
margin: EdgeInsets.zero,
direction: direction ?? PopoverDirection.rightWithTopAligned,
popupBuilder: (_) => Container(
width: emojiPickerSize.width,
@ -79,15 +82,16 @@ class EmojiPickerButton extends StatelessWidget {
);
}
return FlowyTextButton(
emoji,
overflow: TextOverflow.visible,
fontSize: emojiSize,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 36.0),
fillColor: Colors.transparent,
mainAxisAlignment: MainAxisAlignment.center,
onPressed: enable
return FlowyButton(
useIntrinsicWidth: true,
margin:
margin ?? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
text: FlowyText.emoji(
emoji,
fontSize: emojiSize,
optimizeEmojiAlign: true,
),
onTap: enable
? () async {
final result = await context.push<EmojiPickerResult>(
Uri(

View File

@ -53,7 +53,10 @@ class SelectableItem extends StatelessWidget {
return SizedBox(
height: 32,
child: FlowyButton(
text: FlowyText.medium(item),
text: FlowyText.medium(
item,
lineHeight: 1.0,
),
rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null,
onTap: onTap,
),

View File

@ -1,6 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class SelectableSvgWidget extends StatelessWidget {
@ -9,21 +8,31 @@ class SelectableSvgWidget extends StatelessWidget {
required this.data,
required this.isSelected,
required this.style,
this.size,
this.padding,
});
final FlowySvgData data;
final bool isSelected;
final SelectionMenuStyle style;
final Size? size;
final EdgeInsets? padding;
@override
Widget build(BuildContext context) {
return FlowySvg(
final child = FlowySvg(
data,
size: const Size.square(18.0),
size: size ?? const Size.square(16.0),
color: isSelected
? style.selectionMenuItemSelectedIconColor
: style.selectionMenuItemIconColor,
);
if (padding != null) {
return Padding(padding: padding!, child: child);
} else {
return child;
}
}
}

View File

@ -68,9 +68,11 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder {
CalloutBlockComponentBuilder({
super.configuration,
required this.defaultColor,
required this.inlinePadding,
});
final Color defaultColor;
final EdgeInsets inlinePadding;
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
@ -79,6 +81,7 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder {
key: node.key,
node: node,
defaultColor: defaultColor,
inlinePadding: inlinePadding,
configuration: configuration,
showActions: showActions(node),
actionBuilder: (context, state) => actionBuilder(
@ -105,9 +108,11 @@ class CalloutBlockComponentWidget extends BlockComponentStatefulWidget {
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
required this.defaultColor,
required this.inlinePadding,
});
final Color defaultColor;
final EdgeInsets inlinePadding;
@override
State<CalloutBlockComponentWidget> createState() =>
@ -176,6 +181,7 @@ class _CalloutBlockComponentWidgetState
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: backgroundColor,
),
padding: widget.inlinePadding,
width: double.infinity,
alignment: alignment,
child: Row(
@ -183,27 +189,22 @@ class _CalloutBlockComponentWidgetState
mainAxisSize: MainAxisSize.min,
textDirection: textDirection,
children: [
if (PlatformExtension.isDesktopOrWeb) const HSpace(4.0),
// the emoji picker button for the note
Padding(
padding: const EdgeInsets.only(
top: 6.0,
left: 4.0,
right: 4.0,
),
child: EmojiPickerButton(
key: ValueKey(
emoji.toString(),
), // force to refresh the popover state
enable: editorState.editable,
title: '',
emoji: emoji,
emojiSize: 16.0,
onSubmitted: (emoji, controller) {
setEmoji(emoji);
controller?.close();
},
),
EmojiPickerButton(
key: ValueKey(
emoji.toString(),
), // force to refresh the popover state
enable: editorState.editable,
title: '',
emoji: emoji,
emojiSize: 15.0,
onSubmitted: (emoji, controller) {
setEmoji(emoji);
controller?.close();
},
),
if (PlatformExtension.isDesktopOrWeb) const HSpace(4.0),
Flexible(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
@ -248,24 +249,21 @@ class _CalloutBlockComponentWidgetState
BuildContext context,
TextDirection textDirection,
) {
return Padding(
padding: padding,
child: AppFlowyRichText(
key: forwardKey,
delegate: this,
node: widget.node,
editorState: editorState,
placeholderText: placeholderText,
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(
textStyle,
),
placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle(
placeholderTextStyle,
),
textDirection: textDirection,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
return AppFlowyRichText(
key: forwardKey,
delegate: this,
node: widget.node,
editorState: editorState,
placeholderText: placeholderText,
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(
textStyle,
),
placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle(
placeholderTextStyle,
),
textDirection: textDirection,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
);
}

View File

@ -0,0 +1,18 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
final codeBlockSelectionMenuItem = SelectionMenuItem.node(
getName: () => LocaleKeys.document_selectionMenu_codeBlock.tr(),
iconBuilder: (editorState, onSelected, style) => SelectableSvgWidget(
data: FlowySvgs.icon_code_block_s,
isSelected: onSelected,
style: style,
),
keywords: ['code', 'codeblock'],
nodeBuilder: (_, __) => codeBlockNode(),
replace: (_, node) => node.delta?.isEmpty ?? false,
);

View File

@ -40,6 +40,13 @@ class ClipboardServiceData {
}
class ClipboardService {
static ClipboardServiceData? _mockData;
@visibleForTesting
static void mockSetData(ClipboardServiceData? data) {
_mockData = data;
}
Future<void> setData(ClipboardServiceData data) async {
final plainText = data.plainText;
final html = data.html;
@ -81,6 +88,10 @@ class ClipboardService {
}
Future<ClipboardServiceData> getData() async {
if (_mockData != null) {
return _mockData!;
}
final reader = await SystemClipboard.instance?.read();
if (reader == null) {

View File

@ -1,4 +1,3 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';

View File

@ -1,6 +1,5 @@
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart';

View File

@ -1,188 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
final _listTypes = [
BulletedListBlockKeys.type,
TodoListBlockKeys.type,
NumberedListBlockKeys.type,
];
extension PasteNodes on EditorState {
Future<void> pasteSingleLineNode(Node insertedNode) async {
final selection = await deleteSelectionIfNeeded();
if (selection == null) {
return;
}
final node = getNodeAtPath(selection.start.path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}
final transaction = this.transaction;
final insertedDelta = insertedNode.delta;
// if the node is empty and its type is paragprah, replace it with the inserted node.
if (delta.isEmpty && node.type == ParagraphBlockKeys.type) {
transaction.insertNode(
selection.end.path.next,
insertedNode,
);
transaction.deleteNode(node);
final path = calculatePath(selection.end.path, [insertedNode]);
final offset = calculateLength([insertedNode]);
transaction.afterSelection = Selection.collapsed(
Position(
path: path,
offset: offset,
),
);
} else if (_listTypes.contains(node.type)) {
final convertedNode = insertedNode.copyWith(type: node.type);
final path = selection.start.path;
transaction
..insertNode(path, convertedNode)
..deleteNodesAtPath(path);
// Set the afterSelection to the last child of the inserted node
final lastChildPath = calculatePath(path, [convertedNode]);
final lastChildOffset = calculateLength([convertedNode]);
transaction.afterSelection = Selection.collapsed(
Position(path: lastChildPath, offset: lastChildOffset),
);
} else if (insertedDelta != null) {
// if the node is not empty, insert the delta from inserted node after the selection.
transaction.insertTextDelta(node, selection.endIndex, insertedDelta);
}
await apply(transaction);
}
Future<void> pasteMultiLineNodes(List<Node> nodes) async {
assert(nodes.length > 1);
final selection = await deleteSelectionIfNeeded();
if (selection == null) {
return;
}
final node = getNodeAtPath(selection.start.path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}
final transaction = this.transaction;
final lastNodeLength = calculateLength(nodes);
// merge the current selected node delta into the nodes.
if (delta.isNotEmpty) {
nodes.first.insertDelta(
delta.slice(0, selection.startIndex),
insertAfter: false,
);
nodes.last.insertDelta(
delta.slice(selection.endIndex),
);
}
if (delta.isEmpty && node.type != ParagraphBlockKeys.type) {
nodes[0] = nodes.first.copyWith(
type: node.type,
attributes: {
...node.attributes,
...nodes.first.attributes,
},
);
}
for (final child in node.children) {
nodes.last.insert(child);
}
transaction.insertNodes(selection.end.path, nodes);
// delete the current node.
transaction.deleteNode(node);
final path = calculatePath(selection.start.path, nodes);
transaction.afterSelection = Selection.collapsed(
Position(
path: path,
offset: lastNodeLength,
),
);
await apply(transaction);
}
// delete the selection if it's not collapsed.
Future<Selection?> deleteSelectionIfNeeded() async {
final selection = this.selection;
if (selection == null) {
return null;
}
// delete the selection first.
if (!selection.isCollapsed) {
await deleteSelection(selection);
}
// fetch selection again.selection = editorState.selection;
assert(this.selection?.isCollapsed == true);
return this.selection;
}
Path calculatePath(Path start, List<Node> nodes) {
var path = start;
for (var i = 0; i < nodes.length; i++) {
path = path.next;
}
path = path.previous;
if (nodes.last.children.isNotEmpty) {
return [
...path,
...calculatePath([0], nodes.last.children.toList()),
];
}
return path;
}
int calculateLength(List<Node> nodes) {
if (nodes.last.children.isNotEmpty) {
return calculateLength(nodes.last.children.toList());
}
return nodes.last.delta?.length ?? 0;
}
}
extension on Node {
void insertDelta(Delta delta, {bool insertAfter = true}) {
assert(delta.every((element) => element is TextInsert));
if (this.delta == null) {
updateAttributes({
blockComponentDelta: delta.toJson(),
});
} else if (insertAfter) {
updateAttributes(
{
blockComponentDelta: this
.delta!
.compose(
Delta()
..retain(this.delta!.length)
..addAll(delta),
)
.toJson(),
},
);
} else {
updateAttributes(
{
blockComponentDelta: delta
.compose(
Delta()
..retain(delta.length)
..addAll(this.delta!),
)
.toJson(),
},
);
}
}
}

View File

@ -1,4 +1,3 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
extension PasteFromHtml on EditorState {

View File

@ -1,6 +1,5 @@
import 'dart:convert';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;

View File

@ -1,4 +1,3 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy_editor/appflowy_editor.dart';

View File

@ -84,7 +84,7 @@ class _DatabaseBlockComponentWidgetState
child: FocusScope(
skipTraversal: true,
onFocusChange: (value) {
if (value) {
if (value && keepEditorFocusNotifier.value == 0) {
context.read<EditorState>().selection = null;
}
},

View File

@ -208,7 +208,11 @@ class FileBlockComponentState extends State<FileBlockComponent>
child: Row(
children: [
const HSpace(10),
const Icon(Icons.upload_file_outlined),
FlowySvg(
FlowySvgs.slash_menu_icon_file_s,
color: Theme.of(context).hintColor,
size: const Size.square(24),
),
const HSpace(10),
..._buildTrailing(context),
],
@ -348,6 +352,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
? LocaleKeys.document_plugins_file_placeholderDragging.tr()
: LocaleKeys.document_plugins_file_placeholderText.tr(),
overflow: TextOverflow.ellipsis,
color: Theme.of(context).hintColor,
),
),
];

View File

@ -142,7 +142,7 @@ class _FileUploadLocalState extends State<_FileUploadLocal> {
borderType: BorderType.RRect,
color: isDragging
? Theme.of(context).colorScheme.primary
: Colors.black,
: Theme.of(context).hintColor,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -153,7 +153,7 @@ class _FileUploadLocalState extends State<_FileUploadLocal> {
LocaleKeys.document_plugins_file_dropFileToUpload
.tr(),
fontSize: 16,
color: Theme.of(context).colorScheme.primary,
color: Theme.of(context).hintColor,
),
const VSpace(13.5),
] else ...[
@ -162,8 +162,9 @@ class _FileUploadLocalState extends State<_FileUploadLocal> {
.tr(),
fontSize: 16,
maxLines: 2,
textAlign: TextAlign.center,
lineHeight: 1.5,
textAlign: TextAlign.center,
color: Theme.of(context).hintColor,
),
],
],
@ -208,6 +209,7 @@ class _FileUploadNetworkState extends State<_FileUploadNetwork> {
alignment: Alignment.center,
child: Column(
children: [
const VSpace(12),
FlowyTextField(
hintText: LocaleKeys.document_plugins_file_networkHint.tr(),
onChanged: (value) => inputText = value,
@ -220,19 +222,25 @@ class _FileUploadNetworkState extends State<_FileUploadNetwork> {
color: Theme.of(context).colorScheme.error,
),
],
const VSpace(8),
const VSpace(20),
SizedBox(
width: 160,
height: 32,
width: 300,
child: FlowyButton(
backgroundColor: Theme.of(context).colorScheme.primary,
hoverColor:
Theme.of(context).colorScheme.primary.withOpacity(0.9),
showDefaultBoxDecorationOnMobile: true,
margin: const EdgeInsets.all(8.0),
margin: const EdgeInsets.all(5),
text: FlowyText(
LocaleKeys.document_plugins_file_networkAction.tr(),
textAlign: TextAlign.center,
color: Theme.of(context).colorScheme.onPrimary,
),
onTap: submit,
),
),
const VSpace(8),
],
),
);

View File

@ -247,6 +247,7 @@ class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
size: Size(20, 20),
),
text: FlowyText(
lineHeight: 1.0,
LocaleKeys.document_plugins_cover_pickFromFiles.tr(),
),
),

View File

@ -71,9 +71,10 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
child: Row(
children: [
const HSpace(10),
const FlowySvg(
FlowySvgs.image_placeholder_s,
size: Size.square(24),
FlowySvg(
FlowySvgs.slash_menu_icon_image_s,
size: const Size.square(24),
color: Theme.of(context).hintColor,
),
const HSpace(10),
..._buildTrailing(context),
@ -101,7 +102,6 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
UploadImageType.local,
UploadImageType.url,
UploadImageType.unsplash,
UploadImageType.stabilityAI,
],
onSelectedLocalImages: (paths) {
controller.close();
@ -192,6 +192,7 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
? LocaleKeys.document_plugins_image_dropImageToInsert.tr()
: LocaleKeys.document_plugins_image_addAnImageDesktop.tr()
: LocaleKeys.document_plugins_image_addAnImageMobile.tr(),
color: Theme.of(context).hintColor,
),
),
];

View File

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
final customImageMenuItem = SelectionMenuItem(
getName: () => AppFlowyEditorL10n.current.image,
@ -28,8 +29,9 @@ final customImageMenuItem = SelectionMenuItem(
final multiImageMenuItem = SelectionMenuItem(
getName: () => LocaleKeys.document_plugins_photoGallery_name.tr(),
icon: (_, isSelected, style) => SelectionMenuIconWidget(
icon: Icons.photo_library_outlined,
icon: (_, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.image_s,
size: const Size.square(16.0),
isSelected: isSelected,
style: style,
),

View File

@ -1,8 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
@ -25,6 +22,8 @@ import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
@ -129,7 +128,7 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
UploadImageType.local,
UploadImageType.url,
UploadImageType.unsplash,
UploadImageType.stabilityAI,
],
onSelectedLocalImages: insertLocalImages,
onSelectedAIImage: insertAIImage,

View File

@ -1,7 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
@ -23,6 +22,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart';
import 'package:path/path.dart' as p;
@ -66,7 +66,11 @@ class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
child: Row(
children: [
const Icon(Icons.photo_library_outlined, size: 24),
FlowySvg(
FlowySvgs.slash_menu_icon_photo_gallery_s,
color: Theme.of(context).hintColor,
size: const Size.square(24),
),
const HSpace(10),
FlowyText(
PlatformExtension.isDesktop
@ -76,6 +80,7 @@ class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
: LocaleKeys.document_plugins_image_addAnImageDesktop
.tr()
: LocaleKeys.document_plugins_image_addAnImageMobile.tr(),
color: Theme.of(context).hintColor,
),
],
),
@ -101,7 +106,6 @@ class MultiImagePlaceholderState extends State<MultiImagePlaceholder> {
UploadImageType.local,
UploadImageType.url,
UploadImageType.unsplash,
UploadImageType.stabilityAI,
],
onSelectedLocalImages: (paths) {
controller.close();

Some files were not shown because too many files have changed in this diff Show More