Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1ad03eaa9 | ||
|
|
17c9c9b556 | ||
|
|
93f9a2cab1 | ||
|
|
4b710527c9 | ||
|
|
6e26dc128c | ||
|
|
d3d9ab2fb0 | ||
|
|
1db8480b75 | ||
|
|
7e53b34484 | ||
|
|
34465efc24 | ||
|
|
a29b170b13 | ||
|
|
14b60fb9b0 | ||
|
|
1c638dd930 | ||
|
|
55a4810d60 | ||
|
|
2bc7875bdd | ||
|
|
f7adcae8ff | ||
|
|
23997e977c | ||
|
|
3ff47b7e1e | ||
|
|
d33af70a5c | ||
|
|
510752868d | ||
|
|
42d1bb84c5 | ||
|
|
2debd0283c | ||
|
|
e87ade6b3f | ||
|
|
e2a923a796 | ||
|
|
cd0f8d80e9 | ||
|
|
7abe9f4661 | ||
|
|
758c304a74 | ||
|
|
f84473c857 | ||
|
|
a26b2a356c | ||
|
|
57bbb6cc41 | ||
|
|
e82edc0419 | ||
|
|
f57297e76d | ||
|
|
7769034467 | ||
|
|
a523b8ff90 | ||
|
|
7b7b907017 | ||
|
|
29b262a1c6 | ||
|
|
e28a251e72 | ||
|
|
98b7882d43 | ||
|
|
e279ad1cc7 | ||
|
|
a798b037db | ||
|
|
5fbaf664ba | ||
|
|
41a346c7ed | ||
|
|
aab942d163 | ||
|
|
5757cc9a1d | ||
|
|
453e6309d5 | ||
|
|
4041724980 | ||
|
|
d378c456d4 | ||
|
|
0abf916796 | ||
|
|
393850ae4b | ||
|
|
46bad4e7e8 | ||
|
|
e9fc003e10 | ||
|
|
cb60488bbe | ||
|
|
04556252e1 | ||
|
|
c2e8a12427 | ||
|
|
73421e0d58 | ||
|
|
b9fd3701cd | ||
|
|
9fbba5fb60 | ||
|
|
7261d1e8da | ||
|
|
27aac2b911 | ||
|
|
0cd5af5ffa | ||
|
|
87e950733f | ||
|
|
2402b4c6f1 | ||
|
|
ed81a0aff2 | ||
|
|
dce9231118 | ||
|
|
d1c1449cf6 | ||
|
|
7c3dd5375d | ||
|
|
81532d014e | ||
|
|
735a09b333 | ||
|
|
d52a04a9e3 | ||
|
|
a14ced458c | ||
|
|
5250a151c8 | ||
|
|
d5a5a64fcf | ||
|
|
175c90e379 |
No files matched your search
@@ -249,7 +249,7 @@ jobs:
|
||||
BACKEND_VERSION: 0.3.24-amd64
|
||||
run: |
|
||||
if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then
|
||||
docker compose pull
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
else
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
working-directory: AppFlowy-Cloud
|
||||
run: |
|
||||
if [ "$(docker ps --filter name=appflowy-cloud -q)" == "" ]; then
|
||||
docker compose pull
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
else
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
# 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.
|
||||
- Image galleries and drag-and-drop image support in documents.
|
||||
|
||||
### Bug Fixes
|
||||
- Fix minor UI issues on Desktop and Mobile.
|
||||
|
||||
## Version 0.6.5 - 24/07/2024
|
||||
### New Features
|
||||
- Publish a Database to the Web
|
||||
|
||||
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
|
||||
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
|
||||
CARGO_MAKE_CRATE_NAME = "dart-ffi"
|
||||
LIB_NAME = "dart_ffi"
|
||||
APPFLOWY_VERSION = "0.6.6"
|
||||
APPFLOWY_VERSION = "0.6.7"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "artificial_intelligence": [ { "name": "ai-chip-spark", "keywords": [ "chip", "processor", "artificial", "intelligence", "ai" ], "content": "<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<g clip-path=\"url(#clip0_1068_189544)\">\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.71468 0.0400391C9.1289 0.0400391 9.46468 0.375825 9.46468 0.790039V1.97754H10.4999C11.3283 1.97754 11.9999 2.64911 11.9999 3.47754V4.51367H13.2186C13.6328 4.51367 13.9686 4.84946 13.9686 5.26367C13.9686 5.67789 13.6328 6.01367 13.2186 6.01367H11.9999V7.94324H13.2186C13.6328 7.94324 13.9686 8.27902 13.9686 8.69324C13.9686 9.10745 13.6328 9.44324 13.2186 9.44324H11.9999V10.4775C11.9999 11.306 11.3283 11.9775 10.4999 11.9775H9.46468V13.2099C9.46468 13.6241 9.1289 13.9599 8.71468 13.9599C8.30047 13.9599 7.96468 13.6241 7.96468 13.2099V11.9775H6.03524V13.2099C6.03524 13.6241 5.69945 13.9599 5.28524 13.9599C4.87103 13.9599 4.53524 13.6241 4.53524 13.2099V11.9775H3.49984C2.67141 11.9775 1.99984 11.306 1.99984 10.4775V9.44324H0.783203C0.368989 9.44324 0.0332031 9.10745 0.0332031 8.69324C0.0332031 8.27902 0.368989 7.94324 0.783203 7.94324H1.99984V6.01367H0.783203C0.368989 6.01367 0.0332031 5.67789 0.0332031 5.26367C0.0332031 4.84946 0.368989 4.51367 0.783203 4.51367H1.99984V3.47754C1.99984 2.64911 2.67141 1.97754 3.49984 1.97754H4.53524V0.790039C4.53524 0.375825 4.87103 0.0400391 5.28524 0.0400391C5.69945 0.0400391 6.03524 0.375825 6.03524 0.790039V1.97754H7.96468V0.790039C7.96468 0.375825 8.30047 0.0400391 8.71468 0.0400391ZM6.24357 4.5682C6.41951 3.76446 7.56392 3.75946 7.74688 4.56163L7.75499 4.59732C7.76038 4.62113 7.76543 4.64342 7.7708 4.66628C7.96264 5.48335 8.62389 6.10768 9.45164 6.25169C10.2908 6.39768 10.2908 7.60232 9.45164 7.74831C8.61948 7.89308 7.95559 8.52332 7.76776 9.34682L7.74688 9.43837C7.56392 10.2405 6.41951 10.2355 6.24357 9.4318L6.22638 9.35325C6.04547 8.52684 5.38257 7.89201 4.54911 7.74701C3.71142 7.60128 3.71141 6.39872 4.54911 6.25299C5.37967 6.10849 6.04086 5.47757 6.22447 4.65539L6.23707 4.598L6.24357 4.5682Z\" fill=\"black\"/>\n</g>\n<defs>\n<clipPath id=\"clip0_1068_189544\">\n<rect width=\"14\" height=\"14\" fill=\"white\"/>\n</clipPath>\n</defs>\n</svg>\n" }, { "name": "ai-cloud-spark", "keywords": [ "cloud", "internet", "server", "network", "artificial", "intelligence", "ai" ], "content": "<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <g clip-path=\"url(#clip0_1068_189529)\">\n <path fill-rule=\"evenodd\" clip-rule=\"evenodd\"\n d=\"M7.88381 0.165291C6.89196 0.0450664 5.88886 0.268505 5.04181 0.798345C4.25349 1.29145 3.64467 2.02219 3.30135 2.88247C2.87262 2.92977 2.45508 3.05187 2.06781 3.24362C1.63324 3.45878 1.24531 3.75744 0.92616 4.12253C0.281611 4.85986 -0.0436335 5.82304 0.0219743 6.80017C0.087582 7.77731 0.538668 8.68836 1.276 9.33291C1.52925 9.55429 1.80913 9.738 2.10708 9.88114C2.23853 9.09496 2.79956 8.38069 3.79016 8.20836C4.35848 8.10949 4.81104 7.67811 4.93736 7.11585L4.96023 7.01146C5.43591 4.83845 8.53 4.82492 9.02466 6.9937L9.03475 7.03814C9.04166 7.06865 9.047 7.09221 9.05244 7.11538C9.18449 7.6778 9.63973 8.10763 10.2096 8.20676C11.2609 8.38966 11.8287 9.18251 11.9131 10.0243C12.2792 9.884 12.6197 9.67707 12.9162 9.41068C13.5042 8.88231 13.878 8.15668 13.9669 7.37119C14.0558 6.58569 13.8536 5.79486 13.3985 5.14845C12.9811 4.55545 12.3775 4.12175 11.6866 3.91417C11.5613 2.99088 11.1436 2.12994 10.4923 1.45901C9.79633 0.742138 8.87567 0.285516 7.88381 0.165291ZM6.18132 7.27876C6.37146 6.41016 7.60823 6.40476 7.80595 7.27166L7.81577 7.3149C7.8225 7.34462 7.82879 7.37239 7.83553 7.4011C8.08204 8.451 8.93171 9.25323 9.99531 9.43826C10.9004 9.59573 10.9004 10.8951 9.99531 11.0525C8.92604 11.2386 8.07299 12.0484 7.83164 13.1065L7.80595 13.2191C7.60823 14.086 6.37146 14.0806 6.18132 13.212L6.16017 13.1154C5.92765 12.0532 5.07564 11.2373 4.0044 11.0509C3.10108 10.8938 3.10107 9.59701 4.0044 9.43986C5.07191 9.25414 5.92172 8.44323 6.15771 7.38648L6.17343 7.31494L6.18132 7.27876Z\"\n fill=\"black\"/>\n </g>\n <defs>\n <clipPath id=\"clip0_1068_189529\">\n <rect width=\"14\" height=\"14\" fill=\"white\"/>\n </clipPath>\n </defs>\n</svg>\n" }, { "name": "ai-edit-spark", "keywords": [ "change", "edit", "modify", "pencil", "write", "writing", "artificial", "intelligence", "ai" ], "content": "<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<g clip-path=\"url(#clip0_1068_189535)\">\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M2.85637 0.654372C3.04651 -0.214226 4.28328 -0.219632 4.481 0.647271L4.49082 0.690505C4.49756 0.720231 4.50384 0.747995 4.51058 0.776709C4.75709 1.82661 5.60676 2.62884 6.67036 2.81387C7.57548 2.97134 7.57548 4.27068 6.67036 4.42815C5.60109 4.61417 4.74804 5.42398 4.50669 6.48214L4.481 6.59475C4.28328 7.46166 3.04651Line truncated
|
||||
+6
-3
@@ -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();
|
||||
}
|
||||
+39
-6
@@ -1,11 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@@ -166,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();
|
||||
@@ -246,10 +283,6 @@ void main() {
|
||||
expect(editorState.document.root.children.length, 2);
|
||||
final node = editorState.getNodeAtPath([0])!;
|
||||
expect(node.type, ImageBlockKeys.type);
|
||||
expect(
|
||||
node.attributes[ImageBlockKeys.url],
|
||||
'https://user-images.githubusercontent.com/9403740/262918875-603f4adb-58dd-49b5-8201-341d354935fd.png',
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'document_more_actions_test.dart' as document_more_actions_test;
|
||||
import 'document_text_direction_test.dart' as document_text_direction_test;
|
||||
import 'document_with_cover_image_test.dart' as document_with_cover_image_test;
|
||||
import 'document_with_database_test.dart' as document_with_database_test;
|
||||
import 'document_with_file_test.dart' as document_with_file_test;
|
||||
import 'document_with_image_block_test.dart' as document_with_image_block_test;
|
||||
import 'document_with_inline_math_equation_test.dart'
|
||||
as document_with_inline_math_equation_test;
|
||||
@@ -43,4 +44,5 @@ void startTesting() {
|
||||
document_with_multi_image_block_test.main();
|
||||
document_inline_page_reference_test.main();
|
||||
document_more_actions_test.main();
|
||||
document_with_file_test.main();
|
||||
}
|
||||
+6
-16
@@ -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();
|
||||
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/core/config/kv_keys.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
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;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../shared/mock/mock_file_picker.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('file block in document', () {
|
||||
testWidgets('insert a file from local file + rename file', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithNameUnderParent(name: 'Insert file test');
|
||||
|
||||
// tap the first line of the document
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_file.tr(),
|
||||
);
|
||||
expect(find.byType(FileBlockComponent), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(FileBlockComponent));
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
expect(find.byType(FileUploadMenu), findsOneWidget);
|
||||
|
||||
final image = await rootBundle.load('assets/test/images/sample.jpeg');
|
||||
final tempDirectory = await getTemporaryDirectory();
|
||||
final filePath = p.join(tempDirectory.path, 'sample.jpeg');
|
||||
final file = File(filePath)..writeAsBytesSync(image.buffer.asUint8List());
|
||||
|
||||
mockPickFilePaths(paths: [filePath]);
|
||||
|
||||
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
|
||||
await tester.tap(
|
||||
find.text(LocaleKeys.document_plugins_file_fileUploadHint.tr()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FileUploadMenu), findsNothing);
|
||||
expect(find.byType(FileBlockComponent), findsOneWidget);
|
||||
|
||||
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
expect(node.type, FileBlockKeys.type);
|
||||
expect(node.attributes[FileBlockKeys.url], isNotEmpty);
|
||||
expect(
|
||||
node.attributes[FileBlockKeys.urlType],
|
||||
FileUrlType.local.toIntValue(),
|
||||
);
|
||||
|
||||
// Check the name of the file is correctly extracted
|
||||
expect(node.attributes[FileBlockKeys.name], 'sample.jpeg');
|
||||
expect(find.text('sample.jpeg'), findsOneWidget);
|
||||
|
||||
const newName = "Renamed file";
|
||||
|
||||
// Hover on the widget to see the three dots to open FileBlockMenu
|
||||
await tester.hoverOnWidget(
|
||||
find.byType(FileBlockComponent),
|
||||
onHover: () async {
|
||||
await tester.tap(find.byType(FileMenuTrigger));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.text(LocaleKeys.document_plugins_file_renameFile_title.tr()),
|
||||
);
|
||||
},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FlowyTextField), findsOneWidget);
|
||||
await tester.enterText(find.byType(FlowyTextField), newName);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text(LocaleKeys.button_save.tr()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final updatedNode =
|
||||
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
expect(updatedNode.attributes[FileBlockKeys.name], newName);
|
||||
expect(find.text(newName), findsOneWidget);
|
||||
|
||||
// remove the temp file
|
||||
file.deleteSync();
|
||||
});
|
||||
|
||||
testWidgets('insert a file from network', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a new document
|
||||
await tester.createNewPageWithNameUnderParent(name: 'Insert file test');
|
||||
|
||||
// tap the first line of the document
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_file.tr(),
|
||||
);
|
||||
expect(find.byType(FileBlockComponent), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(FileBlockComponent));
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
expect(find.byType(FileUploadMenu), findsOneWidget);
|
||||
|
||||
// Navigate to integrate link tab
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_plugins_file_networkTab.tr(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
const url =
|
||||
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640';
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(FileUploadMenu),
|
||||
matching: find.byType(FlowyTextField),
|
||||
),
|
||||
url,
|
||||
);
|
||||
await tester.tapButton(
|
||||
find.descendant(
|
||||
of: find.byType(FileUploadMenu),
|
||||
matching: find.text(
|
||||
LocaleKeys.document_plugins_file_networkAction.tr(),
|
||||
findRichText: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FileUploadMenu), findsNothing);
|
||||
expect(find.byType(FileBlockComponent), findsOneWidget);
|
||||
|
||||
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
expect(node.type, FileBlockKeys.type);
|
||||
expect(node.attributes[FileBlockKeys.url], isNotEmpty);
|
||||
expect(
|
||||
node.attributes[FileBlockKeys.urlType],
|
||||
FileUrlType.network.toIntValue(),
|
||||
);
|
||||
|
||||
// Check the name is correctly extracted from the url
|
||||
expect(
|
||||
node.attributes[FileBlockKeys.name],
|
||||
'photo-1469474968028-56623f02e42e',
|
||||
);
|
||||
expect(find.text('photo-1469474968028-56623f02e42e'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
+14
-7
@@ -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(
|
||||
|
||||
+7
-7
@@ -33,7 +33,7 @@ void main() {
|
||||
);
|
||||
|
||||
// tap the inline math equation button
|
||||
final inlineMathEquationButton = find.byTooltip(
|
||||
final inlineMathEquationButton = find.findFlowyTooltip(
|
||||
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
);
|
||||
await tester.tapButton(inlineMathEquationButton);
|
||||
@@ -78,7 +78,7 @@ void main() {
|
||||
);
|
||||
|
||||
// tap the inline math equation button
|
||||
var inlineMathEquationButton = find.byTooltip(
|
||||
var inlineMathEquationButton = find.findFlowyTooltip(
|
||||
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
);
|
||||
await tester.tapButton(inlineMathEquationButton);
|
||||
@@ -93,11 +93,11 @@ void main() {
|
||||
);
|
||||
|
||||
// expect to the see the inline math equation button is highlighted
|
||||
inlineMathEquationButton = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is SVGIconItemWidget &&
|
||||
widget.tooltip ==
|
||||
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
inlineMathEquationButton = find.descendant(
|
||||
of: find.findFlowyTooltip(
|
||||
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
|
||||
),
|
||||
matching: find.byType(SVGIconItemWidget),
|
||||
);
|
||||
expect(
|
||||
tester.widget<SVGIconItemWidget>(inlineMathEquationButton).isHighlight,
|
||||
|
||||
+13
-7
@@ -1,16 +1,13 @@
|
||||
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';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_layouts.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
@@ -19,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;
|
||||
@@ -48,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);
|
||||
|
||||
@@ -143,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);
|
||||
|
||||
|
||||
+2
-1
@@ -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();
|
||||
}
|
||||
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.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 '../../shared/mock/mock_openai_repository.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
const service = TestWorkspaceService(TestWorkspace.aiWorkSpace);
|
||||
|
||||
group('integration tests for open-ai smart menu', () {
|
||||
setUpAll(() async => service.setUpAll());
|
||||
setUp(() async => service.setUp());
|
||||
|
||||
testWidgets('testing selection on open-ai smart menu replace',
|
||||
(tester) async {
|
||||
final appFlowyEditor = await setUpOpenAITesting(tester);
|
||||
final editorState = appFlowyEditor.editorState;
|
||||
|
||||
editorState.service.selectionService.updateSelection(
|
||||
Selection(
|
||||
start: Position(path: [1], offset: 4),
|
||||
end: Position(path: [1], offset: 10),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ToolbarWidget), findsAtLeastNWidgets(1));
|
||||
|
||||
await tester.tap(find.byTooltip('AI Assistants'));
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 500));
|
||||
|
||||
await tester.tap(find.text('Summarize'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester
|
||||
.tap(find.byType(FlowyRichTextButton, skipOffstage: false).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
editorState.service.selectionService.currentSelection.value,
|
||||
Selection(
|
||||
start: Position(path: [1], offset: 4),
|
||||
end: Position(path: [1], offset: 84),
|
||||
),
|
||||
);
|
||||
});
|
||||
testWidgets('testing selection on open-ai smart menu insert',
|
||||
(tester) async {
|
||||
final appFlowyEditor = await setUpOpenAITesting(tester);
|
||||
final editorState = appFlowyEditor.editorState;
|
||||
|
||||
editorState.service.selectionService.updateSelection(
|
||||
Selection(
|
||||
start: Position(path: [1]),
|
||||
end: Position(path: [1], offset: 5),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 500));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ToolbarWidget), findsAtLeastNWidgets(1));
|
||||
|
||||
await tester.tap(find.byTooltip('AI Assistants'));
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 500));
|
||||
|
||||
await tester.tap(find.text('Summarize'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester
|
||||
.tap(find.byType(FlowyRichTextButton, skipOffstage: false).at(1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
editorState.service.selectionService.currentSelection.value,
|
||||
Selection(
|
||||
start: Position(path: [2]),
|
||||
end: Position(path: [3]),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<AppFlowyEditor> setUpOpenAITesting(WidgetTester tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await mockOpenAIRepository();
|
||||
|
||||
await simulateKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await simulateKeyDownEvent(LogicalKeyboardKey.backslash);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final Finder editor = find.byType(AppFlowyEditor);
|
||||
await tester.tap(editor);
|
||||
await tester.pumpAndSettle();
|
||||
return tester.state(editor).widget as AppFlowyEditor;
|
||||
}
|
||||
|
||||
Future<void> mockOpenAIRepository() async {
|
||||
await getIt.unregister<AIRepository>();
|
||||
getIt.registerFactoryAsync<AIRepository>(
|
||||
() => Future.value(
|
||||
MockOpenAIRepository(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/env/cloud_env_test.dart';
|
||||
import 'package:appflowy/startup/entry_point.dart';
|
||||
@@ -16,6 +13,8 @@ import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widget
|
||||
import 'package:appflowy/workspace/application/settings/prelude.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -231,6 +230,16 @@ extension AppFlowyFinderTestBase on CommonFinders {
|
||||
(widget) => widget is FlowyText && widget.text == text,
|
||||
);
|
||||
}
|
||||
|
||||
Finder findFlowyTooltip(String richMessage, {bool skipOffstage = true}) {
|
||||
return byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is FlowyTooltip &&
|
||||
widget.richMessage != null &&
|
||||
widget.richMessage!.toPlainText().contains(richMessage),
|
||||
skipOffstage: skipOffstage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> useTestSupabaseCloud() async {
|
||||
|
||||
@@ -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,8 +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/plugins/base/emoji/emoji_skin_tone.dart';
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker.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';
|
||||
@@ -12,6 +10,8 @@ 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';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -84,7 +84,7 @@ class EditorOperations {
|
||||
final Finder button = !isInPicker
|
||||
? find.text(LocaleKeys.document_plugins_cover_removeIcon.tr())
|
||||
: find.descendant(
|
||||
of: find.byType(FlowyIconPicker),
|
||||
of: find.byType(FlowyIconEmojiPicker),
|
||||
matching: find.text(LocaleKeys.button_remove.tr()),
|
||||
);
|
||||
await tester.tapButton(button);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
|
||||
@@ -58,7 +58,7 @@ extension AppFlowyWorkspace on WidgetTester {
|
||||
);
|
||||
expect(iconButton, findsOneWidget);
|
||||
await tapButton(iconButton);
|
||||
final iconPicker = find.byType(FlowyIconPicker);
|
||||
final iconPicker = find.byType(FlowyIconEmojiPicker);
|
||||
expect(iconPicker, findsOneWidget);
|
||||
await tapButton(find.findTextInFlowyText(icon));
|
||||
}
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_service.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
|
||||
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
|
||||
import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:time/time.dart';
|
||||
|
||||
part 'notification_reminder_bloc.freezed.dart';
|
||||
|
||||
class NotificationReminderBloc
|
||||
extends Bloc<NotificationReminderEvent, NotificationReminderState> {
|
||||
NotificationReminderBloc() : super(NotificationReminderState.initial()) {
|
||||
on<NotificationReminderEvent>((event, emit) async {
|
||||
await event.when(
|
||||
initial: (reminder, dateFormat, timeFormat) async {
|
||||
this.reminder = reminder;
|
||||
this.dateFormat = dateFormat;
|
||||
this.timeFormat = timeFormat;
|
||||
|
||||
add(const NotificationReminderEvent.reset());
|
||||
},
|
||||
reset: () async {
|
||||
final createdAt = await _getCreatedAt(
|
||||
reminder,
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
);
|
||||
final view = await _getView(reminder);
|
||||
|
||||
if (view == null) {
|
||||
emit(
|
||||
NotificationReminderState(
|
||||
createdAt: createdAt,
|
||||
pageTitle: '',
|
||||
reminderContent: '',
|
||||
status: NotificationReminderStatus.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final layout = view!.layout;
|
||||
|
||||
if (layout.isDocumentView) {
|
||||
final node = await _getContent(reminder);
|
||||
if (node != null) {
|
||||
emit(
|
||||
NotificationReminderState(
|
||||
createdAt: createdAt,
|
||||
pageTitle: view.name,
|
||||
view: view,
|
||||
reminderContent: node.delta?.toPlainText() ?? '',
|
||||
nodes: [node],
|
||||
status: NotificationReminderStatus.loaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (layout.isDatabaseView) {
|
||||
emit(
|
||||
NotificationReminderState(
|
||||
createdAt: createdAt,
|
||||
pageTitle: view.name,
|
||||
view: view,
|
||||
reminderContent: reminder.message,
|
||||
status: NotificationReminderStatus.loaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
late final ReminderPB reminder;
|
||||
late final UserDateFormatPB dateFormat;
|
||||
late final UserTimeFormatPB timeFormat;
|
||||
|
||||
Future<String> _getCreatedAt(
|
||||
ReminderPB reminder,
|
||||
UserDateFormatPB dateFormat,
|
||||
UserTimeFormatPB timeFormat,
|
||||
) async {
|
||||
final rCreatedAt = reminder.createdAt;
|
||||
final createdAt = rCreatedAt != null
|
||||
? _formatTimestamp(
|
||||
rCreatedAt,
|
||||
timeFormat: timeFormat,
|
||||
dateFormate: dateFormat,
|
||||
)
|
||||
: '';
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
Future<ViewPB?> _getView(ReminderPB reminder) async {
|
||||
return ViewBackendService.getView(reminder.objectId)
|
||||
.fold((s) => s, (_) => null);
|
||||
}
|
||||
|
||||
Future<Node?> _getContent(ReminderPB reminder) async {
|
||||
final blockId = reminder.meta[ReminderMetaKeys.blockId];
|
||||
|
||||
if (blockId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final document = await DocumentService()
|
||||
.openDocument(
|
||||
documentId: reminder.objectId,
|
||||
)
|
||||
.fold((s) => s.toDocument(), (_) => null);
|
||||
|
||||
if (document == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final node = _searchById(document.root, blockId);
|
||||
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
Node? _searchById(Node current, String id) {
|
||||
if (current.id == id) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (current.children.isNotEmpty) {
|
||||
for (final child in current.children) {
|
||||
final node = _searchById(child, id);
|
||||
|
||||
if (node != null) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String _formatTimestamp(
|
||||
int timestamp, {
|
||||
required UserDateFormatPB dateFormate,
|
||||
required UserTimeFormatPB timeFormat,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final difference = now.difference(dateTime);
|
||||
final String date;
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
date = LocaleKeys.sideBar_justNow.tr();
|
||||
} else if (difference.inHours < 1 && dateTime.isToday) {
|
||||
// Less than 1 hour
|
||||
date = LocaleKeys.sideBar_minutesAgo
|
||||
.tr(namedArgs: {'count': difference.inMinutes.toString()});
|
||||
} else if (difference.inHours >= 1 && dateTime.isToday) {
|
||||
// in same day
|
||||
date = timeFormat.formatTime(dateTime);
|
||||
} else {
|
||||
date = dateFormate.formatDate(dateTime, false);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class NotificationReminderEvent with _$NotificationReminderEvent {
|
||||
const factory NotificationReminderEvent.initial(
|
||||
ReminderPB reminder,
|
||||
UserDateFormatPB dateFormat,
|
||||
UserTimeFormatPB timeFormat,
|
||||
) = _Initial;
|
||||
|
||||
const factory NotificationReminderEvent.reset() = _Reset;
|
||||
}
|
||||
|
||||
enum NotificationReminderStatus {
|
||||
initial,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
}
|
||||
|
||||
@freezed
|
||||
class NotificationReminderState with _$NotificationReminderState {
|
||||
const NotificationReminderState._();
|
||||
|
||||
const factory NotificationReminderState({
|
||||
required String createdAt,
|
||||
required String pageTitle,
|
||||
required String reminderContent,
|
||||
@Default(NotificationReminderStatus.initial)
|
||||
NotificationReminderStatus status,
|
||||
@Default([]) List<Node> nodes,
|
||||
ViewPB? view,
|
||||
}) = _NotificationReminderState;
|
||||
|
||||
factory NotificationReminderState.initial() =>
|
||||
const NotificationReminderState(
|
||||
createdAt: '',
|
||||
pageTitle: '',
|
||||
reminderContent: '',
|
||||
);
|
||||
}
|
||||
+11
-1
@@ -149,7 +149,7 @@ class DocumentPageStyleBloc
|
||||
) {
|
||||
double padding = switch (fontLayout) {
|
||||
PageStyleFontLayout.small => 1.0,
|
||||
PageStyleFontLayout.normal => 2.0,
|
||||
PageStyleFontLayout.normal => 1.0,
|
||||
PageStyleFontLayout.large => 4.0,
|
||||
};
|
||||
switch (lineHeightLayout) {
|
||||
@@ -165,6 +165,16 @@ class DocumentPageStyleBloc
|
||||
return max(0, padding);
|
||||
}
|
||||
|
||||
double calculateIconScale(
|
||||
PageStyleFontLayout fontLayout,
|
||||
) {
|
||||
return switch (fontLayout) {
|
||||
PageStyleFontLayout.small => 0.8,
|
||||
PageStyleFontLayout.normal => 1.0,
|
||||
PageStyleFontLayout.large => 1.2,
|
||||
};
|
||||
}
|
||||
|
||||
PageStyleFontLayout _getSelectedFontLayout(Map layoutObject) {
|
||||
final fontLayout = layoutObject[ViewExtKeys.fontLayoutKey] ??
|
||||
PageStyleFontLayout.normal.toString();
|
||||
|
||||
@@ -9,7 +9,7 @@ class AnimatedGestureDetector extends StatefulWidget {
|
||||
this.duration = const Duration(milliseconds: 100),
|
||||
this.alignment = Alignment.center,
|
||||
this.behavior = HitTestBehavior.opaque,
|
||||
required this.onTapUp,
|
||||
this.onTapUp,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ class AnimatedGestureDetector extends StatefulWidget {
|
||||
final Alignment alignment;
|
||||
final bool feedback;
|
||||
final HitTestBehavior behavior;
|
||||
final VoidCallback onTapUp;
|
||||
final VoidCallback? onTapUp;
|
||||
|
||||
@override
|
||||
State<AnimatedGestureDetector> createState() =>
|
||||
@@ -36,9 +36,9 @@ class _AnimatedGestureDetectorState extends State<AnimatedGestureDetector> {
|
||||
onTapUp: (details) {
|
||||
setState(() => scale = 1.0);
|
||||
|
||||
HapticFeedbackType.vibrate.call();
|
||||
HapticFeedbackType.light.call();
|
||||
|
||||
widget.onTapUp();
|
||||
widget.onTapUp?.call();
|
||||
},
|
||||
onTapDown: (details) {
|
||||
setState(() => scale = widget.scaleFactor);
|
||||
|
||||
@@ -46,10 +46,18 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
// control the app bar opacity when in immersive mode
|
||||
final ValueNotifier<double> _appBarOpacity = ValueNotifier(1.0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
getIt<ReminderBloc>().add(const ReminderEvent.started());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appBarOpacity.dispose();
|
||||
_scrollNotificationObserver = null;
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -78,8 +86,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
ViewBloc(view: view)..add(const ViewEvent.initial()),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: getIt<ReminderBloc>()
|
||||
..add(const ReminderEvent.started()),
|
||||
value: getIt<ReminderBloc>(),
|
||||
),
|
||||
if (view.layout.isDocumentView)
|
||||
BlocProvider(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
+2
-1
@@ -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,
|
||||
),
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/home.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/space/mobile_space.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
@@ -33,78 +31,56 @@ class MobileFolders extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) => SidebarSectionsBloc()
|
||||
..add(SidebarSectionsEvent.initial(user, workspaceId)),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => SpaceBloc()
|
||||
..add(SpaceEvent.initial(user, workspaceId, openFirstPage: false)),
|
||||
),
|
||||
],
|
||||
child: BlocListener<UserWorkspaceBloc, UserWorkspaceState>(
|
||||
listener: (context, state) {
|
||||
context.read<SidebarSectionsBloc>().add(
|
||||
SidebarSectionsEvent.initial(
|
||||
user,
|
||||
state.currentWorkspace?.workspaceId ?? workspaceId,
|
||||
final workspaceId =
|
||||
context.read<UserWorkspaceBloc>().state.currentWorkspace?.workspaceId ??
|
||||
'';
|
||||
return BlocListener<UserWorkspaceBloc, UserWorkspaceState>(
|
||||
listener: (context, state) {
|
||||
context.read<SidebarSectionsBloc>().add(
|
||||
SidebarSectionsEvent.initial(
|
||||
user,
|
||||
state.currentWorkspace?.workspaceId ?? workspaceId,
|
||||
),
|
||||
);
|
||||
context.read<SpaceBloc>().add(
|
||||
SpaceEvent.reset(
|
||||
user,
|
||||
state.currentWorkspace?.workspaceId ?? workspaceId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const _MobileFolder(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileFolder extends StatefulWidget {
|
||||
const _MobileFolder();
|
||||
|
||||
@override
|
||||
State<_MobileFolder> createState() => _MobileFolderState();
|
||||
}
|
||||
|
||||
class _MobileFolderState extends State<_MobileFolder> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SidebarSectionsBloc, SidebarSectionsState>(
|
||||
builder: (context, state) {
|
||||
return SlidableAutoCloseBehavior(
|
||||
child: Column(
|
||||
children: [
|
||||
..._buildSpaceOrSection(context, state),
|
||||
const VSpace(4.0),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
|
||||
),
|
||||
);
|
||||
context.read<SpaceBloc>().add(
|
||||
SpaceEvent.reset(
|
||||
user,
|
||||
state.currentWorkspace?.workspaceId ?? workspaceId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<SpaceBloc, SpaceState>(
|
||||
listenWhen: (p, c) =>
|
||||
p.lastCreatedPage?.id != c.lastCreatedPage?.id,
|
||||
listener: (context, state) {
|
||||
final lastCreatedPage = state.lastCreatedPage;
|
||||
if (lastCreatedPage != null) {
|
||||
context.pushView(lastCreatedPage);
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<SidebarSectionsBloc, SidebarSectionsState>(
|
||||
listenWhen: (p, c) =>
|
||||
p.lastCreatedRootView?.id != c.lastCreatedRootView?.id,
|
||||
listener: (context, state) {
|
||||
final lastCreatedPage = state.lastCreatedRootView;
|
||||
if (lastCreatedPage != null) {
|
||||
context.pushView(lastCreatedPage);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<SidebarSectionsBloc, SidebarSectionsState>(
|
||||
builder: (context, state) {
|
||||
return SlidableAutoCloseBehavior(
|
||||
child: Column(
|
||||
children: [
|
||||
..._buildSpaceOrSection(context, state),
|
||||
const VSpace(4.0),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
|
||||
),
|
||||
child: _TrashButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _TrashButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart';
|
||||
@@ -9,7 +7,9 @@ import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/recent/cached_recent_service.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
@@ -18,6 +18,8 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -74,6 +76,9 @@ class MobileHomeScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final PropertyValueNotifier<UserWorkspacePB?> mCurrentWorkspace =
|
||||
PropertyValueNotifier<UserWorkspacePB?>(null);
|
||||
|
||||
class MobileHomePage extends StatefulWidget {
|
||||
const MobileHomePage({
|
||||
super.key,
|
||||
@@ -94,11 +99,13 @@ class _MobileHomePageState extends State<MobileHomePage> {
|
||||
super.initState();
|
||||
|
||||
getIt<MenuSharedState>().addLatestViewListener(_onLatestViewChange);
|
||||
getIt<ReminderBloc>().add(const ReminderEvent.started());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
getIt<MenuSharedState>().removeLatestViewListener(_onLatestViewChange);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -122,12 +129,17 @@ class _MobileHomePageState extends State<MobileHomePage> {
|
||||
buildWhen: (previous, current) =>
|
||||
previous.currentWorkspace?.workspaceId !=
|
||||
current.currentWorkspace?.workspaceId,
|
||||
listener: (context, state) => getIt<CachedRecentService>().reset(),
|
||||
listener: (context, state) {
|
||||
getIt<CachedRecentService>().reset();
|
||||
mCurrentWorkspace.value = state.currentWorkspace;
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.currentWorkspace == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final workspaceId = state.currentWorkspace!.workspaceId;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header
|
||||
@@ -143,9 +155,36 @@ class _MobileHomePageState extends State<MobileHomePage> {
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
SpaceOrderBloc()..add(const SpaceOrderEvent.initial()),
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) => SpaceOrderBloc()
|
||||
..add(const SpaceOrderEvent.initial()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => SidebarSectionsBloc()
|
||||
..add(
|
||||
SidebarSectionsEvent.initial(
|
||||
widget.userProfile,
|
||||
workspaceId,
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) =>
|
||||
FavoriteBloc()..add(const FavoriteEvent.initial()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => SpaceBloc()
|
||||
..add(
|
||||
SpaceEvent.initial(
|
||||
widget.userProfile,
|
||||
workspaceId,
|
||||
openFirstPage: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MobileSpaceTab(
|
||||
userProfile: widget.userProfile,
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/mobile_home_setting_page.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/built_in_svgs.dart';
|
||||
import 'package:appflowy/workspace/application/user/settings_user_bloc.dart';
|
||||
@@ -51,9 +51,10 @@ class MobileHomePageHeader extends StatelessWidget {
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: FlowySvg(FlowySvgs.m_setting_m),
|
||||
child: FlowySvg(FlowySvgs.m_notification_settings_s),
|
||||
),
|
||||
),
|
||||
const HSpace(8.0),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -132,6 +133,7 @@ class _MobileWorkspace extends StatelessWidget {
|
||||
fontSize: 16.0,
|
||||
enableEdit: false,
|
||||
alignment: Alignment.centerLeft,
|
||||
figmaLineHeight: 16.0,
|
||||
onSelected: (result) => context.read<UserWorkspaceBloc>().add(
|
||||
UserWorkspaceEvent.updateWorkspaceIcon(
|
||||
currentWorkspace.workspaceId,
|
||||
|
||||
+67
-39
@@ -1,6 +1,6 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
|
||||
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
|
||||
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
|
||||
@@ -43,48 +43,17 @@ class MobileSectionFolder extends StatelessWidget {
|
||||
onPressed: () => context
|
||||
.read<FolderBloc>()
|
||||
.add(const FolderEvent.expandOrUnExpand()),
|
||||
onAdded: () {
|
||||
context.read<SidebarSectionsBloc>().add(
|
||||
SidebarSectionsEvent.createRootViewInSection(
|
||||
name: LocaleKeys.menuAppHeader_defaultNewPageName
|
||||
.tr(),
|
||||
index: 0,
|
||||
viewSection: spaceType.toViewSectionPB,
|
||||
),
|
||||
);
|
||||
context.read<FolderBloc>().add(
|
||||
const FolderEvent.expandOrUnExpand(isExpanded: true),
|
||||
);
|
||||
},
|
||||
onAdded: () => _createNewPage(context),
|
||||
),
|
||||
),
|
||||
if (state.isExpanded)
|
||||
...views.map(
|
||||
(view) => MobileViewItem(
|
||||
key: ValueKey(
|
||||
'${FolderSpaceType.private.name} ${view.id}',
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: HomeSpaceViewSizes.leftPadding,
|
||||
),
|
||||
child: _Pages(
|
||||
views: views,
|
||||
spaceType: spaceType,
|
||||
isFirstChild: view.id == views.first.id,
|
||||
view: view,
|
||||
level: 0,
|
||||
leftPadding: HomeSpaceViewSizes.leftPadding,
|
||||
isFeedback: false,
|
||||
onSelected: context.pushView,
|
||||
endActionPane: (context) {
|
||||
final view = context.read<ViewBloc>().state.view;
|
||||
return buildEndActionPane(
|
||||
context,
|
||||
[
|
||||
MobilePaneActionType.more,
|
||||
if (view.layout == ViewLayoutPB.Document)
|
||||
MobilePaneActionType.add,
|
||||
],
|
||||
spaceType: spaceType,
|
||||
needSpace: false,
|
||||
spaceRatio: 5,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -93,4 +62,63 @@ class MobileSectionFolder extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _createNewPage(BuildContext context) {
|
||||
context.read<SidebarSectionsBloc>().add(
|
||||
SidebarSectionsEvent.createRootViewInSection(
|
||||
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
index: 0,
|
||||
viewSection: spaceType.toViewSectionPB,
|
||||
),
|
||||
);
|
||||
context.read<FolderBloc>().add(
|
||||
const FolderEvent.expandOrUnExpand(isExpanded: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pages extends StatelessWidget {
|
||||
const _Pages({
|
||||
required this.views,
|
||||
required this.spaceType,
|
||||
});
|
||||
|
||||
final List<ViewPB> views;
|
||||
final FolderSpaceType spaceType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: views
|
||||
.map(
|
||||
(view) => MobileViewItem(
|
||||
key: ValueKey(
|
||||
'${FolderSpaceType.private.name} ${view.id}',
|
||||
),
|
||||
spaceType: spaceType,
|
||||
isFirstChild: view.id == views.first.id,
|
||||
view: view,
|
||||
level: 0,
|
||||
leftPadding: HomeSpaceViewSizes.leftPadding,
|
||||
isFeedback: false,
|
||||
onSelected: context.pushView,
|
||||
endActionPane: (context) {
|
||||
final view = context.read<ViewBloc>().state.view;
|
||||
return buildEndActionPane(
|
||||
context,
|
||||
[
|
||||
MobilePaneActionType.more,
|
||||
if (view.layout == ViewLayoutPB.Document)
|
||||
MobilePaneActionType.add,
|
||||
],
|
||||
spaceType: spaceType,
|
||||
needSpace: false,
|
||||
spaceRatio: 5,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
-8
@@ -32,6 +32,7 @@ class _MobileSectionFolderHeaderState extends State<MobileSectionFolderHeader> {
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
const HSpace(HomeSpaceViewSizes.mHorizontalPadding),
|
||||
Expanded(
|
||||
child: FlowyButton(
|
||||
text: FlowyText.medium(
|
||||
@@ -57,15 +58,19 @@ class _MobileSectionFolderHeaderState extends State<MobileSectionFolderHeader> {
|
||||
},
|
||||
),
|
||||
),
|
||||
FlowyIconButton(
|
||||
key: mobileCreateNewPageButtonKey,
|
||||
hoverColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
height: HomeSpaceViewSizes.mViewButtonDimension,
|
||||
width: HomeSpaceViewSizes.mViewButtonDimension,
|
||||
icon: const FlowySvg(
|
||||
FlowySvgs.m_space_add_s,
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: widget.onAdded,
|
||||
child: Container(
|
||||
// expand the touch area
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.m_space_add_s,
|
||||
),
|
||||
),
|
||||
onPressed: widget.onAdded,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart';
|
||||
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
@@ -15,26 +14,9 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class MobileSpace extends StatefulWidget {
|
||||
class MobileSpace extends StatelessWidget {
|
||||
const MobileSpace({super.key});
|
||||
|
||||
@override
|
||||
State<MobileSpace> createState() => _MobileSpaceState();
|
||||
}
|
||||
|
||||
class _MobileSpaceState extends State<MobileSpace> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
createNewPageNotifier.addListener(_createNewPage);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
createNewPageNotifier.removeListener(_createNewPage);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SpaceBloc, SpaceState>(
|
||||
@@ -50,7 +32,7 @@ class _MobileSpaceState extends State<MobileSpace> {
|
||||
MobileSpaceHeader(
|
||||
isExpanded: state.isExpanded,
|
||||
space: currentSpace,
|
||||
onAdded: () => _showCreatePageMenu(currentSpace),
|
||||
onAdded: () => _showCreatePageMenu(context, currentSpace),
|
||||
onPressed: () => _showSpaceMenu(context),
|
||||
),
|
||||
Padding(
|
||||
@@ -91,16 +73,7 @@ class _MobileSpaceState extends State<MobileSpace> {
|
||||
);
|
||||
}
|
||||
|
||||
void _createNewPage() {
|
||||
context.read<SpaceBloc>().add(
|
||||
SpaceEvent.createPage(
|
||||
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
layout: ViewLayoutPB.Document,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreatePageMenu(ViewPB space) {
|
||||
void _showCreatePageMenu(BuildContext context, ViewPB space) {
|
||||
final title = space.name;
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
@@ -167,15 +140,16 @@ class _Pages extends StatelessWidget {
|
||||
onSelected: context.pushView,
|
||||
endActionPane: (context) {
|
||||
final view = context.read<ViewBloc>().state.view;
|
||||
final actions = [
|
||||
MobilePaneActionType.more,
|
||||
if (view.layout == ViewLayoutPB.Document)
|
||||
MobilePaneActionType.add,
|
||||
];
|
||||
return buildEndActionPane(
|
||||
context,
|
||||
[
|
||||
MobilePaneActionType.more,
|
||||
if (view.layout == ViewLayoutPB.Document)
|
||||
MobilePaneActionType.add,
|
||||
],
|
||||
actions,
|
||||
spaceType: spaceType,
|
||||
spaceRatio: 4,
|
||||
spaceRatio: actions.length == 1 ? 3 : 4,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -35,6 +35,8 @@ class MobileSpaceHeader extends StatelessWidget {
|
||||
SpaceIcon(
|
||||
dimension: 24,
|
||||
space: space,
|
||||
svgSize: 14,
|
||||
textDimension: 18.0,
|
||||
cornerRadius: 6.0,
|
||||
),
|
||||
const HSpace(8),
|
||||
|
||||
@@ -74,6 +74,8 @@ class _SidebarSpaceMenuItem extends StatelessWidget {
|
||||
leftIcon: SpaceIcon(
|
||||
dimension: 24,
|
||||
space: space,
|
||||
svgSize: 14,
|
||||
textDimension: 18.0,
|
||||
cornerRadius: 6.0,
|
||||
),
|
||||
leftIconSize: const Size.square(24),
|
||||
|
||||
@@ -21,12 +21,14 @@ class MobileSpaceTabBar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final baseStyle = Theme.of(context).textTheme.bodyMedium;
|
||||
final labelStyle = baseStyle?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 16.0,
|
||||
height: 22.0 / 16.0,
|
||||
);
|
||||
final unselectedLabelStyle = baseStyle?.copyWith(
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 15.0,
|
||||
height: 22.0 / 15.0,
|
||||
);
|
||||
|
||||
return Container(
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/favorite_folder/favorite_space.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/home_space/home_space.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/recent_folder/recent_space.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/tab/_tab_bar.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class MobileSpaceTab extends StatefulWidget {
|
||||
const MobileSpaceTab({super.key, required this.userProfile});
|
||||
const MobileSpaceTab({
|
||||
super.key,
|
||||
required this.userProfile,
|
||||
});
|
||||
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
@@ -22,10 +33,19 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
|
||||
with SingleTickerProviderStateMixin {
|
||||
TabController? tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
mobileCreateNewPageNotifier.addListener(_createNewPage);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
tabController?.removeListener(_onTabChange);
|
||||
tabController?.dispose();
|
||||
mobileCreateNewPageNotifier.removeListener(_createNewPage);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -33,36 +53,60 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
|
||||
Widget build(BuildContext context) {
|
||||
return Provider.value(
|
||||
value: widget.userProfile,
|
||||
child: BlocBuilder<SpaceOrderBloc, SpaceOrderState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<SpaceBloc, SpaceState>(
|
||||
listenWhen: (p, c) =>
|
||||
p.lastCreatedPage?.id != c.lastCreatedPage?.id,
|
||||
listener: (context, state) {
|
||||
final lastCreatedPage = state.lastCreatedPage;
|
||||
if (lastCreatedPage != null) {
|
||||
context.pushView(lastCreatedPage);
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<SidebarSectionsBloc, SidebarSectionsState>(
|
||||
listenWhen: (p, c) =>
|
||||
p.lastCreatedRootView?.id != c.lastCreatedRootView?.id,
|
||||
listener: (context, state) {
|
||||
final lastCreatedPage = state.lastCreatedRootView;
|
||||
if (lastCreatedPage != null) {
|
||||
context.pushView(lastCreatedPage);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<SpaceOrderBloc, SpaceOrderState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
_initTabController(state);
|
||||
_initTabController(state);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MobileSpaceTabBar(
|
||||
tabController: tabController!,
|
||||
tabs: state.tabsOrder,
|
||||
onReorder: (from, to) {
|
||||
context.read<SpaceOrderBloc>().add(
|
||||
SpaceOrderEvent.reorder(from, to),
|
||||
);
|
||||
},
|
||||
),
|
||||
const HSpace(12.0),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: _buildTabs(state),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MobileSpaceTabBar(
|
||||
tabController: tabController!,
|
||||
tabs: state.tabsOrder,
|
||||
onReorder: (from, to) {
|
||||
context.read<SpaceOrderBloc>().add(
|
||||
SpaceOrderEvent.reorder(from, to),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
const HSpace(12.0),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: _buildTabs(state),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -104,4 +148,27 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// quick create new page when clicking the add button in navigation bar
|
||||
void _createNewPage() {
|
||||
if (context.read<SpaceBloc>().state.spaces.isNotEmpty) {
|
||||
context.read<SpaceBloc>().add(
|
||||
SpaceEvent.createPage(
|
||||
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
layout: ViewLayoutPB.Document,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
context.read<SidebarSectionsBloc>().add(
|
||||
SidebarSectionsEvent.createRootViewInSection(
|
||||
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
index: 0,
|
||||
viewSection: FolderSpaceType.public.toViewSectionPB,
|
||||
),
|
||||
);
|
||||
context.read<FolderBloc>().add(
|
||||
const FolderEvent.expandOrUnExpand(isExpanded: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -144,6 +144,7 @@ class _WorkspaceMenuItem extends StatelessWidget {
|
||||
enableEdit: false,
|
||||
iconSize: 26,
|
||||
fontSize: 16.0,
|
||||
figmaLineHeight: 16.0,
|
||||
workspace: workspace,
|
||||
onSelected: (result) => context.read<UserWorkspaceBloc>().add(
|
||||
UserWorkspaceEvent.updateWorkspaceIcon(
|
||||
|
||||
+273
-38
@@ -1,14 +1,31 @@
|
||||
import 'dart:ui';
|
||||
|
||||
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';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
final PropertyValueNotifier<ViewLayoutPB?> createNewPageNotifier =
|
||||
enum BottomNavigationBarActionType {
|
||||
home,
|
||||
notificationMultiSelect,
|
||||
}
|
||||
|
||||
final PropertyValueNotifier<ViewLayoutPB?> mobileCreateNewPageNotifier =
|
||||
PropertyValueNotifier(null);
|
||||
final ValueNotifier<BottomNavigationBarActionType> bottomNavigationBarType =
|
||||
ValueNotifier(BottomNavigationBarActionType.home);
|
||||
|
||||
const _homeLabel = 'home';
|
||||
const _addLabel = 'add';
|
||||
@@ -25,16 +42,16 @@ final _items = <BottomNavigationBarItem>[
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
label: _notificationLabel,
|
||||
icon: FlowySvg(FlowySvgs.m_home_notification_m),
|
||||
activeIcon: FlowySvg(
|
||||
FlowySvgs.m_home_notification_m,
|
||||
icon: _NotificationNavigationBarItemIcon(),
|
||||
activeIcon: _NotificationNavigationBarItemIcon(
|
||||
isActive: true,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
/// Builds the "shell" for the app by building a Scaffold with a
|
||||
/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
|
||||
class MobileBottomNavigationBar extends StatelessWidget {
|
||||
class MobileBottomNavigationBar extends StatefulWidget {
|
||||
/// Constructs an [MobileBottomNavigationBar].
|
||||
const MobileBottomNavigationBar({
|
||||
required this.navigationShell,
|
||||
@@ -44,42 +61,148 @@ class MobileBottomNavigationBar extends StatelessWidget {
|
||||
/// The navigation shell and container for the branch Navigators.
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
@override
|
||||
State<MobileBottomNavigationBar> createState() =>
|
||||
_MobileBottomNavigationBarState();
|
||||
}
|
||||
|
||||
class _MobileBottomNavigationBarState extends State<MobileBottomNavigationBar> {
|
||||
Widget? _bottomNavigationBar;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
bottomNavigationBarType.addListener(_animate);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
bottomNavigationBarType.removeListener(_animate);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLightMode = Theme.of(context).isLightMode;
|
||||
final backgroundColor = isLightMode
|
||||
? Colors.white.withOpacity(0.95)
|
||||
: const Color(0xFF23262B).withOpacity(0.95);
|
||||
final borderColor = isLightMode
|
||||
? const Color(0x141F2329)
|
||||
: const Color(0xFF23262B).withOpacity(0.5);
|
||||
_bottomNavigationBar = switch (bottomNavigationBarType.value) {
|
||||
BottomNavigationBarActionType.home =>
|
||||
_buildHomePageNavigationBar(context),
|
||||
BottomNavigationBarActionType.notificationMultiSelect =>
|
||||
_buildNotificationNavigationBar(context),
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
body: navigationShell,
|
||||
body: widget.navigationShell,
|
||||
extendBody: true,
|
||||
bottomNavigationBar: ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 3,
|
||||
sigmaY: 3,
|
||||
bottomNavigationBar: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
switchInCurve: Curves.easeInOut,
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
transitionBuilder: _transitionBuilder,
|
||||
child: _bottomNavigationBar,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHomePageNavigationBar(BuildContext context) {
|
||||
return _HomePageNavigationBar(
|
||||
navigationShell: widget.navigationShell,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationNavigationBar(BuildContext context) {
|
||||
return const _NotificationNavigationBar();
|
||||
}
|
||||
|
||||
// widget A going down, widget B going up
|
||||
Widget _transitionBuilder(
|
||||
Widget child,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void _animate() {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationNavigationBarItemIcon extends StatelessWidget {
|
||||
const _NotificationNavigationBarItemIcon({
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
final bool isActive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: getIt<ReminderBloc>(),
|
||||
child: BlocBuilder<ReminderBloc, ReminderState>(
|
||||
builder: (context, state) {
|
||||
final hasUnreads = state.reminders.any(
|
||||
(reminder) => !reminder.isRead,
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
isActive
|
||||
? const FlowySvg(
|
||||
FlowySvgs.m_home_active_notification_m,
|
||||
blendMode: null,
|
||||
)
|
||||
: const FlowySvg(
|
||||
FlowySvgs.m_home_notification_m,
|
||||
),
|
||||
if (hasUnreads)
|
||||
const Positioned(
|
||||
top: 2,
|
||||
right: 4,
|
||||
child: NotificationRedDot(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomePageNavigationBar extends StatelessWidget {
|
||||
const _HomePageNavigationBar({
|
||||
required this.navigationShell,
|
||||
});
|
||||
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 3,
|
||||
sigmaY: 3,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: context.border,
|
||||
color: context.backgroundColor,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: isLightMode
|
||||
? Border(top: BorderSide(color: borderColor))
|
||||
: null,
|
||||
color: backgroundColor,
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
showSelectedLabels: false,
|
||||
showUnselectedLabels: false,
|
||||
enableFeedback: false,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
items: _items,
|
||||
backgroundColor: Colors.transparent,
|
||||
currentIndex: navigationShell.currentIndex,
|
||||
onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex),
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
showSelectedLabels: false,
|
||||
showUnselectedLabels: false,
|
||||
enableFeedback: false,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
items: _items,
|
||||
backgroundColor: Colors.transparent,
|
||||
currentIndex: navigationShell.currentIndex,
|
||||
onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -89,10 +212,13 @@ class MobileBottomNavigationBar 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
|
||||
createNewPageNotifier.value = ViewLayoutPB.Document;
|
||||
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
|
||||
@@ -107,3 +233,112 @@ class MobileBottomNavigationBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationNavigationBar extends StatelessWidget {
|
||||
const _NotificationNavigationBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
// todo: use real height here.
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
border: context.border,
|
||||
color: context.backgroundColor,
|
||||
),
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: mSelectedNotificationIds,
|
||||
builder: (context, value, child) {
|
||||
if (value.isEmpty) {
|
||||
// not editable
|
||||
return IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: 0.3,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return child!;
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(20),
|
||||
Expanded(
|
||||
child: NavigationBarButton(
|
||||
icon: FlowySvgs.m_notification_action_mark_as_read_s,
|
||||
text: LocaleKeys.settings_notifications_action_markAsRead.tr(),
|
||||
onTap: () => _onMarkAsRead(context),
|
||||
),
|
||||
),
|
||||
const HSpace(16),
|
||||
Expanded(
|
||||
child: NavigationBarButton(
|
||||
icon: FlowySvgs.m_notification_action_archive_s,
|
||||
text: LocaleKeys.settings_notifications_action_archive.tr(),
|
||||
onTap: () => _onArchive(context),
|
||||
),
|
||||
),
|
||||
const HSpace(20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onMarkAsRead(BuildContext context) {
|
||||
if (mSelectedNotificationIds.value.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys
|
||||
.settings_notifications_markAsReadNotifications_allSuccess
|
||||
.tr(),
|
||||
);
|
||||
|
||||
getIt<ReminderBloc>()
|
||||
.add(ReminderEvent.markAsRead(mSelectedNotificationIds.value));
|
||||
|
||||
mSelectedNotificationIds.value = [];
|
||||
}
|
||||
|
||||
void _onArchive(BuildContext context) {
|
||||
if (mSelectedNotificationIds.value.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
|
||||
.tr(),
|
||||
);
|
||||
|
||||
getIt<ReminderBloc>()
|
||||
.add(ReminderEvent.archive(mSelectedNotificationIds.value));
|
||||
|
||||
mSelectedNotificationIds.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
extension on BuildContext {
|
||||
Color get backgroundColor {
|
||||
return Theme.of(this).isLightMode
|
||||
? Colors.white.withOpacity(0.95)
|
||||
: const Color(0xFF23262B).withOpacity(0.95);
|
||||
}
|
||||
|
||||
Color get borderColor {
|
||||
return Theme.of(this).isLightMode
|
||||
? const Color(0x141F2329)
|
||||
: const Color(0xFF23262B).withOpacity(0.5);
|
||||
}
|
||||
|
||||
Border? get border {
|
||||
return Theme.of(this).isLightMode
|
||||
? Border(top: BorderSide(color: borderColor))
|
||||
: null;
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class MobileNotificationsMultiSelectScreen extends StatelessWidget {
|
||||
const MobileNotificationsMultiSelectScreen({super.key});
|
||||
|
||||
static const routeName = '/notifications_multi_select';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ReminderBloc>.value(
|
||||
value: getIt<ReminderBloc>(),
|
||||
child: const MobileNotificationMultiSelect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MobileNotificationMultiSelect extends StatefulWidget {
|
||||
const MobileNotificationMultiSelect({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MobileNotificationMultiSelect> createState() =>
|
||||
_MobileNotificationMultiSelectState();
|
||||
}
|
||||
|
||||
class _MobileNotificationMultiSelectState
|
||||
extends State<MobileNotificationMultiSelect> {
|
||||
@override
|
||||
void dispose() {
|
||||
mSelectedNotificationIds.value.clear();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MobileNotificationMultiSelectPageHeader(),
|
||||
VSpace(12.0),
|
||||
Expanded(
|
||||
child: MultiSelectNotificationTab(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
final PropertyValueNotifier<List<String>> mSelectedNotificationIds =
|
||||
PropertyValueNotifier([]);
|
||||
|
||||
class MobileNotificationsScreenV2 extends StatefulWidget {
|
||||
const MobileNotificationsScreenV2({super.key});
|
||||
|
||||
static const routeName = '/notifications';
|
||||
|
||||
@override
|
||||
State<MobileNotificationsScreenV2> createState() =>
|
||||
_MobileNotificationsScreenV2State();
|
||||
}
|
||||
|
||||
class _MobileNotificationsScreenV2State
|
||||
extends State<MobileNotificationsScreenV2>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
mCurrentWorkspace.addListener(_onRefresh);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
mCurrentWorkspace.removeListener(_onRefresh);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return BlocProvider<ReminderBloc>.value(
|
||||
value: getIt<ReminderBloc>(),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: bottomNavigationBarType,
|
||||
builder: (_, value, __) {
|
||||
switch (value) {
|
||||
case BottomNavigationBarActionType.home:
|
||||
return const MobileNotificationsTab();
|
||||
case BottomNavigationBarActionType.notificationMultiSelect:
|
||||
return const MobileNotificationMultiSelect();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onRefresh() {
|
||||
getIt<ReminderBloc>().add(const ReminderEvent.refresh());
|
||||
}
|
||||
}
|
||||
|
||||
class MobileNotificationsTab extends StatefulWidget {
|
||||
const MobileNotificationsTab({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MobileNotificationsTab> createState() => _MobileNotificationsTabState();
|
||||
}
|
||||
|
||||
class _MobileNotificationsTabState extends State<MobileNotificationsTab>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController tabController;
|
||||
|
||||
final tabs = [
|
||||
MobileNotificationTabType.inbox,
|
||||
MobileNotificationTabType.unread,
|
||||
MobileNotificationTabType.archive,
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
tabController = TabController(
|
||||
length: 3,
|
||||
vsync: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
tabController.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const MobileNotificationPageHeader(),
|
||||
MobileNotificationTabBar(
|
||||
tabController: tabController,
|
||||
tabs: tabs,
|
||||
),
|
||||
const VSpace(12.0),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: tabs.map((e) => NotificationTab(tabType: e)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension NotificationItemColors on BuildContext {
|
||||
Color get notificationItemTextColor {
|
||||
if (Theme.of(this).isLightMode) {
|
||||
return const Color(0xFF171717);
|
||||
}
|
||||
return const Color(0xFFffffff).withOpacity(0.8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class EmptyNotification extends StatelessWidget {
|
||||
const EmptyNotification({
|
||||
super.key,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final MobileNotificationTabType type;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = switch (type) {
|
||||
MobileNotificationTabType.inbox =>
|
||||
LocaleKeys.settings_notifications_emptyInbox_title.tr(),
|
||||
MobileNotificationTabType.archive =>
|
||||
LocaleKeys.settings_notifications_emptyArchived_title.tr(),
|
||||
MobileNotificationTabType.unread =>
|
||||
LocaleKeys.settings_notifications_emptyUnread_title.tr(),
|
||||
};
|
||||
final desc = switch (type) {
|
||||
MobileNotificationTabType.inbox =>
|
||||
LocaleKeys.settings_notifications_emptyInbox_description.tr(),
|
||||
MobileNotificationTabType.archive =>
|
||||
LocaleKeys.settings_notifications_emptyArchived_description.tr(),
|
||||
MobileNotificationTabType.unread =>
|
||||
LocaleKeys.settings_notifications_emptyUnread_description.tr(),
|
||||
};
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const FlowySvg(FlowySvgs.m_empty_notification_xl),
|
||||
const VSpace(12.0),
|
||||
FlowyText(
|
||||
title,
|
||||
fontSize: 16.0,
|
||||
figmaLineHeight: 24.0,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
const VSpace(4.0),
|
||||
Opacity(
|
||||
opacity: 0.45,
|
||||
child: FlowyText(
|
||||
desc,
|
||||
fontSize: 15.0,
|
||||
figmaLineHeight: 22.0,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/widgets/settings_popup_menu.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MobileNotificationPageHeader extends StatelessWidget {
|
||||
const MobileNotificationPageHeader({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 56),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const HSpace(16.0),
|
||||
FlowyText(
|
||||
LocaleKeys.settings_notifications_titles_notifications.tr(),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const Spacer(),
|
||||
const NotificationSettingsPopupMenu(),
|
||||
const HSpace(16.0),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MobileNotificationMultiSelectPageHeader extends StatelessWidget {
|
||||
const MobileNotificationMultiSelectPageHeader({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 56),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildCancelButton(
|
||||
isOpaque: false,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
onTap: () => bottomNavigationBarType.value =
|
||||
BottomNavigationBarActionType.home,
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: mSelectedNotificationIds,
|
||||
builder: (_, value, __) {
|
||||
return FlowyText(
|
||||
// todo: i18n
|
||||
'${value.length} Selected',
|
||||
fontSize: 17.0,
|
||||
figmaLineHeight: 24.0,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
},
|
||||
),
|
||||
// this button is used to align the text to the center
|
||||
_buildCancelButton(
|
||||
isOpaque: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
Widget _buildCancelButton({
|
||||
required bool isOpaque,
|
||||
required EdgeInsets padding,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: FlowyText(
|
||||
LocaleKeys.button_cancel.tr(),
|
||||
fontSize: 17.0,
|
||||
figmaLineHeight: 24.0,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: isOpaque ? Colors.transparent : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/gesture.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class MultiSelectNotificationItem extends StatelessWidget {
|
||||
const MultiSelectNotificationItem({
|
||||
super.key,
|
||||
required this.reminder,
|
||||
});
|
||||
|
||||
final ReminderPB reminder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.read<AppearanceSettingsCubit>().state;
|
||||
final dateFormate = settings.dateFormat;
|
||||
final timeFormate = settings.timeFormat;
|
||||
return BlocProvider<NotificationReminderBloc>(
|
||||
create: (context) => NotificationReminderBloc()
|
||||
..add(
|
||||
NotificationReminderEvent.initial(
|
||||
reminder,
|
||||
dateFormate,
|
||||
timeFormate,
|
||||
),
|
||||
),
|
||||
child: BlocBuilder<NotificationReminderBloc, NotificationReminderState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == NotificationReminderStatus.loading ||
|
||||
state.status == NotificationReminderStatus.initial) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (state.status == NotificationReminderStatus.error) {
|
||||
// error handle.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final child = ValueListenableBuilder(
|
||||
valueListenable: mSelectedNotificationIds,
|
||||
builder: (_, selectedIds, child) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: selectedIds.contains(reminder.id)
|
||||
? ShapeDecoration(
|
||||
color: const Color(0x1900BCF0),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: _InnerNotificationItem(
|
||||
reminder: reminder,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return AnimatedGestureDetector(
|
||||
scaleFactor: 0.99,
|
||||
onTapUp: () {
|
||||
if (mSelectedNotificationIds.value.contains(reminder.id)) {
|
||||
mSelectedNotificationIds.value = mSelectedNotificationIds.value
|
||||
..remove(reminder.id);
|
||||
} else {
|
||||
mSelectedNotificationIds.value = mSelectedNotificationIds.value
|
||||
..add(reminder.id);
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InnerNotificationItem extends StatelessWidget {
|
||||
const _InnerNotificationItem({
|
||||
required this.reminder,
|
||||
});
|
||||
|
||||
final ReminderPB reminder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const HSpace(10.0),
|
||||
NotificationCheckIcon(
|
||||
isSelected: mSelectedNotificationIds.value.contains(reminder.id),
|
||||
),
|
||||
const HSpace(3.0),
|
||||
!reminder.isRead ? const UnreadRedDot() : const HSpace(6.0),
|
||||
const HSpace(3.0),
|
||||
NotificationIcon(reminder: reminder),
|
||||
const HSpace(12.0),
|
||||
Expanded(
|
||||
child: NotificationContent(reminder: reminder),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/gesture.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
|
||||
class NotificationItem extends StatelessWidget {
|
||||
const NotificationItem({
|
||||
super.key,
|
||||
required this.tabType,
|
||||
required this.reminder,
|
||||
});
|
||||
|
||||
final MobileNotificationTabType tabType;
|
||||
final ReminderPB reminder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.read<AppearanceSettingsCubit>().state;
|
||||
final dateFormate = settings.dateFormat;
|
||||
final timeFormate = settings.timeFormat;
|
||||
return BlocProvider<NotificationReminderBloc>(
|
||||
create: (context) => NotificationReminderBloc()
|
||||
..add(
|
||||
NotificationReminderEvent.initial(
|
||||
reminder,
|
||||
dateFormate,
|
||||
timeFormate,
|
||||
),
|
||||
),
|
||||
child: BlocBuilder<NotificationReminderBloc, NotificationReminderState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == NotificationReminderStatus.loading ||
|
||||
state.status == NotificationReminderStatus.initial) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (state.status == NotificationReminderStatus.error) {
|
||||
// error handle.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final child = Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: _SlidableNotificationItem(
|
||||
tabType: tabType,
|
||||
reminder: reminder,
|
||||
child: _InnerNotificationItem(
|
||||
tabType: tabType,
|
||||
reminder: reminder,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return AnimatedGestureDetector(
|
||||
scaleFactor: 0.99,
|
||||
child: child,
|
||||
onTapUp: () async {
|
||||
final view = state.view;
|
||||
if (view == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.pushView(view);
|
||||
|
||||
if (!reminder.isRead && context.mounted) {
|
||||
context.read<ReminderBloc>().add(
|
||||
ReminderEvent.markAsRead([reminder.id]),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InnerNotificationItem extends StatelessWidget {
|
||||
const _InnerNotificationItem({
|
||||
required this.reminder,
|
||||
required this.tabType,
|
||||
});
|
||||
|
||||
final MobileNotificationTabType tabType;
|
||||
final ReminderPB reminder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const HSpace(8.0),
|
||||
!reminder.isRead ? const UnreadRedDot() : const HSpace(6.0),
|
||||
const HSpace(4.0),
|
||||
NotificationIcon(reminder: reminder),
|
||||
const HSpace(12.0),
|
||||
Expanded(
|
||||
child: NotificationContent(reminder: reminder),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlidableNotificationItem extends StatelessWidget {
|
||||
const _SlidableNotificationItem({
|
||||
required this.tabType,
|
||||
required this.reminder,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final MobileNotificationTabType tabType;
|
||||
final ReminderPB reminder;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<NotificationPaneActionType> actions = switch (tabType) {
|
||||
MobileNotificationTabType.inbox => [
|
||||
NotificationPaneActionType.more,
|
||||
if (!reminder.isRead) NotificationPaneActionType.markAsRead,
|
||||
],
|
||||
MobileNotificationTabType.unread => [
|
||||
NotificationPaneActionType.more,
|
||||
NotificationPaneActionType.markAsRead,
|
||||
],
|
||||
MobileNotificationTabType.archive => [
|
||||
if (kDebugMode) NotificationPaneActionType.unArchive,
|
||||
],
|
||||
};
|
||||
|
||||
if (actions.isEmpty) {
|
||||
return child;
|
||||
}
|
||||
|
||||
final children = actions
|
||||
.map(
|
||||
(action) => action.actionButton(
|
||||
context,
|
||||
tabType: tabType,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final extentRatio = actions.length == 1 ? 1 / 5 : 1 / 3;
|
||||
|
||||
return Slidable(
|
||||
endActionPane: ActionPane(
|
||||
motion: const ScrollMotion(),
|
||||
extentRatio: extentRatio,
|
||||
children: children,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
enum _NotificationSettingsPopupMenuItem {
|
||||
settings,
|
||||
markAllAsRead,
|
||||
archiveAll,
|
||||
// only visible in debug mode
|
||||
unarchiveAll;
|
||||
}
|
||||
|
||||
class NotificationSettingsPopupMenu extends StatelessWidget {
|
||||
const NotificationSettingsPopupMenu({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<_NotificationSettingsPopupMenuItem>(
|
||||
offset: const Offset(0, 36),
|
||||
padding: EdgeInsets.zero,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(12.0),
|
||||
),
|
||||
),
|
||||
// todo: replace it with shadows
|
||||
shadowColor: const Color(0x68000000),
|
||||
elevation: 10,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.m_settings_more_s,
|
||||
),
|
||||
),
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<_NotificationSettingsPopupMenuItem>>[
|
||||
_buildItem(
|
||||
value: _NotificationSettingsPopupMenuItem.settings,
|
||||
svg: FlowySvgs.m_notification_settings_s,
|
||||
text: LocaleKeys.settings_notifications_settings_settings.tr(),
|
||||
),
|
||||
const PopupMenuDivider(height: 0.5),
|
||||
_buildItem(
|
||||
value: _NotificationSettingsPopupMenuItem.markAllAsRead,
|
||||
svg: FlowySvgs.m_notification_mark_as_read_s,
|
||||
text: LocaleKeys.settings_notifications_settings_markAllAsRead.tr(),
|
||||
),
|
||||
const PopupMenuDivider(height: 0.5),
|
||||
_buildItem(
|
||||
value: _NotificationSettingsPopupMenuItem.archiveAll,
|
||||
svg: FlowySvgs.m_notification_archived_s,
|
||||
text: LocaleKeys.settings_notifications_settings_archiveAll.tr(),
|
||||
),
|
||||
// only visible in debug mode
|
||||
if (kDebugMode) ...[
|
||||
const PopupMenuDivider(height: 0.5),
|
||||
_buildItem(
|
||||
value: _NotificationSettingsPopupMenuItem.unarchiveAll,
|
||||
svg: FlowySvgs.m_notification_archived_s,
|
||||
text: 'Unarchive all (Debug Mode)',
|
||||
),
|
||||
],
|
||||
],
|
||||
onSelected: (_NotificationSettingsPopupMenuItem value) {
|
||||
switch (value) {
|
||||
case _NotificationSettingsPopupMenuItem.markAllAsRead:
|
||||
_onMarkAllAsRead(context);
|
||||
break;
|
||||
case _NotificationSettingsPopupMenuItem.archiveAll:
|
||||
_onArchiveAll(context);
|
||||
break;
|
||||
case _NotificationSettingsPopupMenuItem.settings:
|
||||
context.push(MobileHomeSettingPage.routeName);
|
||||
break;
|
||||
case _NotificationSettingsPopupMenuItem.unarchiveAll:
|
||||
_onUnarchiveAll(context);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuItem<T> _buildItem<T>({
|
||||
required T value,
|
||||
required FlowySvgData svg,
|
||||
required String text,
|
||||
}) {
|
||||
return PopupMenuItem<T>(
|
||||
value: value,
|
||||
padding: EdgeInsets.zero,
|
||||
child: _PopupButton(
|
||||
svg: svg,
|
||||
text: text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onMarkAllAsRead(BuildContext context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys
|
||||
.settings_notifications_markAsReadNotifications_allSuccess
|
||||
.tr(),
|
||||
);
|
||||
|
||||
context.read<ReminderBloc>().add(const ReminderEvent.markAllRead());
|
||||
}
|
||||
|
||||
void _onArchiveAll(BuildContext context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
|
||||
.tr(),
|
||||
);
|
||||
|
||||
context.read<ReminderBloc>().add(const ReminderEvent.archiveAll());
|
||||
}
|
||||
|
||||
void _onUnarchiveAll(BuildContext context) {
|
||||
if (!kDebugMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: 'Unarchive all success (Debug Mode)',
|
||||
);
|
||||
|
||||
context.read<ReminderBloc>().add(const ReminderEvent.unarchiveAll());
|
||||
}
|
||||
}
|
||||
|
||||
class _PopupButton extends StatelessWidget {
|
||||
const _PopupButton({
|
||||
required this.svg,
|
||||
required this.text,
|
||||
});
|
||||
|
||||
final FlowySvgData svg;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(svg),
|
||||
const HSpace(12),
|
||||
FlowyText.regular(
|
||||
text,
|
||||
fontSize: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/widgets/color.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
const _kNotificationIconHeight = 36.0;
|
||||
|
||||
class NotificationIcon extends StatelessWidget {
|
||||
const NotificationIcon({
|
||||
super.key,
|
||||
required this.reminder,
|
||||
});
|
||||
|
||||
final ReminderPB reminder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const FlowySvg(
|
||||
FlowySvgs.m_notification_reminder_s,
|
||||
size: Size.square(_kNotificationIconHeight),
|
||||
blendMode: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationCheckIcon extends StatelessWidget {
|
||||
const NotificationCheckIcon({super.key, required this.isSelected});
|
||||
|
||||
final bool isSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: _kNotificationIconHeight,
|
||||
child: Center(
|
||||
child: FlowySvg(
|
||||
isSelected
|
||||
? FlowySvgs.m_notification_multi_select_s
|
||||
: FlowySvgs.m_notification_multi_unselect_s,
|
||||
blendMode: isSelected ? null : BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UnreadRedDot extends StatelessWidget {
|
||||
const UnreadRedDot({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(
|
||||
height: _kNotificationIconHeight,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 6.0,
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: Color(0xFFFF6331),
|
||||
shape: OvalBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationContent extends StatefulWidget {
|
||||
const NotificationContent({
|
||||
super.key,
|
||||
required this.reminder,
|
||||
});
|
||||
|
||||
final ReminderPB reminder;
|
||||
|
||||
@override
|
||||
State<NotificationContent> createState() => _NotificationContentState();
|
||||
}
|
||||
|
||||
class _NotificationContentState extends State<NotificationContent> {
|
||||
@override
|
||||
void didUpdateWidget(covariant NotificationContent oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
context.read<NotificationReminderBloc>().add(
|
||||
const NotificationReminderEvent.reset(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<NotificationReminderBloc, NotificationReminderState>(
|
||||
builder: (context, state) {
|
||||
final view = state.view;
|
||||
if (view == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// title
|
||||
_buildHeader(),
|
||||
|
||||
// time & page name
|
||||
_buildTimeAndPageName(
|
||||
context,
|
||||
state.createdAt,
|
||||
state.pageTitle,
|
||||
),
|
||||
|
||||
// content
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: _buildContent(view, nodes: state.nodes),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(ViewPB view, {List<Node>? nodes}) {
|
||||
if (view.layout.isDocumentView && nodes != null) {
|
||||
return IntrinsicHeight(
|
||||
child: BlocProvider(
|
||||
create: (context) => DocumentPageStyleBloc(view: view),
|
||||
child: NotificationDocumentContent(
|
||||
reminder: widget.reminder,
|
||||
nodes: nodes,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (view.layout.isDatabaseView) {
|
||||
final opacity = widget.reminder.type == ReminderType.past ? 0.3 : 1.0;
|
||||
return Opacity(
|
||||
opacity: opacity,
|
||||
child: FlowyText(
|
||||
widget.reminder.message,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 22,
|
||||
color: context.notificationItemTextColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return FlowyText.semibold(
|
||||
LocaleKeys.settings_notifications_titles_reminder.tr(),
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimeAndPageName(
|
||||
BuildContext context,
|
||||
String createdAt,
|
||||
String pageTitle,
|
||||
) {
|
||||
return Opacity(
|
||||
opacity: 0.5,
|
||||
child: Row(
|
||||
children: [
|
||||
// the legacy reminder doesn't contain the timestamp, so we don't show it
|
||||
if (createdAt.isNotEmpty) ...[
|
||||
FlowyText.regular(
|
||||
createdAt,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 18,
|
||||
color: context.notificationItemTextColor,
|
||||
),
|
||||
const NotificationEllipse(),
|
||||
],
|
||||
FlowyText.regular(
|
||||
pageTitle,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 18,
|
||||
color: context.notificationItemTextColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationEllipse extends StatelessWidget {
|
||||
const NotificationEllipse({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 2.50,
|
||||
height: 2.50,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 5.0),
|
||||
decoration: ShapeDecoration(
|
||||
color: context.notificationItemTextColor,
|
||||
shape: const OvalBorder(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationDocumentContent extends StatelessWidget {
|
||||
const NotificationDocumentContent({
|
||||
super.key,
|
||||
required this.reminder,
|
||||
required this.nodes,
|
||||
});
|
||||
|
||||
final ReminderPB reminder;
|
||||
final List<Node> nodes;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final editorState = EditorState(
|
||||
document: Document(
|
||||
root: pageNode(children: nodes),
|
||||
),
|
||||
);
|
||||
|
||||
final styleCustomizer = EditorStyleCustomizer(
|
||||
context: context,
|
||||
padding: EdgeInsets.zero,
|
||||
);
|
||||
|
||||
final editorStyle = styleCustomizer.style().copyWith(
|
||||
// hide the cursor
|
||||
cursorColor: Colors.transparent,
|
||||
cursorWidth: 0,
|
||||
textStyleConfiguration: TextStyleConfiguration(
|
||||
lineHeight: 22 / 14,
|
||||
applyHeightToFirstAscent: true,
|
||||
applyHeightToLastDescent: true,
|
||||
text: TextStyle(
|
||||
fontSize: 14,
|
||||
color: context.notificationItemTextColor,
|
||||
height: 22 / 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
leadingDistribution: TextLeadingDistribution.even,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final blockBuilders = getEditorBuilderMap(
|
||||
context: context,
|
||||
editorState: editorState,
|
||||
styleCustomizer: styleCustomizer,
|
||||
// the editor is not editable in the chat
|
||||
editable: false,
|
||||
customHeadingPadding: EdgeInsets.zero,
|
||||
);
|
||||
|
||||
return IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: reminder.type == ReminderType.past ? 0.3 : 1,
|
||||
child: AppFlowyEditor(
|
||||
editorState: editorState,
|
||||
editorStyle: editorStyle,
|
||||
disableSelectionService: true,
|
||||
disableKeyboardService: true,
|
||||
disableScrollService: true,
|
||||
editable: false,
|
||||
shrinkWrap: true,
|
||||
blockComponentBuilders: blockBuilders,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
|
||||
import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
enum NotificationPaneActionType {
|
||||
more,
|
||||
markAsRead,
|
||||
// only used in the debug mode.
|
||||
unArchive;
|
||||
|
||||
MobileSlideActionButton actionButton(
|
||||
BuildContext context, {
|
||||
required MobileNotificationTabType tabType,
|
||||
}) {
|
||||
switch (this) {
|
||||
case NotificationPaneActionType.markAsRead:
|
||||
return MobileSlideActionButton(
|
||||
backgroundColor: const Color(0xFF00C8FF),
|
||||
svg: FlowySvgs.m_notification_action_mark_as_read_s,
|
||||
size: 24.0,
|
||||
onPressed: (context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys
|
||||
.settings_notifications_markAsReadNotifications_success
|
||||
.tr(),
|
||||
);
|
||||
|
||||
context.read<ReminderBloc>().add(
|
||||
ReminderEvent.update(
|
||||
ReminderUpdate(
|
||||
id: context.read<NotificationReminderBloc>().reminder.id,
|
||||
isRead: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
// this action is only used in the debug mode.
|
||||
case NotificationPaneActionType.unArchive:
|
||||
return MobileSlideActionButton(
|
||||
backgroundColor: const Color(0xFF00C8FF),
|
||||
svg: FlowySvgs.m_notification_action_mark_as_read_s,
|
||||
size: 24.0,
|
||||
onPressed: (context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: 'Unarchive notification success',
|
||||
);
|
||||
|
||||
context.read<ReminderBloc>().add(
|
||||
ReminderEvent.update(
|
||||
ReminderUpdate(
|
||||
id: context.read<NotificationReminderBloc>().reminder.id,
|
||||
isArchived: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
case NotificationPaneActionType.more:
|
||||
return MobileSlideActionButton(
|
||||
backgroundColor: const Color(0xE5515563),
|
||||
svg: FlowySvgs.three_dots_s,
|
||||
size: 24.0,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(10),
|
||||
bottomLeft: Radius.circular(10),
|
||||
),
|
||||
onPressed: (context) {
|
||||
final reminderBloc = context.read<ReminderBloc>();
|
||||
final notificationReminderBloc =
|
||||
context.read<NotificationReminderBloc>();
|
||||
|
||||
showMobileBottomSheet(
|
||||
context,
|
||||
showDragHandle: true,
|
||||
showDivider: false,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
builder: (_) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: reminderBloc),
|
||||
BlocProvider.value(value: notificationReminderBloc),
|
||||
],
|
||||
child: _NotificationMoreActions(
|
||||
onClickMultipleChoice: () {
|
||||
Future.delayed(const Duration(milliseconds: 250), () {
|
||||
bottomNavigationBarType.value =
|
||||
BottomNavigationBarActionType
|
||||
.notificationMultiSelect;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationMoreActions extends StatelessWidget {
|
||||
const _NotificationMoreActions({
|
||||
required this.onClickMultipleChoice,
|
||||
});
|
||||
|
||||
final VoidCallback onClickMultipleChoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reminder = context.read<NotificationReminderBloc>().reminder;
|
||||
return Column(
|
||||
children: [
|
||||
if (!reminder.isRead)
|
||||
FlowyOptionTile.text(
|
||||
height: 52.0,
|
||||
text: LocaleKeys.settings_notifications_action_markAsRead.tr(),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.m_notification_action_mark_as_read_s,
|
||||
size: Size.square(20),
|
||||
),
|
||||
showTopBorder: false,
|
||||
showBottomBorder: false,
|
||||
onTap: () => _onMarkAsRead(context),
|
||||
),
|
||||
FlowyOptionTile.text(
|
||||
height: 52.0,
|
||||
text: LocaleKeys.settings_notifications_action_multipleChoice.tr(),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.m_notification_action_multiple_choice_s,
|
||||
size: Size.square(20),
|
||||
),
|
||||
showTopBorder: false,
|
||||
showBottomBorder: false,
|
||||
onTap: () => _onMultipleChoice(context),
|
||||
),
|
||||
if (!reminder.isArchived)
|
||||
FlowyOptionTile.text(
|
||||
height: 52.0,
|
||||
text: LocaleKeys.settings_notifications_action_archive.tr(),
|
||||
leftIcon: const FlowySvg(
|
||||
FlowySvgs.m_notification_action_archive_s,
|
||||
size: Size.square(20),
|
||||
),
|
||||
showTopBorder: false,
|
||||
showBottomBorder: false,
|
||||
onTap: () => _onArchive(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onMarkAsRead(BuildContext context) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_notifications_markAsReadNotifications_success
|
||||
.tr(),
|
||||
);
|
||||
|
||||
context.read<ReminderBloc>().add(
|
||||
ReminderEvent.update(
|
||||
ReminderUpdate(
|
||||
id: context.read<NotificationReminderBloc>().reminder.id,
|
||||
isRead: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onMultipleChoice(BuildContext context) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
onClickMultipleChoice();
|
||||
}
|
||||
|
||||
void _onArchive(BuildContext context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_notifications_archiveNotifications_success
|
||||
.tr()
|
||||
.tr(),
|
||||
);
|
||||
|
||||
context.read<ReminderBloc>().add(
|
||||
ReminderEvent.update(
|
||||
ReminderUpdate(
|
||||
id: context.read<NotificationReminderBloc>().reminder.id,
|
||||
isRead: true,
|
||||
isArchived: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
|
||||
import 'package:appflowy/shared/list_extension.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/appflowy_backend.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class NotificationTab extends StatefulWidget {
|
||||
const NotificationTab({
|
||||
super.key,
|
||||
required this.tabType,
|
||||
});
|
||||
|
||||
final MobileNotificationTabType tabType;
|
||||
|
||||
@override
|
||||
State<NotificationTab> createState() => _NotificationTabState();
|
||||
}
|
||||
|
||||
class _NotificationTabState extends State<NotificationTab>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return BlocBuilder<ReminderBloc, ReminderState>(
|
||||
builder: (context, state) {
|
||||
final reminders = _filterReminders(state.reminders);
|
||||
|
||||
if (reminders.isEmpty) {
|
||||
// add refresh indicator to the empty notification.
|
||||
return EmptyNotification(
|
||||
type: widget.tabType,
|
||||
);
|
||||
}
|
||||
|
||||
final child = ListView.separated(
|
||||
itemCount: reminders.length,
|
||||
separatorBuilder: (context, index) => const VSpace(8.0),
|
||||
itemBuilder: (context, index) {
|
||||
final reminder = reminders[index];
|
||||
return NotificationItem(
|
||||
key: ValueKey('${widget.tabType}_${reminder.id}'),
|
||||
tabType: widget.tabType,
|
||||
reminder: reminder,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return RefreshIndicator.adaptive(
|
||||
onRefresh: () async => _onRefresh(context),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRefresh(BuildContext context) async {
|
||||
context.read<ReminderBloc>().add(const ReminderEvent.refresh());
|
||||
|
||||
// at least 0.5 seconds to dismiss the refresh indicator.
|
||||
// otherwise, it will be dismissed immediately.
|
||||
await context.read<ReminderBloc>().stream.firstOrNull;
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_notifications_refreshSuccess.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<ReminderPB> _filterReminders(List<ReminderPB> reminders) {
|
||||
switch (widget.tabType) {
|
||||
case MobileNotificationTabType.inbox:
|
||||
return reminders.reversed
|
||||
.where((reminder) => !reminder.isArchived)
|
||||
.toList()
|
||||
.unique((reminder) => reminder.id);
|
||||
case MobileNotificationTabType.archive:
|
||||
return reminders.reversed
|
||||
.where((reminder) => reminder.isArchived)
|
||||
.toList()
|
||||
.unique((reminder) => reminder.id);
|
||||
case MobileNotificationTabType.unread:
|
||||
return reminders.reversed
|
||||
.where((reminder) => !reminder.isRead)
|
||||
.toList()
|
||||
.unique((reminder) => reminder.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MultiSelectNotificationTab extends StatelessWidget {
|
||||
const MultiSelectNotificationTab({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ReminderBloc, ReminderState>(
|
||||
builder: (context, state) {
|
||||
// find the reminders that are not archived or read.
|
||||
final reminders = state.reminders.reversed
|
||||
.where((reminder) => !reminder.isArchived || !reminder.isRead)
|
||||
.toList();
|
||||
|
||||
if (reminders.isEmpty) {
|
||||
// add refresh indicator to the empty notification.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: reminders.length,
|
||||
separatorBuilder: (context, index) => const VSpace(8.0),
|
||||
itemBuilder: (context, index) {
|
||||
final reminder = reminders[index];
|
||||
return MultiSelectNotificationItem(
|
||||
key: ValueKey(reminder.id),
|
||||
reminder: reminder,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:reorderable_tabbar/reorderable_tabbar.dart';
|
||||
|
||||
enum MobileNotificationTabType {
|
||||
inbox,
|
||||
unread,
|
||||
archive;
|
||||
|
||||
String get tr {
|
||||
switch (this) {
|
||||
case MobileNotificationTabType.inbox:
|
||||
return LocaleKeys.settings_notifications_tabs_inbox.tr();
|
||||
case MobileNotificationTabType.unread:
|
||||
return LocaleKeys.settings_notifications_tabs_unread.tr();
|
||||
case MobileNotificationTabType.archive:
|
||||
return LocaleKeys.settings_notifications_tabs_archived.tr();
|
||||
}
|
||||
}
|
||||
|
||||
List<NotificationPaneActionType> get actions {
|
||||
switch (this) {
|
||||
case MobileNotificationTabType.inbox:
|
||||
return [
|
||||
NotificationPaneActionType.more,
|
||||
NotificationPaneActionType.markAsRead,
|
||||
];
|
||||
case MobileNotificationTabType.unread:
|
||||
case MobileNotificationTabType.archive:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MobileNotificationTabBar extends StatelessWidget {
|
||||
const MobileNotificationTabBar({
|
||||
super.key,
|
||||
this.height = 38.0,
|
||||
required this.tabController,
|
||||
required this.tabs,
|
||||
});
|
||||
|
||||
final double height;
|
||||
final List<MobileNotificationTabType> tabs;
|
||||
final TabController tabController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseStyle = Theme.of(context).textTheme.bodyMedium;
|
||||
final labelStyle = baseStyle?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 16.0,
|
||||
height: 22.0 / 16.0,
|
||||
);
|
||||
final unselectedLabelStyle = baseStyle?.copyWith(
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 15.0,
|
||||
height: 22.0 / 15.0,
|
||||
);
|
||||
|
||||
return Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: ReorderableTabBar(
|
||||
controller: tabController,
|
||||
tabs: tabs.map((e) => Tab(text: e.tr)).toList(),
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
isScrollable: true,
|
||||
labelStyle: labelStyle,
|
||||
labelColor: baseStyle?.color,
|
||||
labelPadding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
unselectedLabelStyle: unselectedLabelStyle,
|
||||
overlayColor: WidgetStateProperty.all(Colors.transparent),
|
||||
indicator: const RoundUnderlineTabIndicator(
|
||||
width: 28.0,
|
||||
borderSide: BorderSide(
|
||||
color: Color(0xFF00C8FF),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export 'empty.dart';
|
||||
export 'header.dart';
|
||||
export 'multi_select_notification_item.dart';
|
||||
export 'notification_item.dart';
|
||||
export 'settings_popup_menu.dart';
|
||||
export 'shared.dart';
|
||||
export 'slide_actions.dart';
|
||||
export 'tab.dart';
|
||||
export 'tab_bar.dart';
|
||||
@@ -260,6 +260,7 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
|
||||
child: FlowyText.regular(
|
||||
widget.view.name,
|
||||
fontSize: 16.0,
|
||||
figmaLineHeight: 20.0,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -297,12 +298,17 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
|
||||
? FlowyText.emoji(
|
||||
widget.view.icon.value,
|
||||
fontSize: 18.0,
|
||||
figmaLineHeight: 20.0,
|
||||
optimizeEmojiAlign: true,
|
||||
)
|
||||
: Opacity(
|
||||
opacity: 0.7,
|
||||
child: widget.view.defaultIcon(size: const Size.square(18)),
|
||||
);
|
||||
return SizedBox(width: 18.0, child: icon);
|
||||
return SizedBox(
|
||||
width: 18.0,
|
||||
child: icon,
|
||||
);
|
||||
}
|
||||
|
||||
// > button or · button
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class NavigationBarButton extends StatelessWidget {
|
||||
const NavigationBarButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
this.enable = true,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final FlowySvgData icon;
|
||||
final VoidCallback onTap;
|
||||
final bool enable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: enable ? 1.0 : 0.3,
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: ShapeDecoration(
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: Color(0x3F1F2329)),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: FlowyButton(
|
||||
useIntrinsicWidth: true,
|
||||
expandText: false,
|
||||
iconPadding: 8,
|
||||
leftIcon: FlowySvg(icon),
|
||||
onTap: enable ? onTap : null,
|
||||
text: FlowyText(
|
||||
text,
|
||||
fontSize: 15.0,
|
||||
figmaLineHeight: 18.0,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+34
-5
@@ -1,21 +1,30 @@
|
||||
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-chat/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
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? refSourceJsonString,
|
||||
required this.chatId,
|
||||
required this.questionId,
|
||||
}) : super(ChatAIMessageState.initial(message)) {
|
||||
}) : super(
|
||||
ChatAIMessageState.initial(
|
||||
message,
|
||||
messageReferenceSource(refSourceJsonString),
|
||||
),
|
||||
) {
|
||||
if (state.stream != null) {
|
||||
state.stream!.listen(
|
||||
onData: (text) {
|
||||
@@ -33,6 +42,11 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
}
|
||||
},
|
||||
onMetadata: (sources) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveSources(sources));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (state.stream!.error != null) {
|
||||
@@ -74,7 +88,7 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
chatId: chatId,
|
||||
messageId: questionId,
|
||||
);
|
||||
ChatEventGetAnswerForQuestion(payload).send().then((result) {
|
||||
AIEventGetAnswerForQuestion(payload).send().then((result) {
|
||||
if (!isClosed) {
|
||||
result.fold(
|
||||
(answer) {
|
||||
@@ -103,6 +117,13 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
receiveSources: (List<ChatMessageRefSource> sources) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
sources: sources,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -120,6 +141,9 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
|
||||
const factory ChatAIMessageEvent.retry() = _Retry;
|
||||
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
|
||||
const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit;
|
||||
const factory ChatAIMessageEvent.receiveSources(
|
||||
List<ChatMessageRefSource> sources,
|
||||
) = _ReceiveMetadata;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -128,13 +152,18 @@ class ChatAIMessageState with _$ChatAIMessageState {
|
||||
AnswerStream? stream,
|
||||
required String text,
|
||||
required MessageState messageState,
|
||||
required List<ChatMessageRefSource> sources,
|
||||
}) = _ChatAIMessageState;
|
||||
|
||||
factory ChatAIMessageState.initial(dynamic text) {
|
||||
factory ChatAIMessageState.initial(
|
||||
dynamic text,
|
||||
List<ChatMessageRefSource> sources,
|
||||
) {
|
||||
return ChatAIMessageState(
|
||||
text: text is String ? text : "",
|
||||
stream: text is AnswerStream ? text : null,
|
||||
messageState: const MessageState.ready(),
|
||||
sources: sources,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
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-chat/entities.pb.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';
|
||||
@@ -18,12 +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.freezed.dart';
|
||||
|
||||
const sendMessageErrorKey = "sendMessageError";
|
||||
|
||||
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
ChatBloc({
|
||||
required ViewPB view,
|
||||
@@ -41,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.
|
||||
///
|
||||
@@ -70,7 +69,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
chatId: state.view.id,
|
||||
limit: Int64(10),
|
||||
);
|
||||
ChatEventLoadNextMessage(payload).send().then(
|
||||
AIEventLoadNextMessage(payload).send().then(
|
||||
(result) {
|
||||
result.fold((list) {
|
||||
if (!isClosed) {
|
||||
@@ -84,16 +83,23 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
},
|
||||
);
|
||||
},
|
||||
// Loading messages
|
||||
startLoadingPrevMessage: () async {
|
||||
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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -109,7 +115,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: uniqueMessages,
|
||||
loadingPreviousStatus: const LoadingState.finish(),
|
||||
loadingPreviousStatus: const ChatLoadingState.finish(),
|
||||
hasMorePrevMessage: hasMore,
|
||||
),
|
||||
);
|
||||
@@ -123,24 +129,51 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: uniqueMessages,
|
||||
initialLoadingStatus: const LoadingState.finish(),
|
||||
initialLoadingStatus: const ChatLoadingState.finish(),
|
||||
),
|
||||
);
|
||||
},
|
||||
streaming: (Message message) {
|
||||
final allMessages = _perminentMessages();
|
||||
allMessages.insert(0, message);
|
||||
// streaming message
|
||||
finishAnswerStreaming: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
streamingStatus: const LoadingState.loading(),
|
||||
streamingState: const StreamingState.done(),
|
||||
acceptRelatedQuestion: true,
|
||||
canSendMessage:
|
||||
state.sendingState == const SendMessageState.done(),
|
||||
),
|
||||
);
|
||||
},
|
||||
didFinishStreaming: () {
|
||||
emit(
|
||||
state.copyWith(streamingStatus: const LoadingState.finish()),
|
||||
);
|
||||
didUpdateAnswerStream: (AnswerStream stream) {
|
||||
emit(state.copyWith(answerStream: stream));
|
||||
},
|
||||
stopStream: () async {
|
||||
if (state.answerStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await AIEventStopStream(payload).send();
|
||||
final allMessages = _perminentMessages();
|
||||
if (state.streamingState != const StreamingState.done()) {
|
||||
// If the streaming is not started, remove the message from the list
|
||||
if (!state.answerStream!.hasStarted) {
|
||||
allMessages.removeWhere(
|
||||
(element) => element.id == answerStreamMessageId,
|
||||
);
|
||||
answerStreamMessageId = "";
|
||||
}
|
||||
|
||||
// when stop stream, we will set the answer stream to null. Which means the streaming
|
||||
// is finished or canceled.
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
answerStream: null,
|
||||
streamingState: const StreamingState.done(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
receveMessage: (Message message) {
|
||||
final allMessages = _perminentMessages();
|
||||
@@ -153,23 +186,52 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
sendMessage: (String message) {
|
||||
_startStreamingMessage(message, emit);
|
||||
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();
|
||||
emit(
|
||||
state.copyWith(
|
||||
lastSentMessage: null,
|
||||
messages: allMessages,
|
||||
relatedQuestions: [],
|
||||
acceptRelatedQuestion: false,
|
||||
sendingState: const SendMessageState.sending(),
|
||||
canSendMessage: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
finishSending: (ChatMessagePB message) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
lastSentMessage: message,
|
||||
sendingState: const SendMessageState.done(),
|
||||
canSendMessage:
|
||||
state.streamingState == const StreamingState.done(),
|
||||
),
|
||||
);
|
||||
},
|
||||
// related question
|
||||
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
|
||||
if (questions.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final allMessages = _perminentMessages();
|
||||
final message = CustomMessage(
|
||||
metadata: OnetimeShotType.relatedQuestion.toMap(),
|
||||
author: const User(id: "system"),
|
||||
id: 'system',
|
||||
author: const User(id: systemUserId),
|
||||
id: systemUserId,
|
||||
);
|
||||
allMessages.insert(0, message);
|
||||
emit(
|
||||
@@ -186,44 +248,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
didSentUserMessage: (ChatMessagePB message) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
lastSentMessage: message,
|
||||
),
|
||||
);
|
||||
},
|
||||
didUpdateAnswerStream: (AnswerStream stream) {
|
||||
emit(state.copyWith(answerStream: stream));
|
||||
},
|
||||
stopStream: () async {
|
||||
if (state.answerStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await ChatEventStopStream(payload).send();
|
||||
final allMessages = _perminentMessages();
|
||||
if (state.streamingStatus != const LoadingState.finish()) {
|
||||
// If the streaming is not started, remove the message from the list
|
||||
if (!state.answerStream!.hasStarted) {
|
||||
allMessages.removeWhere(
|
||||
(element) => element.id == lastStreamMessageId,
|
||||
);
|
||||
lastStreamMessageId = "";
|
||||
}
|
||||
|
||||
// when stop stream, we will set the answer stream to null. Which means the streaming
|
||||
// is finished or canceled.
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: allMessages,
|
||||
answerStream: null,
|
||||
streamingStatus: const LoadingState.finish(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -234,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);
|
||||
@@ -247,7 +278,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
chatErrorMessageCallback: (err) {
|
||||
if (!isClosed) {
|
||||
Log.error("chat error: ${err.errorMessage}");
|
||||
add(const ChatEvent.didFinishStreaming());
|
||||
add(const ChatEvent.finishAnswerStreaming());
|
||||
}
|
||||
},
|
||||
latestMessageCallback: (list) {
|
||||
@@ -264,7 +295,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
},
|
||||
finishStreamingCallback: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatEvent.didFinishStreaming());
|
||||
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) {
|
||||
@@ -273,11 +304,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
messageId: state.lastSentMessage!.messageId,
|
||||
);
|
||||
// When user message was sent to the server, we start gettting related question
|
||||
ChatEventGetRelatedQuestion(payload).send().then((result) {
|
||||
AIEventGetRelatedQuestion(payload).send().then((result) {
|
||||
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");
|
||||
@@ -322,11 +355,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
limit: Int64(10),
|
||||
beforeMessageId: beforeMessageId,
|
||||
);
|
||||
ChatEventLoadPrevMessage(payload).send();
|
||||
AIEventLoadPrevMessage(payload).send();
|
||||
}
|
||||
|
||||
Future<void> _startStreamingMessage(
|
||||
String message,
|
||||
Map<String, dynamic>? metadata,
|
||||
Emitter<ChatState> emit,
|
||||
) async {
|
||||
if (state.answerStream != null) {
|
||||
@@ -334,29 +368,37 @@ 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 ChatEventStreamMessage(payload).send();
|
||||
final result = await AIEventStreamMessage(payload).send();
|
||||
result.fold(
|
||||
(ChatMessagePB question) {
|
||||
if (!isClosed) {
|
||||
add(ChatEvent.didSentUserMessage(question));
|
||||
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) {
|
||||
@@ -369,8 +411,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
|
||||
final error = CustomMessage(
|
||||
metadata: metadata,
|
||||
author: const User(id: "system"),
|
||||
id: 'system',
|
||||
author: const User(id: systemUserId),
|
||||
id: systemUserId,
|
||||
);
|
||||
|
||||
add(ChatEvent.receveMessage(error));
|
||||
@@ -379,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: nanoid()),
|
||||
author: User(id: "streamId:${nanoid()}"),
|
||||
metadata: {
|
||||
"$AnswerStream": stream,
|
||||
"question": questionMessageId,
|
||||
messageQuestionIdKey: questionMessageId,
|
||||
"chatId": chatId,
|
||||
},
|
||||
id: streamMessageId,
|
||||
@@ -397,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();
|
||||
|
||||
@@ -410,6 +480,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
id: messageId,
|
||||
text: message.content,
|
||||
createdAt: message.createdAt.toInt() * 1000,
|
||||
metadata: {
|
||||
messageRefSourceJsonStringKey: message.metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -417,7 +490,22 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
@freezed
|
||||
class ChatEvent with _$ChatEvent {
|
||||
const factory ChatEvent.initialLoad() = _InitialLoadMessage;
|
||||
const factory ChatEvent.sendMessage(String message) = _SendMessage;
|
||||
|
||||
// send message
|
||||
const factory ChatEvent.sendMessage({
|
||||
required String message,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) = _SendMessage;
|
||||
const factory ChatEvent.finishSending(ChatMessagePB message) =
|
||||
_FinishSendMessage;
|
||||
|
||||
// receive message
|
||||
const factory ChatEvent.startAnswerStreaming(Message message) =
|
||||
_StartAnswerStreaming;
|
||||
const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage;
|
||||
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming;
|
||||
|
||||
// loading messages
|
||||
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
|
||||
const factory ChatEvent.didLoadPreviousMessages(
|
||||
List<Message> messages,
|
||||
@@ -425,16 +513,13 @@ class ChatEvent with _$ChatEvent {
|
||||
) = _DidLoadPreviousMessages;
|
||||
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
|
||||
_DidLoadMessages;
|
||||
const factory ChatEvent.streaming(Message message) = _StreamingMessage;
|
||||
const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage;
|
||||
|
||||
const factory ChatEvent.didFinishStreaming() = _FinishStreamingMessage;
|
||||
// related questions
|
||||
const factory ChatEvent.didReceiveRelatedQuestion(
|
||||
List<RelatedQuestionPB> questions,
|
||||
) = _DidReceiveRelatedQueston;
|
||||
const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion;
|
||||
const factory ChatEvent.didSentUserMessage(ChatMessagePB message) =
|
||||
_DidSendUserMessage;
|
||||
|
||||
const factory ChatEvent.didUpdateAnswerStream(
|
||||
AnswerStream stream,
|
||||
) = _DidUpdateAnswerStream;
|
||||
@@ -449,20 +534,23 @@ 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 LoadingState streamingStatus,
|
||||
required StreamingState streamingState,
|
||||
required SendMessageState sendingState,
|
||||
// Indicate whether there are more previous messages to load.
|
||||
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,
|
||||
@Default(true) bool canSendMessage,
|
||||
}) = _ChatState;
|
||||
|
||||
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
|
||||
@@ -470,137 +558,17 @@ class ChatState with _$ChatState {
|
||||
view: view,
|
||||
messages: [],
|
||||
userProfile: userProfile,
|
||||
initialLoadingStatus: const LoadingState.finish(),
|
||||
loadingPreviousStatus: const LoadingState.finish(),
|
||||
streamingStatus: const LoadingState.finish(),
|
||||
initialLoadingStatus: const ChatLoadingState.finish(),
|
||||
loadingPreviousStatus: const ChatLoadingState.finish(),
|
||||
streamingState: const StreamingState.done(),
|
||||
sendingState: const SendMessageState.done(),
|
||||
hasMorePrevMessage: true,
|
||||
relatedQuestions: [],
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class LoadingState with _$LoadingState {
|
||||
const factory LoadingState.loading() = _Loading;
|
||||
const factory LoadingState.finish({FlowyError? error}) = _Finish;
|
||||
}
|
||||
|
||||
enum OnetimeShotType {
|
||||
unknown,
|
||||
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 == "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;
|
||||
|
||||
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,
|
||||
}) {
|
||||
_onData = onData;
|
||||
_onStart = onStart;
|
||||
_onEnd = onEnd;
|
||||
_onError = onError;
|
||||
_onAIResponseLimit = onAIResponseLimit;
|
||||
|
||||
if (_onStart != null) {
|
||||
_onStart!();
|
||||
}
|
||||
}
|
||||
bool isOtherUserMessage(Message message) {
|
||||
return message.author.id != aiResponseUserId &&
|
||||
message.author.id != systemUserId &&
|
||||
!message.author.id.startsWith("streamId:");
|
||||
}
|
||||
@@ -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,18 +1,20 @@
|
||||
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';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
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) {
|
||||
@@ -31,7 +33,7 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initial: () async {
|
||||
final result = await ChatEventGetLocalAIChatState().send();
|
||||
final result = await AIEventGetLocalAIChatState().send();
|
||||
result.fold(
|
||||
(chatState) {
|
||||
if (!isClosed) {
|
||||
@@ -46,31 +48,16 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
);
|
||||
},
|
||||
newFile: (String filePath, String fileName) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
indexFileIndicator: IndexFileIndicator.indexing(fileName),
|
||||
),
|
||||
);
|
||||
final payload = ChatFilePB(filePath: filePath, chatId: chatId);
|
||||
unawaited(
|
||||
ChatEventChatWithFile(payload).send().then((result) {
|
||||
if (!isClosed) {
|
||||
result.fold((_) {
|
||||
add(
|
||||
ChatFileEvent.updateIndexFile(
|
||||
IndexFileIndicator.finish(fileName),
|
||||
),
|
||||
);
|
||||
}, (err) {
|
||||
add(
|
||||
ChatFileEvent.updateIndexFile(
|
||||
IndexFileIndicator.error(err.msg),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
final files = List<ChatFile>.from(state.uploadFiles);
|
||||
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
|
||||
@@ -83,22 +70,56 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
updateIndexFile: (IndexFileIndicator indicator) {
|
||||
emit(
|
||||
state.copyWith(indexFileIndicator: indicator),
|
||||
);
|
||||
},
|
||||
updatePluginState: (LocalAIPluginStatePB chatState) {
|
||||
final fileEnabled = state.chatState?.fileEnabled ?? false;
|
||||
final supportChatWithFile =
|
||||
fileEnabled && chatState.state == RunningStatePB.Running;
|
||||
emit(state.copyWith(supportChatWithFile: supportChatWithFile));
|
||||
|
||||
final aiType = chatState.state == RunningStatePB.Running
|
||||
? const AIType.localAI()
|
||||
: const AIType.appflowyAI();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
supportChatWithFile: supportChatWithFile,
|
||||
aiType: aiType,
|
||||
),
|
||||
);
|
||||
},
|
||||
deleteFile: (file) {
|
||||
final files = List<ChatFile>.from(state.uploadFiles);
|
||||
files.remove(file);
|
||||
emit(
|
||||
state.copyWith(
|
||||
uploadFiles: files,
|
||||
),
|
||||
);
|
||||
},
|
||||
clear: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
uploadFiles: [],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -113,27 +134,21 @@ 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.updateChatState(LocalAIChatPB chatState) =
|
||||
_UpdateChatState;
|
||||
const factory ChatFileEvent.updatePluginState(
|
||||
LocalAIPluginStatePB chatState,
|
||||
) = _UpdatePluginState;
|
||||
const factory ChatFileEvent.updateIndexFile(IndexFileIndicator indicator) =
|
||||
_UpdateIndexFile;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatFileState with _$ChatFileState {
|
||||
const factory ChatFileState({
|
||||
@Default(false) bool supportChatWithFile,
|
||||
IndexFileIndicator? indexFileIndicator,
|
||||
LocalAIChatPB? chatState,
|
||||
@Default([]) List<ChatFile> uploadFiles,
|
||||
@Default(AIType.appflowyAI()) AIType aiType,
|
||||
}) = _ChatFileState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class IndexFileIndicator with _$IndexFileIndicator {
|
||||
const factory IndexFileIndicator.finish(String fileName) = _Finish;
|
||||
const factory IndexFileIndicator.indexing(String fileName) = _Indexing;
|
||||
const factory IndexFileIndicator.error(String error) = _Error;
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/workspace/application/view/view_ext.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';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'chat_input_action_control.dart';
|
||||
|
||||
part 'chat_input_action_bloc.freezed.dart';
|
||||
|
||||
class ChatInputActionBloc
|
||||
extends Bloc<ChatInputActionEvent, ChatInputActionState> {
|
||||
ChatInputActionBloc({required this.chatId})
|
||||
: super(const ChatInputActionState()) {
|
||||
on<ChatInputActionEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
|
||||
Future<void> _handleEvent(
|
||||
ChatInputActionEvent event,
|
||||
Emitter<ChatInputActionState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
started: () async {
|
||||
unawaited(
|
||||
ViewBackendService.getAllViews().then(
|
||||
(result) {
|
||||
final views = result
|
||||
.toNullable()
|
||||
?.items
|
||||
.where(
|
||||
(v) =>
|
||||
v.layout.isDocumentView &&
|
||||
!v.isSpace &&
|
||||
v.parentViewId.isNotEmpty,
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
if (!isClosed) {
|
||||
add(ChatInputActionEvent.refreshViews(views));
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
refreshViews: (List<ViewPB> views) {
|
||||
final List<ViewActionPage> pages = _filterPages(
|
||||
views,
|
||||
state.selectedPages,
|
||||
state.filter,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
views: views,
|
||||
pages: pages,
|
||||
indicator: const ChatActionMenuIndicator.ready(),
|
||||
),
|
||||
);
|
||||
},
|
||||
filter: (String filter) {
|
||||
Log.debug("Filter chat input pages: $filter");
|
||||
final List<ViewActionPage> pages = _filterPages(
|
||||
state.views,
|
||||
state.selectedPages,
|
||||
filter,
|
||||
);
|
||||
|
||||
emit(state.copyWith(pages: pages, filter: filter));
|
||||
},
|
||||
handleKeyEvent: (PhysicalKeyboardKey physicalKey) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
keyboardKey: ChatInputKeyboardEvent(physicalKey: physicalKey),
|
||||
),
|
||||
);
|
||||
},
|
||||
addPage: (ChatInputMention page) {
|
||||
if (!state.selectedPages.any((p) => p.pageId == page.pageId)) {
|
||||
final List<ViewActionPage> pages = _filterPages(
|
||||
state.views,
|
||||
state.selectedPages,
|
||||
state.filter,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
pages: pages,
|
||||
selectedPages: [...state.selectedPages, page],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
removePage: (String text) {
|
||||
final List<ChatInputMention> selectedPages =
|
||||
List.from(state.selectedPages);
|
||||
selectedPages.retainWhere((t) => !text.contains(t.title));
|
||||
|
||||
final List<ViewActionPage> allPages = _filterPages(
|
||||
state.views,
|
||||
state.selectedPages,
|
||||
state.filter,
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedPages: selectedPages,
|
||||
pages: allPages,
|
||||
),
|
||||
);
|
||||
},
|
||||
clear: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedPages: [],
|
||||
filter: "",
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<ViewActionPage> _filterPages(
|
||||
List<ViewPB> views,
|
||||
List<ChatInputMention> selectedPages,
|
||||
String filter,
|
||||
) {
|
||||
final pages = views
|
||||
.map(
|
||||
(v) => ViewActionPage(view: v),
|
||||
)
|
||||
.toList();
|
||||
|
||||
pages.retainWhere((page) {
|
||||
return !selectedPages.contains(page);
|
||||
});
|
||||
|
||||
if (filter.isEmpty) {
|
||||
return pages;
|
||||
}
|
||||
|
||||
return pages
|
||||
.where(
|
||||
(v) => v.title.toLowerCase().contains(filter.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
class ViewActionPage extends ChatInputMention {
|
||||
ViewActionPage({required this.view});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
String get pageId => view.id;
|
||||
|
||||
@override
|
||||
String get title => view.name;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [pageId];
|
||||
|
||||
@override
|
||||
dynamic get page => view;
|
||||
|
||||
@override
|
||||
Widget get icon => view.defaultIcon();
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatInputActionEvent with _$ChatInputActionEvent {
|
||||
const factory ChatInputActionEvent.started() = _Started;
|
||||
const factory ChatInputActionEvent.refreshViews(List<ViewPB> views) =
|
||||
_RefreshViews;
|
||||
const factory ChatInputActionEvent.filter(String filter) = _Filter;
|
||||
const factory ChatInputActionEvent.handleKeyEvent(
|
||||
PhysicalKeyboardKey keyboardKey,
|
||||
) = _HandleKeyEvent;
|
||||
const factory ChatInputActionEvent.addPage(ChatInputMention page) = _AddPage;
|
||||
const factory ChatInputActionEvent.removePage(String text) = _RemovePage;
|
||||
const factory ChatInputActionEvent.clear() = _Clear;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatInputActionState with _$ChatInputActionState {
|
||||
const factory ChatInputActionState({
|
||||
@Default([]) List<ViewPB> views,
|
||||
@Default([]) List<ChatInputMention> pages,
|
||||
@Default([]) List<ChatInputMention> selectedPages,
|
||||
@Default("") String filter,
|
||||
ChatInputKeyboardEvent? keyboardKey,
|
||||
@Default(ChatActionMenuIndicator.loading())
|
||||
ChatActionMenuIndicator indicator,
|
||||
}) = _ChatInputActionState;
|
||||
}
|
||||
|
||||
class ChatInputKeyboardEvent extends Equatable {
|
||||
ChatInputKeyboardEvent({required this.physicalKey});
|
||||
|
||||
final PhysicalKeyboardKey physicalKey;
|
||||
final int timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [timestamp];
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatActionMenuIndicator with _$ChatActionMenuIndicator {
|
||||
const factory ChatActionMenuIndicator.ready() = _Ready;
|
||||
const factory ChatActionMenuIndicator.loading() = _Loading;
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class ChatInputMention extends Equatable {
|
||||
String get title;
|
||||
String get pageId;
|
||||
dynamic get page;
|
||||
Widget get icon;
|
||||
}
|
||||
|
||||
/// Key: the key is the pageId
|
||||
typedef ChatInputMentionMetadata = Map<String, ChatInputMention>;
|
||||
|
||||
class ChatInputActionControl extends ChatActionHandler {
|
||||
ChatInputActionControl({
|
||||
required this.textController,
|
||||
required this.textFieldFocusNode,
|
||||
required this.chatId,
|
||||
}) : _commandBloc = ChatInputActionBloc(chatId: chatId);
|
||||
|
||||
final TextEditingController textController;
|
||||
final ChatInputActionBloc _commandBloc;
|
||||
final FocusNode textFieldFocusNode;
|
||||
final String chatId;
|
||||
|
||||
// Private attributes
|
||||
String _atText = "";
|
||||
String _prevText = "";
|
||||
String _showMenuText = "";
|
||||
|
||||
// Getter
|
||||
List<String> get tags =>
|
||||
_commandBloc.state.selectedPages.map((e) => e.title).toList();
|
||||
|
||||
ChatInputMentionMetadata consumeMetaData() {
|
||||
final metadata = _commandBloc.state.selectedPages.fold(
|
||||
<String, ChatInputMention>{},
|
||||
(map, page) => map..putIfAbsent(page.pageId, () => page),
|
||||
);
|
||||
|
||||
if (metadata.isNotEmpty) {
|
||||
_commandBloc.add(const ChatInputActionEvent.clear());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
void handleKeyEvent(KeyEvent event) {
|
||||
// ignore: deprecated_member_use
|
||||
if (event is KeyDownEvent || event is RawKeyDownEvent) {
|
||||
_commandBloc.add(ChatInputActionEvent.handleKeyEvent(event.physicalKey));
|
||||
}
|
||||
}
|
||||
|
||||
bool canHandleKeyEvent(KeyEvent event) {
|
||||
return _showMenuText.isNotEmpty &&
|
||||
<PhysicalKeyboardKey>{
|
||||
PhysicalKeyboardKey.arrowDown,
|
||||
PhysicalKeyboardKey.arrowUp,
|
||||
PhysicalKeyboardKey.enter,
|
||||
PhysicalKeyboardKey.escape,
|
||||
}.contains(event.physicalKey);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_commandBloc.close();
|
||||
}
|
||||
|
||||
@override
|
||||
void onSelected(ChatInputMention page) {
|
||||
_commandBloc.add(ChatInputActionEvent.addPage(page));
|
||||
textController.text = "$_showMenuText${page.title}";
|
||||
|
||||
onExit();
|
||||
}
|
||||
|
||||
@override
|
||||
void onExit() {
|
||||
_atText = "";
|
||||
_showMenuText = "";
|
||||
_prevText = "";
|
||||
_commandBloc.add(const ChatInputActionEvent.filter(""));
|
||||
}
|
||||
|
||||
@override
|
||||
void onEnter() {
|
||||
_commandBloc.add(const ChatInputActionEvent.started());
|
||||
_showMenuText = textController.text;
|
||||
}
|
||||
|
||||
@override
|
||||
double actionMenuOffsetX() {
|
||||
final TextPosition textPosition = textController.selection.extent;
|
||||
if (textFieldFocusNode.context == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final RenderBox renderBox =
|
||||
textFieldFocusNode.context?.findRenderObject() as RenderBox;
|
||||
|
||||
final TextPainter textPainter = TextPainter(
|
||||
text: TextSpan(text: textController.text),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout(
|
||||
minWidth: renderBox.size.width,
|
||||
maxWidth: renderBox.size.width,
|
||||
);
|
||||
|
||||
final Offset caretOffset =
|
||||
textPainter.getOffsetForCaret(textPosition, Rect.zero);
|
||||
final List<TextBox> boxes = textPainter.getBoxesForSelection(
|
||||
TextSelection(
|
||||
baseOffset: textPosition.offset,
|
||||
extentOffset: textPosition.offset,
|
||||
),
|
||||
);
|
||||
|
||||
if (boxes.isNotEmpty) {
|
||||
return boxes.last.right;
|
||||
}
|
||||
return caretOffset.dx;
|
||||
}
|
||||
|
||||
bool onTextChanged(String text) {
|
||||
final String inputText = text;
|
||||
if (_prevText.length > inputText.length) {
|
||||
final deleteStartIndex = textController.selection.baseOffset;
|
||||
final deleteEndIndex =
|
||||
_prevText.length - inputText.length + deleteStartIndex;
|
||||
final deletedText = _prevText.substring(deleteStartIndex, deleteEndIndex);
|
||||
_commandBloc.add(ChatInputActionEvent.removePage(deletedText));
|
||||
}
|
||||
|
||||
// If the action menu is shown, filter the views
|
||||
if (_showMenuText.isNotEmpty) {
|
||||
if (text.length >= _showMenuText.length) {
|
||||
final filterText = inputText.substring(_showMenuText.length);
|
||||
_commandBloc.add(ChatInputActionEvent.filter(filterText));
|
||||
}
|
||||
|
||||
// If the text change from "xxx @"" to "xxx", which means user delete the @, we should hide the action menu
|
||||
if (_atText.isNotEmpty && !inputText.contains(_atText)) {
|
||||
_commandBloc.add(
|
||||
const ChatInputActionEvent.handleKeyEvent(PhysicalKeyboardKey.escape),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final isTypingNewAt =
|
||||
text.endsWith("@") && _prevText.length < text.length;
|
||||
if (isTypingNewAt) {
|
||||
_atText = text;
|
||||
_prevText = text;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_prevText = text;
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onFilter(String filter) {
|
||||
Log.info("filter: $filter");
|
||||
}
|
||||
|
||||
@override
|
||||
ChatInputActionBloc get commandBloc => _commandBloc;
|
||||
}
|
||||
@@ -3,24 +3,25 @@ import 'dart:async';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'chat_input_bloc.freezed.dart';
|
||||
|
||||
class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
|
||||
ChatInputBloc()
|
||||
class ChatInputStateBloc
|
||||
extends Bloc<ChatInputStateEvent, ChatInputStateState> {
|
||||
ChatInputStateBloc()
|
||||
: listener = LocalLLMListener(),
|
||||
super(const ChatInputState(aiType: _AppFlowyAI())) {
|
||||
super(const ChatInputStateState(aiType: _AppFlowyAI())) {
|
||||
listener.start(
|
||||
chatStateCallback: (aiState) {
|
||||
stateCallback: (pluginState) {
|
||||
if (!isClosed) {
|
||||
add(ChatInputEvent.updateState(aiState));
|
||||
add(ChatInputStateEvent.updatePluginState(pluginState));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
on<ChatInputEvent>(_handleEvent);
|
||||
on<ChatInputStateEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
final LocalLLMListener listener;
|
||||
@@ -32,17 +33,17 @@ class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
|
||||
}
|
||||
|
||||
Future<void> _handleEvent(
|
||||
ChatInputEvent event,
|
||||
Emitter<ChatInputState> emit,
|
||||
ChatInputStateEvent event,
|
||||
Emitter<ChatInputStateState> emit,
|
||||
) async {
|
||||
await event.when(
|
||||
started: () async {
|
||||
final result = await ChatEventGetLocalAIChatState().send();
|
||||
final result = await AIEventGetLocalAIPluginState().send();
|
||||
result.fold(
|
||||
(aiState) {
|
||||
(pluginState) {
|
||||
if (!isClosed) {
|
||||
add(
|
||||
ChatInputEvent.updateState(aiState),
|
||||
ChatInputStateEvent.updatePluginState(pluginState),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -51,11 +52,11 @@ class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
|
||||
},
|
||||
);
|
||||
},
|
||||
updateState: (aiState) {
|
||||
if (aiState.enabled) {
|
||||
emit(const ChatInputState(aiType: _LocalAI()));
|
||||
updatePluginState: (pluginState) {
|
||||
if (pluginState.state == RunningStatePB.Running) {
|
||||
emit(const ChatInputStateState(aiType: _LocalAI()));
|
||||
} else {
|
||||
emit(const ChatInputState(aiType: _AppFlowyAI()));
|
||||
emit(const ChatInputStateState(aiType: _AppFlowyAI()));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -63,15 +64,16 @@ class ChatInputBloc extends Bloc<ChatInputEvent, ChatInputState> {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatInputEvent with _$ChatInputEvent {
|
||||
const factory ChatInputEvent.started() = _Started;
|
||||
const factory ChatInputEvent.updateState(LocalAIChatPB aiState) =
|
||||
_UpdateAIState;
|
||||
class ChatInputStateEvent with _$ChatInputStateEvent {
|
||||
const factory ChatInputStateEvent.started() = _Started;
|
||||
const factory ChatInputStateEvent.updatePluginState(
|
||||
LocalAIPluginStatePB pluginState,
|
||||
) = _UpdatePluginState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatInputState with _$ChatInputState {
|
||||
const factory ChatInputState({required AIType aiType}) = _ChatInputState;
|
||||
class ChatInputStateState with _$ChatInputStateState {
|
||||
const factory ChatInputStateState({required AIType aiType}) = _ChatInputState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -79,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;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_member_bloc.freezed.dart';
|
||||
|
||||
class ChatMemberBloc extends Bloc<ChatMemberEvent, ChatMemberState> {
|
||||
ChatMemberBloc() : super(const ChatMemberState()) {
|
||||
on<ChatMemberEvent>(
|
||||
(event, emit) async {
|
||||
event.when(
|
||||
initial: () {},
|
||||
receiveMemberInfo: (String id, WorkspaceMemberPB memberInfo) {
|
||||
final members = Map<String, ChatMember>.from(state.members);
|
||||
members[id] = ChatMember(info: memberInfo);
|
||||
emit(state.copyWith(members: members));
|
||||
},
|
||||
getMemberInfo: (String userId) {
|
||||
if (state.members.containsKey(userId)) {
|
||||
// Member info already exists. Debouncing refresh member info from backend would be better.
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = WorkspaceMemberIdPB(
|
||||
uid: Int64.parseInt(userId),
|
||||
);
|
||||
UserEventGetMemberInfo(payload).send().then((result) {
|
||||
if (!isClosed) {
|
||||
result.fold((member) {
|
||||
add(
|
||||
ChatMemberEvent.receiveMemberInfo(
|
||||
userId,
|
||||
member,
|
||||
),
|
||||
);
|
||||
}, (err) {
|
||||
Log.error("Error getting member info: $err");
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatMemberEvent with _$ChatMemberEvent {
|
||||
const factory ChatMemberEvent.initial() = Initial;
|
||||
const factory ChatMemberEvent.getMemberInfo(
|
||||
String userId,
|
||||
) = _GetMemberInfo;
|
||||
const factory ChatMemberEvent.receiveMemberInfo(
|
||||
String id,
|
||||
WorkspaceMemberPB memberInfo,
|
||||
) = _ReceiveMemberInfo;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatMemberState with _$ChatMemberState {
|
||||
const factory ChatMemberState({
|
||||
@Default({}) Map<String, ChatMember> members,
|
||||
}) = _ChatMemberState;
|
||||
}
|
||||
|
||||
class ChatMember extends Equatable {
|
||||
ChatMember({
|
||||
required this.info,
|
||||
});
|
||||
final DateTime _date = DateTime.now();
|
||||
final WorkspaceMemberPB info;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [_date, info];
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
|
||||
import 'package:appflowy_backend/rust_stream.dart';
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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';
|
||||
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,
|
||||
) async {
|
||||
final List<ChatMessageMetaPB> metadata = [];
|
||||
if (map != null) {
|
||||
for (final entry in map.entries) {
|
||||
if (entry.value is ViewActionPage) {
|
||||
if (entry.value.page is ViewPB) {
|
||||
final view = entry.value.page as ViewPB;
|
||||
if (view.layout.isDocumentView) {
|
||||
final payload = OpenDocumentPayloadPB(documentId: view.id);
|
||||
final result = await DocumentEventGetDocumentText(payload).send();
|
||||
result.fold((pb) {
|
||||
metadata.add(
|
||||
ChatMessageMetaPB(
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
data: pb.text,
|
||||
dataType: ChatMessageMetaTypePB.Txt,
|
||||
source: appflowySoruce,
|
||||
),
|
||||
);
|
||||
}, (err) {
|
||||
Log.error('Failed to get document text: $err');
|
||||
});
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy/core/notification/notification_helper.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/notification.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart';
|
||||
import 'package:appflowy_backend/rust_stream.dart';
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:async';
|
||||
|
||||
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';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_side_pannel_bloc.freezed.dart';
|
||||
|
||||
const double kDefaultSidePannelWidth = 500;
|
||||
|
||||
class ChatSidePannelBloc
|
||||
extends Bloc<ChatSidePannelEvent, ChatSidePannelState> {
|
||||
ChatSidePannelBloc({
|
||||
required this.chatId,
|
||||
}) : super(const ChatSidePannelState()) {
|
||||
on<ChatSidePannelEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
selectedMetadata: (ChatMessageRefSource metadata) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
metadata: metadata,
|
||||
indicator: const ChatSidePannelIndicator.loading(),
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
ViewBackendService.getView(metadata.id).then(
|
||||
(result) {
|
||||
result.fold((view) {
|
||||
if (!isClosed) {
|
||||
add(ChatSidePannelEvent.open(view));
|
||||
}
|
||||
}, (err) {
|
||||
Log.error("Failed to get view: $err");
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
close: () {
|
||||
emit(state.copyWith(metadata: null, isShowPannel: false));
|
||||
},
|
||||
open: (ViewPB view) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
indicator: ChatSidePannelIndicator.ready(view),
|
||||
isShowPannel: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePannelEvent with _$ChatSidePannelEvent {
|
||||
const factory ChatSidePannelEvent.selectedMetadata(
|
||||
ChatMessageRefSource metadata,
|
||||
) = _SelectedMetadata;
|
||||
const factory ChatSidePannelEvent.close() = _Close;
|
||||
const factory ChatSidePannelEvent.open(ViewPB view) = _Open;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePannelState with _$ChatSidePannelState {
|
||||
const factory ChatSidePannelState({
|
||||
ChatMessageRefSource? metadata,
|
||||
@Default(ChatSidePannelIndicator.loading())
|
||||
ChatSidePannelIndicator indicator,
|
||||
@Default(false) bool isShowPannel,
|
||||
}) = _ChatSidePannelState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePannelIndicator with _$ChatSidePannelIndicator {
|
||||
const factory ChatSidePannelIndicator.ready(ViewPB view) = _Ready;
|
||||
const factory ChatSidePannelIndicator.loading() = _Loading;
|
||||
}
|
||||
+110
-25
@@ -1,9 +1,6 @@
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:fixnum/fixnum.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';
|
||||
@@ -11,26 +8,85 @@ part 'chat_user_message_bloc.freezed.dart';
|
||||
class ChatUserMessageBloc
|
||||
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
|
||||
ChatUserMessageBloc({
|
||||
required Message message,
|
||||
}) : super(ChatUserMessageState.initial(message)) {
|
||||
required dynamic message,
|
||||
}) : super(
|
||||
ChatUserMessageState.initial(
|
||||
message,
|
||||
),
|
||||
) {
|
||||
on<ChatUserMessageEvent>(
|
||||
(event, emit) async {
|
||||
(event, emit) {
|
||||
event.when(
|
||||
initial: () {
|
||||
final payload =
|
||||
WorkspaceMemberIdPB(uid: Int64.parseInt(message.author.id));
|
||||
UserEventGetMemberInfo(payload).send().then((result) {
|
||||
if (state.stream != null) {
|
||||
if (!isClosed) {
|
||||
result.fold((member) {
|
||||
add(ChatUserMessageEvent.didReceiveMemberInfo(member));
|
||||
}, (err) {
|
||||
Log.error("Error getting member info: $err");
|
||||
});
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
didReceiveMemberInfo: (WorkspaceMemberPB memberInfo) {
|
||||
emit(state.copyWith(member: memberInfo));
|
||||
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));
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -41,18 +97,47 @@ class ChatUserMessageBloc
|
||||
@freezed
|
||||
class ChatUserMessageEvent with _$ChatUserMessageEvent {
|
||||
const factory ChatUserMessageEvent.initial() = Initial;
|
||||
const factory ChatUserMessageEvent.didReceiveMemberInfo(
|
||||
WorkspaceMemberPB memberInfo,
|
||||
) = _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,
|
||||
WorkspaceMemberPB? member,
|
||||
required String text,
|
||||
QuestionStream? stream,
|
||||
String? messageId,
|
||||
@Default(QuestionMessageState.finish()) QuestionMessageState messageState,
|
||||
}) = _ChatUserMessageState;
|
||||
|
||||
factory ChatUserMessageState.initial(Message message) =>
|
||||
ChatUserMessageState(message: message);
|
||||
factory ChatUserMessageState.initial(
|
||||
dynamic message,
|
||||
) =>
|
||||
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;
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -104,6 +105,11 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
|
||||
}
|
||||
});
|
||||
|
||||
if (context.userProfile == null) {
|
||||
Log.error("User profile is null when opening AI Chat plugin");
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return BlocProvider<ViewInfoBloc>.value(
|
||||
value: bloc,
|
||||
child: AIChatPage(
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
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/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
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/presentation/ai_message_bubble.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/user_message_bubble.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.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_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/platform_extension.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.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';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
|
||||
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';
|
||||
import 'presentation/chat_welcome_page.dart';
|
||||
@@ -72,54 +75,45 @@ class AIChatPage extends StatelessWidget {
|
||||
if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) => ChatFileBloc(chatId: view.id.toString())
|
||||
..add(const ChatFileEvent.initial()),
|
||||
),
|
||||
/// [ChatBloc] is used to handle chat messages including send/receive message
|
||||
BlocProvider(
|
||||
create: (_) => ChatBloc(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
)..add(const ChatEvent.initialLoad()),
|
||||
),
|
||||
|
||||
/// [ChatFileBloc] is used to handle file indexing as a chat context
|
||||
BlocProvider(
|
||||
create: (_) => ChatInputBloc()..add(const ChatInputEvent.started()),
|
||||
create: (_) => ChatFileBloc()..add(const ChatFileEvent.initial()),
|
||||
),
|
||||
|
||||
/// [ChatInputStateBloc] is used to handle chat input text field state
|
||||
BlocProvider(
|
||||
create: (_) =>
|
||||
ChatInputStateBloc()..add(const ChatInputStateEvent.started()),
|
||||
),
|
||||
BlocProvider(create: (_) => ChatSidePannelBloc(chatId: view.id)),
|
||||
BlocProvider(create: (_) => ChatMemberBloc()),
|
||||
],
|
||||
child: BlocListener<ChatFileBloc, ChatFileState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.indexFileIndicator != current.indexFileIndicator,
|
||||
listener: (context, state) {
|
||||
_handleIndexIndicator(state.indexFileIndicator, 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -131,35 +125,6 @@ class AIChatPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleIndexIndicator(
|
||||
IndexFileIndicator? indicator,
|
||||
BuildContext context,
|
||||
) {
|
||||
if (indicator != null) {
|
||||
indicator.when(
|
||||
finish: (fileName) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.chat_indexFileSuccess.tr(args: [fileName]),
|
||||
);
|
||||
},
|
||||
indexing: (fileName) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
LocaleKeys.chat_indexingFile.tr(args: [fileName]),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
},
|
||||
error: (err) {
|
||||
showSnackBarMessage(
|
||||
context,
|
||||
err,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatContentPage extends StatefulWidget {
|
||||
@@ -187,7 +152,71 @@ class _ChatContentPageState extends State<_ChatContentPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) {
|
||||
return buildChatWidget();
|
||||
if (PlatformExtension.isDesktop) {
|
||||
return BlocSelector<ChatSidePannelBloc, ChatSidePannelState, bool>(
|
||||
selector: (state) => state.isShowPannel,
|
||||
builder: (context, isShowPannel) {
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final double chatOffsetX = isShowPannel
|
||||
? 60
|
||||
: (constraints.maxWidth > 784
|
||||
? (constraints.maxWidth - 784) / 2.0
|
||||
: 60);
|
||||
|
||||
final double width = isShowPannel
|
||||
? (constraints.maxWidth - chatOffsetX * 2) * 0.46
|
||||
: min(constraints.maxWidth - chatOffsetX * 2, 784);
|
||||
|
||||
final double sidePannelOffsetX = chatOffsetX + width;
|
||||
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
children: [
|
||||
buildChatWidget()
|
||||
.constrained(width: width)
|
||||
.positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: chatOffsetX,
|
||||
animate: true,
|
||||
)
|
||||
.animate(
|
||||
const Duration(milliseconds: 200),
|
||||
Curves.easeOut,
|
||||
),
|
||||
if (isShowPannel)
|
||||
buildChatSidePannel()
|
||||
.positioned(
|
||||
left: sidePannelOffsetX,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
animate: true,
|
||||
)
|
||||
.animate(
|
||||
const Duration(milliseconds: 200),
|
||||
Curves.easeOut,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 784),
|
||||
child: buildChatWidget(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Center(
|
||||
@@ -198,94 +227,122 @@ class _ChatContentPageState extends State<_ChatContentPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChatWidget() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 784),
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (blocContext, state) => Chat(
|
||||
messages: state.messages,
|
||||
onSendPressed: (_) {
|
||||
// We use custom bottom widget for chat input, so
|
||||
// do not need to handle this event.
|
||||
},
|
||||
customBottomWidget: buildChatInput(blocContext),
|
||||
user: _user,
|
||||
theme: buildTheme(context),
|
||||
onEndReached: () async {
|
||||
if (state.hasMorePrevMessage &&
|
||||
state.loadingPreviousStatus !=
|
||||
const LoadingState.loading()) {
|
||||
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(question)),
|
||||
),
|
||||
)
|
||||
: const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
),
|
||||
messageWidthRatio: AIChatUILayout.messageWidthRatio,
|
||||
textMessageBuilder: (
|
||||
textMessage, {
|
||||
required messageWidth,
|
||||
required showName,
|
||||
}) =>
|
||||
_buildAITextMessage(blocContext, textMessage),
|
||||
bubbleBuilder: (
|
||||
child, {
|
||||
required message,
|
||||
required nextMessageInGroup,
|
||||
}) {
|
||||
if (message.author.id == _user.id) {
|
||||
return ChatUserMessageBubble(
|
||||
message: message,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
Widget buildChatSidePannel() {
|
||||
if (PlatformExtension.isDesktop) {
|
||||
return BlocBuilder<ChatSidePannelBloc, ChatSidePannelState>(
|
||||
builder: (context, state) {
|
||||
if (state.metadata != null) {
|
||||
return const ChatSidePannel();
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// TODO(lucas): implement mobile chat side panel
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
return _buildAIBubble(message, blocContext, state, child);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Widget buildChatWidget() {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (blocContext, state) => Chat(
|
||||
key: ValueKey(widget.view.id),
|
||||
messages: state.messages,
|
||||
onSendPressed: (_) {
|
||||
// We use custom bottom widget for chat input, so
|
||||
// do not need to handle this event.
|
||||
},
|
||||
customBottomWidget: _buildBottom(blocContext),
|
||||
user: _user,
|
||||
theme: buildTheme(context),
|
||||
onEndReached: () async {
|
||||
if (state.hasMorePrevMessage &&
|
||||
state.loadingPreviousStatus.isFinish) {
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.startLoadingPrevMessage());
|
||||
}
|
||||
},
|
||||
emptyState: BlocBuilder<ChatBloc, ChatState>(
|
||||
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: (
|
||||
textMessage, {
|
||||
required messageWidth,
|
||||
required showName,
|
||||
}) =>
|
||||
_buildTextMessage(blocContext, textMessage),
|
||||
bubbleBuilder: (
|
||||
child, {
|
||||
required message,
|
||||
required nextMessageInGroup,
|
||||
}) =>
|
||||
_buildBubble(blocContext, message, child, state),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAITextMessage(BuildContext context, TextMessage message) {
|
||||
final isAuthor = message.author.id == _user.id;
|
||||
if (isAuthor) {
|
||||
return ChatTextMessageWidget(
|
||||
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) {
|
||||
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"];
|
||||
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,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
onSelectedMetadata: (ChatMessageRefSource metadata) {
|
||||
context.read<ChatSidePannelBloc>().add(
|
||||
ChatSidePannelEvent.selectedMetadata(metadata),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -309,7 +366,9 @@ class _ChatContentPageState extends State<_ChatContentPage> {
|
||||
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||
return RelatedQuestionList(
|
||||
onQuestionSelected: (question) {
|
||||
blocContext.read<ChatBloc>().add(ChatEvent.sendMessage(question));
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(message: question));
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.clearReleatedQuestion());
|
||||
@@ -326,73 +385,14 @@ 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 buildChatInput(BuildContext context) {
|
||||
Widget _buildBottom(BuildContext context) {
|
||||
return ClipRect(
|
||||
child: Padding(
|
||||
padding: AIChatUILayout.safeAreaInsets(context),
|
||||
child: BlocBuilder<ChatInputBloc, ChatInputState>(
|
||||
child: BlocBuilder<ChatInputStateBloc, ChatInputStateState>(
|
||||
builder: (context, state) {
|
||||
// Show different hint text based on the AI type
|
||||
final aiType = state.aiType;
|
||||
final hintText = state.aiType.when(
|
||||
appflowyAI: () => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
localAI: () => LocaleKeys.chat_inputLocalAIMessageHint.tr(),
|
||||
@@ -400,14 +400,21 @@ class _ChatContentPageState extends State<_ChatContentPage> {
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
BlocSelector<ChatBloc, ChatState, LoadingState>(
|
||||
selector: (state) => state.streamingStatus,
|
||||
builder: (context, state) {
|
||||
BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) => state.canSendMessage,
|
||||
builder: (context, canSendMessage) {
|
||||
return ChatInput(
|
||||
aiType: aiType,
|
||||
chatId: widget.view.id,
|
||||
onSendPressed: (message) =>
|
||||
onSendPressed(context, message.text),
|
||||
isStreaming: state != const LoadingState.finish(),
|
||||
onSendPressed: (message) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: message.text,
|
||||
metadata: message.metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
@@ -432,54 +439,50 @@ class _ChatContentPageState extends State<_ChatContentPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AFDefaultChatTheme buildTheme(BuildContext context) {
|
||||
return AFDefaultChatTheme(
|
||||
backgroundColor: AFThemeExtension.of(context).background,
|
||||
primaryColor: Theme.of(context).colorScheme.primary,
|
||||
secondaryColor: AFThemeExtension.of(context).tint1,
|
||||
receivedMessageDocumentIconColor: Theme.of(context).primaryColor,
|
||||
receivedMessageCaptionTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
receivedMessageBodyTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
receivedMessageLinkTitleTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
receivedMessageBodyLinkTextStyle: const TextStyle(
|
||||
color: Colors.lightBlue,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
sentMessageBodyTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
sentMessageBodyLinkTextStyle: const TextStyle(
|
||||
color: Colors.blue,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
inputElevation: 2,
|
||||
);
|
||||
}
|
||||
|
||||
void onSendPressed(BuildContext context, String message) {
|
||||
context.read<ChatBloc>().add(ChatEvent.sendMessage(message));
|
||||
}
|
||||
AFDefaultChatTheme buildTheme(BuildContext context) {
|
||||
return AFDefaultChatTheme(
|
||||
backgroundColor: AFThemeExtension.of(context).background,
|
||||
primaryColor: Theme.of(context).colorScheme.primary,
|
||||
secondaryColor: AFThemeExtension.of(context).tint1,
|
||||
receivedMessageDocumentIconColor: Theme.of(context).primaryColor,
|
||||
receivedMessageCaptionTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
receivedMessageBodyTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
receivedMessageLinkTitleTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
receivedMessageBodyLinkTextStyle: const TextStyle(
|
||||
color: Colors.lightBlue,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
sentMessageBodyTextStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
sentMessageBodyLinkTextStyle: const TextStyle(
|
||||
color: Colors.blue,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
inputElevation: 2,
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
const defaultAvatarSize = 30.0;
|
||||
|
||||
class ChatChatUserAvatar extends StatelessWidget {
|
||||
const ChatChatUserAvatar({required this.userId, super.key});
|
||||
|
||||
@@ -33,15 +35,18 @@ class ChatBorderedCircleAvatar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CircleAvatar(
|
||||
backgroundColor: border.color,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints.expand(),
|
||||
child: CircleAvatar(
|
||||
backgroundImage: backgroundImage,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: child,
|
||||
return SizedBox(
|
||||
width: defaultAvatarSize,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: border.color,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints.expand(),
|
||||
child: CircleAvatar(
|
||||
backgroundImage: backgroundImage,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -53,13 +58,15 @@ class ChatUserAvatar extends StatelessWidget {
|
||||
super.key,
|
||||
required this.iconUrl,
|
||||
required this.name,
|
||||
required this.size,
|
||||
this.size = defaultAvatarSize,
|
||||
this.isHovering = false,
|
||||
this.defaultName,
|
||||
});
|
||||
|
||||
final String iconUrl;
|
||||
final String name;
|
||||
final double size;
|
||||
final String? defaultName;
|
||||
|
||||
// If true, a border will be applied on top of the avatar
|
||||
final bool isHovering;
|
||||
@@ -76,7 +83,8 @@ class ChatUserAvatar extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildEmptyAvatar(BuildContext context) {
|
||||
final String nameOrDefault = _userName(name);
|
||||
final String nameOrDefault = _userName(name, defaultName);
|
||||
|
||||
final Color color = ColorGenerator(name).toColor();
|
||||
const initialsCount = 2;
|
||||
|
||||
@@ -170,8 +178,8 @@ class ChatUserAvatar extends StatelessWidget {
|
||||
/// Return the user name, if the user name is empty,
|
||||
/// return the default user name.
|
||||
///
|
||||
String _userName(String name) =>
|
||||
name.isEmpty ? LocaleKeys.defaultUsername.tr() : name;
|
||||
String _userName(String name, String? defaultName) =>
|
||||
name.isEmpty ? (defaultName ?? LocaleKeys.defaultUsername.tr()) : name;
|
||||
|
||||
/// Used to darken the generated color for the hover border effect.
|
||||
/// The color is darkened by 15% - Hence the 0.15 value.
|
||||
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class ChatActionMenuItem {
|
||||
String get title;
|
||||
}
|
||||
|
||||
abstract class ChatActionHandler {
|
||||
List<ChatActionMenuItem> get items;
|
||||
void onEnter();
|
||||
void onSelected(ChatActionMenuItem item);
|
||||
void onExit();
|
||||
}
|
||||
|
||||
abstract class ChatAnchor {
|
||||
GlobalKey get anchorKey;
|
||||
LayerLink get layerLink;
|
||||
}
|
||||
|
||||
const int _itemHeight = 34;
|
||||
const int _itemVerticalPadding = 4;
|
||||
|
||||
class ChatActionsMenu {
|
||||
ChatActionsMenu({
|
||||
required this.anchor,
|
||||
required this.context,
|
||||
required this.handler,
|
||||
required this.style,
|
||||
});
|
||||
|
||||
final BuildContext context;
|
||||
final ChatAnchor anchor;
|
||||
final ChatActionsMenuStyle style;
|
||||
final ChatActionHandler handler;
|
||||
|
||||
OverlayEntry? _overlayEntry;
|
||||
|
||||
void dismiss() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
handler.onExit();
|
||||
}
|
||||
|
||||
void show() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
|
||||
}
|
||||
|
||||
void _show() {
|
||||
if (_overlayEntry != null) {
|
||||
dismiss();
|
||||
}
|
||||
|
||||
if (anchor.anchorKey.currentContext == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler.onEnter();
|
||||
|
||||
final height = handler.items.length * (_itemHeight + _itemVerticalPadding);
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Stack(
|
||||
children: [
|
||||
CompositedTransformFollower(
|
||||
link: anchor.layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: Offset(0, -height - 4),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 200,
|
||||
maxWidth: 200,
|
||||
maxHeight: 200,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: ActionList(
|
||||
handler: handler,
|
||||
onDismiss: () => dismiss(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionItem extends StatelessWidget {
|
||||
const _ActionItem({
|
||||
required this.item,
|
||||
required this.onTap,
|
||||
required this.isSelected,
|
||||
});
|
||||
|
||||
final ChatActionMenuItem item;
|
||||
final VoidCallback? onTap;
|
||||
final bool isSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: _itemHeight.toDouble(),
|
||||
padding: const EdgeInsets.symmetric(vertical: _itemVerticalPadding / 2.0),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: FlowyButton(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
iconPadding: 10.0,
|
||||
text: FlowyText.regular(
|
||||
item.title,
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ActionList extends StatefulWidget {
|
||||
const ActionList({super.key, required this.handler, required this.onDismiss});
|
||||
|
||||
final ChatActionHandler handler;
|
||||
final VoidCallback? onDismiss;
|
||||
|
||||
@override
|
||||
State<ActionList> createState() => _ActionListState();
|
||||
}
|
||||
|
||||
class _ActionListState extends State<ActionList> {
|
||||
final FocusScopeNode _focusNode =
|
||||
FocusScopeNode(debugLabel: 'ChatActionsMenu');
|
||||
int _selectedIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleKeyPress(event) {
|
||||
setState(() {
|
||||
// ignore: deprecated_member_use
|
||||
if (event is KeyDownEvent || event is RawKeyDownEvent) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
_selectedIndex = (_selectedIndex + 1) % widget.handler.items.length;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
_selectedIndex = (_selectedIndex - 1 + widget.handler.items.length) %
|
||||
widget.handler.items.length;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
widget.handler.onSelected(widget.handler.items[_selectedIndex]);
|
||||
widget.onDismiss?.call();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
widget.onDismiss?.call();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusScope(
|
||||
node: _focusNode,
|
||||
onKey: (node, event) {
|
||||
_handleKeyPress(event);
|
||||
return KeyEventResult.handled;
|
||||
},
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: widget.handler.items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ChatActionMenuItem item = entry.value;
|
||||
return _ActionItem(
|
||||
item: item,
|
||||
onTap: () {
|
||||
widget.handler.onSelected(item);
|
||||
widget.onDismiss?.call();
|
||||
},
|
||||
isSelected: _selectedIndex == index,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatActionsMenuStyle {
|
||||
ChatActionsMenuStyle({
|
||||
required this.backgroundColor,
|
||||
required this.groupTextColor,
|
||||
required this.menuItemTextColor,
|
||||
required this.menuItemSelectedColor,
|
||||
required this.menuItemSelectedTextColor,
|
||||
});
|
||||
|
||||
const ChatActionsMenuStyle.light()
|
||||
: backgroundColor = Colors.white,
|
||||
groupTextColor = const Color(0xFF555555),
|
||||
menuItemTextColor = const Color(0xFF333333),
|
||||
menuItemSelectedColor = const Color(0xFFE0F8FF),
|
||||
menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247);
|
||||
|
||||
const ChatActionsMenuStyle.dark()
|
||||
: backgroundColor = const Color(0xFF282E3A),
|
||||
groupTextColor = const Color(0xFFBBC3CD),
|
||||
menuItemTextColor = const Color(0xFFBBC3CD),
|
||||
menuItemSelectedColor = const Color(0xFF00BCF0),
|
||||
menuItemSelectedTextColor = const Color(0xFF131720);
|
||||
|
||||
final Color backgroundColor;
|
||||
final Color groupTextColor;
|
||||
final Color menuItemTextColor;
|
||||
final Color menuItemSelectedColor;
|
||||
final Color menuItemSelectedTextColor;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.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:flutter/material.dart';
|
||||
|
||||
class ChatInputAtButton extends StatelessWidget {
|
||||
const ChatInputAtButton({required this.onTap, super.key});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_clickToMention.tr(),
|
||||
child: FlowyIconButton(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: BorderRadius.circular(6),
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.chat_at_s,
|
||||
size: const Size.square(20),
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_inline_action_menu.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatTextFieldInterceptor {
|
||||
String previosText = "";
|
||||
|
||||
ChatActionHandler? onTextChanged(
|
||||
String text,
|
||||
TextEditingController textController,
|
||||
FocusNode textFieldFocusNode,
|
||||
) {
|
||||
if (previosText == "/" && text == "/ ") {
|
||||
final handler = IndexActionHandler(
|
||||
textController: textController,
|
||||
textFieldFocusNode: textFieldFocusNode,
|
||||
) as ChatActionHandler;
|
||||
return handler;
|
||||
}
|
||||
previosText = text;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class FixGrammarMenuItem extends ChatActionMenuItem {
|
||||
@override
|
||||
String get title => "Fix Grammar";
|
||||
}
|
||||
|
||||
class ImproveWritingMenuItem extends ChatActionMenuItem {
|
||||
@override
|
||||
String get title => "Improve Writing";
|
||||
}
|
||||
|
||||
class ChatWithFileMenuItem extends ChatActionMenuItem {
|
||||
@override
|
||||
String get title => "Chat With PDF";
|
||||
}
|
||||
|
||||
class IndexActionHandler extends ChatActionHandler {
|
||||
IndexActionHandler({
|
||||
required this.textController,
|
||||
required this.textFieldFocusNode,
|
||||
});
|
||||
|
||||
final TextEditingController textController;
|
||||
final FocusNode textFieldFocusNode;
|
||||
|
||||
@override
|
||||
List<ChatActionMenuItem> get items => [
|
||||
ChatWithFileMenuItem(),
|
||||
FixGrammarMenuItem(),
|
||||
ImproveWritingMenuItem(),
|
||||
];
|
||||
|
||||
@override
|
||||
void onSelected(ChatActionMenuItem item) {
|
||||
textController.clear();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => textFieldFocusNode.requestFocus(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onExit() {
|
||||
if (!textFieldFocusNode.hasFocus) {
|
||||
textFieldFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onEnter() {
|
||||
if (textFieldFocusNode.hasFocus) {
|
||||
textFieldFocusNode.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
+320
-112
@@ -1,18 +1,34 @@
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_inline_action_menu.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_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';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:extended_text_field/extended_text_field.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
import 'package:flowy_infra/platform_extension.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flutter/foundation.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_ui/flutter_chat_ui.dart';
|
||||
|
||||
import 'chat_accessory_button.dart';
|
||||
import 'chat_at_button.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.
|
||||
const ChatInput({
|
||||
super.key,
|
||||
this.isAttachmentUploading,
|
||||
this.onAttachmentPressed,
|
||||
required this.onSendPressed,
|
||||
required this.chatId,
|
||||
@@ -20,9 +36,9 @@ class ChatInput extends StatefulWidget {
|
||||
required this.isStreaming,
|
||||
required this.onStopStreaming,
|
||||
required this.hintText,
|
||||
required this.aiType,
|
||||
});
|
||||
|
||||
final bool? isAttachmentUploading;
|
||||
final VoidCallback? onAttachmentPressed;
|
||||
final void Function(types.PartialText) onSendPressed;
|
||||
final void Function() onStopStreaming;
|
||||
@@ -30,6 +46,7 @@ class ChatInput extends StatefulWidget {
|
||||
final String chatId;
|
||||
final bool isStreaming;
|
||||
final String hintText;
|
||||
final AIType aiType;
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
@@ -39,41 +56,45 @@ class ChatInput extends StatefulWidget {
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
final GlobalKey _textFieldKey = GlobalKey();
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
// final ChatTextFieldInterceptor _textFieldInterceptor =
|
||||
// ChatTextFieldInterceptor();
|
||||
|
||||
late final _inputFocusNode = FocusNode(
|
||||
onKeyEvent: (node, event) {
|
||||
if (event.physicalKey == PhysicalKeyboardKey.enter &&
|
||||
!HardwareKeyboard.instance.physicalKeysPressed.any(
|
||||
(el) => <PhysicalKeyboardKey>{
|
||||
PhysicalKeyboardKey.shiftLeft,
|
||||
PhysicalKeyboardKey.shiftRight,
|
||||
}.contains(el),
|
||||
)) {
|
||||
if (kIsWeb && _textController.value.isComposingRangeValid) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (event is KeyDownEvent) {
|
||||
if (!widget.isStreaming) {
|
||||
_handleSendPressed();
|
||||
}
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
},
|
||||
);
|
||||
late ChatInputActionControl _inputActionControl;
|
||||
late FocusNode _inputFocusNode;
|
||||
late TextEditingController _textController;
|
||||
|
||||
bool _sendButtonVisible = false;
|
||||
bool _sendButtonEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_textController = InputTextFieldController();
|
||||
_inputFocusNode = FocusNode(
|
||||
onKeyEvent: (node, event) {
|
||||
if (PlatformExtension.isDesktop) {
|
||||
if (_inputActionControl.canHandleKeyEvent(event)) {
|
||||
_inputActionControl.handleKeyEvent(event);
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
return _handleEnterKeyWithoutShift(
|
||||
event,
|
||||
_textController,
|
||||
widget.isStreaming,
|
||||
_handleSendPressed,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
_inputFocusNode.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
_inputActionControl = ChatInputActionControl(
|
||||
chatId: widget.chatId,
|
||||
textController: _textController,
|
||||
textFieldFocusNode: _inputFocusNode,
|
||||
);
|
||||
_inputFocusNode.requestFocus();
|
||||
_handleSendButtonVisibilityModeChange();
|
||||
}
|
||||
|
||||
@@ -81,35 +102,75 @@ class _ChatInputState extends State<ChatInput> {
|
||||
void dispose() {
|
||||
_inputFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
_inputActionControl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const textPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
|
||||
const buttonPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
|
||||
const inputPadding = EdgeInsets.all(6);
|
||||
|
||||
return Focus(
|
||||
child: Padding(
|
||||
padding: inputPadding,
|
||||
return Padding(
|
||||
padding: inputPadding,
|
||||
// ignore: use_decorated_box
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _inputFocusNode.hasFocus && !isMobile
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.6)
|
||||
: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
color: isMobile
|
||||
? Theme.of(context).colorScheme.surfaceContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
elevation: 0.6,
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.onAttachmentPressed != null)
|
||||
AttachmentButton(
|
||||
isLoading: widget.isAttachmentUploading ?? false,
|
||||
onPressed: widget.onAttachmentPressed,
|
||||
padding: buttonPadding,
|
||||
borderRadius: borderRadius,
|
||||
color: color,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
//
|
||||
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),
|
||||
],
|
||||
),
|
||||
Expanded(child: _inputTextField(textPadding)),
|
||||
_sendButton(buttonPadding),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -118,7 +179,7 @@ class _ChatInputState extends State<ChatInput> {
|
||||
|
||||
void _handleSendButtonVisibilityModeChange() {
|
||||
_textController.removeListener(_handleTextControllerChange);
|
||||
_sendButtonVisible =
|
||||
_sendButtonEnabled =
|
||||
_textController.text.trim() != '' || widget.isStreaming;
|
||||
_textController.addListener(_handleTextControllerChange);
|
||||
}
|
||||
@@ -126,9 +187,22 @@ class _ChatInputState extends State<ChatInput> {
|
||||
void _handleSendPressed() {
|
||||
final trimmedText = _textController.text.trim();
|
||||
if (trimmedText != '') {
|
||||
final partialText = types.PartialText(text: trimmedText);
|
||||
widget.onSendPressed(partialText);
|
||||
// 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: metadata,
|
||||
);
|
||||
widget.onSendPressed(partialText);
|
||||
_textController.clear();
|
||||
}
|
||||
}
|
||||
@@ -138,84 +212,183 @@ class _ChatInputState extends State<ChatInput> {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sendButtonVisible = _textController.text.trim() != '';
|
||||
_sendButtonEnabled = _textController.text.trim() != '';
|
||||
});
|
||||
}
|
||||
|
||||
Widget _inputTextField(EdgeInsets textPadding) {
|
||||
Widget _inputTextField(BuildContext context, EdgeInsets textPadding) {
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: Padding(
|
||||
padding: textPadding,
|
||||
child: TextField(
|
||||
child: ExtendedTextField(
|
||||
key: _textFieldKey,
|
||||
controller: _textController,
|
||||
focusNode: _inputFocusNode,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: widget.hintText,
|
||||
hintStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
decoration: _buildInputDecoration(context),
|
||||
keyboardType: TextInputType.multiline,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
maxLines: 10,
|
||||
minLines: 1,
|
||||
// onChanged: (text) {
|
||||
// final handler = _textFieldInterceptor.onTextChanged(
|
||||
// text,
|
||||
// _textController,
|
||||
// _inputFocusNode,
|
||||
// );
|
||||
// // If the handler is not null, it means that the text has been
|
||||
// // recognized as a command.
|
||||
// if (handler != null) {
|
||||
// ChatActionsMenu(
|
||||
// anchor: ChatInputAnchor(
|
||||
// anchorKey: _textFieldKey,
|
||||
// layerLink: _layerLink,
|
||||
// ),
|
||||
// handler: handler,
|
||||
// context: context,
|
||||
// style: Theme.of(context).brightness == Brightness.dark
|
||||
// ? const ChatActionsMenuStyle.dark()
|
||||
// : const ChatActionsMenuStyle.light(),
|
||||
// ).show();
|
||||
// }
|
||||
// },
|
||||
maxLines: 10,
|
||||
style: _buildTextStyle(context),
|
||||
specialTextSpanBuilder: ChatInputTextSpanBuilder(
|
||||
inputActionControl: _inputActionControl,
|
||||
),
|
||||
onChanged: (text) {
|
||||
_handleOnTextChange(context, text);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ConstrainedBox _sendButton(EdgeInsets buttonPadding) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: buttonPadding.bottom + buttonPadding.top + 24,
|
||||
InputDecoration _buildInputDecoration(BuildContext context) {
|
||||
return InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: widget.hintText,
|
||||
focusedBorder: InputBorder.none,
|
||||
hintStyle: TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor.withOpacity(0.5),
|
||||
),
|
||||
child: Visibility(
|
||||
visible: _sendButtonVisible,
|
||||
child: Padding(
|
||||
padding: buttonPadding,
|
||||
child: ChatInputAccessoryButton(
|
||||
onSendPressed: () {
|
||||
if (!widget.isStreaming) {
|
||||
widget.onStopStreaming();
|
||||
_handleSendPressed();
|
||||
}
|
||||
},
|
||||
onStopStreaming: () => widget.onStopStreaming(),
|
||||
isStreaming: widget.isStreaming,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle? _buildTextStyle(BuildContext context) {
|
||||
if (!isMobile) {
|
||||
return TextStyle(
|
||||
color: AFThemeExtension.of(context).textColor,
|
||||
fontSize: 15,
|
||||
);
|
||||
}
|
||||
|
||||
return Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 15,
|
||||
height: 1.2,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleOnTextChange(BuildContext context, String text) async {
|
||||
if (!_inputActionControl.onTextChanged(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (PlatformExtension.isDesktop) {
|
||||
ChatActionsMenu(
|
||||
anchor: ChatInputAnchor(
|
||||
anchorKey: _textFieldKey,
|
||||
layerLink: _layerLink,
|
||||
),
|
||||
handler: _inputActionControl,
|
||||
context: context,
|
||||
style: Theme.of(context).brightness == Brightness.dark
|
||||
? const ChatActionsMenuStyle.dark()
|
||||
: const ChatActionsMenuStyle.light(),
|
||||
).show();
|
||||
} else {
|
||||
// if the focus node is on focus, unfocus it for better animation
|
||||
// otherwise, the page sheet animation will be blocked by the keyboard
|
||||
if (_inputFocusNode.hasFocus) {
|
||||
_inputFocusNode.unfocus();
|
||||
Future.delayed(const Duration(milliseconds: 100), () async {
|
||||
await _referPage(_inputActionControl);
|
||||
});
|
||||
} else {
|
||||
await _referPage(_inputActionControl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _sendButton(EdgeInsets buttonPadding) {
|
||||
return Padding(
|
||||
padding: buttonPadding,
|
||||
child: SizedBox.square(
|
||||
dimension: sendButtonSize,
|
||||
child: ChatInputSendButton(
|
||||
onSendPressed: () {
|
||||
if (!_sendButtonEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!widget.isStreaming) {
|
||||
widget.onStopStreaming();
|
||||
_handleSendPressed();
|
||||
}
|
||||
},
|
||||
onStopStreaming: () => widget.onStopStreaming(),
|
||||
isStreaming: widget.isStreaming,
|
||||
enabled: _sendButtonEnabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _attachmentButton(EdgeInsets buttonPadding) {
|
||||
return Padding(
|
||||
padding: buttonPadding,
|
||||
child: SizedBox.square(
|
||||
dimension: attachButtonSize,
|
||||
child: ChatInputAttachment(
|
||||
onTap: () async {
|
||||
final path = await getIt<FilePickerService>().pickFiles(
|
||||
dialogTitle: '',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ["pdf"],
|
||||
);
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final file in path.files) {
|
||||
if (file.path != null) {
|
||||
if (mounted) {
|
||||
context
|
||||
.read<ChatFileBloc>()
|
||||
.add(ChatFileEvent.newFile(file.path!, file.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mentionButton(EdgeInsets buttonPadding) {
|
||||
return Padding(
|
||||
padding: buttonPadding,
|
||||
child: SizedBox.square(
|
||||
dimension: attachButtonSize,
|
||||
child: ChatInputAtButton(
|
||||
onTap: () {
|
||||
_textController.text += '@';
|
||||
if (!isMobile) {
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
_handleOnTextChange(context, _textController.text);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _referPage(ChatActionHandler handler) async {
|
||||
handler.onEnter();
|
||||
final selectedView = await showPageSelectorSheet(
|
||||
context,
|
||||
filter: (view) =>
|
||||
view.layout.isDocumentView &&
|
||||
!view.isSpace &&
|
||||
view.parentViewId.isNotEmpty,
|
||||
);
|
||||
if (selectedView == null) {
|
||||
handler.onExit();
|
||||
return;
|
||||
}
|
||||
handler.onSelected(ViewActionPage(view: selectedView));
|
||||
handler.onExit();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ChatInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -238,3 +411,38 @@ class ChatInputAnchor extends ChatAnchor {
|
||||
@override
|
||||
final LayerLink layerLink;
|
||||
}
|
||||
|
||||
/// Handles the key press event for the Enter key without Shift.
|
||||
///
|
||||
/// This function checks if the Enter key is pressed without either of the Shift keys.
|
||||
/// If the conditions are met, it performs the action of sending a message if the
|
||||
/// text controller is not in a composing range and if the event is a key down event.
|
||||
///
|
||||
/// - Returns: A `KeyEventResult` indicating whether the key event was handled or ignored.
|
||||
KeyEventResult _handleEnterKeyWithoutShift(
|
||||
KeyEvent event,
|
||||
TextEditingController textController,
|
||||
bool isStreaming,
|
||||
void Function() handleSendPressed,
|
||||
) {
|
||||
if (event.physicalKey == PhysicalKeyboardKey.enter &&
|
||||
!HardwareKeyboard.instance.physicalKeysPressed.any(
|
||||
(el) => <PhysicalKeyboardKey>{
|
||||
PhysicalKeyboardKey.shiftLeft,
|
||||
PhysicalKeyboardKey.shiftRight,
|
||||
}.contains(el),
|
||||
)) {
|
||||
if (textController.value.isComposingRangeValid) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
if (event is KeyDownEvent) {
|
||||
if (!isStreaming) {
|
||||
handleSendPressed();
|
||||
}
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.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';
|
||||
|
||||
class ChatInputAttachment extends StatelessWidget {
|
||||
const ChatInputAttachment({required this.onTap, super.key});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_uploadFile.tr(),
|
||||
child: FlowyIconButton(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: BorderRadius.circular(6),
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.ai_attachment_s,
|
||||
size: const Size.square(20),
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+130
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import 'package:extended_text_library/extended_text_library.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../application/chat_input_action_control.dart';
|
||||
|
||||
class ChatInputTextSpanBuilder extends SpecialTextSpanBuilder {
|
||||
ChatInputTextSpanBuilder({
|
||||
required this.inputActionControl,
|
||||
});
|
||||
|
||||
final ChatInputActionControl inputActionControl;
|
||||
|
||||
@override
|
||||
SpecialText? createSpecialText(
|
||||
String flag, {
|
||||
TextStyle? textStyle,
|
||||
SpecialTextGestureTapCallback? onTap,
|
||||
int? index,
|
||||
}) {
|
||||
if (flag == '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
//index is end index of start flag, so text start index should be index-(flag.length-1)
|
||||
if (isStart(flag, AtText.flag)) {
|
||||
return AtText(
|
||||
inputActionControl,
|
||||
textStyle,
|
||||
onTap,
|
||||
start: index! - (AtText.flag.length - 1),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class AtText extends SpecialText {
|
||||
AtText(
|
||||
this.inputActionControl,
|
||||
TextStyle? textStyle,
|
||||
SpecialTextGestureTapCallback? onTap, {
|
||||
this.start,
|
||||
}) : super(flag, '', textStyle, onTap: onTap);
|
||||
static const String flag = '@';
|
||||
final int? start;
|
||||
final ChatInputActionControl inputActionControl;
|
||||
|
||||
@override
|
||||
bool isEnd(String value) {
|
||||
return inputActionControl.tags.contains(value);
|
||||
}
|
||||
|
||||
@override
|
||||
InlineSpan finishText() {
|
||||
final TextStyle? textStyle =
|
||||
this.textStyle?.copyWith(color: Colors.blue, fontSize: 15.0);
|
||||
|
||||
final String atText = toString();
|
||||
|
||||
return SpecialTextSpan(
|
||||
text: atText,
|
||||
actualText: atText,
|
||||
start: start!,
|
||||
style: textStyle,
|
||||
recognizer: (TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
if (onTap != null) {
|
||||
onTap!(atText);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
-7
@@ -3,26 +3,27 @@ import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatInputAccessoryButton extends StatelessWidget {
|
||||
const ChatInputAccessoryButton({
|
||||
class ChatInputSendButton extends StatelessWidget {
|
||||
const ChatInputSendButton({
|
||||
required this.onSendPressed,
|
||||
required this.onStopStreaming,
|
||||
required this.isStreaming,
|
||||
required this.enabled,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final void Function() onSendPressed;
|
||||
final void Function() onStopStreaming;
|
||||
final bool isStreaming;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isStreaming) {
|
||||
return FlowyIconButton(
|
||||
width: 36,
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.ai_stream_stop_s,
|
||||
size: const Size.square(28),
|
||||
size: const Size.square(20),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed: onStopStreaming,
|
||||
@@ -32,14 +33,15 @@ class ChatInputAccessoryButton extends StatelessWidget {
|
||||
);
|
||||
} else {
|
||||
return FlowyIconButton(
|
||||
width: 36,
|
||||
fillColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: BorderRadius.circular(18),
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.send_s,
|
||||
size: const Size.square(24),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: const Size.square(14),
|
||||
color: enabled
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
onPressed: onSendPressed,
|
||||
);
|
||||
+13
@@ -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;
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.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:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
|
||||
abstract class ChatActionHandler {
|
||||
void onEnter();
|
||||
void onSelected(ChatInputMention page);
|
||||
void onExit();
|
||||
ChatInputActionBloc get commandBloc;
|
||||
void onFilter(String filter);
|
||||
double actionMenuOffsetX();
|
||||
}
|
||||
|
||||
abstract class ChatAnchor {
|
||||
GlobalKey get anchorKey;
|
||||
LayerLink get layerLink;
|
||||
}
|
||||
|
||||
const int _itemHeight = 34;
|
||||
const int _itemVerticalPadding = 4;
|
||||
const int _noPageHeight = 20;
|
||||
|
||||
class ChatActionsMenu {
|
||||
ChatActionsMenu({
|
||||
required this.anchor,
|
||||
required this.context,
|
||||
required this.handler,
|
||||
required this.style,
|
||||
});
|
||||
|
||||
final BuildContext context;
|
||||
final ChatAnchor anchor;
|
||||
final ChatActionsMenuStyle style;
|
||||
final ChatActionHandler handler;
|
||||
|
||||
OverlayEntry? _overlayEntry;
|
||||
|
||||
void dismiss() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
handler.onExit();
|
||||
}
|
||||
|
||||
void show() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
|
||||
}
|
||||
|
||||
void _show() {
|
||||
if (_overlayEntry != null) {
|
||||
dismiss();
|
||||
}
|
||||
|
||||
if (anchor.anchorKey.currentContext == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler.onEnter();
|
||||
const double maxHeight = 300;
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: handler.commandBloc,
|
||||
child: BlocBuilder<ChatInputActionBloc, ChatInputActionState>(
|
||||
builder: (context, state) {
|
||||
final height = min(
|
||||
max(
|
||||
state.pages.length * (_itemHeight + _itemVerticalPadding),
|
||||
_noPageHeight,
|
||||
),
|
||||
maxHeight,
|
||||
);
|
||||
final isLoading =
|
||||
state.indicator == const ChatActionMenuIndicator.loading();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
CompositedTransformFollower(
|
||||
link: anchor.layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: Offset(handler.actionMenuOffsetX(), -height - 4),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 200,
|
||||
maxWidth: 200,
|
||||
maxHeight: maxHeight,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 2,
|
||||
vertical: 2,
|
||||
),
|
||||
child: ActionList(
|
||||
isLoading: isLoading,
|
||||
handler: handler,
|
||||
onDismiss: () => dismiss(),
|
||||
pages: state.pages,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionItem extends StatelessWidget {
|
||||
const _ActionItem({
|
||||
required this.item,
|
||||
required this.onTap,
|
||||
required this.isSelected,
|
||||
});
|
||||
|
||||
final ChatInputMention item;
|
||||
final VoidCallback? onTap;
|
||||
final bool isSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: _itemHeight.toDouble(),
|
||||
padding: const EdgeInsets.symmetric(vertical: _itemVerticalPadding / 2.0),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: FlowyButton(
|
||||
leftIcon: item.icon,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
iconPadding: 10.0,
|
||||
text: FlowyText.regular(
|
||||
lineHeight: 1.0,
|
||||
item.title,
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ActionList extends StatefulWidget {
|
||||
const ActionList({
|
||||
super.key,
|
||||
required this.handler,
|
||||
required this.onDismiss,
|
||||
required this.pages,
|
||||
required this.isLoading,
|
||||
});
|
||||
|
||||
final ChatActionHandler handler;
|
||||
final VoidCallback? onDismiss;
|
||||
final List<ChatInputMention> pages;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
State<ActionList> createState() => _ActionListState();
|
||||
}
|
||||
|
||||
class _ActionListState extends State<ActionList> {
|
||||
int _selectedIndex = 0;
|
||||
final _scrollController = AutoScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyPress(logicalKey) {
|
||||
bool isHandle = false;
|
||||
setState(() {
|
||||
if (logicalKey == PhysicalKeyboardKey.arrowDown) {
|
||||
_selectedIndex = (_selectedIndex + 1) % widget.pages.length;
|
||||
_scrollToSelectedIndex();
|
||||
isHandle = true;
|
||||
} else if (logicalKey == PhysicalKeyboardKey.arrowUp) {
|
||||
_selectedIndex =
|
||||
(_selectedIndex - 1 + widget.pages.length) % widget.pages.length;
|
||||
_scrollToSelectedIndex();
|
||||
isHandle = true;
|
||||
} else if (logicalKey == PhysicalKeyboardKey.enter) {
|
||||
widget.handler.onSelected(widget.pages[_selectedIndex]);
|
||||
widget.onDismiss?.call();
|
||||
isHandle = true;
|
||||
} else if (logicalKey == PhysicalKeyboardKey.escape) {
|
||||
widget.onDismiss?.call();
|
||||
isHandle = true;
|
||||
}
|
||||
});
|
||||
return isHandle ? KeyEventResult.handled : KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ChatInputActionBloc, ChatInputActionState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.keyboardKey != current.keyboardKey,
|
||||
listener: (context, state) {
|
||||
if (state.keyboardKey != null) {
|
||||
_handleKeyPress(state.keyboardKey!.physicalKey);
|
||||
}
|
||||
},
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(4),
|
||||
children: _buildPages(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildPages() {
|
||||
if (widget.isLoading) {
|
||||
return [
|
||||
SizedBox(
|
||||
height: _noPageHeight.toDouble(),
|
||||
child: const Center(child: CircularProgressIndicator.adaptive()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (widget.pages.isEmpty) {
|
||||
return [
|
||||
SizedBox(
|
||||
height: _noPageHeight.toDouble(),
|
||||
child:
|
||||
Center(child: FlowyText(LocaleKeys.chat_inputActionNoPages.tr())),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return widget.pages.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ChatInputMention item = entry.value;
|
||||
return AutoScrollTag(
|
||||
key: ValueKey(item.pageId),
|
||||
index: index,
|
||||
controller: _scrollController,
|
||||
child: _ActionItem(
|
||||
item: item,
|
||||
onTap: () {
|
||||
widget.handler.onSelected(item);
|
||||
widget.onDismiss?.call();
|
||||
},
|
||||
isSelected: _selectedIndex == index,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _scrollToSelectedIndex() {
|
||||
_scrollController.scrollToIndex(
|
||||
_selectedIndex,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
preferPosition: AutoScrollPosition.begin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatActionsMenuStyle {
|
||||
ChatActionsMenuStyle({
|
||||
required this.backgroundColor,
|
||||
required this.groupTextColor,
|
||||
required this.menuItemTextColor,
|
||||
required this.menuItemSelectedColor,
|
||||
required this.menuItemSelectedTextColor,
|
||||
});
|
||||
|
||||
const ChatActionsMenuStyle.light()
|
||||
: backgroundColor = Colors.white,
|
||||
groupTextColor = const Color(0xFF555555),
|
||||
menuItemTextColor = const Color(0xFF333333),
|
||||
menuItemSelectedColor = const Color(0xFFE0F8FF),
|
||||
menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247);
|
||||
|
||||
const ChatActionsMenuStyle.dark()
|
||||
: backgroundColor = const Color(0xFF282E3A),
|
||||
groupTextColor = const Color(0xFFBBC3CD),
|
||||
menuItemTextColor = const Color(0xFFBBC3CD),
|
||||
menuItemSelectedColor = const Color(0xFF00BCF0),
|
||||
menuItemSelectedTextColor = const Color(0xFF131720);
|
||||
|
||||
final Color backgroundColor;
|
||||
final Color groupTextColor;
|
||||
final Color menuItemTextColor;
|
||||
final Color menuItemSelectedColor;
|
||||
final Color menuItemSelectedTextColor;
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_side_pannel_bloc.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ChatSidePannel extends StatelessWidget {
|
||||
const ChatSidePannel({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatSidePannelBloc, ChatSidePannelState>(
|
||||
builder: (context, state) {
|
||||
return state.indicator.when(
|
||||
loading: () {
|
||||
return const CircularProgressIndicator.adaptive();
|
||||
},
|
||||
ready: (view) {
|
||||
final plugin = view.plugin();
|
||||
plugin.init();
|
||||
|
||||
final pluginContext = PluginContext();
|
||||
final child = plugin.widgetBuilder
|
||||
.buildWidget(context: pluginContext, shrinkWrap: false);
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FlowyIconButton(
|
||||
icon: const FlowySvg(FlowySvgs.show_menu_s),
|
||||
onPressed: () {
|
||||
context
|
||||
.read<ChatSidePannelBloc>()
|
||||
.add(const ChatSidePannelEvent.close());
|
||||
},
|
||||
),
|
||||
),
|
||||
const VSpace(6),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
+1
@@ -124,6 +124,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
editorScrollController: scrollController,
|
||||
blockComponentBuilders: blockBuilders,
|
||||
commandShortcutEvents: [customCopyCommand],
|
||||
disableAutoScroll: true,
|
||||
editorState: editorState,
|
||||
),
|
||||
);
|
||||
|
||||
+7
-7
@@ -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';
|
||||
@@ -49,8 +49,9 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
const ChatBorderedCircleAvatar(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.flowy_ai_chat_logo_s,
|
||||
size: Size.square(24),
|
||||
FlowySvgs.flowy_logo_s,
|
||||
size: Size.square(20),
|
||||
blendMode: null,
|
||||
),
|
||||
),
|
||||
Expanded(child: widget),
|
||||
@@ -177,10 +178,9 @@ class CopyButton extends StatelessWidget {
|
||||
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,
|
||||
icon: const FlowySvg(
|
||||
FlowySvgs.copy_s,
|
||||
size: Size.square(20),
|
||||
),
|
||||
onPressed: () async {
|
||||
final document = customMarkdownToDocument(textMessage.text);
|
||||
@@ -0,0 +1,72 @@
|
||||
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_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';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AIMessageMetadata extends StatelessWidget {
|
||||
const AIMessageMetadata({
|
||||
required this.sources,
|
||||
required this.onSelectedMetadata,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<ChatMessageRefSource> sources;
|
||||
final Function(ChatMessageRefSource metadata) onSelectedMetadata;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = sources.length == 1
|
||||
? LocaleKeys.chat_referenceSource.tr(args: [sources.length.toString()])
|
||||
: LocaleKeys.chat_referenceSources
|
||||
.tr(args: [sources.length.toString()]);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (sources.isNotEmpty)
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: FlowyText(title, fontSize: 12),
|
||||
),
|
||||
const VSpace(6),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
children: sources
|
||||
.map(
|
||||
(m) => SizedBox(
|
||||
height: 24,
|
||||
child: FlowyButton(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 4,
|
||||
),
|
||||
useIntrinsicWidth: true,
|
||||
radius: BorderRadius.circular(6),
|
||||
text: Opacity(
|
||||
opacity: 0.5,
|
||||
child: FlowyText(
|
||||
m.name,
|
||||
fontSize: 14,
|
||||
lineHeight: 1.0,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
disable: m.source != appflowySoruce,
|
||||
onTap: () {
|
||||
if (m.source != appflowySoruce) {
|
||||
return;
|
||||
}
|
||||
onSelectedMetadata(m);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+26
-7
@@ -1,5 +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_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';
|
||||
@@ -11,27 +12,36 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
|
||||
class ChatAITextMessageWidget extends StatelessWidget {
|
||||
const ChatAITextMessageWidget({
|
||||
import 'ai_metadata.dart';
|
||||
|
||||
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.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? refSourceJsonString;
|
||||
final void Function(ChatMessageRefSource metadata) onSelectedMetadata;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => ChatAIMessageBloc(
|
||||
message: text,
|
||||
message: message,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
chatId: chatId,
|
||||
questionId: questionId,
|
||||
)..add(const ChatAIMessageEvent.initial()),
|
||||
@@ -50,15 +60,24 @@ class ChatAITextMessageWidget extends StatelessWidget {
|
||||
onAIResponseLimit: () {
|
||||
return FlowyText(
|
||||
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
|
||||
maxLines: 10,
|
||||
lineHeight: 1.5,
|
||||
maxLines: 10,
|
||||
);
|
||||
},
|
||||
ready: () {
|
||||
if (state.text.isEmpty) {
|
||||
return const ChatAILoading();
|
||||
} else {
|
||||
return AIMarkdownText(markdown: state.text);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AIMarkdownText(markdown: state.text),
|
||||
AIMessageMetadata(
|
||||
sources: state.sources,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
loading: () {
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
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_member_bloc.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';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.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:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
const _leftPadding = 16.0;
|
||||
|
||||
class OtherUserMessageBubble extends StatelessWidget {
|
||||
const OtherUserMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const padding = EdgeInsets.symmetric(horizontal: _leftPadding);
|
||||
final childWithPadding = Padding(padding: padding, child: child);
|
||||
final widget = isMobile
|
||||
? _wrapPopMenu(childWithPadding)
|
||||
: _wrapHover(childWithPadding);
|
||||
|
||||
if (context.read<ChatMemberBloc>().state.members[message.author.id] ==
|
||||
null) {
|
||||
context
|
||||
.read<ChatMemberBloc>()
|
||||
.add(ChatMemberEvent.getMemberInfo(message.author.id));
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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 ChatUserAvatar(
|
||||
iconUrl: member?.info.avatarUrl ?? "",
|
||||
name: member?.info.name ?? "",
|
||||
defaultName: "",
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(child: widget),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
OtherUserMessageHover _wrapHover(Padding child) {
|
||||
return OtherUserMessageHover(
|
||||
message: message,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
ChatPopupMenu _wrapPopMenu(Padding childWithPadding) {
|
||||
return ChatPopupMenu(
|
||||
onAction: (action) {
|
||||
if (action == ChatMessageAction.copy && message is TextMessage) {
|
||||
Clipboard.setData(ClipboardData(text: (message as TextMessage).text));
|
||||
showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
|
||||
}
|
||||
},
|
||||
builder: (context) => childWithPadding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OtherUserMessageHover extends StatefulWidget {
|
||||
const OtherUserMessageHover({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final bool autoShowHover = true;
|
||||
|
||||
@override
|
||||
State<OtherUserMessageHover> createState() => _OtherUserMessageHoverState();
|
||||
}
|
||||
|
||||
class _OtherUserMessageHoverState extends State<OtherUserMessageHover> {
|
||||
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) {
|
||||
children.addAll(_buildOnHoverItems());
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildOnHoverItems() {
|
||||
final List<Widget> children = [];
|
||||
if (widget.message is TextMessage) {
|
||||
children.add(
|
||||
CopyButton(
|
||||
textMessage: widget.message as TextMessage,
|
||||
).positioned(left: _leftPadding, bottom: 0),
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
class CopyButton extends StatelessWidget {
|
||||
const CopyButton({
|
||||
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: () async {
|
||||
final document = customMarkdownToDocument(textMessage.text);
|
||||
await getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(
|
||||
plainText: textMessage.text,
|
||||
inAppJson: jsonEncode(document.toJson()),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copiedNotification.tr(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
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: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';
|
||||
|
||||
class ChatUserMessageBubble extends StatelessWidget {
|
||||
const ChatUserMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const borderRadius = BorderRadius.all(Radius.circular(6));
|
||||
final backgroundColor =
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
if (context.read<ChatMemberBloc>().state.members[message.author.id] ==
|
||||
null) {
|
||||
context
|
||||
.read<ChatMemberBloc>()
|
||||
.add(ChatMemberEvent.getMemberInfo(message.author.id));
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
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 ?? "",
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
-21
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_user_message_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: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({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const borderRadius = BorderRadius.all(Radius.circular(6));
|
||||
final backgroundColor =
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) => ChatUserMessageBloc(message: message)
|
||||
..add(const ChatUserMessageEvent.initial()),
|
||||
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
|
||||
builder: (context, state) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// _wrapHover(
|
||||
Flexible(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
color: backgroundColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
// ),
|
||||
BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
|
||||
builder: (context, state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ChatUserAvatar(
|
||||
iconUrl: state.member?.avatarUrl ?? "",
|
||||
name: state.member?.name ?? "",
|
||||
size: 36,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,10 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker_header.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_search_bar.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/emoji_search_bar.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
|
||||
|
||||
// use a global value to store the selected emoji to prevent reloading every time.
|
||||
@@ -65,36 +64,43 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
|
||||
perLine: widget.emojiPerLine,
|
||||
),
|
||||
onEmojiSelected: widget.onEmojiSelected,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
headerBuilder: (context, category) {
|
||||
return FlowyEmojiHeader(
|
||||
category: category,
|
||||
);
|
||||
},
|
||||
itemBuilder: (context, emojiId, emoji, callback) {
|
||||
return SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
final name = emojiData?.emojis[emojiId]?.name ?? '';
|
||||
return SizedBox.square(
|
||||
dimension: 36.0,
|
||||
child: FlowyButton(
|
||||
margin: EdgeInsets.zero,
|
||||
radius: Corners.s8Border,
|
||||
text: FlowyText.emoji(
|
||||
emoji,
|
||||
fontSize: 24.0,
|
||||
text: FlowyTooltip(
|
||||
message: name,
|
||||
child: FlowyText.emoji(
|
||||
emoji,
|
||||
fontSize: 24.0,
|
||||
),
|
||||
),
|
||||
onTap: () => callback(emojiId, emoji),
|
||||
),
|
||||
);
|
||||
},
|
||||
searchBarBuilder: (context, keyword, skinTone) {
|
||||
return FlowyEmojiSearchBar(
|
||||
emojiData: emojiData!,
|
||||
onKeywordChanged: (value) {
|
||||
keyword.value = value;
|
||||
},
|
||||
onSkinToneChanged: (value) {
|
||||
skinTone.value = value;
|
||||
},
|
||||
onRandomEmojiSelected: widget.onEmojiSelected,
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: FlowyEmojiSearchBar(
|
||||
emojiData: emojiData!,
|
||||
onKeywordChanged: (value) {
|
||||
keyword.value = value;
|
||||
},
|
||||
onSkinToneChanged: (value) {
|
||||
skinTone.value = value;
|
||||
},
|
||||
onRandomEmojiSelected: widget.onEmojiSelected,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -20,7 +21,7 @@ class FlowyEmojiHeader extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText.regular(
|
||||
category.id,
|
||||
category.id.capitalize(),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker_page.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/icon.pbenum.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension ToProto on FlowyIconType {
|
||||
ViewIconTypePB toProto() {
|
||||
switch (this) {
|
||||
case FlowyIconType.emoji:
|
||||
return ViewIconTypePB.Emoji;
|
||||
case FlowyIconType.icon:
|
||||
return ViewIconTypePB.Icon;
|
||||
case FlowyIconType.custom:
|
||||
return ViewIconTypePB.Url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FlowyIconType {
|
||||
emoji,
|
||||
icon,
|
||||
custom;
|
||||
}
|
||||
|
||||
class EmojiPickerResult {
|
||||
factory EmojiPickerResult.none() =>
|
||||
const EmojiPickerResult(FlowyIconType.icon, '');
|
||||
|
||||
factory EmojiPickerResult.emoji(String emoji) =>
|
||||
EmojiPickerResult(FlowyIconType.emoji, emoji);
|
||||
|
||||
const EmojiPickerResult(
|
||||
this.type,
|
||||
this.emoji,
|
||||
);
|
||||
|
||||
final FlowyIconType type;
|
||||
final String emoji;
|
||||
}
|
||||
|
||||
class FlowyIconPicker extends StatelessWidget {
|
||||
const FlowyIconPicker({
|
||||
super.key,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final void Function(EmojiPickerResult result) onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const VSpace(8.0),
|
||||
Row(
|
||||
children: [
|
||||
FlowyText(LocaleKeys.newSettings_workplace_chooseAnIcon.tr()),
|
||||
const Spacer(),
|
||||
_RemoveIconButton(
|
||||
onTap: () => onSelected(EmojiPickerResult.none()),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VSpace(12.0),
|
||||
const Divider(height: 0.5),
|
||||
Expanded(
|
||||
child: FlowyEmojiPicker(
|
||||
emojiPerLine: _getEmojiPerLine(context),
|
||||
onEmojiSelected: (_, emoji) =>
|
||||
onSelected(EmojiPickerResult.emoji(emoji)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _getEmojiPerLine(BuildContext context) {
|
||||
if (PlatformExtension.isDesktopOrWeb) {
|
||||
return 9;
|
||||
}
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return width ~/ 40.0; // the size of the emoji
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoveIconButton extends StatelessWidget {
|
||||
const _RemoveIconButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 24,
|
||||
child: FlowyButton(
|
||||
onTap: onTap,
|
||||
useIntrinsicWidth: true,
|
||||
text: FlowyText.regular(
|
||||
LocaleKeys.button_remove.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class IconPickerPage extends StatelessWidget {
|
||||
const IconPickerPage({
|
||||
@@ -22,7 +21,7 @@ class IconPickerPage extends StatelessWidget {
|
||||
titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: FlowyIconPicker(onSelected: onSelected),
|
||||
child: FlowyIconEmojiPicker(onSelectedEmoji: onSelected),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Loaded 100 of 1644 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user