Merge branch 'main' into feat/workspace-roles

This commit is contained in:
Zack Fu Zi Xiang 2024-11-15 14:44:30 +08:00
commit 1e19ec2ebe
No known key found for this signature in database
137 changed files with 4308 additions and 3051 deletions

View File

@ -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"

View File

@ -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"

View File

@ -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),
);

View File

@ -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,
);
},
);
});
});
}

View File

@ -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,
);
},
);
}

View File

@ -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();
}

View File

@ -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));
});
});
}

View File

@ -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.
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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]))),
);
});
});
}

View File

@ -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();
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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);

View File

@ -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,

View File

@ -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),
),
),
),
),

View File

@ -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;
}

View File

@ -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;

View File

@ -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,

View File

@ -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

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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) {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,
);
}
}

View File

@ -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();
}
}

View File

@ -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),
);
}
}

View File

@ -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,
);
}
}

View File

@ -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,
),
);
}
}

View File

@ -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;
}
}

View File

@ -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,
),
);
}
}

View File

@ -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,
);
}
}

View File

@ -65,9 +65,7 @@ class AtText extends SpecialText {
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
if (onTap != null) {
onTap!(atText);
}
onTap?.call(atText);
}),
);
}

View File

@ -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,
);
}
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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,
),
);
}

View File

@ -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),
),
),
);
}
}

View File

@ -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();
}
}
}

View File

@ -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),
);
}
}

View File

@ -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),
],
);
},
);

View File

@ -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();
}
}

View File

@ -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,
// ),
// );
// }
// }

View File

@ -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,
);
}

View File

@ -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,
),
),
],
),
),
);
}

View File

@ -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);
},
);
}
}

View File

@ -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));
}

View File

@ -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),
),
]
],
),
);
}

View File

@ -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,
),
);
}
}

View File

@ -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(),
),
],
],
),
);
}
}

View File

@ -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();
}

View File

@ -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(),
);
}
},
),
);
}
}

View File

@ -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(

View File

@ -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,

View File

@ -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;
}

View File

@ -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!,

View File

@ -485,6 +485,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
borderRadius: BorderRadius.circular(4),
),
child: FindAndReplaceMenuWidget(
showReplaceMenu: showReplaceMenu,
editorState: editorState,
onDismiss: onDismiss,
),

View File

@ -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;
}

View File

@ -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) {

View File

@ -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),

View File

@ -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,
);
}

View File

@ -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',

View File

@ -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);

View File

@ -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),
);
},
),
);
},

View File

@ -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,

View File

@ -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();
}
}

View File

@ -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,
),

View File

@ -341,6 +341,7 @@ class _ToggleListBlockComponentWidgetState
..updateNode(node, {
ToggleListBlockKeys.collapsed: !collapsed,
});
transaction.afterSelection = editorState.selection;
await editorState.apply(transaction);
}
}

View File

@ -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)

View File

@ -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,

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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({

View File

@ -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;
}

View File

@ -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;
},
),
);

View File

@ -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) ...[

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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:

View File

@ -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:

View 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

View File

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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