mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
9213c233ff
11
CHANGELOG.md
11
CHANGELOG.md
@ -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.
|
||||
|
0
frontend/appflowy_flutter/build.yaml
Normal file
0
frontend/appflowy_flutter/build.yaml
Normal 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();
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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));
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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
|
||||
|
@ -29,6 +29,7 @@ class FontSetting extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyText(
|
||||
lineHeight: 1.0,
|
||||
name,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
|
@ -38,6 +38,7 @@ class _LanguageSettingGroupState extends State<LanguageSettingGroup> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyText(
|
||||
lineHeight: 1.0,
|
||||
languageFromLocale(locale),
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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}";
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
@ -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),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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 += '@';
|
||||
|
@ -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,
|
||||
),
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
|
@ -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;
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
),
|
||||
],
|
||||
|
@ -124,6 +124,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
editorScrollController: scrollController,
|
||||
blockComponentBuilders: blockBuilders,
|
||||
commandShortcutEvents: [customCopyCommand],
|
||||
disableAutoScroll: true,
|
||||
editorState: editorState,
|
||||
),
|
||||
);
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
@ -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,
|
||||
),
|
||||
],
|
||||
|
@ -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: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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: () {
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -23,6 +23,7 @@ class GridAddRowButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
text: FlowyText(
|
||||
lineHeight: 1.0,
|
||||
LocaleKeys.grid_row_newRow.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -34,6 +34,7 @@ class SortChoiceButton extends StatelessWidget {
|
||||
useIntrinsicWidth: true,
|
||||
text: FlowyText(
|
||||
text,
|
||||
lineHeight: 1.0,
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
};
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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: () {
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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),
|
||||
),
|
||||
|
@ -193,6 +193,7 @@ class _GridGroupCell extends StatelessWidget {
|
||||
text: FlowyText.medium(
|
||||
name,
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
lineHeight: 1.0,
|
||||
),
|
||||
leftIcon: icon != null
|
||||
? FlowySvg(
|
||||
|
@ -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);
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
);
|
@ -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) {
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
@ -84,7 +84,7 @@ class _DatabaseBlockComponentWidgetState
|
||||
child: FocusScope(
|
||||
skipTraversal: true,
|
||||
onFocusChange: (value) {
|
||||
if (value) {
|
||||
if (value && keepEditorFocusNotifier.value == 0) {
|
||||
context.read<EditorState>().selection = null;
|
||||
}
|
||||
},
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -247,6 +247,7 @@ class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
|
||||
size: Size(20, 20),
|
||||
),
|
||||
text: FlowyText(
|
||||
lineHeight: 1.0,
|
||||
LocaleKeys.document_plugins_cover_pickFromFiles.tr(),
|
||||
),
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user