Merge branch 'main' into feat/workspace-roles
2
.github/workflows/flutter_ci.yaml
vendored
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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<WorkspaceIcon>(
|
||||
find.byType(WorkspaceIcon),
|
||||
);
|
||||
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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<ClipboardService>().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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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.
|
||||
}
|
||||
|
@ -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<void> main() async {
|
||||
await runIntegration9OnDesktop();
|
||||
}
|
||||
|
||||
Future<void> runIntegration9OnDesktop() async {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
first_test.main();
|
||||
|
||||
tabs_test.main();
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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]))),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
@ -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<void> runIntegration1OnMobile() async {
|
||||
|
||||
anonymous_sign_in_test.main();
|
||||
create_new_page_test.main();
|
||||
page_style_test.main();
|
||||
document_test_runner.main();
|
||||
}
|
||||
|
@ -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<void> main() async {
|
||||
await runIntegration6OnDesktop();
|
||||
await runIntegration7OnDesktop();
|
||||
await runIntegration8OnDesktop();
|
||||
await runIntegration9OnDesktop();
|
||||
} else if (Platform.isIOS || Platform.isAndroid) {
|
||||
await runIntegration1OnMobile();
|
||||
} else {
|
||||
|
@ -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<void> 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<TextField>(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<void> 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 {
|
||||
|
@ -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);
|
||||
|
@ -9,12 +9,14 @@ class TypeOptionMenuItemValue<T> {
|
||||
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<T> 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<T> extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _GridView(
|
||||
return TypeOptionGridView(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: maxAxisSpacing * scaleFactor,
|
||||
itemWidth: width * scaleFactor,
|
||||
children: values
|
||||
.map(
|
||||
(value) => _TypeOptionMenuItem<T>(
|
||||
(value) => TypeOptionMenuItem<T>(
|
||||
value: value,
|
||||
width: width,
|
||||
iconWidth: iconWidth,
|
||||
scaleFactor: scaleFactor,
|
||||
iconPadding: value.iconPadding,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@ -57,18 +60,21 @@ class TypeOptionMenu<T> extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeOptionMenuItem<T> extends StatelessWidget {
|
||||
const _TypeOptionMenuItem({
|
||||
class TypeOptionMenuItem<T> extends StatelessWidget {
|
||||
const TypeOptionMenuItem({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.width = 94,
|
||||
this.iconWidth = 72,
|
||||
this.scaleFactor = 1.0,
|
||||
this.iconPadding,
|
||||
});
|
||||
|
||||
final TypeOptionMenuItemValue<T> 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<T> 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<T> 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,
|
||||
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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<AIPromptInputEvent, AIPromptInputState> {
|
||||
AIPromptInputBloc()
|
||||
: _listener = LocalLLMListener(),
|
||||
super(AIPromptInputState.initial()) {
|
||||
_dispatch();
|
||||
_startListening();
|
||||
_init();
|
||||
}
|
||||
|
||||
class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
|
||||
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<void> close() async {
|
||||
await _listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
void _dispatch() {
|
||||
on<AIPromptInputEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
newFile: (String filePath, String fileName) {
|
||||
final files = [...state.uploadFiles];
|
||||
|
||||
on<ChatFileEvent>(
|
||||
(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<ChatFile>.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<ChatFileEvent, ChatFileState> {
|
||||
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<ChatFileEvent, ChatFileState> {
|
||||
);
|
||||
}
|
||||
|
||||
ChatInputFileMetadata consumeMetaData() {
|
||||
final metadata = state.uploadFiles.fold(
|
||||
<String, ChatFile>{},
|
||||
(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<void> 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<ChatFile> uploadFiles,
|
||||
@Default(AIType.appflowyAI()) AIType aiType,
|
||||
}) = _ChatFileState;
|
||||
required List<ChatFile> 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;
|
||||
}
|
@ -59,9 +59,8 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
}
|
||||
|
||||
on<ChatAIMessageEvent>(
|
||||
(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<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
|
||||
@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;
|
||||
|
@ -106,7 +106,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
didLoadPreviousMessages: (List<Message> 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<ChatEvent, ChatState> {
|
||||
},
|
||||
didLoadLatestMessages: (List<Message> 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<ChatEvent, ChatState> {
|
||||
|
||||
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<ChatEvent, ChatState> {
|
||||
);
|
||||
}
|
||||
},
|
||||
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<ChatEvent, ChatState> {
|
||||
);
|
||||
},
|
||||
startAnswerStreaming: (Message message) {
|
||||
final allMessages = _perminentMessages();
|
||||
final allMessages = _permanentMessages();
|
||||
allMessages.insert(0, message);
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -199,7 +199,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
},
|
||||
sendMessage: (String message, Map<String, dynamic>? 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<ChatEvent, ChatState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
failedSending: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: _permanentMessages()..removeAt(0),
|
||||
sendingState: const SendMessageState.done(),
|
||||
canSendMessage: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
// related question
|
||||
didReceiveRelatedQuestion: (List<RelatedQuestionPB> 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<ChatEvent, ChatState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
clearReleatedQuestion: () {
|
||||
clearRelatedQuestions: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
relatedQuestions: [],
|
||||
@ -272,7 +282,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
}
|
||||
|
||||
final message = _createTextMessage(pb);
|
||||
add(ChatEvent.receveMessage(message));
|
||||
add(ChatEvent.receiveMessage(message));
|
||||
}
|
||||
},
|
||||
chatErrorMessageCallback: (err) {
|
||||
@ -325,7 +335,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
}
|
||||
|
||||
// Returns the list of messages that are not include one-time messages.
|
||||
List<Message> _perminentMessages() {
|
||||
List<Message> _permanentMessages() {
|
||||
final allMessages = state.messages.where((element) {
|
||||
return !(element.metadata?.containsKey(onetimeShotType) == true);
|
||||
}).toList();
|
||||
@ -384,7 +394,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
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<ChatEvent, ChatState> {
|
||||
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<ChatEvent, ChatState> {
|
||||
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<ChatEvent, ChatState> {
|
||||
"chatId": chatId,
|
||||
},
|
||||
id: streamMessageId,
|
||||
showStatus: false,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
text: '',
|
||||
);
|
||||
@ -462,6 +475,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
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<ChatEvent, ChatState> {
|
||||
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<RelatedQuestionPB> questions,
|
||||
) = _DidReceiveRelatedQueston;
|
||||
const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion;
|
||||
const factory ChatEvent.clearRelatedQuestions() = _ClearRelatedQuestions;
|
||||
|
||||
const factory ChatEvent.didUpdateAnswerStream(
|
||||
AnswerStream stream,
|
||||
|
@ -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<Object?> get props => [filePath];
|
||||
}
|
||||
|
||||
extension ChatFileTypeExtension on ChatMessageMetaTypePB {
|
||||
Widget get icon {
|
||||
switch (this) {
|
||||
case ChatMessageMetaTypePB.PDF:
|
||||
return const FlowySvg(
|
||||
FlowySvgs.file_pdf_s,
|
||||
color: Color(0xff00BCF0),
|
||||
);
|
||||
case ChatMessageMetaTypePB.Txt:
|
||||
return const FlowySvg(
|
||||
FlowySvgs.file_txt_s,
|
||||
color: Color(0xff00BCF0),
|
||||
);
|
||||
case ChatMessageMetaTypePB.Markdown:
|
||||
return const FlowySvg(
|
||||
FlowySvgs.file_md_s,
|
||||
color: Color(0xff00BCF0),
|
||||
);
|
||||
default:
|
||||
return const FlowySvg(FlowySvgs.file_unknown_s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typedef ChatInputFileMetadata = Map<String, ChatFile>;
|
||||
|
||||
@freezed
|
||||
|
@ -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<ChatInputStateEvent, ChatInputStateState> {
|
||||
ChatInputStateBloc()
|
||||
: listener = LocalLLMListener(),
|
||||
super(const ChatInputStateState(aiType: _AppFlowyAI())) {
|
||||
listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
if (!isClosed) {
|
||||
add(ChatInputStateEvent.updatePluginState(pluginState));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
on<ChatInputStateEvent>(_handleEvent);
|
||||
}
|
||||
|
||||
final LocalLLMListener listener;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await listener.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _handleEvent(
|
||||
ChatInputStateEvent event,
|
||||
Emitter<ChatInputStateState> 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;
|
||||
}
|
@ -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<ChatInputFileEvent, ChatInputFileState> {
|
||||
ChatInputFileBloc({
|
||||
// ignore: avoid_unused_constructor_parameters
|
||||
required String chatId,
|
||||
required this.file,
|
||||
}) : super(const ChatInputFileState()) {
|
||||
on<ChatInputFileEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
initial: () async {},
|
||||
event.when(
|
||||
updateUploadState: (UploadFileIndicator indicator) {
|
||||
emit(state.copyWith(uploadFileIndicator: indicator));
|
||||
},
|
||||
@ -28,7 +24,6 @@ class ChatInputFileBloc extends Bloc<ChatInputFileEvent, ChatInputFileState> {
|
||||
|
||||
@freezed
|
||||
class ChatInputFileEvent with _$ChatInputFileEvent {
|
||||
const factory ChatInputFileEvent.initial() = Initial;
|
||||
const factory ChatInputFileEvent.updateUploadState(
|
||||
UploadFileIndicator indicator,
|
||||
) = _UpdateUploadState;
|
||||
|
@ -13,7 +13,6 @@ class ChatMemberBloc extends Bloc<ChatMemberEvent, ChatMemberState> {
|
||||
on<ChatMemberEvent>(
|
||||
(event, emit) async {
|
||||
event.when(
|
||||
initial: () {},
|
||||
receiveMemberInfo: (String id, WorkspaceMemberPB memberInfo) {
|
||||
final members = Map<String, ChatMember>.from(state.members);
|
||||
members[id] = ChatMember(info: memberInfo);
|
||||
@ -51,7 +50,6 @@ class ChatMemberBloc extends Bloc<ChatMemberEvent, ChatMemberState> {
|
||||
|
||||
@freezed
|
||||
class ChatMemberEvent with _$ChatMemberEvent {
|
||||
const factory ChatMemberEvent.initial() = Initial;
|
||||
const factory ChatMemberEvent.getMemberInfo(
|
||||
String userId,
|
||||
) = _GetMemberInfo;
|
||||
|
@ -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<ChatFile> fileListFromMessageMetadata(
|
||||
Map<String, dynamic>? map,
|
||||
@ -119,7 +119,7 @@ Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
|
||||
name: view.name,
|
||||
data: pb.text,
|
||||
dataType: ChatMessageMetaTypePB.Txt,
|
||||
source: appflowySoruce,
|
||||
source: appflowySource,
|
||||
),
|
||||
);
|
||||
}, (err) {
|
||||
|
@ -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<ChatSidePanelEvent, ChatSidePanelState> {
|
||||
ChatSidePanelBloc({
|
||||
required this.chatId,
|
||||
}) : super(const ChatSidePanelState()) {
|
||||
on<ChatSidePanelEvent>(
|
||||
(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;
|
||||
}
|
@ -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<ChatSidePannelEvent, ChatSidePannelState> {
|
||||
ChatSidePannelBloc({
|
||||
required this.chatId,
|
||||
}) : super(const ChatSidePannelState()) {
|
||||
on<ChatSidePannelEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
selectedMetadata: (ChatMessageRefSource metadata) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
metadata: metadata,
|
||||
indicator: const ChatSidePannelIndicator.loading(),
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
ViewBackendService.getView(metadata.id).then(
|
||||
(result) {
|
||||
result.fold((view) {
|
||||
if (!isClosed) {
|
||||
add(ChatSidePannelEvent.open(view));
|
||||
}
|
||||
}, (err) {
|
||||
Log.error("Failed to get view: $err");
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
close: () {
|
||||
emit(state.copyWith(metadata: null, isShowPannel: false));
|
||||
},
|
||||
open: (ViewPB view) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
indicator: ChatSidePannelIndicator.ready(view),
|
||||
isShowPannel: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePannelEvent with _$ChatSidePannelEvent {
|
||||
const factory ChatSidePannelEvent.selectedMetadata(
|
||||
ChatMessageRefSource metadata,
|
||||
) = _SelectedMetadata;
|
||||
const factory ChatSidePannelEvent.close() = _Close;
|
||||
const factory ChatSidePannelEvent.open(ViewPB view) = _Open;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePannelState with _$ChatSidePannelState {
|
||||
const factory ChatSidePannelState({
|
||||
ChatMessageRefSource? metadata,
|
||||
@Default(ChatSidePannelIndicator.loading())
|
||||
ChatSidePannelIndicator indicator,
|
||||
@Default(false) bool isShowPannel,
|
||||
}) = _ChatSidePannelState;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSidePannelIndicator with _$ChatSidePannelIndicator {
|
||||
const factory ChatSidePannelIndicator.ready(ViewPB view) = _Ready;
|
||||
const factory ChatSidePannelIndicator.loading() = _Loading;
|
||||
}
|
@ -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<ChatFileBloc, ChatFileState>(
|
||||
builder: (context, state) {
|
||||
return DropTarget(
|
||||
onDragDone: (DropDoneDetails detail) async {
|
||||
if (state.supportChatWithFile) {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<ChatFileBloc>()
|
||||
.add(ChatFileEvent.newFile(file.path, file.name));
|
||||
}
|
||||
}
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
);
|
||||
},
|
||||
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<AIPromptInputBloc>().state.supportChatWithFile) {
|
||||
for (final file in detail.files) {
|
||||
context
|
||||
.read<AIPromptInputBloc>()
|
||||
.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<ChatSidePannelBloc, ChatSidePannelState, bool>(
|
||||
selector: (state) => state.isShowPannel,
|
||||
builder: (context, isShowPannel) {
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final double chatOffsetX = isShowPannel
|
||||
? 60
|
||||
: (constraints.maxWidth > 784
|
||||
? (constraints.maxWidth - 784) / 2.0
|
||||
: 60);
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
return BlocSelector<ChatSidePanelBloc, ChatSidePanelState, bool>(
|
||||
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<ChatSidePannelBloc, ChatSidePannelState>(
|
||||
builder: (context, state) {
|
||||
if (state.metadata != null) {
|
||||
return const ChatSidePannel();
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// TODO(lucas): implement mobile chat side panel
|
||||
return const SizedBox.shrink();
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 784),
|
||||
child: buildChatWidget(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildChatSidePanel() {
|
||||
return BlocBuilder<ChatSidePanelBloc, ChatSidePanelState>(
|
||||
builder: (context, state) {
|
||||
if (state.metadata == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return const ChatSidePanel();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChatWidget() {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (blocContext, state) => Chat(
|
||||
key: ValueKey(widget.view.id),
|
||||
messages: state.messages,
|
||||
onSendPressed: (_) {
|
||||
// We use custom bottom widget for chat input, so
|
||||
// do not need to handle this event.
|
||||
},
|
||||
customBottomWidget: _buildBottom(blocContext),
|
||||
user: _user,
|
||||
theme: buildTheme(context),
|
||||
onEndReached: () async {
|
||||
if (state.hasMorePrevMessage &&
|
||||
state.loadingPreviousStatus.isFinish) {
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.startLoadingPrevMessage());
|
||||
}
|
||||
},
|
||||
emptyState: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (_, state) => state.initialLoadingStatus.isFinish
|
||||
? Padding(
|
||||
padding: AIChatUILayout.welcomePagePadding,
|
||||
child: ChatWelcomePage(
|
||||
userProfile: widget.userProfile,
|
||||
onSelectedQuestion: (question) => blocContext
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(message: question)),
|
||||
builder: (context, state) {
|
||||
return ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
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<ChatBloc>()
|
||||
.add(const ChatEvent.startLoadingPrevMessage());
|
||||
}
|
||||
},
|
||||
emptyState: ChatWelcomePage(
|
||||
userProfile: userProfile,
|
||||
onSelectedQuestion: (question) => context
|
||||
.read<ChatBloc>()
|
||||
.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<ChatBloc>();
|
||||
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<ChatBloc, ChatState, bool>(
|
||||
key: ValueKey(message.id),
|
||||
questionId: questionId,
|
||||
chatId: widget.view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
onSelectedMetadata: (ChatMessageRefSource metadata) {
|
||||
context.read<ChatSidePannelBloc>().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<ChatSidePanelBloc>()
|
||||
.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<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(message: question));
|
||||
blocContext
|
||||
.read<ChatBloc>()
|
||||
.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<ChatInputStateBloc, ChatInputStateState>(
|
||||
builder: (context, state) {
|
||||
// Show different hint text based on the AI type
|
||||
final aiType = state.aiType;
|
||||
final hintText = state.aiType.when(
|
||||
appflowyAI: () => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
localAI: () => LocaleKeys.chat_inputLocalAIMessageHint.tr(),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) => state.canSendMessage,
|
||||
builder: (context, canSendMessage) {
|
||||
return ChatInput(
|
||||
aiType: aiType,
|
||||
chatId: widget.view.id,
|
||||
onSendPressed: (message) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: message.text,
|
||||
metadata: message.metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.stopStream());
|
||||
},
|
||||
hintText: hintText,
|
||||
);
|
||||
return Padding(
|
||||
padding: AIChatUILayout.safeAreaInsets(context),
|
||||
child: BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) => state.canSendMessage,
|
||||
builder: (context, canSendMessage) {
|
||||
return UniversalPlatform.isDesktop
|
||||
? DesktopAIPromptInput(
|
||||
chatId: view.id,
|
||||
indicateFocus: true,
|
||||
onSubmitted: (message) {
|
||||
context.read<ChatBloc>().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<ChatBloc>().add(const ChatEvent.stopStream());
|
||||
},
|
||||
)
|
||||
: MobileAIPromptInput(
|
||||
chatId: view.id,
|
||||
onSubmitted: (message) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: message.text,
|
||||
metadata: message.metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
context.read<ChatBloc>().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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<Object>? 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();
|
||||
}
|
||||
}
|
||||
|
@ -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<AppearanceSettingsCubit>().state.font;
|
||||
final appearance = context.read<DocumentAppearanceCubit>().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<AppearanceSettingsCubit>().state.textScaleFactor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TextStyle headingStyleBuilder(int level) {
|
||||
final String? fontFamily;
|
||||
final List<double> fontSizes;
|
||||
const fontSize = 14.0;
|
||||
|
||||
fontFamily = context.read<DocumentAppearanceCubit>().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<DocumentAppearanceCubit>().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<DocumentPageStyleBloc>().state;
|
||||
final fontFamily = pageStyle.fontFamily ?? defaultFontFamily;
|
||||
final baseTextStyle = this.baseTextStyle(fontFamily);
|
||||
return baseTextStyle.copyWith(
|
||||
color: afThemeExtension.onBackground,
|
||||
);
|
||||
} else {
|
||||
final fontSize = context.read<DocumentAppearanceCubit>().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),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
/// [ChatInput] widget state.
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
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<ChatFileBloc>().state.uploadFiles.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
left: textPadding.left + sendButtonSize,
|
||||
right: textPadding.right,
|
||||
),
|
||||
child: BlocBuilder<ChatFileBloc, ChatFileState>(
|
||||
builder: (context, state) {
|
||||
return ChatInputFile(
|
||||
chatId: widget.chatId,
|
||||
files: state.uploadFiles,
|
||||
onDeleted: (file) => context.read<ChatFileBloc>().add(
|
||||
ChatFileEvent.deleteFile(file),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
//
|
||||
Row(
|
||||
children: [
|
||||
// TODO(lucas): support mobile
|
||||
if (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<ChatFileBloc>().consumeMetaData();
|
||||
|
||||
// combine metadata
|
||||
final Map<String, dynamic> metadata = {}
|
||||
..addAll(mentionPageMetadata)
|
||||
..addAll(fileMetadata);
|
||||
|
||||
final partialText = types.PartialText(
|
||||
text: trimmedText,
|
||||
metadata: metadata,
|
||||
);
|
||||
widget.onSendPressed(partialText);
|
||||
_textController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
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<void> _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<FilePickerService>().pickFiles(
|
||||
dialogTitle: '',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ["pdf"],
|
||||
);
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final file in path.files) {
|
||||
if (file.path != null) {
|
||||
if (mounted) {
|
||||
context
|
||||
.read<ChatFileBloc>()
|
||||
.add(ChatFileEvent.newFile(file.path!, file.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mentionButton(EdgeInsets buttonPadding) {
|
||||
return Padding(
|
||||
padding: buttonPadding,
|
||||
child: SizedBox.square(
|
||||
dimension: attachButtonSize,
|
||||
child: ChatInputAtButton(
|
||||
onTap: () {
|
||||
_textController.text += '@';
|
||||
if (!isMobile) {
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
_handleOnTextChange(context, _textController.text);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _referPage(ChatActionHandler handler) async {
|
||||
handler.onEnter();
|
||||
final selectedView = await showPageSelectorSheet(
|
||||
context,
|
||||
filter: (view) =>
|
||||
view.layout.isDocumentView &&
|
||||
!view.isSpace &&
|
||||
view.parentViewId.isNotEmpty,
|
||||
);
|
||||
if (selectedView == null) {
|
||||
handler.onExit();
|
||||
return;
|
||||
}
|
||||
handler.onSelected(ViewActionPage(view: selectedView));
|
||||
handler.onExit();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ChatInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_handleSendButtonVisibilityModeChange();
|
||||
}
|
||||
}
|
||||
|
||||
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
class ChatInputAnchor extends ChatAnchor {
|
||||
ChatInputAnchor({
|
||||
required this.anchorKey,
|
||||
required this.layerLink,
|
||||
});
|
||||
|
||||
@override
|
||||
final GlobalKey<State<StatefulWidget>> 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>{
|
||||
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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<ChatFile> files;
|
||||
final String chatId;
|
||||
|
||||
final Function(ChatFile) onDeleted;
|
||||
final String chatId;
|
||||
final void Function(ChatFile) onDeleted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Widget> children = files
|
||||
.map(
|
||||
(file) => ChatFilePreview(
|
||||
chatId: chatId,
|
||||
file: file,
|
||||
onDeleted: onDeleted,
|
||||
return BlocSelector<AIPromptInputBloc, AIPromptInputState, List<ChatFile>>(
|
||||
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<ChatFilePreview> createState() => _ChatFilePreviewState();
|
||||
}
|
||||
|
||||
class _ChatFilePreviewState extends State<ChatFilePreview> {
|
||||
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<ChatInputFileBloc, ChatInputFileState>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -65,9 +65,7 @@ class AtText extends SpecialText {
|
||||
style: textStyle,
|
||||
recognizer: (TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
if (onTap != null) {
|
||||
onTap!(atText);
|
||||
}
|
||||
onTap?.call(atText);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
|
||||
}
|
||||
|
||||
class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
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<AIPromptInputBloc>()
|
||||
.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<AIPromptInputBloc, AIPromptInputState>(
|
||||
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<AIPromptInputBloc>().consumeMetadata();
|
||||
|
||||
// combine metadata
|
||||
final Map<String, dynamic> 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<AIPromptInputBloc, AIPromptInputState>(
|
||||
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<void> _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<FilePickerService>().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<AIPromptInputBloc>()
|
||||
.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<State<StatefulWidget>> 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>{
|
||||
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;
|
||||
}
|
||||
}
|
@ -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;
|
@ -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<MobileAIPromptInput> createState() => _MobileAIPromptInputState();
|
||||
}
|
||||
|
||||
class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
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<AIPromptInputBloc>()
|
||||
.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<AIPromptInputBloc>().consumeMetadata();
|
||||
|
||||
// combine metadata
|
||||
final Map<String, dynamic> 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<AIPromptInputBloc, AIPromptInputState>(
|
||||
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<void> _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<void> _referPage(ChatActionHandler handler) async {
|
||||
handler.onEnter();
|
||||
final selectedView = await showPageSelectorSheet(
|
||||
context,
|
||||
filter: (view) =>
|
||||
view.layout.isDocumentView &&
|
||||
!view.isSpace &&
|
||||
view.parentViewId.isNotEmpty,
|
||||
);
|
||||
if (selectedView != null) {
|
||||
handler.onSelected(ViewActionPage(view: selectedView));
|
||||
}
|
||||
handler.onExit();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<ChatPopupMenu> createState() => _ChatPopupMenuState();
|
||||
}
|
||||
|
||||
class _ChatPopupMenuState extends State<ChatPopupMenu> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopoverActionList<ChatMessageActionWrapper>(
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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<RelatedQuestionPB> 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<RelatedQuestionItem> createState() => _RelatedQuestionItemState();
|
||||
}
|
||||
|
||||
class _RelatedQuestionItemState extends State<RelatedQuestionItem> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<ChatSidePannelBloc, ChatSidePannelState>(
|
||||
return BlocBuilder<ChatSidePanelBloc, ChatSidePanelState>(
|
||||
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<ChatSidePannelBloc>()
|
||||
.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<ChatSidePanelBloc>()
|
||||
.add(const ChatSidePanelEvent.close());
|
||||
},
|
||||
),
|
||||
const VSpace(6),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
const VSpace(6),
|
||||
Expanded(child: child),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
@ -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();
|
||||
}
|
||||
}
|
@ -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,
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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<WelcomeQuestion> items = [
|
||||
WelcomeQuestion(
|
||||
text: LocaleKeys.chat_question1.tr(),
|
||||
iconData: FlowySvgs.chat_lightbulb_s,
|
||||
),
|
||||
WelcomeQuestion(
|
||||
text: LocaleKeys.chat_question2.tr(),
|
||||
iconData: FlowySvgs.chat_scholar_s,
|
||||
),
|
||||
WelcomeQuestion(
|
||||
text: LocaleKeys.chat_question3.tr(),
|
||||
iconData: FlowySvgs.chat_question_s,
|
||||
),
|
||||
WelcomeQuestion(
|
||||
text: LocaleKeys.chat_question4.tr(),
|
||||
iconData: FlowySvgs.chat_feather_s,
|
||||
),
|
||||
static final List<String> desktopItems = [
|
||||
LocaleKeys.chat_question1.tr(),
|
||||
LocaleKeys.chat_question2.tr(),
|
||||
LocaleKeys.chat_question3.tr(),
|
||||
LocaleKeys.chat_question4.tr(),
|
||||
];
|
||||
|
||||
static final List<List<String>> 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<Widget> buildDesktopSampleQuestions(BuildContext context) {
|
||||
return desktopItems.map(
|
||||
(question) => Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: WelcomeSampleQuestion(
|
||||
question: question,
|
||||
onSelected: onSelectedQuestion,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> 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<Color?>((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<String> 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<ScrollNotification>(
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
@ -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),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
|
||||
}
|
||||
|
||||
class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
|
||||
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<Widget> 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<Widget> _buildOnHoverItems() {
|
||||
final List<Widget> 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<Widget> 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<ClipboardService>().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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<ChatMessageRefSource> sources;
|
||||
final Function(ChatMessageRefSource metadata) onSelectedMetadata;
|
||||
final void Function(ChatMessageRefSource metadata) onSelectedMetadata;
|
||||
|
||||
@override
|
||||
State<AIMessageMetadata> createState() => _AIMessageMetadataState();
|
||||
}
|
||||
|
||||
class _AIMessageMetadataState extends State<AIMessageMetadata> {
|
||||
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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<String>
|
||||
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<ChatAIMessageBloc, ChatAIMessageState>(
|
||||
builder: (context, state) {
|
||||
return state.messageState.when(
|
||||
onError: (err) {
|
||||
return StreamingError(
|
||||
onRetryPressed: () {
|
||||
context.read<ChatAIMessageBloc>().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<ChatAIMessageBloc>()
|
||||
.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<StreamingError> createState() => _StreamingErrorState();
|
||||
}
|
||||
|
||||
class _StreamingErrorState extends State<StreamingError> {
|
||||
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();
|
||||
}
|
||||
|
@ -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<ChatMemberBloc>().state.members[message.author.id] ==
|
||||
null) {
|
||||
context
|
||||
.read<ChatMemberBloc>()
|
||||
.add(ChatMemberEvent.getMemberInfo(message.author.id));
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BlocConsumer<ChatMemberBloc, ChatMemberState>(
|
||||
listenWhen: (previous, current) {
|
||||
return previous.members[message.author.id] !=
|
||||
current.members[message.author.id];
|
||||
},
|
||||
listener: (context, state) {},
|
||||
builder: (context, state) {
|
||||
final member = state.members[message.author.id];
|
||||
return ChatUserAvatar(
|
||||
iconUrl: member?.info.avatarUrl ?? "",
|
||||
name: member?.info.name ?? "",
|
||||
defaultName: "",
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(child: widget),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
OtherUserMessageHover _wrapHover(Padding child) {
|
||||
return OtherUserMessageHover(
|
||||
message: message,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
ChatPopupMenu _wrapPopMenu(Padding childWithPadding) {
|
||||
return ChatPopupMenu(
|
||||
onAction: (action) {
|
||||
if (action == ChatMessageAction.copy && message is TextMessage) {
|
||||
Clipboard.setData(ClipboardData(text: (message as TextMessage).text));
|
||||
showMessageToast(LocaleKeys.grid_row_copyProperty.tr());
|
||||
}
|
||||
},
|
||||
builder: (context) => childWithPadding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OtherUserMessageHover extends StatefulWidget {
|
||||
const OtherUserMessageHover({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final bool autoShowHover = true;
|
||||
|
||||
@override
|
||||
State<OtherUserMessageHover> createState() => _OtherUserMessageHoverState();
|
||||
}
|
||||
|
||||
class _OtherUserMessageHoverState extends State<OtherUserMessageHover> {
|
||||
bool _isHover = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isHover = widget.autoShowHover ? false : true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Widget> children = [
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: Corners.s6Border,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 30),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
if (_isHover) {
|
||||
children.addAll(_buildOnHoverItems());
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
opaque: false,
|
||||
onEnter: (p) => setState(() {
|
||||
if (widget.autoShowHover) {
|
||||
_isHover = true;
|
||||
}
|
||||
}),
|
||||
onExit: (p) => setState(() {
|
||||
if (widget.autoShowHover) {
|
||||
_isHover = false;
|
||||
}
|
||||
}),
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildOnHoverItems() {
|
||||
final List<Widget> children = [];
|
||||
if (widget.message is TextMessage) {
|
||||
children.add(
|
||||
CopyButton(
|
||||
textMessage: widget.message as TextMessage,
|
||||
).positioned(left: _leftPadding, bottom: 0),
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
class CopyButton extends StatelessWidget {
|
||||
const CopyButton({
|
||||
super.key,
|
||||
required this.textMessage,
|
||||
});
|
||||
final TextMessage textMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.settings_menu_clickToCopy.tr(),
|
||||
child: FlowyIconButton(
|
||||
width: 24,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
fillColor: Theme.of(context).cardColor,
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.ai_copy_s,
|
||||
size: const Size.square(14),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed: () async {
|
||||
final document = customMarkdownToDocument(textMessage.text);
|
||||
await getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(
|
||||
plainText: textMessage.text,
|
||||
inAppJson: jsonEncode(document.toJson()),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copiedNotification.tr(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<ChatMemberBloc>().state.members[message.author.id] ==
|
||||
null) {
|
||||
context
|
||||
@ -36,60 +38,80 @@ class ChatUserMessageBubble extends StatelessWidget {
|
||||
),
|
||||
child: BlocBuilder<ChatUserMessageBubbleBloc, ChatUserMessageBubbleState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (state.files.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: defaultAvatarSize + 32),
|
||||
child: _MessageFileList(files: state.files),
|
||||
),
|
||||
const VSpace(6),
|
||||
],
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
color: backgroundColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
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<ChatMemberBloc, ChatMemberState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.members[message.author.id] !=
|
||||
current.members[message.author.id],
|
||||
listener: (context, state) {},
|
||||
builder: (context, state) {
|
||||
final member = state.members[message.author.id];
|
||||
return ChatUserAvatar(
|
||||
iconUrl: member?.info.avatarUrl ?? "",
|
||||
name: member?.info.name ?? "",
|
||||
);
|
||||
},
|
||||
),
|
||||
padding: const EdgeInsets.only(right: 32),
|
||||
child: _MessageFileList(files: state.files),
|
||||
),
|
||||
const VSpace(6),
|
||||
],
|
||||
),
|
||||
],
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: getChildren(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> 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<ChatMemberBloc, ChatMemberState>(
|
||||
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(
|
||||
|
@ -22,24 +22,20 @@ class ChatUserMessageWidget extends StatelessWidget {
|
||||
..add(const ChatUserMessageEvent.initial()),
|
||||
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
|
||||
builder: (context, state) {
|
||||
final List<Widget> 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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -161,7 +161,16 @@ class _DocumentPageState extends State<DocumentPage>
|
||||
}
|
||||
|
||||
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!,
|
||||
|
@ -485,6 +485,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: FindAndReplaceMenuWidget(
|
||||
showReplaceMenu: showReplaceMenu,
|
||||
editorState: editorState,
|
||||
onDismiss: onDismiss,
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -160,8 +160,9 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
|
||||
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
|
||||
|
||||
late EditorDropManagerState dropManagerState =
|
||||
context.read<EditorDropManagerState>();
|
||||
late EditorDropManagerState? dropManagerState = UniversalPlatform.isMobile
|
||||
? null
|
||||
: context.read<EditorDropManagerState>();
|
||||
|
||||
final fileKey = GlobalKey();
|
||||
final showActionsNotifier = ValueNotifier<bool>(false);
|
||||
@ -176,7 +177,9 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
dropManagerState = context.read<EditorDropManagerState>();
|
||||
if (!UniversalPlatform.isMobile) {
|
||||
dropManagerState = context.read<EditorDropManagerState>();
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@ -240,17 +243,17 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
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<FileBlockComponent>
|
||||
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<FileBlockComponent>
|
||||
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<FileBlockComponent>
|
||||
}
|
||||
|
||||
// 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<FileBlockComponent>
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
@ -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),
|
||||
|
@ -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<FindAndReplaceMenuWidget> createState() =>
|
||||
_FindAndReplaceMenuWidgetState();
|
||||
}
|
||||
|
||||
class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
|
||||
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<FindAndReplaceMenuWidget> {
|
||||
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<FindMenu> createState() => _FindMenuState();
|
||||
}
|
||||
|
||||
class _FindMenuState extends State<FindMenu> {
|
||||
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<FindMenu> {
|
||||
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<FindMenu> {
|
||||
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<FindMenu> {
|
||||
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<FindMenu> {
|
||||
}
|
||||
|
||||
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<ReplaceMenu> createState() => _ReplaceMenuState();
|
||||
}
|
||||
|
||||
class _ReplaceMenuState extends State<ReplaceMenu> {
|
||||
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<ReplaceMenu> {
|
||||
// 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<ReplaceMenu> {
|
||||
),
|
||||
tooltipText: LocaleKeys.findAndReplace_replaceAll.tr(),
|
||||
onPressed: () => widget.searchService.replaceAllMatches(
|
||||
replaceTextEditingController.text,
|
||||
textController.text,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -307,7 +313,7 @@ class _ReplaceMenuState extends State<ReplaceMenu> {
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
@ -45,11 +45,10 @@ class _InnerCoverTitle extends StatefulWidget {
|
||||
|
||||
class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
final titleTextController = TextEditingController();
|
||||
final titleFocusNode = FocusNode();
|
||||
|
||||
late final editorContext = context.read<SharedEditorContext>();
|
||||
late final editorState = context.read<EditorState>();
|
||||
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',
|
||||
|
@ -103,8 +103,6 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
|
||||
late final ViewListener viewListener;
|
||||
int retryCount = 0;
|
||||
|
||||
final titleTextController = TextEditingController();
|
||||
final titleFocusNode = FocusNode();
|
||||
final isCoverTitleHovered = ValueNotifier<bool>(false);
|
||||
|
||||
late final gestureInterceptor = SelectionGestureInterceptor(
|
||||
@ -120,7 +118,6 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
|
||||
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<DocumentCoverWidget> {
|
||||
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<DocumentCoverWidget> {
|
||||
void dispose() {
|
||||
viewListener.stop();
|
||||
widget.node.removeListener(_reload);
|
||||
titleTextController.dispose();
|
||||
titleFocusNode.dispose();
|
||||
isCoverTitleHovered.dispose();
|
||||
widget.editorState.service.selectionService
|
||||
.unregisterGestureInterceptor(_interceptorKey);
|
||||
|
@ -25,8 +25,9 @@ Future<ViewPB?> 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -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<bool?> 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,
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -341,6 +341,7 @@ class _ToggleListBlockComponentWidgetState
|
||||
..updateNode(node, {
|
||||
ToggleListBlockKeys.collapsed: !collapsed,
|
||||
});
|
||||
transaction.afterSelection = editorState.selection;
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -328,6 +328,16 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
|
||||
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,
|
||||
|
@ -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<TabMenuEvent, TabMenuState> {
|
||||
TabMenuBloc({required this.viewId}) : super(const TabMenuState.isLoading()) {
|
||||
_fetchView();
|
||||
_dispatch();
|
||||
}
|
||||
|
||||
final String viewId;
|
||||
ViewPB? view;
|
||||
|
||||
void _dispatch() {
|
||||
on<TabMenuEvent>(
|
||||
(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<void> _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;
|
||||
}
|
@ -58,6 +58,18 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
|
||||
_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<TabsEvent, TabsState> {
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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<HomeStack> createState() => _HomeStackState();
|
||||
}
|
||||
|
||||
class _HomeStackState extends State<HomeStack> {
|
||||
int selectedIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<TabsBloc>.value(
|
||||
value: getIt<TabsBloc>(),
|
||||
child: BlocBuilder<TabsBloc, TabsState>(
|
||||
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<TabsBloc>().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<FadingIndexedStack> {
|
||||
return TweenAnimationBuilder<double>(
|
||||
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<PageNotifier, Widget>(
|
||||
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<HomeTopBar> createState() => _HomeTopBarState();
|
||||
}
|
||||
|
||||
class _HomeTopBarState extends State<HomeTopBar>
|
||||
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;
|
||||
}
|
||||
|
@ -22,9 +22,11 @@ class WorkspaceMoreActionList extends StatelessWidget {
|
||||
const WorkspaceMoreActionList({
|
||||
super.key,
|
||||
required this.workspace,
|
||||
required this.isShowingMoreActions,
|
||||
});
|
||||
|
||||
final UserWorkspacePB workspace;
|
||||
final ValueNotifier<bool> 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;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@ -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<UserWorkspacePB> workspaces;
|
||||
|
||||
@override
|
||||
State<WorkspacesMenu> createState() => _WorkspacesMenuState();
|
||||
}
|
||||
|
||||
class _WorkspacesMenuState extends State<WorkspacesMenu> {
|
||||
final ValueNotifier<bool> 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<bool> isShowingMoreActions;
|
||||
|
||||
@override
|
||||
State<WorkspaceMenuItem> createState() => _WorkspaceMenuItemState();
|
||||
@ -211,7 +227,10 @@ class _WorkspaceMenuItemState extends State<WorkspaceMenuItem> {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: WorkspaceMoreActionList(workspace: widget.workspace),
|
||||
child: WorkspaceMoreActionList(
|
||||
workspace: widget.workspace,
|
||||
isShowingMoreActions: widget.isShowingMoreActions,
|
||||
),
|
||||
),
|
||||
const HSpace(8.0),
|
||||
if (widget.isSelected) ...[
|
||||
|
@ -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<FlowyTab> createState() => _FlowyTabState();
|
||||
}
|
||||
|
||||
class _FlowyTabState extends State<FlowyTab> {
|
||||
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<TabsBloc>(),
|
||||
child: BlocProvider<TabMenuBloc>(
|
||||
create: (_) => TabMenuBloc(
|
||||
viewId: widget.pageManager.plugin.id,
|
||||
),
|
||||
child: TabMenu(pageId: widget.pageManager.plugin.id),
|
||||
),
|
||||
),
|
||||
child: ChangeNotifierProvider.value(
|
||||
value: widget.pageManager.notifier,
|
||||
child: Consumer<PageNotifier>(
|
||||
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<TabsBloc>()
|
||||
.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<TabMenuBloc, TabMenuState>(
|
||||
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<TabsBloc>()
|
||||
.add(TabsEvent.closeTab(widget.pageManager.plugin.id));
|
||||
void _closeTab(BuildContext context) =>
|
||||
context.read<TabsBloc>().add(TabsEvent.closeTab(pageId));
|
||||
|
||||
void _closeOtherTabs(BuildContext context) =>
|
||||
context.read<TabsBloc>().add(TabsEvent.closeOtherTabs(pageId));
|
||||
|
||||
void _toggleFavorite(BuildContext context) =>
|
||||
context.read<TabMenuBloc>().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;
|
||||
}
|
||||
}
|
||||
|
@ -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<TabsManager> createState() => _TabsManagerState();
|
||||
}
|
||||
|
||||
class _TabsManagerState extends State<TabsManager>
|
||||
with TickerProviderStateMixin {
|
||||
late TabController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TabController(vsync: this, length: 1);
|
||||
}
|
||||
|
||||
class _TabsManagerState extends State<TabsManager> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<TabsBloc>.value(
|
||||
value: BlocProvider.of<TabsBloc>(context),
|
||||
value: context.read<TabsBloc>(),
|
||||
child: BlocListener<TabsBloc, TabsState>(
|
||||
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<TabsBloc, TabsState>(
|
||||
builder: (context, state) {
|
||||
if (_controller.length == 1) {
|
||||
if (state.pages == 1) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@ -63,31 +39,29 @@ class _TabsManagerState extends State<TabsManager>
|
||||
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<TabsBloc>().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<Widget>((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<TabsManager>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
4
frontend/resources/flowy_icons/16x/ai_at.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.5 8C5.5 8.66304 5.76339 9.29893 6.23223 9.76777C6.70107 10.2366 7.33696 10.5 8 10.5C8.66304 10.5 9.29893 10.2366 9.76777 9.76777C10.2366 9.29893 10.5 8.66304 10.5 8C10.5 7.33696 10.2366 6.70107 9.76777 6.23223C9.29893 5.76339 8.66304 5.5 8 5.5C7.33696 5.5 6.70107 5.76339 6.23223 6.23223C5.76339 6.70107 5.5 7.33696 5.5 8Z" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.5 5.50001V8.62501C10.5 9.12229 10.6976 9.5992 11.0492 9.95083C11.4008 10.3025 11.8777 10.5 12.375 10.5C12.8723 10.5 13.3492 10.3025 13.7008 9.95083C14.0525 9.5992 14.25 9.12229 14.25 8.62501V8.00001C14.25 6.59207 13.7746 5.22538 12.9009 4.12136C12.0271 3.01734 10.8062 2.24068 9.43596 1.9172C8.06569 1.59372 6.62634 1.74238 5.35111 2.3391C4.07588 2.93582 3.03949 3.94563 2.40984 5.20492C1.78019 6.46422 1.59418 7.89922 1.88195 9.27743C2.16971 10.6556 2.91439 11.8963 3.99534 12.7985C5.07628 13.7006 6.43016 14.2113 7.83762 14.2479C9.24508 14.2845 10.6237 13.8448 11.75 13" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-0.5 -0.5 13.2 13.2" id="Paperclip-1--Streamline-Flex.svg" height="13.2" width="13.2"><desc>Paperclip 1 Streamline Icon: https://streamlinehq.com</desc><g id="paperclip-1--attachment-link-paperclip-unlink"><path id="Vector 201" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" d="M6.100165571428572 4.376418857142857 3.9434931428571427 6.5331c-0.5104741428571429 0.5104654285714285 -0.5104741428571429 1.3381047142857143 0 1.848578857142857 0.5104654285714285 0.5104654285714285 1.3381047142857143 0.5104654285714285 1.848578857142857 0l3.5431065714285714 -3.5431152857142862c0.935827142857143 -0.935862 0.935827142857143 -2.4531934285714287 0 -3.3890554285714285 -0.935862 -0.935866357142857 -2.453202142857143 -0.935866357142857 -3.389064142857143 0L2.0949055714285714 5.300708285714285c-1.3612516000000001 1.3612585714285714 -1.3612524714285714 3.568256 0 4.929514571428571 1.3612585714285714 1.3612585714285714 3.5682908571428573 1.3612585714285714 4.929549428571429 0l4.313353571428571 -4.313318714285714" stroke-width="1"></path></g></svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.20789 11.9705L10.6031 6.80618C11.2508 6.18618 11.2508 5.18087 10.6031 4.56087C9.95533 3.94087 8.90514 3.9408 8.25739 4.56087L2.90127 9.68781C1.67058 10.8658 1.67058 12.7759 2.90127 13.9539C4.13202 15.1321 6.12739 15.1321 7.35814 13.9539L12.7924 8.75218C14.6061 7.01605 14.6061 4.2013 12.7924 2.46518C10.9787 0.729055 8.03808 0.729055 6.22439 2.46518L1.8457 6.65649" stroke="black" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 521 B |
5
frontend/resources/flowy_icons/16x/ai_chat_outlined.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 6.06201H11.1667C11.5 6.06201 11.8333 6.32764 11.8333 6.72868C11.8333 7.06201 11.5755 7.39535 11.1667 7.39534H6.51302C6.09865 7.39535 5.83333 7.06201 5.83333 6.72868C5.83333 6.32764 6.16667 6.06201 6.5 6.06201Z" fill="black"/>
|
||||
<path d="M6.5 8.72868H9.15885C9.57031 8.72868 9.83333 9.06201 9.83333 9.39535C9.83333 9.72868 9.57031 10.062 9.16667 10.062H6.5C6.16667 10.062 5.83333 9.79653 5.83333 9.39535C5.83333 8.99416 6.16667 8.72868 6.5 8.72868Z" fill="black"/>
|
||||
<path d="M2.76088 13.8836L3.46384 12.8777C2.32857 11.711 1.60305 10.1638 1.51107 8.45342C1.50372 8.32386 1.5 8.19335 1.5 8.06201C1.5 4.19602 4.72439 1.06201 8.70189 1.06201C8.72413 1.06201 8.74635 1.06211 8.76854 1.06231C8.79012 1.06211 8.81171 1.06201 8.83333 1.06201C10.574 1.06201 12.1663 1.69737 13.391 2.74883C14.3416 3.54217 15.0769 4.5724 15.5 5.74532C15.5145 5.78564 15.8333 7.10668 15.8333 8.06201C15.8333 11.645 13.1413 14.5993 9.66957 15.0126L9.66791 15.0128C9.39425 15.0453 9.11574 15.062 8.83333 15.062L8.69835 15.0617C8.56267 15.0614 8.38169 15.0609 8.3441 15.0617C8.33953 15.0618 8.33708 15.062 8.33708 15.062H3.40299C2.77997 15.062 2.41082 14.3845 2.76088 13.8836ZM8.3408 13.7283C8.35765 13.7281 8.37817 13.728 8.39845 13.728C8.44025 13.7279 8.49549 13.728 8.55174 13.7281L8.83333 13.7287C11.9629 13.7287 14.5 11.1916 14.5 8.06201C14.5 7.70427 14.4372 7.22782 14.3597 6.79581C14.3225 6.58852 14.285 6.40744 14.2563 6.27777C14.2435 6.21991 14.2328 6.17374 14.2251 6.14142C13.8818 5.22135 13.2962 4.40641 12.5367 3.77253L12.5295 3.76654L12.5224 3.76045C11.5304 2.90871 10.2435 2.39535 8.83333 2.39535C8.81574 2.39535 8.79817 2.39542 8.78063 2.39558L8.76871 2.39569L8.75679 2.39559C8.73852 2.39543 8.72021 2.39535 8.70189 2.39535C5.42478 2.39535 2.83333 4.96788 2.83333 8.06201C2.83333 8.16816 2.83634 8.27349 2.84226 8.37793L2.84248 8.38181C2.91624 9.7534 3.49725 11.0002 4.41943 11.9479L5.18819 12.7379L4.49581 13.7287H8.3143C8.32535 13.7284 8.33506 13.7283 8.3408 13.7283ZM14.2152 6.10117L14.216 6.10405C14.2116 6.08819 14.2109 6.08427 14.2152 6.10117Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
10
frontend/resources/flowy_icons/16x/ai_check_filled.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3494_21449)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.35 8A7.35 7.35 0 1 1 0.65 8a7.35 7.35 0 0 1 14.7 0M11.002 5.742a0.56 0.56 0 0 1 0 0.79L7.278 10.256a0.56 0.56 0 0 1 -0.79 0L5 8.768a0.558 0.558 0 1 1 0.79 -0.79l1.094 1.094 1.664 -1.665 1.665 -1.665a0.56 0.56 0 0 1 0.79 0" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3494_21449">
|
||||
<path width="16" height="16" fill="white" d="M0 0H16V16H0V0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 588 B |
3
frontend/resources/flowy_icons/16x/ai_close.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.10968 3.35641C3.9014 3.14813 3.56371 3.14813 3.35543 3.35641C3.14715 3.56468 3.14715 3.90237 3.35543 4.11065L7.24497 8.0002L3.35543 11.8897C3.14715 12.098 3.14715 12.4357 3.35543 12.644C3.56371 12.8523 3.9014 12.8523 4.10968 12.644L7.99922 8.75444L11.8888 12.644C12.097 12.8523 12.4347 12.8523 12.643 12.644C12.8513 12.4357 12.8513 12.098 12.643 11.8897L8.75347 8.0002L12.643 4.11065C12.8513 3.90237 12.8513 3.56468 12.643 3.35641C12.4347 3.14813 12.097 3.14813 11.8888 3.35641L7.99922 7.24595L4.10968 3.35641Z" fill="black" stroke="black" stroke-width="0.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 724 B |
10
frontend/resources/flowy_icons/16x/ai_close_filled.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3544_30046)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.35 8A7.35 7.35 0 1 1 0.65 8a7.35 7.35 0 0 1 14.7 0M5.773 5.773a0.55 0.55 0 0 1 0.78 0L8 7.221l1.448 -1.448a0.551 0.551 0 0 1 0.78 0.78L8.78 8l1.448 1.448a0.55 0.55 0 1 1 -0.78 0.78L8 8.78l-1.447 1.448a0.551 0.551 0 1 1 -0.78 -0.78L7.221 8 5.773 6.553a0.55 0.55 0 0 1 0 -0.78" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3544_30046">
|
||||
<path width="16" height="16" fill="white" d="M0 0H16V16H0V0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 641 B |
3
frontend/resources/flowy_icons/16x/ai_dislike.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.84673 9.2077L2.35761 9.25189C2.33398 9.52552 2.09898 9.73183 1.82467 9.72002C1.5503 9.7082 1.33398 9.48227 1.33398 9.2077H1.84673ZM13.6314 7.96114L13.149 5.1717L14.1597 4.99695L14.642 7.78639L13.6314 7.96114ZM8.85148 1.67595H5.67311V0.650391H8.85148V1.67595ZM5.04998 2.24864L4.49467 8.67058L3.47292 8.5822L4.02817 2.16027L5.04998 2.24864ZM13.149 5.1717C12.8025 3.16802 10.9958 1.67595 8.85148 1.67595V0.650391C11.4675 0.650391 13.7235 2.4752 14.1597 4.99695L13.149 5.1717ZM8.85817 12.7179L8.40505 9.95258L9.41717 9.78683L9.8703 12.5521L8.85817 12.7179ZM4.70998 9.19989L5.69367 10.0476L5.02417 10.8245L4.04048 9.97683L4.70998 9.19989ZM7.48336 12.8055L7.80861 14.0594L6.81586 14.3169L6.49061 13.0631L7.48336 12.8055ZM8.29986 14.3054L8.39898 14.2735L8.71267 15.25L8.61355 15.2818L8.29986 14.3054ZM6.99073 11.5448C7.20398 11.9441 7.36961 12.367 7.48336 12.8055L6.49061 13.0631C6.39742 12.7036 6.26155 12.3565 6.08611 12.028L6.99073 11.5448ZM8.39898 14.2735C8.60848 14.2063 8.75724 14.0444 8.80611 13.8559L9.79892 14.1134C9.65836 14.6549 9.24392 15.0793 8.71267 15.25L8.39898 14.2735ZM7.80861 14.0594C7.83261 14.1516 7.89923 14.2365 8.00011 14.2851L7.55486 15.209C7.19111 15.0338 6.91786 14.7103 6.81586 14.3169L7.80861 14.0594ZM8.00011 14.2851C8.09217 14.3295 8.20061 14.3372 8.29986 14.3054L8.61355 15.2818C8.26505 15.3938 7.8848 15.368 7.55486 15.209L8.00011 14.2851ZM9.47273 8.69489H13.0152V9.72045H9.47273V8.69489ZM3.02192 1.5692L2.35761 9.25189L1.33586 9.16352L2.00017 1.48089L3.02192 1.5692ZM2.35955 1.49633V9.2077H1.33398V1.49633H2.35955ZM2.00017 1.48089C1.99117 1.5852 2.07336 1.67595 2.17955 1.67595V0.650391C2.67586 0.650391 3.06461 1.0757 3.02192 1.5692L2.00017 1.48089ZM9.8703 12.5521C9.95542 13.0718 9.93111 13.6036 9.79892 14.1134L8.80611 13.8559C8.90255 13.4844 8.92023 13.0968 8.85817 12.7179L9.8703 12.5521ZM5.67311 1.67595C5.34905 1.67595 5.07805 1.9242 5.04998 2.24864L4.02817 2.16027C4.10198 1.30658 4.81592 0.650391 5.67311 0.650391V1.67595ZM5.69367 10.0476C6.15848 10.4481 6.65961 10.9248 6.99073 11.5448L6.08611 12.028C5.8493 11.5846 5.47217 11.2105 5.02417 10.8245L5.69367 10.0476ZM14.642 7.78639C14.8166 8.79589 14.0403 9.72045 13.0152 9.72045V8.69489C13.4025 8.69489 13.6978 8.34502 13.6314 7.96114L14.642 7.78639ZM2.17955 1.67595C2.27948 1.67595 2.35955 1.59502 2.35955 1.49633H1.33398C1.33398 1.0297 1.71198 0.650391 2.17955 0.650391V1.67595ZM8.40505 9.95258C8.29723 9.29427 8.80467 8.69489 9.47273 8.69489V9.72045C9.43867 9.72045 9.41136 9.75139 9.41717 9.78683L8.40505 9.95258ZM4.49467 8.67058C4.4773 8.87158 4.55755 9.06852 4.70998 9.19989L4.04048 9.97683C3.63823 9.63014 3.42717 9.1112 3.47292 8.5822L4.49467 8.67058Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
11
frontend/resources/flowy_icons/16x/ai_expand.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3622_33848)">
|
||||
<path d="M5.94811 10.0508L1.16211 14.8368M1.16211 14.8368H5.16673M1.16211 14.8368V10.8322" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.0508 5.94909L14.8368 1.16309M14.8368 1.16309H10.8322M14.8368 1.16309V5.16771" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3622_33848">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 561 B |
12
frontend/resources/flowy_icons/16x/ai_image.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3551_49504)">
|
||||
<path d="M2.01758 8.00012C2.01758 5.17988 2.01758 3.76982 2.89367 2.89367C3.76982 2.01758 5.17988 2.01758 8.00012 2.01758C10.8203 2.01758 12.2304 2.01758 13.1065 2.89367C13.9827 3.76982 13.9827 5.17988 13.9827 8.00012C13.9827 10.8203 13.9827 12.2304 13.1065 13.1065C12.2305 13.9827 10.8203 13.9827 8.00012 13.9827C5.17988 13.9827 3.76982 13.9827 2.89367 13.1065C2.01758 12.2305 2.01758 10.8203 2.01758 8.00012Z" stroke="black"/>
|
||||
<path d="M9.19727 5.60711C9.1953 6.52816 10.1912 7.10599 10.9898 6.64716C11.3621 6.43328 11.5912 6.03641 11.5903 5.60711C11.5923 4.68601 10.5964 4.10824 9.79774 4.56707C9.42548 4.78089 9.19634 5.17782 9.19727 5.60711Z" fill="black" stroke="black" stroke-width="0.2"/>
|
||||
<path d="M2.01758 8.29931L3.06545 7.38242C3.61063 6.90544 4.43231 6.93278 4.94451 7.44498L7.51088 10.0114C7.92197 10.4225 8.5692 10.4786 9.04492 10.1442L9.22331 10.0189C9.90784 9.53777 10.834 9.59349 11.4559 10.1532L13.3844 11.8888" stroke="black" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3551_49504">
|
||||
<rect width="14" height="14" fill="white" transform="translate(1 1)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
3
frontend/resources/flowy_icons/16x/ai_like.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.8468 6.79307L2.35767 6.74888C2.33405 6.47532 2.09905 6.26888 1.82467 6.28076C1.55036 6.29257 1.33398 6.51844 1.33398 6.79307H1.8468ZM13.6315 8.03963L13.1491 10.829L14.1597 11.0038L14.6421 8.21438L13.6315 8.03963ZM8.85155 14.3248H5.67317V15.3504H8.85155V14.3248ZM5.05005 13.7522L4.49473 7.33019L3.47298 7.41851L4.02823 13.8405L5.05005 13.7522ZM13.1491 10.829C12.8026 12.8327 10.9959 14.3248 8.85155 14.3248V15.3504C11.4676 15.3504 13.7236 13.5255 14.1597 11.0038L13.1491 10.829ZM8.85823 3.28282L8.40511 6.04819L9.41723 6.21401L9.87036 3.44863L8.85823 3.28282ZM4.71005 6.80088L5.69373 5.95319L5.02423 5.17632L4.04055 6.02394L4.71005 6.80088ZM7.48342 3.19526L7.80867 1.94138L6.81598 1.68388L6.49067 2.93769L7.48342 3.19526ZM8.29992 1.69538L8.39905 1.72726L8.71273 0.750819L8.61361 0.718944L8.29992 1.69538ZM6.9908 4.45594C7.20405 4.05669 7.36973 3.63369 7.48342 3.19526L6.49067 2.93769C6.39748 3.29713 6.26161 3.64432 6.08617 3.97276L6.9908 4.45594ZM8.39905 1.72726C8.60855 1.79451 8.75724 1.95632 8.80617 2.14488L9.79898 1.88738C9.65848 1.34588 9.24398 0.921444 8.71273 0.750819L8.39905 1.72726ZM7.80867 1.94138C7.83267 1.84907 7.8993 1.76426 8.00017 1.71563L7.55492 0.791757C7.19117 0.967007 6.91798 1.29051 6.81598 1.68388L7.80867 1.94138ZM8.00017 1.71563C8.0923 1.67126 8.20073 1.66351 8.29992 1.69538L8.61361 0.718944C8.26511 0.607007 7.88492 0.632757 7.55492 0.791757L8.00017 1.71563ZM9.47286 7.30588H13.0152V6.28026H9.47286V7.30588ZM3.02198 14.4315L2.35767 6.74888L1.33592 6.83726L2.00023 14.5199L3.02198 14.4315ZM2.35961 14.5044V6.79307H1.33398L1.33405 14.5044H2.35961ZM2.00023 14.5199C1.99123 14.4156 2.07342 14.3248 2.17961 14.3248V15.3504C2.67592 15.3504 3.06467 14.925 3.02198 14.4315L2.00023 14.5199ZM9.87036 3.44863C9.95548 2.92894 9.93117 2.39713 9.79898 1.88738L8.80617 2.14488C8.90261 2.51644 8.9203 2.90401 8.85823 3.28282L9.87036 3.44863ZM5.67317 14.3248C5.34911 14.3248 5.07811 14.0766 5.05005 13.7522L4.02823 13.8405C4.10205 14.6941 4.81598 15.3504 5.67317 15.3504V14.3248ZM5.69373 5.95319C6.15855 5.55263 6.65967 5.07588 6.9908 4.45594L6.08617 3.97276C5.84936 4.41607 5.47223 4.79026 5.02423 5.17632L5.69373 5.95319ZM14.6421 8.21438C14.8167 7.20488 14.0404 6.28026 13.0152 6.28026V7.30588C13.4027 7.30588 13.6979 7.65569 13.6315 8.03963L14.6421 8.21438ZM2.17961 14.3248C2.27955 14.3248 2.35961 14.4058 2.35961 14.5044H1.33405C1.33405 14.9711 1.71205 15.3504 2.17961 15.3504V14.3248ZM8.40511 6.04819C8.2973 6.70657 8.8048 7.30588 9.47286 7.30588V6.28026C9.43873 6.28026 9.41142 6.24938 9.41723 6.21401L8.40511 6.04819ZM4.49473 7.33019C4.47736 7.12919 4.55761 6.93226 4.71005 6.80088L4.04055 6.02394C3.6383 6.37057 3.42723 6.88951 3.47298 7.41851L4.49473 7.33019Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
8
frontend/resources/flowy_icons/16x/ai_list.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.5 4.25H13.625" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.5 8H13.625" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.5 11.75H13.625" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.375 4.25H2.38125" stroke="black" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.375 8H2.38125" stroke="black" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.375 11.75H2.38125" stroke="black" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 711 B |
8
frontend/resources/flowy_icons/16x/ai_number_list.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.75 4.25H13.625" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.75 8H13.625" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.75 11.75H13.625" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 4.25H3.625V6.75" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 6.75H4.25" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.25 11.7495H3C3 11.1245 4.25 10.4995 4.25 9.87455C4.25 9.24955 3.625 8.93705 3 9.24955" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 720 B |
4
frontend/resources/flowy_icons/16x/ai_page.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.6673 6.74503V10.0183C13.6673 13.2916 12.534 14.6009 9.70065 14.6009H6.30065C3.46732 14.6009 2.33398 13.2916 2.33398 10.0183V6.09038C2.33398 2.81712 3.46732 1.50781 6.30065 1.50781H9.13398" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.6173 6.66608H11.3507C9.65065 6.66608 9.08398 6.00787 9.08398 4.03323V1.68666C9.08398 1.58813 9.20647 1.54266 9.27075 1.61733L13.6173 6.66608Z" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 589 B |
5
frontend/resources/flowy_icons/16x/ai_paragraph.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.75586 2.3252V14.4311" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.7832 2.3252V14.4311" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.2969 2.3252H6.10908C3.48807 2.3252 1.84996 5.16255 3.16044 7.43241C3.76862 8.4858 4.89272 9.13472 6.10908 9.13477H8.75721" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 499 B |