diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml index 2dc45f879a..86d5b7ef30 100644 --- a/.github/workflows/flutter_ci.yaml +++ b/.github/workflows/flutter_ci.yaml @@ -346,7 +346,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - test_number: [1, 2, 3, 4, 5, 6, 7, 8] + test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9] include: - os: ubuntu-latest target: "x86_64-unknown-linux-gnu" diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index d2d915f5b9..b91b783e6d 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -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.7.3" +APPFLOWY_VERSION = "0.7.4" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart index 594f13995f..f205b35354 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart @@ -1,6 +1,8 @@ import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.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'; @@ -38,6 +40,10 @@ void main() { await tester.changeWorkspaceIcon(icon); await tester.changeWorkspaceName(name); + await tester.pumpUntilNotFound( + find.text(LocaleKeys.workspace_renameSuccess.tr()), + ); + workspaceIcon = tester.widget( find.byType(WorkspaceIcon), ); diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/collaborative_workspace_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/collaborative_workspace_test.dart index accf9dfe0e..2de6fb8fa7 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/collaborative_workspace_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/collaborative_workspace_test.dart @@ -101,5 +101,52 @@ void main() { final memberCount = find.text('1 member'); expect(memberCount, findsNWidgets(2)); }); + + testWidgets('only display one menu item in the workspace menu', + (tester) async { + // only run the test when the feature flag is on + if (!FeatureFlag.collaborativeWorkspace.isOn) { + return; + } + + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const name = 'AppFlowy.IO'; + // the workspace will be opened after created + await tester.createCollaborativeWorkspace(name); + + final loading = find.byType(Loading); + await tester.pumpUntilNotFound(loading); + + await tester.openCollaborativeWorkspaceMenu(); + + // hover on the workspace and click the more button + final workspaceItem = find.byWidgetPredicate( + (w) => w is WorkspaceMenuItem && w.workspace.name == name, + ); + await tester.hoverOnWidget( + workspaceItem, + onHover: () async { + final moreButton = find.byWidgetPredicate( + (w) => w is WorkspaceMoreActionList && w.workspace.name == name, + ); + expect(moreButton, findsOneWidget); + await tester.tapButton(moreButton); + + // click it again + await tester.tapButton(moreButton); + + // nothing should happen + expect( + find.text(LocaleKeys.button_rename.tr()), + findsOneWidget, + ); + }, + ); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart new file mode 100644 index 0000000000..1603dc7937 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart @@ -0,0 +1,144 @@ +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.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:universal_platform/universal_platform.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + String generateRandomString(int len) { + final r = Random(); + return String.fromCharCodes( + List.generate(len, (index) => r.nextInt(33) + 89), + ); + } + + testWidgets( + 'document find menu test', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + // tap editor to get focus + await tester.tapButton(find.byType(AppFlowyEditor)); + + // set clipboard data + final data = [ + "123456\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n"), + "1234567\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n"), + "12345678\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n"), + ].join(); + await getIt().setData( + ClipboardServiceData( + plainText: data, + ), + ); + + // paste + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + // go back to beginning of document + // FIXME: Cannot run Ctrl+F unless selection is on screen + await tester.editor + .updateSelection(Selection.collapsed(Position(path: [0]))); + await tester.pumpAndSettle(); + + expect(find.byType(FindAndReplaceMenuWidget), findsNothing); + + // press cmd/ctrl+F to display the find menu + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyF, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget); + + final textField = find.descendant( + of: find.byType(FindAndReplaceMenuWidget), + matching: find.byType(TextField), + ); + + await tester.enterText( + textField, + "123456", + ); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("123456", findRichText: true), + ), + findsOneWidget, + ); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("1234567", findRichText: true), + ), + findsOneWidget, + ); + + await tester.showKeyboard(textField); + await tester.idle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("12345678", findRichText: true), + ), + findsOneWidget, + ); + + // tap next button, go back to beginning of document + await tester.tapButton( + find.descendant( + of: find.byType(FindMenu), + matching: find.byFlowySvg(FlowySvgs.arrow_down_s), + ), + ); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("123456", findRichText: true), + ), + findsOneWidget, + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart index 7ad52cd5a9..b98c1aad62 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart @@ -1,6 +1,7 @@ import 'package:integration_test/integration_test.dart'; import 'document_block_option_test.dart' as document_block_option_test; +import 'document_find_menu_test.dart' as document_find_menu_test; import 'document_inline_page_reference_test.dart' as document_inline_page_reference_test; import 'document_more_actions_test.dart' as document_more_actions_test; @@ -22,5 +23,6 @@ void main() { document_with_file_test.main(); document_shortcuts_test.main(); document_block_option_test.main(); + document_find_menu_test.main(); document_toolbar_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart index 7deea4aae4..4f3345cc8f 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart @@ -1,17 +1,18 @@ import 'dart:io'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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 '../../shared/base.dart'; -import '../../shared/common_operations.dart'; -import '../../shared/expectation.dart'; import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; const _documentName = 'First Doc'; const _documentTwoName = 'Second Doc'; @@ -20,17 +21,12 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Tabs', () { - testWidgets('Open AppFlowy and open/navigate/close tabs', (tester) async { + testWidgets('open/navigate/close tabs', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); - expect( - find.descendant( - of: find.byType(TabsManager), - matching: find.byType(TabBar), - ), - findsNothing, - ); + // No tabs rendered yet + expect(find.byType(FlowyTab), findsNothing); await tester.createNewPageWithNameUnderParent(name: _documentName); @@ -44,7 +40,7 @@ void main() { expect( find.descendant( - of: find.byType(TabBar), + of: find.byType(TabsManager), matching: find.byType(FlowyTab), ), findsNWidgets(3), @@ -71,11 +67,83 @@ void main() { expect( find.descendant( - of: find.byType(TabBar), + of: find.byType(TabsManager), matching: find.byType(FlowyTab), ), findsNWidgets(2), ); }); + + testWidgets('right click show tab menu, close others', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(TabBar), + ), + findsNothing, + ); + + await tester.createNewPageWithNameUnderParent(name: _documentName); + await tester.createNewPageWithNameUnderParent(name: _documentTwoName); + + /// Open second menu item in a new tab + await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); + + /// Open third menu item in a new tab + await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(FlowyTab), + ), + findsNWidgets(3), + ); + + /// Right click on second tab + await tester.tap( + buttons: kSecondaryButton, + find.descendant( + of: find.byType(FlowyTab), + matching: find.text(gettingStarted), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(TabMenu), findsOneWidget); + + final firstTabFinder = find.descendant( + of: find.byType(FlowyTab), + matching: find.text(_documentTwoName), + ); + final secondTabFinder = find.descendant( + of: find.byType(FlowyTab), + matching: find.text(gettingStarted), + ); + final thirdTabFinder = find.descendant( + of: find.byType(FlowyTab), + matching: find.text(_documentName), + ); + + expect(firstTabFinder, findsOneWidget); + expect(secondTabFinder, findsOneWidget); + expect(thirdTabFinder, findsOneWidget); + + // Close other tabs than the second item + await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr())); + await tester.pumpAndSettle(); + + // We expect to not find any tabs + expect(firstTabFinder, findsNothing); + expect(secondTabFinder, findsNothing); + expect(thirdTabFinder, findsNothing); + + // Expect second tab to be current page (current page has breadcrumb, cover title, + // and in this case view name in sidebar) + expect(find.text(gettingStarted), findsNWidgets(3)); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart index 4f43652c2e..f7d94e8b4a 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart @@ -4,7 +4,6 @@ import 'emoji_shortcut_test.dart' as emoji_shortcut_test; import 'hotkeys_test.dart' as hotkeys_test; import 'import_files_test.dart' as import_files_test; import 'share_markdown_test.dart' as share_markdown_test; -import 'tabs_test.dart' as tabs_test; import 'zoom_in_out_test.dart' as zoom_in_out_test; void main() { @@ -17,7 +16,6 @@ void main() { emoji_shortcut_test.main(); share_markdown_test.main(); import_files_test.main(); - tabs_test.main(); zoom_in_out_test.main(); // DON'T add more tests here. } diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_9.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_9.dart new file mode 100644 index 0000000000..819bd26817 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_9.dart @@ -0,0 +1,16 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/uncategorized/tabs_test.dart' as tabs_test; +import 'desktop/first_test/first_test.dart' as first_test; + +Future main() async { + await runIntegration9OnDesktop(); +} + +Future runIntegration9OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + first_test.main(); + + tabs_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart index c719051174..015e11676c 100644 --- a/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart +++ b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart @@ -1,6 +1,7 @@ import 'package:integration_test/integration_test.dart'; import 'page_style_test.dart' as page_style_test; +import 'plus_menu_test.dart' as plus_menu_test; import 'title_test.dart' as title_test; void main() { @@ -9,4 +10,5 @@ void main() { // Document integration tests title_test.main(); page_style_test.main(); + plus_menu_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart new file mode 100644 index 0000000000..bdd84f9098 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document plus menu:', () { + testWidgets('add the toggle heading blocks via plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile('toggle heading blocks'); + + final editorState = tester.editor.getCurrentEditorState(); + // focus on the editor + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [0])), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // open the plus menu and select the toggle heading block + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), + ); + + // check the block is inserted + final block1 = editorState.getNodeAtPath([0])!; + expect(block1.type, equals(ToggleListBlockKeys.type)); + expect(block1.attributes[ToggleListBlockKeys.level], equals(1)); + + // click the expand button won't cancel the selection + await tester.tapButton(find.byIcon(Icons.arrow_right)); + expect( + editorState.selection, + equals(Selection.collapsed(Position(path: [0]))), + ); + + // focus on the next line + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [1])), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // open the plus menu and select the toggle heading block + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), + ); + + // check the block is inserted + final block2 = editorState.getNodeAtPath([1])!; + expect(block2.type, equals(ToggleListBlockKeys.type)); + expect(block2.attributes[ToggleListBlockKeys.level], equals(2)); + + // focus on the next line + await tester.pumpAndSettle(); + + // open the plus menu and select the toggle heading block + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), + ); + + // check the block is inserted + final block3 = editorState.getNodeAtPath([2])!; + expect(block3.type, equals(ToggleListBlockKeys.type)); + expect(block3.attributes[ToggleListBlockKeys.level], equals(3)); + + // wait a few milliseconds to ensure the selection is updated + await Future.delayed(const Duration(milliseconds: 100)); + // check the selection is collapsed + expect( + editorState.selection, + equals(Selection.collapsed(Position(path: [2]))), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile_runner_1.dart b/frontend/appflowy_flutter/integration_test/mobile_runner_1.dart index bf86020141..a17fe909e7 100644 --- a/frontend/appflowy_flutter/integration_test/mobile_runner_1.dart +++ b/frontend/appflowy_flutter/integration_test/mobile_runner_1.dart @@ -1,7 +1,7 @@ import 'package:appflowy_backend/log.dart'; import 'package:integration_test/integration_test.dart'; -import 'mobile/document/page_style_test.dart' as page_style_test; +import 'mobile/document/document_test_runner.dart' as document_test_runner; import 'mobile/home_page/create_new_page_test.dart' as create_new_page_test; import 'mobile/sign_in/anonymous_sign_in_test.dart' as anonymous_sign_in_test; @@ -16,5 +16,5 @@ Future runIntegration1OnMobile() async { anonymous_sign_in_test.main(); create_new_page_test.main(); - page_style_test.main(); + document_test_runner.main(); } diff --git a/frontend/appflowy_flutter/integration_test/runner.dart b/frontend/appflowy_flutter/integration_test/runner.dart index 247c233236..0fc3c5d826 100644 --- a/frontend/appflowy_flutter/integration_test/runner.dart +++ b/frontend/appflowy_flutter/integration_test/runner.dart @@ -8,6 +8,7 @@ import 'desktop_runner_5.dart'; import 'desktop_runner_6.dart'; import 'desktop_runner_7.dart'; import 'desktop_runner_8.dart'; +import 'desktop_runner_9.dart'; import 'mobile_runner_1.dart'; /// The main task runner for all integration tests in AppFlowy. @@ -27,6 +28,7 @@ Future main() async { await runIntegration6OnDesktop(); await runIntegration7OnDesktop(); await runIntegration8OnDesktop(); + await runIntegration9OnDesktop(); } else if (Platform.isIOS || Platform.isAndroid) { await runIntegration1OnMobile(); } else { diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index 4649413717..5aef1e34ec 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -4,8 +4,10 @@ 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/mobile/presentation/base/type_option_menu_item.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart'; import 'package:appflowy/plugins/shared/share/share_button.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart'; @@ -42,6 +44,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_platform/universal_platform.dart'; import 'emoji.dart'; import 'util.dart'; @@ -787,6 +790,57 @@ extension CommonOperations on WidgetTester { await tap(finder); await pumpAndSettle(const Duration(seconds: 2)); } + + /// Create a new document on mobile + Future createNewDocumentOnMobile(String name) async { + final createPageButton = find.byKey( + BottomNavigationBarItemType.add.valueKey, + ); + await tapButton(createPageButton); + expect(find.byType(MobileDocumentScreen), findsOneWidget); + + final title = editor.findDocumentTitle(''); + expect(title, findsOneWidget); + final textField = widget(title); + expect(textField.focusNode!.hasFocus, isTrue); + + // input new name and press done button + await enterText(title, name); + await testTextInput.receiveAction(TextInputAction.done); + await pumpAndSettle(); + final newTitle = editor.findDocumentTitle(name); + expect(newTitle, findsOneWidget); + expect(textField.controller!.text, name); + } + + /// Open the plus menu + Future openPlusMenuAndClickButton(String buttonName) async { + assert( + UniversalPlatform.isMobile, + 'This method is only supported on mobile platforms', + ); + + final plusMenuButton = find.byKey(addBlockToolbarItemKey); + final addMenuItem = find.byType(AddBlockMenu); + await tapButton(plusMenuButton); + await pumpUntilFound(addMenuItem); + + final toggleHeading1 = find.byWidgetPredicate( + (widget) => + widget is TypeOptionMenuItem && widget.value.text == buttonName, + ); + final scrollable = find.ancestor( + of: find.byType(TypeOptionGridView), + matching: find.byType(Scrollable), + ); + await scrollUntilVisible( + toggleHeading1, + 100, + scrollable: scrollable, + ); + await tapButton(toggleHeading1); + await pumpUntilNotFound(addMenuItem); + } } extension SettingsFinder on CommonFinders { diff --git a/frontend/appflowy_flutter/integration_test/shared/workspace.dart b/frontend/appflowy_flutter/integration_test/shared/workspace.dart index 67506879d5..1b2f22b944 100644 --- a/frontend/appflowy_flutter/integration_test/shared/workspace.dart +++ b/frontend/appflowy_flutter/integration_test/shared/workspace.dart @@ -40,9 +40,13 @@ extension AppFlowyWorkspace on WidgetTester { moreButton, onHover: () async { await tapButton(moreButton); - await tapButton( - find.findTextInFlowyText(LocaleKeys.button_rename.tr()), + // wait for the menu to open + final renameButton = find.findTextInFlowyText( + LocaleKeys.button_rename.tr(), ); + await pumpUntilFound(renameButton); + expect(renameButton, findsOneWidget); + await tapButton(renameButton); final input = find.byType(TextFormField); expect(input, findsOneWidget); await enterText(input, name); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart index 5e4595a1e5..e0c3140ea9 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart @@ -9,12 +9,14 @@ class TypeOptionMenuItemValue { required this.text, required this.backgroundColor, required this.onTap, + this.iconPadding, }); final T value; final FlowySvgData icon; final String text; final Color backgroundColor; + final EdgeInsets? iconPadding; final void Function(BuildContext context, T value) onTap; } @@ -22,7 +24,7 @@ class TypeOptionMenu extends StatelessWidget { const TypeOptionMenu({ super.key, required this.values, - this.width = 94, + this.width = 98, this.iconWidth = 72, this.scaleFactor = 1.0, this.maxAxisSpacing = 18, @@ -39,17 +41,18 @@ class TypeOptionMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return _GridView( + return TypeOptionGridView( crossAxisCount: crossAxisCount, mainAxisSpacing: maxAxisSpacing * scaleFactor, itemWidth: width * scaleFactor, children: values .map( - (value) => _TypeOptionMenuItem( + (value) => TypeOptionMenuItem( value: value, width: width, iconWidth: iconWidth, scaleFactor: scaleFactor, + iconPadding: value.iconPadding, ), ) .toList(), @@ -57,18 +60,21 @@ class TypeOptionMenu extends StatelessWidget { } } -class _TypeOptionMenuItem extends StatelessWidget { - const _TypeOptionMenuItem({ +class TypeOptionMenuItem extends StatelessWidget { + const TypeOptionMenuItem({ + super.key, required this.value, this.width = 94, this.iconWidth = 72, this.scaleFactor = 1.0, + this.iconPadding, }); final TypeOptionMenuItemValue value; final double iconWidth; final double width; final double scaleFactor; + final EdgeInsets? iconPadding; double get scaledIconWidth => iconWidth * scaleFactor; double get scaledWidth => width * scaleFactor; @@ -88,7 +94,8 @@ class _TypeOptionMenuItem extends StatelessWidget { borderRadius: BorderRadius.circular(24 * scaleFactor), ), ), - padding: EdgeInsets.all(21 * scaleFactor), + padding: EdgeInsets.all(21 * scaleFactor) + + (iconPadding ?? EdgeInsets.zero), child: FlowySvg( value.icon, ), @@ -113,8 +120,9 @@ class _TypeOptionMenuItem extends StatelessWidget { } } -class _GridView extends StatelessWidget { - const _GridView({ +class TypeOptionGridView extends StatelessWidget { + const TypeOptionGridView({ + super.key, required this.children, required this.crossAxisCount, required this.mainAxisSpacing, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart index a5cb39ffaa..b3f10bbbd2 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart @@ -16,15 +16,18 @@ class FloatingAIEntry extends StatelessWidget { scaleFactor: 0.99, onTapUp: () => mobileCreateNewAIChatNotifier.value = mobileCreateNewAIChatNotifier.value + 1, - child: DecoratedBox( - decoration: _buildShadowDecoration(context), - child: Container( - decoration: _buildWrapperDecoration(context), - height: 48, - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(left: 18), - child: _buildHintText(context), + child: Hero( + tag: "ai_chat_prompt", + child: DecoratedBox( + decoration: _buildShadowDecoration(context), + child: Container( + decoration: _buildWrapperDecoration(context), + height: 48, + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 18), + child: _buildHintText(context), + ), ), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_file_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart similarity index 52% rename from frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_file_bloc.dart rename to frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart index a01c3b32d5..99a00228c7 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_file_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart @@ -5,58 +5,52 @@ import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.da import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'chat_input_bloc.dart'; +part 'ai_prompt_input_bloc.freezed.dart'; -part 'chat_file_bloc.freezed.dart'; +class AIPromptInputBloc extends Bloc { + AIPromptInputBloc() + : _listener = LocalLLMListener(), + super(AIPromptInputState.initial()) { + _dispatch(); + _startListening(); + _init(); + } -class ChatFileBloc extends Bloc { - ChatFileBloc() - : listener = LocalLLMListener(), - super(const ChatFileState()) { - listener.start( - stateCallback: (pluginState) { - if (!isClosed) { - add(ChatFileEvent.updatePluginState(pluginState)); - } - }, - chatStateCallback: (chatState) { - if (!isClosed) { - add(ChatFileEvent.updateChatState(chatState)); - } - }, - ); + ChatInputFileMetadata consumeMetadata() { + final metadata = { + for (final file in state.uploadFiles) file.filePath: file, + }; + + if (metadata.isNotEmpty) { + add(const AIPromptInputEvent.clear()); + } + + return metadata; + } + + final LocalLLMListener _listener; + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) { + event.when( + newFile: (String filePath, String fileName) { + final files = [...state.uploadFiles]; - on( - (event, emit) async { - await event.when( - initial: () async { - final result = await AIEventGetLocalAIChatState().send(); - result.fold( - (chatState) { - if (!isClosed) { - add( - ChatFileEvent.updateChatState(chatState), - ); - } - }, - (err) { - Log.error(err.toString()); - }, - ); - }, - newFile: (String filePath, String fileName) async { - final files = List.from(state.uploadFiles); final newFile = ChatFile.fromFilePath(filePath); if (newFile != null) { files.add(newFile); - emit( - state.copyWith( - uploadFiles: files, - ), - ); + emit(state.copyWith(uploadFiles: files)); } }, updateChatState: (LocalAIChatPB chatState) { @@ -76,8 +70,8 @@ class ChatFileBloc extends Bloc { fileEnabled && chatState.state == RunningStatePB.Running; final aiType = chatState.state == RunningStatePB.Running - ? const AIType.localAI() - : const AIType.appflowyAI(); + ? AIType.localAI + : AIType.appflowyAI; emit( state.copyWith( @@ -107,48 +101,66 @@ class ChatFileBloc extends Bloc { ); } - ChatInputFileMetadata consumeMetaData() { - final metadata = state.uploadFiles.fold( - {}, - (map, file) => map..putIfAbsent(file.filePath, () => file), + void _startListening() { + _listener.start( + stateCallback: (pluginState) { + if (!isClosed) { + add(AIPromptInputEvent.updatePluginState(pluginState)); + } + }, + chatStateCallback: (chatState) { + if (!isClosed) { + add(AIPromptInputEvent.updateChatState(chatState)); + } + }, ); - - if (metadata.isNotEmpty) { - add(const ChatFileEvent.clear()); - } - - return metadata; } - final LocalLLMListener listener; - - @override - Future close() async { - await listener.stop(); - return super.close(); + void _init() { + AIEventGetLocalAIChatState().send().fold( + (chatState) { + if (!isClosed) { + add(AIPromptInputEvent.updateChatState(chatState)); + } + }, + Log.error, + ); } } @freezed -class ChatFileEvent with _$ChatFileEvent { - const factory ChatFileEvent.initial() = Initial; - const factory ChatFileEvent.newFile(String filePath, String fileName) = +class AIPromptInputEvent with _$AIPromptInputEvent { + const factory AIPromptInputEvent.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( + const factory AIPromptInputEvent.deleteFile(ChatFile file) = _DeleteFile; + const factory AIPromptInputEvent.clear() = _ClearFile; + const factory AIPromptInputEvent.updateChatState( + LocalAIChatPB chatState, + ) = _UpdateChatState; + const factory AIPromptInputEvent.updatePluginState( LocalAIPluginStatePB chatState, ) = _UpdatePluginState; } @freezed -class ChatFileState with _$ChatFileState { - const factory ChatFileState({ - @Default(false) bool supportChatWithFile, +class AIPromptInputState with _$AIPromptInputState { + const factory AIPromptInputState({ + required bool supportChatWithFile, LocalAIChatPB? chatState, - @Default([]) List uploadFiles, - @Default(AIType.appflowyAI()) AIType aiType, - }) = _ChatFileState; + required List uploadFiles, + required AIType aiType, + }) = _AIPromptInputState; + + factory AIPromptInputState.initial() => const AIPromptInputState( + supportChatWithFile: false, + uploadFiles: [], + aiType: AIType.appflowyAI, + ); +} + +enum AIType { + appflowyAI, + localAI; + + bool get isLocalAI => this == localAI; } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart index 19fff60bbf..d0eec2c226 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart @@ -59,9 +59,8 @@ class ChatAIMessageBloc extends Bloc { } on( - (event, emit) async { - await event.when( - initial: () async {}, + (event, emit) { + event.when( updateText: (newText) { emit( state.copyWith( @@ -135,7 +134,6 @@ class ChatAIMessageBloc extends Bloc { @freezed class ChatAIMessageEvent with _$ChatAIMessageEvent { - const factory ChatAIMessageEvent.initial() = Initial; const factory ChatAIMessageEvent.updateText(String text) = _UpdateText; const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError; const factory ChatAIMessageEvent.retry() = _Retry; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart index f1b6be106a..f3114a8d01 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -106,7 +106,7 @@ class ChatBloc extends Bloc { didLoadPreviousMessages: (List messages, bool hasMore) { Log.debug("did load previous messages: ${messages.length}"); final onetimeMessages = _getOnetimeMessages(); - final allMessages = _perminentMessages(); + final allMessages = _permanentMessages(); final uniqueMessages = {...allMessages, ...messages}.toList() ..sort((a, b) => b.id.compareTo(a.id)); @@ -122,7 +122,7 @@ class ChatBloc extends Bloc { }, didLoadLatestMessages: (List messages) { final onetimeMessages = _getOnetimeMessages(); - final allMessages = _perminentMessages(); + final allMessages = _permanentMessages(); final uniqueMessages = {...allMessages, ...messages}.toList() ..sort((a, b) => b.id.compareTo(a.id)); uniqueMessages.insertAll(0, onetimeMessages); @@ -154,7 +154,7 @@ class ChatBloc extends Bloc { final payload = StopStreamPB(chatId: chatId); await AIEventStopStream(payload).send(); - final allMessages = _perminentMessages(); + final allMessages = _permanentMessages(); if (state.streamingState != const StreamingState.done()) { // If the streaming is not started, remove the message from the list if (!state.answerStream!.hasStarted) { @@ -175,8 +175,8 @@ class ChatBloc extends Bloc { ); } }, - receveMessage: (Message message) { - final allMessages = _perminentMessages(); + receiveMessage: (Message message) { + final allMessages = _permanentMessages(); // remove message with the same id allMessages.removeWhere((element) => element.id == message.id); allMessages.insert(0, message); @@ -187,7 +187,7 @@ class ChatBloc extends Bloc { ); }, startAnswerStreaming: (Message message) { - final allMessages = _perminentMessages(); + final allMessages = _permanentMessages(); allMessages.insert(0, message); emit( state.copyWith( @@ -199,7 +199,7 @@ class ChatBloc extends Bloc { }, sendMessage: (String message, Map? metadata) async { unawaited(_startStreamingMessage(message, metadata, emit)); - final allMessages = _perminentMessages(); + final allMessages = _permanentMessages(); emit( state.copyWith( lastSentMessage: null, @@ -221,16 +221,26 @@ class ChatBloc extends Bloc { ), ); }, + failedSending: () { + emit( + state.copyWith( + messages: _permanentMessages()..removeAt(0), + sendingState: const SendMessageState.done(), + canSendMessage: true, + ), + ); + }, // related question didReceiveRelatedQuestion: (List questions) { if (questions.isEmpty) { return; } - final allMessages = _perminentMessages(); + final allMessages = _permanentMessages(); final message = CustomMessage( metadata: OnetimeShotType.relatedQuestion.toMap(), author: const User(id: systemUserId), + showStatus: false, id: systemUserId, ); allMessages.insert(0, message); @@ -241,7 +251,7 @@ class ChatBloc extends Bloc { ), ); }, - clearReleatedQuestion: () { + clearRelatedQuestions: () { emit( state.copyWith( relatedQuestions: [], @@ -272,7 +282,7 @@ class ChatBloc extends Bloc { } final message = _createTextMessage(pb); - add(ChatEvent.receveMessage(message)); + add(ChatEvent.receiveMessage(message)); } }, chatErrorMessageCallback: (err) { @@ -325,7 +335,7 @@ class ChatBloc extends Bloc { } // Returns the list of messages that are not include one-time messages. - List _perminentMessages() { + List _permanentMessages() { final allMessages = state.messages.where((element) { return !(element.metadata?.containsKey(onetimeShotType) == true); }).toList(); @@ -384,7 +394,7 @@ class ChatBloc extends Bloc { questionStream, metadata, ); - add(ChatEvent.receveMessage(questionStreamMessage)); + add(ChatEvent.receiveMessage(questionStreamMessage)); // Stream message to the server final result = await AIEventStreamMessage(payload).send(); @@ -394,7 +404,7 @@ class ChatBloc extends Bloc { add(ChatEvent.finishSending(question)); // final message = _createTextMessage(question); - // add(ChatEvent.receveMessage(message)); + // add(ChatEvent.receiveMessage(message)); final streamAnswer = _createAnswerStreamMessage(answerStream, question.messageId); @@ -412,10 +422,12 @@ class ChatBloc extends Bloc { final error = CustomMessage( metadata: metadata, author: const User(id: systemUserId), + showStatus: false, id: systemUserId, ); - add(ChatEvent.receveMessage(error)); + add(const ChatEvent.failedSending()); + add(ChatEvent.receiveMessage(error)); } }, ); @@ -436,6 +448,7 @@ class ChatBloc extends Bloc { "chatId": chatId, }, id: streamMessageId, + showStatus: false, createdAt: DateTime.now().millisecondsSinceEpoch, text: '', ); @@ -462,6 +475,7 @@ class ChatBloc extends Bloc { author: User(id: state.userProfile.id.toString()), metadata: metadata, id: questionStreamMessageId, + showStatus: false, createdAt: DateTime.now().millisecondsSinceEpoch, text: '', ); @@ -480,6 +494,7 @@ class ChatBloc extends Bloc { id: messageId, text: message.content, createdAt: message.createdAt.toInt() * 1000, + showStatus: false, metadata: { messageRefSourceJsonStringKey: message.metadata, }, @@ -498,11 +513,12 @@ class ChatEvent with _$ChatEvent { }) = _SendMessage; const factory ChatEvent.finishSending(ChatMessagePB message) = _FinishSendMessage; + const factory ChatEvent.failedSending() = _FailSendMessage; // receive message const factory ChatEvent.startAnswerStreaming(Message message) = _StartAnswerStreaming; - const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage; + const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage; const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming; // loading messages @@ -518,7 +534,7 @@ class ChatEvent with _$ChatEvent { const factory ChatEvent.didReceiveRelatedQuestion( List questions, ) = _DidReceiveRelatedQueston; - const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion; + const factory ChatEvent.clearRelatedQuestions() = _ClearRelatedQuestions; const factory ChatEvent.didUpdateAnswerStream( AnswerStream stream, diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart index c0a2d5bf32..0ef46f5661 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart @@ -1,11 +1,9 @@ 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; @@ -100,30 +98,6 @@ class ChatFile extends Equatable { List 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; @freezed diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart deleted file mode 100644 index a0cedd2900..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_bloc.dart +++ /dev/null @@ -1,87 +0,0 @@ -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-ai/entities.pb.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -part 'chat_input_bloc.freezed.dart'; - -class ChatInputStateBloc - extends Bloc { - ChatInputStateBloc() - : listener = LocalLLMListener(), - super(const ChatInputStateState(aiType: _AppFlowyAI())) { - listener.start( - stateCallback: (pluginState) { - if (!isClosed) { - add(ChatInputStateEvent.updatePluginState(pluginState)); - } - }, - ); - - on(_handleEvent); - } - - final LocalLLMListener listener; - - @override - Future close() async { - await listener.stop(); - return super.close(); - } - - Future _handleEvent( - ChatInputStateEvent event, - Emitter emit, - ) async { - await event.when( - started: () async { - final result = await AIEventGetLocalAIPluginState().send(); - result.fold( - (pluginState) { - if (!isClosed) { - add( - ChatInputStateEvent.updatePluginState(pluginState), - ); - } - }, - (err) { - Log.error(err.toString()); - }, - ); - }, - updatePluginState: (pluginState) { - if (pluginState.state == RunningStatePB.Running) { - emit(const ChatInputStateState(aiType: _LocalAI())); - } else { - emit(const ChatInputStateState(aiType: _AppFlowyAI())); - } - }, - ); - } -} - -@freezed -class ChatInputStateEvent with _$ChatInputStateEvent { - const factory ChatInputStateEvent.started() = _Started; - const factory ChatInputStateEvent.updatePluginState( - LocalAIPluginStatePB pluginState, - ) = _UpdatePluginState; -} - -@freezed -class ChatInputStateState with _$ChatInputStateState { - const factory ChatInputStateState({required AIType aiType}) = _ChatInputState; -} - -@freezed -class AIType with _$AIType { - const factory AIType.appflowyAI() = _AppFlowyAI; - const factory AIType.localAI() = _LocalAI; -} - -extension AITypeX on AIType { - bool isLocalAI() => this is _LocalAI; -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart index 048c8709b3..31d58eb000 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart @@ -1,4 +1,3 @@ - import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -7,14 +6,11 @@ part 'chat_input_file_bloc.freezed.dart'; class ChatInputFileBloc extends Bloc { ChatInputFileBloc({ - // ignore: avoid_unused_constructor_parameters - required String chatId, required this.file, }) : super(const ChatInputFileState()) { on( (event, emit) async { - await event.when( - initial: () async {}, + event.when( updateUploadState: (UploadFileIndicator indicator) { emit(state.copyWith(uploadFileIndicator: indicator)); }, @@ -28,7 +24,6 @@ class ChatInputFileBloc extends Bloc { @freezed class ChatInputFileEvent with _$ChatInputFileEvent { - const factory ChatInputFileEvent.initial() = Initial; const factory ChatInputFileEvent.updateUploadState( UploadFileIndicator indicator, ) = _UpdateUploadState; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart index c0f68bb9b3..d1d0898ccc 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart @@ -13,7 +13,6 @@ class ChatMemberBloc extends Bloc { on( (event, emit) async { event.when( - initial: () {}, receiveMemberInfo: (String id, WorkspaceMemberPB memberInfo) { final members = Map.from(state.members); members[id] = ChatMember(info: memberInfo); @@ -51,7 +50,6 @@ class ChatMemberBloc extends Bloc { @freezed class ChatMemberEvent with _$ChatMemberEvent { - const factory ChatMemberEvent.initial() = Initial; const factory ChatMemberEvent.getMemberInfo( String userId, ) = _GetMemberInfo; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart index 1985e41242..d359d36a6c 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart @@ -11,7 +11,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:nanoid/nanoid.dart'; /// Indicate file source from appflowy document -const appflowySoruce = "appflowy"; +const appflowySource = "appflowy"; List fileListFromMessageMetadata( Map? map, @@ -119,7 +119,7 @@ Future> metadataPBFromMetadata( name: view.name, data: pb.text, dataType: ChatMessageMetaTypePB.Txt, - source: appflowySoruce, + source: appflowySource, ), ); }, (err) { diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_side_panel_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_side_panel_bloc.dart new file mode 100644 index 0000000000..18786c4165 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_side_panel_bloc.dart @@ -0,0 +1,75 @@ +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:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_side_panel_bloc.freezed.dart'; + +class ChatSidePanelBloc extends Bloc { + ChatSidePanelBloc({ + required this.chatId, + }) : super(const ChatSidePanelState()) { + on( + (event, emit) async { + await event.when( + selectedMetadata: (ChatMessageRefSource metadata) async { + emit( + state.copyWith( + metadata: metadata, + indicator: const ChatSidePanelIndicator.loading(), + ), + ); + await ViewBackendService.getView(metadata.id).fold( + (view) { + if (!isClosed) { + add(ChatSidePanelEvent.open(view)); + } + }, + (err) => Log.error("Failed to get view: $err"), + ); + }, + close: () { + emit(state.copyWith(metadata: null, isShowPanel: false)); + }, + open: (ViewPB view) { + emit( + state.copyWith( + indicator: ChatSidePanelIndicator.ready(view), + isShowPanel: true, + ), + ); + }, + ); + }, + ); + } + + final String chatId; +} + +@freezed +class ChatSidePanelEvent with _$ChatSidePanelEvent { + const factory ChatSidePanelEvent.selectedMetadata( + ChatMessageRefSource metadata, + ) = _SelectedMetadata; + const factory ChatSidePanelEvent.close() = _Close; + const factory ChatSidePanelEvent.open(ViewPB view) = _Open; +} + +@freezed +class ChatSidePanelState with _$ChatSidePanelState { + const factory ChatSidePanelState({ + ChatMessageRefSource? metadata, + @Default(ChatSidePanelIndicator.loading()) ChatSidePanelIndicator indicator, + @Default(false) bool isShowPanel, + }) = _ChatSidePanelState; +} + +@freezed +class ChatSidePanelIndicator with _$ChatSidePanelIndicator { + const factory ChatSidePanelIndicator.ready(ViewPB view) = _Ready; + const factory ChatSidePanelIndicator.loading() = _Loading; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_side_pannel_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_side_pannel_bloc.dart deleted file mode 100644 index 83dc4375b0..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_side_pannel_bloc.dart +++ /dev/null @@ -1,85 +0,0 @@ -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 { - ChatSidePannelBloc({ - required this.chatId, - }) : super(const ChatSidePannelState()) { - on( - (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; -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart index 0d991032b6..0dc51991fb 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -1,14 +1,9 @@ -import 'dart:math'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_file_bloc.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_input_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/message/ai_message_bubble.dart'; -import 'package:appflowy/plugins/ai_chat/presentation/message/other_user_message_bubble.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/user_message_bubble.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; @@ -25,39 +20,17 @@ import 'package:styled_widget/styled_widget.dart'; import 'package:universal_platform/universal_platform.dart'; import 'application/chat_member_bloc.dart'; -import 'application/chat_side_pannel_bloc.dart'; -import 'presentation/chat_input/chat_input.dart'; -import 'presentation/chat_side_pannel.dart'; +import 'application/chat_side_panel_bloc.dart'; +import 'presentation/chat_input/desktop_ai_prompt_input.dart'; +import 'presentation/chat_input/mobile_ai_prompt_input.dart'; +import 'presentation/chat_side_panel.dart'; import 'presentation/chat_theme.dart'; import 'presentation/chat_user_invalid_message.dart'; import 'presentation/chat_welcome_page.dart'; +import 'presentation/layout_define.dart'; import 'presentation/message/ai_text_message.dart'; import 'presentation/message/user_text_message.dart'; -class AIChatUILayout { - static EdgeInsets get chatPadding => - isMobile ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 20); - - static EdgeInsets get welcomePagePadding => isMobile - ? const EdgeInsets.symmetric(horizontal: 20) - : const EdgeInsets.symmetric(horizontal: 50); - - static double get messageWidthRatio => 0.85; - - static EdgeInsets safeAreaInsets(BuildContext context) { - final query = MediaQuery.of(context); - return isMobile - ? EdgeInsets.fromLTRB( - query.padding.left, - 0, - query.padding.right, - query.viewInsets.bottom + query.padding.bottom, - ) - : const EdgeInsets.symmetric(horizontal: 50) + - const EdgeInsets.only(bottom: 20); - } -} - class AIChatPage extends StatelessWidget { const AIChatPage({ super.key, @@ -72,62 +45,50 @@ class AIChatPage extends StatelessWidget { @override Widget build(BuildContext context) { - if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) { - return MultiBlocProvider( - providers: [ - /// [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: (_) => 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: BlocBuilder( - builder: (context, state) { - return DropTarget( - onDragDone: (DropDoneDetails detail) async { - if (state.supportChatWithFile) { - for (final file in detail.files) { - context - .read() - .add(ChatFileEvent.newFile(file.path, file.name)); - } - } - }, - child: _ChatContentPage( - view: view, - userProfile: userProfile, - ), - ); - }, + if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { + return Center( + child: FlowyText( + LocaleKeys.chat_unsupportedCloudPrompt.tr(), + fontSize: 20, ), ); } - return Center( - child: FlowyText( - LocaleKeys.chat_unsupportedCloudPrompt.tr(), - fontSize: 20, + return MultiBlocProvider( + providers: [ + /// [ChatBloc] is used to handle chat messages including send/receive message + BlocProvider( + create: (_) => ChatBloc( + view: view, + userProfile: userProfile, + )..add(const ChatEvent.initialLoad()), + ), + + /// [AIPromptInputBloc] is used to handle the user prompt + BlocProvider(create: (_) => AIPromptInputBloc()), + BlocProvider(create: (_) => ChatSidePanelBloc(chatId: view.id)), + BlocProvider(create: (_) => ChatMemberBloc()), + ], + child: DropTarget( + onDragDone: (DropDoneDetails detail) async { + if (context.read().state.supportChatWithFile) { + for (final file in detail.files) { + context + .read() + .add(AIPromptInputEvent.newFile(file.path, file.name)); + } + } + }, + child: _ChatContentPage( + view: view, + userProfile: userProfile, + ), ), ); } } -class _ChatContentPage extends StatefulWidget { +class _ChatContentPage extends StatelessWidget { const _ChatContentPage({ required this.view, required this.userProfile, @@ -136,189 +97,167 @@ class _ChatContentPage extends StatefulWidget { final UserProfilePB userProfile; final ViewPB view; - @override - State<_ChatContentPage> createState() => _ChatContentPageState(); -} - -class _ChatContentPageState extends State<_ChatContentPage> { - late types.User _user; - - @override - void initState() { - super.initState(); - _user = types.User(id: widget.userProfile.id.toString()); - } - @override Widget build(BuildContext context) { - if (widget.userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) { - if (UniversalPlatform.isDesktop) { - return BlocSelector( - 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); + if (UniversalPlatform.isDesktop) { + return BlocSelector( + selector: (state) => state.isShowPanel, + builder: (context, isShowPanel) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final sidePanelRatio = isShowPanel ? 0.33 : 0.0; + final chatWidth = constraints.maxWidth * (1 - sidePanelRatio); + final sidePanelWidth = + constraints.maxWidth * sidePanelRatio - 1.0; - 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, + return Row( + children: [ + Center( + child: buildChatWidget() + .constrained( + maxWidth: 784, ) + .padding(horizontal: 32) + .animate( + const Duration(milliseconds: 200), + Curves.easeOut, + ), + ).constrained(width: chatWidth), + if (isShowPanel) ...[ + const VerticalDivider( + width: 1.0, + thickness: 1.0, + ), + buildChatSidePanel() + .constrained(width: sidePanelWidth) .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( - child: FlowyText( - LocaleKeys.chat_unsupportedCloudPrompt.tr(), - fontSize: 20, - ), - ); - } - - Widget buildChatSidePannel() { - if (UniversalPlatform.isDesktop) { - return BlocBuilder( - 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 Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 784), + child: buildChatWidget(), + ), + ), + ], + ); } } + Widget buildChatSidePanel() { + return BlocBuilder( + builder: (context, state) { + if (state.metadata == null) { + return const SizedBox.shrink(); + } + return const ChatSidePanel(); + }, + ); + } + Widget buildChatWidget() { return BlocBuilder( - 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() - .add(const ChatEvent.startLoadingPrevMessage()); - } - }, - emptyState: BlocBuilder( - builder: (_, state) => state.initialLoadingStatus.isFinish - ? Padding( - padding: AIChatUILayout.welcomePagePadding, - child: ChatWelcomePage( - userProfile: widget.userProfile, - onSelectedQuestion: (question) => blocContext - .read() - .add(ChatEvent.sendMessage(message: question)), + builder: (context, state) { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: BlocBuilder( + builder: (_, state) => state.initialLoadingStatus.isFinish + ? Chat( + messages: state.messages, + dateHeaderBuilder: (_) => const SizedBox.shrink(), + onSendPressed: (_) { + // We use custom bottom widget for chat input, so + // do not need to handle this event. + }, + customBottomWidget: _buildBottom(context), + user: types.User(id: userProfile.id.toString()), + theme: _buildTheme(context), + onEndReached: () async { + if (state.hasMorePrevMessage && + state.loadingPreviousStatus.isFinish) { + context + .read() + .add(const ChatEvent.startLoadingPrevMessage()); + } + }, + emptyState: ChatWelcomePage( + userProfile: userProfile, + onSelectedQuestion: (question) => context + .read() + .add(ChatEvent.sendMessage(message: question)), + ), + messageWidthRatio: AIChatUILayout.messageWidthRatio, + textMessageBuilder: ( + textMessage, { + required messageWidth, + required showName, + }) => + _buildTextMessage(context, textMessage, state), + customMessageBuilder: (message, {required messageWidth}) { + final messageType = onetimeMessageTypeFromMeta( + message.metadata, + ); + + if (messageType == OnetimeShotType.invalidSendMesssage) { + return ChatInvalidUserMessage( + message: message, + ); + } + if (messageType == OnetimeShotType.relatedQuestion) { + return RelatedQuestionList( + relatedQuestions: state.relatedQuestions, + onQuestionSelected: (question) { + final bloc = context.read(); + bloc + ..add(ChatEvent.sendMessage(message: question)) + ..add(const ChatEvent.clearRelatedQuestions()); + }, + ); + } + + return const SizedBox.shrink(); + }, + bubbleBuilder: ( + child, { + required message, + required nextMessageInGroup, + }) => + _buildBubble(context, message, child), + ) + : const Center( + child: CircularProgressIndicator.adaptive(), ), - ) - : 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 _buildBubble( - BuildContext blocContext, - Message message, - Widget child, + Widget _buildTextMessage( + BuildContext context, + TextMessage message, ChatState state, ) { - if (message.author.id == _user.id) { - return ChatUserMessageBubble( - message: message, - child: child, + if (message.author.id == userProfile.id.toString()) { + final stream = message.metadata?["$QuestionStream"]; + return ChatUserMessageWidget( + key: ValueKey(message.id), + user: message.author, + message: stream is QuestionStream ? stream : message.text, ); } 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), @@ -330,160 +269,115 @@ class _ChatContentPageState extends State<_ChatContentPage> { final questionId = message.metadata?[messageQuestionIdKey]; final refSourceJsonString = message.metadata?[messageRefSourceJsonStringKey] as String?; - return ChatAIMessageWidget( - user: message.author, - messageUserId: message.id, - message: stream is AnswerStream ? stream : message.text, + + return BlocSelector( key: ValueKey(message.id), - questionId: questionId, - chatId: widget.view.id, - refSourceJsonString: refSourceJsonString, - onSelectedMetadata: (ChatMessageRefSource metadata) { - context.read().add( - ChatSidePannelEvent.selectedMetadata(metadata), - ); + selector: (state) { + final messages = state.messages.where((e) { + final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata); + if (oneTimeMessageType == null) { + return true; + } + if (oneTimeMessageType + case OnetimeShotType.relatedQuestion || + OnetimeShotType.sendingMessage || + OnetimeShotType.invalidSendMesssage) { + return false; + } + return true; + }); + return messages.isEmpty ? false : messages.first.id == message.id; + }, + builder: (context, isLastMessage) { + return ChatAIMessageWidget( + user: message.author, + messageUserId: message.id, + message: message, + stream: stream is AnswerStream ? stream : null, + questionId: questionId, + chatId: view.id, + refSourceJsonString: refSourceJsonString, + isLastMessage: isLastMessage, + onSelectedMetadata: (metadata) { + context + .read() + .add(ChatSidePanelEvent.selectedMetadata(metadata)); + }, + ); }, ); } } - Widget _buildAIBubble( + Widget _buildBubble( + BuildContext context, Message message, - BuildContext blocContext, - ChatState state, Widget child, ) { - final messageType = onetimeMessageTypeFromMeta( - message.metadata, - ); - - if (messageType == OnetimeShotType.invalidSendMesssage) { - return ChatInvalidUserMessage( + if (message.author.id == userProfile.id.toString()) { + return ChatUserMessageBubble( message: message, + child: child, ); - } - - if (messageType == OnetimeShotType.relatedQuestion) { - return RelatedQuestionList( - onQuestionSelected: (question) { - blocContext - .read() - .add(ChatEvent.sendMessage(message: question)); - blocContext - .read() - .add(const ChatEvent.clearReleatedQuestion()); - }, - chatId: widget.view.id, - relatedQuestions: state.relatedQuestions, + } else if (isOtherUserMessage(message)) { + return ChatUserMessageBubble( + message: message, + isCurrentUser: false, + child: child, ); + } else { + // The bubble is rendered in the child already + return child; } - - return ChatAIMessageBubble( - message: message, - customMessageType: messageType, - child: child, - ); } Widget _buildBottom(BuildContext context) { - return ClipRect( - child: Padding( - padding: AIChatUILayout.safeAreaInsets(context), - child: BlocBuilder( - 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(), - ); - - return Column( - children: [ - BlocSelector( - selector: (state) => state.canSendMessage, - builder: (context, canSendMessage) { - return ChatInput( - aiType: aiType, - chatId: widget.view.id, - onSendPressed: (message) { - context.read().add( - ChatEvent.sendMessage( - message: message.text, - metadata: message.metadata, - ), - ); - }, - isStreaming: !canSendMessage, - onStopStreaming: () { - context - .read() - .add(const ChatEvent.stopStream()); - }, - hintText: hintText, - ); + return Padding( + padding: AIChatUILayout.safeAreaInsets(context), + child: BlocSelector( + selector: (state) => state.canSendMessage, + builder: (context, canSendMessage) { + return UniversalPlatform.isDesktop + ? DesktopAIPromptInput( + chatId: view.id, + indicateFocus: true, + onSubmitted: (message) { + context.read().add( + ChatEvent.sendMessage( + message: message.text, + metadata: message.metadata, + ), + ); }, - ), - const VSpace(6), - if (UniversalPlatform.isDesktop) - Opacity( - opacity: 0.6, - child: FlowyText( - LocaleKeys.chat_aiMistakePrompt.tr(), - fontSize: 12, - ), - ), - ], - ); - }, - ), + isStreaming: !canSendMessage, + onStopStreaming: () { + context.read().add(const ChatEvent.stopStream()); + }, + ) + : MobileAIPromptInput( + chatId: view.id, + onSubmitted: (message) { + context.read().add( + ChatEvent.sendMessage( + message: message.text, + metadata: message.metadata, + ), + ); + }, + isStreaming: !canSendMessage, + onStopStreaming: () { + context.read().add(const ChatEvent.stopStream()); + }, + ); + }, ), ); } -} -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, - ); + AFDefaultChatTheme _buildTheme(BuildContext context) { + return AFDefaultChatTheme( + primaryColor: Theme.of(context).colorScheme.primary, + secondaryColor: AFThemeExtension.of(context).tint1, + ); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart index c9f1422900..d9d4594240 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart @@ -4,49 +4,34 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/built_in_svgs.dart'; import 'package:appflowy/util/color_generator/color_generator.dart'; import 'package:easy_localization/easy_localization.dart'; -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; +import 'layout_define.dart'; -class ChatChatUserAvatar extends StatelessWidget { - const ChatChatUserAvatar({required this.userId, super.key}); - - final String userId; - - @override - Widget build(BuildContext context) { - return const ChatBorderedCircleAvatar(); - } -} - -class ChatBorderedCircleAvatar extends StatelessWidget { - const ChatBorderedCircleAvatar({ +class ChatAIAvatar extends StatelessWidget { + const ChatAIAvatar({ super.key, - this.border = const BorderSide(), - this.backgroundImage, - this.child, }); - final BorderSide border; - final ImageProvider? backgroundImage; - final Widget? child; - @override Widget build(BuildContext context) { - 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, - ), + return Container( + width: DesktopAIConvoSizes.avatarSize, + height: DesktopAIConvoSizes.avatarSize, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(shape: BoxShape.circle), + foregroundDecoration: ShapeDecoration( + shape: CircleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.outline), + ), + ), + child: const CircleAvatar( + backgroundColor: Colors.transparent, + child: FlowySvg( + FlowySvgs.flowy_logo_s, + size: Size.square(16), + blendMode: null, ), ), ); @@ -58,28 +43,35 @@ class ChatUserAvatar extends StatelessWidget { super.key, required this.iconUrl, required this.name, - 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; - @override Widget build(BuildContext context) { + late final Widget child; if (iconUrl.isEmpty) { - return _buildEmptyAvatar(context); + child = _buildEmptyAvatar(context); } else if (isURL(iconUrl)) { - return _buildUrlAvatar(context); + child = _buildUrlAvatar(context); } else { - return _buildEmojiAvatar(context); + child = _buildEmojiAvatar(context); } + return Container( + width: DesktopAIConvoSizes.avatarSize, + height: DesktopAIConvoSizes.avatarSize, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(shape: BoxShape.circle), + foregroundDecoration: ShapeDecoration( + shape: CircleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.outline), + ), + ), + child: child, + ); } Widget _buildEmptyAvatar(BuildContext context) { @@ -96,96 +88,50 @@ class ChatUserAvatar extends StatelessWidget { .map((element) => element[0].toUpperCase()) .join(); - return Container( - width: size, - height: size, - alignment: Alignment.center, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: isHovering - ? Border.all( - color: _darken(color), - width: 4, - ) - : null, - ), - child: FlowyText.regular( - nameInitials, - color: Colors.black, + return ColoredBox( + color: color, + child: Center( + child: FlowyText.regular( + nameInitials, + color: Colors.black, + ), ), ); } Widget _buildUrlAvatar(BuildContext context) { - return SizedBox.square( - dimension: size, - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: isHovering - ? Border.all( - color: Theme.of(context).colorScheme.secondary, - width: 4, - ) - : null, - ), - child: ClipRRect( - borderRadius: Corners.s5Border, - child: CircleAvatar( - backgroundColor: Colors.transparent, - child: Image.network( - iconUrl, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - _buildEmptyAvatar(context), - ), - ), - ), + return CircleAvatar( + backgroundColor: Colors.transparent, + radius: DesktopAIConvoSizes.avatarSize / 2, + child: Image.network( + iconUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildEmptyAvatar(context), ), ); } Widget _buildEmojiAvatar(BuildContext context) { - return SizedBox.square( - dimension: size, - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: isHovering - ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 4, - ) - : null, - ), - child: ClipRRect( - borderRadius: Corners.s5Border, - child: CircleAvatar( - backgroundColor: Colors.transparent, - child: builtInSVGIcons.contains(iconUrl) - ? FlowySvg( - FlowySvgData('emoji/$iconUrl'), - blendMode: null, - ) - : FlowyText.emoji(iconUrl), - ), - ), - ), + return CircleAvatar( + backgroundColor: Colors.transparent, + radius: DesktopAIConvoSizes.avatarSize / 2, + child: builtInSVGIcons.contains(iconUrl) + ? FlowySvg( + FlowySvgData('emoji/$iconUrl'), + blendMode: null, + ) + : FlowyText.emoji( + iconUrl, + fontSize: 24, // cannot reduce + optimizeEmojiAlign: true, + ), ); } - /// Return the user name, if the user name is empty, - /// return the default user name. + /// Return the user name. /// + /// If the user name is empty, return the default user 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. - /// - Color _darken(Color color) { - final hsl = HSLColor.fromColor(color); - return hsl.withLightness((hsl.lightness - 0.15).clamp(0.0, 1.0)).toColor(); - } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart new file mode 100644 index 0000000000..2b4a1200b7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart @@ -0,0 +1,143 @@ +// ref appflowy_flutter/lib/plugins/document/presentation/editor_style.dart + +// diff: +// - text style +// - heading text style and padding builders +// - don't listen to document appearance cubit +// + +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class ChatEditorStyleCustomizer extends EditorStyleCustomizer { + ChatEditorStyleCustomizer({ + required super.context, + required super.padding, + super.width, + }); + + @override + EditorStyle desktop() { + final theme = Theme.of(context); + final afThemeExtension = AFThemeExtension.of(context); + final appearanceFont = context.read().state.font; + final appearance = context.read().state; + const fontSize = 14.0; + String fontFamily = appearance.fontFamily; + if (fontFamily.isEmpty && appearanceFont.isNotEmpty) { + fontFamily = appearanceFont; + } + + return EditorStyle.desktop( + padding: padding, + maxWidth: width, + cursorColor: appearance.cursorColor ?? + DefaultAppearanceSettings.getDefaultCursorColor(context), + selectionColor: appearance.selectionColor ?? + DefaultAppearanceSettings.getDefaultSelectionColor(context), + defaultTextDirection: appearance.defaultTextDirection, + textStyleConfiguration: TextStyleConfiguration( + lineHeight: 1.4, + applyHeightToFirstAscent: true, + applyHeightToLastDescent: true, + text: baseTextStyle(fontFamily).copyWith( + fontSize: fontSize, + color: afThemeExtension.onBackground, + ), + bold: baseTextStyle(fontFamily, fontWeight: FontWeight.bold).copyWith( + fontWeight: FontWeight.w600, + ), + italic: baseTextStyle(fontFamily).copyWith(fontStyle: FontStyle.italic), + underline: baseTextStyle(fontFamily).copyWith( + decoration: TextDecoration.underline, + ), + strikethrough: baseTextStyle(fontFamily).copyWith( + decoration: TextDecoration.lineThrough, + ), + href: baseTextStyle(fontFamily).copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + code: GoogleFonts.robotoMono( + textStyle: baseTextStyle(fontFamily).copyWith( + fontSize: fontSize, + fontWeight: FontWeight.normal, + color: Colors.red, + backgroundColor: theme.colorScheme.inverseSurface.withOpacity(0.8), + ), + ), + ), + textSpanDecorator: customizeAttributeDecorator, + textScaleFactor: + context.watch().state.textScaleFactor, + ); + } + + @override + TextStyle headingStyleBuilder(int level) { + final String? fontFamily; + final List fontSizes; + const fontSize = 14.0; + + fontFamily = context.read().state.fontFamily; + fontSizes = [ + fontSize + 12, + fontSize + 10, + fontSize + 6, + fontSize + 2, + fontSize, + ]; + return baseTextStyle(fontFamily, fontWeight: FontWeight.w600).copyWith( + fontSize: fontSizes.elementAtOrNull(level - 1) ?? fontSize, + ); + } + + @override + TextStyle codeBlockStyleBuilder() { + final fontFamily = + context.read().state.codeFontFamily; + return baseTextStyle(fontFamily).copyWith( + height: 1.4, + color: AFThemeExtension.of(context).onBackground, + ); + } + + @override + TextStyle calloutBlockStyleBuilder() { + if (UniversalPlatform.isMobile) { + final afThemeExtension = AFThemeExtension.of(context); + final pageStyle = context.read().state; + final fontFamily = pageStyle.fontFamily ?? defaultFontFamily; + final baseTextStyle = this.baseTextStyle(fontFamily); + return baseTextStyle.copyWith( + color: afThemeExtension.onBackground, + ); + } else { + final fontSize = context.read().state.fontSize; + return baseTextStyle(null).copyWith( + fontSize: fontSize, + height: 1.5, + ); + } + } + + @override + TextStyle outlineBlockPlaceholderStyleBuilder() { + return TextStyle( + fontFamily: defaultFontFamily, + height: 1.5, + color: AFThemeExtension.of(context).onBackground.withOpacity(0.6), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/ai_prompt_buttons.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/ai_prompt_buttons.dart new file mode 100644 index 0000000000..7d0fc10e3f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/ai_prompt_buttons.dart @@ -0,0 +1,123 @@ +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'; + +import '../layout_define.dart'; + +enum SendButtonState { enabled, streaming, disabled } + +class PromptInputAttachmentButton extends StatelessWidget { + const PromptInputAttachmentButton({required this.onTap, super.key}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_uploadFile.tr(), + child: SizedBox.square( + dimension: DesktopAIPromptSizes.actionBarButtonSize, + child: FlowyIconButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: BorderRadius.circular(8), + icon: FlowySvg( + FlowySvgs.ai_attachment_s, + size: const Size.square(16), + color: Theme.of(context).iconTheme.color, + ), + onPressed: onTap, + ), + ), + ); + } +} + +class PromptInputMentionButton extends StatelessWidget { + const PromptInputMentionButton({ + super.key, + required this.buttonSize, + required this.iconSize, + required this.onTap, + }); + + final double buttonSize; + final double iconSize; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_clickToMention.tr(), + preferBelow: false, + child: FlowyIconButton( + width: buttonSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: BorderRadius.circular(8), + icon: FlowySvg( + FlowySvgs.chat_at_s, + size: Size.square(iconSize), + color: Theme.of(context).iconTheme.color, + ), + onPressed: onTap, + ), + ); + } +} + +class PromptInputSendButton extends StatelessWidget { + const PromptInputSendButton({ + super.key, + required this.buttonSize, + required this.iconSize, + required this.state, + required this.onSendPressed, + required this.onStopStreaming, + }); + + final double buttonSize; + final double iconSize; + final SendButtonState state; + final VoidCallback onSendPressed; + final VoidCallback onStopStreaming; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + width: buttonSize, + icon: switch (state) { + SendButtonState.enabled => FlowySvg( + FlowySvgs.ai_send_filled_s, + size: Size.square(iconSize), + color: Theme.of(context).colorScheme.primary, + ), + SendButtonState.disabled => FlowySvg( + FlowySvgs.ai_send_filled_s, + size: Size.square(iconSize), + color: Theme.of(context).disabledColor, + ), + SendButtonState.streaming => FlowySvg( + FlowySvgs.ai_stop_filled_s, + size: Size.square(iconSize), + color: Theme.of(context).colorScheme.primary, + ), + }, + onPressed: () { + switch (state) { + case SendButtonState.enabled: + onSendPressed(); + break; + case SendButtonState.streaming: + onStopStreaming(); + break; + case SendButtonState.disabled: + break; + } + }, + hoverColor: Colors.transparent, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_at_button.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_at_button.dart deleted file mode 100644 index 53741f4431..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_at_button.dart +++ /dev/null @@ -1,30 +0,0 @@ -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, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart deleted file mode 100644 index 5f7aed9b40..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input.dart +++ /dev/null @@ -1,451 +0,0 @@ -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/theme_extension.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.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 'package:universal_platform/universal_platform.dart'; - -import 'chat_at_button.dart'; -import 'chat_input_attachment.dart'; -import 'chat_input_span.dart'; -import 'chat_send_button.dart'; -import 'layout_define.dart'; - -class ChatInput extends StatefulWidget { - /// Creates [ChatInput] widget. - const ChatInput({ - super.key, - this.onAttachmentPressed, - required this.onSendPressed, - required this.chatId, - this.options = const InputOptions(), - required this.isStreaming, - required this.onStopStreaming, - required this.hintText, - required this.aiType, - }); - - final VoidCallback? onAttachmentPressed; - final void Function(types.PartialText) onSendPressed; - final void Function() onStopStreaming; - final InputOptions options; - final String chatId; - final bool isStreaming; - final String hintText; - final AIType aiType; - - @override - State createState() => _ChatInputState(); -} - -/// [ChatInput] widget state. -class _ChatInputState extends State { - final GlobalKey _textFieldKey = GlobalKey(); - final LayerLink _layerLink = LayerLink(); - late ChatInputActionControl _inputActionControl; - late FocusNode _inputFocusNode; - late TextEditingController _textController; - bool _sendButtonEnabled = false; - - @override - void initState() { - super.initState(); - _textController = InputTextFieldController(); - _inputFocusNode = FocusNode( - onKeyEvent: (node, event) { - if (UniversalPlatform.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(_updateState); - - _inputActionControl = ChatInputActionControl( - chatId: widget.chatId, - textController: _textController, - textFieldFocusNode: _inputFocusNode, - ); - _inputFocusNode.requestFocus(); - _handleSendButtonVisibilityModeChange(); - } - - @override - void dispose() { - _inputFocusNode.removeListener(_updateState); - _inputFocusNode.dispose(); - _textController.dispose(); - _inputActionControl.dispose(); - super.dispose(); - } - - void _updateState() => setState(() {}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: inputPadding, - // ignore: use_decorated_box - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: _inputFocusNode.hasFocus - ? Theme.of(context).colorScheme.primary.withOpacity(0.6) - : Theme.of(context).colorScheme.secondary, - ), - borderRadius: borderRadius, - ), - child: Material( - borderRadius: borderRadius, - color: color, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (context.read().state.uploadFiles.isNotEmpty) - Padding( - padding: EdgeInsets.only( - top: 12, - bottom: 12, - left: textPadding.left + sendButtonSize, - right: textPadding.right, - ), - child: BlocBuilder( - builder: (context, state) { - return ChatInputFile( - chatId: widget.chatId, - files: state.uploadFiles, - onDeleted: (file) => context.read().add( - ChatFileEvent.deleteFile(file), - ), - ); - }, - ), - ), - - // - Row( - children: [ - // TODO(lucas): support mobile - if (UniversalPlatform.isDesktop && - widget.aiType.isLocalAI()) - _attachmentButton(buttonPadding), - - // text field - Expanded(child: _inputTextField(context, textPadding)), - - // mention button - _mentionButton(buttonPadding), - - if (UniversalPlatform.isMobile) const HSpace(6.0), - - // send button - _sendButton(buttonPadding), - ], - ), - ], - ), - ), - ), - ), - ); - } - - void _handleSendButtonVisibilityModeChange() { - _textController.removeListener(_handleTextControllerChange); - _sendButtonEnabled = - _textController.text.trim() != '' || widget.isStreaming; - _textController.addListener(_handleTextControllerChange); - } - - void _handleSendPressed() { - final trimmedText = _textController.text.trim(); - if (trimmedText != '') { - // consume metadata - final ChatInputMentionMetadata mentionPageMetadata = - _inputActionControl.consumeMetaData(); - final ChatInputFileMetadata fileMetadata = - context.read().consumeMetaData(); - - // combine metadata - final Map metadata = {} - ..addAll(mentionPageMetadata) - ..addAll(fileMetadata); - - final partialText = types.PartialText( - text: trimmedText, - metadata: metadata, - ); - widget.onSendPressed(partialText); - _textController.clear(); - } - } - - void _handleTextControllerChange() { - if (_textController.value.isComposingRangeValid) { - return; - } - setState(() { - _sendButtonEnabled = _textController.text.trim() != ''; - }); - } - - Widget _inputTextField(BuildContext context, EdgeInsets textPadding) { - return CompositedTransformTarget( - link: _layerLink, - child: Padding( - padding: textPadding, - child: ExtendedTextField( - key: _textFieldKey, - controller: _textController, - focusNode: _inputFocusNode, - decoration: _buildInputDecoration(context), - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - minLines: 1, - maxLines: 10, - style: _buildTextStyle(context), - specialTextSpanBuilder: ChatInputTextSpanBuilder( - inputActionControl: _inputActionControl, - ), - onChanged: (text) { - _handleOnTextChange(context, text); - }, - ), - ), - ); - } - - InputDecoration _buildInputDecoration(BuildContext context) { - return InputDecoration( - border: InputBorder.none, - enabledBorder: InputBorder.none, - hintText: widget.hintText, - focusedBorder: InputBorder.none, - hintStyle: TextStyle( - color: AFThemeExtension.of(context).textColor.withOpacity(0.5), - ), - ); - } - - 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 _handleOnTextChange(BuildContext context, String text) async { - if (!_inputActionControl.onTextChanged(text)) { - return; - } - - if (UniversalPlatform.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().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() - .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 _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); - _handleSendButtonVisibilityModeChange(); - } -} - -final isMobile = defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; - -class ChatInputAnchor extends ChatAnchor { - ChatInputAnchor({ - required this.anchorKey, - required this.layerLink, - }); - - @override - final GlobalKey> anchorKey; - - @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.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; - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_attachment.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_attachment.dart deleted file mode 100644 index 954988da7c..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_attachment.dart +++ /dev/null @@ -1,30 +0,0 @@ -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, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_file.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_file.dart index ba6170a69a..b0e260fd08 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_file.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_file.dart @@ -1,130 +1,167 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.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/theme_extension.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'; +import '../layout_define.dart'; + class ChatInputFile extends StatelessWidget { const ChatInputFile({ - required this.chatId, - required this.files, - required this.onDeleted, super.key, + required this.chatId, + required this.onDeleted, }); - final List files; - final String chatId; - final Function(ChatFile) onDeleted; + final String chatId; + final void Function(ChatFile) onDeleted; @override Widget build(BuildContext context) { - final List children = files - .map( - (file) => ChatFilePreview( - chatId: chatId, - file: file, - onDeleted: onDeleted, + return BlocSelector>( + selector: (state) => state.uploadFiles, + builder: (context, files) { + if (files.isEmpty) { + return const SizedBox.shrink(); + } + return ListView.separated( + scrollDirection: Axis.horizontal, + padding: DesktopAIPromptSizes.attachedFilesBarPadding - + const EdgeInsets.only(top: 6), + separatorBuilder: (context, index) => const HSpace( + DesktopAIPromptSizes.attachedFilesPreviewSpacing - 6, ), - ) - .toList(); - - return Wrap( - spacing: 6, - runSpacing: 6, - children: children, + itemCount: files.length, + itemBuilder: (context, index) => ChatFilePreview( + chatId: chatId, + file: files[index], + onDeleted: () => onDeleted(files[index]), + ), + ); + }, ); } } -class ChatFilePreview extends StatelessWidget { +class ChatFilePreview extends StatefulWidget { const ChatFilePreview({ required this.chatId, required this.file, required this.onDeleted, super.key, }); + final String chatId; final ChatFile file; - final Function(ChatFile) onDeleted; + final VoidCallback onDeleted; + + @override + State createState() => _ChatFilePreviewState(); +} + +class _ChatFilePreviewState extends State { + bool isHover = false; @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => ChatInputFileBloc(chatId: chatId, file: file) - ..add(const ChatInputFileEvent.initial()), + create: (context) => ChatInputFileBloc(file: widget.file), child: BlocBuilder( builder: (context, state) { - return FlowyHover( - builder: (context, onHover) { - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 260, - ), - child: DecoratedBox( + return MouseRegion( + onEnter: (_) => setHover(true), + onExit: (_) => setHover(false), + child: Stack( + children: [ + Container( + margin: const EdgeInsetsDirectional.only(top: 6, end: 6), + constraints: const BoxConstraints(maxWidth: 240), decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border.all( + color: Theme.of(context).dividerColor, + ), borderRadius: BorderRadius.circular(8), ), - child: Stack( - clipBehavior: Clip.none, + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, - vertical: 14, + Container( + decoration: BoxDecoration( + color: AFThemeExtension.of(context).tint1, + borderRadius: BorderRadius.circular(8), ), - child: Row( - mainAxisSize: MainAxisSize.min, + height: 32, + width: 32, + child: Center( + child: FlowySvg( + FlowySvgs.page_m, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ), + ), + ), + const HSpace(8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - file.fileType.icon, - const HSpace(6), - Flexible( - child: FlowyText( - file.fileName, - fontSize: 12, - maxLines: 6, - ), + FlowyText( + widget.file.fileName, + fontSize: 12.0, + ), + FlowyText( + widget.file.fileType.name, + color: Theme.of(context).hintColor, + fontSize: 12.0, ), ], ), ), - if (onHover) - _CloseButton( - onPressed: () => onDeleted(file), - ).positioned(top: -6, right: -6), ], ), ), - ); - }, + if (isHover) + _CloseButton( + onTap: widget.onDeleted, + ).positioned(top: 0, right: 0), + ], + ), ); }, ), ); } + + void setHover(bool value) { + if (value != isHover) { + setState(() => isHover = value); + } + } } class _CloseButton extends StatelessWidget { - const _CloseButton({required this.onPressed}); - final VoidCallback onPressed; + const _CloseButton({required this.onTap}); + + final VoidCallback onTap; @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), + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + child: FlowySvg( + FlowySvgs.ai_close_filled_s, + color: AFThemeExtension.of(context).greyHover, + size: const Size.square(16), + ), ), - onPressed: onPressed, ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_span.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_span.dart index 1a474c4882..854211d95d 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_span.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_span.dart @@ -65,9 +65,7 @@ class AtText extends SpecialText { style: textStyle, recognizer: (TapGestureRecognizer() ..onTap = () { - if (onTap != null) { - onTap!(atText); - } + onTap?.call(atText); }), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_send_button.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_send_button.dart deleted file mode 100644 index 2de77e9362..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_send_button.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/material.dart'; - -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( - icon: FlowySvg( - FlowySvgs.ai_stream_stop_s, - size: const Size.square(20), - color: Theme.of(context).colorScheme.primary, - ), - onPressed: onStopStreaming, - radius: BorderRadius.circular(18), - fillColor: AFThemeExtension.of(context).lightGreyHover, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - ); - } else { - return FlowyIconButton( - fillColor: AFThemeExtension.of(context).lightGreyHover, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - radius: BorderRadius.circular(18), - icon: FlowySvg( - FlowySvgs.send_s, - size: const Size.square(14), - color: enabled - ? Theme.of(context).colorScheme.primary - : Colors.grey.shade600, - ), - onPressed: onSendPressed, - ); - } - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_ai_prompt_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_ai_prompt_input.dart new file mode 100644 index 0000000000..4f30171752 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_ai_prompt_input.dart @@ -0,0 +1,387 @@ +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/ai_prompt_input_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.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/startup/startup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart' as types; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'ai_prompt_buttons.dart'; +import 'chat_input_span.dart'; +import '../layout_define.dart'; + +class DesktopAIPromptInput extends StatefulWidget { + const DesktopAIPromptInput({ + super.key, + required this.chatId, + required this.indicateFocus, + this.options = const InputOptions(), + required this.isStreaming, + required this.onStopStreaming, + required this.onSubmitted, + }); + + final String chatId; + final bool indicateFocus; + final InputOptions options; + final bool isStreaming; + final void Function() onStopStreaming; + final void Function(types.PartialText) onSubmitted; + + @override + State createState() => _DesktopAIPromptInputState(); +} + +class _DesktopAIPromptInputState extends State { + final GlobalKey _textFieldKey = GlobalKey(); + final LayerLink _layerLink = LayerLink(); + + late final ChatInputActionControl _inputActionControl; + late final FocusNode _inputFocusNode; + late final TextEditingController _textController; + + late SendButtonState sendButtonState; + + @override + void initState() { + super.initState(); + + _textController = InputTextFieldController() + ..addListener(_handleTextControllerChange); + + _inputFocusNode = FocusNode( + onKeyEvent: (node, event) { + if (_inputActionControl.canHandleKeyEvent(event)) { + _inputActionControl.handleKeyEvent(event); + return KeyEventResult.handled; + } else { + return _handleEnterKeyWithoutShift( + event, + _textController, + widget.isStreaming, + _handleSendPressed, + ); + } + }, + )..addListener(() { + // refresh border color on focus change + if (widget.indicateFocus) { + setState(() {}); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _inputFocusNode.requestFocus(); + }); + + _inputActionControl = ChatInputActionControl( + chatId: widget.chatId, + textController: _textController, + textFieldFocusNode: _inputFocusNode, + ); + + updateSendButtonState(); + } + + @override + void didUpdateWidget(covariant oldWidget) { + updateSendButtonState(); + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _inputFocusNode.dispose(); + _textController.dispose(); + _inputActionControl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: _inputFocusNode.hasFocus && widget.indicateFocus + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + ), + borderRadius: DesktopAIPromptSizes.promptFrameRadius, + ), + child: Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: DesktopAIPromptSizes.attachedFilesBarPadding.vertical + + DesktopAIPromptSizes.attachedFilesPreviewHeight, + ), + child: TextFieldTapRegion( + child: ChatInputFile( + chatId: widget.chatId, + onDeleted: (file) => context + .read() + .add(AIPromptInputEvent.deleteFile(file)), + ), + ), + ), + Stack( + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + minHeight: DesktopAIPromptSizes.textFieldMinHeight + + DesktopAIPromptSizes.actionBarHeight, + maxHeight: 300, + ), + child: _inputTextField(context), + ), + Positioned.fill( + top: null, + child: TextFieldTapRegion( + child: Container( + height: DesktopAIPromptSizes.actionBarHeight, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: BlocBuilder( + builder: (context, state) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // _predefinedFormatButton(), + const Spacer(), + _mentionButton(), + const HSpace( + DesktopAIPromptSizes.actionBarButtonSpacing, + ), + if (UniversalPlatform.isDesktop && + state.supportChatWithFile) ...[ + _attachmentButton(), + const HSpace( + DesktopAIPromptSizes.actionBarButtonSpacing, + ), + ], + _sendButton(), + ], + ); + }, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + void updateSendButtonState() { + if (widget.isStreaming) { + sendButtonState = SendButtonState.streaming; + } else if (_textController.text.trim().isEmpty) { + sendButtonState = SendButtonState.disabled; + } else { + sendButtonState = SendButtonState.enabled; + } + } + + void _handleSendPressed() { + final trimmedText = _textController.text.trim(); + _textController.clear(); + if (trimmedText.isEmpty) { + return; + } + // consume metadata + final ChatInputMentionMetadata mentionPageMetadata = + _inputActionControl.consumeMetaData(); + final ChatInputFileMetadata fileMetadata = + context.read().consumeMetadata(); + + // combine metadata + final Map metadata = {} + ..addAll(mentionPageMetadata) + ..addAll(fileMetadata); + + final partialText = types.PartialText( + text: trimmedText, + metadata: metadata, + ); + widget.onSubmitted(partialText); + } + + void _handleTextControllerChange() { + if (_textController.value.isComposingRangeValid) { + return; + } + setState(() => updateSendButtonState()); + } + + Widget _inputTextField(BuildContext context) { + return CompositedTransformTarget( + link: _layerLink, + child: BlocBuilder( + builder: (context, state) { + return ExtendedTextField( + key: _textFieldKey, + controller: _textController, + focusNode: _inputFocusNode, + decoration: InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: DesktopAIPromptSizes.textFieldContentPadding.add( + const EdgeInsets.only( + bottom: DesktopAIPromptSizes.actionBarHeight, + ), + ), + hintText: switch (state.aiType) { + AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(), + AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr() + }, + hintStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).hintColor), + isCollapsed: true, + isDense: true, + ), + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + specialTextSpanBuilder: ChatInputTextSpanBuilder( + inputActionControl: _inputActionControl, + ), + onChanged: (text) { + _handleOnTextChange(context, text); + }, + ); + }, + ), + ); + } + + Future _handleOnTextChange(BuildContext context, String text) async { + if (!_inputActionControl.onTextChanged(text)) { + return; + } + + 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(); + } + + Widget _mentionButton() { + return PromptInputMentionButton( + iconSize: DesktopAIPromptSizes.actionBarIconSize, + buttonSize: DesktopAIPromptSizes.actionBarButtonSize, + onTap: () { + _textController.text += '@'; + if (!_inputFocusNode.hasFocus) { + _inputFocusNode.requestFocus(); + } + _handleOnTextChange(context, _textController.text); + }, + ); + } + + Widget _attachmentButton() { + return PromptInputAttachmentButton( + onTap: () async { + final path = await getIt().pickFiles( + dialogTitle: '', + type: FileType.custom, + allowedExtensions: ["pdf", "txt", "md"], + ); + + if (path == null) { + return; + } + + for (final file in path.files) { + if (file.path != null) { + if (mounted) { + context + .read() + .add(AIPromptInputEvent.newFile(file.path!, file.name)); + } + } + } + }, + ); + } + + Widget _sendButton() { + return PromptInputSendButton( + buttonSize: DesktopAIPromptSizes.actionBarButtonSize, + iconSize: DesktopAIPromptSizes.sendButtonSize, + state: sendButtonState, + onSendPressed: _handleSendPressed, + onStopStreaming: widget.onStopStreaming, + ); + } +} + +class ChatInputAnchor extends ChatAnchor { + ChatInputAnchor({ + required this.anchorKey, + required this.layerLink, + }); + + @override + final GlobalKey> anchorKey; + + @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.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; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/layout_define.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/layout_define.dart deleted file mode 100644 index 74a66d0130..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/layout_define.dart +++ /dev/null @@ -1,13 +0,0 @@ -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; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_ai_prompt_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_ai_prompt_input.dart new file mode 100644 index 0000000000..230130a1b7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_ai_prompt_input.dart @@ -0,0 +1,283 @@ +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/ai_prompt_input_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/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/workspace/application/view/view_ext.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:extended_text_field/extended_text_field.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' as types; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; + +import 'ai_prompt_buttons.dart'; +import 'chat_input_span.dart'; +import '../layout_define.dart'; + +class MobileAIPromptInput extends StatefulWidget { + const MobileAIPromptInput({ + super.key, + required this.chatId, + this.options = const InputOptions(), + required this.isStreaming, + required this.onStopStreaming, + required this.onSubmitted, + }); + + final String chatId; + final InputOptions options; + final bool isStreaming; + final void Function() onStopStreaming; + final void Function(types.PartialText) onSubmitted; + + @override + State createState() => _MobileAIPromptInputState(); +} + +class _MobileAIPromptInputState extends State { + late final ChatInputActionControl _inputActionControl; + late final FocusNode _inputFocusNode; + late final TextEditingController _textController; + + late SendButtonState sendButtonState; + + @override + void initState() { + super.initState(); + + _textController = InputTextFieldController() + ..addListener(_handleTextControllerChange); + + _inputFocusNode = FocusNode(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _inputFocusNode.requestFocus(); + }); + + _inputActionControl = ChatInputActionControl( + chatId: widget.chatId, + textController: _textController, + textFieldFocusNode: _inputFocusNode, + ); + + updateSendButtonState(); + } + + @override + void didUpdateWidget(covariant oldWidget) { + updateSendButtonState(); + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _inputFocusNode.dispose(); + _textController.dispose(); + _inputActionControl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Hero( + tag: "ai_chat_prompt", + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).colorScheme.outline), + ), + color: Theme.of(context).colorScheme.surface, + boxShadow: const [ + BoxShadow( + blurRadius: 4.0, + offset: Offset(0, -2), + color: Color.fromRGBO(0, 0, 0, 0.05), + ), + ], + borderRadius: MobileAIPromptSizes.promptFrameRadius, + ), + child: Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + MobileAIPromptSizes.attachedFilesBarPadding.vertical + + MobileAIPromptSizes.attachedFilesPreviewHeight, + ), + child: ChatInputFile( + chatId: widget.chatId, + onDeleted: (file) => context + .read() + .add(AIPromptInputEvent.deleteFile(file)), + ), + ), + Container( + constraints: const BoxConstraints( + minHeight: MobileAIPromptSizes.textFieldMinHeight, + maxHeight: 220, + ), + padding: const EdgeInsetsDirectional.fromSTEB(0, 8.0, 12.0, 8.0), + child: IntrinsicHeight( + child: Row( + children: [ + const HSpace(6.0), + Expanded(child: _inputTextField(context)), + _mentionButton(), + const HSpace(6.0), + _sendButton(), + ], + ), + ), + ), + ], + ), + ), + ); + } + + void updateSendButtonState() { + if (widget.isStreaming) { + sendButtonState = SendButtonState.streaming; + } else if (_textController.text.trim().isEmpty) { + sendButtonState = SendButtonState.disabled; + } else { + sendButtonState = SendButtonState.enabled; + } + } + + void _handleSendPressed() { + final trimmedText = _textController.text.trim(); + _textController.clear(); + if (trimmedText.isEmpty) { + return; + } + // consume metadata + final ChatInputMentionMetadata mentionPageMetadata = + _inputActionControl.consumeMetaData(); + final ChatInputFileMetadata fileMetadata = + context.read().consumeMetadata(); + + // combine metadata + final Map metadata = {} + ..addAll(mentionPageMetadata) + ..addAll(fileMetadata); + + final partialText = types.PartialText( + text: trimmedText, + metadata: metadata, + ); + widget.onSubmitted(partialText); + } + + void _handleTextControllerChange() { + if (_textController.value.isComposingRangeValid) { + return; + } + setState(() => updateSendButtonState()); + } + + Widget _inputTextField(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return ExtendedTextField( + controller: _textController, + focusNode: _inputFocusNode, + decoration: _buildInputDecoration(state), + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + specialTextSpanBuilder: ChatInputTextSpanBuilder( + inputActionControl: _inputActionControl, + ), + onChanged: (text) { + _handleOnTextChange(context, text); + }, + ); + }, + ); + } + + InputDecoration _buildInputDecoration(AIPromptInputState state) { + return InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: MobileAIPromptSizes.textFieldContentPadding, + hintText: switch (state.aiType) { + AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(), + AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr() + }, + isCollapsed: true, + isDense: true, + ); + } + + Future _handleOnTextChange(BuildContext context, String text) async { + if (!_inputActionControl.onTextChanged(text)) { + return; + } + + // 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 _mentionButton() { + return Align( + alignment: Alignment.bottomCenter, + child: PromptInputMentionButton( + iconSize: MobileAIPromptSizes.mentionIconSize, + buttonSize: MobileAIPromptSizes.sendButtonSize, + onTap: () { + _textController.text += '@'; + if (!_inputFocusNode.hasFocus) { + _inputFocusNode.requestFocus(); + } + _handleOnTextChange(context, _textController.text); + }, + ), + ); + } + + Widget _sendButton() { + return Align( + alignment: Alignment.bottomCenter, + child: PromptInputSendButton( + buttonSize: MobileAIPromptSizes.sendButtonSize, + iconSize: MobileAIPromptSizes.sendButtonSize, + onSendPressed: _handleSendPressed, + onStopStreaming: widget.onStopStreaming, + state: sendButtonState, + ), + ); + } + + Future _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.onSelected(ViewActionPage(view: selectedView)); + } + handler.onExit(); + _inputFocusNode.requestFocus(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart index 2378379e6e..d23e966ecd 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input_action_menu.dart @@ -4,8 +4,7 @@ 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:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -151,15 +150,21 @@ class _ActionItem extends StatelessWidget { : 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, + child: FlowyTooltip( + message: item.title, + child: FlowyButton( + leftIcon: item.icon, + margin: const EdgeInsets.symmetric(horizontal: 6), + iconPadding: 10.0, + text: FlowyText( + item.title.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : item.title, + lineHeight: 1.0, + overflow: TextOverflow.ellipsis, + ), + onTap: onTap, ), - onTap: onTap, ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_loading.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_loading.dart index 9c4bd64cb6..b7a1251308 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_loading.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_loading.dart @@ -1,69 +1,76 @@ -import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:shimmer/shimmer.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +/// An animated generating indicator for an AI response class ChatAILoading extends StatelessWidget { - const ChatAILoading({super.key}); + const ChatAILoading({ + super.key, + this.duration = const Duration(seconds: 1), + }); + + final Duration duration; @override Widget build(BuildContext context) { - return Shimmer.fromColors( - baseColor: AFThemeExtension.of(context).lightGreyHover, - highlightColor: - AFThemeExtension.of(context).lightGreyHover.withOpacity(0.5), - period: const Duration(seconds: 3), - child: const ContentPlaceholder(), - ); - } -} - -class ContentPlaceholder extends StatelessWidget { - const ContentPlaceholder({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5); + return SizedBox( + height: 20, + child: SeparatedRow( + separatorBuilder: () => const HSpace(4), children: [ - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 30, - height: 16.0, - margin: const EdgeInsets.only(bottom: 8.0), - decoration: BoxDecoration( - color: AFThemeExtension.of(context).lightGreyHover, - borderRadius: BorderRadius.circular(4.0), - ), - ), - const HSpace(10), - Container( - width: 100, - height: 16.0, - margin: const EdgeInsets.only(bottom: 8.0), - decoration: BoxDecoration( - color: AFThemeExtension.of(context).lightGreyHover, - borderRadius: BorderRadius.circular(4.0), - ), - ), - ], + Padding( + padding: const EdgeInsetsDirectional.only(end: 4.0), + child: FlowyText( + LocaleKeys.chat_generatingResponse.tr(), + color: Theme.of(context).hintColor, + ), ), - // Container( - // width: 140, - // height: 16.0, - // margin: const EdgeInsets.only(bottom: 8.0), - // decoration: BoxDecoration( - // color: AFThemeExtension.of(context).lightGreyHover, - // borderRadius: BorderRadius.circular(4.0), - // ), - // ), + buildDot(const Color(0xFF9327FF)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice, begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0) + .then() + .slideY(duration: slice * 2, begin: 0, end: 0), + buildDot(const Color(0xFFFB006D)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice, begin: 0, end: 0) + .then() + .slideY(begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0) + .then() + .slideY(begin: 0, end: 0), + buildDot(const Color(0xFFFFCE00)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice * 2, begin: 0, end: 0) + .then() + .slideY(duration: slice, begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0), ], ), ); } + + Widget buildDot(Color color) { + return SizedBox.square( + dimension: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_popmenu.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_popmenu.dart deleted file mode 100644 index 6b0b50dcca..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_popmenu.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; - -import 'package:flutter/material.dart'; - -class ChatPopupMenu extends StatefulWidget { - const ChatPopupMenu({ - super.key, - required this.onAction, - required this.builder, - }); - - final Function(ChatMessageAction) onAction; - final Widget Function(BuildContext context) builder; - - @override - State createState() => _ChatPopupMenuState(); -} - -class _ChatPopupMenuState extends State { - @override - Widget build(BuildContext context) { - return PopoverActionList( - asBarrier: true, - actions: ChatMessageAction.values - .map((action) => ChatMessageActionWrapper(action)) - .toList(), - buildChild: (controller) { - return GestureDetector( - onLongPress: () { - controller.show(); - }, - child: widget.builder(context), - ); - }, - onSelected: (action, controller) async { - widget.onAction(action.inner); - controller.close(); - }, - direction: PopoverDirection.bottomWithCenterAligned, - ); - } -} - -enum ChatMessageAction { - copy, -} - -class ChatMessageActionWrapper extends ActionCell { - ChatMessageActionWrapper(this.inner); - - final ChatMessageAction inner; - - @override - Widget? leftIcon(Color iconColor) => null; - - @override - String get name => inner.name; -} - -extension ChatMessageActionExtension on ChatMessageAction { - String get name { - switch (this) { - case ChatMessageAction.copy: - return LocaleKeys.document_plugins_contextMenu_copy.tr(); - } - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart index 22c8fa90de..9f2fb2350b 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart @@ -2,61 +2,46 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.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/button.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:universal_platform/universal_platform.dart'; class RelatedQuestionList extends StatelessWidget { const RelatedQuestionList({ - required this.chatId, + super.key, required this.onQuestionSelected, required this.relatedQuestions, - super.key, }); - final String chatId; final Function(String) onQuestionSelected; final List relatedQuestions; @override Widget build(BuildContext context) { - return ListView.builder( + return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: relatedQuestions.length, + itemCount: relatedQuestions.length + 1, + padding: const EdgeInsets.only(bottom: 8.0) + + (UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(horizontal: 16) + : EdgeInsets.zero), + separatorBuilder: (context, index) => const VSpace(4.0), itemBuilder: (context, index) { - final question = relatedQuestions[index]; if (index == 0) { - return Column( - children: [ - const Divider(height: 36), - Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - FlowySvg( - FlowySvgs.ai_summary_generate_s, - size: const Size.square(24), - color: Theme.of(context).colorScheme.primary, - ), - const HSpace(6), - FlowyText( - LocaleKeys.chat_relatedQuestion.tr(), - fontSize: 18, - ), - ], - ), - ), - const Divider(height: 6), - RelatedQuestionItem( - question: question, - onQuestionSelected: onQuestionSelected, - ), - ], + return Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText( + LocaleKeys.chat_relatedQuestion.tr(), + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w600, + ), ); } else { return RelatedQuestionItem( - question: question, + question: relatedQuestions[index - 1], onQuestionSelected: onQuestionSelected, ); } @@ -65,7 +50,7 @@ class RelatedQuestionList extends StatelessWidget { } } -class RelatedQuestionItem extends StatefulWidget { +class RelatedQuestionItem extends StatelessWidget { const RelatedQuestionItem({ required this.question, required this.onQuestionSelected, @@ -75,38 +60,23 @@ class RelatedQuestionItem extends StatefulWidget { final RelatedQuestionPB question; final Function(String) onQuestionSelected; - @override - State createState() => _RelatedQuestionItemState(); -} - -class _RelatedQuestionItemState extends State { - bool _isHovered = false; - @override Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - ), - title: Text( - widget.question.content, - style: TextStyle( - color: _isHovered ? Theme.of(context).colorScheme.primary : null, - fontSize: 14, - height: 1.5, - ), - ), - onTap: () { - widget.onQuestionSelected(widget.question.content); - }, - trailing: FlowySvg( - FlowySvgs.add_m, - color: Theme.of(context).colorScheme.primary, - ), + return FlowyButton( + text: FlowyText( + question.content, + lineHeight: 1.4, + overflow: TextOverflow.ellipsis, ), + margin: UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0) + : const EdgeInsets.all(8.0), + leftIcon: FlowySvg( + FlowySvgs.ai_chat_outlined_s, + color: Theme.of(context).colorScheme.primary, + size: const Size.square(16.0), + ), + onTap: () => onQuestionSelected(question.content), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_side_pannel.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_side_panel.dart similarity index 50% rename from frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_side_pannel.dart rename to frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_side_panel.dart index f3899308ce..7904b44437 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_side_pannel.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_side_panel.dart @@ -1,17 +1,17 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/plugins/ai_chat/application/chat_side_pannel_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_side_panel_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}); +class ChatSidePanel extends StatelessWidget { + const ChatSidePanel({super.key}); @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { return state.indicator.when( loading: () { @@ -24,26 +24,23 @@ class ChatSidePannel extends StatelessWidget { 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() - .add(const ChatSidePannelEvent.close()); - }, - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: FlowyIconButton( + icon: const FlowySvg(FlowySvgs.show_menu_s), + onPressed: () { + context + .read() + .add(const ChatSidePanelEvent.close()); + }, ), - const VSpace(6), - Expanded(child: child), - ], - ), + ), + const VSpace(6), + Expanded(child: child), + ], ); }, ); diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_stream_text_field.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_stream_text_field.dart deleted file mode 100644 index 7589a1b634..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_stream_text_field.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class StreamTextField extends StatelessWidget { - const StreamTextField({super.key}); - - @override - Widget build(BuildContext context) { - return const Placeholder(); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_streaming_error_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_streaming_error_message.dart deleted file mode 100644 index 1f825d3355..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_streaming_error_message.dart +++ /dev/null @@ -1,83 +0,0 @@ -// import 'package:appflowy/generated/locale_keys.g.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_chat_types/flutter_chat_types.dart'; - -// class ChatStreamingError extends StatelessWidget { -// const ChatStreamingError({ -// required this.message, -// required this.onRetryPressed, -// super.key, -// }); - -// final void Function() onRetryPressed; -// final Message message; -// @override -// Widget build(BuildContext context) { -// final canRetry = message.metadata?[canRetryKey] != null; - -// if (canRetry) { -// return Column( -// children: [ -// const Divider(height: 4, thickness: 1), -// const VSpace(16), -// Center( -// child: Column( -// children: [ -// _aiUnvaliable(), -// const VSpace(10), -// _retryButton(), -// ], -// ), -// ), -// ], -// ); -// } else { -// return Center( -// child: Column( -// children: [ -// const Divider(height: 20, thickness: 1), -// Padding( -// padding: const EdgeInsets.all(8.0), -// child: FlowyText( -// LocaleKeys.chat_serverUnavailable.tr(), -// fontSize: 14, -// ), -// ), -// ], -// ), -// ); -// } -// } - -// FlowyButton _retryButton() { -// return FlowyButton( -// radius: BorderRadius.circular(20), -// useIntrinsicWidth: true, -// text: Padding( -// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), -// child: FlowyText( -// LocaleKeys.chat_regenerateAnswer.tr(), -// fontSize: 14, -// ), -// ), -// onTap: onRetryPressed, -// iconPadding: 0, -// leftIcon: const Icon( -// Icons.refresh, -// size: 20, -// ), -// ); -// } - -// Padding _aiUnvaliable() { -// return Padding( -// padding: const EdgeInsets.all(8.0), -// child: FlowyText( -// LocaleKeys.chat_aiServerUnavailable.tr(), -// fontSize: 14, -// ), -// ); -// } -// } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_theme.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_theme.dart index 456ac0c184..4a70268424 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_theme.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_theme.dart @@ -1,222 +1,85 @@ import 'package:flutter/material.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; -// For internal usage only. Use values from theme itself. - -/// See [ChatTheme.userAvatarNameColors]. -const colors = [ - Color(0xffff6767), - Color(0xff66e0da), - Color(0xfff5a2d9), - Color(0xfff0c722), - Color(0xff6a85e5), - Color(0xfffd9a6f), - Color(0xff92db6e), - Color(0xff73b8e5), - Color(0xfffd7590), - Color(0xffc78ae5), -]; - -/// Dark. -const dark = Color(0xff1f1c38); - -/// Error. -const error = Color(0xffff6767); - -/// N0. -const neutral0 = Color(0xff1d1c21); - -/// N1. -const neutral1 = Color(0xff615e6e); - -/// N2. -const neutral2 = Color(0xff9e9cab); - -/// N7. -const neutral7 = Color(0xffffffff); - -/// N7 with opacity. -const neutral7WithOpacity = Color(0x80ffffff); - -/// Primary. -const primary = Color(0xff6f61e8); - -/// Secondary. -const secondary = Color(0xfff5f5f7); - -/// Secondary dark. -const secondaryDark = Color(0xff2b2250); - /// Default chat theme which extends [ChatTheme]. @immutable class AFDefaultChatTheme extends ChatTheme { - /// Creates a default chat theme. Use this constructor if you want to - /// override only a couple of properties, otherwise create a new class - /// which extends [ChatTheme]. const AFDefaultChatTheme({ - super.attachmentButtonIcon, - super.attachmentButtonMargin, - super.backgroundColor = neutral7, - super.bubbleMargin, - super.dateDividerMargin = const EdgeInsets.only( - bottom: 32, - top: 16, - ), - super.dateDividerTextStyle = const TextStyle( - color: neutral2, - fontSize: 12, - fontWeight: FontWeight.w800, - height: 1.333, - ), - super.deliveredIcon, - super.documentIcon, - super.emptyChatPlaceholderTextStyle = const TextStyle( - color: neutral2, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - super.errorColor = error, - super.errorIcon, - super.inputBackgroundColor = neutral0, - super.inputSurfaceTintColor = neutral0, - super.inputElevation = 0, - super.inputBorderRadius = const BorderRadius.vertical( - top: Radius.circular(20), - ), - super.inputContainerDecoration, - super.inputMargin = EdgeInsets.zero, - super.inputPadding = const EdgeInsets.fromLTRB(14, 20, 14, 20), - super.inputTextColor = neutral7, - super.inputTextCursorColor, - super.inputTextDecoration = const InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - isCollapsed: true, - ), - super.inputTextStyle = const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - super.messageBorderRadius = 20, - super.messageInsetsHorizontal = 0, - super.messageInsetsVertical = 0, - super.messageMaxWidth = 1000, - super.primaryColor = primary, - super.receivedEmojiMessageTextStyle = const TextStyle(fontSize: 40), - super.receivedMessageBodyBoldTextStyle, - super.receivedMessageBodyCodeTextStyle, - super.receivedMessageBodyLinkTextStyle, - super.receivedMessageBodyTextStyle = const TextStyle( - color: neutral0, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - super.receivedMessageCaptionTextStyle = const TextStyle( - color: neutral2, - fontSize: 12, - fontWeight: FontWeight.w500, - height: 1.333, - ), - super.receivedMessageDocumentIconColor = primary, - super.receivedMessageLinkDescriptionTextStyle = const TextStyle( - color: neutral0, - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1.428, - ), - super.receivedMessageLinkTitleTextStyle = const TextStyle( - color: neutral0, - fontSize: 16, - fontWeight: FontWeight.w800, - height: 1.375, - ), - super.secondaryColor = secondary, - super.seenIcon, - super.sendButtonIcon, - super.sendButtonMargin, - super.sendingIcon, - super.sentEmojiMessageTextStyle = const TextStyle(fontSize: 40), - super.sentMessageBodyBoldTextStyle, - super.sentMessageBodyCodeTextStyle, - super.sentMessageBodyLinkTextStyle, - super.sentMessageBodyTextStyle = const TextStyle( - color: neutral7, - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.5, - ), - super.sentMessageCaptionTextStyle = const TextStyle( - color: neutral7WithOpacity, - fontSize: 12, - fontWeight: FontWeight.w500, - height: 1.333, - ), - super.sentMessageDocumentIconColor = neutral7, - super.sentMessageLinkDescriptionTextStyle = const TextStyle( - color: neutral7, - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1.428, - ), - super.sentMessageLinkTitleTextStyle = const TextStyle( - color: neutral7, - fontSize: 16, - fontWeight: FontWeight.w800, - height: 1.375, - ), - super.statusIconPadding = const EdgeInsets.symmetric(horizontal: 4), - super.systemMessageTheme = const SystemMessageTheme( - margin: EdgeInsets.only( - bottom: 24, - top: 8, - left: 8, - right: 8, - ), - textStyle: TextStyle( - color: neutral2, - fontSize: 12, - fontWeight: FontWeight.w800, - height: 1.333, - ), - ), - super.typingIndicatorTheme = const TypingIndicatorTheme( - animatedCirclesColor: neutral1, - animatedCircleSize: 5.0, - bubbleBorder: BorderRadius.all(Radius.circular(27.0)), - bubbleColor: neutral7, - countAvatarColor: primary, - countTextColor: secondary, - multipleUserTextStyle: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: neutral2, - ), - ), - super.unreadHeaderTheme = const UnreadHeaderTheme( - color: secondary, - textStyle: TextStyle( - color: neutral2, - fontSize: 12, - fontWeight: FontWeight.w500, - height: 1.333, - ), - ), - super.userAvatarImageBackgroundColor = Colors.transparent, - super.userAvatarNameColors = colors, - super.userAvatarTextStyle = const TextStyle( - color: neutral7, - fontSize: 12, - fontWeight: FontWeight.w800, - height: 1.333, - ), - super.userNameTextStyle = const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w800, - height: 1.333, - ), - super.highlightMessageColor, - }); + required super.primaryColor, + required super.secondaryColor, + }) : super( + backgroundColor: Colors.transparent, + // TODO: think how to offset the default 12 pixels set by chat package + bubbleMargin: const EdgeInsets.symmetric(vertical: 4.0), + messageMaxWidth: double.infinity, + // unused + dateDividerMargin: EdgeInsets.zero, + dateDividerTextStyle: const TextStyle(), + attachmentButtonIcon: null, + attachmentButtonMargin: null, + deliveredIcon: null, + documentIcon: null, + emptyChatPlaceholderTextStyle: const TextStyle(), + errorColor: error, + errorIcon: null, + inputBackgroundColor: neutral0, + inputSurfaceTintColor: neutral0, + inputElevation: 0, + inputBorderRadius: BorderRadius.zero, + inputContainerDecoration: null, + inputMargin: EdgeInsets.zero, + inputPadding: EdgeInsets.zero, + inputTextColor: neutral7, + inputTextCursorColor: null, + inputTextDecoration: const InputDecoration(), + inputTextStyle: const TextStyle(), + messageBorderRadius: 0, + messageInsetsHorizontal: 0, + messageInsetsVertical: 0, + receivedEmojiMessageTextStyle: const TextStyle(), + receivedMessageBodyBoldTextStyle: null, + receivedMessageBodyCodeTextStyle: null, + receivedMessageBodyLinkTextStyle: null, + receivedMessageBodyTextStyle: const TextStyle(), + receivedMessageDocumentIconColor: primary, + receivedMessageCaptionTextStyle: const TextStyle(), + receivedMessageLinkDescriptionTextStyle: const TextStyle(), + receivedMessageLinkTitleTextStyle: const TextStyle(), + seenIcon: null, + sendButtonIcon: null, + sendButtonMargin: null, + sendingIcon: null, + sentEmojiMessageTextStyle: const TextStyle(), + sentMessageBodyBoldTextStyle: null, + sentMessageBodyCodeTextStyle: null, + sentMessageBodyLinkTextStyle: null, + sentMessageBodyTextStyle: const TextStyle(), + sentMessageCaptionTextStyle: const TextStyle(), + sentMessageDocumentIconColor: neutral7, + sentMessageLinkDescriptionTextStyle: const TextStyle(), + sentMessageLinkTitleTextStyle: const TextStyle(), + statusIconPadding: EdgeInsets.zero, + systemMessageTheme: const SystemMessageTheme( + margin: EdgeInsets.zero, + textStyle: TextStyle(), + ), + typingIndicatorTheme: const TypingIndicatorTheme( + animatedCirclesColor: neutral1, + animatedCircleSize: 0.0, + bubbleBorder: BorderRadius.zero, + bubbleColor: neutral7, + countAvatarColor: primary, + countTextColor: secondary, + multipleUserTextStyle: TextStyle(), + ), + unreadHeaderTheme: const UnreadHeaderTheme( + color: secondary, + textStyle: TextStyle(), + ), + userAvatarImageBackgroundColor: Colors.transparent, + userAvatarNameColors: colors, + userAvatarTextStyle: const TextStyle(), + userNameTextStyle: const TextStyle(), + highlightMessageColor: null, + ); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart index 39ea8c29b9..9d381a9af9 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_user_invalid_message.dart @@ -1,7 +1,9 @@ +import 'package:appflowy/generated/flowy_svgs.g.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'; +import 'package:universal_platform/universal_platform.dart'; class ChatInvalidUserMessage extends StatelessWidget { const ChatInvalidUserMessage({ @@ -14,17 +16,33 @@ class ChatInvalidUserMessage extends StatelessWidget { Widget build(BuildContext context) { final errorMessage = message.metadata?[sendMessageErrorKey] ?? ""; return Center( - child: Column( - children: [ - const Divider(height: 20, thickness: 1), - Padding( - padding: const EdgeInsets.all(8.0), - child: FlowyText( - errorMessage, - fontSize: 14, + child: Container( + margin: const EdgeInsets.only(top: 16.0, bottom: 24.0), + constraints: UniversalPlatform.isDesktop + ? const BoxConstraints(maxWidth: 480) + : const BoxConstraints(), + padding: const EdgeInsets.all(8.0), + decoration: const BoxDecoration( + color: Color(0x80FFE7EE), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.warning_filled_s, + blendMode: null, ), - ), - ], + const HSpace(8.0), + Flexible( + child: FlowyText( + errorMessage, + lineHeight: 1.4, + maxLines: null, + ), + ), + ], + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart index 5524f1ffbe..7120c2c47f 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart @@ -1,24 +1,18 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/debounce.dart'; +import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.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 'chat_input/chat_input.dart'; - -class WelcomeQuestion { - WelcomeQuestion({ - required this.text, - required this.iconData, - }); - final String text; - final FlowySvgData iconData; -} +import 'package:flutter/rendering.dart'; +import 'package:time/time.dart'; +import 'package:universal_platform/universal_platform.dart'; class ChatWelcomePage extends StatelessWidget { - ChatWelcomePage({ + const ChatWelcomePage({ required this.userProfile, required this.onSelectedQuestion, super.key, @@ -27,112 +21,323 @@ class ChatWelcomePage extends StatelessWidget { final void Function(String) onSelectedQuestion; final UserProfilePB userProfile; - final List 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, - ), + static final List desktopItems = [ + LocaleKeys.chat_question1.tr(), + LocaleKeys.chat_question2.tr(), + LocaleKeys.chat_question3.tr(), + LocaleKeys.chat_question4.tr(), ]; + + static final List> mobileItems = [ + [ + LocaleKeys.chat_question1.tr(), + LocaleKeys.chat_question2.tr(), + ], + [ + LocaleKeys.chat_question3.tr(), + LocaleKeys.chat_question4.tr(), + ], + [ + LocaleKeys.chat_question5.tr(), + LocaleKeys.chat_question6.tr(), + ], + ]; + @override Widget build(BuildContext context) { - return AnimatedOpacity( - opacity: 1.0, - duration: const Duration(seconds: 3), - 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, - spacing: isMobile ? 12.0 : 0.0, - children: items - .map( - (i) => WelcomeQuestionWidget( - question: i, - onSelected: onSelectedQuestion, - ), - ) - .toList(), - ), - const VSpace(20), - ], + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.flowy_logo_xl, + size: Size.square(32), + blendMode: null, + ), + const VSpace(16), + FlowyText( + fontSize: 15, + LocaleKeys.chat_questionDetail.tr(args: [userProfile.name]), + ), + UniversalPlatform.isDesktop ? const VSpace(32 - 16) : const VSpace(24), + ...UniversalPlatform.isDesktop + ? buildDesktopSampleQuestions(context) + : buildMobileSampleQuestions(context), + ], + ); + } + + Iterable buildDesktopSampleQuestions(BuildContext context) { + return desktopItems.map( + (question) => Padding( + padding: const EdgeInsets.only(top: 16.0), + child: WelcomeSampleQuestion( + question: question, + onSelected: onSelectedQuestion, ), ), ); } + + Iterable buildMobileSampleQuestions(BuildContext context) { + return [ + _AutoScrollingSampleQuestions( + key: const ValueKey('inf_scroll_1'), + onSelected: onSelectedQuestion, + questions: mobileItems[0], + offset: 60.0, + ), + const VSpace(8), + _AutoScrollingSampleQuestions( + key: const ValueKey('inf_scroll_2'), + onSelected: onSelectedQuestion, + questions: mobileItems[1], + offset: -50.0, + reverse: true, + ), + const VSpace(8), + _AutoScrollingSampleQuestions( + key: const ValueKey('inf_scroll_3'), + onSelected: onSelectedQuestion, + questions: mobileItems[2], + offset: 120.0, + ), + ]; + } } -class WelcomeQuestionWidget extends StatelessWidget { - const WelcomeQuestionWidget({ +class WelcomeSampleQuestion extends StatelessWidget { + const WelcomeSampleQuestion({ required this.question, required this.onSelected, super.key, }); final void Function(String) onSelected; - final WelcomeQuestion question; + final String question; @override Widget build(BuildContext context) { - return InkWell( - onTap: () => onSelected(question.text), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - child: FlowyHover( - // Make the hover effect only available on mobile - isSelected: () => isMobile, - style: HoverStyle( - borderRadius: BorderRadius.circular(6), + final isLightMode = Theme.of(context).isLightMode; + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + offset: const Offset(0, 1), + blurRadius: 2, + spreadRadius: -2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), ), - child: Padding( - 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.text, - maxLines: null, - ), - ], + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 4, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 8, + spreadRadius: 2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + ], + ), + child: TextButton( + onPressed: () => onSelected(question), + style: ButtonStyle( + padding: WidgetStatePropertyAll( + EdgeInsets.symmetric( + horizontal: 16, + vertical: UniversalPlatform.isDesktop ? 8 : 0, ), ), + backgroundColor: WidgetStateProperty.resolveWith((state) { + if (state.contains(WidgetState.hovered)) { + return isLightMode + ? const Color(0xFFF9FAFD) + : AFThemeExtension.of(context).lightGreyHover; + } + return Theme.of(context).colorScheme.surface; + }), + overlayColor: WidgetStateColor.transparent, + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + ), + child: FlowyText( + question, + color: isLightMode + ? Theme.of(context).hintColor + : const Color(0xFF666D76), ), ), ); } } + +class _AutoScrollingSampleQuestions extends StatefulWidget { + const _AutoScrollingSampleQuestions({ + super.key, + required this.questions, + this.offset = 0.0, + this.reverse = false, + required this.onSelected, + }); + + final List questions; + final void Function(String) onSelected; + final double offset; + final bool reverse; + + @override + State<_AutoScrollingSampleQuestions> createState() => + _AutoScrollingSampleQuestionsState(); +} + +class _AutoScrollingSampleQuestionsState + extends State<_AutoScrollingSampleQuestions> { + final restartAutoScrollDebounce = Debounce(duration: 3.seconds); + late final ScrollController scrollController; + + bool userIntervened = false; + + @override + void initState() { + super.initState(); + scrollController = ScrollController( + onAttach: (_) => startScroll(), + initialScrollOffset: widget.offset, + ); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (notification) { + if (notification is ScrollUpdateNotification) { + return false; + } + if (notification is ScrollEndNotification && !userIntervened) { + startScroll(); + } else if (notification is UserScrollNotification && + notification.direction == ScrollDirection.idle) { + scheduleRestart(); + } + return false; + }, + child: SizedBox( + height: 36, + child: Stack( + children: [ + InfiniteScrollView( + centerKey: UniqueKey(), + scrollController: scrollController, + itemCount: widget.questions.length, + itemBuilder: (context, index) { + return WelcomeSampleQuestion( + question: widget.questions[index], + onSelected: widget.onSelected, + ); + }, + separatorBuilder: (context, index) => const HSpace(8), + ), + Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + userIntervened = true; + scrollController.jumpTo(scrollController.offset); + }, + ), + ], + ), + ), + ); + } + + void startScroll() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final delta = widget.reverse ? -250 : 250; + scrollController.animateTo( + scrollController.offset + delta, + duration: 20.seconds, + curve: Curves.linear, + ); + }); + } + + void scheduleRestart() { + restartAutoScrollDebounce.call(() { + userIntervened = false; + startScroll(); + }); + } +} + +class InfiniteScrollView extends StatelessWidget { + const InfiniteScrollView({ + super.key, + required this.itemCount, + required this.centerKey, + required this.itemBuilder, + required this.separatorBuilder, + this.scrollController, + }); + + final int itemCount; + final Widget Function(BuildContext context, int index) itemBuilder; + final Widget Function(BuildContext context, int index) separatorBuilder; + final Key centerKey; + + final ScrollController? scrollController; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + center: centerKey, + anchor: 0.5, + slivers: [ + _buildList(isForward: false), + SliverToBoxAdapter( + child: separatorBuilder.call(context, 0), + ), + SliverToBoxAdapter( + key: centerKey, + child: itemBuilder.call(context, 0), + ), + SliverToBoxAdapter( + child: separatorBuilder.call(context, 0), + ), + _buildList(isForward: true), + ], + ); + } + + Widget _buildList({required bool isForward}) { + return SliverList.separated( + itemBuilder: (context, index) { + index = (index + 1) % itemCount; + return itemBuilder(context, index); + }, + separatorBuilder: (context, index) { + index = (index + 1) % itemCount; + return separatorBuilder(context, index); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart new file mode 100644 index 0000000000..1cd7fd4eb6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class AIChatUILayout { + static double get messageWidthRatio => 0.94; // Chat adds extra 0.06 + + static EdgeInsets safeAreaInsets(BuildContext context) { + final query = MediaQuery.of(context); + return UniversalPlatform.isMobile + ? EdgeInsets.fromLTRB( + query.padding.left, + 0, + query.padding.right, + query.viewInsets.bottom + query.padding.bottom, + ) + : const EdgeInsets.only(bottom: 16); + } +} + +class DesktopAIPromptSizes { + static const promptFrameRadius = BorderRadius.all(Radius.circular(8)); + + static const attachedFilesBarPadding = + EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0); + static const attachedFilesPreviewHeight = 48.0; + static const attachedFilesPreviewSpacing = 12.0; + + static const textFieldMinHeight = 36.0; + static const textFieldContentPadding = + EdgeInsetsDirectional.fromSTEB(12.0, 12.0, 12.0, 4.0); + + static const actionBarHeight = 28.0; + static const actionBarButtonSize = 24.0; + static const actionBarIconSize = 16.0; + static const actionBarButtonSpacing = 4.0; + static const sendButtonSize = 20.0; +} + +class MobileAIPromptSizes { + static const promptFrameRadius = + BorderRadius.vertical(top: Radius.circular(8.0)); + + static const attachedFilesBarHeight = 68.0; + static const attachedFilesBarPadding = + EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0, bottom: 4.0); + static const attachedFilesPreviewHeight = 56.0; + static const attachedFilesPreviewSpacing = 8.0; + + static const textFieldMinHeight = 48.0; + static const textFieldContentPadding = EdgeInsets.all(8.0); + + static const mentionIconSize = 20.0; + static const sendButtonSize = 32.0; +} + +class DesktopAIConvoSizes { + static const avatarSize = 32.0; + + static const avatarAndChatBubbleSpacing = 12.0; + + static const actionBarIconSize = 24.0; + static const actionBarIconSpacing = 8.0; + static const actionBarIconRadius = BorderRadius.all(Radius.circular(8.0)); +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart index f8669ffa09..48626b4895 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart @@ -1,16 +1,18 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/util/theme_extension.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/theme_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:markdown_widget/markdown_widget.dart'; +import '../chat_editor_style.dart'; import 'selectable_highlight.dart'; enum AIMarkdownType { @@ -98,8 +100,8 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> { @override Widget build(BuildContext context) { // don't lazy load the styleCustomizer and blockBuilders, - // it needs the context to get the theme. - final styleCustomizer = EditorStyleCustomizer( + // it needs the context to get the theme. + final styleCustomizer = ChatEditorStyleCustomizer( context: context, padding: EdgeInsets.zero, ); @@ -126,6 +128,15 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> { commandShortcutEvents: [customCopyCommand], disableAutoScroll: true, editorState: editorState, + contextMenuItems: [ + [ + ContextMenuItem( + getName: LocaleKeys.document_plugins_contextMenu_copy.tr, + onPressed: (editorState) => + customCopyCommand.execute(editorState), + ), + ] + ], ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart index d13cd94071..75945bb542 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart @@ -2,80 +2,117 @@ 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_entity.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.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/util/theme_extension.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:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart'; -import 'package:styled_widget/styled_widget.dart'; +import 'package:universal_platform/universal_platform.dart'; -const _leftPadding = 16.0; +import '../layout_define.dart'; +/// Wraps an AI response message with the avatar and actions. On desktop, +/// the actions will be displayed below the response if the response is the +/// last message in the chat. For other AI responses, the actions will be shown +/// on hover. On mobile, the actions will be displayed in a bottom sheet on +/// long press. class ChatAIMessageBubble extends StatelessWidget { const ChatAIMessageBubble({ super.key, required this.message, required this.child, - this.customMessageType, + required this.showActions, + this.isLastMessage = false, }); final Message message; final Widget child; - final OnetimeShotType? customMessageType; + final bool showActions; + final bool isLastMessage; @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); - - return Row( - mainAxisSize: MainAxisSize.min, + final avatarAndMessage = Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const ChatBorderedCircleAvatar( - child: FlowySvg( - FlowySvgs.flowy_logo_s, - size: Size.square(20), - blendMode: null, - ), - ), - Expanded(child: widget), + const ChatAIAvatar(), + const HSpace(DesktopAIConvoSizes.avatarAndChatBubbleSpacing), + Expanded(child: child), ], ); + + return showActions + ? UniversalPlatform.isMobile + ? _wrapPopMenu(avatarAndMessage) + : isLastMessage + ? _wrapBottomActions(avatarAndMessage) + : _wrapHover(avatarAndMessage) + : avatarAndMessage; } - ChatAIMessageHover _wrapHover(Padding child) { - return ChatAIMessageHover( + Widget _wrapBottomActions(Widget child) { + return ChatAIBottomInlineActions( message: message, - customMessageType: customMessageType, 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, + Widget _wrapHover(Widget child) { + return ChatAIMessageHover( + message: message, + child: child, + ); + } + + Widget _wrapPopMenu(Widget child) { + return ChatAIMessagePopup( + message: message, + child: child, + ); + } +} + +class ChatAIBottomInlineActions extends StatelessWidget { + const ChatAIBottomInlineActions({ + super.key, + required this.child, + required this.message, + }); + + final Widget child; + final Message message; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + const VSpace(16.0), + Padding( + padding: const EdgeInsetsDirectional.only( + start: DesktopAIConvoSizes.avatarSize + + DesktopAIConvoSizes.avatarAndChatBubbleSpacing, + ), + child: AIResponseActionBar( + showDecoration: false, + children: [ + CopyButton( + textMessage: message as TextMessage, + ), + ], + ), + ), + const VSpace(32.0), + ], ); } } @@ -85,81 +122,208 @@ class ChatAIMessageHover extends StatefulWidget { super.key, required this.child, required this.message, - this.customMessageType, }); final Widget child; final Message message; - final bool autoShowHover = true; - final OnetimeShotType? customMessageType; @override State createState() => _ChatAIMessageHoverState(); } class _ChatAIMessageHoverState extends State { - bool _isHover = false; + final controller = OverlayPortalController(); + final layerLink = LayerLink(); + + bool hoverBubble = false; + bool hoverActionBar = false; + + ScrollPosition? scrollPosition; @override void initState() { super.initState(); - _isHover = widget.autoShowHover ? false : true; + WidgetsBinding.instance.addPostFrameCallback((_) { + addScrollListener(); + controller.show(); + }); } @override Widget build(BuildContext context) { - final List children = [ - DecoratedBox( - decoration: const BoxDecoration( - color: Colors.transparent, - borderRadius: Corners.s6Border, - ), - child: Padding( - padding: const EdgeInsets.only(bottom: 30), + return MouseRegion( + opaque: false, + onEnter: (_) { + if (!hoverBubble && isBottomOfWidgetVisible(context)) { + setState(() => hoverBubble = true); + } + }, + onHover: (_) { + if (!hoverBubble && isBottomOfWidgetVisible(context)) { + setState(() => hoverBubble = true); + } + }, + onExit: (_) { + if (hoverBubble) { + setState(() => hoverBubble = false); + } + }, + child: OverlayPortal( + controller: controller, + overlayChildBuilder: (_) { + return CompositedTransformFollower( + showWhenUnlinked: false, + link: layerLink, + targetAnchor: Alignment.bottomLeft, + offset: const Offset( + DesktopAIConvoSizes.avatarSize + + DesktopAIConvoSizes.avatarAndChatBubbleSpacing, + 0, + ), + child: Align( + alignment: Alignment.topLeft, + child: MouseRegion( + opaque: false, + onEnter: (_) { + if (!hoverActionBar && isBottomOfWidgetVisible(context)) { + setState(() => hoverActionBar = true); + } + }, + onExit: (_) { + if (hoverActionBar) { + setState(() => hoverActionBar = false); + } + }, + child: Container( + constraints: const BoxConstraints( + maxWidth: 784, + maxHeight: 28, + ), + alignment: Alignment.topLeft, + child: hoverBubble || hoverActionBar + ? AIResponseActionBar( + showDecoration: true, + children: [ + CopyButton( + textMessage: widget.message as TextMessage, + ), + ], + ) + : null, + ), + ), + ), + ); + }, + child: CompositedTransformTarget( + link: layerLink, 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 _buildOnHoverItems() { - final List children = []; - if (widget.customMessageType != null) { - // - } else { - if (widget.message is TextMessage) { - children.add( - CopyButton( - textMessage: widget.message as TextMessage, - ).positioned(left: _leftPadding, bottom: 0), - ); - } + void addScrollListener() { + if (!mounted) { + return; } + scrollPosition = Scrollable.maybeOf(context)?.position; + scrollPosition?.addListener(handleScroll); + } - return children; + void handleScroll() { + if (!mounted) { + return; + } + if ((hoverActionBar || hoverBubble) && !isBottomOfWidgetVisible(context)) { + setState(() { + hoverBubble = false; + hoverActionBar = false; + }); + } + } + + bool isBottomOfWidgetVisible(BuildContext context) { + if (Scrollable.maybeOf(context) == null) { + return false; + } + final scrollableRenderBox = + Scrollable.of(context).context.findRenderObject() as RenderBox; + final scrollableHeight = scrollableRenderBox.size.height; + final scrollableOffset = scrollableRenderBox.localToGlobal(Offset.zero); + + final messageRenderBox = context.findRenderObject() as RenderBox; + final messageOffset = messageRenderBox.localToGlobal(Offset.zero); + final messageHeight = messageRenderBox.size.height; + + return messageOffset.dy + messageHeight + 28 <= + scrollableOffset.dy + scrollableHeight; + } + + @override + void dispose() { + scrollPosition?.isScrollingNotifier.removeListener(handleScroll); + super.dispose(); + } +} + +class AIResponseActionBar extends StatelessWidget { + const AIResponseActionBar({ + super.key, + required this.showDecoration, + required this.children, + }); + + final List children; + final bool showDecoration; + + @override + Widget build(BuildContext context) { + final isLightMode = Theme.of(context).isLightMode; + + final child = SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => + const HSpace(DesktopAIConvoSizes.actionBarIconSpacing), + children: children, + ); + + return showDecoration + ? Container( + padding: const EdgeInsets.all(2.0), + decoration: BoxDecoration( + borderRadius: DesktopAIConvoSizes.actionBarIconRadius, + border: Border.all(color: Theme.of(context).dividerColor), + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 1), + blurRadius: 2, + spreadRadius: -2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 4, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 8, + spreadRadius: 2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + ], + ), + child: child, + ) + : child; } } @@ -175,12 +339,14 @@ class CopyButton extends StatelessWidget { return FlowyTooltip( message: LocaleKeys.settings_menu_clickToCopy.tr(), child: FlowyIconButton( - width: 24, + width: DesktopAIConvoSizes.actionBarIconSize, hoverColor: AFThemeExtension.of(context).lightGreyHover, fillColor: Theme.of(context).cardColor, - icon: const FlowySvg( + radius: DesktopAIConvoSizes.actionBarIconRadius, + icon: FlowySvg( FlowySvgs.copy_s, - size: Size.square(20), + color: Theme.of(context).hintColor, + size: const Size.square(16), ), onPressed: () async { final document = customMarkdownToDocument(textMessage.text); @@ -201,3 +367,65 @@ class CopyButton extends StatelessWidget { ); } } + +class ChatAIMessagePopup extends StatelessWidget { + const ChatAIMessagePopup({ + super.key, + required this.child, + required this.message, + }); + + final Widget child; + final Message message; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: () { + showMobileBottomSheet( + context, + showDragHandle: true, + backgroundColor: AFThemeExtension.of(context).background, + builder: (bottomSheetContext) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + MobileQuickActionButton( + onTap: () async { + if (message is! TextMessage) { + return; + } + final textMessage = message as TextMessage; + final document = customMarkdownToDocument(textMessage.text); + await getIt().setData( + ClipboardServiceData( + plainText: textMessage.text, + inAppJson: jsonEncode(document.toJson()), + ), + ); + if (bottomSheetContext.mounted) { + Navigator.of(bottomSheetContext).pop(); + } + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.grid_url_copiedNotification.tr(), + ); + } + }, + icon: FlowySvgs.copy_s, + iconSize: const Size.square(20), + text: LocaleKeys.button_copy.tr(), + ), + ], + ); + }, + ); + }, + child: IgnorePointer( + child: child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart index 6d4f85d616..e0a5d3177f 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; @@ -6,8 +7,9 @@ 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'; +import 'package:time/time.dart'; -class AIMessageMetadata extends StatelessWidget { +class AIMessageMetadata extends StatefulWidget { const AIMessageMetadata({ required this.sources, required this.onSelectedMetadata, @@ -15,58 +17,91 @@ class AIMessageMetadata extends StatelessWidget { }); final List sources; - final Function(ChatMessageRefSource metadata) onSelectedMetadata; + final void Function(ChatMessageRefSource metadata) onSelectedMetadata; + + @override + State createState() => _AIMessageMetadataState(); +} + +class _AIMessageMetadataState extends State { + bool isExpanded = true; + @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), + return AnimatedSize( + duration: 150.milliseconds, + alignment: AlignmentDirectional.topStart, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(8.0), + ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 24, + maxWidth: 240, + ), + child: FlowyButton( + margin: const EdgeInsets.all(4.0), + useIntrinsicWidth: true, + radius: BorderRadius.circular(8.0), + text: FlowyText( + LocaleKeys.chat_referenceSource.plural( + widget.sources.length, + namedArgs: {'count': '${widget.sources.length}'}, + ), + fontSize: 12, + color: Theme.of(context).hintColor, + ), + rightIcon: FlowySvg( + isExpanded ? FlowySvgs.arrow_up_s : FlowySvgs.arrow_down_s, + size: const Size.square(10), + ), + onTap: () { + setState(() => isExpanded = !isExpanded); + }, + ), ), - 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, + if (isExpanded) ...[ + const VSpace(4.0), + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: widget.sources.map( + (m) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 24, + maxWidth: 240, ), - useIntrinsicWidth: true, - radius: BorderRadius.circular(6), - text: Opacity( - opacity: 0.5, - child: FlowyText( + child: FlowyButton( + margin: const EdgeInsets.all(4.0), + useIntrinsicWidth: true, + radius: BorderRadius.circular(8.0), + text: FlowyText( m.name, - fontSize: 14, - lineHeight: 1.0, + fontSize: 12, overflow: TextOverflow.ellipsis, ), + leftIcon: FlowySvg( + FlowySvgs.icon_document_s, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ), + disable: m.source != appflowySource, + onTap: () { + if (m.source != appflowySource) { + return; + } + widget.onSelectedMetadata(m); + }, ), - disable: m.source != appflowySoruce, - onTap: () { - if (m.source != appflowySoruce) { - return; - } - onSelectedMetadata(m); - }, - ), - ), - ) - .toList(), - ), - ], + ); + }, + ).toList(), + ), + ], + ], + ), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart index 4b711e4fda..cb6b383fe6 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart @@ -1,88 +1,112 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; 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/application/chat_message_stream.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'; import 'package:fixnum/fixnum.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/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:universal_platform/universal_platform.dart'; +import 'ai_message_bubble.dart'; import 'ai_metadata.dart'; +/// [ChatAIMessageWidget] includes both the text of the AI response as well as +/// the avatar, decorations and hover effects that are also rendered. This is +/// different from [ChatUserMessageWidget] which only contains the message and +/// has to be separately wrapped with a bubble since the hover effects need to +/// know the current streaming status of the message. class ChatAIMessageWidget extends StatelessWidget { const ChatAIMessageWidget({ super.key, required this.user, required this.messageUserId, required this.message, + required this.stream, required this.questionId, required this.chatId, required this.refSourceJsonString, required this.onSelectedMetadata, + this.isLastMessage = false, }); final User user; final String messageUserId; - /// message can be a striing or Stream - final dynamic message; + final Message message; + final AnswerStream? stream; final Int64? questionId; final String chatId; final String? refSourceJsonString; final void Function(ChatMessageRefSource metadata) onSelectedMetadata; + final bool isLastMessage; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => ChatAIMessageBloc( - message: message, + message: stream ?? (message as TextMessage).text, refSourceJsonString: refSourceJsonString, chatId: chatId, questionId: questionId, - )..add(const ChatAIMessageEvent.initial()), + ), child: BlocBuilder( builder: (context, state) { - return state.messageState.when( - onError: (err) { - return StreamingError( - onRetryPressed: () { - context.read().add( - const ChatAIMessageEvent.retry(), - ); - }, - ); - }, - onAIResponseLimit: () { - return FlowyText( - LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(), - lineHeight: 1.5, - maxLines: 10, - ); - }, - ready: () { - if (state.text.isEmpty) { - return const ChatAILoading(); - } else { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AIMarkdownText(markdown: state.text), - AIMessageMetadata( - sources: state.sources, - onSelectedMetadata: onSelectedMetadata, - ), - ], + return Padding( + padding: UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(horizontal: 16) + : EdgeInsets.zero, + child: state.messageState.when( + loading: () { + return ChatAIMessageBubble( + message: message, + showActions: false, + child: const Padding( + padding: EdgeInsets.only(top: 8.0), + child: ChatAILoading(), + ), ); - } - }, - loading: () { - return const ChatAILoading(); - }, + }, + ready: () { + return state.text.isEmpty + ? const SizedBox.shrink() + : ChatAIMessageBubble( + message: message, + isLastMessage: isLastMessage, + showActions: stream == null && state.text.isNotEmpty, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AIMarkdownText(markdown: state.text), + if (state.sources.isNotEmpty) + AIMessageMetadata( + sources: state.sources, + onSelectedMetadata: onSelectedMetadata, + ), + ], + ), + ); + }, + onError: (err) { + return StreamingError( + onRetry: () { + context + .read() + .add(const ChatAIMessageEvent.retry()); + }, + ); + }, + onAIResponseLimit: () { + return const AIResponseLimitReachedError(); + }, + ), ); }, ), @@ -90,59 +114,124 @@ class ChatAIMessageWidget extends StatelessWidget { } } -class StreamingError extends StatelessWidget { +class StreamingError extends StatefulWidget { const StreamingError({ - required this.onRetryPressed, + required this.onRetry, super.key, }); - final void Function() onRetryPressed; + final VoidCallback onRetry; + + @override + State createState() => _StreamingErrorState(); +} + +class _StreamingErrorState extends State { + late final TapGestureRecognizer recognizer; + + @override + void initState() { + super.initState(); + recognizer = TapGestureRecognizer()..onTap = widget.onRetry; + } + + @override + void dispose() { + recognizer.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Column( - children: [ - const Divider(height: 4, thickness: 1), - const VSpace(16), - Center( - child: Column( - children: [ - _aiUnvaliable(), - const VSpace(10), - _retryButton(), - ], - ), + return Center( + child: Container( + margin: const EdgeInsets.only(top: 16.0, bottom: 24.0), + padding: const EdgeInsets.all(8.0), + decoration: const BoxDecoration( + color: Color(0x80FFE7EE), + borderRadius: BorderRadius.all(Radius.circular(8.0)), ), - ], - ); - } - - FlowyButton _retryButton() { - return FlowyButton( - radius: BorderRadius.circular(20), - useIntrinsicWidth: true, - text: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: FlowyText( - LocaleKeys.chat_regenerateAnswer.tr(), - fontSize: 14, + constraints: _errorConstraints(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.warning_filled_s, + blendMode: null, + ), + const HSpace(8.0), + Flexible( + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.chat_aiServerUnavailable.tr(), + style: Theme.of(context).textTheme.bodyMedium, + ), + TextSpan( + text: " ", + style: Theme.of(context).textTheme.bodyMedium, + ), + TextSpan( + text: LocaleKeys.chat_retry.tr(), + recognizer: recognizer, + mouseCursor: SystemMouseCursors.click, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ), + ), + ], ), ), - onTap: onRetryPressed, - iconPadding: 0, - leftIcon: const Icon( - Icons.refresh, - size: 20, - ), - ); - } - - Padding _aiUnvaliable() { - return Padding( - padding: const EdgeInsets.all(8.0), - child: FlowyText( - LocaleKeys.chat_aiServerUnavailable.tr(), - fontSize: 14, - ), ); } } + +class AIResponseLimitReachedError extends StatelessWidget { + const AIResponseLimitReachedError({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + margin: const EdgeInsets.only(top: 16.0, bottom: 24.0), + constraints: _errorConstraints(), + padding: const EdgeInsets.all(8.0), + decoration: const BoxDecoration( + color: Color(0x80FFE7EE), + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.warning_filled_s, + blendMode: null, + ), + const HSpace(8.0), + Flexible( + child: FlowyText( + LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(), + lineHeight: 1.4, + maxLines: null, + ), + ), + ], + ), + ), + ); + } +} + +BoxConstraints _errorConstraints() { + return UniversalPlatform.isDesktop + ? const BoxConstraints(maxWidth: 480) + : const BoxConstraints(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/other_user_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/other_user_message_bubble.dart deleted file mode 100644 index 899c0bec3c..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/other_user_message_bubble.dart +++ /dev/null @@ -1,211 +0,0 @@ -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().state.members[message.author.id] == - null) { - context - .read() - .add(ChatMemberEvent.getMemberInfo(message.author.id)); - } - - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BlocConsumer( - 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 createState() => _OtherUserMessageHoverState(); -} - -class _OtherUserMessageHoverState extends State { - bool _isHover = false; - - @override - void initState() { - super.initState(); - _isHover = widget.autoShowHover ? false : true; - } - - @override - Widget build(BuildContext context) { - final List 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 _buildOnHoverItems() { - final List 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().setData( - ClipboardServiceData( - plainText: textMessage.text, - inAppJson: jsonEncode(document.toJson()), - ), - ); - if (context.mounted) { - showToastNotification( - context, - message: LocaleKeys.grid_url_copiedNotification.tr(), - ); - } - }, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart index 08c627b0b7..9410820137 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart @@ -1,28 +1,30 @@ +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_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:appflowy/plugins/ai_chat/presentation/layout_define.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart'; +import 'package:universal_platform/universal_platform.dart'; class ChatUserMessageBubble extends StatelessWidget { const ChatUserMessageBubble({ super.key, required this.message, required this.child, + this.isCurrentUser = true, }); final Message message; final Widget child; + final bool isCurrentUser; @override Widget build(BuildContext context) { - const borderRadius = BorderRadius.all(Radius.circular(6)); - final backgroundColor = - Theme.of(context).colorScheme.surfaceContainerHighest; if (context.read().state.members[message.author.id] == null) { context @@ -36,60 +38,80 @@ class ChatUserMessageBubble extends StatelessWidget { ), child: BlocBuilder( 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, - ), - ), - ), + return Padding( + padding: UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(horizontal: 16) + : EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (state.files.isNotEmpty) ...[ Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: BlocConsumer( - listenWhen: (previous, current) => - previous.members[message.author.id] != - current.members[message.author.id], - listener: (context, state) {}, - builder: (context, state) { - final member = state.members[message.author.id]; - return ChatUserAvatar( - iconUrl: member?.info.avatarUrl ?? "", - name: member?.info.name ?? "", - ); - }, - ), + padding: const EdgeInsets.only(right: 32), + child: _MessageFileList(files: state.files), ), + const VSpace(6), ], - ), - ], + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: getChildren(context), + ), + ], + ), ); }, ), ); } + + List getChildren(BuildContext context) { + if (isCurrentUser) { + return [ + const Spacer(), + _buildBubble(context), + const HSpace(DesktopAIConvoSizes.avatarAndChatBubbleSpacing), + _buildAvatar(), + ]; + } else { + return [ + _buildAvatar(), + const HSpace(DesktopAIConvoSizes.avatarAndChatBubbleSpacing), + _buildBubble(context), + const Spacer(), + ]; + } + } + + Widget _buildAvatar() { + return BlocBuilder( + builder: (context, state) { + final member = state.members[message.author.id]; + return ChatUserAvatar( + iconUrl: member?.info.avatarUrl ?? "", + name: member?.info.name ?? "", + ); + }, + ); + } + + Widget _buildBubble(BuildContext context) { + return Flexible( + flex: 5, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: child, + ), + ); + } } class _MessageFileList extends StatelessWidget { @@ -137,7 +159,11 @@ class _MessageFile extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - SizedBox.square(dimension: 16, child: file.fileType.icon), + FlowySvg( + FlowySvgs.page_m, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ), const HSpace(6), Flexible( child: ConstrainedBox( diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart index c804441188..f28708a4ef 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart @@ -22,24 +22,20 @@ class ChatUserMessageWidget extends StatelessWidget { ..add(const ChatUserMessageEvent.initial()), child: BlocBuilder( builder: (context, state) { - final List children = []; - children.add( - Flexible( - child: TextMessageText( - text: state.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, + children: [ + Flexible( + child: TextMessageText( + text: state.text, + ), + ), + if (!state.messageState.isFinish) ...[ + const HSpace(6), + const CircularProgressIndicator.adaptive(), + ], + ], ); }, ), @@ -61,8 +57,7 @@ class TextMessageText extends StatelessWidget { Widget build(BuildContext context) { return FlowyText( text, - fontSize: 16, - fontWeight: FontWeight.w500, + lineHeight: 1.4, maxLines: null, selectable: true, color: AFThemeExtension.of(context).textColor, diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart index 43da1a0849..4ccb8b7545 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart @@ -230,9 +230,7 @@ class DatabaseTabBarViewPlugin extends Plugin { const kDatabasePluginWidgetBuilderHorizontalPadding = 'horizontal_padding'; class DatabasePluginWidgetBuilderSize { - const DatabasePluginWidgetBuilderSize({ - required this.horizontalPadding, - }); + const DatabasePluginWidgetBuilderSize({required this.horizontalPadding}); final double horizontalPadding; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 23be6c299c..5827506b17 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -161,7 +161,16 @@ class _DocumentPageState extends State } return Provider( - create: (_) => SharedEditorContext(), + create: (_) { + final context = SharedEditorContext(); + if (widget.view.name.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.coverTitleFocusNode.requestFocus(); + }); + } + return context; + }, + dispose: (buildContext, editorContext) => editorContext.dispose(), child: EditorTransactionService( viewId: widget.view.id, editorState: state.editorState!, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 2f43c2c0b5..bf4dcb97e0 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -485,6 +485,7 @@ class _AppFlowyEditorPageState extends State borderRadius: BorderRadius.circular(4), ), child: FindAndReplaceMenuWidget( + showReplaceMenu: showReplaceMenu, editorState: editorState, onDismiss: onDismiss, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart index 430542ae08..ac3774a511 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart @@ -299,11 +299,11 @@ class _TurnInfoButton extends StatelessWidget { } else if (type == ToggleListBlockKeys.type) { switch (level) { case 1: - return FlowySvgs.slash_menu_icon_h1_s; + return FlowySvgs.toggle_heading1_s; case 2: - return FlowySvgs.slash_menu_icon_h2_s; + return FlowySvgs.toggle_heading2_s; case 3: - return FlowySvgs.slash_menu_icon_h3_s; + return FlowySvgs.toggle_heading3_s; default: return FlowySvgs.slash_menu_icon_toggle_s; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart index 94a4361dda..7077352c59 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart @@ -160,8 +160,9 @@ class FileBlockComponentState extends State RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; - late EditorDropManagerState dropManagerState = - context.read(); + late EditorDropManagerState? dropManagerState = UniversalPlatform.isMobile + ? null + : context.read(); final fileKey = GlobalKey(); final showActionsNotifier = ValueNotifier(false); @@ -176,7 +177,9 @@ class FileBlockComponentState extends State @override void didChangeDependencies() { - dropManagerState = context.read(); + if (!UniversalPlatform.isMobile) { + dropManagerState = context.read(); + } super.didChangeDependencies(); } @@ -240,17 +243,17 @@ class FileBlockComponentState extends State if (url == null || url.isEmpty) { child = DropTarget( onDragEntered: (_) { - if (dropManagerState.isDropEnabled) { + if (dropManagerState?.isDropEnabled == true) { setState(() => isDragging = true); } }, onDragExited: (_) { - if (dropManagerState.isDropEnabled) { + if (dropManagerState?.isDropEnabled == true) { setState(() => isDragging = false); } }, onDragDone: (details) { - if (dropManagerState.isDropEnabled) { + if (dropManagerState?.isDropEnabled == true) { insertFileFromLocal(details.files); } }, @@ -263,8 +266,8 @@ class FileBlockComponentState extends State minHeight: 80, ), clickHandler: PopoverClickHandler.gestureDetector, - onOpen: () => dropManagerState.add(FileBlockKeys.type), - onClose: () => dropManagerState.remove(FileBlockKeys.type), + onOpen: () => dropManagerState?.add(FileBlockKeys.type), + onClose: () => dropManagerState?.remove(FileBlockKeys.type), popupBuilder: (_) => FileUploadMenu( onInsertLocalFile: insertFileFromLocal, onInsertNetworkFile: insertNetworkFile, @@ -342,7 +345,7 @@ class FileBlockComponentState extends State void _openMenu() { if (UniversalPlatform.isDesktopOrWeb) { controller.show(); - dropManagerState.add(FileBlockKeys.type); + dropManagerState?.add(FileBlockKeys.type); } else { showUploadFileMobileMenu(); } @@ -502,7 +505,7 @@ class FileBlockComponentState extends State } // Remove the file block from the drop state manager - dropManagerState.remove(FileBlockKeys.type); + dropManagerState?.remove(FileBlockKeys.type); final transaction = editorState.transaction; transaction.updateNode(widget.node, { @@ -524,7 +527,7 @@ class FileBlockComponentState extends State } // Remove the file block from the drop state manager - dropManagerState.remove(FileBlockKeys.type); + dropManagerState?.remove(FileBlockKeys.type); final uri = Uri.tryParse(url); if (uri == null) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart index af00faee88..75f02f5079 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart @@ -136,10 +136,9 @@ class _FileUploadLocalState extends State<_FileUploadLocal> { if (UniversalPlatform.isMobile) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.all(12), child: SizedBox( height: 32, - width: 300, child: FlowyButton( backgroundColor: Theme.of(context).colorScheme.primary, hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart index e9e120a568..0fee679a42 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart @@ -1,62 +1,90 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.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:flowy_infra_ui/style_widget/text_input.dart'; +import 'package:flutter/material.dart'; class FindAndReplaceMenuWidget extends StatefulWidget { const FindAndReplaceMenuWidget({ super.key, required this.onDismiss, required this.editorState, + required this.showReplaceMenu, }); final EditorState editorState; final VoidCallback onDismiss; + /// Whether to show the replace menu initially + final bool showReplaceMenu; + @override State createState() => _FindAndReplaceMenuWidgetState(); } class _FindAndReplaceMenuWidgetState extends State { - bool showReplaceMenu = false; + late bool showReplaceMenu = widget.showReplaceMenu; + + final findFocusNode = FocusNode(); + final replaceFocusNode = FocusNode(); late SearchServiceV3 searchService = SearchServiceV3( editorState: widget.editorState, ); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.showReplaceMenu) { + replaceFocusNode.requestFocus(); + } else { + findFocusNode.requestFocus(); + } + }); + } + + @override + void dispose() { + findFocusNode.dispose(); + replaceFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: FindMenu( - onDismiss: widget.onDismiss, - editorState: widget.editorState, - searchService: searchService, - onShowReplace: (value) => setState( - () => showReplaceMenu = value, + return TextFieldTapRegion( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: FindMenu( + onDismiss: widget.onDismiss, + editorState: widget.editorState, + searchService: searchService, + focusNode: findFocusNode, + showReplaceMenu: showReplaceMenu, + onToggleShowReplace: () => setState(() { + showReplaceMenu = !showReplaceMenu; + }), ), ), - ), - showReplaceMenu - ? Padding( - padding: const EdgeInsets.only( - bottom: 8.0, - ), - child: ReplaceMenu( - editorState: widget.editorState, - searchService: searchService, - ), - ) - : const SizedBox.shrink(), - ], + if (showReplaceMenu) + Padding( + padding: const EdgeInsets.only( + bottom: 8.0, + ), + child: ReplaceMenu( + editorState: widget.editorState, + searchService: searchService, + focusNode: replaceFocusNode, + ), + ), + ], + ), ); } } @@ -64,29 +92,30 @@ class _FindAndReplaceMenuWidgetState extends State { class FindMenu extends StatefulWidget { const FindMenu({ super.key, - required this.onDismiss, required this.editorState, required this.searchService, - required this.onShowReplace, + required this.showReplaceMenu, + required this.focusNode, + required this.onDismiss, + required this.onToggleShowReplace, }); final EditorState editorState; - final VoidCallback onDismiss; final SearchServiceV3 searchService; - final void Function(bool value) onShowReplace; + + final bool showReplaceMenu; + final FocusNode focusNode; + + final VoidCallback onDismiss; + final void Function() onToggleShowReplace; @override State createState() => _FindMenuState(); } class _FindMenuState extends State { - late final FocusNode findTextFieldFocusNode; + final textController = TextEditingController(); - final findTextEditingController = TextEditingController(); - - String queriedPattern = ''; - - bool showReplaceMenu = false; bool caseSensitive = false; @override @@ -96,11 +125,7 @@ class _FindMenuState extends State { widget.searchService.matchWrappers.addListener(_setState); widget.searchService.currentSelectedIndex.addListener(_setState); - findTextEditingController.addListener(_searchPattern); - - WidgetsBinding.instance.addPostFrameCallback((_) { - findTextFieldFocusNode.requestFocus(); - }); + textController.addListener(_searchPattern); } @override @@ -108,9 +133,7 @@ class _FindMenuState extends State { widget.searchService.matchWrappers.removeListener(_setState); widget.searchService.currentSelectedIndex.removeListener(_setState); widget.searchService.dispose(); - findTextEditingController.removeListener(_searchPattern); - findTextEditingController.dispose(); - findTextFieldFocusNode.dispose(); + textController.dispose(); super.dispose(); } @@ -124,42 +147,36 @@ class _FindMenuState extends State { const HSpace(4.0), // expand/collapse button _FindAndReplaceIcon( - icon: showReplaceMenu + icon: widget.showReplaceMenu ? FlowySvgs.drop_menu_show_s : FlowySvgs.drop_menu_hide_s, tooltipText: '', - onPressed: () { - widget.onShowReplace(!showReplaceMenu); - setState( - () => showReplaceMenu = !showReplaceMenu, - ); - }, + onPressed: widget.onToggleShowReplace, ), const HSpace(4.0), // find text input SizedBox( - width: 150, + width: 200, height: 30, - child: FlowyFormTextInput( - onFocusCreated: (focusNode) { - findTextFieldFocusNode = focusNode; - }, - onEditingComplete: () { + child: TextField( + key: const Key('findTextField'), + focusNode: widget.focusNode, + controller: textController, + style: Theme.of(context).textTheme.bodyMedium, + onSubmitted: (_) { widget.searchService.navigateToMatch(); + // after update selection or navigate to match, the editor - // will request focus, here's a workaround to request the - // focus back to the findTextField - Future.delayed(const Duration(milliseconds: 50), () { - if (context.mounted) { - FocusScope.of(context).requestFocus( - findTextFieldFocusNode, - ); - } - }); + // will request focus, here's a workaround to request the + // focus back to the text field + Future.delayed( + const Duration(milliseconds: 50), + () => widget.focusNode.requestFocus(), + ); }, - controller: findTextEditingController, - hintText: LocaleKeys.findAndReplace_find.tr(), - textAlign: TextAlign.left, + decoration: _buildInputDecoration( + LocaleKeys.findAndReplace_find.tr(), + ), ), ), // the count of matches @@ -210,11 +227,8 @@ class _FindMenuState extends State { } void _searchPattern() { - if (findTextEditingController.text.isEmpty) { - return; - } - widget.searchService.findAndHighlight(findTextEditingController.text); - setState(() => queriedPattern = findTextEditingController.text); + widget.searchService.findAndHighlight(textController.text); + _setState(); } void _setState() { @@ -227,27 +241,24 @@ class ReplaceMenu extends StatefulWidget { super.key, required this.editorState, required this.searchService, - this.localizations, + required this.focusNode, }); final EditorState editorState; - - /// The localizations of the find and replace menu - final FindReplaceLocalizations? localizations; - final SearchServiceV3 searchService; + final FocusNode focusNode; + @override State createState() => _ReplaceMenuState(); } class _ReplaceMenuState extends State { - late final FocusNode replaceTextFieldFocusNode; - final replaceTextEditingController = TextEditingController(); + final textController = TextEditingController(); @override void dispose() { - replaceTextEditingController.dispose(); + textController.dispose(); super.dispose(); } @@ -258,31 +269,26 @@ class _ReplaceMenuState extends State { // placeholder for aligning the replace menu const HSpace(30), SizedBox( - width: 150, + width: 200, height: 30, - child: FlowyFormTextInput( - onFocusCreated: (focusNode) { - replaceTextFieldFocusNode = focusNode; + child: TextField( + key: const Key('replaceTextField'), + focusNode: widget.focusNode, + controller: textController, + style: Theme.of(context).textTheme.bodyMedium, + onSubmitted: (_) { + _replaceSelectedWord(); + + Future.delayed( + const Duration(milliseconds: 50), + () => widget.focusNode.requestFocus(), + ); }, - onEditingComplete: () { - widget.searchService.navigateToMatch(); - // after update selection or navigate to match, the editor - // will request focus, here's a workaround to request the - // focus back to the findTextField - Future.delayed(const Duration(milliseconds: 50), () { - if (context.mounted) { - FocusScope.of(context).requestFocus( - replaceTextFieldFocusNode, - ); - } - }); - }, - controller: replaceTextEditingController, - hintText: LocaleKeys.findAndReplace_replace.tr(), - textAlign: TextAlign.left, + decoration: _buildInputDecoration( + LocaleKeys.findAndReplace_replace.tr(), + ), ), ), - const HSpace(4.0), _FindAndReplaceIcon( onPressed: _replaceSelectedWord, iconBuilder: (_) => const Icon( @@ -299,7 +305,7 @@ class _ReplaceMenuState extends State { ), tooltipText: LocaleKeys.findAndReplace_replaceAll.tr(), onPressed: () => widget.searchService.replaceAllMatches( - replaceTextEditingController.text, + textController.text, ), ), ], @@ -307,7 +313,7 @@ class _ReplaceMenuState extends State { } void _replaceSelectedWord() { - widget.searchService.replaceSelectedWord(replaceTextEditingController.text); + widget.searchService.replaceSelectedWord(textController.text); } } @@ -333,10 +339,20 @@ class _FindAndReplaceIcon extends StatelessWidget { height: 24, onPressed: onPressed, icon: iconBuilder?.call(context) ?? - (icon != null ? FlowySvg(icon!) : const Placeholder()), + (icon != null + ? FlowySvg(icon!, color: Theme.of(context).iconTheme.color) + : const Placeholder()), tooltipText: tooltipText, isSelected: isSelected, iconColorOnHover: Theme.of(context).colorScheme.onSecondary, ); } } + +InputDecoration _buildInputDecoration(String hintText) { + return InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + border: const UnderlineInputBorder(), + hintText: hintText, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart index 72c88b9351..f48a1aeec2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart @@ -45,11 +45,10 @@ class _InnerCoverTitle extends StatefulWidget { class _InnerCoverTitleState extends State<_InnerCoverTitle> { final titleTextController = TextEditingController(); - final titleFocusNode = FocusNode(); late final editorContext = context.read(); late final editorState = context.read(); - bool isTitleFocused = false; + late final titleFocusNode = editorContext.coverTitleFocusNode; int lineCount = 1; @override @@ -58,53 +57,32 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> { titleTextController.text = widget.view.name; titleTextController.addListener(_onViewNameChanged); - titleFocusNode.onKeyEvent = _onKeyEvent; - titleFocusNode.addListener(_onTitleFocusChanged); + + titleFocusNode + ..onKeyEvent = _onKeyEvent + ..addListener(_onFocusChanged); editorState.selectionNotifier.addListener(_onSelectionChanged); - _requestFocusIfNeeded(widget.view, null); - - editorContext.coverTitleFocusNode = titleFocusNode; } @override void dispose() { - editorContext.coverTitleFocusNode = null; - editorState.selectionNotifier.removeListener(_onSelectionChanged); - - titleTextController.removeListener(_onViewNameChanged); + titleFocusNode + ..onKeyEvent = null + ..removeListener(_onFocusChanged); titleTextController.dispose(); - titleFocusNode.removeListener(_onTitleFocusChanged); - titleFocusNode.dispose(); - + editorState.selectionNotifier.removeListener(_onSelectionChanged); super.dispose(); } void _onSelectionChanged() { // if title is focused and the selection is not null, clear the selection - if (editorState.selection != null && isTitleFocused) { + if (editorState.selection != null && titleFocusNode.hasFocus) { Log.info('title is focused, clear the editor selection'); editorState.selection = null; } } - void _onTitleFocusChanged() { - isTitleFocused = titleFocusNode.hasFocus; - - if (titleFocusNode.hasFocus && editorState.selection != null) { - Log.info('cover title got focus, clear the editor selection'); - editorState.selection = null; - } - - if (isTitleFocused) { - Log.info('cover title got focus, disable keyboard service'); - editorState.service.keyboardService?.disable(); - } else { - Log.info('cover title lost focus, enable keyboard service'); - editorState.service.keyboardService?.enable(); - } - } - @override Widget build(BuildContext context) { final fontStyle = Theme.of(context) @@ -175,6 +153,21 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> { } } + void _onFocusChanged() { + if (titleFocusNode.hasFocus) { + if (editorState.selection != null) { + Log.info('cover title got focus, clear the editor selection'); + editorState.selection = null; + } + + Log.info('cover title got focus, disable keyboard service'); + editorState.service.keyboardService?.disable(); + } else { + Log.info('cover title lost focus, enable keyboard service'); + editorState.service.keyboardService?.enable(); + } + } + void _onViewNameChanged() { Debounce.debounce( 'update view name', diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart index e3b5c2578e..8e84d17523 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart @@ -103,8 +103,6 @@ class _DocumentCoverWidgetState extends State { late final ViewListener viewListener; int retryCount = 0; - final titleTextController = TextEditingController(); - final titleFocusNode = FocusNode(); final isCoverTitleHovered = ValueNotifier(false); late final gestureInterceptor = SelectionGestureInterceptor( @@ -120,7 +118,6 @@ class _DocumentCoverWidgetState extends State { viewIcon = value.isNotEmpty ? value : icon ?? ''; cover = widget.view.cover; view = widget.view; - titleTextController.text = view.name; widget.node.addListener(_reload); widget.editorState.service.selectionService .registerGestureInterceptor(gestureInterceptor); @@ -128,9 +125,6 @@ class _DocumentCoverWidgetState extends State { viewListener = ViewListener(viewId: widget.view.id) ..start( onViewUpdated: (view) { - if (titleTextController.text != view.name) { - titleTextController.text = view.name; - } setState(() { viewIcon = view.icon.value; cover = view.cover; @@ -144,8 +138,6 @@ class _DocumentCoverWidgetState extends State { void dispose() { viewListener.stop(); widget.node.removeListener(_reload); - titleTextController.dispose(); - titleFocusNode.dispose(); isCoverTitleHovered.dispose(); widget.editorState.service.selectionService .unregisterGestureInterceptor(_interceptorKey); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart index ae493d402a..c8a392fb71 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart @@ -25,8 +25,9 @@ Future showPageSelectorSheet( showHeader: true, showCloseButton: true, showDragHandle: true, - builder: (context) => Container( - margin: const EdgeInsets.only(top: 12.0), + useSafeArea: false, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (context) => ConstrainedBox( constraints: const BoxConstraints( maxHeight: 340, minHeight: 80, @@ -112,27 +113,29 @@ class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> { } return Flexible( - child: ListView( - children: filtered - .map( - (view) => FlowyOptionTile.checkbox( - leftIcon: view.icon.value.isNotEmpty - ? EmojiText( - emoji: view.icon.value, - fontSize: 18, - textAlign: TextAlign.center, - lineHeight: 1.3, - ) - : FlowySvg( - view.layout.icon, - size: const Size.square(20), - ), - text: view.name, - isSelected: view.id == widget.selectedViewId, - onTap: () => Navigator.of(context).pop(view), - ), - ) - .toList(), + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final view = filtered.elementAt(index); + return FlowyOptionTile.checkbox( + leftIcon: view.icon.value.isNotEmpty + ? EmojiText( + emoji: view.icon.value, + fontSize: 18, + textAlign: TextAlign.center, + lineHeight: 1.3, + ) + : FlowySvg( + view.layout.icon, + size: const Size.square(20), + ), + text: view.name, + showTopBorder: index != 0, + showBottomBorder: false, + isSelected: view.id == widget.selectedViewId, + onTap: () => Navigator.of(context).pop(view), + ); + }, ), ); }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart index 7e894ca95b..7f003a22d2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; @@ -19,11 +17,16 @@ import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +@visibleForTesting +const addBlockToolbarItemKey = ValueKey('add_block_toolbar_item'); + final addBlockToolbarItem = AppFlowyMobileToolbarItem( itemBuilder: (context, editorState, service, __, onAction) { return AppFlowyMobileToolbarIconItem( + key: addBlockToolbarItemKey, editorState: editorState, icon: FlowySvgs.m_toolbar_add_m, onTap: () { @@ -75,12 +78,13 @@ Future showAddBlockMenu( enableDraggableScrollable: true, builder: (_) => Padding( padding: EdgeInsets.all(16 * context.scale), - child: _AddBlockMenu(selection: selection, editorState: editorState), + child: AddBlockMenu(selection: selection, editorState: editorState), ), ); -class _AddBlockMenu extends StatelessWidget { - const _AddBlockMenu({ +class AddBlockMenu extends StatelessWidget { + const AddBlockMenu({ + super.key, required this.selection, required this.editorState, }); @@ -100,7 +104,32 @@ class _AddBlockMenu extends StatelessWidget { AppGlobals.rootNavKey.currentContext?.pop(true); Future.delayed( const Duration(milliseconds: 100), - () => editorState.insertBlockAfterCurrentSelection(selection, node), + () async { + // if current selected block is a empty paragraph block, replace it with the new block. + if (selection.isCollapsed) { + final currentNode = editorState.getNodeAtPath(selection.end.path); + final text = currentNode?.delta?.toPlainText(); + if (currentNode != null && + currentNode.type == ParagraphBlockKeys.type && + text != null && + text.isEmpty) { + final transaction = editorState.transaction; + transaction.insertNode( + selection.end.path.next, + node, + ); + transaction.deleteNode(currentNode); + transaction.afterSelection = Selection.collapsed( + Position(path: selection.end.path), + ); + transaction.selectionExtraInfo = {}; + await editorState.apply(transaction); + return; + } + } + + await editorState.insertBlockAfterCurrentSelection(selection, node); + }, ); } @@ -182,6 +211,32 @@ class _AddBlockMenu extends StatelessWidget { onTap: (_, __) => _insertBlock(toggleListBlockNode()), ), + // toggle headings + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), + icon: FlowySvgs.toggle_heading1_s, + iconPadding: const EdgeInsets.all(3), + onTap: (_, __) => _insertBlock(toggleHeadingNode()), + ), + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), + icon: FlowySvgs.toggle_heading2_s, + iconPadding: const EdgeInsets.all(3), + onTap: (_, __) => _insertBlock(toggleHeadingNode(level: 2)), + ), + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), + icon: FlowySvgs.toggle_heading3_s, + iconPadding: const EdgeInsets.all(3), + onTap: (_, __) => _insertBlock(toggleHeadingNode(level: 3)), + ), + // image TypeOptionMenuItemValue( value: ImageBlockKeys.type, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart index 9eefb79051..0d344347ba 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart @@ -6,9 +6,14 @@ import 'package:flutter/widgets.dart'; /// so we need to use the shared context to get the focus node. /// class SharedEditorContext { - SharedEditorContext(); + SharedEditorContext() : _coverTitleFocusNode = FocusNode(); // The focus node of the cover title. - // It's null when the cover title is not focused. - FocusNode? coverTitleFocusNode; + final FocusNode _coverTitleFocusNode; + + FocusNode get coverTitleFocusNode => _coverTitleFocusNode; + + void dispose() { + _coverTitleFocusNode.dispose(); + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart index 217245e75f..1358c08abf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart @@ -81,7 +81,7 @@ final toggleHeading1SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), nameBuilder: _slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.slash_menu_icon_h1_s, + data: FlowySvgs.toggle_heading1_s, isSelected: isSelected, style: style, ), @@ -99,7 +99,7 @@ final toggleHeading2SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), nameBuilder: _slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.slash_menu_icon_h2_s, + data: FlowySvgs.toggle_heading2_s, isSelected: isSelected, style: style, ), @@ -117,7 +117,7 @@ final toggleHeading3SlashMenuItem = SelectionMenuItem( getName: () => LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), nameBuilder: _slashMenuItemNameBuilder, icon: (editorState, isSelected, style) => SelectableSvgWidget( - data: FlowySvgs.slash_menu_icon_h3_s, + data: FlowySvgs.toggle_heading3_s, isSelected: isSelected, style: style, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart index aab3181127..cb49ca792d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart @@ -341,6 +341,7 @@ class _ToggleListBlockComponentWidgetState ..updateNode(node, { ToggleListBlockKeys.collapsed: !collapsed, }); + transaction.afterSelection = editorState.selection; await editorState.apply(transaction); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart index 0f6a42b563..63235ba217 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart @@ -53,7 +53,7 @@ class MobileAppearance extends BaseAppearance { //Snack bar surface: Colors.white, onSurface: _onSurfaceColor, // text/body color - surfaceContainerHighest: const Color.fromARGB(255, 216, 216, 216), + surfaceContainerHighest: theme.sidebarBg, ) : ColorScheme( brightness: brightness, @@ -69,6 +69,7 @@ class MobileAppearance extends BaseAppearance { //Snack bar surface: const Color(0xFF171A1F), onSurface: const Color(0xffC5C6C7), // text/body color + surfaceContainerHighest: theme.sidebarBg, ); final hintColor = brightness == Brightness.light ? const Color(0x991F2329) diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart index c15e1509c1..46d4943ddf 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -328,6 +328,16 @@ class SpaceBloc extends Bloc { didReceiveSpaceUpdate: () async { final (spaces, _, _) = await _getSpaces(); final currentSpace = await _getLastOpenedSpace(spaces); + + for (var i = 0; i < spaces.length; i++) { + Log.info( + 'did receive space update[$i]: ${spaces[i].name}(${spaces[i].id})', + ); + } + Log.info( + 'did receive space update, current space: ${currentSpace?.name}(${currentSpace?.id})', + ); + emit( state.copyWith( spaces: spaces, diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tab_menu_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tab_menu_bloc.dart new file mode 100644 index 0000000000..e27e07bb5f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tab_menu_bloc.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'tab_menu_bloc.freezed.dart'; + +class TabMenuBloc extends Bloc { + TabMenuBloc({required this.viewId}) : super(const TabMenuState.isLoading()) { + _fetchView(); + _dispatch(); + } + + final String viewId; + ViewPB? view; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + error: (error) async => emit(const TabMenuState.isError()), + fetchedView: (view) async => + emit(TabMenuState.isReady(isFavorite: view.isFavorite)), + toggleFavorite: () async { + final didToggle = await ViewBackendService.favorite(viewId: viewId); + if (didToggle.isSuccess) { + final isFavorite = state.maybeMap( + isReady: (s) => s.isFavorite, + orElse: () => null, + ); + if (isFavorite != null) { + emit(TabMenuState.isReady(isFavorite: !isFavorite)); + } + } + }, + ); + }, + ); + } + + Future _fetchView() async { + final viewOrFailure = await ViewBackendService.getView(viewId); + viewOrFailure.fold( + (view) { + this.view = view; + add(TabMenuEvent.fetchedView(view)); + }, + (error) { + Log.error(error); + add(TabMenuEvent.error(error)); + }, + ); + } +} + +@freezed +class TabMenuEvent with _$TabMenuEvent { + const factory TabMenuEvent.error(FlowyError error) = _Error; + const factory TabMenuEvent.fetchedView(ViewPB view) = _FetchedView; + const factory TabMenuEvent.toggleFavorite() = _ToggleFavorite; +} + +@freezed +class TabMenuState with _$TabMenuState { + const factory TabMenuState.isLoading() = _IsLoading; + + /// This will only be the state in case fetching the view failed. + /// + /// One such case can be from when a View is in the trash, as such we can disable + /// certain options in the TabMenu such as the favorite option. + /// + const factory TabMenuState.isError() = _IsError; + + const factory TabMenuState.isReady({required bool isFavorite}) = _IsReady; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart index 07f325f6c7..a53d87cf10 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart @@ -58,6 +58,18 @@ class TabsBloc extends Bloc { _setLatestOpenView(view); } }, + closeOtherTabs: (String pluginId) { + final pagesToClose = [ + ...state._pageManagers.where((pm) => pm.plugin.id != pluginId), + ]; + + final newstate = state; + for (final pm in pagesToClose) { + newstate.closeView(pm.plugin.id); + } + emit(newstate.copyWith(newIndex: 0)); + _setLatestOpenView(); + }, ); }, ); @@ -69,7 +81,8 @@ class TabsBloc extends Bloc { } else { final pageManager = state.currentPageManager; final notifier = pageManager.plugin.notifier; - if (notifier is ViewPluginNotifier) { + if (notifier is ViewPluginNotifier && + menuSharedState.latestOpenView?.id != notifier.view.id) { menuSharedState.latestOpenView = notifier.view; } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart index 335c383f2e..2d82b0e70e 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_event.dart @@ -4,6 +4,7 @@ part of 'tabs_bloc.dart'; class TabsEvent with _$TabsEvent { const factory TabsEvent.moveTab() = _MoveTab; const factory TabsEvent.closeTab(String pluginId) = _CloseTab; + const factory TabsEvent.closeOtherTabs(String pluginId) = _CloseOtherTabs; const factory TabsEvent.closeCurrentTab() = _CloseCurrentTab; const factory TabsEvent.selectTab(int index) = _SelectTab; const factory TabsEvent.openTab({ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart index d1e1b2c221..bbcf8f3f6e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -34,7 +34,7 @@ abstract class HomeStackDelegate { void didDeleteStackWidget(ViewPB view, int? index); } -class HomeStack extends StatelessWidget { +class HomeStack extends StatefulWidget { const HomeStack({ super.key, required this.delegate, @@ -47,49 +47,67 @@ class HomeStack extends StatelessWidget { final UserProfilePB userProfile; @override - Widget build(BuildContext context) { - final pageController = PageController(); + State createState() => _HomeStackState(); +} +class _HomeStackState extends State { + int selectedIndex = 0; + + @override + Widget build(BuildContext context) { return BlocProvider.value( value: getIt(), child: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - if (Platform.isWindows) - Column( - mainAxisSize: MainAxisSize.min, - children: [ - WindowTitleBar( - leftChildren: [ - _buildToggleMenuButton(context), - ], - ), - ], - ), - Padding( - padding: EdgeInsets.only(left: layout.menuSpacing), - child: TabsManager(pageController: pageController), + buildWhen: (prev, curr) => prev.currentIndex != curr.currentIndex, + builder: (context, state) => Column( + children: [ + if (Platform.isWindows) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + WindowTitleBar( + leftChildren: [_buildToggleMenuButton(context)], + ), + ], ), - state.currentPageManager.stackTopBar(layout: layout), - Expanded( - child: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: pageController, - children: state.pageManagers - .map( - (pm) => PageStack( - pageManager: pm, - delegate: delegate, - userProfile: userProfile, - ), - ) - .toList(), - ), + Padding( + padding: EdgeInsets.only(left: widget.layout.menuSpacing), + child: TabsManager( + onIndexChanged: (index) { + if (selectedIndex != index) { + // Unfocus editor to hide selection toolbar + FocusScope.of(context).unfocus(); + + context.read().add(TabsEvent.selectTab(index)); + setState(() => selectedIndex = index); + } + }, ), - ], - ); - }, + ), + Expanded( + child: FadingIndexedStack( + index: selectedIndex, + duration: const Duration(milliseconds: 350), + children: state.pageManagers + .map( + (pm) => Column( + children: [ + pm.stackTopBar(layout: widget.layout), + Expanded( + child: PageStack( + pageManager: pm, + delegate: widget.delegate, + userProfile: widget.userProfile, + ), + ), + ], + ), + ) + .toList(), + ), + ), + ], + ), ), ); } @@ -145,7 +163,6 @@ class PageStack extends StatefulWidget { }); final PageManager pageManager; - final HomeStackDelegate delegate; final UserProfilePB userProfile; @@ -216,9 +233,7 @@ class FadingIndexedStackState extends State { return TweenAnimationBuilder( duration: _targetOpacity > 0 ? widget.duration : 0.milliseconds, tween: Tween(begin: 0, end: _targetOpacity), - builder: (_, value, child) { - return Opacity(opacity: value, child: child); - }, + builder: (_, value, child) => Opacity(opacity: value, child: child), child: IndexedStack(index: widget.index, children: widget.children), ); } @@ -279,15 +294,9 @@ class PageManager { _notifier.setPlugin(newPlugin, setLatest); } - void setStackWithId(String id) { - // Navigate to the page with id - } - Widget stackTopBar({required HomeLayout layout}) { return MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: _notifier), - ], + providers: [ChangeNotifierProvider.value(value: _notifier)], child: Selector( selector: (context, notifier) => notifier.titleWidget, builder: (_, __, child) => MoveWindowDetector( @@ -319,7 +328,6 @@ class PageManager { shrinkWrap: false, ); - // TODO(Xazin): Board should fill up full width return Padding( padding: builder.contentPadding, child: pluginWidget, @@ -340,13 +348,21 @@ class PageManager { } } -class HomeTopBar extends StatelessWidget { +class HomeTopBar extends StatefulWidget { const HomeTopBar({super.key, required this.layout}); final HomeLayout layout; + @override + State createState() => _HomeTopBarState(); +} + +class _HomeTopBarState extends State + with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { + super.build(context); + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, @@ -359,7 +375,7 @@ class HomeTopBar extends StatelessWidget { ), child: Row( children: [ - HSpace(layout.menuSpacing), + HSpace(widget.layout.menuSpacing), const FlowyNavigation(), const HSpace(16), ChangeNotifierProvider.value( @@ -375,4 +391,7 @@ class HomeTopBar extends StatelessWidget { ), ); } + + @override + bool get wantKeepAlive => true; } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart index a0be2b43b3..ee5bc3fab3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart @@ -22,9 +22,11 @@ class WorkspaceMoreActionList extends StatelessWidget { const WorkspaceMoreActionList({ super.key, required this.workspace, + required this.isShowingMoreActions, }); final UserWorkspacePB workspace; + final ValueNotifier isShowingMoreActions; @override Widget build(BuildContext context) { @@ -46,6 +48,13 @@ class WorkspaceMoreActionList extends StatelessWidget { .map((e) => _WorkspaceMoreActionWrapper(e, workspace)) .toList(), constraints: const BoxConstraints(minWidth: 220), + animationDuration: Durations.short3, + slideDistance: 2, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + onClosed: () { + isShowingMoreActions.value = false; + }, buildChild: (controller) { return SizedBox.square( dimension: 24.0, @@ -55,7 +64,11 @@ class WorkspaceMoreActionList extends StatelessWidget { FlowySvgs.workspace_three_dots_s, ), onTap: () { - controller.show(); + if (!isShowingMoreActions.value) { + controller.show(); + } + + isShowingMoreActions.value = true; }, ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index af8e0cb8c4..4119239694 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -25,7 +25,7 @@ const createWorkspaceButtonKey = ValueKey('createWorkspaceButton'); @visibleForTesting const importNotionButtonKey = ValueKey('importNotinoButton'); -class WorkspacesMenu extends StatelessWidget { +class WorkspacesMenu extends StatefulWidget { const WorkspacesMenu({ super.key, required this.userProfile, @@ -37,6 +37,19 @@ class WorkspacesMenu extends StatelessWidget { final UserWorkspacePB currentWorkspace; final List workspaces; + @override + State createState() => _WorkspacesMenuState(); +} + +class _WorkspacesMenuState extends State { + final ValueNotifier isShowingMoreActions = ValueNotifier(false); + + @override + void dispose() { + isShowingMoreActions.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Column( @@ -72,13 +85,14 @@ class WorkspacesMenu extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - for (final workspace in workspaces) ...[ + for (final workspace in widget.workspaces) ...[ WorkspaceMenuItem( key: ValueKey(workspace.workspaceId), workspace: workspace, - userProfile: userProfile, - isSelected: - workspace.workspaceId == currentWorkspace.workspaceId, + userProfile: widget.userProfile, + isSelected: workspace.workspaceId == + widget.currentWorkspace.workspaceId, + isShowingMoreActions: isShowingMoreActions, ), const VSpace(6.0), ], @@ -99,12 +113,12 @@ class WorkspacesMenu extends StatelessWidget { } String _getUserInfo() { - if (userProfile.email.isNotEmpty) { - return userProfile.email; + if (widget.userProfile.email.isNotEmpty) { + return widget.userProfile.email; } - if (userProfile.name.isNotEmpty) { - return userProfile.name; + if (widget.userProfile.name.isNotEmpty) { + return widget.userProfile.name; } return LocaleKeys.defaultUsername.tr(); @@ -117,11 +131,13 @@ class WorkspaceMenuItem extends StatefulWidget { required this.workspace, required this.userProfile, required this.isSelected, + required this.isShowingMoreActions, }); final UserProfilePB userProfile; final UserWorkspacePB workspace; final bool isSelected; + final ValueNotifier isShowingMoreActions; @override State createState() => _WorkspaceMenuItemState(); @@ -211,7 +227,10 @@ class _WorkspaceMenuItemState extends State { ), ); }, - child: WorkspaceMoreActionList(workspace: widget.workspace), + child: WorkspaceMoreActionList( + workspace: widget.workspace, + isShowingMoreActions: widget.isShowingMoreActions, + ), ), const HSpace(8.0), if (widget.isSelected) ...[ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart index a871d91565..7e8946f077 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart @@ -1,10 +1,16 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/tabs/tab_menu_bloc.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/home_stack.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/icon_button.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; class FlowyTab extends StatefulWidget { @@ -12,63 +18,187 @@ class FlowyTab extends StatefulWidget { super.key, required this.pageManager, required this.isCurrent, + required this.onTap, }); final PageManager pageManager; final bool isCurrent; + final VoidCallback onTap; @override State createState() => _FlowyTabState(); } class _FlowyTabState extends State { + final controller = PopoverController(); + @override Widget build(BuildContext context) { return FlowyHover( - isSelected: () => widget.isCurrent, - style: const HoverStyle( + resetHoverOnRebuild: false, + style: HoverStyle( borderRadius: BorderRadius.zero, + backgroundColor: widget.isCurrent + ? Theme.of(context).colorScheme.surface + : Theme.of(context).colorScheme.surfaceContainerHighest, + hoverColor: + widget.isCurrent ? Theme.of(context).colorScheme.surface : null, ), - builder: (context, onHover) { - return ChangeNotifierProvider.value( + builder: (context, isHovering) => AppFlowyPopover( + controller: controller, + offset: const Offset(4, 4), + triggerActions: PopoverTriggerFlags.secondaryClick, + showAtCursor: true, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: BlocProvider( + create: (_) => TabMenuBloc( + viewId: widget.pageManager.plugin.id, + ), + child: TabMenu(pageId: widget.pageManager.plugin.id), + ), + ), + child: ChangeNotifierProvider.value( value: widget.pageManager.notifier, child: Consumer( - builder: (context, value, child) => Padding( + builder: (context, value, _) => Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - width: HomeSizes.tabBarWidth, - height: HomeSizes.tabBarHeight, - child: Row( - children: [ - Expanded( - child: widget.pageManager.notifier - .tabBarWidget(widget.pageManager.plugin.id), + // We use a Listener to avoid gesture detector onPanStart debounce + child: Listener( + onPointerDown: (event) { + if (event.buttons == kPrimaryButton) { + widget.onTap(); + } + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + // Stop move window detector + onPanStart: (_) {}, + child: Container( + constraints: const BoxConstraints( + maxWidth: HomeSizes.tabBarWidth, + minWidth: 100, ), - Visibility( - visible: onHover, - child: SizedBox( - width: 26, - height: 26, - child: FlowyIconButton( - onPressed: _closeTab, - icon: const FlowySvg( - FlowySvgs.close_s, - size: Size.square(22), + height: HomeSizes.tabBarHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: widget.pageManager.notifier + .tabBarWidget(widget.pageManager.plugin.id), + ), + Visibility( + visible: isHovering, + child: SizedBox( + width: 26, + height: 26, + child: FlowyIconButton( + onPressed: () => _closeTab(context), + icon: const FlowySvg( + FlowySvgs.close_s, + size: Size.square(22), + ), + ), ), ), - ), + ], ), - ], + ), ), ), ), ), + ), + ), + ); + } + + void _closeTab(BuildContext context) => context + .read() + .add(TabsEvent.closeTab(widget.pageManager.plugin.id)); +} + +@visibleForTesting +class TabMenu extends StatelessWidget { + const TabMenu({super.key, required this.pageId}); + + final String pageId; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.maybeMap( + isLoading: (_) => true, + orElse: () => false, + )) { + return const SizedBox.shrink(); + } + + final disableFavoriteOption = state.maybeWhen( + isReady: (_) => false, + orElse: () => true, + ); + + return SeparatedColumn( + separatorBuilder: () => const VSpace(4), + mainAxisSize: MainAxisSize.min, + children: [ + FlowyButton( + text: FlowyText.regular(LocaleKeys.tabMenu_close.tr()), + onTap: () => _closeTab(context), + ), + FlowyButton( + text: FlowyText.regular( + LocaleKeys.tabMenu_closeOthers.tr(), + ), + onTap: () => _closeOtherTabs(context), + ), + const Divider(height: 1), + _favoriteDisabledTooltip( + showTooltip: disableFavoriteOption, + child: FlowyButton( + disable: disableFavoriteOption, + text: FlowyText.regular( + state.maybeWhen( + isReady: (isFavorite) => isFavorite + ? LocaleKeys.tabMenu_unfavorite.tr() + : LocaleKeys.tabMenu_favorite.tr(), + orElse: () => LocaleKeys.tabMenu_favorite.tr(), + ), + color: disableFavoriteOption + ? Theme.of(context).hintColor + : null, + ), + onTap: () => _toggleFavorite(context), + ), + ), + ], ); }, ); } - void _closeTab([TapUpDetails? details]) => context - .read() - .add(TabsEvent.closeTab(widget.pageManager.plugin.id)); + void _closeTab(BuildContext context) => + context.read().add(TabsEvent.closeTab(pageId)); + + void _closeOtherTabs(BuildContext context) => + context.read().add(TabsEvent.closeOtherTabs(pageId)); + + void _toggleFavorite(BuildContext context) => + context.read().add(const TabMenuEvent.toggleFavorite()); + + Widget _favoriteDisabledTooltip({ + required bool showTooltip, + required Widget child, + }) { + if (showTooltip) { + return FlowyTooltip( + message: LocaleKeys.tabMenu_favoriteDisabledHint.tr(), + child: child, + ); + } + + return child; + } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart index 51f3e0b766..dd382fb584 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart @@ -1,59 +1,35 @@ +import 'package:appflowy/core/frameless_window.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class TabsManager extends StatefulWidget { - const TabsManager({super.key, required this.pageController}); + const TabsManager({ + super.key, + required this.onIndexChanged, + }); - final PageController pageController; + final void Function(int) onIndexChanged; @override State createState() => _TabsManagerState(); } -class _TabsManagerState extends State - with TickerProviderStateMixin { - late TabController _controller; - - @override - void initState() { - super.initState(); - _controller = TabController(vsync: this, length: 1); - } - +class _TabsManagerState extends State { @override Widget build(BuildContext context) { return BlocProvider.value( - value: BlocProvider.of(context), + value: context.read(), child: BlocListener( - listener: (context, state) { - if (_controller.length != state.pages) { - _controller.dispose(); - _controller = TabController( - vsync: this, - initialIndex: state.currentIndex, - length: state.pages, - ); - } - - if (state.currentIndex != widget.pageController.page) { - // Unfocus editor to hide selection toolbar - FocusScope.of(context).unfocus(); - - widget.pageController.animateToPage( - state.currentIndex, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, + listenWhen: (prev, curr) => + prev.currentIndex != curr.currentIndex || prev.pages != curr.pages, + listener: (context, state) => widget.onIndexChanged(state.currentIndex), child: BlocBuilder( builder: (context, state) { - if (_controller.length == 1) { + if (state.pages == 1) { return const SizedBox.shrink(); } @@ -63,31 +39,29 @@ class _TabsManagerState extends State decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, ), - - /// TODO(Xazin): Custom Reorderable TabBar - child: TabBar( - padding: EdgeInsets.zero, - labelPadding: EdgeInsets.zero, - indicator: BoxDecoration( - border: Border.all(width: 0, color: Colors.transparent), - ), - indicatorWeight: 0, - dividerColor: Colors.transparent, - isScrollable: true, - controller: _controller, - onTap: (newIndex) { - AFFocusManager.of(context).notifyLoseFocus(); - context.read().add(TabsEvent.selectTab(newIndex)); - }, - tabs: state.pageManagers - .map( - (pm) => FlowyTab( - key: UniqueKey(), - pageManager: pm, - isCurrent: state.currentPageManager == pm, + child: MoveWindowDetector( + child: Row( + children: state.pageManagers.map((pm) { + return Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: HomeSizes.tabBarWidth, + ), + child: FlowyTab( + key: ValueKey('tab-${pm.plugin.id}'), + pageManager: pm, + isCurrent: state.currentPageManager == pm, + onTap: () { + if (state.currentPageManager != pm) { + final index = state.pageManagers.indexOf(pm); + widget.onIndexChanged(index); + } + }, + ), ), - ) - .toList(), + ); + }).toList(), + ), ), ); }, @@ -95,10 +69,4 @@ class _TabsManagerState extends State ), ); } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_date_picker.dart index 06c3f3975b..e9f3262cc3 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_date_picker.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_date_picker.dart @@ -336,7 +336,7 @@ class _TimePicker extends StatelessWidget { use24hFormat: timeFormat == TimeFormatPB.TwentyFourHour, mode: CupertinoDatePickerMode.date, ); - handleDateTimePickerResult(result, isStartDay); + handleDateTimePickerResult(result, isStartDay, true); }, child: Padding( padding: const EdgeInsets.symmetric( @@ -363,7 +363,7 @@ class _TimePicker extends StatelessWidget { use24hFormat: timeFormat == TimeFormatPB.TwentyFourHour, mode: CupertinoDatePickerMode.date, ); - handleDateTimePickerResult(result, isStartDay); + handleDateTimePickerResult(result, isStartDay, true); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -389,7 +389,7 @@ class _TimePicker extends StatelessWidget { use24hFormat: timeFormat == TimeFormatPB.TwentyFourHour, mode: CupertinoDatePickerMode.time, ); - handleDateTimePickerResult(result, isStartDay); + handleDateTimePickerResult(result, isStartDay, false); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -461,11 +461,27 @@ class _TimePicker extends StatelessWidget { ); } - void handleDateTimePickerResult(DateTime? result, bool isStartDay) { + void handleDateTimePickerResult( + DateTime? result, + bool isStartDay, + bool isDate, + ) { if (result == null) { return; - } else if (isStartDay) { - onStartTimeChanged(result); + } + + if (isDate) { + final date = isStartDay ? dateTime : endDateTime; + + if (date != null) { + final timeComponent = Duration(hours: date.hour, minutes: date.minute); + result = + DateTime(result.year, result.month, result.day).add(timeComponent); + } + } + + if (isStartDay) { + onStartTimeChanged.call(result); } else { onEndTimeChanged?.call(result); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart index d5e6282f2e..bc391daf9e 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart @@ -7,8 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flowy_infra/size.dart'; class FlowyFormTextInput extends StatelessWidget { - static EdgeInsets kDefaultTextInputPadding = - EdgeInsets.only(bottom: Insets.sm, top: 4); + static EdgeInsets kDefaultTextInputPadding = const EdgeInsets.only(bottom: 2); final String? label; final bool? autoFocus; diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 27ee616618..f78522353d 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -61,8 +61,8 @@ packages: dependency: "direct main" description: path: "." - ref: "76daa96" - resolved-ref: "76daa96af51f0ad4e881c10426a91780977544e5" + ref: ea81e3c + resolved-ref: ea81e3c1647344aff45970c39556902ffad4373d url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "4.0.0" @@ -1812,14 +1812,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - shimmer: - dependency: "direct main" - description: - name: shimmer - sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" - url: "https://pub.dev" - source: hosted - version: "3.0.0" simple_gesture_detector: dependency: transitive description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 153324b1b6..c5dc07be7a 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -4,7 +4,7 @@ description: Bring projects, wikis, and teams together with AI. AppFlowy is an your data. The best open source alternative to Notion. publish_to: "none" -version: 0.7.3 +version: 0.7.4 environment: flutter: ">=3.22.0" @@ -123,7 +123,6 @@ dependencies: share_plus: ^10.0.2 shared_preferences: ^2.2.2 sheet: - shimmer: ^3.0.0 sized_context: ^1.0.0+4 string_validator: ^1.0.0 styled_widget: ^0.4.1 @@ -172,7 +171,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "76daa96" + ref: "ea81e3c" appflowy_editor_plugins: git: diff --git a/frontend/resources/flowy_icons/16x/ai_at.svg b/frontend/resources/flowy_icons/16x/ai_at.svg new file mode 100644 index 0000000000..72128f6c9c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_at.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_attachment.svg b/frontend/resources/flowy_icons/16x/ai_attachment.svg index 60a3cd5177..d998bbd621 100644 --- a/frontend/resources/flowy_icons/16x/ai_attachment.svg +++ b/frontend/resources/flowy_icons/16x/ai_attachment.svg @@ -1 +1,3 @@ -Paperclip 1 Streamline Icon: https://streamlinehq.com \ No newline at end of file + + + diff --git a/frontend/resources/flowy_icons/16x/ai_chat_outlined.svg b/frontend/resources/flowy_icons/16x/ai_chat_outlined.svg new file mode 100644 index 0000000000..3be82d4bf2 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_chat_outlined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_check_filled.svg b/frontend/resources/flowy_icons/16x/ai_check_filled.svg new file mode 100644 index 0000000000..79efe0bd0a --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_check_filled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_close.svg b/frontend/resources/flowy_icons/16x/ai_close.svg new file mode 100644 index 0000000000..9941c80abc --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_close.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_close_filled.svg b/frontend/resources/flowy_icons/16x/ai_close_filled.svg new file mode 100644 index 0000000000..9bf64c2bd8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_close_filled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_dislike.svg b/frontend/resources/flowy_icons/16x/ai_dislike.svg new file mode 100644 index 0000000000..5754775e88 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_dislike.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_expand.svg b/frontend/resources/flowy_icons/16x/ai_expand.svg new file mode 100644 index 0000000000..83df4e9234 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_expand.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_image.svg b/frontend/resources/flowy_icons/16x/ai_image.svg new file mode 100644 index 0000000000..79f6081090 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_image.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_like.svg b/frontend/resources/flowy_icons/16x/ai_like.svg new file mode 100644 index 0000000000..b7a92725aa --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_like.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_list.svg b/frontend/resources/flowy_icons/16x/ai_list.svg new file mode 100644 index 0000000000..ee31345b13 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_number_list.svg b/frontend/resources/flowy_icons/16x/ai_number_list.svg new file mode 100644 index 0000000000..c226e339f7 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_number_list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_page.svg b/frontend/resources/flowy_icons/16x/ai_page.svg new file mode 100644 index 0000000000..09598791b9 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_paragraph.svg b/frontend/resources/flowy_icons/16x/ai_paragraph.svg new file mode 100644 index 0000000000..fc184ace91 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_paragraph.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_reset.svg b/frontend/resources/flowy_icons/16x/ai_reset.svg new file mode 100644 index 0000000000..a589eda1fe --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_reset.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_retry.svg b/frontend/resources/flowy_icons/16x/ai_retry.svg new file mode 100644 index 0000000000..cfc50452af --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_retry.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_retry_filled.svg b/frontend/resources/flowy_icons/16x/ai_retry_filled.svg new file mode 100644 index 0000000000..021f724b01 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_retry_filled.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_retry_font.svg b/frontend/resources/flowy_icons/16x/ai_retry_font.svg new file mode 100644 index 0000000000..1c622f4040 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_retry_font.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_save.svg b/frontend/resources/flowy_icons/16x/ai_save.svg new file mode 100644 index 0000000000..ad228863b2 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_save.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_send_filled.svg b/frontend/resources/flowy_icons/16x/ai_send_filled.svg new file mode 100644 index 0000000000..5f86dd3528 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_send_filled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_sparks.svg b/frontend/resources/flowy_icons/16x/ai_sparks.svg new file mode 100644 index 0000000000..dc23a6c287 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_sparks.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/ai_stop_filled.svg b/frontend/resources/flowy_icons/16x/ai_stop_filled.svg new file mode 100644 index 0000000000..33c0fe090c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_stop_filled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_stream_stop.svg b/frontend/resources/flowy_icons/16x/ai_stream_stop.svg deleted file mode 100644 index 55c7355ab7..0000000000 --- a/frontend/resources/flowy_icons/16x/ai_stream_stop.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/ai_table.svg b/frontend/resources/flowy_icons/16x/ai_table.svg new file mode 100644 index 0000000000..993e1d5764 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_table.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_text.svg b/frontend/resources/flowy_icons/16x/ai_text.svg new file mode 100644 index 0000000000..0198b267b8 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_text.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_text_image.svg b/frontend/resources/flowy_icons/16x/ai_text_image.svg new file mode 100644 index 0000000000..166048108f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_text_image.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/resources/flowy_icons/16x/ai_undo.svg b/frontend/resources/flowy_icons/16x/ai_undo.svg new file mode 100644 index 0000000000..c3f51de34d --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ai_undo.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/16x/chat_feather.svg b/frontend/resources/flowy_icons/16x/chat_feather.svg deleted file mode 100644 index 7a960957c0..0000000000 --- a/frontend/resources/flowy_icons/16x/chat_feather.svg +++ /dev/null @@ -1 +0,0 @@ -Feather Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/chat_lightbulb.svg b/frontend/resources/flowy_icons/16x/chat_lightbulb.svg deleted file mode 100644 index dc5d1806e4..0000000000 --- a/frontend/resources/flowy_icons/16x/chat_lightbulb.svg +++ /dev/null @@ -1 +0,0 @@ -Lightbulb Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/chat_pen.svg b/frontend/resources/flowy_icons/16x/chat_pen.svg deleted file mode 100644 index d26493b164..0000000000 --- a/frontend/resources/flowy_icons/16x/chat_pen.svg +++ /dev/null @@ -1 +0,0 @@ -Pen Line Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/chat_question.svg b/frontend/resources/flowy_icons/16x/chat_question.svg deleted file mode 100644 index db16cb1f9d..0000000000 --- a/frontend/resources/flowy_icons/16x/chat_question.svg +++ /dev/null @@ -1 +0,0 @@ -Message Circle Question Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/chat_scholar.svg b/frontend/resources/flowy_icons/16x/chat_scholar.svg deleted file mode 100644 index 49fb435836..0000000000 --- a/frontend/resources/flowy_icons/16x/chat_scholar.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/file_md.svg b/frontend/resources/flowy_icons/16x/file_md.svg deleted file mode 100644 index 0414453c10..0000000000 --- a/frontend/resources/flowy_icons/16x/file_md.svg +++ /dev/null @@ -1 +0,0 @@ -File Md Light Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/file_pdf.svg b/frontend/resources/flowy_icons/16x/file_pdf.svg deleted file mode 100644 index 04684dc74a..0000000000 --- a/frontend/resources/flowy_icons/16x/file_pdf.svg +++ /dev/null @@ -1 +0,0 @@ -File Pdf Light Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/file_txt.svg b/frontend/resources/flowy_icons/16x/file_txt.svg deleted file mode 100644 index 27c5aec523..0000000000 --- a/frontend/resources/flowy_icons/16x/file_txt.svg +++ /dev/null @@ -1 +0,0 @@ -File Txt Light Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/file_unknown.svg b/frontend/resources/flowy_icons/16x/file_unknown.svg deleted file mode 100644 index cc4225f5f0..0000000000 --- a/frontend/resources/flowy_icons/16x/file_unknown.svg +++ /dev/null @@ -1 +0,0 @@ -File X Light Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/toggle_heading1.svg b/frontend/resources/flowy_icons/16x/toggle_heading1.svg new file mode 100644 index 0000000000..8392acb665 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toggle_heading1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/toggle_heading2.svg b/frontend/resources/flowy_icons/16x/toggle_heading2.svg new file mode 100644 index 0000000000..1b0721777e --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toggle_heading2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/toggle_heading3.svg b/frontend/resources/flowy_icons/16x/toggle_heading3.svg new file mode 100644 index 0000000000..0939a5e997 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/toggle_heading3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/resources/flowy_icons/16x/warning_filled.svg b/frontend/resources/flowy_icons/16x/warning_filled.svg new file mode 100644 index 0000000000..b9b65cc1d7 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/warning_filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index b3546eb033..d760fb8014 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -691,9 +691,7 @@ "createNew": "إنشاء جديد", "orSelectOne": "أو حدد خيارًا", "typeANewOption": "اكتب خيارًا جديدًا", - "tagName": "اسم العلامة", - "colorPannelTitle": "لوحة الالوان", - "pannelTitle": "لوحة الخيارات" + "tagName": "اسم العلامة" }, "checklist": { "taskHint": "وصف المهمة", diff --git a/frontend/resources/translations/de-DE.json b/frontend/resources/translations/de-DE.json index a7211b4fda..c722447891 100644 --- a/frontend/resources/translations/de-DE.json +++ b/frontend/resources/translations/de-DE.json @@ -190,11 +190,13 @@ "chatWithFilePrompt": "Möchtest du mit der Datei chatten?", "indexFileSuccess": "Datei erfolgreich indiziert", "inputActionNoPages": "Keine Seitenergebnisse", - "referenceSource": "{} Quelle gefunden", - "referenceSources": "{} Quellen gefunden", + "referenceSource": { + "zero": "0 Quellen gefunden", + "one": "{count} Quelle gefunden", + "other": "{count} Quellen gefunden" + }, "clickToMention": "Klicke hier, um eine Seite zu erwähnen", "uploadFile": "Lade PDF-, md- oder txt-Dateien in den Chat hoch", - "questionTitle": "Ideen", "questionDetail": "Hallo {}! Wie kann ich dir heute helfen?", "indexingFile": "Indizierung {}" }, diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 8293e20cb5..c23c8e82a6 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -178,26 +178,32 @@ "inputMessageHint": "Ask @:appName AI", "inputLocalAIMessageHint": "Ask @:appName Local AI", "unsupportedCloudPrompt": "This feature is only available when using @:appName Cloud", - "relatedQuestion": "Related", + "relatedQuestion": "Suggested", "serverUnavailable": "Service Temporarily Unavailable. Please try again later.", - "aiServerUnavailable": "🌈 Uh-oh! 🌈. A unicorn ate our response. Please retry!", + "aiServerUnavailable": "Connection lost. Please check your internet and", + "retry": "Retry", "clickToRetry": "Click to retry", "regenerateAnswer": "Regenerate", "question1": "How to use Kanban to manage tasks", "question2": "Explain the GTD method", "question3": "Why use Rust", "question4": "Recipe with what's in my kitchen", + "question5": "Create an illustration for my page", + "question6": "Draw up a to-do list for my upcoming week", "aiMistakePrompt": "AI can make mistakes. Check important info.", "chatWithFilePrompt": "Do you want to chat with the file?", "indexFileSuccess": "Indexing file successfully", "inputActionNoPages": "No page results", - "referenceSource": "{} source found", - "referenceSources": "{} sources found", - "clickToMention": "Click to mention a page", - "uploadFile": "Upload PDFs, md or txt files to chat with", - "questionTitle": "Ideas", + "referenceSource": { + "zero": "0 sources gefunden", + "one": "{count} source found", + "other": "{count} sources found" + }, + "clickToMention": "Mention a page", + "uploadFile": "Attach PDFs, text or markdown files", "questionDetail": "Hi {}! How can I help you today?", - "indexingFile": "Indexing {}" + "indexingFile": "Indexing {}", + "generatingResponse": "Generating response" }, "trash": { "text": "Trash", @@ -328,7 +334,7 @@ "aiResponseLimit": "You have run out of free AI responses.\n\nGo to Settings -> Plan -> Click AI Max or Pro Plan to get more AI responses", "askOwnerToUpgradeToPro": "Your workspace is running out of free storage. Please ask your workspace owner to upgrade to the Pro Plan", "askOwnerToUpgradeToProIOS": "Your workspace is running out of free storage.", - "askOwnerToUpgradeToAIMax": "Your workspace is running out of free AI responses. Please ask your workspace owner to upgrade the plan or purchase AI add-ons", + "askOwnerToUpgradeToAIMax": "Your workspace has ran out of free AI responses. Please ask your workspace owner to upgrade the plan or purchase AI add-ons", "askOwnerToUpgradeToAIMaxIOS": "Your workspace is running out of free AI responses.", "purchaseStorageSpace": "Purchase Storage Space", "purchaseAIResponse": "Purchase ", @@ -368,6 +374,7 @@ "upload": "Upload", "edit": "Edit", "delete": "Delete", + "copy": "Copy", "duplicate": "Duplicate", "putback": "Put Back", "update": "Update", @@ -1036,7 +1043,7 @@ "cloudAppFlowy": "@:appName Cloud", "cloudAppFlowySelfHost": "@:appName Cloud Self-hosted", "appFlowyCloudUrlCanNotBeEmpty": "The cloud url can't be empty", - "clickToCopy": "Click to copy", + "clickToCopy": "Copy to clipboard", "selfHostStart": "If you don't have a server, please refer to the", "selfHostContent": "document", "selfHostEnd": "for guidance on how to self-host your own server", @@ -2845,5 +2852,12 @@ "one": "1 member", "many": "{count} members", "other": "{count} members" + }, + "tabMenu": { + "close": "Close", + "closeOthers": "Close other tabs", + "favorite": "Favorite", + "unfavorite": "Unfavorite", + "favoriteDisabledHint": "Cannot favorite this view" } } diff --git a/frontend/resources/translations/fr-FR.json b/frontend/resources/translations/fr-FR.json index 0089ccab54..372f7fa0c8 100644 --- a/frontend/resources/translations/fr-FR.json +++ b/frontend/resources/translations/fr-FR.json @@ -182,12 +182,14 @@ "chatWithFilePrompt": "Voulez-vous discuter avec le fichier ?", "indexFileSuccess": "Indexation du fichier réussie", "inputActionNoPages": "Aucun résultat de page", - "referenceSource": "{} source trouvée", - "referenceSources": "{} sources trouvées", + "referenceSource": { + "zero": "0 sources trouvées", + "one": "{count} source trouvée", + "other": "{count} sources trouvées" + }, "clickToMention": "Cliquez pour mentionner une page", "uploadFile": "Téléchargez des fichiers PDF, MD ou TXT pour discuter avec", - "questionTitle": "Idées", - "questionDetail": "Bonjour {} ! Comment puis-je vous aider aujourd'hui ?", + "questionDetail": "Bonjour {}! Comment puis-je vous aider aujourd'hui?", "indexingFile": "Indexation {}" }, "trash": { diff --git a/frontend/resources/translations/it-IT.json b/frontend/resources/translations/it-IT.json index 2c5a7b065f..d6877ecd59 100644 --- a/frontend/resources/translations/it-IT.json +++ b/frontend/resources/translations/it-IT.json @@ -176,11 +176,13 @@ "chatWithFilePrompt": "Vuoi chattare col file?", "indexFileSuccess": "Indicizzazione file completata con successo", "inputActionNoPages": "Nessuna pagina risultante", - "referenceSource": "{} fonte trovata", - "referenceSources": "{} fonti trovate", + "referenceSource": { + "zero": "0 fonti trovate", + "one": "{count} fonte trovata", + "other": "{count} fonti trovate" + }, "clickToMention": "Clicca per menzionare una pagina", "uploadFile": "Carica file PDF, MD o TXT con la quale chattare", - "questionTitle": "Idee", "questionDetail": "Salve {}! Come posso aiutarti oggi?", "indexingFile": "Indicizzazione {}" }, diff --git a/frontend/resources/translations/ja-JP.json b/frontend/resources/translations/ja-JP.json index dc8842e195..949f32e2c4 100644 --- a/frontend/resources/translations/ja-JP.json +++ b/frontend/resources/translations/ja-JP.json @@ -177,11 +177,11 @@ "chatWithFilePrompt": "ファイルとチャットしますか?", "indexFileSuccess": "ファイルのインデックス作成が成功しました", "inputActionNoPages": "ページ結果がありません", - "referenceSource": "{} 件のソースが見つかりました", - "referenceSources": "{} 件のソースが見つかりました", + "referenceSource": { + "other": "{count} 件のソースが見つかりました" + }, "clickToMention": "ページをメンションするにはクリック", "uploadFile": "PDF、md、txtファイルをアップロードしてチャット", - "questionTitle": "アイデア", "questionDetail": "こんにちは、{}!今日はどんなお手伝いをしましょうか?", "indexingFile": "{}をインデックス作成中" }, diff --git a/frontend/resources/translations/pt-BR.json b/frontend/resources/translations/pt-BR.json index ce4e53322e..51b585f14b 100644 --- a/frontend/resources/translations/pt-BR.json +++ b/frontend/resources/translations/pt-BR.json @@ -180,11 +180,13 @@ "chatWithFilePrompt": "Você quer conversar com o arquivo?", "indexFileSuccess": "Arquivo indexado com sucesso", "inputActionNoPages": "Nenhum resultado", - "referenceSource": "{} fonte encontrada", - "referenceSources": "{} fontes encontradas", + "referenceSource": { + "zero": "0 fontes encontradas", + "one": "{count} fonte encontrada", + "other": "{count} fontes encontradas" + }, "clickToMention": "Clique para mencionar uma página", "uploadFile": "Carregue arquivos PDFs, md ou txt para conversar", - "questionTitle": "Ideias", "questionDetail": "Olá {}! Como posso te ajudar hoje?", "indexingFile": "Indexando {}" }, @@ -966,9 +968,7 @@ "createNew": "Crie um novo", "orSelectOne": "Ou selecione uma opção", "typeANewOption": "Digite uma nova opção", - "tagName": "Nome da etiqueta", - "colorPannelTitle": "Cores", - "pannelTitle": "Escolha uma opção ou crie uma" + "tagName": "Nome da etiqueta" }, "checklist": { "taskHint": "Descrição da tarefa", diff --git a/frontend/resources/translations/uk-UA.json b/frontend/resources/translations/uk-UA.json index 93fa367a03..35b801cb87 100644 --- a/frontend/resources/translations/uk-UA.json +++ b/frontend/resources/translations/uk-UA.json @@ -180,11 +180,13 @@ "chatWithFilePrompt": "Хочете поспілкуватися з файлом?", "indexFileSuccess": "Файл успішно індексується", "inputActionNoPages": "Сторінка не знайдена", - "referenceSource": "{} джерело знайдено", - "referenceSources": "{} джерел знайдено", + "referenceSource": { + "zero": "0 джерел знайдено", + "one": "{count} джерело знайдено", + "other": "{count} джерел знайдено" + }, "clickToMention": "Натисніть, щоб згадати сторінку", "uploadFile": "Завантажуйте PDF-файли, файли md або txt для спілкування в чаті", - "questionTitle": "Ідеї", "questionDetail": "Привіт {}! Чим я можу тобі допомогти сьогодні?", "indexingFile": "Індексація {}" }, diff --git a/frontend/resources/translations/vi-VN.json b/frontend/resources/translations/vi-VN.json index 924061c921..ea6ceeeca4 100644 --- a/frontend/resources/translations/vi-VN.json +++ b/frontend/resources/translations/vi-VN.json @@ -181,11 +181,13 @@ "chatWithFilePrompt": "Bạn có muốn trò chuyện với tập tin không?", "indexFileSuccess": "Đang lập chỉ mục tệp thành công", "inputActionNoPages": "Không có kết quả trang", - "referenceSource": "{} nguồn đã tìm thấy", - "referenceSources": "{} nguồn được tìm thấy", + "referenceSource": { + "zero": "0 nguồn được tìm thấy", + "one": "{count} nguồn đã tìm thấy", + "other": "{count} nguồn được tìm thấy" + }, "clickToMention": "Nhấp để đề cập đến một trang", "uploadFile": "Tải lên các tệp PDF, md hoặc txt để trò chuyện", - "questionTitle": "Ý tưởng", "questionDetail": "Xin chào {}! Tôi có thể giúp gì cho bạn hôm nay?", "indexingFile": "Đang lập chỉ mục {}" }, diff --git a/frontend/resources/translations/zh-TW.json b/frontend/resources/translations/zh-TW.json index 5cac15ec1e..fa9626e089 100644 --- a/frontend/resources/translations/zh-TW.json +++ b/frontend/resources/translations/zh-TW.json @@ -179,7 +179,6 @@ "aiMistakePrompt": "AI 可能會犯錯,請檢查重要資訊。", "clickToMention": "單擊以提及頁面", "uploadFile": "上傳 PDF、md 或 txt 檔案以進行聊天", - "questionTitle": "想法", "questionDetail": "{} 你好!我能為您提供什麼幫助?" }, "trash": { diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs b/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs index 0f328cdf2a..cee8f1d381 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/watch.rs @@ -5,13 +5,14 @@ use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; use tracing::{error, trace}; #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[allow(dead_code)] pub struct WatchContext { - #[allow(dead_code)] watcher: notify::RecommendedWatcher, pub path: PathBuf, } #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[allow(dead_code)] pub fn watch_offline_app() -> FlowyResult<(WatchContext, UnboundedReceiver)> { use notify::{Event, Watcher};