Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4354227a05 |
No files matched your search
@@ -1,17 +1,4 @@
|
||||
# Release Notes
|
||||
## Version 0.9.2 - 14/05/2025
|
||||
### Desktop
|
||||
#### New Features
|
||||
- Supported AI Overview in Search to answer user queries based on their entire workspace
|
||||
- Revamped the Search panel in the desktop app
|
||||
- Enabled loading custom prompts from an AppFlowy database page
|
||||
#### Bug Fixes
|
||||
- Improved inserting emojis using the colon (:)
|
||||
- Supported automatically filling the link name with the URL if the name is left empty
|
||||
### Mobile
|
||||
#### Bug Fixes
|
||||
- Supported automatically filling the link name with the URL if the name is left empty
|
||||
|
||||
## Version 0.9.1 - 01/05/2025
|
||||
### Desktop
|
||||
#### New Features
|
||||
|
||||
@@ -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.9.2"
|
||||
APPFLOWY_VERSION = "0.9.1"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/ai_test_op.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('chat page:', () {
|
||||
testWidgets('send messages with default messages', (tester) async {
|
||||
skipAIChatWelcomePage = true;
|
||||
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a chat page
|
||||
final pageName = 'Untitled';
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: pageName,
|
||||
layout: ViewLayoutPB.Chat,
|
||||
openAfterCreated: false,
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 300));
|
||||
|
||||
final userId = '457037009907617792';
|
||||
final user = User(id: userId, lastName: 'Lucas');
|
||||
final aiUserId = '0';
|
||||
final aiUser = User(id: aiUserId, lastName: 'AI');
|
||||
|
||||
await tester.loadDefaultMessages(
|
||||
[
|
||||
Message.text(
|
||||
id: '1746776401',
|
||||
text: 'How to use Kanban to manage tasks?',
|
||||
author: user,
|
||||
createdAt: DateTime.now().add(const Duration(seconds: 1)),
|
||||
),
|
||||
Message.text(
|
||||
id: '1746776401_ans',
|
||||
text:
|
||||
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now().add(const Duration(seconds: 2)),
|
||||
),
|
||||
Message.text(
|
||||
id: '1746776402',
|
||||
text: 'How to use Kanban to manage tasks?',
|
||||
author: user,
|
||||
createdAt: DateTime.now().add(const Duration(seconds: 3)),
|
||||
),
|
||||
Message.text(
|
||||
id: '1746776402_ans',
|
||||
text:
|
||||
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now().add(const Duration(seconds: 4)),
|
||||
),
|
||||
].reversed.toList(),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// start chat
|
||||
final int messageId = 1;
|
||||
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'How to use AppFlowy?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text: '''# How to Use AppFlowy
|
||||
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
|
||||
- Create an account or sign in when you first launch the app
|
||||
- The main interface shows your workspace with a sidebar for navigation and a content area''',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
final chatBloc = tester.getCurrentChatBloc();
|
||||
expect(chatBloc.chatController.messages.length, equals(6));
|
||||
});
|
||||
|
||||
testWidgets('send messages without default messages', (tester) async {
|
||||
skipAIChatWelcomePage = true;
|
||||
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a chat page
|
||||
final pageName = 'Untitled';
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: pageName,
|
||||
layout: ViewLayoutPB.Chat,
|
||||
openAfterCreated: false,
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 300));
|
||||
|
||||
final userId = '457037009907617792';
|
||||
final user = User(id: userId, lastName: 'Lucas');
|
||||
final aiUserId = '0';
|
||||
final aiUser = User(id: aiUserId, lastName: 'AI');
|
||||
|
||||
// start chat
|
||||
int messageId = 1;
|
||||
|
||||
// round 1
|
||||
{
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'How to use AppFlowy?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text: '''# How to Use AppFlowy
|
||||
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
|
||||
- Create an account or sign in when you first launch the app
|
||||
- The main interface shows your workspace with a sidebar for navigation and a content area''',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// round 2
|
||||
{
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'How to use AppFlowy?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text:
|
||||
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// round 3
|
||||
{
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'What document formatting options are available?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text:
|
||||
'# AppFlowy Document Formatting\n- Basic formatting: Bold, italic, underline, strikethrough\n- Headings: 6 levels of headings for structuring content\n- Lists: Bullet points, numbered lists, and checklists\n- Code blocks: Format text as code with syntax highlighting\n- Tables: Create and format data tables\n- Embedded content: Add images, files, and other rich media',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// round 4
|
||||
{
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'How do I export my data from AppFlowy?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text:
|
||||
'# Exporting from AppFlowy\n- Export documents in multiple formats: Markdown, HTML, PDF\n- Export databases as CSV or Excel files\n- Batch export entire workspaces for backup\n- Use the export menu (three dots → Export) on any page\n- Exported files maintain most formatting and structure',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// round 5
|
||||
{
|
||||
// send a message
|
||||
await tester.sendUserMessage(
|
||||
Message.text(
|
||||
id: messageId.toString(),
|
||||
text: 'Is there a mobile version of AppFlowy?',
|
||||
author: user,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
// receive a message
|
||||
await tester.receiveAIMessage(
|
||||
Message.text(
|
||||
id: '${messageId}_ans',
|
||||
text:
|
||||
'# AppFlowy on Mobile\n- Yes, AppFlowy is available for iOS and Android devices\n- Download from the App Store or Google Play Store\n- Mobile app includes core functionality: document editing, databases, and boards\n- Offline mode allows working without internet connection\n- Sync automatically when you reconnect\n- Responsive design adapts to different screen sizes',
|
||||
author: aiUser,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
messageId++;
|
||||
}
|
||||
|
||||
final chatBloc = tester.getCurrentChatBloc();
|
||||
expect(chatBloc.chatController.messages.length, equals(10));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import 'database/database_test_runner.dart' as database_test_runner;
|
||||
import 'document/document_test_runner.dart' as document_test_runner;
|
||||
import 'set_env.dart' as preset_af_cloud_env_test;
|
||||
import 'sidebar/sidebar_icon_test.dart' as sidebar_icon_test;
|
||||
import 'sidebar/sidebar_search_test.dart' as sidebar_search_test;
|
||||
import 'sidebar/sidebar_move_page_test.dart' as sidebar_move_page_test;
|
||||
import 'sidebar/sidebar_rename_untitled_test.dart'
|
||||
as sidebar_rename_untitled_test;
|
||||
@@ -32,7 +31,6 @@ Future<void> main() async {
|
||||
sidebar_move_page_test.main();
|
||||
sidebar_rename_untitled_test.main();
|
||||
sidebar_icon_test.main();
|
||||
sidebar_search_test.main();
|
||||
}
|
||||
|
||||
// database
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
import 'package:appflowy/ai/widgets/prompt_input/desktop_prompt_input.dart';
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.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() {
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
testWidgets('Test for searching', (tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
/// show searching page
|
||||
final searchingButton = find.text(LocaleKeys.search_label.tr());
|
||||
await tester.tapButton(searchingButton);
|
||||
final askAIButton = find.byType(SearchAskAiEntrance);
|
||||
expect(askAIButton, findsOneWidget);
|
||||
|
||||
/// searching for [gettingStarted]
|
||||
final searchField = find.byType(SearchField);
|
||||
final textFiled =
|
||||
find.descendant(of: searchField, matching: find.byType(TextField));
|
||||
await tester.enterText(textFiled, gettingStarted);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
/// tap ask AI button
|
||||
await tester.tapButton(askAIButton);
|
||||
expect(find.byType(DesktopPromptInput), findsOneWidget);
|
||||
});
|
||||
}
|
||||
+1
-77
@@ -1,17 +1,7 @@
|
||||
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/page_preview.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
@@ -29,70 +19,4 @@ void main() {
|
||||
expect(find.byType(CommandPaletteModal), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('Search', () {
|
||||
testWidgets('Test for searching', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(name: 'Switch To New Page');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
/// tap getting started page
|
||||
await tester.tapButton(
|
||||
find.descendant(
|
||||
of: find.byType(HomeSideBar),
|
||||
matching: find.textContaining(gettingStarted),
|
||||
),
|
||||
);
|
||||
|
||||
/// show searching page
|
||||
final searchingButton = find.text(LocaleKeys.search_label.tr());
|
||||
await tester.tapButton(searchingButton);
|
||||
final askAIButton = find.byType(SearchAskAiEntrance);
|
||||
expect(askAIButton, findsNothing);
|
||||
final recentList = find.byType(RecentViewsList);
|
||||
expect(recentList, findsOneWidget);
|
||||
|
||||
/// there is [gettingStarted] in recent list
|
||||
final gettingStartedRecentCell = find.descendant(
|
||||
of: recentList,
|
||||
matching: find.textContaining(gettingStarted),
|
||||
);
|
||||
expect(gettingStartedRecentCell, findsAtLeast(1));
|
||||
|
||||
/// hover to show preview
|
||||
await tester.hoverOnWidget(gettingStartedRecentCell.first);
|
||||
final pagePreview = find.byType(PagePreview);
|
||||
expect(pagePreview, findsOneWidget);
|
||||
final gettingStartedPreviewTitle = find.descendant(
|
||||
of: pagePreview,
|
||||
matching: find.textContaining(gettingStarted),
|
||||
);
|
||||
expect(gettingStartedPreviewTitle, findsOneWidget);
|
||||
|
||||
/// searching for [gettingStarted]
|
||||
final searchField = find.byType(SearchField);
|
||||
final textFiled =
|
||||
find.descendant(of: searchField, matching: find.byType(TextField));
|
||||
await tester.enterText(textFiled, gettingStarted);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
|
||||
/// there is [gettingStarted] in result list
|
||||
final resultList = find.byType(SearchResultList);
|
||||
expect(resultList, findsOneWidget);
|
||||
final resultCells = find.byType(SearchResultCell);
|
||||
expect(resultCells, findsAtLeast(1));
|
||||
|
||||
/// hover to show preview
|
||||
await tester.hoverOnWidget(resultCells.first);
|
||||
expect(find.byType(PagePreview), findsOneWidget);
|
||||
|
||||
/// clear search content
|
||||
final clearButton = find.byFlowySvg(FlowySvgs.search_clear_m);
|
||||
await tester.tapButton(clearButton);
|
||||
expect(find.byType(SearchResultList), findsNothing);
|
||||
expect(find.byType(RecentViewsList), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
-83
@@ -366,88 +366,5 @@ void main() {
|
||||
expect(getLinkFromNode(node), link);
|
||||
expect(getNodeText(node), afterText);
|
||||
});
|
||||
|
||||
testWidgets('insert link and clear link name', (tester) async {
|
||||
const text = 'edit link', link = 'https://test.appflowy.cloud';
|
||||
await prepareForToolbar(tester, text);
|
||||
|
||||
/// tap link button to show CreateLinkMenu
|
||||
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
|
||||
await tester.tapButton(linkButton);
|
||||
|
||||
/// search for page and select it
|
||||
final textField = find.descendant(
|
||||
of: find.byType(LinkCreateMenu),
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(textField, link);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
Node node = tester.editor.getNodeAtPath([0]);
|
||||
expect(getLinkFromNode(node), link);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
|
||||
|
||||
/// hover link
|
||||
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
|
||||
|
||||
/// click edit button to show LinkEditMenu
|
||||
final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m);
|
||||
await tester.tapButton(editButton);
|
||||
final linkEditMenu = find.byType(LinkEditMenu);
|
||||
expect(linkEditMenu, findsOneWidget);
|
||||
|
||||
/// clear the link name
|
||||
final titleField = find.descendant(
|
||||
of: linkEditMenu,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(titleField, '');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
expect(getNodeText(node), link);
|
||||
});
|
||||
|
||||
testWidgets('insert link and clear link name and remove link', (tester) async {
|
||||
const text = 'edit link', link = 'https://test.appflowy.cloud';
|
||||
await prepareForToolbar(tester, text);
|
||||
|
||||
/// tap link button to show CreateLinkMenu
|
||||
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
|
||||
await tester.tapButton(linkButton);
|
||||
|
||||
/// search for page and select it
|
||||
final textField = find.descendant(
|
||||
of: find.byType(LinkCreateMenu),
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(textField, link);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
Node node = tester.editor.getNodeAtPath([0]);
|
||||
expect(getLinkFromNode(node), link);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
|
||||
|
||||
/// hover link
|
||||
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
|
||||
|
||||
/// click edit button to show LinkEditMenu
|
||||
final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m);
|
||||
await tester.tapButton(editButton);
|
||||
final linkEditMenu = find.byType(LinkEditMenu);
|
||||
expect(linkEditMenu, findsOneWidget);
|
||||
|
||||
/// clear the link name
|
||||
final titleField = find.descendant(
|
||||
of: linkEditMenu,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(titleField, '');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
expect(getNodeText(node), link);
|
||||
expect(getLinkFromNode(node), null);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'desktop/chat/chat_page_test.dart' as chat_page_test;
|
||||
import 'desktop/database/database_icon_test.dart' as database_icon_test;
|
||||
import 'desktop/first_test/first_test.dart' as first_test;
|
||||
import 'desktop/uncategorized/code_block_language_selector_test.dart'
|
||||
@@ -18,5 +17,4 @@ Future<void> runIntegration9OnDesktop() async {
|
||||
tabs_test.main();
|
||||
code_language_selector.main();
|
||||
database_icon_test.main();
|
||||
chat_page_test.main();
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../test/util.dart';
|
||||
import 'util.dart';
|
||||
|
||||
extension AppFlowyAITest on WidgetTester {
|
||||
@@ -43,27 +38,4 @@ extension AppFlowyAITest on WidgetTester {
|
||||
testTextInput.enterText(text);
|
||||
await pumpAndSettle(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
ChatBloc getCurrentChatBloc() {
|
||||
return element(find.byType(ChatContentPage)).read<ChatBloc>();
|
||||
}
|
||||
|
||||
Future<void> loadDefaultMessages(List<Message> messages) async {
|
||||
final chatBloc = getCurrentChatBloc();
|
||||
chatBloc.add(ChatEvent.didLoadLatestMessages(messages));
|
||||
await blocResponseFuture();
|
||||
}
|
||||
|
||||
Future<void> sendUserMessage(Message message) async {
|
||||
final chatBloc = getCurrentChatBloc();
|
||||
// using received message to simulate the user message
|
||||
chatBloc.add(ChatEvent.receiveMessage(message));
|
||||
await blocResponseFuture();
|
||||
}
|
||||
|
||||
Future<void> receiveAIMessage(Message message) async {
|
||||
final chatBloc = getCurrentChatBloc();
|
||||
chatBloc.add(ChatEvent.receiveMessage(message));
|
||||
await blocResponseFuture();
|
||||
}
|
||||
}
|
||||
@@ -1051,8 +1051,6 @@ extension ViewLayoutPBTest on ViewLayoutPB {
|
||||
return LocaleKeys.document_menuName.tr();
|
||||
case ViewLayoutPB.Calendar:
|
||||
return LocaleKeys.calendar_menuName.tr();
|
||||
case ViewLayoutPB.Chat:
|
||||
return LocaleKeys.chat_newChat.tr();
|
||||
default:
|
||||
throw UnsupportedError('Unsupported layout: $this');
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ PODS:
|
||||
- SDWebImage (5.21.0):
|
||||
- SDWebImage/Core (= 5.21.0)
|
||||
- SDWebImage/Core (5.21.0)
|
||||
- Sentry/HybridSDK (8.46.0)
|
||||
- sentry_flutter (8.14.2):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- Sentry/HybridSDK (= 8.46.0)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -107,6 +112,7 @@ DEPENDENCIES:
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- saver_gallery (from `.symlinks/plugins/saver_gallery/ios`)
|
||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
@@ -120,6 +126,7 @@ SPEC REPOS:
|
||||
- DKPhotoGallery
|
||||
- ReachabilitySwift
|
||||
- SDWebImage
|
||||
- Sentry
|
||||
- SwiftyGif
|
||||
- Toast
|
||||
|
||||
@@ -158,6 +165,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
saver_gallery:
|
||||
:path: ".symlinks/plugins/saver_gallery/ios"
|
||||
sentry_flutter:
|
||||
:path: ".symlinks/plugins/sentry_flutter/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
@@ -193,6 +202,8 @@ SPEC CHECKSUMS:
|
||||
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
|
||||
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
|
||||
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
|
||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
|
||||
@@ -5,9 +5,7 @@ export 'service/appflowy_ai_service.dart';
|
||||
export 'service/error.dart';
|
||||
export 'service/ai_model_state_notifier.dart';
|
||||
export 'service/select_model_bloc.dart';
|
||||
export 'service/view_selector_cubit.dart';
|
||||
export 'widgets/loading_indicator.dart';
|
||||
export 'widgets/view_selector.dart';
|
||||
export 'widgets/prompt_input/action_buttons.dart';
|
||||
export 'widgets/prompt_input/desktop_prompt_input.dart';
|
||||
export 'widgets/prompt_input/file_attachment_list.dart';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
@@ -15,12 +14,11 @@ class AIStreamEventPrefix {
|
||||
static const start = 'start:';
|
||||
static const finish = 'finish:';
|
||||
static const comment = 'comment:';
|
||||
static const aiResponseLimit = 'ai_response_limit:';
|
||||
static const aiImageResponseLimit = 'ai_image_response_limit:';
|
||||
static const aiMaxRequired = 'ai_max_required:';
|
||||
static const localAINotReady = 'local_ai_not_ready:';
|
||||
static const localAIDisabled = 'local_ai_disabled:';
|
||||
static const aiFollowUp = 'ai_follow_up:';
|
||||
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
|
||||
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
|
||||
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
|
||||
static const localAINotReady = 'LOCAL_AI_NOT_READY';
|
||||
static const localAIDisabled = 'LOCAL_AI_DISABLED';
|
||||
}
|
||||
|
||||
enum AiType {
|
||||
@@ -194,23 +192,8 @@ class AiPrompt extends Equatable {
|
||||
required this.category,
|
||||
required this.example,
|
||||
required this.isFeatured,
|
||||
required this.isCustom,
|
||||
});
|
||||
|
||||
factory AiPrompt.fromPB(CustomPromptPB pb) {
|
||||
return AiPrompt(
|
||||
id: pb.id,
|
||||
name: pb.name,
|
||||
content: pb.content,
|
||||
category: AiPromptCategory.values.firstWhere(
|
||||
(e) => e.name == pb.category,
|
||||
),
|
||||
example: pb.example,
|
||||
isFeatured: false,
|
||||
isCustom: true,
|
||||
);
|
||||
}
|
||||
|
||||
factory AiPrompt.fromJson(Map<String, dynamic> json) =>
|
||||
_$AiPromptFromJson(json);
|
||||
|
||||
@@ -228,10 +211,7 @@ class AiPrompt extends Equatable {
|
||||
final String example;
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool isFeatured;
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool isCustom;
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[id, name, content, category, example, isFeatured, isCustom];
|
||||
List<Object?> get props => [id, name, content, category, example, isFeatured];
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -18,7 +14,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
}) : _aiService = aiService ?? AppFlowyAIService(),
|
||||
super(AiPromptSelectorState.loading()) {
|
||||
filterTextController.addListener(_filterTextChanged);
|
||||
_init();
|
||||
_loadPrompts();
|
||||
}
|
||||
|
||||
final AppFlowyAIService _aiService;
|
||||
@@ -31,131 +27,33 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
await super.close();
|
||||
}
|
||||
|
||||
void _init() async {
|
||||
void _loadPrompts() async {
|
||||
availablePrompts.addAll(await _aiService.getBuiltInPrompts());
|
||||
|
||||
final featuredPrompts =
|
||||
availablePrompts.where((prompt) => prompt.isFeatured);
|
||||
final visiblePrompts = _getFilteredPrompts(featuredPrompts);
|
||||
|
||||
emit(
|
||||
AiPromptSelectorState.ready(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
isCustomPromptSectionSelected: false,
|
||||
isFeaturedSectionSelected: true,
|
||||
visiblePrompts: visiblePrompts,
|
||||
showCustomPrompts: false,
|
||||
isFeaturedCategorySelected: true,
|
||||
selectedPromptId: visiblePrompts.firstOrNull?.id,
|
||||
customPromptDatabaseViewId: null,
|
||||
isLoadingCustomPrompts: true,
|
||||
selectedCategory: null,
|
||||
favoritePrompts: [],
|
||||
),
|
||||
);
|
||||
|
||||
loadCustomPrompts();
|
||||
}
|
||||
|
||||
void loadCustomPrompts() {
|
||||
state.maybeMap(
|
||||
ready: (readyState) async {
|
||||
emit(
|
||||
readyState.copyWith(isLoadingCustomPrompts: true),
|
||||
);
|
||||
|
||||
String? databaseViewId = readyState.customPromptDatabaseViewId;
|
||||
if (databaseViewId == null) {
|
||||
final result =
|
||||
await AIEventGetCustomPromptDatabaseViewId().send().toNullable();
|
||||
databaseViewId = result?.id;
|
||||
}
|
||||
|
||||
if (databaseViewId == null) {
|
||||
emit(
|
||||
readyState.copyWith(isLoadingCustomPrompts: false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
availablePrompts.removeWhere((prompt) => prompt.isCustom);
|
||||
|
||||
final customPrompts =
|
||||
await _aiService.getDatabasePrompts(databaseViewId);
|
||||
|
||||
if (customPrompts == null) {
|
||||
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedPromptId: selectedPromptId,
|
||||
customPromptDatabaseViewId: databaseViewId,
|
||||
isLoadingCustomPrompts: false,
|
||||
isFeaturedSectionSelected: true,
|
||||
isCustomPromptSectionSelected: false,
|
||||
selectedCategory: null,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
availablePrompts.addAll(customPrompts);
|
||||
|
||||
final prompts = _getPromptsByCategory(readyState);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
customPromptDatabaseViewId: databaseViewId,
|
||||
isLoadingCustomPrompts: false,
|
||||
selectedPromptId: selectedPromptId,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
}
|
||||
|
||||
void selectCustomSection() {
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final prompts = availablePrompts.where((prompt) => prompt.isCustom);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedPromptId: visiblePrompts.firstOrNull?.id,
|
||||
isCustomPromptSectionSelected: true,
|
||||
isFeaturedSectionSelected: false,
|
||||
selectedCategory: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
}
|
||||
|
||||
void selectFeaturedSection() {
|
||||
void selectFeaturedCategory() {
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
visiblePrompts: visiblePrompts,
|
||||
isFeaturedCategorySelected: true,
|
||||
selectedPromptId: visiblePrompts.firstOrNull?.id,
|
||||
isFeaturedSectionSelected: true,
|
||||
isCustomPromptSectionSelected: false,
|
||||
selectedCategory: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -166,23 +64,24 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
void selectCategory(AiPromptCategory? category) {
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final prompts = category == null
|
||||
final unfilteredPrompts = category == null
|
||||
? availablePrompts
|
||||
: availablePrompts.where((prompt) => prompt.category == category);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
final prompts = _getFilteredPrompts(unfilteredPrompts);
|
||||
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
// Check if the selected prompt is still visible
|
||||
String? selectedPromptId = prompts.firstOrNull?.id;
|
||||
if (readyState.selectedPromptId != null &&
|
||||
prompts.any((prompt) => prompt.id == readyState.selectedPromptId)) {
|
||||
selectedPromptId = readyState.selectedPromptId;
|
||||
}
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
visiblePrompts: prompts,
|
||||
isFeaturedCategorySelected: false,
|
||||
selectedCategory: category,
|
||||
selectedPromptId: selectedPromptId,
|
||||
isFeaturedSectionSelected: false,
|
||||
isCustomPromptSectionSelected: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -217,6 +116,8 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
emit(
|
||||
readyState.copyWith(favoritePrompts: favoritePrompts),
|
||||
);
|
||||
|
||||
// TODO(RS): Save the updated favorite prompts to local storage or database
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
@@ -229,9 +130,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: availablePrompts,
|
||||
isCustomPromptSectionSelected: false,
|
||||
isFeaturedSectionSelected: true,
|
||||
selectedPromptId: availablePrompts.firstOrNull?.id,
|
||||
selectedPromptId: null,
|
||||
selectedCategory: null,
|
||||
),
|
||||
);
|
||||
@@ -240,84 +139,27 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
);
|
||||
}
|
||||
|
||||
void updateCustomPromptDatabaseViewId(String viewId) async {
|
||||
final stateCopy = state;
|
||||
final newState = state.maybeMap(
|
||||
ready: (readyState) {
|
||||
return readyState.customPromptDatabaseViewId == viewId
|
||||
? null
|
||||
: readyState.copyWith(isLoadingCustomPrompts: true);
|
||||
},
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (newState == null) {
|
||||
return;
|
||||
}
|
||||
emit(newState);
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
final customPrompts = await _aiService.getDatabasePrompts(viewId);
|
||||
if (customPrompts == null) {
|
||||
emit(AiPromptSelectorState.invalidDatabase());
|
||||
emit(stateCopy);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
availablePrompts
|
||||
..removeWhere((prompt) => prompt.isCustom)
|
||||
..addAll(customPrompts);
|
||||
|
||||
await AIEventSetCustomPromptDatabaseViewId(
|
||||
CustomPromptDatabaseViewIdPB()..id = viewId,
|
||||
).send().onFailure(Log.error);
|
||||
|
||||
emit(
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final prompts = _getPromptsByCategory(readyState);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
return readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedPromptId: selectedPromptId,
|
||||
customPromptDatabaseViewId: viewId,
|
||||
isLoadingCustomPrompts: false,
|
||||
);
|
||||
},
|
||||
orElse: () => state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _filterTextChanged() {
|
||||
state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final prompts = _getPromptsByCategory(readyState);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
final unfilteredPrompts = readyState.selectedCategory == null
|
||||
? readyState.isFeaturedCategorySelected
|
||||
? availablePrompts.where((prompt) => prompt.isFeatured)
|
||||
: availablePrompts
|
||||
: availablePrompts.where(
|
||||
(prompt) => prompt.category == readyState.selectedCategory,
|
||||
);
|
||||
final filteredPrompts = _getFilteredPrompts(unfilteredPrompts);
|
||||
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedPromptId: selectedPromptId,
|
||||
),
|
||||
readyState.copyWith(visiblePrompts: filteredPrompts),
|
||||
);
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
|
||||
List<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
|
||||
final filterText = filterTextController.value.text.trim().toLowerCase();
|
||||
|
||||
return prompts.where((prompt) {
|
||||
@@ -325,33 +167,6 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
return content.contains(filterText);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Iterable<AiPrompt> _getPromptsByCategory(_AiPromptSelectorReadyState state) {
|
||||
return availablePrompts.where((prompt) {
|
||||
if (state.selectedCategory != null) {
|
||||
return prompt.category == state.selectedCategory;
|
||||
}
|
||||
if (state.isFeaturedSectionSelected) {
|
||||
return prompt.isFeatured;
|
||||
}
|
||||
if (state.isCustomPromptSectionSelected) {
|
||||
return prompt.isCustom;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
String? _getVisibleSelectedPrompt(
|
||||
Iterable<AiPrompt> visiblePrompts,
|
||||
String? currentlySelectedPromptId,
|
||||
) {
|
||||
if (visiblePrompts
|
||||
.any((prompt) => prompt.id == currentlySelectedPromptId)) {
|
||||
return currentlySelectedPromptId;
|
||||
}
|
||||
|
||||
return visiblePrompts.firstOrNull?.id;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -360,18 +175,13 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
|
||||
|
||||
const factory AiPromptSelectorState.loading() = _AiPromptSelectorLoadingState;
|
||||
|
||||
const factory AiPromptSelectorState.invalidDatabase() =
|
||||
_AiPromptSelectorErrorState;
|
||||
|
||||
const factory AiPromptSelectorState.ready({
|
||||
required List<AiPrompt> visiblePrompts,
|
||||
required List<String> favoritePrompts,
|
||||
required bool isCustomPromptSectionSelected,
|
||||
required bool isFeaturedSectionSelected,
|
||||
required bool showCustomPrompts,
|
||||
required bool isFeaturedCategorySelected,
|
||||
required AiPromptCategory? selectedCategory,
|
||||
required String? selectedPromptId,
|
||||
required bool isLoadingCustomPrompts,
|
||||
required String? customPromptDatabaseViewId,
|
||||
}) = _AiPromptSelectorReadyState;
|
||||
|
||||
bool get isLoading => this is _AiPromptSelectorLoadingState;
|
||||
@@ -382,4 +192,9 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
|
||||
.firstWhereOrNull((prompt) => prompt.id == state.selectedPromptId),
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
AiPromptCategory? get selectedCategory => maybeMap(
|
||||
ready: (state) => state.selectedCategory,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import 'package:appflowy/shared/list_extension.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart' as fixnum;
|
||||
@@ -42,8 +41,6 @@ abstract class AIRepository {
|
||||
|
||||
Future<List<AiPrompt>> getBuiltInPrompts();
|
||||
|
||||
Future<List<AiPrompt>?> getDatabasePrompts(String databaseViewId);
|
||||
|
||||
void updateFavoritePrompts(List<String> promptIds);
|
||||
}
|
||||
|
||||
@@ -120,23 +117,6 @@ class AppFlowyAIService implements AIRepository {
|
||||
return prompts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AiPrompt>?> getDatabasePrompts(
|
||||
String databaseViewId,
|
||||
) async {
|
||||
return DatabaseEventGetDatabaseCustomPrompts(
|
||||
DatabaseViewIdPB(value: databaseViewId),
|
||||
).send().fold(
|
||||
(databasePromptsPB) {
|
||||
return databasePromptsPB.items.map(AiPrompt.fromPB).toList();
|
||||
},
|
||||
(err) {
|
||||
Log.error(err);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateFavoritePrompts(List<String> promptIds) {}
|
||||
}
|
||||
|
||||
+83
-147
@@ -8,8 +8,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
class AiPromptCategoryList extends StatefulWidget {
|
||||
const AiPromptCategoryList({
|
||||
super.key,
|
||||
required this.padding,
|
||||
});
|
||||
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
State<AiPromptCategoryList> createState() => _AiPromptCategoryListState();
|
||||
}
|
||||
@@ -20,59 +23,60 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return TextFieldTapRegion(
|
||||
groupId: "ai_prompt_category_list",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
child: AiPromptFeaturedSection(),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
child: AiPromptCustomPromptSection(),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: theme.spacing.s,
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
child: AFDivider(),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.only(
|
||||
top: theme.spacing.s,
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
children: [
|
||||
_buildCategoryItem(context, null),
|
||||
...sortedCategories.map(
|
||||
(category) => _buildCategoryItem(
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(
|
||||
theme.spacing.l,
|
||||
),
|
||||
child: TextFieldTapRegion(
|
||||
groupId: "ai_prompt_category_list",
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: theme.spacing.s,
|
||||
children: [
|
||||
_buildFeaturedCategory(context),
|
||||
const AFDivider(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
_buildCategoryItem(context, null),
|
||||
_buildCategoryItem(context, AiPromptCategory.development),
|
||||
_buildCategoryItem(context, AiPromptCategory.writing),
|
||||
_buildCategoryItem(context, AiPromptCategory.business),
|
||||
_buildCategoryItem(context, AiPromptCategory.marketing),
|
||||
_buildCategoryItem(
|
||||
context,
|
||||
category,
|
||||
AiPromptCategory.healthAndFitness,
|
||||
),
|
||||
),
|
||||
],
|
||||
_buildCategoryItem(context, AiPromptCategory.travel),
|
||||
_buildCategoryItem(context, AiPromptCategory.contentSeo),
|
||||
_buildCategoryItem(context, AiPromptCategory.emailMarketing),
|
||||
_buildCategoryItem(context, AiPromptCategory.paidAds),
|
||||
_buildCategoryItem(context, AiPromptCategory.prCommunication),
|
||||
_buildCategoryItem(context, AiPromptCategory.recruiting),
|
||||
_buildCategoryItem(context, AiPromptCategory.sales),
|
||||
_buildCategoryItem(context, AiPromptCategory.socialMedia),
|
||||
_buildCategoryItem(context, AiPromptCategory.strategy),
|
||||
_buildCategoryItem(context, AiPromptCategory.caseStudies),
|
||||
_buildCategoryItem(context, AiPromptCategory.salesCopy),
|
||||
_buildCategoryItem(context, AiPromptCategory.education),
|
||||
_buildCategoryItem(context, AiPromptCategory.work),
|
||||
_buildCategoryItem(
|
||||
context,
|
||||
AiPromptCategory.podcastProduction,
|
||||
),
|
||||
_buildCategoryItem(context, AiPromptCategory.copyWriting),
|
||||
_buildCategoryItem(context, AiPromptCategory.customerSuccess),
|
||||
_buildCategoryItem(context, AiPromptCategory.other),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Iterable<AiPromptCategory> get sortedCategories {
|
||||
final categories = [...AiPromptCategory.values];
|
||||
categories.sort((a, b) => a.i18n.compareTo(b.i18n));
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
Widget _buildCategoryItem(
|
||||
BuildContext context,
|
||||
AiPromptCategory? category,
|
||||
@@ -84,25 +88,18 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AiPromptFeaturedSection extends StatelessWidget {
|
||||
const AiPromptFeaturedSection({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget _buildFeaturedCategory(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
|
||||
ready: (state) => state.isFeaturedSectionSelected,
|
||||
ready: (state) => state.isFeaturedCategorySelected,
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
return AFBaseButton(
|
||||
onTap: () {
|
||||
if (!isSelected) {
|
||||
context.read<AiPromptSelectorCubit>().selectFeaturedSection();
|
||||
context.read<AiPromptSelectorCubit>().selectFeaturedCategory();
|
||||
}
|
||||
},
|
||||
builder: (context, isHovering, disabled) {
|
||||
@@ -134,61 +131,6 @@ class AiPromptFeaturedSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class AiPromptCustomPromptSection extends StatelessWidget {
|
||||
const AiPromptCustomPromptSection({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
ready: (readyState) {
|
||||
final isSelected = readyState.isCustomPromptSectionSelected;
|
||||
|
||||
return AFBaseButton(
|
||||
onTap: () {
|
||||
if (!isSelected) {
|
||||
context.read<AiPromptSelectorCubit>().selectCustomSection();
|
||||
}
|
||||
},
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
LocaleKeys.ai_customPrompt_custom.tr(),
|
||||
style: AppFlowyTheme.of(context).textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
borderRadius: theme.borderRadius.m,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.s,
|
||||
horizontal: theme.spacing.m,
|
||||
),
|
||||
borderColor: (context, isHovering, disabled, isFocused) =>
|
||||
Colors.transparent,
|
||||
backgroundColor: (context, isHovering, disabled) {
|
||||
if (isSelected) {
|
||||
return theme.fillColorScheme.themeSelect;
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.primaryAlpha5;
|
||||
}
|
||||
return Colors.transparent;
|
||||
},
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AiPromptCategoryItem extends StatelessWidget {
|
||||
const AiPromptCategoryItem({
|
||||
super.key,
|
||||
@@ -201,47 +143,41 @@ class AiPromptCategoryItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final isSelected = state.maybeMap(
|
||||
ready: (state) {
|
||||
return !state.isFeaturedSectionSelected &&
|
||||
!state.isCustomPromptSectionSelected &&
|
||||
state.selectedCategory == category;
|
||||
},
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
|
||||
ready: (state) =>
|
||||
!state.isFeaturedCategorySelected &&
|
||||
state.selectedCategory == category,
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
return AFBaseButton(
|
||||
onTap: onSelect,
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
|
||||
style: AppFlowyTheme.of(context).textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
borderRadius: theme.borderRadius.m,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.s,
|
||||
horizontal: theme.spacing.m,
|
||||
),
|
||||
borderColor: (context, isHovering, disabled, isFocused) =>
|
||||
Colors.transparent,
|
||||
backgroundColor: (context, isHovering, disabled) {
|
||||
if (isSelected) {
|
||||
return theme.fillColorScheme.themeSelect;
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.primaryAlpha5;
|
||||
}
|
||||
return Colors.transparent;
|
||||
},
|
||||
return AFBaseButton(
|
||||
onTap: onSelect,
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
|
||||
style: AppFlowyTheme.of(context).textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
borderRadius: theme.borderRadius.m,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.s,
|
||||
horizontal: theme.spacing.m,
|
||||
),
|
||||
borderColor: (context, isHovering, disabled, isFocused) =>
|
||||
Colors.transparent,
|
||||
backgroundColor: (context, isHovering, disabled) {
|
||||
if (isSelected) {
|
||||
return theme.fillColorScheme.themeSelect;
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.primaryAlpha5;
|
||||
}
|
||||
return Colors.transparent;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
-346
@@ -1,346 +0,0 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/trash/application/trash_service.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class CustomPromptDatabaseSelector extends StatefulWidget {
|
||||
const CustomPromptDatabaseSelector({
|
||||
super.key,
|
||||
this.databaseViewId,
|
||||
this.isLoading = false,
|
||||
this.popoverDirection = PopoverDirection.bottomWithCenterAligned,
|
||||
required this.childBuilder,
|
||||
});
|
||||
|
||||
final String? databaseViewId;
|
||||
final bool isLoading;
|
||||
final PopoverDirection popoverDirection;
|
||||
final Widget Function(VoidCallback) childBuilder;
|
||||
|
||||
@override
|
||||
State<CustomPromptDatabaseSelector> createState() =>
|
||||
_CustomPromptDatabaseSelectorState();
|
||||
}
|
||||
|
||||
class _CustomPromptDatabaseSelectorState
|
||||
extends State<CustomPromptDatabaseSelector> {
|
||||
final popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewSelector(
|
||||
viewSelectorCubit: BlocProvider(
|
||||
create: (context) => ViewSelectorCubit(
|
||||
getIgnoreViewType: getIgnoreViewType,
|
||||
),
|
||||
),
|
||||
child: BlocSelector<SpaceBloc, SpaceState, (List<ViewPB>, ViewPB?)>(
|
||||
selector: (state) => (state.spaces, state.currentSpace),
|
||||
builder: (context, state) {
|
||||
return AppFlowyPopover(
|
||||
controller: popoverController,
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
margin: EdgeInsets.zero,
|
||||
offset: const Offset(0, 4.0),
|
||||
direction: widget.popoverDirection,
|
||||
constraints: const BoxConstraints.tightFor(width: 300, height: 400),
|
||||
popupBuilder: (_) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ViewSelectorCubit>(),
|
||||
child: _PopoverContent(
|
||||
onSelectViewItem: (item) {
|
||||
final view = item.view;
|
||||
if (view.layout.isDatabaseView) {
|
||||
context
|
||||
.read<AiPromptSelectorCubit>()
|
||||
.updateCustomPromptDatabaseViewId(view.id);
|
||||
popoverController.close();
|
||||
} else if (!view.isSpace && view.layout.isDocumentView) {
|
||||
context.read<ViewSelectorCubit>().toggleIsExpanded(
|
||||
item,
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.childBuilder(
|
||||
() {
|
||||
if (!widget.isLoading) {
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources(state.$1, state.$2);
|
||||
popoverController.show();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IgnoreViewType getIgnoreViewType(ViewSelectorItem item) {
|
||||
final layout = item.view.layout;
|
||||
|
||||
if (layout.isDatabaseView) {
|
||||
return IgnoreViewType.none;
|
||||
}
|
||||
if (layout.isDocumentView) {
|
||||
return hasDatabaseDescendent(item)
|
||||
? IgnoreViewType.none
|
||||
: IgnoreViewType.hide;
|
||||
}
|
||||
return IgnoreViewType.hide;
|
||||
}
|
||||
|
||||
bool hasDatabaseDescendent(ViewSelectorItem viewSelectorItem) {
|
||||
final layout = viewSelectorItem.view.layout;
|
||||
|
||||
if (layout == ViewLayoutPB.Chat) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (layout.isDatabaseView) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// document may have children
|
||||
return viewSelectorItem.children.any(
|
||||
(child) => hasDatabaseDescendent(child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AiPromptDatabaseSelectorButton extends StatefulWidget {
|
||||
const AiPromptDatabaseSelectorButton({
|
||||
super.key,
|
||||
required this.selectedViewId,
|
||||
required this.isLoading,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String? selectedViewId;
|
||||
final bool isLoading;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<AiPromptDatabaseSelectorButton> createState() =>
|
||||
_AiPromptDatabaseSelectorButtonState();
|
||||
}
|
||||
|
||||
class _AiPromptDatabaseSelectorButtonState
|
||||
extends State<AiPromptDatabaseSelectorButton> {
|
||||
late Future<ViewPB?> viewFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
viewFuture = getView();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AiPromptDatabaseSelectorButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selectedViewId != widget.selectedViewId) {
|
||||
viewFuture = getView();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return FutureBuilder<ViewPB?>(
|
||||
future: viewFuture,
|
||||
builder: (context, snapshot) {
|
||||
String name = "";
|
||||
|
||||
if (snapshot.hasData &&
|
||||
snapshot.connectionState == ConnectionState.done) {
|
||||
name = snapshot.data!.nameOrDefault;
|
||||
}
|
||||
|
||||
return Row(
|
||||
spacing: theme.spacing.s,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
"${LocaleKeys.ai_customPrompt_promptDatabase.tr()}: $name",
|
||||
maxLines: 1,
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 150,
|
||||
),
|
||||
child: AFOutlinedButton.normal(
|
||||
onTap: widget.onTap,
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Row(
|
||||
spacing: theme.spacing.s,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isLoading) buildLoadingIndicator(theme),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.isLoading
|
||||
? LocaleKeys.ai_customPrompt_loading.tr()
|
||||
: LocaleKeys.button_change.tr(),
|
||||
maxLines: 1,
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLoadingIndicator(AppFlowyThemeData theme) {
|
||||
return SizedBox.square(
|
||||
dimension: 20,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(2.5),
|
||||
child: CircularProgressIndicator(
|
||||
color: theme.iconColorScheme.tertiary,
|
||||
strokeWidth: 2.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ViewPB?> getView() async {
|
||||
if (widget.selectedViewId == null) {
|
||||
return null;
|
||||
}
|
||||
final view =
|
||||
await ViewBackendService.getView(widget.selectedViewId!).toNullable();
|
||||
|
||||
if (view != null) {
|
||||
return view;
|
||||
}
|
||||
|
||||
final trashViews = await TrashService().readTrash().toNullable();
|
||||
final trashedItem = trashViews?.items
|
||||
.firstWhereOrNull((element) => element.id == widget.selectedViewId);
|
||||
|
||||
if (trashedItem == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ViewPB()
|
||||
..id = trashedItem.id
|
||||
..name = trashedItem.name;
|
||||
}
|
||||
}
|
||||
|
||||
class _PopoverContent extends StatefulWidget {
|
||||
const _PopoverContent({
|
||||
required this.onSelectViewItem,
|
||||
});
|
||||
|
||||
final void Function(ViewSelectorItem item) onSelectViewItem;
|
||||
|
||||
@override
|
||||
State<_PopoverContent> createState() => _PopoverContentState();
|
||||
}
|
||||
|
||||
class _PopoverContentState extends State<_PopoverContent> {
|
||||
final focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
VSpace(theme.spacing.m),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: theme.spacing.m,
|
||||
),
|
||||
child: AFTextField(
|
||||
focusNode: focusNode,
|
||||
size: AFTextFieldSize.m,
|
||||
hintText: LocaleKeys.search_label.tr(),
|
||||
controller: context.read<ViewSelectorCubit>().filterTextController,
|
||||
),
|
||||
),
|
||||
VSpace(theme.spacing.m),
|
||||
AFDivider(),
|
||||
Expanded(
|
||||
child: BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
|
||||
builder: (context, state) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
children: _buildVisibleSources(context, state).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildVisibleSources(
|
||||
BuildContext context,
|
||||
ViewSelectorState state,
|
||||
) {
|
||||
return state.visibleSources.map(
|
||||
(e) => ViewSelectorTreeItem(
|
||||
key: ValueKey(
|
||||
'custom_prompt_database_tree_item_${e.view.id}',
|
||||
),
|
||||
viewSelectorItem: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
showCheckbox: false,
|
||||
onSelected: widget.onSelectViewItem,
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -10,7 +8,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'ai_prompt_category_list.dart';
|
||||
import 'ai_prompt_onboarding.dart';
|
||||
import 'ai_prompt_preview.dart';
|
||||
import 'ai_prompt_visible_list.dart';
|
||||
|
||||
@@ -18,20 +15,11 @@ Future<AiPrompt?> showAiPromptModal(
|
||||
BuildContext context, {
|
||||
required AiPromptSelectorCubit aiPromptSelectorCubit,
|
||||
}) async {
|
||||
aiPromptSelectorCubit.loadCustomPrompts();
|
||||
|
||||
return showDialog<AiPrompt?>(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(
|
||||
value: aiPromptSelectorCubit,
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: context.read<UserWorkspaceBloc>(),
|
||||
),
|
||||
],
|
||||
builder: (context) {
|
||||
return BlocProvider.value(
|
||||
value: aiPromptSelectorCubit,
|
||||
child: const AiPromptModal(),
|
||||
);
|
||||
},
|
||||
@@ -50,122 +38,92 @@ class AiPromptModal extends StatelessWidget {
|
||||
maxWidth: 1200,
|
||||
maxHeight: 800,
|
||||
),
|
||||
child: BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
listener: (context, state) {
|
||||
state.maybeMap(
|
||||
invalidDatabase: (_) {
|
||||
showLoadPromptFailedDialog(context);
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
AFModalHeader(
|
||||
leading: Text(
|
||||
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
|
||||
style: theme.textStyle.heading4.prominent(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
AFModalHeader(
|
||||
leading: Text(
|
||||
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
|
||||
style: theme.textStyle.heading4.prominent(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
trailing: [
|
||||
AFGhostButton.normal(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.all(theme.spacing.xs),
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.toast_close_s,
|
||||
size: Size.square(20),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: AFModalBody(
|
||||
child:
|
||||
BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
loading: (_) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
},
|
||||
ready: (readyState) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Expanded(
|
||||
child: AiPromptCategoryList(),
|
||||
),
|
||||
if (readyState.isCustomPromptSectionSelected &&
|
||||
readyState.customPromptDatabaseViewId == null)
|
||||
const Expanded(
|
||||
flex: 5,
|
||||
child: Center(
|
||||
child: AiPromptOnboarding(),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
const Expanded(
|
||||
flex: 2,
|
||||
child: AiPromptVisibleList(),
|
||||
trailing: [
|
||||
AFGhostButton.normal(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.all(theme.spacing.s),
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.toast_close_s,
|
||||
size: Size.square(20),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: AFModalBody(
|
||||
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return state.map(
|
||||
loading: (_) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
},
|
||||
ready: (_) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AiPromptCategoryList(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
top: theme.spacing.l,
|
||||
end: theme.spacing.l,
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: BlocBuilder<AiPromptSelectorCubit,
|
||||
AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
final selectedPrompt = state.maybeMap(
|
||||
ready: (state) {
|
||||
return state.visiblePrompts
|
||||
.firstWhereOrNull(
|
||||
(prompt) =>
|
||||
prompt.id ==
|
||||
state.selectedPromptId,
|
||||
);
|
||||
},
|
||||
orElse: () => null,
|
||||
);
|
||||
if (selectedPrompt == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return AiPromptPreview(
|
||||
prompt: selectedPrompt,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: AiPromptVisibleList(),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: BlocBuilder<AiPromptSelectorCubit,
|
||||
AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
final selectedPrompt = state.maybeMap(
|
||||
ready: (state) {
|
||||
return state.visiblePrompts
|
||||
.firstWhereOrNull(
|
||||
(prompt) =>
|
||||
prompt.id == state.selectedPromptId,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
orElse: () => null,
|
||||
);
|
||||
if (selectedPrompt == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return AiPromptPreview(
|
||||
prompt: selectedPrompt,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showLoadPromptFailedDialog(
|
||||
BuildContext context,
|
||||
) {
|
||||
showSimpleAFDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.ai_customPrompt_invalidDatabase.tr(),
|
||||
content: LocaleKeys.ai_customPrompt_invalidDatabaseHelp.tr(),
|
||||
primaryAction: (
|
||||
LocaleKeys.button_ok.tr(),
|
||||
(context) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'ai_prompt_database_selector.dart';
|
||||
|
||||
class AiPromptOnboarding extends StatelessWidget {
|
||||
const AiPromptOnboarding({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
LocaleKeys.ai_customPrompt_customPrompt.tr(),
|
||||
style: theme.textStyle.heading3.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
VSpace(
|
||||
theme.spacing.s,
|
||||
),
|
||||
Text(
|
||||
LocaleKeys.ai_customPrompt_databasePrompts.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
VSpace(
|
||||
theme.spacing.xxl,
|
||||
),
|
||||
CustomPromptDatabaseSelector(
|
||||
childBuilder: (onTap) => AFFilledButton.primary(
|
||||
onTap: onTap,
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.onFill,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,10 @@ class AiPromptPreview extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: theme.spacing.l,
|
||||
padding: EdgeInsets.only(
|
||||
top: theme.spacing.l,
|
||||
left: theme.spacing.l,
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
child: SelectionContainer.disabled(
|
||||
child: Row(
|
||||
|
||||
+40
-139
@@ -7,12 +7,10 @@ import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:diffutil_dart/diffutil.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'ai_prompt_database_selector.dart';
|
||||
|
||||
const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
|
||||
|
||||
class AiPromptVisibleList extends StatefulWidget {
|
||||
@@ -37,12 +35,12 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
|
||||
super.initState();
|
||||
cubit = context.read<AiPromptSelectorCubit>();
|
||||
final textController = cubit.filterTextController;
|
||||
filterIsEmpty = textController.text.isEmpty;
|
||||
textController.addListener(handleFilterTextChanged);
|
||||
final prompts = cubit.state.maybeMap(
|
||||
ready: (value) => value.visiblePrompts,
|
||||
orElse: () => <AiPrompt>[],
|
||||
);
|
||||
filterIsEmpty = textController.text.isEmpty;
|
||||
textController.addListener(handleFilterTextChanged);
|
||||
oldList.addAll(prompts);
|
||||
}
|
||||
|
||||
@@ -55,87 +53,52 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
final spacing = AppFlowyTheme.of(context).spacing;
|
||||
return BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
listener: (context, state) {
|
||||
state.maybeMap(
|
||||
ready: (state) {
|
||||
handleVisiblePromptListChanged(state.visiblePrompts);
|
||||
},
|
||||
orElse: () {},
|
||||
final list = state.maybeMap(
|
||||
ready: (state) => state.visiblePrompts,
|
||||
orElse: () => <AiPrompt>[],
|
||||
);
|
||||
handleVisiblePromptListChanged(list);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
VSpace(spacing.l),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: theme.spacing.l),
|
||||
padding: EdgeInsets.symmetric(horizontal: spacing.l),
|
||||
child: buildSearchField(context),
|
||||
),
|
||||
VSpace(
|
||||
theme.spacing.s,
|
||||
),
|
||||
BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
buildWhen: (p, c) {
|
||||
return p.maybeMap(
|
||||
ready: (pr) => c.maybeMap(
|
||||
ready: (cr) =>
|
||||
pr.customPromptDatabaseViewId !=
|
||||
cr.customPromptDatabaseViewId ||
|
||||
pr.isLoadingCustomPrompts != cr.isLoadingCustomPrompts ||
|
||||
pr.isCustomPromptSectionSelected !=
|
||||
cr.isCustomPromptSectionSelected,
|
||||
orElse: () => false,
|
||||
),
|
||||
orElse: () => true,
|
||||
);
|
||||
},
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
ready: (readyState) {
|
||||
if (!readyState.isCustomPromptSectionSelected) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: theme.spacing.l,
|
||||
top: theme.spacing.s,
|
||||
right: theme.spacing.l,
|
||||
),
|
||||
child: CustomPromptDatabaseSelector(
|
||||
databaseViewId: readyState.customPromptDatabaseViewId,
|
||||
isLoading: readyState.isLoadingCustomPrompts,
|
||||
popoverDirection: PopoverDirection.bottomWithRightAligned,
|
||||
childBuilder: (onTap) {
|
||||
return AiPromptDatabaseSelectorButton(
|
||||
selectedViewId: readyState.customPromptDatabaseViewId,
|
||||
isLoading: readyState.isLoadingCustomPrompts,
|
||||
onTap: onTap,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
VSpace(spacing.s),
|
||||
Expanded(
|
||||
child: TextFieldTapRegion(
|
||||
groupId: "ai_prompt_category_list",
|
||||
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
ready: (readyState) {
|
||||
if (readyState.visiblePrompts.isEmpty) {
|
||||
return buildEmptyPrompts();
|
||||
}
|
||||
return buildPromptList();
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: AnimatedList(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.all(spacing.l),
|
||||
key: listKey,
|
||||
initialItemCount: oldList.length,
|
||||
itemBuilder: (context, index, animation) {
|
||||
final cubit = context.read<AiPromptSelectorCubit>();
|
||||
|
||||
return cubit.state.maybeMap(
|
||||
ready: (state) {
|
||||
final prompt = state.visiblePrompts[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: index == 0 ? 0 : spacing.s,
|
||||
bottom: index == state.visiblePrompts.length - 1
|
||||
? 0
|
||||
: spacing.s,
|
||||
),
|
||||
child: _AiPromptListItem(
|
||||
animation: animation,
|
||||
prompt: prompt,
|
||||
isSelected: state.selectedPromptId == prompt.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -178,68 +141,6 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEmptyPrompts() {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.m_home_search_icon_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: Size.square(24),
|
||||
),
|
||||
VSpace(theme.spacing.m),
|
||||
Text(
|
||||
LocaleKeys.ai_customPrompt_noResults.tr(),
|
||||
style: theme.textStyle.body
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPromptList() {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return AnimatedList(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.all(theme.spacing.l),
|
||||
key: listKey,
|
||||
initialItemCount: oldList.length,
|
||||
itemBuilder: (context, index, animation) {
|
||||
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
ready: (state) {
|
||||
final prompt = state.visiblePrompts[index];
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: index == 0 ? 0 : theme.spacing.s,
|
||||
bottom: index == state.visiblePrompts.length - 1
|
||||
? 0
|
||||
: theme.spacing.s,
|
||||
),
|
||||
child: _AiPromptListItem(
|
||||
animation: animation,
|
||||
prompt: prompt,
|
||||
isSelected: state.selectedPromptId == prompt.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void handleVisiblePromptListChanged(
|
||||
List<AiPrompt> newList,
|
||||
) {
|
||||
@@ -357,7 +258,6 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
|
||||
padding: EdgeInsets.all(theme.spacing.m),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(theme.borderRadius.m),
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: widget.isSelected
|
||||
? isHovering
|
||||
@@ -367,6 +267,7 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
|
||||
? theme.borderColorScheme.greyTertiaryHover
|
||||
: theme.borderColorScheme.greyTertiary,
|
||||
),
|
||||
color: theme.surfaceColorScheme.primary,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:appflowy/plugins/ai_chat/application/chat_user_cubit.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
@@ -58,6 +57,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
chatUserCubit.fetchUserProfile();
|
||||
|
||||
widget.textController.addListener(handleTextControllerChanged);
|
||||
focusNode
|
||||
@@ -77,7 +77,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
focusNode.requestFocus();
|
||||
checkForAskingAI();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,26 +228,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
);
|
||||
}
|
||||
|
||||
void checkForAskingAI() {
|
||||
final paletteBloc = context.read<CommandPaletteBloc?>(),
|
||||
paletteState = paletteBloc?.state;
|
||||
if (paletteBloc == null || paletteState == null) return;
|
||||
final isAskingAI = paletteState.askAI;
|
||||
if (!isAskingAI) return;
|
||||
paletteBloc.add(CommandPaletteEvent.askedAI());
|
||||
final query = paletteState.query ?? '';
|
||||
if (query.isEmpty) return;
|
||||
final sources = (paletteState.askAISources ?? []).map((e) => e.id).toList();
|
||||
final metadata =
|
||||
context.read<AIPromptInputBloc?>()?.consumeMetadata() ?? {};
|
||||
final promptState = context.read<AIPromptInputBloc?>()?.state;
|
||||
final predefinedFormat = promptState?.predefinedFormat;
|
||||
if (sources.isNotEmpty) {
|
||||
widget.onUpdateSelectedSources(sources);
|
||||
}
|
||||
widget.onSubmitted.call(query, predefinedFormat, metadata);
|
||||
}
|
||||
|
||||
void startMentionPageFromButton() {
|
||||
if (overlayController.isShowing) {
|
||||
return;
|
||||
|
||||
+36
-35
@@ -1,16 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/ai/service/view_selector_cubit.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/base/drag_handler.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -35,18 +32,7 @@ class PromptInputMobileSelectSourcesButton extends StatefulWidget {
|
||||
|
||||
class _PromptInputMobileSelectSourcesButtonState
|
||||
extends State<PromptInputMobileSelectSourcesButton> {
|
||||
late final cubit = ViewSelectorCubit(
|
||||
maxSelectedParentPageCount: 3,
|
||||
getIgnoreViewType: (item) {
|
||||
if (item.view.isSpace) {
|
||||
return IgnoreViewType.none;
|
||||
}
|
||||
if (item.view.layout != ViewLayoutPB.Document) {
|
||||
return IgnoreViewType.hide;
|
||||
}
|
||||
return IgnoreViewType.none;
|
||||
},
|
||||
);
|
||||
late final cubit = ChatSettingsCubit();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -104,12 +90,9 @@ class _PromptInputMobileSelectSourcesButtonState
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
unawaited(
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace),
|
||||
);
|
||||
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
await showMobileBottomSheet<void>(
|
||||
context,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
@@ -146,17 +129,32 @@ class _PromptInputMobileSelectSourcesButtonState
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileSelectSourcesSheetBody extends StatelessWidget {
|
||||
class _MobileSelectSourcesSheetBody extends StatefulWidget {
|
||||
const _MobileSelectSourcesSheetBody({
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
|
||||
@override
|
||||
State<_MobileSelectSourcesSheetBody> createState() =>
|
||||
_MobileSelectSourcesSheetBodyState();
|
||||
}
|
||||
|
||||
class _MobileSelectSourcesSheetBodyState
|
||||
extends State<_MobileSelectSourcesSheetBody> {
|
||||
final textController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
controller: scrollController,
|
||||
controller: widget.scrollController,
|
||||
shrinkWrap: true,
|
||||
slivers: [
|
||||
SliverPersistentHeader(
|
||||
@@ -185,9 +183,10 @@ class _MobileSelectSourcesSheetBody extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
height: 44.0,
|
||||
child: FlowySearchTextField(
|
||||
controller: context
|
||||
.read<ViewSelectorCubit>()
|
||||
.filterTextController,
|
||||
controller: textController,
|
||||
onChanged: (value) => context
|
||||
.read<ChatSettingsCubit>()
|
||||
.updateFilter(value),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -197,25 +196,27 @@ class _MobileSelectSourcesSheetBody extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
|
||||
BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
|
||||
builder: (context, state) {
|
||||
final sources = state.visibleSources
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide);
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: state.visibleSources.length,
|
||||
childCount: sources.length,
|
||||
(context, index) {
|
||||
final source = state.visibleSources.elementAt(index);
|
||||
return ViewSelectorTreeItem(
|
||||
final source = sources.elementAt(index);
|
||||
return ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'visible_select_sources_tree_item_${source.view.id}',
|
||||
),
|
||||
viewSelectorItem: source,
|
||||
chatSource: source,
|
||||
level: 0,
|
||||
isDescendentOfSpace: source.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
onSelected: (item) {
|
||||
onSelected: (chatSource) {
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.toggleSelectedStatus(item, false);
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleSelectedStatus(chatSource);
|
||||
},
|
||||
height: 40.0,
|
||||
);
|
||||
|
||||
@@ -2,13 +2,15 @@ import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -16,8 +18,6 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../service/view_selector_cubit.dart';
|
||||
import '../view_selector.dart';
|
||||
import 'layout_define.dart';
|
||||
import 'mention_page_menu.dart';
|
||||
|
||||
@@ -38,24 +38,7 @@ class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
|
||||
|
||||
class _PromptInputDesktopSelectSourcesButtonState
|
||||
extends State<PromptInputDesktopSelectSourcesButton> {
|
||||
late final cubit = ViewSelectorCubit(
|
||||
maxSelectedParentPageCount: 3,
|
||||
getIgnoreViewType: (item) {
|
||||
final view = item.view;
|
||||
|
||||
if (view.isSpace) {
|
||||
return IgnoreViewType.none;
|
||||
}
|
||||
if (view.layout == ViewLayoutPB.Chat) {
|
||||
return IgnoreViewType.hide;
|
||||
}
|
||||
if (view.layout != ViewLayoutPB.Document) {
|
||||
return IgnoreViewType.disable;
|
||||
}
|
||||
|
||||
return IgnoreViewType.none;
|
||||
},
|
||||
);
|
||||
late final cubit = ChatSettingsCubit();
|
||||
final popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
@@ -76,10 +59,23 @@ class _PromptInputDesktopSelectSourcesButtonState
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewSelector(
|
||||
viewSelectorCubit: BlocProvider.value(
|
||||
value: cubit,
|
||||
),
|
||||
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
|
||||
final userProfile = userWorkspaceBloc.state.userProfile;
|
||||
final workspaceId =
|
||||
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => SpaceBloc(
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
)..add(const SpaceEvent.initial(openFirstPage: false)),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: cubit,
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<SpaceBloc, SpaceState>(
|
||||
builder: (context, state) {
|
||||
return AppFlowyPopover(
|
||||
@@ -90,18 +86,18 @@ class _PromptInputDesktopSelectSourcesButtonState
|
||||
controller: popoverController,
|
||||
onOpen: () {
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
},
|
||||
onClose: () {
|
||||
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
},
|
||||
popupBuilder: (_) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ViewSelectorCubit>(),
|
||||
value: context.read<ChatSettingsCubit>(),
|
||||
child: const _PopoverContent(),
|
||||
);
|
||||
},
|
||||
@@ -190,26 +186,20 @@ class _PopoverContent extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
|
||||
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
|
||||
builder: (context, state) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 8),
|
||||
child: AFTextField(
|
||||
size: AFTextFieldSize.m,
|
||||
controller:
|
||||
context.read<ViewSelectorCubit>().filterTextController,
|
||||
hintText: LocaleKeys.search_label.tr(),
|
||||
child: SpaceSearchField(
|
||||
width: 600,
|
||||
onSearch: (context, value) =>
|
||||
context.read<ChatSettingsCubit>().updateFilter(value),
|
||||
),
|
||||
),
|
||||
AFDivider(
|
||||
startIndent: theme.spacing.l,
|
||||
endIndent: theme.spacing.l,
|
||||
),
|
||||
_buildDivider(),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
@@ -218,10 +208,9 @@ class _PopoverContent extends StatelessWidget {
|
||||
..._buildSelectedSources(context, state),
|
||||
if (state.selectedSources.isNotEmpty &&
|
||||
state.visibleSources.isNotEmpty)
|
||||
AFDivider(
|
||||
spacing: 4.0,
|
||||
startIndent: theme.spacing.l,
|
||||
endIndent: theme.spacing.l,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: _buildDivider(),
|
||||
),
|
||||
..._buildVisibleSources(context, state),
|
||||
],
|
||||
@@ -233,53 +222,70 @@ class _PopoverContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return const Divider(
|
||||
height: 1.0,
|
||||
thickness: 1.0,
|
||||
indent: 8.0,
|
||||
endIndent: 8.0,
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildSelectedSources(
|
||||
BuildContext context,
|
||||
ViewSelectorState state,
|
||||
ChatSettingsState state,
|
||||
) {
|
||||
return state.selectedSources.map(
|
||||
(e) => ViewSelectorTreeItem(
|
||||
key: ValueKey(
|
||||
'selected_select_sources_tree_item_${e.view.id}',
|
||||
),
|
||||
viewSelectorItem: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: true,
|
||||
onSelected: (item) {
|
||||
context.read<ViewSelectorCubit>().toggleSelectedStatus(item, true);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
return state.selectedSources
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
|
||||
.map(
|
||||
(e) => ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'selected_select_sources_tree_item_${e.view.id}',
|
||||
),
|
||||
chatSource: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: true,
|
||||
onSelected: (chatSource) {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleSelectedStatus(chatSource);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildVisibleSources(
|
||||
BuildContext context,
|
||||
ViewSelectorState state,
|
||||
ChatSettingsState state,
|
||||
) {
|
||||
return state.visibleSources.map(
|
||||
(e) => ViewSelectorTreeItem(
|
||||
key: ValueKey(
|
||||
'visible_select_sources_tree_item_${e.view.id}',
|
||||
),
|
||||
viewSelectorItem: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
onSelected: (item) {
|
||||
context.read<ViewSelectorCubit>().toggleSelectedStatus(item, false);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
return state.visibleSources
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
|
||||
.map(
|
||||
(e) => ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'visible_select_sources_tree_item_${e.view.id}',
|
||||
),
|
||||
chatSource: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
onSelected: (chatSource) {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleSelectedStatus(chatSource);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ViewSelectorTreeItem extends StatefulWidget {
|
||||
const ViewSelectorTreeItem({
|
||||
class ChatSourceTreeItem extends StatefulWidget {
|
||||
const ChatSourceTreeItem({
|
||||
super.key,
|
||||
required this.viewSelectorItem,
|
||||
required this.chatSource,
|
||||
required this.level,
|
||||
required this.isDescendentOfSpace,
|
||||
required this.isSelectedSection,
|
||||
@@ -290,7 +296,7 @@ class ViewSelectorTreeItem extends StatefulWidget {
|
||||
this.showCheckbox = true,
|
||||
});
|
||||
|
||||
final ViewSelectorItem viewSelectorItem;
|
||||
final ChatSource chatSource;
|
||||
|
||||
/// nested level of the view item
|
||||
final int level;
|
||||
@@ -299,9 +305,9 @@ class ViewSelectorTreeItem extends StatefulWidget {
|
||||
|
||||
final bool isSelectedSection;
|
||||
|
||||
final void Function(ViewSelectorItem viewSelectorItem) onSelected;
|
||||
final void Function(ChatSource chatSource) onSelected;
|
||||
|
||||
final void Function(ViewSelectorItem viewSelectorItem)? onAdd;
|
||||
final void Function(ChatSource chatSource)? onAdd;
|
||||
|
||||
final bool showSaveButton;
|
||||
|
||||
@@ -310,16 +316,16 @@ class ViewSelectorTreeItem extends StatefulWidget {
|
||||
final bool showCheckbox;
|
||||
|
||||
@override
|
||||
State<ViewSelectorTreeItem> createState() => _ViewSelectorTreeItemState();
|
||||
State<ChatSourceTreeItem> createState() => _ChatSourceTreeItemState();
|
||||
}
|
||||
|
||||
class _ViewSelectorTreeItemState extends State<ViewSelectorTreeItem> {
|
||||
class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = SizedBox(
|
||||
height: widget.height,
|
||||
child: ViewSelectorTreeItemInner(
|
||||
viewSelectorItem: widget.viewSelectorItem,
|
||||
child: ChatSourceTreeItemInner(
|
||||
chatSource: widget.chatSource,
|
||||
level: widget.level,
|
||||
isDescendentOfSpace: widget.isDescendentOfSpace,
|
||||
isSelectedSection: widget.isSelectedSection,
|
||||
@@ -330,30 +336,33 @@ class _ViewSelectorTreeItemState extends State<ViewSelectorTreeItem> {
|
||||
),
|
||||
);
|
||||
|
||||
final disabledEnabledChild = widget.viewSelectorItem.isDisabled
|
||||
? FlowyTooltip(
|
||||
message: widget.showCheckbox
|
||||
? switch (widget.viewSelectorItem.view.layout) {
|
||||
ViewLayoutPB.Document =>
|
||||
LocaleKeys.chat_sourcesLimitReached.tr(),
|
||||
_ => LocaleKeys.chat_sourceUnsupported.tr(),
|
||||
}
|
||||
: "",
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.forbidden,
|
||||
child: IgnorePointer(child: child),
|
||||
),
|
||||
),
|
||||
)
|
||||
: child;
|
||||
final disabledEnabledChild =
|
||||
widget.chatSource.ignoreStatus == IgnoreViewType.disable
|
||||
? FlowyTooltip(
|
||||
message: widget.showCheckbox
|
||||
? switch (widget.chatSource.view.layout) {
|
||||
ViewLayoutPB.Document =>
|
||||
LocaleKeys.chat_sourcesLimitReached.tr(),
|
||||
_ => LocaleKeys.chat_sourceUnsupported.tr(),
|
||||
}
|
||||
: "",
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.forbidden,
|
||||
child: IgnorePointer(child: child),
|
||||
),
|
||||
),
|
||||
)
|
||||
: child;
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.viewSelectorItem.isExpandedNotifier,
|
||||
valueListenable: widget.chatSource.isExpandedNotifier,
|
||||
builder: (context, isExpanded, child) {
|
||||
// filter the child views that should be ignored
|
||||
final childViews = widget.viewSelectorItem.children;
|
||||
final childViews = widget.chatSource.children
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
|
||||
.toList();
|
||||
|
||||
if (!isExpanded || childViews.isEmpty) {
|
||||
return disabledEnabledChild;
|
||||
@@ -364,11 +373,11 @@ class _ViewSelectorTreeItemState extends State<ViewSelectorTreeItem> {
|
||||
children: [
|
||||
disabledEnabledChild,
|
||||
...childViews.map(
|
||||
(childSource) => ViewSelectorTreeItem(
|
||||
(childSource) => ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'select_sources_tree_item_${childSource.view.id}',
|
||||
),
|
||||
viewSelectorItem: childSource,
|
||||
chatSource: childSource,
|
||||
level: widget.level + 1,
|
||||
isDescendentOfSpace: widget.isDescendentOfSpace,
|
||||
isSelectedSection: widget.isSelectedSection,
|
||||
@@ -386,10 +395,10 @@ class _ViewSelectorTreeItemState extends State<ViewSelectorTreeItem> {
|
||||
}
|
||||
}
|
||||
|
||||
class ViewSelectorTreeItemInner extends StatelessWidget {
|
||||
const ViewSelectorTreeItemInner({
|
||||
class ChatSourceTreeItemInner extends StatelessWidget {
|
||||
const ChatSourceTreeItemInner({
|
||||
super.key,
|
||||
required this.viewSelectorItem,
|
||||
required this.chatSource,
|
||||
required this.level,
|
||||
required this.isDescendentOfSpace,
|
||||
required this.isSelectedSection,
|
||||
@@ -399,27 +408,34 @@ class ViewSelectorTreeItemInner extends StatelessWidget {
|
||||
this.onAdd,
|
||||
});
|
||||
|
||||
final ViewSelectorItem viewSelectorItem;
|
||||
final ChatSource chatSource;
|
||||
final int level;
|
||||
final bool isDescendentOfSpace;
|
||||
final bool isSelectedSection;
|
||||
final bool showCheckbox;
|
||||
final bool showSaveButton;
|
||||
final void Function(ViewSelectorItem)? onSelected;
|
||||
final void Function(ViewSelectorItem)? onAdd;
|
||||
final void Function(ChatSource)? onSelected;
|
||||
final void Function(ChatSource)? onAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => onSelected?.call(viewSelectorItem),
|
||||
onTap: () {
|
||||
if (!isSelectedSection) {
|
||||
onSelected?.call(chatSource);
|
||||
}
|
||||
},
|
||||
child: FlowyHover(
|
||||
cursor: isSelectedSection ? SystemMouseCursors.basic : null,
|
||||
style: HoverStyle(
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
hoverColor: isSelectedSection
|
||||
? Colors.transparent
|
||||
: AFThemeExtension.of(context).lightGreyHover,
|
||||
),
|
||||
builder: (context, onHover) {
|
||||
final isSaveButtonVisible =
|
||||
showSaveButton && !viewSelectorItem.view.isSpace;
|
||||
showSaveButton && !chatSource.view.isSpace;
|
||||
final isAddButtonVisible = onAdd != null;
|
||||
return Row(
|
||||
children: [
|
||||
@@ -427,26 +443,26 @@ class ViewSelectorTreeItemInner extends StatelessWidget {
|
||||
HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)),
|
||||
// builds the >, ^ or · button
|
||||
ToggleIsExpandedButton(
|
||||
viewSelectorItem: viewSelectorItem,
|
||||
chatSource: chatSource,
|
||||
isSelectedSection: isSelectedSection,
|
||||
),
|
||||
const HSpace(2.0),
|
||||
// checkbox
|
||||
if (!viewSelectorItem.view.isSpace && showCheckbox) ...[
|
||||
if (!chatSource.view.isSpace && showCheckbox) ...[
|
||||
SourceSelectedStatusCheckbox(
|
||||
viewSelectorItem: viewSelectorItem,
|
||||
chatSource: chatSource,
|
||||
),
|
||||
const HSpace(4.0),
|
||||
],
|
||||
// icon
|
||||
MentionViewIcon(
|
||||
view: viewSelectorItem.view,
|
||||
view: chatSource.view,
|
||||
),
|
||||
const HSpace(6.0),
|
||||
// title
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
viewSelectorItem.view.nameOrDefault,
|
||||
chatSource.view.nameOrDefault,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14.0,
|
||||
figmaLineHeight: 18.0,
|
||||
@@ -463,7 +479,7 @@ class ViewSelectorTreeItemInner extends StatelessWidget {
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onPressed: () => onSelected?.call(viewSelectorItem),
|
||||
onPressed: () => onSelected?.call(chatSource),
|
||||
),
|
||||
if (isSaveButtonVisible && isAddButtonVisible)
|
||||
const HSpace(4.0),
|
||||
@@ -476,7 +492,7 @@ class ViewSelectorTreeItemInner extends StatelessWidget {
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onPressed: () => onAdd?.call(viewSelectorItem),
|
||||
onPressed: () => onAdd?.call(chatSource),
|
||||
),
|
||||
const HSpace(4.0),
|
||||
],
|
||||
@@ -491,30 +507,27 @@ class ViewSelectorTreeItemInner extends StatelessWidget {
|
||||
class ToggleIsExpandedButton extends StatelessWidget {
|
||||
const ToggleIsExpandedButton({
|
||||
super.key,
|
||||
required this.viewSelectorItem,
|
||||
required this.chatSource,
|
||||
required this.isSelectedSection,
|
||||
});
|
||||
|
||||
final ViewSelectorItem viewSelectorItem;
|
||||
final ChatSource chatSource;
|
||||
final bool isSelectedSection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isReferencedDatabaseView(
|
||||
viewSelectorItem.view,
|
||||
viewSelectorItem.parentView,
|
||||
)) {
|
||||
if (isReferencedDatabaseView(chatSource.view, chatSource.parentView)) {
|
||||
return const _DotIconWidget();
|
||||
}
|
||||
|
||||
if (viewSelectorItem.children.isEmpty) {
|
||||
if (chatSource.children.isEmpty) {
|
||||
return const SizedBox.square(dimension: 16.0);
|
||||
}
|
||||
|
||||
return FlowyHover(
|
||||
child: GestureDetector(
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: viewSelectorItem.isExpandedNotifier,
|
||||
valueListenable: chatSource.isExpandedNotifier,
|
||||
builder: (context, value, _) => FlowySvg(
|
||||
value
|
||||
? FlowySvgs.view_item_expand_s
|
||||
@@ -523,8 +536,8 @@ class ToggleIsExpandedButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onTap: () => context
|
||||
.read<ViewSelectorCubit>()
|
||||
.toggleIsExpanded(viewSelectorItem, isSelectedSection),
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleIsExpanded(chatSource, isSelectedSection),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -552,20 +565,20 @@ class _DotIconWidget extends StatelessWidget {
|
||||
class SourceSelectedStatusCheckbox extends StatelessWidget {
|
||||
const SourceSelectedStatusCheckbox({
|
||||
super.key,
|
||||
required this.viewSelectorItem,
|
||||
required this.chatSource,
|
||||
});
|
||||
|
||||
final ViewSelectorItem viewSelectorItem;
|
||||
final ChatSource chatSource;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: viewSelectorItem.selectedStatusNotifier,
|
||||
valueListenable: chatSource.selectedStatusNotifier,
|
||||
builder: (context, selectedStatus, _) => FlowySvg(
|
||||
switch (selectedStatus) {
|
||||
ViewSelectedStatus.unselected => FlowySvgs.uncheck_s,
|
||||
ViewSelectedStatus.selected => FlowySvgs.check_filled_s,
|
||||
ViewSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
|
||||
SourceSelectedStatus.unselected => FlowySvgs.uncheck_s,
|
||||
SourceSelectedStatus.selected => FlowySvgs.check_filled_s,
|
||||
SourceSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
|
||||
},
|
||||
size: const Size.square(18.0),
|
||||
blendMode: null,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/single_child_widget.dart';
|
||||
|
||||
class ViewSelector extends StatelessWidget {
|
||||
const ViewSelector({
|
||||
super.key,
|
||||
required this.viewSelectorCubit,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final SingleChildWidget viewSelectorCubit;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
|
||||
final userProfile = userWorkspaceBloc.state.userProfile;
|
||||
final workspaceId =
|
||||
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) {
|
||||
return SpaceBloc(
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
)..add(const SpaceEvent.initial(openFirstPage: false));
|
||||
},
|
||||
),
|
||||
viewSelectorCubit,
|
||||
],
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ class MobileSearchAskAiEntrance extends StatelessWidget {
|
||||
onTap: () {
|
||||
context
|
||||
.read<CommandPaletteBloc?>()
|
||||
?.add(CommandPaletteEvent.goingToAskAI());
|
||||
?.add(CommandPaletteEvent.gointToAskAI());
|
||||
mobileCreateNewAIChatNotifier.value =
|
||||
mobileCreateNewAIChatNotifier.value + 1;
|
||||
},
|
||||
|
||||
+4
-3
@@ -150,9 +150,10 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 10, 8, 10),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.search_clear_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: const Size.square(20),
|
||||
FlowySvgs.clear_s,
|
||||
blendMode: null,
|
||||
color: theme.iconColorScheme.tertiary,
|
||||
size: Size.square(20),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
@@ -21,8 +18,6 @@ class ViewAncestorBloc extends Bloc<ViewAncestorEvent, ViewAncestorState> {
|
||||
(event, emit) async {
|
||||
await event.map(
|
||||
initial: (e) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
final ancester = await _cache.getAncestor(
|
||||
state.viewId,
|
||||
onRefresh: (ancestor) {
|
||||
@@ -58,46 +53,3 @@ class ViewAncestorState with _$ViewAncestorState {
|
||||
ancestor: ViewAncestor.empty(),
|
||||
);
|
||||
}
|
||||
|
||||
extension ViewAncestorTextExtension on ViewAncestorState {
|
||||
Widget buildPath(BuildContext context, {TextStyle? style}) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final ancestors = ancestor.ancestors;
|
||||
final textStyle = style ??
|
||||
theme.textStyle.caption.standard(color: theme.textColorScheme.tertiary);
|
||||
final textHeight = (textStyle.fontSize ?? 0.0) * (textStyle.height ?? 1.0);
|
||||
if (isLoading) return VSpace(textHeight);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
final List<String> displayPath = ancestors.map((e) => e.name).toList();
|
||||
if (displayPath.isEmpty) return const SizedBox.shrink();
|
||||
TextPainter textPainter =
|
||||
_buildTextPainter(displayPath.join(' / '), textStyle);
|
||||
textPainter.layout(maxWidth: constrains.maxWidth);
|
||||
if (textPainter.didExceedMaxLines && displayPath.length > 2) {
|
||||
displayPath.removeAt(displayPath.length - 2);
|
||||
displayPath.insert(displayPath.length - 1, '...');
|
||||
}
|
||||
textPainter = _buildTextPainter(displayPath.join(' / '), textStyle);
|
||||
textPainter.layout(maxWidth: constrains.maxWidth);
|
||||
while (textPainter.didExceedMaxLines && displayPath.length > 3) {
|
||||
displayPath.removeAt(displayPath.length - 2);
|
||||
textPainter = _buildTextPainter(displayPath.join(' / '), textStyle);
|
||||
textPainter.layout(maxWidth: constrains.maxWidth);
|
||||
}
|
||||
return Text(
|
||||
displayPath.join(' / '),
|
||||
style: textStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
TextPainter _buildTextPainter(String text, TextStyle style) => TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
}
|
||||
@@ -11,4 +11,5 @@ export 'chat_message_service.dart';
|
||||
export 'chat_message_stream.dart';
|
||||
export 'chat_notification.dart';
|
||||
export 'chat_select_message_bloc.dart';
|
||||
export 'chat_select_sources_cubit.dart';
|
||||
export 'chat_user_message_bloc.dart';
|
||||
@@ -117,14 +117,6 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_OnAIFollowUp>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: MessageState.aiFollowUp(event.followUpData),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _initializeStreamListener() {
|
||||
@@ -145,9 +137,6 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
},
|
||||
onLocalAIInitializing: () =>
|
||||
_safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()),
|
||||
onAIFollowUp: (data) {
|
||||
_safeAdd(ChatAIMessageEvent.onAIFollowUp(data));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -185,9 +174,6 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
|
||||
const factory ChatAIMessageEvent.receiveMetadata(
|
||||
MetadataCollection metadata,
|
||||
) = _ReceiveMetadata;
|
||||
const factory ChatAIMessageEvent.onAIFollowUp(
|
||||
AIFollowUpData followUpData,
|
||||
) = _OnAIFollowUp;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -223,6 +209,4 @@ class MessageState with _$MessageState {
|
||||
const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing;
|
||||
const factory MessageState.ready() = _Ready;
|
||||
const factory MessageState.loading() = _Loading;
|
||||
const factory MessageState.aiFollowUp(AIFollowUpData followUpData) =
|
||||
_AIFollowUp;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/util/int64_extension.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
@@ -13,291 +15,267 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
import 'chat_entity.dart';
|
||||
import 'chat_message_handler.dart';
|
||||
import 'chat_message_listener.dart';
|
||||
import 'chat_message_service.dart';
|
||||
import 'chat_message_stream.dart';
|
||||
import 'chat_settings_manager.dart';
|
||||
import 'chat_stream_manager.dart';
|
||||
|
||||
part 'chat_bloc.freezed.dart';
|
||||
|
||||
/// Returns current Unix timestamp (seconds since epoch)
|
||||
int timestamp() {
|
||||
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
|
||||
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
ChatBloc({
|
||||
required this.chatId,
|
||||
required this.userId,
|
||||
}) : chatController = InMemoryChatController(),
|
||||
listener = ChatMessageListener(chatId: chatId),
|
||||
selectedSourcesNotifier = ValueNotifier([]),
|
||||
super(ChatState.initial()) {
|
||||
// Initialize managers
|
||||
_messageHandler = ChatMessageHandler(
|
||||
chatId: chatId,
|
||||
userId: userId,
|
||||
chatController: chatController,
|
||||
);
|
||||
|
||||
_streamManager = ChatStreamManager(chatId);
|
||||
_settingsManager = ChatSettingsManager(chatId: chatId);
|
||||
|
||||
_startListening();
|
||||
_dispatch();
|
||||
_loadMessages();
|
||||
_loadSettings();
|
||||
_loadSetting();
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final String userId;
|
||||
final ChatMessageListener listener;
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final ChatController chatController;
|
||||
|
||||
// Managers
|
||||
late final ChatMessageHandler _messageHandler;
|
||||
late final ChatStreamManager _streamManager;
|
||||
late final ChatSettingsManager _settingsManager;
|
||||
/// The last streaming message id
|
||||
String answerStreamMessageId = '';
|
||||
String questionStreamMessageId = '';
|
||||
|
||||
ChatMessagePB? lastSentMessage;
|
||||
|
||||
/// Using a temporary map to associate the real message ID with the last streaming message ID.
|
||||
///
|
||||
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
|
||||
/// between the real message ID and the last streaming message ID, we use this map to store the associations.
|
||||
///
|
||||
/// This map will be updated when receiving a message from the server and its author type
|
||||
/// is 3 (AI response).
|
||||
final HashMap<String, String> temporaryMessageIDMap = HashMap();
|
||||
|
||||
bool isLoadingPreviousMessages = false;
|
||||
bool hasMorePreviousMessages = true;
|
||||
AnswerStream? answerStream;
|
||||
bool isFetchingRelatedQuestions = false;
|
||||
bool shouldFetchRelatedQuestions = false;
|
||||
|
||||
// Accessor for selected sources
|
||||
ValueNotifier<List<String>> get selectedSourcesNotifier =>
|
||||
_settingsManager.selectedSourcesNotifier;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
// Safely dispose all resources
|
||||
await _streamManager.dispose();
|
||||
await answerStream?.dispose();
|
||||
await listener.stop();
|
||||
|
||||
final request = ViewIdPB(value: chatId);
|
||||
unawaited(FolderEventCloseView(request).send());
|
||||
|
||||
_settingsManager.dispose();
|
||||
selectedSourcesNotifier.dispose();
|
||||
chatController.dispose();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
void _dispatch() {
|
||||
on<ChatEvent>((event, emit) async {
|
||||
await event.when(
|
||||
// Chat settings
|
||||
didReceiveChatSettings: (settings) async =>
|
||||
_handleChatSettings(settings),
|
||||
updateSelectedSources: (selectedSourcesIds) async =>
|
||||
_handleUpdateSources(selectedSourcesIds),
|
||||
on<ChatEvent>(
|
||||
(event, emit) async {
|
||||
await event.when(
|
||||
// Loading messages
|
||||
didLoadLatestMessages: (List<Message> messages) async {
|
||||
for (final message in messages) {
|
||||
await chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
// Message loading
|
||||
didLoadLatestMessages: (messages) async =>
|
||||
_handleLatestMessages(messages, emit),
|
||||
loadPreviousMessages: () async => _loadPreviousMessagesIfNeeded(),
|
||||
didLoadPreviousMessages: (messages, hasMore) async =>
|
||||
_handlePreviousMessages(messages, hasMore),
|
||||
switch (state.loadingState) {
|
||||
case LoadChatMessageStatus.loading
|
||||
when chatController.messages.isEmpty:
|
||||
emit(
|
||||
state.copyWith(
|
||||
loadingState: LoadChatMessageStatus.loadingRemote,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case LoadChatMessageStatus.loading:
|
||||
case LoadChatMessageStatus.loadingRemote:
|
||||
emit(
|
||||
state.copyWith(loadingState: LoadChatMessageStatus.ready),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
loadPreviousMessages: () {
|
||||
if (isLoadingPreviousMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Message handling
|
||||
receiveMessage: (message) async => _handleReceiveMessage(message),
|
||||
final oldestMessage = _getOldestMessage();
|
||||
|
||||
// Sending messages
|
||||
sendMessage: (message, format, metadata) async =>
|
||||
_handleSendMessage(message, format, metadata, emit),
|
||||
finishSending: () async => emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.streamingAnswer,
|
||||
),
|
||||
),
|
||||
if (oldestMessage != null) {
|
||||
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
|
||||
if (oldestMessageId == null) {
|
||||
Log.error("Failed to parse message_id: ${oldestMessage.id}");
|
||||
return;
|
||||
}
|
||||
isLoadingPreviousMessages = true;
|
||||
_loadPreviousMessages(oldestMessageId);
|
||||
}
|
||||
},
|
||||
didLoadPreviousMessages: (messages, hasMore) {
|
||||
Log.debug("did load previous messages: ${messages.length}");
|
||||
|
||||
// Stream control
|
||||
stopStream: () async => _handleStopStream(emit),
|
||||
failedSending: () async => _handleFailedSending(emit),
|
||||
for (final message in messages) {
|
||||
chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
// Answer regeneration
|
||||
regenerateAnswer: (id, format, model) async =>
|
||||
_handleRegenerateAnswer(id, format, model, emit),
|
||||
isLoadingPreviousMessages = false;
|
||||
hasMorePreviousMessages = hasMore;
|
||||
},
|
||||
didFinishAnswerStream: () {
|
||||
emit(
|
||||
state.copyWith(promptResponseState: PromptResponseState.ready),
|
||||
);
|
||||
},
|
||||
didReceiveRelatedQuestions: (List<String> questions) {
|
||||
if (questions.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Streaming completion
|
||||
didFinishAnswerStream: () async => emit(
|
||||
state.copyWith(promptResponseState: PromptResponseState.ready),
|
||||
),
|
||||
final metadata = {
|
||||
onetimeShotType: OnetimeShotType.relatedQuestion,
|
||||
'questions': questions,
|
||||
};
|
||||
|
||||
// Related questions
|
||||
didReceiveRelatedQuestions: (questions) async =>
|
||||
_handleRelatedQuestions(questions),
|
||||
final createdAt = DateTime.now();
|
||||
|
||||
// Message management
|
||||
deleteMessage: (message) async => chatController.remove(message),
|
||||
final message = TextMessage(
|
||||
id: "related_question_$createdAt",
|
||||
text: '',
|
||||
metadata: metadata,
|
||||
author: const User(id: systemUserId),
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
// AI follow-up
|
||||
onAIFollowUp: (followUpData) async {
|
||||
shouldFetchRelatedQuestions =
|
||||
followUpData.shouldGenerateRelatedQuestion;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
chatController.insert(message);
|
||||
},
|
||||
receiveMessage: (Message message) {
|
||||
final oldMessage = chatController.messages
|
||||
.firstWhereOrNull((m) => m.id == message.id);
|
||||
if (oldMessage == null) {
|
||||
chatController.insert(message);
|
||||
} else {
|
||||
chatController.update(oldMessage, message);
|
||||
}
|
||||
},
|
||||
sendMessage: (
|
||||
String message,
|
||||
PredefinedFormat? format,
|
||||
Map<String, dynamic>? metadata,
|
||||
) {
|
||||
_clearErrorMessages(emit);
|
||||
_clearRelatedQuestions();
|
||||
_startStreamingMessage(message, format, metadata);
|
||||
lastSentMessage = null;
|
||||
|
||||
// Chat settings handlers
|
||||
void _handleChatSettings(ChatSettingsPB settings) {
|
||||
_settingsManager.selectedSourcesNotifier.value = settings.ragIds;
|
||||
}
|
||||
isFetchingRelatedQuestions = false;
|
||||
shouldFetchRelatedQuestions =
|
||||
format == null || format.imageFormat.hasText;
|
||||
|
||||
Future<void> _handleUpdateSources(List<String> selectedSourcesIds) async {
|
||||
await _settingsManager.updateSelectedSources(selectedSourcesIds);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
},
|
||||
finishSending: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.streamingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
stopStream: () async {
|
||||
if (answerStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Message loading handlers
|
||||
Future<void> _handleLatestMessages(
|
||||
List<Message> messages,
|
||||
Emitter<ChatState> emit,
|
||||
) async {
|
||||
for (final message in messages) {
|
||||
await chatController.insert(message, index: 0);
|
||||
}
|
||||
// tell backend to stop
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await AIEventStopStream(payload).send();
|
||||
|
||||
// Check if emit is still valid after async operations
|
||||
if (emit.isDone) {
|
||||
return;
|
||||
}
|
||||
// allow user input
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
);
|
||||
|
||||
switch (state.loadingState) {
|
||||
case LoadChatMessageStatus.loading when chatController.messages.isEmpty:
|
||||
emit(state.copyWith(loadingState: LoadChatMessageStatus.loadingRemote));
|
||||
break;
|
||||
case LoadChatMessageStatus.loading:
|
||||
case LoadChatMessageStatus.loadingRemote:
|
||||
emit(state.copyWith(loadingState: LoadChatMessageStatus.ready));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
// no need to remove old message if stream has started already
|
||||
if (answerStream!.hasStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
void _handlePreviousMessages(List<Message> messages, bool hasMore) {
|
||||
for (final message in messages) {
|
||||
chatController.insert(message, index: 0);
|
||||
}
|
||||
// remove the non-started message from the list
|
||||
final message = chatController.messages.lastWhereOrNull(
|
||||
(e) => e.id == answerStreamMessageId,
|
||||
);
|
||||
if (message != null) {
|
||||
await chatController.remove(message);
|
||||
}
|
||||
|
||||
isLoadingPreviousMessages = false;
|
||||
hasMorePreviousMessages = hasMore;
|
||||
}
|
||||
// set answer stream to null
|
||||
await answerStream?.dispose();
|
||||
answerStream = null;
|
||||
answerStreamMessageId = '';
|
||||
},
|
||||
failedSending: () {
|
||||
final lastMessage = chatController.messages.lastOrNull;
|
||||
if (lastMessage != null) {
|
||||
chatController.remove(lastMessage);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
);
|
||||
},
|
||||
regenerateAnswer: (id, format, model) {
|
||||
_clearRelatedQuestions();
|
||||
_regenerateAnswer(id, format, model);
|
||||
lastSentMessage = null;
|
||||
|
||||
// Message handling
|
||||
void _handleReceiveMessage(Message message) {
|
||||
final oldMessage =
|
||||
chatController.messages.firstWhereOrNull((m) => m.id == message.id);
|
||||
if (oldMessage == null) {
|
||||
chatController.insert(message);
|
||||
} else {
|
||||
chatController.update(oldMessage, message);
|
||||
}
|
||||
}
|
||||
isFetchingRelatedQuestions = false;
|
||||
shouldFetchRelatedQuestions = false;
|
||||
|
||||
// Message sending handlers
|
||||
void _handleSendMessage(
|
||||
String message,
|
||||
PredefinedFormat? format,
|
||||
Map<String, dynamic>? metadata,
|
||||
Emitter<ChatState> emit,
|
||||
) {
|
||||
_messageHandler.clearErrorMessages();
|
||||
emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
},
|
||||
didReceiveChatSettings: (settings) {
|
||||
selectedSourcesNotifier.value = settings.ragIds;
|
||||
},
|
||||
updateSelectedSources: (selectedSourcesIds) async {
|
||||
selectedSourcesNotifier.value = [...selectedSourcesIds];
|
||||
|
||||
_messageHandler.clearRelatedQuestions();
|
||||
_startStreamingMessage(message, format, metadata);
|
||||
lastSentMessage = null;
|
||||
|
||||
isFetchingRelatedQuestions = false;
|
||||
shouldFetchRelatedQuestions = format == null || format.imageFormat.hasText;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
final payload = UpdateChatSettingsPB(
|
||||
chatId: ChatId(value: chatId),
|
||||
ragIds: selectedSourcesIds,
|
||||
);
|
||||
await AIEventUpdateChatSettings(payload)
|
||||
.send()
|
||||
.onFailure(Log.error);
|
||||
},
|
||||
deleteMessage: (mesesage) async {
|
||||
await chatController.remove(mesesage);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Stream control handlers
|
||||
Future<void> _handleStopStream(Emitter<ChatState> emit) async {
|
||||
await _streamManager.stopStream();
|
||||
|
||||
// Allow user input
|
||||
emit(state.copyWith(promptResponseState: PromptResponseState.ready));
|
||||
|
||||
// No need to remove old message if stream has started already
|
||||
if (_streamManager.hasAnswerStreamStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the non-started message from the list
|
||||
final message = chatController.messages.lastWhereOrNull(
|
||||
(e) => e.id == _messageHandler.answerStreamMessageId,
|
||||
);
|
||||
if (message != null) {
|
||||
await chatController.remove(message);
|
||||
}
|
||||
|
||||
await _streamManager.disposeAnswerStream();
|
||||
}
|
||||
|
||||
void _handleFailedSending(Emitter<ChatState> emit) {
|
||||
final lastMessage = chatController.messages.lastOrNull;
|
||||
if (lastMessage != null) {
|
||||
chatController.remove(lastMessage);
|
||||
}
|
||||
emit(state.copyWith(promptResponseState: PromptResponseState.ready));
|
||||
}
|
||||
|
||||
// Answer regeneration handler
|
||||
void _handleRegenerateAnswer(
|
||||
String id,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
Emitter<ChatState> emit,
|
||||
) {
|
||||
_messageHandler.clearRelatedQuestions();
|
||||
_regenerateAnswer(id, format, model);
|
||||
lastSentMessage = null;
|
||||
|
||||
isFetchingRelatedQuestions = false;
|
||||
shouldFetchRelatedQuestions = false;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Related questions handler
|
||||
void _handleRelatedQuestions(List<String> questions) {
|
||||
if (questions.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final metadata = {
|
||||
onetimeShotType: OnetimeShotType.relatedQuestion,
|
||||
'questions': questions,
|
||||
};
|
||||
|
||||
final createdAt = DateTime.now();
|
||||
final message = TextMessage(
|
||||
id: "related_question_$createdAt",
|
||||
text: '',
|
||||
metadata: metadata,
|
||||
author: const User(id: systemUserId),
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
chatController.insert(message);
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
listener.start(
|
||||
chatMessageCallback: (pb) {
|
||||
@@ -305,8 +283,25 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
return;
|
||||
}
|
||||
|
||||
_messageHandler.processReceivedMessage(pb);
|
||||
final message = _messageHandler.createTextMessage(pb);
|
||||
// 3 mean message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap.putIfAbsent(
|
||||
pb.messageId.toString(),
|
||||
() => answerStreamMessageId,
|
||||
);
|
||||
answerStreamMessageId = '';
|
||||
}
|
||||
|
||||
// 1 mean message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap.putIfAbsent(
|
||||
pb.messageId.toString(),
|
||||
() => questionStreamMessageId,
|
||||
);
|
||||
questionStreamMessageId = '';
|
||||
}
|
||||
|
||||
final message = _createTextMessage(pb);
|
||||
add(ChatEvent.receiveMessage(message));
|
||||
},
|
||||
chatErrorMessageCallback: (err) {
|
||||
@@ -317,15 +312,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
},
|
||||
latestMessageCallback: (list) {
|
||||
if (!isClosed) {
|
||||
final messages =
|
||||
list.messages.map(_messageHandler.createTextMessage).toList();
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
add(ChatEvent.didLoadLatestMessages(messages));
|
||||
}
|
||||
},
|
||||
prevMessageCallback: (list) {
|
||||
if (!isClosed) {
|
||||
final messages =
|
||||
list.messages.map(_messageHandler.createTextMessage).toList();
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
|
||||
}
|
||||
},
|
||||
@@ -335,55 +328,52 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
}
|
||||
|
||||
add(const ChatEvent.didFinishAnswerStream());
|
||||
unawaited(_fetchRelatedQuestionsIfNeeded());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Split method to handle related questions
|
||||
Future<void> _fetchRelatedQuestionsIfNeeded() async {
|
||||
// Don't fetch related questions if conditions aren't met
|
||||
if (_streamManager.answerStream == null ||
|
||||
lastSentMessage == null ||
|
||||
!shouldFetchRelatedQuestions) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: lastSentMessage!.messageId,
|
||||
);
|
||||
|
||||
isFetchingRelatedQuestions = true;
|
||||
await AIEventGetRelatedQuestion(payload).send().fold(
|
||||
(list) {
|
||||
// while fetching related questions, the user might enter a new
|
||||
// question or regenerate a previous response. In such cases, don't
|
||||
// display the relatedQuestions
|
||||
if (!isClosed && isFetchingRelatedQuestions) {
|
||||
add(
|
||||
ChatEvent.didReceiveRelatedQuestions(
|
||||
list.items.map((e) => e.content).toList(),
|
||||
),
|
||||
);
|
||||
isFetchingRelatedQuestions = false;
|
||||
// The answer stream will bet set to null after the streaming has
|
||||
// finished, got cancelled, or errored. In this case, don't retrieve
|
||||
// related questions.
|
||||
if (answerStream == null ||
|
||||
lastSentMessage == null ||
|
||||
!shouldFetchRelatedQuestions) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: lastSentMessage!.messageId,
|
||||
);
|
||||
|
||||
isFetchingRelatedQuestions = true;
|
||||
await AIEventGetRelatedQuestion(payload).send().fold(
|
||||
(list) {
|
||||
// while fetching related questions, the user might enter a new
|
||||
// question or regenerate a previous response. In such cases, don't
|
||||
// display the relatedQuestions
|
||||
if (!isClosed && isFetchingRelatedQuestions) {
|
||||
add(
|
||||
ChatEvent.didReceiveRelatedQuestions(
|
||||
list.items.map((e) => e.content).toList(),
|
||||
),
|
||||
);
|
||||
isFetchingRelatedQuestions = false;
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to get related questions: $err"),
|
||||
);
|
||||
},
|
||||
(err) => Log.error("Failed to get related questions: $err"),
|
||||
);
|
||||
}
|
||||
|
||||
void _loadSettings() async {
|
||||
void _loadSetting() async {
|
||||
final getChatSettingsPayload =
|
||||
AIEventGetChatSettings(ChatId(value: chatId));
|
||||
|
||||
await getChatSettingsPayload.send().fold(
|
||||
(settings) {
|
||||
if (!isClosed) {
|
||||
add(ChatEvent.didReceiveChatSettings(settings: settings));
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to load chat settings: $err"),
|
||||
Log.error,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -392,12 +382,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
chatId: chatId,
|
||||
limit: Int64(10),
|
||||
);
|
||||
|
||||
await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
|
||||
(list) {
|
||||
if (!isClosed) {
|
||||
final messages =
|
||||
list.messages.map(_messageHandler.createTextMessage).toList();
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
add(ChatEvent.didLoadLatestMessages(messages));
|
||||
}
|
||||
},
|
||||
@@ -405,22 +393,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _loadPreviousMessagesIfNeeded() {
|
||||
if (isLoadingPreviousMessages) {
|
||||
return;
|
||||
}
|
||||
bool _isOneTimeMessage(Message message) {
|
||||
return message.metadata != null &&
|
||||
message.metadata!.containsKey(onetimeShotType);
|
||||
}
|
||||
|
||||
final oldestMessage = _messageHandler.getOldestMessage();
|
||||
|
||||
if (oldestMessage != null) {
|
||||
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
|
||||
if (oldestMessageId == null) {
|
||||
Log.error("Failed to parse message_id: ${oldestMessage.id}");
|
||||
return;
|
||||
}
|
||||
isLoadingPreviousMessages = true;
|
||||
_loadPreviousMessages(oldestMessageId);
|
||||
}
|
||||
/// get the last message that is not a one-time message
|
||||
Message? _getOldestMessage() {
|
||||
return chatController.messages
|
||||
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
|
||||
}
|
||||
|
||||
void _loadPreviousMessages(Int64? beforeMessageId) {
|
||||
@@ -437,23 +418,36 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
PredefinedFormat? format,
|
||||
Map<String, dynamic>? metadata,
|
||||
) async {
|
||||
// Prepare streams
|
||||
await _streamManager.prepareStreams();
|
||||
await answerStream?.dispose();
|
||||
|
||||
// Create and add question message
|
||||
final questionStreamMessage = _messageHandler.createQuestionStreamMessage(
|
||||
_streamManager.questionStream!,
|
||||
answerStream = AnswerStream();
|
||||
final questionStream = QuestionStream();
|
||||
|
||||
// add a streaming question message
|
||||
final questionStreamMessage = _createQuestionStreamMessage(
|
||||
questionStream,
|
||||
metadata,
|
||||
);
|
||||
add(ChatEvent.receiveMessage(questionStreamMessage));
|
||||
|
||||
// Send stream request
|
||||
await _streamManager.sendStreamRequest(message, format).fold(
|
||||
final payload = StreamChatPayloadPB(
|
||||
chatId: chatId,
|
||||
message: message,
|
||||
messageType: ChatMessageTypePB.User,
|
||||
questionStreamPort: Int64(questionStream.nativePort),
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
//metadata: await metadataPBFromMetadata(metadata),
|
||||
);
|
||||
if (format != null) {
|
||||
payload.format = format.toPB();
|
||||
}
|
||||
|
||||
// stream the question to the server
|
||||
await AIEventStreamMessage(payload).send().fold(
|
||||
(question) {
|
||||
if (!isClosed) {
|
||||
// Create and add answer stream message
|
||||
final streamAnswer = _messageHandler.createAnswerStreamMessage(
|
||||
stream: _streamManager.answerStream!,
|
||||
final streamAnswer = _createAnswerStreamMessage(
|
||||
stream: answerStream!,
|
||||
questionMessageId: question.messageId,
|
||||
fakeQuestionMessageId: questionStreamMessage.id,
|
||||
);
|
||||
@@ -487,42 +481,143 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
);
|
||||
}
|
||||
|
||||
// Refactored method to handle answer regeneration
|
||||
void _regenerateAnswer(
|
||||
String answerMessageIdString,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
) async {
|
||||
final id = _messageHandler.getEffectiveMessageId(answerMessageIdString);
|
||||
final id = temporaryMessageIDMap.entries
|
||||
.firstWhereOrNull((e) => e.value == answerMessageIdString)
|
||||
?.key ??
|
||||
answerMessageIdString;
|
||||
final answerMessageId = Int64.tryParseInt(id);
|
||||
|
||||
if (answerMessageId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _streamManager.prepareStreams();
|
||||
await _streamManager
|
||||
.sendRegenerateRequest(
|
||||
answerMessageId,
|
||||
format,
|
||||
model,
|
||||
)
|
||||
.fold(
|
||||
(_) {
|
||||
await answerStream?.dispose();
|
||||
answerStream = AnswerStream();
|
||||
|
||||
final payload = RegenerateResponsePB(
|
||||
chatId: chatId,
|
||||
answerMessageId: answerMessageId,
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
);
|
||||
if (format != null) {
|
||||
payload.format = format.toPB();
|
||||
}
|
||||
if (model != null) {
|
||||
payload.model = model;
|
||||
}
|
||||
|
||||
await AIEventRegenerateResponse(payload).send().fold(
|
||||
(success) {
|
||||
if (!isClosed) {
|
||||
final streamAnswer = _messageHandler
|
||||
.createAnswerStreamMessage(
|
||||
stream: _streamManager.answerStream!,
|
||||
questionMessageId: answerMessageId - 1,
|
||||
)
|
||||
.copyWith(id: answerMessageIdString);
|
||||
final streamAnswer = _createAnswerStreamMessage(
|
||||
stream: answerStream!,
|
||||
questionMessageId: answerMessageId - 1,
|
||||
).copyWith(id: answerMessageIdString);
|
||||
|
||||
add(ChatEvent.receiveMessage(streamAnswer));
|
||||
add(const ChatEvent.finishSending());
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to regenerate answer: ${err.msg}"),
|
||||
(err) => Log.error("Failed to send message: ${err.msg}"),
|
||||
);
|
||||
}
|
||||
|
||||
Message _createAnswerStreamMessage({
|
||||
required AnswerStream stream,
|
||||
required Int64 questionMessageId,
|
||||
String? fakeQuestionMessageId,
|
||||
}) {
|
||||
answerStreamMessageId = fakeQuestionMessageId == null
|
||||
? (questionMessageId + 1).toString()
|
||||
: "${fakeQuestionMessageId}_ans";
|
||||
|
||||
return TextMessage(
|
||||
id: answerStreamMessageId,
|
||||
text: '',
|
||||
author: User(id: "streamId:${nanoid()}"),
|
||||
metadata: {
|
||||
"$AnswerStream": stream,
|
||||
messageQuestionIdKey: questionMessageId,
|
||||
"chatId": chatId,
|
||||
},
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Message _createQuestionStreamMessage(
|
||||
QuestionStream stream,
|
||||
Map<String, dynamic>? sentMetadata,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
|
||||
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
|
||||
|
||||
return TextMessage(
|
||||
author: User(id: userId),
|
||||
metadata: {
|
||||
"$QuestionStream": stream,
|
||||
"chatId": chatId,
|
||||
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
|
||||
},
|
||||
id: questionStreamMessageId,
|
||||
createdAt: now,
|
||||
text: '',
|
||||
);
|
||||
}
|
||||
|
||||
Message _createTextMessage(ChatMessagePB message) {
|
||||
String messageId = message.messageId.toString();
|
||||
|
||||
/// If the message id is in the temporary map, we will use the previous fake message id
|
||||
if (temporaryMessageIDMap.containsKey(messageId)) {
|
||||
messageId = temporaryMessageIDMap[messageId]!;
|
||||
}
|
||||
final metadata = message.metadata == 'null' ? '[]' : message.metadata;
|
||||
|
||||
return TextMessage(
|
||||
author: User(id: message.authorId),
|
||||
id: messageId,
|
||||
text: message.content,
|
||||
createdAt: message.createdAt.toDateTime(),
|
||||
metadata: {
|
||||
messageRefSourceJsonStringKey: metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _clearErrorMessages(Emitter<ChatState> emit) {
|
||||
final errorMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.error,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in errorMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
|
||||
}
|
||||
|
||||
void _clearRelatedQuestions() {
|
||||
final relatedQuestionMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.relatedQuestion,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in relatedQuestionMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -573,9 +668,6 @@ class ChatEvent with _$ChatEvent {
|
||||
) = _DidReceiveRelatedQueston;
|
||||
|
||||
const factory ChatEvent.deleteMessage(Message message) = _DeleteMessage;
|
||||
|
||||
const factory ChatEvent.onAIFollowUp(AIFollowUpData followUpData) =
|
||||
_OnAIFollowUp;
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:appflowy/util/int64_extension.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
import 'chat_entity.dart';
|
||||
import 'chat_message_stream.dart';
|
||||
|
||||
/// Returns current Unix timestamp (seconds since epoch)
|
||||
int timestamp() {
|
||||
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
|
||||
/// Handles message creation and manipulation for the chat system
|
||||
class ChatMessageHandler {
|
||||
ChatMessageHandler({
|
||||
required this.chatId,
|
||||
required this.userId,
|
||||
required this.chatController,
|
||||
});
|
||||
final String chatId;
|
||||
final String userId;
|
||||
final ChatController chatController;
|
||||
|
||||
/// Maps real message IDs to temporary streaming message IDs
|
||||
final HashMap<String, String> _temporaryMessageIDMap = HashMap();
|
||||
|
||||
/// Gets the effective message ID from the temporary map
|
||||
String getEffectiveMessageId(String messageId) {
|
||||
return _temporaryMessageIDMap.entries
|
||||
.firstWhereOrNull((entry) => entry.value == messageId)
|
||||
?.key ??
|
||||
messageId;
|
||||
}
|
||||
|
||||
String answerStreamMessageId = '';
|
||||
String questionStreamMessageId = '';
|
||||
|
||||
/// Create a message from ChatMessagePB object
|
||||
Message createTextMessage(ChatMessagePB message) {
|
||||
String messageId = message.messageId.toString();
|
||||
|
||||
/// If the message id is in the temporary map, we will use the previous fake message id
|
||||
if (_temporaryMessageIDMap.containsKey(messageId)) {
|
||||
messageId = _temporaryMessageIDMap[messageId]!;
|
||||
}
|
||||
final metadata = message.metadata == 'null' ? '[]' : message.metadata;
|
||||
|
||||
return TextMessage(
|
||||
author: User(id: message.authorId),
|
||||
id: messageId,
|
||||
text: message.content,
|
||||
createdAt: message.createdAt.toDateTime(),
|
||||
metadata: {
|
||||
messageRefSourceJsonStringKey: metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a streaming answer message
|
||||
Message createAnswerStreamMessage({
|
||||
required AnswerStream stream,
|
||||
required Int64 questionMessageId,
|
||||
String? fakeQuestionMessageId,
|
||||
}) {
|
||||
answerStreamMessageId = fakeQuestionMessageId == null
|
||||
? (questionMessageId + 1).toString()
|
||||
: "${fakeQuestionMessageId}_ans";
|
||||
|
||||
return TextMessage(
|
||||
id: answerStreamMessageId,
|
||||
text: '',
|
||||
author: User(id: "streamId:${nanoid()}"),
|
||||
metadata: {
|
||||
"$AnswerStream": stream,
|
||||
messageQuestionIdKey: questionMessageId,
|
||||
"chatId": chatId,
|
||||
},
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a streaming question message
|
||||
Message createQuestionStreamMessage(
|
||||
QuestionStream stream,
|
||||
Map<String, dynamic>? sentMetadata,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
questionStreamMessageId = timestamp().toString();
|
||||
|
||||
return TextMessage(
|
||||
author: User(id: userId),
|
||||
metadata: {
|
||||
"$QuestionStream": stream,
|
||||
"chatId": chatId,
|
||||
if (sentMetadata != null)
|
||||
messageChatFileListKey: sentMetadata[messageChatFileListKey],
|
||||
},
|
||||
id: questionStreamMessageId,
|
||||
createdAt: now,
|
||||
text: '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear error messages from the chat
|
||||
void clearErrorMessages() {
|
||||
final errorMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.error,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in errorMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear related questions from the chat
|
||||
void clearRelatedQuestions() {
|
||||
final relatedQuestionMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.relatedQuestion,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in relatedQuestionMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a message is a one-time message
|
||||
bool isOneTimeMessage(Message message) {
|
||||
return message.metadata != null &&
|
||||
message.metadata!.containsKey(onetimeShotType);
|
||||
}
|
||||
|
||||
/// Get the oldest message that is not a one-time message
|
||||
Message? getOldestMessage() {
|
||||
return chatController.messages
|
||||
.firstWhereOrNull((message) => !isOneTimeMessage(message));
|
||||
}
|
||||
|
||||
/// Add a message to the temporary ID map when receiving from server
|
||||
void processReceivedMessage(ChatMessagePB pb) {
|
||||
// 3 means message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
_temporaryMessageIDMap.putIfAbsent(
|
||||
pb.messageId.toString(),
|
||||
() => answerStreamMessageId,
|
||||
);
|
||||
answerStreamMessageId = '';
|
||||
}
|
||||
|
||||
// 1 means message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
_temporaryMessageIDMap.putIfAbsent(
|
||||
pb.messageId.toString(),
|
||||
() => questionStreamMessageId,
|
||||
);
|
||||
questionStreamMessageId = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:appflowy/ai/service/ai_entities.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'chat_message_stream.g.dart';
|
||||
|
||||
/// A stream that receives answer events from an isolate or external process.
|
||||
/// It caches events that might occur before a listener is attached.
|
||||
@@ -42,7 +37,7 @@ class AnswerStream {
|
||||
void Function()? _onAIImageResponseLimit;
|
||||
void Function(String message)? _onAIMaxRequired;
|
||||
void Function(MetadataCollection metadata)? _onMetadata;
|
||||
void Function(AIFollowUpData)? _onAIFollowUp;
|
||||
|
||||
// Caches for events that occur before listen() is called.
|
||||
final List<String> _pendingAIMaxRequiredEvents = [];
|
||||
bool _pendingLocalAINotReady = false;
|
||||
@@ -93,15 +88,6 @@ class AnswerStream {
|
||||
} else {
|
||||
_pendingLocalAINotReady = true;
|
||||
}
|
||||
} else if (event.startsWith(AIStreamEventPrefix.aiFollowUp)) {
|
||||
final s = event.substring(AIStreamEventPrefix.aiFollowUp.length);
|
||||
try {
|
||||
final dynamic jsonData = jsonDecode(s);
|
||||
final data = AIFollowUpData.fromJson(jsonData);
|
||||
_onAIFollowUp?.call(data);
|
||||
} catch (e) {
|
||||
Log.error('Error deserializing AIFollowUp data: $e\nRaw JSON: $s');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +114,6 @@ class AnswerStream {
|
||||
void Function(String message)? onAIMaxRequired,
|
||||
void Function(MetadataCollection metadata)? onMetadata,
|
||||
void Function()? onLocalAIInitializing,
|
||||
void Function(AIFollowUpData)? onAIFollowUp,
|
||||
}) {
|
||||
_onData = onData;
|
||||
_onStart = onStart;
|
||||
@@ -139,7 +124,7 @@ class AnswerStream {
|
||||
_onAIMaxRequired = onAIMaxRequired;
|
||||
_onMetadata = onMetadata;
|
||||
_onLocalAIInitializing = onLocalAIInitializing;
|
||||
_onAIFollowUp = onAIFollowUp;
|
||||
|
||||
// Flush pending AI_MAX_REQUIRED events.
|
||||
if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) {
|
||||
for (final msg in _pendingAIMaxRequiredEvents) {
|
||||
@@ -220,6 +205,7 @@ class QuestionStream {
|
||||
void Function()? _onIndexStart;
|
||||
void Function()? _onIndexEnd;
|
||||
void Function()? _onDone;
|
||||
|
||||
int get nativePort => _port.sendPort.nativePort;
|
||||
bool get hasStarted => _hasStarted;
|
||||
String? get error => _error;
|
||||
@@ -255,18 +241,3 @@ class QuestionStream {
|
||||
_onDone = onDone;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class AIFollowUpData {
|
||||
AIFollowUpData({
|
||||
required this.shouldGenerateRelatedQuestion,
|
||||
});
|
||||
|
||||
factory AIFollowUpData.fromJson(Map<String, dynamic> json) =>
|
||||
_$AIFollowUpDataFromJson(json);
|
||||
|
||||
@JsonKey(name: 'should_generate_related_question')
|
||||
final bool shouldGenerateRelatedQuestion;
|
||||
|
||||
Map<String, dynamic> toJson() => _$AIFollowUpDataToJson(this);
|
||||
}
|
||||
+123
-161
@@ -1,4 +1,3 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
@@ -7,12 +6,13 @@ import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'view_selector_cubit.freezed.dart';
|
||||
part 'chat_select_sources_cubit.freezed.dart';
|
||||
|
||||
enum ViewSelectedStatus {
|
||||
const int _kMaxSelectedParentPageCount = 3;
|
||||
|
||||
enum SourceSelectedStatus {
|
||||
unselected,
|
||||
selected,
|
||||
partiallySelected;
|
||||
@@ -22,46 +22,45 @@ enum ViewSelectedStatus {
|
||||
bool get isPartiallySelected => this == partiallySelected;
|
||||
}
|
||||
|
||||
class ViewSelectorItem {
|
||||
ViewSelectorItem({
|
||||
class ChatSource {
|
||||
ChatSource({
|
||||
required this.view,
|
||||
required this.parentView,
|
||||
required this.children,
|
||||
required bool isExpanded,
|
||||
required ViewSelectedStatus selectedStatus,
|
||||
required bool isDisabled,
|
||||
required SourceSelectedStatus selectedStatus,
|
||||
required IgnoreViewType ignoreStatus,
|
||||
}) : isExpandedNotifier = ValueNotifier(isExpanded),
|
||||
selectedStatusNotifier = ValueNotifier(selectedStatus),
|
||||
isDisabledNotifier = ValueNotifier(isDisabled);
|
||||
ignoreStatusNotifier = ValueNotifier(ignoreStatus);
|
||||
|
||||
final ViewPB view;
|
||||
final ViewPB? parentView;
|
||||
final List<ViewSelectorItem> children;
|
||||
final List<ChatSource> children;
|
||||
final ValueNotifier<bool> isExpandedNotifier;
|
||||
final ValueNotifier<bool> isDisabledNotifier;
|
||||
final ValueNotifier<ViewSelectedStatus> selectedStatusNotifier;
|
||||
final ValueNotifier<SourceSelectedStatus> selectedStatusNotifier;
|
||||
final ValueNotifier<IgnoreViewType> ignoreStatusNotifier;
|
||||
|
||||
bool get isExpanded => isExpandedNotifier.value;
|
||||
ViewSelectedStatus get selectedStatus => selectedStatusNotifier.value;
|
||||
bool get isDisabled => isDisabledNotifier.value;
|
||||
SourceSelectedStatus get selectedStatus => selectedStatusNotifier.value;
|
||||
IgnoreViewType get ignoreStatus => ignoreStatusNotifier.value;
|
||||
|
||||
void toggleIsExpanded() {
|
||||
isExpandedNotifier.value = !isExpandedNotifier.value;
|
||||
isExpandedNotifier.value = !isExpanded;
|
||||
}
|
||||
|
||||
ViewSelectorItem copy() {
|
||||
return ViewSelectorItem(
|
||||
ChatSource copy() {
|
||||
return ChatSource(
|
||||
view: view,
|
||||
parentView: parentView,
|
||||
children:
|
||||
children.map<ViewSelectorItem>((child) => child.copy()).toList(),
|
||||
isDisabled: isDisabledNotifier.value,
|
||||
isExpanded: isExpandedNotifier.value,
|
||||
selectedStatus: selectedStatusNotifier.value,
|
||||
children: children.map<ChatSource>((child) => child.copy()).toList(),
|
||||
ignoreStatus: ignoreStatus,
|
||||
isExpanded: isExpanded,
|
||||
selectedStatus: selectedStatus,
|
||||
);
|
||||
}
|
||||
|
||||
ViewSelectorItem? findChildBySourceId(String sourceId) {
|
||||
ChatSource? findChildBySourceId(String sourceId) {
|
||||
if (view.id == sourceId) {
|
||||
return this;
|
||||
}
|
||||
@@ -74,19 +73,20 @@ class ViewSelectorItem {
|
||||
return null;
|
||||
}
|
||||
|
||||
void setIsDisabledRecursive(bool Function(ViewSelectorItem) newIsDisabled) {
|
||||
isDisabledNotifier.value = newIsDisabled(this);
|
||||
void resetIgnoreViewTypeRecursive() {
|
||||
ignoreStatusNotifier.value = view.layout.isDocumentView
|
||||
? IgnoreViewType.none
|
||||
: IgnoreViewType.disable;
|
||||
|
||||
for (final child in children) {
|
||||
child.setIsDisabledRecursive(newIsDisabled);
|
||||
child.resetIgnoreViewTypeRecursive();
|
||||
}
|
||||
}
|
||||
|
||||
void setIsSelectedStatusRecursive(ViewSelectedStatus selectedStatus) {
|
||||
selectedStatusNotifier.value = selectedStatus;
|
||||
|
||||
void updateIgnoreViewTypeRecursive(IgnoreViewType newIgnoreViewType) {
|
||||
ignoreStatusNotifier.value = newIgnoreViewType;
|
||||
for (final child in children) {
|
||||
child.setIsSelectedStatusRecursive(selectedStatus);
|
||||
child.updateIgnoreViewTypeRecursive(newIgnoreViewType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,45 +96,36 @@ class ViewSelectorItem {
|
||||
}
|
||||
isExpandedNotifier.dispose();
|
||||
selectedStatusNotifier.dispose();
|
||||
isDisabledNotifier.dispose();
|
||||
ignoreStatusNotifier.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ViewSelectorCubit extends Cubit<ViewSelectorState> {
|
||||
ViewSelectorCubit({
|
||||
required this.getIgnoreViewType,
|
||||
this.maxSelectedParentPageCount,
|
||||
}) : super(ViewSelectorState.initial()) {
|
||||
filterTextController.addListener(onFilterChanged);
|
||||
}
|
||||
class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
ChatSettingsCubit({
|
||||
this.hideDisabled = false,
|
||||
}) : super(ChatSettingsState.initial());
|
||||
|
||||
final IgnoreViewType Function(ViewSelectorItem) getIgnoreViewType;
|
||||
final int? maxSelectedParentPageCount;
|
||||
final bool hideDisabled;
|
||||
|
||||
final List<String> selectedSourceIds = [];
|
||||
final List<ViewSelectorItem> sources = [];
|
||||
final List<ViewSelectorItem> selectedSources = [];
|
||||
final filterTextController = TextEditingController();
|
||||
final List<ChatSource> sources = [];
|
||||
final List<ChatSource> selectedSources = [];
|
||||
String filter = '';
|
||||
|
||||
void updateSelectedSources(List<String> newSelectedSourceIds) {
|
||||
selectedSourceIds.clear();
|
||||
selectedSourceIds.addAll(newSelectedSourceIds);
|
||||
}
|
||||
|
||||
Future<void> refreshSources(
|
||||
List<ViewPB> spaceViews,
|
||||
ViewPB? currentSpace,
|
||||
) async {
|
||||
filterTextController.clear();
|
||||
void refreshSources(List<ViewPB> spaceViews, ViewPB? currentSpace) async {
|
||||
filter = "";
|
||||
|
||||
final newSources = await Future.wait(
|
||||
spaceViews.map((view) => _recursiveBuild(view, null)),
|
||||
);
|
||||
|
||||
_setIsDisabledAndHideIfNecessary(newSources);
|
||||
|
||||
_restrictSelectionIfNecessary(newSources);
|
||||
|
||||
for (final source in newSources) {
|
||||
_restrictSelectionIfNecessary(source.children);
|
||||
}
|
||||
if (currentSpace != null) {
|
||||
newSources
|
||||
.firstWhereOrNull((e) => e.view.id == currentSpace.id)
|
||||
@@ -164,31 +155,29 @@ class ViewSelectorCubit extends Cubit<ViewSelectorState> {
|
||||
..addAll(selected.map((e) => e.copy()));
|
||||
}
|
||||
|
||||
Future<ViewSelectorItem> _recursiveBuild(
|
||||
ViewPB view,
|
||||
ViewPB? parentView,
|
||||
) async {
|
||||
ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected;
|
||||
Future<ChatSource> _recursiveBuild(ViewPB view, ViewPB? parentView) async {
|
||||
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
|
||||
final isThisSourceSelected = selectedSourceIds.contains(view.id);
|
||||
|
||||
final List<ViewPB>? childrenViews;
|
||||
if (integrationMode().isTest) {
|
||||
childrenViews = view.childViews;
|
||||
} else {
|
||||
childrenViews =
|
||||
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
|
||||
}
|
||||
final childrenViews =
|
||||
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
|
||||
|
||||
int selectedCount = 0;
|
||||
final children = <ViewSelectorItem>[];
|
||||
final children = <ChatSource>[];
|
||||
|
||||
if (childrenViews != null) {
|
||||
for (final childView in childrenViews) {
|
||||
final childItem = await _recursiveBuild(childView, view);
|
||||
if (childItem.selectedStatus.isSelected) {
|
||||
if (childView.layout == ViewLayoutPB.Chat) {
|
||||
continue;
|
||||
}
|
||||
if (childView.layout != ViewLayoutPB.Document && hideDisabled) {
|
||||
continue;
|
||||
}
|
||||
final childChatSource = await _recursiveBuild(childView, view);
|
||||
if (childChatSource.selectedStatus.isSelected) {
|
||||
selectedCount++;
|
||||
}
|
||||
children.add(childItem);
|
||||
children.add(childChatSource);
|
||||
}
|
||||
|
||||
final areAllChildrenSelectedOrNoChildren =
|
||||
@@ -197,64 +186,47 @@ class ViewSelectorCubit extends Cubit<ViewSelectorState> {
|
||||
children.any((e) => !e.selectedStatus.isUnselected);
|
||||
|
||||
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
|
||||
selectedStatus = ViewSelectedStatus.selected;
|
||||
selectedStatus = SourceSelectedStatus.selected;
|
||||
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
|
||||
selectedStatus = ViewSelectedStatus.partiallySelected;
|
||||
selectedStatus = SourceSelectedStatus.partiallySelected;
|
||||
}
|
||||
} else if (isThisSourceSelected) {
|
||||
selectedStatus = ViewSelectedStatus.selected;
|
||||
selectedStatus = SourceSelectedStatus.selected;
|
||||
}
|
||||
|
||||
return ViewSelectorItem(
|
||||
return ChatSource(
|
||||
view: view,
|
||||
parentView: parentView,
|
||||
children: children,
|
||||
isDisabled: false,
|
||||
ignoreStatus: view.layout.isDocumentView
|
||||
? IgnoreViewType.none
|
||||
: IgnoreViewType.disable,
|
||||
isExpanded: false,
|
||||
selectedStatus: selectedStatus,
|
||||
);
|
||||
}
|
||||
|
||||
void _setIsDisabledAndHideIfNecessary(
|
||||
List<ViewSelectorItem> sources,
|
||||
) {
|
||||
sources.retainWhere((source) {
|
||||
final ignoreViewType = getIgnoreViewType(source);
|
||||
return ignoreViewType != IgnoreViewType.hide;
|
||||
});
|
||||
|
||||
void _restrictSelectionIfNecessary(List<ChatSource> sources) {
|
||||
for (final source in sources) {
|
||||
source.isDisabledNotifier.value =
|
||||
getIgnoreViewType(source) == IgnoreViewType.disable;
|
||||
_setIsDisabledAndHideIfNecessary(source.children);
|
||||
}
|
||||
}
|
||||
|
||||
void _restrictSelectionIfNecessary(List<ViewSelectorItem> sources) {
|
||||
if (maxSelectedParentPageCount == null) {
|
||||
return;
|
||||
}
|
||||
for (final source in sources) {
|
||||
source.setIsDisabledRecursive((view) {
|
||||
return getIgnoreViewType(view) == IgnoreViewType.disable;
|
||||
});
|
||||
source.resetIgnoreViewTypeRecursive();
|
||||
}
|
||||
if (sources.where((e) => !e.selectedStatus.isUnselected).length >=
|
||||
maxSelectedParentPageCount!) {
|
||||
_kMaxSelectedParentPageCount) {
|
||||
sources
|
||||
.where((e) => e.selectedStatus == ViewSelectedStatus.unselected)
|
||||
.where((e) => e.selectedStatus == SourceSelectedStatus.unselected)
|
||||
.forEach(
|
||||
(e) => e.setIsDisabledRecursive((_) => true),
|
||||
(e) => e.updateIgnoreViewTypeRecursive(IgnoreViewType.disable),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onFilterChanged() {
|
||||
void updateFilter(String filter) {
|
||||
this.filter = filter;
|
||||
for (final source in state.visibleSources) {
|
||||
source.dispose();
|
||||
}
|
||||
if (sources.isEmpty) {
|
||||
emit(ViewSelectorState.initial());
|
||||
emit(ChatSettingsState.initial());
|
||||
} else {
|
||||
final selected =
|
||||
selectedSources.map(_buildSearchResults).nonNulls.toList();
|
||||
@@ -270,13 +242,13 @@ class ViewSelectorCubit extends Cubit<ViewSelectorState> {
|
||||
}
|
||||
|
||||
/// traverse tree to build up search query
|
||||
ViewSelectorItem? _buildSearchResults(ViewSelectorItem item) {
|
||||
final isVisible = item.view.nameOrDefault
|
||||
ChatSource? _buildSearchResults(ChatSource chatSource) {
|
||||
final isVisible = chatSource.view.nameOrDefault
|
||||
.toLowerCase()
|
||||
.contains(filterTextController.text.toLowerCase());
|
||||
.contains(filter.toLowerCase());
|
||||
|
||||
final childrenResults = <ViewSelectorItem>[];
|
||||
for (final childSource in item.children) {
|
||||
final childrenResults = <ChatSource>[];
|
||||
for (final childSource in chatSource.children) {
|
||||
final childResult = _buildSearchResults(childSource);
|
||||
if (childResult != null) {
|
||||
childrenResults.add(childResult);
|
||||
@@ -284,50 +256,48 @@ class ViewSelectorCubit extends Cubit<ViewSelectorState> {
|
||||
}
|
||||
|
||||
return isVisible || childrenResults.isNotEmpty
|
||||
? ViewSelectorItem(
|
||||
view: item.view,
|
||||
parentView: item.parentView,
|
||||
? ChatSource(
|
||||
view: chatSource.view,
|
||||
parentView: chatSource.parentView,
|
||||
children: childrenResults,
|
||||
isDisabled: item.isDisabled,
|
||||
isExpanded: item.isExpanded,
|
||||
selectedStatus: item.selectedStatus,
|
||||
ignoreStatus: chatSource.ignoreStatus,
|
||||
isExpanded: chatSource.isExpanded,
|
||||
selectedStatus: chatSource.selectedStatus,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// traverse tree to build up selected sources
|
||||
Iterable<ViewSelectorItem> _buildSelectedSources(
|
||||
ViewSelectorItem item,
|
||||
) {
|
||||
final children = <ViewSelectorItem>[];
|
||||
Iterable<ChatSource> _buildSelectedSources(ChatSource chatSource) {
|
||||
final children = <ChatSource>[];
|
||||
|
||||
for (final childSource in item.children) {
|
||||
for (final childSource in chatSource.children) {
|
||||
children.addAll(_buildSelectedSources(childSource));
|
||||
}
|
||||
|
||||
return selectedSourceIds.contains(item.view.id)
|
||||
return selectedSourceIds.contains(chatSource.view.id)
|
||||
? [
|
||||
ViewSelectorItem(
|
||||
view: item.view,
|
||||
parentView: item.parentView,
|
||||
ChatSource(
|
||||
view: chatSource.view,
|
||||
parentView: chatSource.parentView,
|
||||
children: children,
|
||||
isDisabled: item.isDisabled,
|
||||
selectedStatus: item.selectedStatus,
|
||||
ignoreStatus: chatSource.ignoreStatus,
|
||||
selectedStatus: chatSource.selectedStatus,
|
||||
isExpanded: true,
|
||||
),
|
||||
]
|
||||
: children;
|
||||
}
|
||||
|
||||
void toggleSelectedStatus(ViewSelectorItem item, bool isSelectedSection) {
|
||||
if (item.view.isSpace) {
|
||||
void toggleSelectedStatus(ChatSource chatSource) {
|
||||
if (chatSource.view.isSpace) {
|
||||
return;
|
||||
}
|
||||
final allIds = _recursiveGetSourceIds(item);
|
||||
final allIds = _recursiveGetSourceIds(chatSource);
|
||||
|
||||
if (item.selectedStatus.isUnselected ||
|
||||
item.selectedStatus.isPartiallySelected &&
|
||||
!item.view.layout.isDocumentView) {
|
||||
if (chatSource.selectedStatus.isUnselected ||
|
||||
chatSource.selectedStatus.isPartiallySelected &&
|
||||
!chatSource.view.layout.isDocumentView) {
|
||||
for (final id in allIds) {
|
||||
if (!selectedSourceIds.contains(id)) {
|
||||
selectedSourceIds.add(id);
|
||||
@@ -341,22 +311,13 @@ class ViewSelectorCubit extends Cubit<ViewSelectorState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelectedSection) {
|
||||
item.setIsSelectedStatusRecursive(
|
||||
item.selectedStatus.isUnselected ||
|
||||
item.selectedStatus.isPartiallySelected
|
||||
? ViewSelectedStatus.selected
|
||||
: ViewSelectedStatus.unselected,
|
||||
);
|
||||
}
|
||||
|
||||
updateSelectedStatus();
|
||||
}
|
||||
|
||||
List<String> _recursiveGetSourceIds(ViewSelectorItem item) {
|
||||
List<String> _recursiveGetSourceIds(ChatSource chatSource) {
|
||||
return [
|
||||
if (item.view.layout.isDocumentView) item.view.id,
|
||||
for (final childSource in item.children)
|
||||
if (chatSource.view.layout.isDocumentView) chatSource.view.id,
|
||||
for (final childSource in chatSource.children)
|
||||
..._recursiveGetSourceIds(childSource),
|
||||
];
|
||||
}
|
||||
@@ -381,42 +342,44 @@ class ViewSelectorCubit extends Cubit<ViewSelectorState> {
|
||||
);
|
||||
}
|
||||
|
||||
ViewSelectedStatus _recursiveUpdateSelectedStatus(ViewSelectorItem item) {
|
||||
ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected;
|
||||
SourceSelectedStatus _recursiveUpdateSelectedStatus(ChatSource chatSource) {
|
||||
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
|
||||
|
||||
int selectedCount = 0;
|
||||
for (final childSource in item.children) {
|
||||
for (final childSource in chatSource.children) {
|
||||
final childStatus = _recursiveUpdateSelectedStatus(childSource);
|
||||
if (childStatus.isSelected) {
|
||||
selectedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
final isThisSourceSelected = selectedSourceIds.contains(item.view.id);
|
||||
final isThisSourceSelected = selectedSourceIds.contains(chatSource.view.id);
|
||||
final areAllChildrenSelectedOrNoChildren =
|
||||
item.children.length == selectedCount;
|
||||
chatSource.children.length == selectedCount;
|
||||
final isAnyChildNotUnselected =
|
||||
item.children.any((e) => !e.selectedStatus.isUnselected);
|
||||
chatSource.children.any((e) => !e.selectedStatus.isUnselected);
|
||||
|
||||
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
|
||||
selectedStatus = ViewSelectedStatus.selected;
|
||||
selectedStatus = SourceSelectedStatus.selected;
|
||||
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
|
||||
selectedStatus = ViewSelectedStatus.partiallySelected;
|
||||
selectedStatus = SourceSelectedStatus.partiallySelected;
|
||||
}
|
||||
|
||||
item.selectedStatusNotifier.value = selectedStatus;
|
||||
chatSource.selectedStatusNotifier.value = selectedStatus;
|
||||
return selectedStatus;
|
||||
}
|
||||
|
||||
void toggleIsExpanded(ViewSelectorItem item, bool isSelectedSection) {
|
||||
item.toggleIsExpanded();
|
||||
void toggleIsExpanded(ChatSource chatSource, bool isSelectedSection) {
|
||||
chatSource.toggleIsExpanded();
|
||||
if (isSelectedSection) {
|
||||
for (final selectedSource in selectedSources) {
|
||||
selectedSource.findChildBySourceId(item.view.id)?.toggleIsExpanded();
|
||||
selectedSource
|
||||
.findChildBySourceId(chatSource.view.id)
|
||||
?.toggleIsExpanded();
|
||||
}
|
||||
} else {
|
||||
for (final source in sources) {
|
||||
final child = source.findChildBySourceId(item.view.id);
|
||||
final child = source.findChildBySourceId(chatSource.view.id);
|
||||
if (child != null) {
|
||||
child.toggleIsExpanded();
|
||||
break;
|
||||
@@ -439,19 +402,18 @@ class ViewSelectorCubit extends Cubit<ViewSelectorState> {
|
||||
for (final child in state.visibleSources) {
|
||||
child.dispose();
|
||||
}
|
||||
filterTextController.dispose();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ViewSelectorState with _$ViewSelectorState {
|
||||
const factory ViewSelectorState({
|
||||
required List<ViewSelectorItem> visibleSources,
|
||||
required List<ViewSelectorItem> selectedSources,
|
||||
}) = _ViewSelectorState;
|
||||
class ChatSettingsState with _$ChatSettingsState {
|
||||
const factory ChatSettingsState({
|
||||
required List<ChatSource> visibleSources,
|
||||
required List<ChatSource> selectedSources,
|
||||
}) = _ChatSettingsState;
|
||||
|
||||
factory ViewSelectorState.initial() => const ViewSelectorState(
|
||||
factory ChatSettingsState.initial() => const ChatSettingsState(
|
||||
visibleSources: [],
|
||||
selectedSources: [],
|
||||
);
|
||||
@@ -1,48 +0,0 @@
|
||||
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/foundation.dart';
|
||||
|
||||
/// Manages settings for a chat
|
||||
class ChatSettingsManager {
|
||||
ChatSettingsManager({
|
||||
required this.chatId,
|
||||
}) : selectedSourcesNotifier = ValueNotifier([]);
|
||||
final String chatId;
|
||||
|
||||
/// Notifies listeners when selected sources change
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
|
||||
/// Load settings from backend
|
||||
Future<void> loadSettings() async {
|
||||
final getChatSettingsPayload =
|
||||
AIEventGetChatSettings(ChatId(value: chatId));
|
||||
|
||||
await getChatSettingsPayload.send().then((result) {
|
||||
result.fold(
|
||||
(settings) {
|
||||
selectedSourcesNotifier.value = settings.ragIds;
|
||||
},
|
||||
(err) => Log.error("Failed to load chat settings: $err"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Update selected sources
|
||||
Future<void> updateSelectedSources(List<String> selectedSourcesIds) async {
|
||||
selectedSourcesNotifier.value = [...selectedSourcesIds];
|
||||
|
||||
final payload = UpdateChatSettingsPB(
|
||||
chatId: ChatId(value: chatId),
|
||||
ragIds: selectedSourcesIds,
|
||||
);
|
||||
|
||||
await AIEventUpdateChatSettings(payload).send().onFailure(Log.error);
|
||||
}
|
||||
|
||||
/// Clean up resources
|
||||
void dispose() {
|
||||
selectedSourcesNotifier.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
|
||||
import 'chat_message_stream.dart';
|
||||
|
||||
/// Manages chat streaming operations
|
||||
class ChatStreamManager {
|
||||
ChatStreamManager(this.chatId);
|
||||
final String chatId;
|
||||
|
||||
AnswerStream? answerStream;
|
||||
QuestionStream? questionStream;
|
||||
|
||||
/// Dispose of all streams
|
||||
Future<void> dispose() async {
|
||||
await answerStream?.dispose();
|
||||
answerStream = null;
|
||||
|
||||
await questionStream?.dispose();
|
||||
questionStream = null;
|
||||
}
|
||||
|
||||
/// Prepare streams for a new message
|
||||
Future<void> prepareStreams() async {
|
||||
await dispose();
|
||||
answerStream = AnswerStream();
|
||||
questionStream = QuestionStream();
|
||||
}
|
||||
|
||||
/// Build the payload for a streaming message
|
||||
StreamChatPayloadPB buildStreamPayload(
|
||||
String message,
|
||||
PredefinedFormat? format,
|
||||
) {
|
||||
final payload = StreamChatPayloadPB(
|
||||
chatId: chatId,
|
||||
message: message,
|
||||
messageType: ChatMessageTypePB.User,
|
||||
questionStreamPort: Int64(questionStream!.nativePort),
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
);
|
||||
|
||||
if (format != null) {
|
||||
payload.format = format.toPB();
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Send a streaming message request to the server
|
||||
Future<FlowyResult<ChatMessagePB, FlowyError>> sendStreamRequest(
|
||||
String message,
|
||||
PredefinedFormat? format,
|
||||
) async {
|
||||
final payload = buildStreamPayload(message, format);
|
||||
return AIEventStreamMessage(payload).send();
|
||||
}
|
||||
|
||||
/// Build the payload for regenerating a response
|
||||
RegenerateResponsePB buildRegeneratePayload(
|
||||
Int64 answerMessageId,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
) {
|
||||
final payload = RegenerateResponsePB(
|
||||
chatId: chatId,
|
||||
answerMessageId: answerMessageId,
|
||||
answerStreamPort: Int64(answerStream!.nativePort),
|
||||
);
|
||||
|
||||
if (format != null) {
|
||||
payload.format = format.toPB();
|
||||
}
|
||||
|
||||
if (model != null) {
|
||||
payload.model = model;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Send a request to regenerate a response
|
||||
Future<FlowyResult<dynamic, FlowyError>> sendRegenerateRequest(
|
||||
Int64 answerMessageId,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
) async {
|
||||
final payload = buildRegeneratePayload(answerMessageId, format, model);
|
||||
return AIEventRegenerateResponse(payload).send();
|
||||
}
|
||||
|
||||
/// Stop the current streaming message
|
||||
Future<void> stopStream() async {
|
||||
if (answerStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = StopStreamPB(chatId: chatId);
|
||||
await AIEventStopStream(payload).send();
|
||||
}
|
||||
|
||||
/// Check if the answer stream has started
|
||||
bool get hasAnswerStreamStarted =>
|
||||
answerStream != null && answerStream!.hasStarted;
|
||||
|
||||
Future<void> disposeAnswerStream() async {
|
||||
if (answerStream == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await answerStream!.dispose();
|
||||
answerStream = null;
|
||||
}
|
||||
}
|
||||
+224
-158
@@ -1,17 +1,15 @@
|
||||
// ignore_for_file: implementation_imports
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/util/debounce.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:diffutil_dart/diffutil.dart' as diffutil;
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
|
||||
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class ChatAnimatedList extends StatefulWidget {
|
||||
const ChatAnimatedList({
|
||||
@@ -43,6 +41,7 @@ class ChatAnimatedList extends StatefulWidget {
|
||||
|
||||
class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
|
||||
late final ChatController _chatController = Provider.of<ChatController>(
|
||||
context,
|
||||
listen: false,
|
||||
@@ -54,27 +53,9 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
late final Animation<double> _scrollToBottomAnimation;
|
||||
Timer? _scrollToBottomShowTimer;
|
||||
|
||||
final ScrollOffsetController scrollOffsetController =
|
||||
ScrollOffsetController();
|
||||
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
|
||||
final ScrollOffsetListener scrollOffsetListener =
|
||||
ScrollOffsetListener.create();
|
||||
|
||||
final ItemPositionsListener itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
int _lastUserMessageIndex = 0;
|
||||
bool _userHasScrolled = false;
|
||||
bool _isScrollingToBottom = false;
|
||||
|
||||
final _loadPreviousMessagesDebounce = Debounce(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
int initialScrollIndex = 0;
|
||||
double initialAlignment = 1.0;
|
||||
List<Message> messages = [];
|
||||
// String _lastInsertedMessageId = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -83,9 +64,6 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
// TODO: Add assert for messages having same id
|
||||
_oldList = List.from(_chatController.messages);
|
||||
_operationsSubscription = _chatController.operationsStream.listen((event) {
|
||||
setState(() {
|
||||
messages = _chatController.messages;
|
||||
});
|
||||
switch (event.type) {
|
||||
case ChatOperationType.insert:
|
||||
assert(
|
||||
@@ -96,7 +74,6 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
event.message != null,
|
||||
'Message must be provided when inserting a message.',
|
||||
);
|
||||
|
||||
_onInserted(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
@@ -109,7 +86,6 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
event.message != null,
|
||||
'Message must be provided when removing a message.',
|
||||
);
|
||||
|
||||
_onRemoved(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
break;
|
||||
@@ -133,8 +109,6 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
}
|
||||
});
|
||||
|
||||
messages = _chatController.messages;
|
||||
|
||||
_scrollToBottomController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@@ -145,127 +119,186 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
itemPositionsListener.itemPositions.addListener(() {
|
||||
_handleToggleScrollToBottom();
|
||||
});
|
||||
widget.scrollController.addListener(_handleLoadPreviousMessages);
|
||||
|
||||
itemPositionsListener.itemPositions.addListener(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleLoadPreviousMessages();
|
||||
});
|
||||
|
||||
// A trick to avoid the first message being scrolled to the top
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.dispose();
|
||||
_operationsSubscription.cancel();
|
||||
|
||||
super.dispose();
|
||||
widget.scrollController.removeListener(_handleLoadPreviousMessages);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final builders = context.watch<Builders>();
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
// A trick to avoid the first message being scrolled to the top
|
||||
|
||||
initialScrollIndex = messages.length;
|
||||
initialAlignment = 1.0;
|
||||
if (messages.length <= 2) {
|
||||
initialScrollIndex = 0;
|
||||
initialAlignment = 0.0;
|
||||
}
|
||||
|
||||
final Widget child = Stack(
|
||||
children: [
|
||||
ScrollablePositionedList.builder(
|
||||
scrollOffsetController: scrollOffsetController,
|
||||
itemScrollController: itemScrollController,
|
||||
initialScrollIndex: initialScrollIndex,
|
||||
initialAlignment: initialAlignment,
|
||||
scrollOffsetListener: scrollOffsetListener,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
physics: ClampingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
// the extra item is a vertical padding.
|
||||
itemCount: messages.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < 0 || index > messages.length) {
|
||||
Log.error('[chat animation list] index out of range: $index');
|
||||
return const SizedBox.shrink();
|
||||
return NotificationListener<Notification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is UserScrollNotification) {
|
||||
// When user scrolls up, save it to `_userHasScrolled`
|
||||
if (notification.direction == ScrollDirection.reverse) {
|
||||
_userHasScrolled = true;
|
||||
} else {
|
||||
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
|
||||
if (notification.metrics.pixels ==
|
||||
notification.metrics.maxScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (index == messages.length) {
|
||||
return VSpace(height - 400);
|
||||
}
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_handleToggleScrollToBottom();
|
||||
}
|
||||
|
||||
final message = messages[index];
|
||||
return widget.itemBuilder(
|
||||
context,
|
||||
Tween<double>(begin: 1, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: _scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
// Allow other listeners to get the notification
|
||||
return false;
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
physics: ClampingScrollPhysics(),
|
||||
slivers: <Widget>[
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
top: widget.bottomPadding ?? 0,
|
||||
),
|
||||
),
|
||||
message,
|
||||
);
|
||||
},
|
||||
),
|
||||
builders.scrollToBottomBuilder?.call(
|
||||
context,
|
||||
_scrollToBottomAnimation,
|
||||
_handleScrollToBottom,
|
||||
) ??
|
||||
ScrollToBottom(
|
||||
animation: _scrollToBottomAnimation,
|
||||
onPressed: _handleScrollToBottom,
|
||||
),
|
||||
],
|
||||
SliverAnimatedList(
|
||||
key: _listKey,
|
||||
initialItemCount: _chatController.messages.length,
|
||||
itemBuilder: (
|
||||
BuildContext context,
|
||||
int index,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
final message = _chatController.messages[index];
|
||||
final child = widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
message,
|
||||
);
|
||||
return RepaintBoundary(
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: max(0, height - widget.scrollBottomPadding),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
builders.scrollToBottomBuilder?.call(
|
||||
context,
|
||||
_scrollToBottomAnimation,
|
||||
_handleScrollToBottom,
|
||||
) ??
|
||||
ScrollToBottom(
|
||||
animation: _scrollToBottomAnimation,
|
||||
onPressed: _handleScrollToBottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
Future<void> _scrollLastUserMessageToTop() async {
|
||||
final user = Provider.of<User>(context, listen: false);
|
||||
final lastUserMessageIndex = messages.lastIndexWhere(
|
||||
(message) => message.author.id == user.id,
|
||||
// void _subsequentScrollToEnd(Message data) async {
|
||||
// final user = Provider.of<User>(context, listen: false);
|
||||
|
||||
// // We only want to scroll to the bottom if user has not scrolled up
|
||||
// // or if the message is sent by the current user.
|
||||
// if (data.id == _lastInsertedMessageId &&
|
||||
// widget.scrollController.offset <
|
||||
// widget.scrollController.position.maxScrollExtent &&
|
||||
// (user.id != data.author.id && !_userHasScrolled)) {
|
||||
// if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
// widget.scrollController
|
||||
// .jumpTo(widget.scrollController.position.maxScrollExtent);
|
||||
// } else {
|
||||
// await widget.scrollController.animateTo(
|
||||
// widget.scrollController.position.maxScrollExtent,
|
||||
// duration: widget.scrollToEndAnimationDuration,
|
||||
// curve: Curves.linearToEaseOut,
|
||||
// );
|
||||
// }
|
||||
|
||||
// if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
// // Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
|
||||
// // we need an additional jump to the end. Sometimes Flutter
|
||||
// // will not scroll to the very end. Sometimes it will not scroll to the
|
||||
// // very end even with this, so this is something that needs to be
|
||||
// // addressed by the Flutter team.
|
||||
// //
|
||||
// // Additionally here we have a check for the message id, because
|
||||
// // if new message arrives in the meantime it will trigger another
|
||||
// // scroll to the end animation, making this logic redundant.
|
||||
// if (data.id == _lastInsertedMessageId &&
|
||||
// widget.scrollController.offset <
|
||||
// widget.scrollController.position.maxScrollExtent &&
|
||||
// (user.id == data.author.id && _userHasScrolled)) {
|
||||
// widget.scrollController
|
||||
// .jumpTo(widget.scrollController.position.maxScrollExtent);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
void _scrollToTop(Message data) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
final user = Provider.of<User>(context, listen: false);
|
||||
final lastMessage = _chatController.messages.last;
|
||||
if (lastMessage.author.id == user.id) {
|
||||
// scroll the current message to the top
|
||||
widget.scrollController.animateTo(
|
||||
widget.scrollController.position.maxScrollExtent,
|
||||
duration: Durations.medium4,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (lastUserMessageIndex == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_lastUserMessageIndex != lastUserMessageIndex) {
|
||||
// scroll the current message to the top
|
||||
await itemScrollController.scrollTo(
|
||||
index: lastUserMessageIndex,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
_lastUserMessageIndex = lastUserMessageIndex;
|
||||
}
|
||||
|
||||
Future<void> _handleScrollToBottom() async {
|
||||
void _handleScrollToBottom() {
|
||||
_isScrollingToBottom = true;
|
||||
_scrollToBottomController.reverse();
|
||||
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
await _scrollToBottomController.reverse();
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
final paddingHeight = height - widget.scrollBottomPadding;
|
||||
final offset =
|
||||
widget.scrollController.position.maxScrollExtent - paddingHeight;
|
||||
|
||||
await itemScrollController.scrollTo(
|
||||
index: messages.length + 1,
|
||||
alignment: 1.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController.jumpTo(offset);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
offset,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
_isScrollingToBottom = false;
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
_isScrollingToBottom = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleToggleScrollToBottom() {
|
||||
@@ -273,55 +306,88 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
return;
|
||||
}
|
||||
|
||||
// get the max item
|
||||
final sortedItems = itemPositionsListener.itemPositions.value.toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
final maxItem = sortedItems.lastOrNull;
|
||||
|
||||
if (maxItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxItem.index > messages.length - 1 ||
|
||||
(maxItem.index == messages.length - 1 &&
|
||||
maxItem.itemTrailingEdge <= 1.01)) {
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.reverse();
|
||||
return;
|
||||
}
|
||||
|
||||
final paddingHeight =
|
||||
MediaQuery.of(context).size.height - widget.scrollBottomPadding;
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
if (mounted) {
|
||||
_scrollToBottomController.forward();
|
||||
if (widget.scrollController.offset <
|
||||
widget.scrollController.position.maxScrollExtent - paddingHeight) {
|
||||
_scrollToBottomShowTimer =
|
||||
Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
if (mounted) {
|
||||
_scrollToBottomController.forward();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (_scrollToBottomController.status != AnimationStatus.completed) {
|
||||
_scrollToBottomController.stop();
|
||||
}
|
||||
});
|
||||
_scrollToBottomController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleLoadPreviousMessages() {
|
||||
final sortedItems = itemPositionsListener.itemPositions.value.toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
final minItem = sortedItems.firstOrNull;
|
||||
|
||||
if (minItem == null || minItem.index > 0 || minItem.itemLeadingEdge < 0) {
|
||||
return;
|
||||
void _onInserted(final int position, final Message data) {
|
||||
// There is a scroll notification listener the controls the
|
||||
// `_userHasScrolled` variable.
|
||||
//
|
||||
// If for some reason `_userHasScrolled` is true and the user is not at the
|
||||
// bottom of the list, set `_userHasScrolled` to false so that the scroll
|
||||
// animation is triggered.
|
||||
if (position == 0 &&
|
||||
_userHasScrolled &&
|
||||
widget.scrollController.offset <
|
||||
widget.scrollController.position.maxScrollExtent) {
|
||||
_userHasScrolled = false;
|
||||
}
|
||||
|
||||
_loadPreviousMessagesDebounce.call(
|
||||
() {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
},
|
||||
_listKey.currentState!.insertItem(
|
||||
0,
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
|
||||
// Used later to trigger scroll to end only for the last inserted message.
|
||||
// _lastInsertedMessageId = data.id;
|
||||
|
||||
if (position == _oldList.length) {
|
||||
_scrollToTop(data);
|
||||
// _scrollToEnd(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _onRemoved(final int position, final Message data) {
|
||||
final visualPosition = max(_oldList.length - position - 1, 0);
|
||||
_listKey.currentState!.removeItem(
|
||||
visualPosition,
|
||||
(context, animation) => widget.itemBuilder(
|
||||
context,
|
||||
animation,
|
||||
data,
|
||||
isRemoved: true,
|
||||
),
|
||||
duration: widget.removeAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onInserted(final int position, final Message data) async {
|
||||
// scroll the last user message to the top if it's the last message
|
||||
if (position == _oldList.length) {
|
||||
await _scrollLastUserMessageToTop();
|
||||
}
|
||||
void _onChanged(int position, Message oldData, Message newData) {
|
||||
_onRemoved(position, oldData);
|
||||
_listKey.currentState!.insertItem(
|
||||
max(_oldList.length - position - 1, 0),
|
||||
duration: widget.insertAnimationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
void _onRemoved(final int position, final Message data) {}
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
|
||||
update.when<void>(
|
||||
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
|
||||
remove: (pos, data) => _onRemoved(pos, data),
|
||||
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
|
||||
move: (_, __, ___) => throw UnimplementedError('unused'),
|
||||
);
|
||||
}
|
||||
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {}
|
||||
void _handleLoadPreviousMessages() {
|
||||
if (widget.scrollController.offset >=
|
||||
widget.scrollController.position.maxScrollExtent) {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-22
@@ -1,17 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
@@ -133,22 +133,23 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewSelector(
|
||||
viewSelectorCubit: BlocProvider(
|
||||
create: (context) => ViewSelectorCubit(
|
||||
getIgnoreViewType: (item) {
|
||||
final view = item.view;
|
||||
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
|
||||
final userProfile = userWorkspaceBloc.state.userProfile;
|
||||
final workspaceId =
|
||||
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
|
||||
|
||||
if (view.isSpace) {
|
||||
return IgnoreViewType.none;
|
||||
}
|
||||
if (view.layout != ViewLayoutPB.Document) {
|
||||
return IgnoreViewType.hide;
|
||||
}
|
||||
return IgnoreViewType.none;
|
||||
},
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => SpaceBloc(
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
)..add(const SpaceEvent.initial(openFirstPage: false)),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => ChatSettingsCubit(hideDisabled: true),
|
||||
),
|
||||
],
|
||||
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
|
||||
selector: (state) => state.currentSpace,
|
||||
builder: (context, spaceView) {
|
||||
@@ -187,11 +188,9 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
await updateSelection(documentId);
|
||||
} else {
|
||||
if (spaceView != null) {
|
||||
unawaited(
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources([spaceView], spaceView),
|
||||
);
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources([spaceView], spaceView);
|
||||
}
|
||||
popoverController.show();
|
||||
}
|
||||
@@ -211,7 +210,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
|
||||
Widget buildPopover(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ViewSelectorCubit>(),
|
||||
value: context.read<ChatSettingsCubit>(),
|
||||
child: SaveToPagePopoverContent(
|
||||
onAddToNewPage: (parentViewId) async {
|
||||
await addMessageToNewPage(context, parentViewId);
|
||||
|
||||
+1
-10
@@ -9,9 +9,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
|
||||
@visibleForTesting
|
||||
bool skipAIChatWelcomePage = false;
|
||||
|
||||
class ChatAnimationListWidget extends StatefulWidget {
|
||||
const ChatAnimationListWidget({
|
||||
super.key,
|
||||
@@ -37,7 +34,7 @@ class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
|
||||
final bloc = context.read<ChatBloc>();
|
||||
|
||||
// this logic is quite weird, why don't we just get the message from the state?
|
||||
if (bloc.chatController.messages.isEmpty && !skipAIChatWelcomePage) {
|
||||
if (bloc.chatController.messages.isEmpty) {
|
||||
return ChatWelcomePage(
|
||||
userProfile: widget.userProfile,
|
||||
onSelectedQuestion: (question) {
|
||||
@@ -73,9 +70,6 @@ class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
|
||||
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
|
||||
: 8.0,
|
||||
onLoadPreviousMessages: () {
|
||||
if (bloc.isClosed) {
|
||||
return;
|
||||
}
|
||||
bloc.add(const ChatEvent.loadPreviousMessages());
|
||||
},
|
||||
)
|
||||
@@ -86,9 +80,6 @@ class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
|
||||
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
|
||||
: 8.0,
|
||||
onLoadPreviousMessages: () {
|
||||
if (bloc.isClosed) {
|
||||
return;
|
||||
}
|
||||
bloc.add(const ChatEvent.loadPreviousMessages());
|
||||
},
|
||||
);
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ChatMessage extends StatelessWidget {
|
||||
const ChatMessage({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.child,
|
||||
this.sentMessageAlignment = AlignmentDirectional.centerEnd,
|
||||
this.receivedMessageAlignment = AlignmentDirectional.centerStart,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final Widget child;
|
||||
final AlignmentGeometry sentMessageAlignment;
|
||||
final AlignmentGeometry receivedMessageAlignment;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSentByMe = context.watch<User>().id == message.author.id;
|
||||
|
||||
return Align(
|
||||
alignment: isSentByMe ? sentMessageAlignment : receivedMessageAlignment,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-21
@@ -1,14 +1,13 @@
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_footer.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_message_widget.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/text_message_widget.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/scroll_to_bottom.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart' hide ChatMessage;
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class LoadChatMessageStatusReady extends StatelessWidget {
|
||||
@@ -45,7 +44,6 @@ class LoadChatMessageStatusReady extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
final bool enableAnimation = true;
|
||||
return Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
@@ -63,32 +61,20 @@ class LoadChatMessageStatusReady extends StatelessWidget {
|
||||
builders: Builders(
|
||||
// we have a custom input builder, so we don't need the default one
|
||||
inputBuilder: (_) => const SizedBox.shrink(),
|
||||
textMessageBuilder: (
|
||||
context,
|
||||
message,
|
||||
) =>
|
||||
TextMessageWidget(
|
||||
textMessageBuilder: (context, message) => TextMessageWidget(
|
||||
message: message,
|
||||
userProfile: userProfile,
|
||||
view: view,
|
||||
enableAnimation: enableAnimation,
|
||||
),
|
||||
chatMessageBuilder: (
|
||||
context,
|
||||
message,
|
||||
animation,
|
||||
child,
|
||||
) =>
|
||||
chatMessageBuilder: (context, message, animation, child) =>
|
||||
ChatMessage(
|
||||
message: message,
|
||||
animation: animation,
|
||||
padding: const EdgeInsets.symmetric(vertical: 18.0),
|
||||
receivedMessageScaleAnimationAlignment: Alignment.center,
|
||||
child: child,
|
||||
),
|
||||
scrollToBottomBuilder: (
|
||||
context,
|
||||
animation,
|
||||
onPressed,
|
||||
) =>
|
||||
scrollToBottomBuilder: (context, animation, onPressed) =>
|
||||
CustomScrollToBottom(
|
||||
animation: animation,
|
||||
onPressed: onPressed,
|
||||
@@ -102,7 +88,7 @@ class LoadChatMessageStatusReady extends StatelessWidget {
|
||||
userProfile: userProfile,
|
||||
scrollController: scrollController,
|
||||
itemBuilder: itemBuilder,
|
||||
enableReversedList: !enableAnimation,
|
||||
enableReversedList: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
-2
@@ -79,8 +79,6 @@ class TextMessageWidget extends StatelessWidget {
|
||||
selector: (state) => state.isSelectingMessages,
|
||||
builder: (context, isSelectingMessages) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.promptResponseState != current.promptResponseState,
|
||||
builder: (context, state) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
final messages = chatController.messages
|
||||
|
||||
+70
-65
@@ -6,6 +6,7 @@ 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_edit_document_service.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
@@ -14,15 +15,16 @@ import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -513,23 +515,23 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewSelector(
|
||||
viewSelectorCubit: BlocProvider(
|
||||
create: (context) => ViewSelectorCubit(
|
||||
getIgnoreViewType: (item) {
|
||||
final view = item.view;
|
||||
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
|
||||
final userProfile = userWorkspaceBloc.state.userProfile;
|
||||
final workspaceId =
|
||||
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
|
||||
|
||||
if (view.isSpace) {
|
||||
return IgnoreViewType.none;
|
||||
}
|
||||
if (view.layout != ViewLayoutPB.Document) {
|
||||
return IgnoreViewType.hide;
|
||||
}
|
||||
|
||||
return IgnoreViewType.none;
|
||||
},
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => SpaceBloc(
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
)..add(const SpaceEvent.initial(openFirstPage: false)),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => ChatSettingsCubit(hideDisabled: true),
|
||||
),
|
||||
],
|
||||
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
|
||||
selector: (state) => state.currentSpace,
|
||||
builder: (context, spaceView) {
|
||||
@@ -544,7 +546,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
onClose: () {
|
||||
if (spaceView != null) {
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources([spaceView], spaceView);
|
||||
}
|
||||
widget.onOverrideVisibility?.call(false);
|
||||
@@ -581,11 +583,9 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
} else {
|
||||
widget.onOverrideVisibility?.call(true);
|
||||
if (spaceView != null) {
|
||||
unawaited(
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources([spaceView], spaceView),
|
||||
);
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources([spaceView], spaceView);
|
||||
}
|
||||
popoverController.show();
|
||||
}
|
||||
@@ -596,7 +596,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
|
||||
Widget buildPopover(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ViewSelectorCubit>(),
|
||||
value: context.read<ChatSettingsCubit>(),
|
||||
child: SaveToPagePopoverContent(
|
||||
onAddToNewPage: (parentViewId) {
|
||||
addMessageToNewPage(context, parentViewId);
|
||||
@@ -697,10 +697,8 @@ class SaveToPagePopoverContent extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
|
||||
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
|
||||
builder: (context, state) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -709,26 +707,22 @@ class SaveToPagePopoverContent extends StatelessWidget {
|
||||
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_addToPageTitle.tr(),
|
||||
style: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
fontSize: 12.0,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
|
||||
child: AFTextField(
|
||||
controller:
|
||||
context.read<ViewSelectorCubit>().filterTextController,
|
||||
hintText: LocaleKeys.search_label.tr(),
|
||||
size: AFTextFieldSize.m,
|
||||
child: SpaceSearchField(
|
||||
width: 600,
|
||||
onSearch: (context, value) =>
|
||||
context.read<ChatSettingsCubit>().updateFilter(value),
|
||||
),
|
||||
),
|
||||
AFDivider(
|
||||
startIndent: theme.spacing.l,
|
||||
endIndent: theme.spacing.l,
|
||||
),
|
||||
_buildDivider(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
@@ -742,33 +736,44 @@ class SaveToPagePopoverContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildVisibleSources(
|
||||
BuildContext context,
|
||||
ViewSelectorState state,
|
||||
) {
|
||||
return state.visibleSources.map(
|
||||
(e) => ViewSelectorTreeItem(
|
||||
key: ValueKey(
|
||||
'save_to_page_tree_item_${e.view.id}',
|
||||
),
|
||||
viewSelectorItem: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
showCheckbox: false,
|
||||
showSaveButton: true,
|
||||
onSelected: (source) {
|
||||
if (source.view.isSpace) {
|
||||
onAddToNewPage(source.view.id);
|
||||
} else {
|
||||
onAddToExistingPage(source.view.id);
|
||||
}
|
||||
},
|
||||
onAdd: (source) {
|
||||
onAddToNewPage(source.view.id);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
Widget _buildDivider() {
|
||||
return const Divider(
|
||||
height: 1.0,
|
||||
thickness: 1.0,
|
||||
indent: 12.0,
|
||||
endIndent: 12.0,
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildVisibleSources(
|
||||
BuildContext context,
|
||||
ChatSettingsState state,
|
||||
) {
|
||||
return state.visibleSources
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
|
||||
.map(
|
||||
(e) => ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'save_to_page_tree_item_${e.view.id}',
|
||||
),
|
||||
chatSource: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
showCheckbox: false,
|
||||
showSaveButton: true,
|
||||
onSelected: (source) {
|
||||
if (source.view.isSpace) {
|
||||
onAddToNewPage(source.view.id);
|
||||
} else {
|
||||
onAddToExistingPage(source.view.id);
|
||||
}
|
||||
},
|
||||
onAdd: (source) {
|
||||
onAddToNewPage(source.view.id);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+78
-91
@@ -71,106 +71,93 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
chatId: chatId,
|
||||
questionId: questionId,
|
||||
),
|
||||
child: BlocConsumer<ChatAIMessageBloc, ChatAIMessageState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.messageState != current.messageState,
|
||||
listener: (context, state) => _handleMessageState(state, context),
|
||||
builder: (context, blocState) {
|
||||
final loadingText = blocState.progress?.step ??
|
||||
LocaleKeys.chat_generatingResponse.tr();
|
||||
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
|
||||
builder: (context, state) {
|
||||
final loadingText =
|
||||
state.progress?.step ?? LocaleKeys.chat_generatingResponse.tr();
|
||||
|
||||
return Padding(
|
||||
padding: AIChatUILayout.messageMargin,
|
||||
child: blocState.messageState.when(
|
||||
loading: () => ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
return BlocListener<ChatBloc, ChatState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.clearErrorMessages != current.clearErrorMessages,
|
||||
listener: (context, chatState) {
|
||||
if (state.stream?.error?.isEmpty != false) {
|
||||
return;
|
||||
}
|
||||
context.read<ChatBloc>().add(ChatEvent.deleteMessage(message));
|
||||
},
|
||||
child: Padding(
|
||||
padding: AIChatUILayout.messageMargin,
|
||||
child: state.messageState.when(
|
||||
loading: () => ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
),
|
||||
),
|
||||
),
|
||||
ready: () {
|
||||
return blocState.text.isEmpty
|
||||
? _LoadingMessage(
|
||||
message: message,
|
||||
loadingText: loadingText,
|
||||
)
|
||||
: _NonEmptyMessage(
|
||||
user: user,
|
||||
messageUserId: messageUserId,
|
||||
message: message,
|
||||
stream: stream,
|
||||
questionId: questionId,
|
||||
chatId: chatId,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
onStopStream: onStopStream,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
isLastMessage: isLastMessage,
|
||||
isStreaming: isStreaming,
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
enableAnimation: enableAnimation,
|
||||
);
|
||||
},
|
||||
onError: (error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage:
|
||||
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIImageResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIMaxRequired: (message) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: message,
|
||||
);
|
||||
},
|
||||
onInitializingLocalAI: () {
|
||||
onStopStream();
|
||||
ready: () {
|
||||
return state.text.isEmpty
|
||||
? _LoadingMessage(
|
||||
message: message,
|
||||
loadingText: loadingText,
|
||||
)
|
||||
: _NonEmptyMessage(
|
||||
user: user,
|
||||
messageUserId: messageUserId,
|
||||
message: message,
|
||||
stream: stream,
|
||||
questionId: questionId,
|
||||
chatId: chatId,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
onStopStream: onStopStream,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
isLastMessage: isLastMessage,
|
||||
isStreaming: isStreaming,
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
enableAnimation: enableAnimation,
|
||||
);
|
||||
},
|
||||
onError: (error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage:
|
||||
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIImageResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIMaxRequired: (message) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: message,
|
||||
);
|
||||
},
|
||||
onInitializingLocalAI: () {
|
||||
onStopStream();
|
||||
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage:
|
||||
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
|
||||
);
|
||||
},
|
||||
aiFollowUp: (followUpData) {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys
|
||||
.settings_aiPage_keys_localAIInitializing
|
||||
.tr(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMessageState(ChatAIMessageState state, BuildContext context) {
|
||||
if (state.stream?.error?.isEmpty != false) {
|
||||
state.messageState.maybeMap(
|
||||
aiFollowUp: (messageState) {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.onAIFollowUp(messageState.followUpData));
|
||||
},
|
||||
orElse: () {
|
||||
// do nothing
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
context.read<ChatBloc>().add(ChatEvent.deleteMessage(message));
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingMessage extends StatelessWidget {
|
||||
|
||||
+1
-2
@@ -219,8 +219,7 @@ class DateCellEditorBloc
|
||||
} else {
|
||||
if (option == ReminderOption.none) {
|
||||
// remove reminder from reminder bloc and cell data
|
||||
_reminderBloc
|
||||
.add(ReminderEvent.removeReminder(reminderId: state.reminderId));
|
||||
_reminderBloc.add(ReminderEvent.removeReminder(reminderId: state.reminderId));
|
||||
await _updateCellReminderId("");
|
||||
} else {
|
||||
// Update reminder
|
||||
|
||||
@@ -109,10 +109,6 @@ class EditorDropHandler extends StatelessWidget {
|
||||
void _onDragUpdated(Offset position) {
|
||||
final data = editorState.selectionService.getDropTargetRenderData(position);
|
||||
|
||||
if (dropManagerState?.isDropEnabled == false) {
|
||||
return editorState.selectionService.removeDropTarget();
|
||||
}
|
||||
|
||||
if (data != null &&
|
||||
data.dropPath != null &&
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ class EditorDropManagerState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
bool get isDropEnabled => _draggedTypes.isEmpty;
|
||||
|
||||
bool contains(String type) => _draggedTypes.contains(type);
|
||||
}
|
||||
|
||||
final enableDocumentDragNotifier = ValueNotifier(true);
|
||||
+18
-32
@@ -3,7 +3,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class BulletedListIcon extends StatefulWidget {
|
||||
class BulletedListIcon extends StatelessWidget {
|
||||
const BulletedListIcon({
|
||||
super.key,
|
||||
required this.node,
|
||||
@@ -17,30 +17,28 @@ class BulletedListIcon extends StatefulWidget {
|
||||
FlowySvgs.bulleted_list_icon_3_s,
|
||||
];
|
||||
|
||||
@override
|
||||
State<BulletedListIcon> createState() => _BulletedListIconState();
|
||||
}
|
||||
|
||||
class _BulletedListIconState extends State<BulletedListIcon> {
|
||||
int index = 0;
|
||||
double size = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final textStyle =
|
||||
context.read<EditorState>().editorStyle.textStyleConfiguration;
|
||||
final fontSize = textStyle.text.fontSize ?? 16.0;
|
||||
final height = textStyle.text.height ?? textStyle.lineHeight;
|
||||
index = level % BulletedListIcon.bulletedListIcons.length;
|
||||
size = fontSize * height;
|
||||
int get level {
|
||||
var level = 0;
|
||||
var parent = node.parent;
|
||||
while (parent != null) {
|
||||
if (parent.type == BulletedListBlockKeys.type) {
|
||||
level++;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle =
|
||||
context.read<EditorState>().editorStyle.textStyleConfiguration;
|
||||
final fontSize = textStyle.text.fontSize ?? 16.0;
|
||||
final height = textStyle.text.height ?? textStyle.lineHeight;
|
||||
final size = fontSize * height;
|
||||
final index = level % bulletedListIcons.length;
|
||||
final icon = FlowySvg(
|
||||
BulletedListIcon.bulletedListIcons[index],
|
||||
bulletedListIcons[index],
|
||||
size: Size.square(size * 0.8),
|
||||
);
|
||||
return Container(
|
||||
@@ -51,16 +49,4 @@ class _BulletedListIconState extends State<BulletedListIcon> {
|
||||
child: icon,
|
||||
);
|
||||
}
|
||||
|
||||
int get level {
|
||||
var level = 0;
|
||||
var parent = widget.node.parent;
|
||||
while (parent != null) {
|
||||
if (parent.type == BulletedListBlockKeys.type) {
|
||||
level++;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -27,12 +27,11 @@ extension LinkExtension on EditorState {
|
||||
final node = getNodeAtPath(selection.start.path);
|
||||
if (node == null) return;
|
||||
final transaction = this.transaction;
|
||||
final linkName = info.name.isEmpty ? info.link : info.name;
|
||||
transaction.replaceText(
|
||||
node,
|
||||
selection.startIndex,
|
||||
selection.length,
|
||||
linkName,
|
||||
info.name,
|
||||
attributes: info.toAttribute(),
|
||||
);
|
||||
apply(transaction);
|
||||
|
||||
+2
-5
@@ -186,11 +186,8 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
|
||||
onDismiss: () => editMenuController.close(),
|
||||
onApply: (info) => editorState.applyLink(selection, info),
|
||||
onRemoveLink: (linkinfo) {
|
||||
final replaceText =
|
||||
linkinfo.name.isEmpty ? linkinfo.link : linkinfo.name;
|
||||
onRemoveAndReplaceLink(editorState, selection, replaceText);
|
||||
},
|
||||
onRemoveLink: (linkinfo) =>
|
||||
onRemoveAndReplaceLink(editorState, selection, linkinfo.name),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
+8
-17
@@ -157,8 +157,6 @@ class FileBlockComponent extends BlockComponentStatefulWidget {
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
static const uploadDragKey = 'FileUploadMenu';
|
||||
|
||||
@override
|
||||
State<FileBlockComponent> createState() => FileBlockComponentState();
|
||||
}
|
||||
@@ -255,23 +253,20 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
if (UniversalPlatform.isDesktopOrWeb) {
|
||||
if (url == null || url.isEmpty) {
|
||||
child = DropTarget(
|
||||
enable: dropManagerState?.isDropEnabled == true ||
|
||||
dropManagerState?.contains(FileBlockKeys.type) == true,
|
||||
onDragEntered: (_) {
|
||||
if (dropManagerState?.isDropEnabled == true) {
|
||||
dropManagerState?.add(FileBlockKeys.type);
|
||||
setState(() => isDragging = true);
|
||||
}
|
||||
},
|
||||
onDragExited: (_) {
|
||||
if (dropManagerState?.contains(FileBlockKeys.type) == true) {
|
||||
dropManagerState?.remove(FileBlockKeys.type);
|
||||
if (dropManagerState?.isDropEnabled == true) {
|
||||
setState(() => isDragging = false);
|
||||
}
|
||||
},
|
||||
onDragDone: (details) {
|
||||
dropManagerState?.remove(FileBlockKeys.type);
|
||||
insertFileFromLocal(details.files);
|
||||
if (dropManagerState?.isDropEnabled == true) {
|
||||
insertFileFromLocal(details.files);
|
||||
}
|
||||
},
|
||||
child: AppFlowyPopover(
|
||||
controller: controller,
|
||||
@@ -282,12 +277,8 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
minHeight: 80,
|
||||
),
|
||||
clickHandler: PopoverClickHandler.gestureDetector,
|
||||
onOpen: () => dropManagerState?.add(
|
||||
FileBlockComponent.uploadDragKey,
|
||||
),
|
||||
onClose: () => dropManagerState?.remove(
|
||||
FileBlockComponent.uploadDragKey,
|
||||
),
|
||||
onOpen: () => dropManagerState?.add(FileBlockKeys.type),
|
||||
onClose: () => dropManagerState?.remove(FileBlockKeys.type),
|
||||
popupBuilder: (_) => FileUploadMenu(
|
||||
onInsertLocalFile: insertFileFromLocal,
|
||||
onInsertNetworkFile: insertNetworkFile,
|
||||
@@ -354,7 +345,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
void _openMenu() {
|
||||
if (UniversalPlatform.isDesktopOrWeb) {
|
||||
controller.show();
|
||||
dropManagerState?.add(FileBlockComponent.uploadDragKey);
|
||||
dropManagerState?.add(FileBlockKeys.type);
|
||||
} else {
|
||||
editorState.updateSelectionWithReason(null, extraInfo: {});
|
||||
showUploadFileMobileMenu();
|
||||
@@ -583,7 +574,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
|
||||
Rect getBlockRect({bool shiftWithBaseOffset = false}) {
|
||||
final renderBox = fileKey.currentContext?.findRenderObject();
|
||||
if (renderBox is RenderBox) {
|
||||
return padding.topLeft & renderBox.size;
|
||||
return Offset.zero & renderBox.size;
|
||||
}
|
||||
return Rect.zero;
|
||||
}
|
||||
|
||||
+7
-13
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension InsertFile on EditorState {
|
||||
Future<void> insertEmptyFileBlock(GlobalKey key) async {
|
||||
@@ -16,18 +17,11 @@ extension InsertFile on EditorState {
|
||||
}
|
||||
final file = fileNode(url: '')..extraInfos = {'global_key': key};
|
||||
|
||||
final transaction = this.transaction;
|
||||
|
||||
// if current node is a paragraph and empty, replace it with the file block
|
||||
if (delta.isEmpty && node.type == ParagraphBlockKeys.type) {
|
||||
final insertedPath = path;
|
||||
transaction.insertNode(insertedPath, file);
|
||||
transaction.deleteNode(node);
|
||||
} else {
|
||||
// otherwise, insert the file block after the current node
|
||||
final insertedPath = path.next;
|
||||
transaction.insertNode(insertedPath, file);
|
||||
}
|
||||
final insertedPath = delta.isEmpty ? path : path.next;
|
||||
final transaction = this.transaction
|
||||
..insertNode(insertedPath, file)
|
||||
..insertNode(insertedPath, paragraphNode())
|
||||
..afterSelection = Selection.collapsed(Position(path: insertedPath.next));
|
||||
|
||||
return apply(transaction);
|
||||
}
|
||||
|
||||
+1
-7
@@ -52,8 +52,6 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
late final titleFocusNode = editorContext.coverTitleFocusNode;
|
||||
int lineCount = 1;
|
||||
|
||||
bool updatingViewName = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -89,7 +87,7 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
final width = context.read<DocumentAppearanceCubit>().state.width;
|
||||
return BlocConsumer<ViewBloc, ViewState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.view.name != current.view.name && !updatingViewName,
|
||||
previous.view.name != current.view.name,
|
||||
listener: _onListen,
|
||||
builder: (context, state) {
|
||||
final appearance = context.read<DocumentAppearanceCubit>().state;
|
||||
@@ -208,8 +206,6 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
}
|
||||
|
||||
void _onViewNameChanged() {
|
||||
updatingViewName = true;
|
||||
|
||||
Debounce.debounce(
|
||||
'update view name',
|
||||
const Duration(milliseconds: 250),
|
||||
@@ -226,8 +222,6 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
|
||||
context
|
||||
.read<ViewInfoBloc?>()
|
||||
?.add(ViewInfoEvent.titleChanged(titleTextController.text));
|
||||
|
||||
updatingViewName = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+2
-26
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
@@ -45,27 +44,11 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
final documentService = DocumentService();
|
||||
late final editorState = context.read<EditorState>();
|
||||
|
||||
late EditorDropManagerState? dropManagerState = UniversalPlatform.isMobile
|
||||
? null
|
||||
: context.read<EditorDropManagerState?>();
|
||||
|
||||
bool get isDragEnabled =>
|
||||
dropManagerState?.isDropEnabled == true ||
|
||||
dropManagerState?.contains(CustomImageBlockKeys.type) == true;
|
||||
|
||||
bool showLoading = false;
|
||||
String? errorMessage;
|
||||
|
||||
bool isDraggingFiles = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
dropManagerState = context.read<EditorDropManagerState>();
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget child = DecoratedBox(
|
||||
@@ -148,15 +131,8 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
|
||||
);
|
||||
},
|
||||
child: DropTarget(
|
||||
enable: isDragEnabled,
|
||||
onDragEntered: (_) {
|
||||
if (isDragEnabled) {
|
||||
setState(() => isDraggingFiles = true);
|
||||
}
|
||||
},
|
||||
onDragExited: (_) {
|
||||
setState(() => isDraggingFiles = false);
|
||||
},
|
||||
onDragEntered: (_) => setState(() => isDraggingFiles = true),
|
||||
onDragExited: (_) => setState(() => isDraggingFiles = false),
|
||||
onDragDone: (details) {
|
||||
// Only accept files where the mimetype is an image,
|
||||
// otherwise we assume it's a file we cannot display.
|
||||
|
||||
+1
-2
@@ -95,8 +95,7 @@ class DateTransactionHandler extends MentionTransactionHandler {
|
||||
final reminderId = data.$2[MentionBlockKeys.reminderId];
|
||||
|
||||
if (reminderId case String _ when reminderId.isNotEmpty) {
|
||||
getIt<ReminderBloc>()
|
||||
.add(ReminderEvent.removeReminder(reminderId: reminderId));
|
||||
getIt<ReminderBloc>().add(ReminderEvent.removeReminder(reminderId: reminderId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -146,9 +146,9 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
|
||||
} else {
|
||||
final rootContext = widget.editorState.document.root.context;
|
||||
if (rootContext != null && _reminderId != null) {
|
||||
rootContext.read<ReminderBloc?>()?.add(
|
||||
ReminderEvent.removeReminder(reminderId: _reminderId!),
|
||||
);
|
||||
rootContext
|
||||
.read<ReminderBloc?>()
|
||||
?.add(ReminderEvent.removeReminder(reminderId: _reminderId!));
|
||||
}
|
||||
_updateBlock(selectedDay, includeTime: _includeTime);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da
|
||||
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
|
||||
import 'package:appflowy/shared/google_fonts_extension.dart';
|
||||
import 'package:appflowy/util/font_family_extension.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/appearance_defaults.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
@@ -199,11 +198,7 @@ class EditorStyleCustomizer {
|
||||
fontSize = state.fontLayout.fontSize;
|
||||
fontSizes = state.fontLayout.headingFontSizes;
|
||||
} else {
|
||||
fontFamily = context
|
||||
.read<DocumentAppearanceCubit>()
|
||||
.state
|
||||
.fontFamily
|
||||
.orDefault(context.read<AppearanceSettingsCubit>().state.font);
|
||||
fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
|
||||
fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
|
||||
fontSizes = [
|
||||
fontSize + 16,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
@@ -50,7 +48,6 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
late int startOffset;
|
||||
late String _search = widget.initialSearchText;
|
||||
double emojiHeight = 36.0;
|
||||
String lastSearchedQuery = '';
|
||||
final configuration = EmojiPickerConfiguration(
|
||||
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
|
||||
);
|
||||
@@ -102,9 +99,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
onKeyEvent: onKeyEvent,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360),
|
||||
padding: noEmojis
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.symmetric(vertical: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
color: Theme.of(context).cardColor,
|
||||
@@ -116,7 +111,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: loaded ? buildEmojis() : buildLoading(),
|
||||
child: noEmojis ? buildLoading() : buildEmojis(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -135,16 +130,6 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
}
|
||||
|
||||
Widget buildEmojis() {
|
||||
final noEmojis = searchedEmojis.isEmpty;
|
||||
if (noEmojis) {
|
||||
return SizedBox(
|
||||
width: 400,
|
||||
height: emojiHeight,
|
||||
child: Center(
|
||||
child: FlowyText.regular(LocaleKeys.inlineActions_noResults.tr()),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height:
|
||||
(searchedEmojis.length / configuration.perLine).ceil() * emojiHeight,
|
||||
@@ -223,14 +208,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
_scrollToItem();
|
||||
});
|
||||
if (searchedEmojis.isEmpty) {
|
||||
if (lastSearchedQuery.isEmpty) {
|
||||
lastSearchedQuery = _search;
|
||||
}
|
||||
if (_search.length - lastSearchedQuery.length >= 5) {
|
||||
widget.onDismiss.call();
|
||||
}
|
||||
} else {
|
||||
lastSearchedQuery = '';
|
||||
widget.onDismiss.call();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +359,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
|
||||
|
||||
void _scrollToItem() {
|
||||
final noEmojis = searchedEmojis.isEmpty;
|
||||
if (noEmojis || !mounted || !scrollController.hasClients) return;
|
||||
if (noEmojis || !mounted) return;
|
||||
final currentItem = selectedIndexNotifier.value;
|
||||
final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight;
|
||||
final maxExtent = scrollController.position.maxScrollExtent;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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_ui/style_widget/icon_button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:fixnum/fixnum.dart' as $fixnum;
|
||||
|
||||
import 'sizes.dart';
|
||||
@@ -28,11 +27,7 @@ class TrashCell extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: TrashSizes.fileNameWidth,
|
||||
child: FlowyText(
|
||||
object.name.isEmpty
|
||||
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
|
||||
: object.name,
|
||||
),
|
||||
child: FlowyText(object.name),
|
||||
),
|
||||
SizedBox(
|
||||
width: TrashSizes.lashModifyWidth,
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/user_settings_service.dart';
|
||||
import 'package:appflowy/util/font_family_extension.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
|
||||
@@ -79,8 +78,7 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
Locale('cs', 'CZ'),
|
||||
Locale('ckb', 'KU'),
|
||||
Locale('de', 'DE'),
|
||||
Locale('en', 'US'),
|
||||
Locale('en', 'GB'),
|
||||
Locale('en'),
|
||||
Locale('es', 'VE'),
|
||||
Locale('eu', 'ES'),
|
||||
Locale('el', 'GR'),
|
||||
@@ -108,7 +106,7 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
Locale('mr', 'IN'),
|
||||
],
|
||||
path: 'assets/translations',
|
||||
fallbackLocale: const Locale('en', 'US'),
|
||||
fallbackLocale: const Locale('en'),
|
||||
useFallbackTranslations: true,
|
||||
child: app,
|
||||
),
|
||||
@@ -139,7 +137,7 @@ class ApplicationWidget extends StatefulWidget {
|
||||
class _ApplicationWidgetState extends State<ApplicationWidget> {
|
||||
late final GoRouter routerConfig;
|
||||
|
||||
final _commandPaletteNotifier = ValueNotifier(CommandPaletteNotifierValue());
|
||||
final _commandPaletteNotifier = ValueNotifier<bool>(false);
|
||||
|
||||
final themeBuilder = AppFlowyDefaultTheme();
|
||||
|
||||
@@ -241,9 +239,8 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
|
||||
routerConfig: routerConfig,
|
||||
builder: (context, child) {
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final fontFamily = state.font
|
||||
.orDefault(defaultFontFamily)
|
||||
.fontFamilyName;
|
||||
final fontFamily =
|
||||
state.font.orDefault(defaultFontFamily);
|
||||
|
||||
return AppFlowyTheme(
|
||||
data: brightness == Brightness.light
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:appflowy/core/helpers/helpers.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/startup/tasks/app_window_size_manager.dart';
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scaled_app/scaled_app.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
@@ -19,14 +21,8 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
|
||||
Future<void> initialize(LaunchContext context) async {
|
||||
await super.initialize(context);
|
||||
|
||||
// Don't initialize in tests or on web
|
||||
if (context.env.isTest || UniversalPlatform.isWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (UniversalPlatform.isMobile) {
|
||||
final scale = await windowSizeManager.getScaleFactor();
|
||||
ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => scale;
|
||||
// Don't initialize on mobile or web.
|
||||
if (!defaultTargetPlatform.isDesktop || context.env.isIntegrationTest) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
|
||||
late final ReminderService _reminderService;
|
||||
Timer? timer;
|
||||
late final AppLifecycleListener _listener;
|
||||
final _deepEquality = DeepCollectionEquality();
|
||||
|
||||
bool hasReminder(String reminderId) =>
|
||||
state.allReminders.where((e) => e.id == reminderId).firstOrNull != null;
|
||||
@@ -76,20 +75,9 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
|
||||
(reminders) async {
|
||||
final availableReminders =
|
||||
await filterAvailableReminders(reminders);
|
||||
// only print the reminder ids are not the same as the previous ones
|
||||
final previousReminderIds =
|
||||
state.reminders.map((e) => e.id).toSet();
|
||||
final newReminderIds =
|
||||
availableReminders.map((e) => e.id).toSet();
|
||||
final diff = _deepEquality.equals(
|
||||
previousReminderIds,
|
||||
newReminderIds,
|
||||
Log.info(
|
||||
'Fetched reminders on refresh: ${availableReminders.length}',
|
||||
);
|
||||
if (!diff) {
|
||||
Log.info(
|
||||
'Fetched reminders on refresh: ${availableReminders.length}',
|
||||
);
|
||||
}
|
||||
if (!isClosed && !emit.isDone) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/google_fonts_extension.dart';
|
||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -13,11 +12,4 @@ extension FontFamilyExtension on String {
|
||||
String get fontFamilyDisplayName => isEmpty || this == defaultFontFamily
|
||||
? LocaleKeys.settings_appearance_fontFamily_defaultFont.tr()
|
||||
: parseFontFamilyName();
|
||||
|
||||
// the font display name is not the same as the font family name
|
||||
// for example, the display name is "Noto Sans HK"
|
||||
// the font family name is "NotoSansHK_Regular"
|
||||
String get fontFamilyName => isEmpty || this == defaultFontFamily
|
||||
? defaultFontFamily
|
||||
: getGoogleFontSafely(this).fontFamily ?? defaultFontFamily;
|
||||
}
|
||||
+3
-6
@@ -286,14 +286,14 @@ class CommandPaletteBloc
|
||||
_GoingToAskAI event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) {
|
||||
emit(state.copyWith(askAI: true, askAISources: event.sources));
|
||||
emit(state.copyWith(askAI: true));
|
||||
}
|
||||
|
||||
FutureOr<void> _onAskedAI(
|
||||
_AskedAI event,
|
||||
Emitter<CommandPaletteState> emit,
|
||||
) {
|
||||
emit(state.copyWith(askAI: false, askAISources: null));
|
||||
emit(state.copyWith(askAI: false));
|
||||
}
|
||||
|
||||
bool _isActiveSearch(String searchId) =>
|
||||
@@ -325,9 +325,7 @@ class CommandPaletteEvent with _$CommandPaletteEvent {
|
||||
@Default(null) String? workspaceId,
|
||||
}) = _WorkspaceChanged;
|
||||
const factory CommandPaletteEvent.clearSearch() = _ClearSearch;
|
||||
const factory CommandPaletteEvent.goingToAskAI({
|
||||
@Default(null) List<SearchSourcePB>? sources,
|
||||
}) = _GoingToAskAI;
|
||||
const factory CommandPaletteEvent.gointToAskAI() = _GoingToAskAI;
|
||||
const factory CommandPaletteEvent.askedAI() = _AskedAI;
|
||||
}
|
||||
|
||||
@@ -360,7 +358,6 @@ class CommandPaletteState with _$CommandPaletteState {
|
||||
required bool searching,
|
||||
required bool generatingAIOverview,
|
||||
@Default(false) bool askAI,
|
||||
@Default(null) List<SearchSourcePB>? askAISources,
|
||||
@Default([]) List<TrashPB> trash,
|
||||
@Default(null) String? searchId,
|
||||
}) = _CommandPaletteState;
|
||||
|
||||
+1
-2
@@ -19,8 +19,7 @@ extension GetIcon on ResultIconPB {
|
||||
lineHeight: lineHeight,
|
||||
)
|
||||
: null;
|
||||
} else if (iconType == ResultIconTypePB.Icon ||
|
||||
iconType == ResultIconTypePB.Url) {
|
||||
} else if (ty == ResultIconTypePB.Icon) {
|
||||
if (_resultIconValueTypes.contains(iconValue)) {
|
||||
return FlowySvg(
|
||||
getViewSvg(),
|
||||
|
||||
@@ -46,11 +46,6 @@ class RecentViewsBloc extends Bloc<RecentViewsEvent, RecentViewsState> {
|
||||
await _service.reset();
|
||||
add(const RecentViewsEvent.fetchRecentViews());
|
||||
},
|
||||
hoverView: (e) async {
|
||||
emit(
|
||||
state.copyWith(hoveredView: e.view),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -69,7 +64,6 @@ class RecentViewsEvent with _$RecentViewsEvent {
|
||||
RemoveRecentViews;
|
||||
const factory RecentViewsEvent.fetchRecentViews() = FetchRecentViews;
|
||||
const factory RecentViewsEvent.resetRecentViews() = ResetRecentViews;
|
||||
const factory RecentViewsEvent.hoverView(ViewPB view) = HoverView;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -77,7 +71,6 @@ class RecentViewsState with _$RecentViewsState {
|
||||
const factory RecentViewsState({
|
||||
required List<SectionViewPB> views,
|
||||
@Default(true) bool isLoading,
|
||||
@Default(null) ViewPB? hoveredView,
|
||||
}) = _RecentViewsState;
|
||||
|
||||
factory RecentViewsState.initial() => const RecentViewsState(views: []);
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
|
||||
void setLocale(BuildContext context, Locale newLocale) {
|
||||
if (!context.supportedLocales.contains(newLocale)) {
|
||||
// Log.warn("Unsupported locale: $newLocale, Fallback to locale: en");
|
||||
newLocale = const Locale('en', 'US');
|
||||
newLocale = const Locale('en');
|
||||
}
|
||||
|
||||
context.setLocale(newLocale).catchError((e) {
|
||||
|
||||
+409
-557
File diff suppressed because it is too large.
Load diff
@@ -298,21 +298,6 @@ extension ViewExtension on ViewPB {
|
||||
return PageStyleFontLayout.normal;
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
set isSpace(bool value) {
|
||||
try {
|
||||
if (extra.isEmpty) {
|
||||
extra = jsonEncode({ViewExtKeys.isSpaceKey: value});
|
||||
} else {
|
||||
final ext = jsonDecode(extra);
|
||||
ext[ViewExtKeys.isSpaceKey] = value;
|
||||
extra = jsonEncode(ext);
|
||||
}
|
||||
} catch (e) {
|
||||
extra = jsonEncode({ViewExtKeys.isSpaceKey: value});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ViewLayoutExtension on ViewLayoutPB {
|
||||
|
||||
+62
-196
@@ -1,29 +1,15 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'widgets/search_ask_ai_entrance.dart';
|
||||
|
||||
class CommandPalette extends InheritedWidget {
|
||||
CommandPalette({
|
||||
super.key,
|
||||
@@ -33,7 +19,7 @@ class CommandPalette extends InheritedWidget {
|
||||
child: _CommandPaletteController(notifier: notifier, child: child),
|
||||
);
|
||||
|
||||
final ValueNotifier<CommandPaletteNotifierValue> notifier;
|
||||
final ValueNotifier<bool> notifier;
|
||||
|
||||
static CommandPalette of(BuildContext context) {
|
||||
final CommandPalette? result =
|
||||
@@ -44,30 +30,7 @@ class CommandPalette extends InheritedWidget {
|
||||
return result!;
|
||||
}
|
||||
|
||||
static CommandPalette? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<CommandPalette>();
|
||||
|
||||
void toggle({
|
||||
UserWorkspaceBloc? workspaceBloc,
|
||||
SpaceBloc? spaceBloc,
|
||||
}) {
|
||||
final value = notifier.value;
|
||||
notifier.value = notifier.value.copyWith(
|
||||
isOpen: !value.isOpen,
|
||||
userWorkspaceBloc: workspaceBloc,
|
||||
spaceBloc: spaceBloc,
|
||||
);
|
||||
}
|
||||
|
||||
void updateBlocs({
|
||||
UserWorkspaceBloc? workspaceBloc,
|
||||
SpaceBloc? spaceBloc,
|
||||
}) {
|
||||
notifier.value = notifier.value.copyWith(
|
||||
userWorkspaceBloc: workspaceBloc,
|
||||
spaceBloc: spaceBloc,
|
||||
);
|
||||
}
|
||||
void toggle() => notifier.value = !notifier.value;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;
|
||||
@@ -84,7 +47,7 @@ class _CommandPaletteController extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Widget? child;
|
||||
final ValueNotifier<CommandPaletteNotifierValue> notifier;
|
||||
final ValueNotifier<bool> notifier;
|
||||
|
||||
@override
|
||||
State<_CommandPaletteController> createState() =>
|
||||
@@ -92,8 +55,7 @@ class _CommandPaletteController extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CommandPaletteControllerState extends State<_CommandPaletteController> {
|
||||
late ValueNotifier<CommandPaletteNotifierValue> _toggleNotifier =
|
||||
widget.notifier;
|
||||
late ValueNotifier<bool> _toggleNotifier = widget.notifier;
|
||||
bool _isOpen = false;
|
||||
|
||||
@override
|
||||
@@ -119,28 +81,19 @@ class _CommandPaletteControllerState extends State<_CommandPaletteController> {
|
||||
}
|
||||
|
||||
void _onToggle() {
|
||||
if (_toggleNotifier.value.isOpen && !_isOpen) {
|
||||
if (_toggleNotifier.value && !_isOpen) {
|
||||
_isOpen = true;
|
||||
final workspaceBloc = _toggleNotifier.value.userWorkspaceBloc;
|
||||
final spaceBloc = _toggleNotifier.value.spaceBloc;
|
||||
Log.info(
|
||||
'CommandPalette onToggle: workspaceType ${workspaceBloc?.state.userProfile.workspaceType}',
|
||||
);
|
||||
FlowyOverlay.show(
|
||||
context: context,
|
||||
builder: (_) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<CommandPaletteBloc>()),
|
||||
if (workspaceBloc != null) BlocProvider.value(value: workspaceBloc),
|
||||
if (spaceBloc != null) BlocProvider.value(value: spaceBloc),
|
||||
],
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: context.read<CommandPaletteBloc>(),
|
||||
child: CommandPaletteModal(shortcutBuilder: _buildShortcut),
|
||||
),
|
||||
).then((_) {
|
||||
_isOpen = false;
|
||||
_toggleNotifier.value = _toggleNotifier.value.copyWith(isOpen: false);
|
||||
_toggleNotifier.value = false;
|
||||
});
|
||||
} else if (!_toggleNotifier.value.isOpen && _isOpen) {
|
||||
} else if (!_toggleNotifier.value && _isOpen) {
|
||||
FlowyOverlay.pop(context);
|
||||
_isOpen = false;
|
||||
}
|
||||
@@ -154,8 +107,8 @@ class _CommandPaletteControllerState extends State<_CommandPaletteController> {
|
||||
actions: {
|
||||
_ToggleCommandPaletteIntent:
|
||||
CallbackAction<_ToggleCommandPaletteIntent>(
|
||||
onInvoke: (intent) => _toggleNotifier.value = _toggleNotifier.value
|
||||
.copyWith(isOpen: !_toggleNotifier.value.isOpen),
|
||||
onInvoke: (intent) =>
|
||||
_toggleNotifier.value = !_toggleNotifier.value,
|
||||
),
|
||||
},
|
||||
shortcuts: {
|
||||
@@ -177,155 +130,68 @@ class CommandPaletteModal extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final workspaceState = context.read<UserWorkspaceBloc?>()?.state;
|
||||
final showAskingAI =
|
||||
workspaceState?.userProfile.workspaceType == WorkspaceTypePB.ServerW;
|
||||
return BlocListener<CommandPaletteBloc, CommandPaletteState>(
|
||||
listener: (_, state) {
|
||||
if (state.askAI && context.mounted) {
|
||||
if (Navigator.canPop(context)) FlowyOverlay.pop(context);
|
||||
final currentWorkspace = workspaceState?.workspaces;
|
||||
final spaceBloc = context.read<SpaceBloc?>();
|
||||
if (currentWorkspace != null && spaceBloc != null) {
|
||||
spaceBloc.add(
|
||||
SpaceEvent.createPage(
|
||||
name: '',
|
||||
layout: ViewLayoutPB.Chat,
|
||||
index: 0,
|
||||
openAfterCreate: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<CommandPaletteBloc, CommandPaletteState>(
|
||||
builder: (context, state) {
|
||||
final noQuery = state.query?.isEmpty ?? true, hasQuery = !noQuery;
|
||||
final hasResult = state.combinedResponseItems.isNotEmpty;
|
||||
return FlowyDialog(
|
||||
alignment: Alignment.topCenter,
|
||||
insetPadding: const EdgeInsets.only(top: 100),
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 640,
|
||||
maxWidth: 900,
|
||||
minHeight: 640,
|
||||
),
|
||||
expandHeight: false,
|
||||
child: shortcutBuilder(
|
||||
// Change mainAxisSize to max so Expanded works correctly.
|
||||
Column(
|
||||
children: [
|
||||
SearchField(query: state.query, isLoading: state.searching),
|
||||
if (noQuery)
|
||||
Flexible(
|
||||
child: RecentViewsList(
|
||||
onSelected: () => FlowyOverlay.pop(context),
|
||||
),
|
||||
),
|
||||
if (hasResult && hasQuery) ...[
|
||||
AFDivider(),
|
||||
Flexible(
|
||||
child: SearchResultList(
|
||||
trash: state.trash,
|
||||
resultItems:
|
||||
state.combinedResponseItems.values.toList(),
|
||||
resultSummaries: state.resultSummaries,
|
||||
),
|
||||
),
|
||||
]
|
||||
// When there are no results and the query is not empty and not loading,
|
||||
// show the no results message, centered in the available space.
|
||||
else if (hasQuery && !state.searching) ...[
|
||||
AFDivider(),
|
||||
if (showAskingAI) SearchAskAiEntrance(),
|
||||
Expanded(
|
||||
child: const NoSearchResultsHint(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
return BlocBuilder<CommandPaletteBloc, CommandPaletteState>(
|
||||
builder: (context, state) => FlowyDialog(
|
||||
alignment: Alignment.topCenter,
|
||||
insetPadding: const EdgeInsets.only(top: 100),
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 600,
|
||||
maxWidth: 800,
|
||||
minHeight: 600,
|
||||
),
|
||||
expandHeight: false,
|
||||
child: shortcutBuilder(
|
||||
// Change mainAxisSize to max so Expanded works correctly.
|
||||
Column(
|
||||
children: [
|
||||
SearchField(query: state.query, isLoading: state.searching),
|
||||
if (state.query?.isEmpty ?? true) ...[
|
||||
const Divider(height: 0),
|
||||
Flexible(
|
||||
child: RecentViewsList(
|
||||
onSelected: () => FlowyOverlay.pop(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.combinedResponseItems.isNotEmpty &&
|
||||
(state.query?.isNotEmpty ?? false)) ...[
|
||||
const Divider(height: 0),
|
||||
Flexible(
|
||||
child: SearchResultList(
|
||||
trash: state.trash,
|
||||
resultItems: state.combinedResponseItems.values.toList(),
|
||||
resultSummaries: state.resultSummaries,
|
||||
),
|
||||
),
|
||||
]
|
||||
// When there are no results and the query is not empty and not loading,
|
||||
// show the no results message, centered in the available space.
|
||||
else if ((state.query?.isNotEmpty ?? false) &&
|
||||
!state.searching) ...[
|
||||
const Divider(height: 0),
|
||||
Expanded(
|
||||
child: const _NoResultsHint(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updated _NoResultsHint now centers its content.
|
||||
class NoSearchResultsHint extends StatelessWidget {
|
||||
const NoSearchResultsHint({super.key});
|
||||
class _NoResultsHint extends StatelessWidget {
|
||||
const _NoResultsHint();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
textColor = theme.textColorScheme.secondary;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.m_home_search_icon_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: Size.square(24),
|
||||
),
|
||||
const VSpace(8),
|
||||
Text(
|
||||
LocaleKeys.search_noResultForSearching.tr(),
|
||||
style: theme.textStyle.body.enhanced(color: textColor),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const VSpace(4),
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
text: LocaleKeys.search_noResultForSearchingHintWithoutTrash.tr(),
|
||||
style: theme.textStyle.caption.standard(color: textColor),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.trash_text.tr(),
|
||||
style: theme.textStyle.caption.underline(color: textColor),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
FlowyOverlay.pop(context);
|
||||
getIt<MenuSharedState>().latestOpenView = null;
|
||||
getIt<TabsBloc>().add(
|
||||
TabsEvent.openPlugin(
|
||||
plugin: makePlugin(pluginType: PluginType.trash),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: FlowyText.regular(
|
||||
LocaleKeys.commandPalette_noResultsHint.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CommandPaletteNotifierValue {
|
||||
CommandPaletteNotifierValue({
|
||||
this.isOpen = false,
|
||||
this.userWorkspaceBloc,
|
||||
this.spaceBloc,
|
||||
});
|
||||
|
||||
final bool isOpen;
|
||||
final UserWorkspaceBloc? userWorkspaceBloc;
|
||||
final SpaceBloc? spaceBloc;
|
||||
|
||||
CommandPaletteNotifierValue copyWith({
|
||||
bool? isOpen,
|
||||
UserWorkspaceBloc? userWorkspaceBloc,
|
||||
SpaceBloc? spaceBloc,
|
||||
}) {
|
||||
return CommandPaletteNotifierValue(
|
||||
isOpen: isOpen ?? this.isOpen,
|
||||
userWorkspaceBloc: userWorkspaceBloc ?? this.userWorkspaceBloc,
|
||||
spaceBloc: spaceBloc ?? this.spaceBloc,
|
||||
);
|
||||
}
|
||||
}
|
||||
-209
@@ -1,209 +0,0 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_view_ancestors.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/util/int64_extension.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class PagePreview extends StatelessWidget {
|
||||
const PagePreview({
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.onViewOpened,
|
||||
});
|
||||
final ViewPB view;
|
||||
final VoidCallback onViewOpened;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 24,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Center(child: buildIcon(theme, view)),
|
||||
),
|
||||
VSpace(8),
|
||||
buildTitle(context, view),
|
||||
buildPath(context, view),
|
||||
...buildTime(
|
||||
context,
|
||||
LocaleKeys.commandPalette_created.tr(),
|
||||
view.createTime.toDateTime(),
|
||||
),
|
||||
if (view.lastEdited != view.createTime)
|
||||
...buildTime(
|
||||
context,
|
||||
LocaleKeys.commandPalette_edited.tr(),
|
||||
view.lastEdited.toDateTime(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildIcon(AppFlowyThemeData theme, ViewPB view) {
|
||||
return view.icon.value.isNotEmpty
|
||||
? RawEmojiIconWidget(
|
||||
emoji: view.icon.toEmojiIconData(),
|
||||
emojiSize: 20.0,
|
||||
lineHeight: 1,
|
||||
)
|
||||
: FlowySvg(
|
||||
view.iconData,
|
||||
size: const Size.square(20),
|
||||
color: theme.iconColorScheme.secondary,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTitle(BuildContext context, ViewPB view) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final titleStyle = theme.textStyle.heading4
|
||||
.enhanced(color: theme.textColorScheme.primary),
|
||||
titleHoverStyle =
|
||||
titleStyle.copyWith(decoration: TextDecoration.underline);
|
||||
return SizedBox(
|
||||
height: 28,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: AFBaseButton(
|
||||
padding: EdgeInsets.zero,
|
||||
builder: (context, isHovering, disabled) =>
|
||||
SelectionContainer.disabled(
|
||||
child: Text(
|
||||
view.nameOrDefault,
|
||||
style: isHovering ? titleHoverStyle : titleStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
borderColor: (_, __, ___, ____) => Colors.transparent,
|
||||
borderRadius: 0,
|
||||
onTap: onViewOpened,
|
||||
),
|
||||
),
|
||||
HSpace(4),
|
||||
FlowyTooltip(
|
||||
message: LocaleKeys.settings_files_open.tr(),
|
||||
child: AFGhostButton.normal(
|
||||
size: AFButtonSize.s,
|
||||
padding: EdgeInsets.all(theme.spacing.xs),
|
||||
onTap: onViewOpened,
|
||||
builder: (context, isHovering, disabled) => FlowySvg(
|
||||
FlowySvgs.search_arrow_right_m,
|
||||
size: const Size.square(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPath(BuildContext context, ViewPB view) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return BlocProvider(
|
||||
key: ValueKey(view.id),
|
||||
create: (context) => ViewAncestorBloc(view.id),
|
||||
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
|
||||
builder: (context, state) {
|
||||
final isEmpty = state.ancestor.ancestors.isEmpty;
|
||||
if (!state.isLoading && isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
VSpace(16),
|
||||
Text(
|
||||
LocaleKeys.commandPalette_location.tr(),
|
||||
style: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.primary),
|
||||
),
|
||||
state.buildPath(
|
||||
context,
|
||||
style: theme.textStyle.caption.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> buildTime(BuildContext context, String title, DateTime time) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final appearanceSettings = context.watch<AppearanceSettingsCubit>().state;
|
||||
final dateFormat = appearanceSettings.dateFormat,
|
||||
timeFormat = appearanceSettings.timeFormat;
|
||||
return [
|
||||
VSpace(12),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.primary),
|
||||
),
|
||||
Text(
|
||||
dateFormat.formatDate(time, true, timeFormat),
|
||||
style: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class SomethingWentWrong extends StatelessWidget {
|
||||
const SomethingWentWrong({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.something_wrong_warning_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: Size.square(24),
|
||||
),
|
||||
const VSpace(8),
|
||||
Text(
|
||||
LocaleKeys.search_somethingWentWrong.tr(),
|
||||
style: theme.textStyle.body
|
||||
.enhanced(color: theme.textColorScheme.secondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const VSpace(4),
|
||||
Text(
|
||||
LocaleKeys.search_tryAgainOrLater.tr(),
|
||||
style: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
-135
@@ -2,24 +2,15 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
|
||||
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.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.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'page_preview.dart';
|
||||
import 'search_ask_ai_entrance.dart';
|
||||
|
||||
class RecentViewsList extends StatelessWidget {
|
||||
const RecentViewsList({super.key, required this.onSelected});
|
||||
|
||||
@@ -35,133 +26,39 @@ class RecentViewsList extends StatelessWidget {
|
||||
// We remove duplicates by converting the list to a set first
|
||||
final List<ViewPB> recentViews =
|
||||
state.views.map((e) => e.item).toSet().toList();
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final workspaceState = context.read<UserWorkspaceBloc?>()?.state;
|
||||
final showAskingAI = workspaceState?.userProfile.workspaceType ==
|
||||
WorkspaceTypePB.ServerW;
|
||||
final hoveredView = state.hoveredView;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AFDivider(),
|
||||
Flexible(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ScrollControllerBuilder(
|
||||
builder: (context, controller) {
|
||||
return FlowyScrollbar(
|
||||
controller: controller,
|
||||
child: SingleChildScrollView(
|
||||
controller: controller,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAskingAI) SearchAskAiEntrance(),
|
||||
Container(
|
||||
height: 20,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
LocaleKeys.sideBar_recent.tr(),
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color:
|
||||
theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics:
|
||||
const NeverScrollableScrollPhysics(),
|
||||
itemCount: recentViews.length,
|
||||
padding:
|
||||
EdgeInsets.symmetric(horizontal: 12),
|
||||
itemBuilder: (_, index) {
|
||||
final view = recentViews[index];
|
||||
|
||||
final icon = view.icon.value.isNotEmpty
|
||||
? RawEmojiIconWidget(
|
||||
emoji:
|
||||
view.icon.toEmojiIconData(),
|
||||
emojiSize: 16.0,
|
||||
lineHeight: 20 / 16,
|
||||
)
|
||||
: FlowySvg(
|
||||
view.iconData,
|
||||
size: const Size.square(20),
|
||||
color: theme
|
||||
.iconColorScheme.secondary,
|
||||
);
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemCount: recentViews.length + 1,
|
||||
itemBuilder: (_, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: FlowyText(
|
||||
LocaleKeys.commandPalette_recentHistory.tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SearchRecentViewCell(
|
||||
icon: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Center(child: icon),
|
||||
),
|
||||
view: view,
|
||||
onSelected: onSelected,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, index) {
|
||||
final view = recentViews[index];
|
||||
final isHovered =
|
||||
hoveredView?.id == view.id;
|
||||
if (isHovered) return VSpace(1);
|
||||
if (index < recentViews.length - 1) {
|
||||
final nextView =
|
||||
recentViews[index + 1];
|
||||
final isNextHovered =
|
||||
hoveredView?.id == nextView.id;
|
||||
if (isNextHovered) return VSpace(1);
|
||||
}
|
||||
return const AFDivider();
|
||||
},
|
||||
),
|
||||
VSpace(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hoveredView != null) ...[
|
||||
AFDivider(axis: Axis.vertical),
|
||||
Flexible(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: PagePreview(
|
||||
view: hoveredView,
|
||||
onViewOpened: () {
|
||||
getIt<ActionNavigationBloc>().add(
|
||||
ActionNavigationEvent.performAction(
|
||||
action: NavigationAction(
|
||||
objectId: hoveredView.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
onSelected();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
final view = recentViews[index - 1];
|
||||
final icon = view.icon.value.isNotEmpty
|
||||
? EmojiIconWidget(
|
||||
emoji: view.icon.toEmojiIconData(),
|
||||
emojiSize: 18.0,
|
||||
)
|
||||
: FlowySvg(view.iconData, size: const Size.square(20));
|
||||
|
||||
return SearchRecentViewCell(
|
||||
icon: SizedBox(width: 24, child: icon),
|
||||
view: view,
|
||||
onSelected: onSelected,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Divider(height: 0),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
-227
@@ -1,227 +0,0 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'search_summary_cell.dart';
|
||||
|
||||
class SearchAskAiEntrance extends StatelessWidget {
|
||||
const SearchAskAiEntrance({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<CommandPaletteBloc?>(), state = bloc?.state;
|
||||
final generatingAIOverview = state?.generatingAIOverview ?? false;
|
||||
final hasAIOverview =
|
||||
_mockSummary?.isNotEmpty ?? state?.resultSummaries.isNotEmpty ?? false;
|
||||
if (generatingAIOverview) {
|
||||
return _AISearching();
|
||||
} else if (hasAIOverview) {
|
||||
return _AIOverview();
|
||||
}
|
||||
return _AskAIFor();
|
||||
}
|
||||
}
|
||||
|
||||
class _AskAIFor extends StatelessWidget {
|
||||
const _AskAIFor();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final sapceM = theme.spacing.m, spaceL = theme.spacing.l;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(spaceL, sapceM, spaceL, 0),
|
||||
child: AFBaseButton(
|
||||
borderRadius: sapceM,
|
||||
padding: EdgeInsets.symmetric(vertical: spaceL, horizontal: sapceM),
|
||||
backgroundColor: (context, isHovering, disable) {
|
||||
if (isHovering) {
|
||||
return Theme.of(context).colorScheme.secondary;
|
||||
}
|
||||
return theme.fillColorScheme.transparent;
|
||||
},
|
||||
borderColor: (context, isHovering, disable, isFocused) =>
|
||||
theme.fillColorScheme.transparent,
|
||||
builder: (ctx, isHovering, disable) {
|
||||
return Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.m_home_ai_chat_icon_m,
|
||||
size: Size.square(20),
|
||||
blendMode: null,
|
||||
),
|
||||
HSpace(12),
|
||||
buildText(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
onTap: () {
|
||||
context
|
||||
.read<CommandPaletteBloc?>()
|
||||
?.add(CommandPaletteEvent.goingToAskAI());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildText(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final bloc = context.read<CommandPaletteBloc?>();
|
||||
final queryText = bloc?.state.query ?? '';
|
||||
if (queryText.isEmpty) {
|
||||
return Text(
|
||||
LocaleKeys.search_askAIAnything.tr(),
|
||||
style:
|
||||
theme.textStyle.body.standard(color: theme.textColorScheme.primary),
|
||||
);
|
||||
}
|
||||
return Flexible(
|
||||
child: RichText(
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.search_askAIFor.tr(),
|
||||
style: theme.textStyle.body
|
||||
.standard(color: theme.textColorScheme.primary),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' "$queryText"',
|
||||
style: theme.textStyle.body
|
||||
.enhanced(color: theme.textColorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AISearching extends StatelessWidget {
|
||||
const _AISearching();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 8),
|
||||
child: SizedBox(
|
||||
height: 22,
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_searching_icon_m,
|
||||
size: Size.square(20),
|
||||
blendMode: null,
|
||||
),
|
||||
HSpace(8),
|
||||
Text(
|
||||
LocaleKeys.search_searching.tr(),
|
||||
style: theme.textStyle.heading4
|
||||
.enhanced(color: theme.textColorScheme.secondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AIOverview extends StatelessWidget {
|
||||
const _AIOverview();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<CommandPaletteBloc?>(), state = bloc?.state;
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final summaries = _mockSummary ?? state?.resultSummaries ?? [];
|
||||
if (summaries.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildHeader(context),
|
||||
VSpace(8),
|
||||
LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
final summary = summaries.first;
|
||||
return SearchSummaryCell(
|
||||
key: ValueKey(summary.content.trim()),
|
||||
summary: summary,
|
||||
maxWidth: constrains.maxWidth,
|
||||
theme: AppFlowyTheme.of(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
VSpace(8),
|
||||
SizedBox(
|
||||
width: 143,
|
||||
child: AFOutlinedButton.normal(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
borderRadius: 16,
|
||||
builder: (context, hovering, disabled) {
|
||||
return Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.chat_ai_page_s,
|
||||
size: Size.square(20),
|
||||
color: theme.iconColorScheme.primary,
|
||||
),
|
||||
HSpace(8),
|
||||
Text(
|
||||
LocaleKeys.commandPalette_aiAskFollowUp.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
onTap: () {
|
||||
context.read<CommandPaletteBloc?>()?.add(
|
||||
CommandPaletteEvent.goingToAskAI(
|
||||
sources: summaries.first.sources,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildHeader(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return SizedBox(
|
||||
height: 22,
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_searching_icon_m,
|
||||
size: Size.square(20),
|
||||
blendMode: null,
|
||||
),
|
||||
HSpace(8),
|
||||
Text(
|
||||
LocaleKeys.commandPalette_aiOverview.tr(),
|
||||
style: theme.textStyle.heading4
|
||||
.enhanced(color: theme.textColorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<SearchSummaryPB>? _mockSummary;
|
||||
+96
-64
@@ -1,4 +1,3 @@
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -7,6 +6,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.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/text_field.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
@@ -63,26 +63,32 @@ class _SearchFieldState extends State<SearchField> {
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) {
|
||||
final hasText = value.text.trim().isNotEmpty;
|
||||
if (!hasText) return const SizedBox.shrink();
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.commandPalette_clearSearchTooltip.tr(),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _clearSearch,
|
||||
child: SizedBox.square(
|
||||
dimension: 28,
|
||||
child: Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.search_clear_m,
|
||||
color: AppFlowyTheme.of(context).iconColorScheme.secondary,
|
||||
size: const Size.square(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
final clearIcon = Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AFThemeExtension.of(context).lightGreyHover,
|
||||
),
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.close_s,
|
||||
size: Size.square(16),
|
||||
),
|
||||
);
|
||||
return AnimatedOpacity(
|
||||
opacity: hasText ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: hasText
|
||||
? FlowyTooltip(
|
||||
message: LocaleKeys.commandPalette_clearSearchTooltip.tr(),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: _clearSearch,
|
||||
child: clearIcon,
|
||||
),
|
||||
),
|
||||
)
|
||||
: clearIcon,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -90,53 +96,80 @@ class _SearchFieldState extends State<SearchField> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
// Cache theme and text styles
|
||||
final theme = Theme.of(context);
|
||||
final textStyle = theme.textTheme.bodySmall?.copyWith(fontSize: 14);
|
||||
final hintStyle = theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 14,
|
||||
color: theme.hintColor,
|
||||
);
|
||||
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.search_icon_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: Size.square(20),
|
||||
),
|
||||
HSpace(8),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
child: FlowyTextField(
|
||||
focusNode: focusNode,
|
||||
controller: controller,
|
||||
textStyle: theme.textStyle.heading4
|
||||
.standard(color: theme.textColorScheme.primary),
|
||||
decoration: InputDecoration(
|
||||
constraints: const BoxConstraints(maxHeight: 48),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
enabledBorder: const OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
borderRadius: Corners.s8Border,
|
||||
),
|
||||
isDense: false,
|
||||
hintText: LocaleKeys.search_searchOrAskAI.tr(),
|
||||
hintStyle: theme.textStyle.heading4
|
||||
.standard(color: theme.textColorScheme.tertiary),
|
||||
counterText: "",
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: Corners.s8Border,
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
onChanged: (value) => context
|
||||
.read<CommandPaletteBloc>()
|
||||
.add(CommandPaletteEvent.searchChanged(search: value)),
|
||||
// Choose the leading icon based on loading state
|
||||
final Widget leadingIcon = widget.isLoading
|
||||
? FlowyTooltip(
|
||||
message: LocaleKeys.commandPalette_loadingTooltip.tr(),
|
||||
child: const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(3.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2.0),
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.search_m,
|
||||
color: theme.hintColor,
|
||||
),
|
||||
);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
const HSpace(12),
|
||||
leadingIcon,
|
||||
Expanded(
|
||||
child: FlowyTextField(
|
||||
focusNode: focusNode,
|
||||
controller: controller,
|
||||
textStyle: textStyle,
|
||||
decoration: InputDecoration(
|
||||
constraints: const BoxConstraints(maxHeight: 48),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
enabledBorder: const OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
borderRadius: Corners.s8Border,
|
||||
),
|
||||
isDense: false,
|
||||
hintText: LocaleKeys.commandPalette_placeholder.tr(),
|
||||
hintStyle: hintStyle,
|
||||
errorStyle: theme.textTheme.bodySmall!
|
||||
.copyWith(color: theme.colorScheme.error),
|
||||
suffix: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildSuffixIcon(context),
|
||||
const HSpace(8),
|
||||
],
|
||||
),
|
||||
counterText: "",
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: Corners.s8Border,
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: Corners.s8Border,
|
||||
borderSide: BorderSide(color: theme.colorScheme.error),
|
||||
),
|
||||
),
|
||||
onChanged: (value) => context
|
||||
.read<CommandPaletteBloc>()
|
||||
.add(CommandPaletteEvent.searchChanged(search: value)),
|
||||
),
|
||||
_buildSuffixIcon(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,6 +178,5 @@ class _SearchFieldState extends State<SearchField> {
|
||||
context
|
||||
.read<CommandPaletteBloc>()
|
||||
.add(const CommandPaletteEvent.clearSearch());
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
+24
-99
@@ -1,19 +1,13 @@
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_view_ancestors.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
|
||||
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.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:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SearchRecentViewCell extends StatefulWidget {
|
||||
class SearchRecentViewCell extends StatelessWidget {
|
||||
const SearchRecentViewCell({
|
||||
super.key,
|
||||
required this.icon,
|
||||
@@ -25,102 +19,33 @@ class SearchRecentViewCell extends StatefulWidget {
|
||||
final ViewPB view;
|
||||
final VoidCallback onSelected;
|
||||
|
||||
@override
|
||||
State<SearchRecentViewCell> createState() => _SearchRecentViewCellState();
|
||||
}
|
||||
|
||||
class _SearchRecentViewCellState extends State<SearchRecentViewCell> {
|
||||
final focusNode = FocusNode();
|
||||
|
||||
ViewPB get view => widget.view;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
textColor = theme.textColorScheme.primary;
|
||||
final sapceM = theme.spacing.m, spaceL = theme.spacing.l;
|
||||
final bloc = context.read<RecentViewsBloc>(), state = bloc.state;
|
||||
final hoveredView = state.hoveredView;
|
||||
final hovering = hoveredView == view;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _handleSelection(view.id),
|
||||
child: Focus(
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
_handleSelection(view.id);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
onFocusChange: (hasFocus) {
|
||||
if (hasFocus && !hovering) {
|
||||
bloc.add(RecentViewsEvent.hoverView(view));
|
||||
}
|
||||
},
|
||||
child: FlowyHover(
|
||||
onHover: (value) {
|
||||
if (hoveredView == view) return;
|
||||
bloc.add(RecentViewsEvent.hoverView(view));
|
||||
},
|
||||
style: HoverStyle(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
hoverColor: Theme.of(context).colorScheme.secondary,
|
||||
foregroundColorOnHover: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
isSelected: () => hovering,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: spaceL, horizontal: sapceM),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
widget.icon,
|
||||
HSpace(12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
view.nameOrDefault,
|
||||
style: theme.textStyle.body.standard(color: textColor),
|
||||
),
|
||||
buildPath(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Row(
|
||||
children: [
|
||||
icon,
|
||||
const HSpace(6),
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
view.nameOrDefault,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
focusColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
onTap: () {
|
||||
onSelected();
|
||||
|
||||
Widget buildPath(AppFlowyThemeData theme) {
|
||||
return BlocProvider(
|
||||
create: (context) => ViewAncestorBloc(view.id),
|
||||
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
|
||||
builder: (context, state) => state.buildPath(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to handle the selection action.
|
||||
void _handleSelection(String id) {
|
||||
widget.onSelected();
|
||||
getIt<ActionNavigationBloc>().add(
|
||||
ActionNavigationEvent.performAction(
|
||||
action: NavigationAction(objectId: id),
|
||||
),
|
||||
getIt<ActionNavigationBloc>().add(
|
||||
ActionNavigationEvent.performAction(
|
||||
action: NavigationAction(objectId: view.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+124
-129
@@ -1,33 +1,27 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_search_cell.dart';
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_view_ancestors.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pbenum.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.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/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'page_preview.dart';
|
||||
|
||||
class SearchResultCell extends StatefulWidget {
|
||||
const SearchResultCell({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.query,
|
||||
this.isTrashed = false,
|
||||
this.isHovered = false,
|
||||
});
|
||||
|
||||
final SearchResultItem item;
|
||||
final String? query;
|
||||
final bool isTrashed;
|
||||
final bool isHovered;
|
||||
|
||||
@override
|
||||
@@ -38,9 +32,6 @@ class _SearchResultCellState extends State<SearchResultCell> {
|
||||
bool _hasFocus = false;
|
||||
final focusNode = FocusNode();
|
||||
|
||||
String get viewId => item.id;
|
||||
SearchResultItem get item => widget.item;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
@@ -50,18 +41,93 @@ class _SearchResultCellState extends State<SearchResultCell> {
|
||||
/// Helper to handle the selection action.
|
||||
void _handleSelection() {
|
||||
context.read<SearchResultListBloc>().add(
|
||||
SearchResultListEvent.openPage(pageId: viewId),
|
||||
SearchResultListEvent.openPage(pageId: widget.item.id),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to clean up preview text.
|
||||
String _cleanPreview(String preview) {
|
||||
return preview.replaceAll('\n', ' ').trim();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = item.displayName.orDefault(
|
||||
final title = widget.item.displayName.orDefault(
|
||||
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
|
||||
);
|
||||
final icon = widget.item.icon.getIcon();
|
||||
final cleanedPreview = _cleanPreview(widget.item.content);
|
||||
final hasPreview = cleanedPreview.isNotEmpty;
|
||||
final trashHintText =
|
||||
widget.isTrashed ? LocaleKeys.commandPalette_fromTrashHint.tr() : null;
|
||||
|
||||
// Build the tile content based on preview availability.
|
||||
Widget tileContent;
|
||||
if (hasPreview) {
|
||||
tileContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
SizedBox(width: 24, child: icon),
|
||||
const HSpace(6),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.isTrashed)
|
||||
FlowyText(
|
||||
trashHintText!,
|
||||
color: AFThemeExtension.of(context)
|
||||
.textColor
|
||||
.withAlpha(175),
|
||||
fontSize: 10,
|
||||
),
|
||||
FlowyText(
|
||||
title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VSpace(4),
|
||||
_DocumentPreview(preview: cleanedPreview),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
tileContent = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
SizedBox(width: 24, child: icon),
|
||||
const HSpace(6),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isTrashed)
|
||||
FlowyText(
|
||||
trashHintText!,
|
||||
color:
|
||||
AFThemeExtension.of(context).textColor.withAlpha(175),
|
||||
fontSize: 10,
|
||||
),
|
||||
FlowyText(
|
||||
title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final textColor = theme.textColorScheme.primary;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _handleSelection,
|
||||
@@ -79,7 +145,7 @@ class _SearchResultCellState extends State<SearchResultCell> {
|
||||
setState(() {
|
||||
context.read<SearchResultListBloc>().add(
|
||||
SearchResultListEvent.onHoverResult(
|
||||
item: item,
|
||||
item: widget.item,
|
||||
userHovered: true,
|
||||
),
|
||||
);
|
||||
@@ -90,7 +156,7 @@ class _SearchResultCellState extends State<SearchResultCell> {
|
||||
onHover: (value) {
|
||||
context.read<SearchResultListBloc>().add(
|
||||
SearchResultListEvent.onHoverResult(
|
||||
item: item,
|
||||
item: widget.item,
|
||||
userHovered: true,
|
||||
),
|
||||
);
|
||||
@@ -98,143 +164,72 @@ class _SearchResultCellState extends State<SearchResultCell> {
|
||||
isSelected: () => _hasFocus || widget.isHovered,
|
||||
style: HoverStyle(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
hoverColor: Theme.of(context).colorScheme.secondary,
|
||||
hoverColor:
|
||||
Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
foregroundColorOnHover: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Center(child: buildIcon(theme)),
|
||||
),
|
||||
HSpace(12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RichText(
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: buildHighLightSpan(
|
||||
content: title,
|
||||
normal:
|
||||
theme.textStyle.body.standard(color: textColor),
|
||||
highlight: theme.textStyle.body
|
||||
.standard(color: textColor)
|
||||
.copyWith(
|
||||
backgroundColor:
|
||||
theme.fillColorScheme.themeSelect,
|
||||
),
|
||||
),
|
||||
),
|
||||
buildPath(theme),
|
||||
...buildSummary(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 30),
|
||||
child: tileContent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildIcon(AppFlowyThemeData theme) {
|
||||
final icon = item.icon;
|
||||
final color = theme.iconColorScheme.secondary;
|
||||
if (icon.ty == ResultIconTypePB.Emoji) {
|
||||
return icon.getIcon(size: 20, iconColor: color) ?? SizedBox.shrink();
|
||||
} else {
|
||||
return icon.getIcon(size: 20, iconColor: color) ?? SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
class _DocumentPreview extends StatelessWidget {
|
||||
const _DocumentPreview({required this.preview});
|
||||
|
||||
Widget buildPath(AppFlowyThemeData theme) {
|
||||
return BlocProvider(
|
||||
key: ValueKey(viewId),
|
||||
create: (context) => ViewAncestorBloc(viewId),
|
||||
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
|
||||
builder: (context, state) => state.buildPath(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
final String preview;
|
||||
|
||||
TextSpan buildHighLightSpan({
|
||||
required String content,
|
||||
required TextStyle normal,
|
||||
required TextStyle highlight,
|
||||
}) {
|
||||
final queryText = (widget.query ?? '').trim();
|
||||
if (queryText.isEmpty) {
|
||||
return TextSpan(text: content, style: normal);
|
||||
}
|
||||
final contents = content.splitIncludeSeparator(queryText);
|
||||
return TextSpan(
|
||||
children: List.generate(contents.length, (index) {
|
||||
final content = contents[index];
|
||||
final isHighlight = content.toLowerCase() == queryText.toLowerCase();
|
||||
return TextSpan(
|
||||
text: content,
|
||||
style: isHighlight ? highlight : normal,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> buildSummary(AppFlowyThemeData theme) {
|
||||
if (item.content.isEmpty) return [];
|
||||
return [
|
||||
VSpace(4),
|
||||
RichText(
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Combine the horizontal padding for clarity:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(30, 0, 16, 0),
|
||||
child: FlowyText.regular(
|
||||
preview,
|
||||
color: Theme.of(context).hintColor,
|
||||
fontSize: 12,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: buildHighLightSpan(
|
||||
content: item.content,
|
||||
normal: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
highlight: theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.primary)
|
||||
.copyWith(
|
||||
backgroundColor: theme.fillColorScheme.themeSelect,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchResultPreview extends StatelessWidget {
|
||||
const SearchResultPreview({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
final SearchResultItem item;
|
||||
final SearchResultItem data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: ViewBackendService.getView(item.id),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final view = snapshot.data?.toNullable();
|
||||
if (view == null) return SomethingWentWrong();
|
||||
|
||||
return PagePreview(
|
||||
view: view,
|
||||
onViewOpened: () {
|
||||
context
|
||||
.read<SearchResultListBloc?>()
|
||||
?.add(SearchResultListEvent.openPage(pageId: view.id));
|
||||
},
|
||||
);
|
||||
},
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: FlowyText(
|
||||
LocaleKeys.commandPalette_pagePreview.tr(),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const VSpace(6),
|
||||
FlowyText(
|
||||
data.content,
|
||||
maxLines: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+204
-108
@@ -3,10 +3,6 @@ import 'package:appflowy/workspace/application/action_navigation/action_navigati
|
||||
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
@@ -15,7 +11,10 @@ import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
|
||||
import 'search_result_cell.dart';
|
||||
import 'search_summary_cell.dart';
|
||||
|
||||
class SearchResultList extends StatefulWidget {
|
||||
const SearchResultList({
|
||||
@@ -48,119 +47,148 @@ class _SearchResultListState extends State<SearchResultList> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: bloc,
|
||||
child: BlocListener<SearchResultListBloc, SearchResultListState>(
|
||||
listener: (context, state) {
|
||||
if (state.openPageId != null) {
|
||||
FlowyOverlay.pop(context);
|
||||
getIt<ActionNavigationBloc>().add(
|
||||
ActionNavigationEvent.performAction(
|
||||
action: NavigationAction(objectId: state.openPageId!),
|
||||
Widget _buildSectionHeader(String title) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8) +
|
||||
const EdgeInsets.only(left: 8),
|
||||
child: Opacity(
|
||||
opacity: 0.6,
|
||||
child: FlowyText(title, fontSize: 12),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildAIOverviewSection(BuildContext context) {
|
||||
final state = context.read<CommandPaletteBloc>().state;
|
||||
|
||||
if (state.generatingAIOverview) {
|
||||
return Row(
|
||||
children: [
|
||||
_buildSectionHeader(LocaleKeys.commandPalette_aiOverview.tr()),
|
||||
const HSpace(10),
|
||||
const AIOverviewIndicator(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.resultSummaries.isNotEmpty) {
|
||||
if (!bloc.state.userHovered) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
bloc.add(
|
||||
SearchResultListEvent.onHoverSummary(
|
||||
summary: widget.resultSummaries[0],
|
||||
userHovered: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<SearchResultListBloc, SearchResultListState>(
|
||||
builder: (context, state) {
|
||||
final showPreview = state.hoveredResult != null;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(flex: 2, child: _buildResultsSection(context)),
|
||||
if (showPreview) ...[
|
||||
AFDivider(axis: Axis.vertical),
|
||||
Flexible(child: const SearchCellPreview()),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Container(
|
||||
height: 20,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
LocaleKeys.commandPalette_bestMatches.tr(),
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader(LocaleKeys.commandPalette_aiOverview.tr()),
|
||||
ListView.separated(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.resultSummaries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 0),
|
||||
itemBuilder: (_, index) => SearchSummaryCell(
|
||||
summary: widget.resultSummaries[index],
|
||||
isHovered: bloc.state.hoveredSummary != null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildResultsSection(BuildContext context) {
|
||||
final workspaceState = context.read<UserWorkspaceBloc?>()?.state;
|
||||
final showAskingAI =
|
||||
workspaceState?.userProfile.workspaceType == WorkspaceTypePB.ServerW;
|
||||
if (widget.resultItems.isEmpty) return const SizedBox.shrink();
|
||||
final trashIds = widget.trash.map((e) => e.id).toSet();
|
||||
final resultItems = widget.resultItems
|
||||
.where((item) => !trashIds.contains(item.id))
|
||||
.toList();
|
||||
return ScrollControllerBuilder(
|
||||
builder: (context, controller) {
|
||||
final hoveredId = bloc.state.hoveredResult?.id;
|
||||
return FlowyScrollbar(
|
||||
controller: controller,
|
||||
child: SingleChildScrollView(
|
||||
controller: controller,
|
||||
physics: ClampingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAskingAI) SearchAskAiEntrance(),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildSectionHeader(context),
|
||||
VSpace(8),
|
||||
ListView.separated(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: resultItems.length,
|
||||
separatorBuilder: (_, index) {
|
||||
final item = resultItems[index];
|
||||
final isHovered = hoveredId == item.id;
|
||||
if (isHovered) return VSpace(1);
|
||||
if (index < resultItems.length - 1) {
|
||||
final nextView = resultItems[index + 1];
|
||||
final isNextHovered = hoveredId == nextView.id;
|
||||
if (isNextHovered) return VSpace(1);
|
||||
}
|
||||
return const AFDivider();
|
||||
},
|
||||
itemBuilder: (_, index) {
|
||||
final item = resultItems[index];
|
||||
return SearchResultCell(
|
||||
item: item,
|
||||
isHovered: hoveredId == item.id,
|
||||
query: context
|
||||
.read<CommandPaletteBloc?>()
|
||||
?.state
|
||||
.query,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_buildSectionHeader(LocaleKeys.commandPalette_bestMatches.tr()),
|
||||
ListView.separated(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.resultItems.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 0),
|
||||
itemBuilder: (_, index) {
|
||||
final item = widget.resultItems[index];
|
||||
return SearchResultCell(
|
||||
item: item,
|
||||
isTrashed: widget.trash.any((t) => t.id == item.id),
|
||||
isHovered: bloc.state.hoveredResult?.id == item.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: BlocProvider.value(
|
||||
value: bloc,
|
||||
child: BlocListener<SearchResultListBloc, SearchResultListState>(
|
||||
listener: (context, state) {
|
||||
if (state.openPageId != null) {
|
||||
FlowyOverlay.pop(context);
|
||||
getIt<ActionNavigationBloc>().add(
|
||||
ActionNavigationEvent.performAction(
|
||||
action: NavigationAction(objectId: state.openPageId!),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 7,
|
||||
child: BlocBuilder<SearchResultListBloc, SearchResultListState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.hoveredResult != current.hoveredResult ||
|
||||
previous.hoveredSummary != current.hoveredSummary,
|
||||
builder: (context, state) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: [
|
||||
_buildAIOverviewSection(context),
|
||||
const VSpace(10),
|
||||
if (widget.resultItems.isNotEmpty)
|
||||
_buildResultsSection(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const HSpace(10),
|
||||
if (widget.resultItems
|
||||
.any((item) => item.content.isNotEmpty)) ...[
|
||||
const VerticalDivider(
|
||||
thickness: 1.0,
|
||||
),
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 16,
|
||||
),
|
||||
child: const SearchCellPreview(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -172,11 +200,79 @@ class SearchCellPreview extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SearchResultListBloc, SearchResultListState>(
|
||||
builder: (context, state) {
|
||||
if (state.hoveredResult != null) {
|
||||
return SearchResultPreview(item: state.hoveredResult!);
|
||||
if (state.hoveredSummary != null) {
|
||||
return SearchSummaryPreview(summary: state.hoveredSummary!);
|
||||
} else if (state.hoveredResult != null) {
|
||||
return SearchResultPreview(data: state.hoveredResult!);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AIOverviewIndicator extends StatelessWidget {
|
||||
const AIOverviewIndicator({
|
||||
super.key,
|
||||
this.duration = const Duration(seconds: 1),
|
||||
});
|
||||
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5);
|
||||
return SelectionContainer.disabled(
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
width: 100,
|
||||
child: SeparatedRow(
|
||||
separatorBuilder: () => const HSpace(4),
|
||||
children: [
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+103
-402
@@ -1,441 +1,142 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_search_cell.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||
import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SearchSummaryCell extends StatefulWidget {
|
||||
class SearchSummaryCell extends StatelessWidget {
|
||||
const SearchSummaryCell({
|
||||
required this.summary,
|
||||
required this.maxWidth,
|
||||
required this.theme,
|
||||
required this.isHovered,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final SearchSummaryPB summary;
|
||||
final double maxWidth;
|
||||
final AppFlowyThemeData theme;
|
||||
|
||||
@override
|
||||
State<SearchSummaryCell> createState() => _SearchSummaryCellState();
|
||||
}
|
||||
|
||||
class _SearchSummaryCellState extends State<SearchSummaryCell> {
|
||||
late TextPainter _painter;
|
||||
late _TextInfo _textInfo = _TextInfo.normal(summary.content);
|
||||
bool tappedShowMore = false;
|
||||
final maxLines = 5;
|
||||
final popoverController = PopoverController();
|
||||
bool isLinkHovering = false, isReferenceHovering = false;
|
||||
|
||||
SearchSummaryPB get summary => widget.summary;
|
||||
double get maxWidth => widget.maxWidth;
|
||||
AppFlowyThemeData get theme => widget.theme;
|
||||
|
||||
TextStyle get textStyle => theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
);
|
||||
|
||||
TextStyle get showMoreStyle => theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
);
|
||||
|
||||
TextStyle get showMoreUnderlineStyle => theme.textStyle.body.underline(
|
||||
color: theme.textColorScheme.secondary,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
refreshTextPainter();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SearchSummaryCell oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.maxWidth != maxWidth) {
|
||||
refreshTextPainter();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_painter.dispose();
|
||||
popoverController.close();
|
||||
super.dispose();
|
||||
}
|
||||
final bool isHovered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showReference = summary.sources.isNotEmpty;
|
||||
final query = summary.highlights.isEmpty
|
||||
? context.read<CommandPaletteBloc?>()?.state.query
|
||||
: summary.highlights;
|
||||
|
||||
return _textInfo.build(
|
||||
context: context,
|
||||
normal: textStyle,
|
||||
more: showMoreStyle,
|
||||
query: query ?? '',
|
||||
moreUnderline: showMoreUnderlineStyle,
|
||||
showMore: () {
|
||||
setState(() {
|
||||
tappedShowMore = true;
|
||||
_textInfo = _TextInfo.normal(summary.content);
|
||||
});
|
||||
return FlowyHover(
|
||||
isSelected: () => isHovered,
|
||||
onHover: (value) {
|
||||
context.read<SearchResultListBloc>().add(
|
||||
SearchResultListEvent.onHoverSummary(
|
||||
summary: summary,
|
||||
userHovered: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
normalWidgetSpan: showReference
|
||||
? WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (event) {
|
||||
if (!isLinkHovering) {
|
||||
isLinkHovering = true;
|
||||
showPopover();
|
||||
}
|
||||
},
|
||||
onExit: (event) {
|
||||
if (isLinkHovering) {
|
||||
isLinkHovering = false;
|
||||
tryToHidePopover();
|
||||
}
|
||||
},
|
||||
child: buildReferenceIcon(),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildReferenceIcon() {
|
||||
final iconSize = Size(21, 15), placeholderHeight = iconSize.height + 10.0;
|
||||
return AppFlowyPopover(
|
||||
offset: Offset(0, -iconSize.height),
|
||||
constraints:
|
||||
BoxConstraints(maxWidth: 360, maxHeight: 420 + placeholderHeight),
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
margin: EdgeInsets.zero,
|
||||
controller: popoverController,
|
||||
decorationColor: Colors.transparent,
|
||||
popoverDecoration: BoxDecoration(),
|
||||
popupBuilder: (popoverContext) => MouseRegion(
|
||||
onEnter: (event) {
|
||||
if (!isReferenceHovering) {
|
||||
isReferenceHovering = true;
|
||||
}
|
||||
},
|
||||
onExit: (event) {
|
||||
if (isReferenceHovering) {
|
||||
isReferenceHovering = false;
|
||||
tryToHidePopover();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: placeholderHeight),
|
||||
Flexible(
|
||||
child: ReferenceSources(
|
||||
summary.sources,
|
||||
onClose: () {
|
||||
hidePopover();
|
||||
if (context.mounted) FlowyOverlay.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: HoverStyle(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
hoverColor:
|
||||
Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
foregroundColorOnHover: AFThemeExtension.of(context).textColor,
|
||||
),
|
||||
child: Container(
|
||||
width: iconSize.width,
|
||||
height: iconSize.height,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.toolbar_link_m,
|
||||
color: theme.iconColorScheme.primary,
|
||||
size: Size.square(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: FlowyText(
|
||||
summary.content,
|
||||
maxLines: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showPopover() {
|
||||
keepEditorFocusNotifier.increase();
|
||||
popoverController.show();
|
||||
}
|
||||
|
||||
void hidePopover() {
|
||||
popoverController.close();
|
||||
keepEditorFocusNotifier.decrease();
|
||||
}
|
||||
|
||||
void tryToHidePopover() {
|
||||
if (isLinkHovering || isReferenceHovering) return;
|
||||
Future.delayed(Duration(milliseconds: 500), () {
|
||||
if (!context.mounted) return;
|
||||
if (isLinkHovering || isReferenceHovering) return;
|
||||
hidePopover();
|
||||
});
|
||||
}
|
||||
|
||||
void refreshTextPainter() {
|
||||
final content = summary.content,
|
||||
ellipsis = ' ...${LocaleKeys.search_seeMore.tr()}';
|
||||
if (!tappedShowMore) {
|
||||
_painter = TextPainter(
|
||||
text: TextSpan(text: content, style: textStyle),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: maxLines,
|
||||
ellipsis: ellipsis,
|
||||
);
|
||||
_painter.layout(maxWidth: maxWidth);
|
||||
if (_painter.didExceedMaxLines) {
|
||||
final lines = _painter.computeLineMetrics();
|
||||
final lastLine = lines.last;
|
||||
final offset = Offset(
|
||||
lastLine.left + lastLine.width,
|
||||
lines.map((e) => e.height).reduce((a, b) => a + b),
|
||||
);
|
||||
final range = _painter.getPositionForOffset(offset);
|
||||
final text = content.substring(0, range.offset);
|
||||
_textInfo = _TextInfo.overflow(text);
|
||||
} else {
|
||||
_textInfo = _TextInfo.normal(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TextInfo {
|
||||
_TextInfo({required this.text, required this.isOverflow});
|
||||
|
||||
_TextInfo.normal(this.text) : isOverflow = false;
|
||||
|
||||
_TextInfo.overflow(this.text) : isOverflow = true;
|
||||
|
||||
final String text;
|
||||
final bool isOverflow;
|
||||
|
||||
Widget build({
|
||||
required BuildContext context,
|
||||
required TextStyle normal,
|
||||
required TextStyle more,
|
||||
required TextStyle moreUnderline,
|
||||
required VoidCallback showMore,
|
||||
required String query,
|
||||
WidgetSpan? normalWidgetSpan,
|
||||
}) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
if (isOverflow) {
|
||||
return SelectionArea(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
_buildHighLightSpan(
|
||||
content: text,
|
||||
normal: normal,
|
||||
query: query,
|
||||
highlight: normal.copyWith(
|
||||
backgroundColor: theme.fillColorScheme.themeSelect,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ...',
|
||||
style: normal,
|
||||
),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: AFBaseButton(
|
||||
padding: EdgeInsets.zero,
|
||||
builder: (context, isHovering, disabled) =>
|
||||
SelectionContainer.disabled(
|
||||
child: Text(
|
||||
LocaleKeys.search_seeMore.tr(),
|
||||
style: isHovering ? moreUnderline : more,
|
||||
),
|
||||
),
|
||||
borderColor: (_, __, ___, ____) => Colors.transparent,
|
||||
borderRadius: 0,
|
||||
onTap: showMore,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SelectionArea(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
_buildHighLightSpan(
|
||||
content: text,
|
||||
normal: normal,
|
||||
query: query,
|
||||
highlight: normal.copyWith(
|
||||
backgroundColor: theme.fillColorScheme.themeSelect,
|
||||
),
|
||||
),
|
||||
if (normalWidgetSpan != null) normalWidgetSpan,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextSpan _buildHighLightSpan({
|
||||
required String content,
|
||||
required TextStyle normal,
|
||||
required TextStyle highlight,
|
||||
String? query,
|
||||
}) {
|
||||
final queryText = (query ?? '').trim();
|
||||
if (queryText.isEmpty) {
|
||||
return TextSpan(text: content, style: normal);
|
||||
}
|
||||
final contents = content.splitIncludeSeparator(queryText);
|
||||
return TextSpan(
|
||||
children: List.generate(contents.length, (index) {
|
||||
final content = contents[index];
|
||||
final isHighlight = content.toLowerCase() == queryText.toLowerCase();
|
||||
return TextSpan(
|
||||
text: content,
|
||||
style: isHighlight ? highlight : normal,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReferenceSources extends StatelessWidget {
|
||||
const ReferenceSources(
|
||||
this.sources, {
|
||||
class SearchSummaryPreview extends StatelessWidget {
|
||||
const SearchSummaryPreview({
|
||||
required this.summary,
|
||||
super.key,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
final List<SearchSourcePB> sources;
|
||||
final VoidCallback? onClose;
|
||||
final SearchSummaryPB summary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Container(
|
||||
decoration: ShapeDecoration(
|
||||
color: theme.surfaceColorScheme.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(theme.spacing.m),
|
||||
),
|
||||
shadows: theme.shadow.small,
|
||||
),
|
||||
padding: EdgeInsets.all(8),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: Text(
|
||||
LocaleKeys.commandPalette_aiOverviewSource.tr(),
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (summary.highlights.isNotEmpty) ...[
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: FlowyText(
|
||||
LocaleKeys.commandPalette_aiOverviewMoreDetails.tr(),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
final source = sources[index];
|
||||
final displayName = source.displayName.isEmpty
|
||||
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
|
||||
: source.displayName;
|
||||
final sapceM = theme.spacing.m, spaceL = theme.spacing.l;
|
||||
|
||||
return AFBaseButton(
|
||||
borderRadius: sapceM,
|
||||
onTap: () {
|
||||
getIt<ActionNavigationBloc>().add(
|
||||
ActionNavigationEvent.performAction(
|
||||
action: NavigationAction(objectId: source.id),
|
||||
),
|
||||
);
|
||||
onClose?.call();
|
||||
},
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: spaceL,
|
||||
horizontal: sapceM,
|
||||
),
|
||||
backgroundColor: (context, isHovering, disable) {
|
||||
if (isHovering) {
|
||||
return Theme.of(context).colorScheme.secondary;
|
||||
}
|
||||
return theme.fillColorScheme.transparent;
|
||||
},
|
||||
borderColor: (context, isHovering, disabled, isFocused) =>
|
||||
theme.fillColorScheme.transparent,
|
||||
builder: (context, isHovering, disabled) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 20,
|
||||
child: Center(child: buildIcon(source.icon, theme)),
|
||||
),
|
||||
HSpace(8),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => AFDivider(),
|
||||
itemCount: sources.length,
|
||||
),
|
||||
const VSpace(6),
|
||||
SearchSummaryDetail(text: summary.highlights),
|
||||
const VSpace(36),
|
||||
],
|
||||
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: FlowyText(
|
||||
LocaleKeys.commandPalette_aiOverviewSource.tr(),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
// Sources
|
||||
const VSpace(6),
|
||||
...summary.sources.map((e) => SearchSummarySource(source: e)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchSummaryDetail extends StatelessWidget {
|
||||
const SearchSummaryDetail({
|
||||
required this.text,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: AIMarkdownText(markdown: text),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchSummarySource extends StatelessWidget {
|
||||
const SearchSummarySource({
|
||||
required this.source,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final SearchSourcePB source;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = source.icon.getIcon();
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.commandPalette_clickToOpenPage.tr(),
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: FlowyButton(
|
||||
leftIcon: icon,
|
||||
hoverColor:
|
||||
Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
text: FlowyText(source.displayName),
|
||||
onTap: () {
|
||||
context.read<SearchResultListBloc>().add(
|
||||
SearchResultListEvent.openPage(pageId: source.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildIcon(ResultIconPB icon, AppFlowyThemeData theme) {
|
||||
if (icon.ty == ResultIconTypePB.Emoji) {
|
||||
return icon.getIcon(size: 16, lineHeight: 20 / 16) ?? SizedBox.shrink();
|
||||
} else {
|
||||
return icon.getIcon(size: 20, iconColor: theme.iconColorScheme.primary) ??
|
||||
SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,10 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
|
||||
@@ -25,7 +23,6 @@ import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
|
||||
show UserProfilePB;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/container.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -119,9 +116,6 @@ class DesktopHomeScreen extends StatelessWidget {
|
||||
TabsEvent.openPlugin(plugin: view.plugin()),
|
||||
);
|
||||
}
|
||||
|
||||
// switch to the space that contains the last opened view
|
||||
_switchToSpace(view);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<HomeSettingBloc, HomeSettingState>(
|
||||
@@ -129,28 +123,14 @@ class DesktopHomeScreen extends StatelessWidget {
|
||||
builder: (context, state) => BlocProvider(
|
||||
create: (_) => UserWorkspaceBloc(userProfile: userProfile)
|
||||
..add(const UserWorkspaceEvent.initial()),
|
||||
child: BlocListener<UserWorkspaceBloc, UserWorkspaceState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.currentWorkspace != current.currentWorkspace,
|
||||
listener: (context, state) {
|
||||
if (!context.mounted) return;
|
||||
final workspaceBloc =
|
||||
context.read<UserWorkspaceBloc?>();
|
||||
final spaceBloc = context.read<SpaceBloc?>();
|
||||
CommandPalette.maybeOf(context)?.updateBlocs(
|
||||
workspaceBloc: workspaceBloc,
|
||||
spaceBloc: spaceBloc,
|
||||
);
|
||||
},
|
||||
child: HomeHotKeys(
|
||||
userProfile: userProfile,
|
||||
child: FlowyContainer(
|
||||
Theme.of(context).colorScheme.surface,
|
||||
child: _buildBody(
|
||||
context,
|
||||
userProfile,
|
||||
workspaceLatest,
|
||||
),
|
||||
child: HomeHotKeys(
|
||||
userProfile: userProfile,
|
||||
child: FlowyContainer(
|
||||
Theme.of(context).colorScheme.surface,
|
||||
child: _buildBody(
|
||||
context,
|
||||
userProfile,
|
||||
workspaceLatest,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -310,16 +290,6 @@ class DesktopHomeScreen extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _switchToSpace(ViewPB view) async {
|
||||
final ancestors = await ViewBackendService.getViewAncestors(view.id);
|
||||
final space = ancestors.fold(
|
||||
(ancestors) =>
|
||||
ancestors.items.firstWhereOrNull((ancestor) => ancestor.isSpace),
|
||||
(error) => null,
|
||||
);
|
||||
switchToSpaceNotifier.value = space;
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopHomeScreenStackAdaptor extends HomeStackDelegate {
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_b
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
@@ -19,7 +18,6 @@ typedef KeyDownHandler = void Function(HotKey hotKey);
|
||||
|
||||
ValueNotifier<int> switchToTheNextSpace = ValueNotifier(0);
|
||||
ValueNotifier<int> createNewPageNotifier = ValueNotifier(0);
|
||||
ValueNotifier<ViewPB?> switchToSpaceNotifier = ValueNotifier(null);
|
||||
|
||||
@visibleForTesting
|
||||
final zoomInKeyCodes = [KeyCode.equal, KeyCode.numpadAdd, KeyCode.add];
|
||||
|
||||
@@ -516,12 +516,7 @@ class _SidebarSearchButton extends StatelessWidget {
|
||||
onTap: () {
|
||||
// exit editing mode when doing search to avoid the toolbar showing up
|
||||
EditorNotification.exitEditing().post();
|
||||
final workspaceBloc = context.read<UserWorkspaceBloc?>();
|
||||
final spaceBloc = context.read<SpaceBloc?>();
|
||||
CommandPalette.of(context).toggle(
|
||||
workspaceBloc: workspaceBloc,
|
||||
spaceBloc: spaceBloc,
|
||||
);
|
||||
CommandPalette.of(context).toggle();
|
||||
},
|
||||
leftIcon: const FlowySvg(FlowySvgs.search_s),
|
||||
iconPadding: 12.0,
|
||||
|
||||
-16
@@ -73,9 +73,7 @@ class _SpaceState extends State<_Space> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
switchToTheNextSpace.addListener(_switchToNextSpace);
|
||||
switchToSpaceNotifier.addListener(_switchToSpace);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -83,7 +81,6 @@ class _SpaceState extends State<_Space> {
|
||||
switchToTheNextSpace.removeListener(_switchToNextSpace);
|
||||
isHovered.dispose();
|
||||
isExpandedNotifier.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -178,17 +175,4 @@ class _SpaceState extends State<_Space> {
|
||||
void _switchToNextSpace() {
|
||||
context.read<SpaceBloc>().add(const SpaceEvent.switchToNextSpace());
|
||||
}
|
||||
|
||||
void _switchToSpace() {
|
||||
if (!mounted || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final space = switchToSpaceNotifier.value;
|
||||
if (space == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<SpaceBloc>().add(SpaceEvent.open(space));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
|
||||
@@ -888,8 +887,6 @@ void moveViewCrossSpace(
|
||||
'Move view(${from.name}) to another space(${toSpace.name}), unpublish the view',
|
||||
);
|
||||
context.read<ViewBloc>().add(const ViewEvent.unpublish(sync: false));
|
||||
|
||||
switchToSpaceNotifier.value = toSpace;
|
||||
}
|
||||
|
||||
context.read<ViewBloc>().add(ViewEvent.move(from, toId, null, null, null));
|
||||
|
||||
+6
-2
@@ -101,7 +101,9 @@ class _Description extends StatelessWidget {
|
||||
LocaleKeys.settings_appearance_members_inviteFailedDialogTitle.tr(),
|
||||
description:
|
||||
LocaleKeys.settings_appearance_members_inviteFailedMemberLimit.tr(),
|
||||
confirmLabel: LocaleKeys.upgradePlanModal_actionButton.tr(),
|
||||
confirmLabel: LocaleKeys
|
||||
.settings_appearance_members_memberLimitExceededUpgrade
|
||||
.tr(),
|
||||
onConfirm: () => context
|
||||
.read<WorkspaceMemberBloc>()
|
||||
.add(const WorkspaceMemberEvent.upgradePlan()),
|
||||
@@ -180,7 +182,9 @@ class _CopyLinkButtonState extends State<_CopyLinkButton> {
|
||||
description: LocaleKeys
|
||||
.settings_appearance_members_inviteFailedMemberLimit
|
||||
.tr(),
|
||||
confirmLabel: LocaleKeys.upgradePlanModal_actionButton.tr(),
|
||||
confirmLabel: LocaleKeys
|
||||
.settings_appearance_members_memberLimitExceededUpgrade
|
||||
.tr(),
|
||||
onConfirm: () => context
|
||||
.read<WorkspaceMemberBloc>()
|
||||
.add(const WorkspaceMemberEvent.upgradePlan()),
|
||||
|
||||
@@ -43,10 +43,9 @@ Future<void> showSimpleAFDialog({
|
||||
trailing: [
|
||||
AFGhostButton.normal(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.all(theme.spacing.xs),
|
||||
builder: (context, isHovering, disabled) {
|
||||
return FlowySvg(
|
||||
FlowySvgs.toast_close_s,
|
||||
FlowySvgs.close_s,
|
||||
size: Size.square(20),
|
||||
);
|
||||
},
|
||||
@@ -58,12 +57,7 @@ Future<void> showSimpleAFDialog({
|
||||
// AFModalDimension.dialogHeight - header - footer
|
||||
constraints: BoxConstraints(minHeight: 108.0),
|
||||
child: AFModalBody(
|
||||
child: Text(
|
||||
content,
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
child: Text(content),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+4
-1
@@ -109,7 +109,10 @@ class LockPageButtonWrapper extends StatelessWidget {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.lockPage_lockedOperationTooltip.tr(),
|
||||
child: IgnorePointer(
|
||||
child: child,
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,11 +136,9 @@ class ViewTitleBar extends StatelessWidget {
|
||||
? ViewTitleBehavior.editable // only the last one is editable
|
||||
: ViewTitleBehavior.uneditable, // others are not editable
|
||||
onUpdated: () {
|
||||
if (context.mounted) {
|
||||
context
|
||||
.read<ViewTitleBarBloc>()
|
||||
.add(const ViewTitleBarEvent.reload());
|
||||
}
|
||||
context
|
||||
.read<ViewTitleBarBloc>()
|
||||
.add(const ViewTitleBarEvent.reload());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -135,32 +135,32 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468
|
||||
appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7
|
||||
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
|
||||
bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9
|
||||
connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5
|
||||
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
|
||||
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
||||
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
|
||||
flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e
|
||||
app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
|
||||
appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9
|
||||
auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d
|
||||
bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
|
||||
connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747
|
||||
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
|
||||
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
|
||||
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
|
||||
flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
|
||||
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
|
||||
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
|
||||
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
|
||||
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
|
||||
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
|
||||
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
||||
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
|
||||
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
Sparkle: 9c328bdcfbcaf8f030c4b678eadfd0fcb03822d8
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
|
||||
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
|
||||
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
|
||||
window_manager: e8d0b1431ab6c454f2b5c9ae26004bbfa43469aa
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
|
||||
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
|
||||
window_manager: 990c8e348c4da2a93b81da638245d40554ec9436
|
||||
|
||||
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823
|
||||
|
||||
|
||||
@@ -4,14 +4,7 @@ String languageFromLocale(Locale locale) {
|
||||
switch (locale.languageCode) {
|
||||
// Most often used languages
|
||||
case "en":
|
||||
switch (locale.countryCode) {
|
||||
case "GB":
|
||||
return "English (GB)";
|
||||
case "US":
|
||||
return "English (US)";
|
||||
default:
|
||||
return "English (US)";
|
||||
}
|
||||
return "English";
|
||||
case "zh":
|
||||
switch (locale.countryCode) {
|
||||
case "CN":
|
||||
|
||||
@@ -50,32 +50,3 @@ class _FlowyScrollbarState extends State<FlowyScrollbar> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollControllerBuilder extends StatefulWidget {
|
||||
const ScrollControllerBuilder({super.key, required this.builder});
|
||||
final ScrollControllerWidgetBuilder builder;
|
||||
|
||||
@override
|
||||
State<ScrollControllerBuilder> createState() =>
|
||||
_ScrollControllerBuilderState();
|
||||
}
|
||||
|
||||
class _ScrollControllerBuilderState extends State<ScrollControllerBuilder> {
|
||||
final ScrollController controller = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.builder(context, controller);
|
||||
}
|
||||
}
|
||||
|
||||
typedef ScrollControllerWidgetBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
ScrollController controller,
|
||||
);
|
||||
@@ -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.9.2
|
||||
version: 0.9.1
|
||||
|
||||
environment:
|
||||
flutter: ">=3.27.4"
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
import 'package:appflowy/ai/service/view_selector_cubit.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
ViewPB testView(
|
||||
String id,
|
||||
String name,
|
||||
ViewLayoutPB layout, [
|
||||
bool isSpace = false,
|
||||
List<ViewPB> children = const [],
|
||||
]) {
|
||||
return ViewPB()
|
||||
..id = id
|
||||
..name = name
|
||||
..layout = layout
|
||||
..isSpace = isSpace
|
||||
..childViews.addAll(children);
|
||||
}
|
||||
|
||||
List<ViewPB> createTestViews() {
|
||||
return [
|
||||
testView('1', 'View 1', ViewLayoutPB.Document, true, [
|
||||
testView('1-1', 'View 1-1', ViewLayoutPB.Document),
|
||||
testView('1-2', 'View 1-2', ViewLayoutPB.Document),
|
||||
]),
|
||||
testView('2', 'View 2', ViewLayoutPB.Document, false, [
|
||||
testView('2-1', 'View 2-1', ViewLayoutPB.Document),
|
||||
testView('2-2', 'View 2-2', ViewLayoutPB.Grid),
|
||||
testView('2-3', 'View 2-3', ViewLayoutPB.Document, false, [
|
||||
testView('2-3-1', 'View 2-3-1', ViewLayoutPB.Document),
|
||||
]),
|
||||
]),
|
||||
testView('3', 'View 3', ViewLayoutPB.Document, true, [
|
||||
testView('3-1', 'View 3-1', ViewLayoutPB.Grid, false, [
|
||||
testView('3-1-1', 'View 3-1-1', ViewLayoutPB.Board),
|
||||
]),
|
||||
]),
|
||||
testView('4', 'View 4', ViewLayoutPB.Document, true, [
|
||||
testView('4-1', 'View 4-1', ViewLayoutPB.Chat),
|
||||
testView('4-2', 'View 4-2', ViewLayoutPB.Document, false, [
|
||||
testView('4-2-1', 'View 4-2-1', ViewLayoutPB.Document),
|
||||
testView('4-2-2', 'View 4-2-2', ViewLayoutPB.Document),
|
||||
]),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
Map<String, ViewSelectedStatus> getSelectedStatus(
|
||||
List<ViewSelectorItem> items,
|
||||
) {
|
||||
return {
|
||||
for (final item in items) item.view.id: item.selectedStatus,
|
||||
for (final item in items)
|
||||
if (item.children.isNotEmpty) ...getSelectedStatus(item.children),
|
||||
};
|
||||
}
|
||||
|
||||
group('ViewSelectorCubit test:', () {
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'initial state',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 1,
|
||||
),
|
||||
act: (_) {},
|
||||
verify: (cubit) {
|
||||
final s = cubit.state;
|
||||
expect(s.visibleSources, isEmpty);
|
||||
expect(s.selectedSources, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'update sources',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 1,
|
||||
),
|
||||
act: (cubit) async {
|
||||
final views = createTestViews();
|
||||
await cubit.refreshSources(views, views.first);
|
||||
},
|
||||
verify: (cubit) {
|
||||
final s = cubit.state;
|
||||
expect(s.visibleSources.length, 4);
|
||||
expect(s.visibleSources[0].isExpanded, isTrue);
|
||||
expect(s.visibleSources[0].children.length, 2);
|
||||
expect(s.visibleSources[1].children.length, 3);
|
||||
expect(s.visibleSources[2].children.length, 1);
|
||||
expect(s.visibleSources[3].children.length, 2);
|
||||
expect(s.selectedSources.isEmpty, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'update sources multiple times',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 1,
|
||||
),
|
||||
act: (cubit) async {
|
||||
final views = createTestViews();
|
||||
await cubit.refreshSources([], null);
|
||||
await cubit.refreshSources(views, null);
|
||||
await cubit.refreshSources([], null);
|
||||
},
|
||||
expect: () => [
|
||||
predicate<ViewSelectorState>(
|
||||
(s) => s.visibleSources.isEmpty && s.selectedSources.isEmpty,
|
||||
),
|
||||
predicate<ViewSelectorState>(
|
||||
(s) => s.visibleSources.isNotEmpty && s.selectedSources.isEmpty,
|
||||
),
|
||||
predicate<ViewSelectorState>(
|
||||
(s) => s.visibleSources.isEmpty && s.selectedSources.isEmpty,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'update selected sources',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 100,
|
||||
),
|
||||
act: (cubit) async {
|
||||
final views = createTestViews();
|
||||
cubit.updateSelectedSources([
|
||||
'2-3-1',
|
||||
'3-1',
|
||||
'3-1-1',
|
||||
'4-2',
|
||||
'4-2-1',
|
||||
]);
|
||||
await cubit.refreshSources(views, null);
|
||||
},
|
||||
skip: 1,
|
||||
expect: () => [
|
||||
predicate<ViewSelectorState>((s) {
|
||||
final lengthCheck =
|
||||
s.visibleSources.length == 4 && s.selectedSources.length == 3;
|
||||
|
||||
final expected = {
|
||||
'1': ViewSelectedStatus.unselected,
|
||||
'1-1': ViewSelectedStatus.unselected,
|
||||
'1-2': ViewSelectedStatus.unselected,
|
||||
'2': ViewSelectedStatus.partiallySelected,
|
||||
'2-1': ViewSelectedStatus.unselected,
|
||||
'2-2': ViewSelectedStatus.unselected,
|
||||
'2-3': ViewSelectedStatus.partiallySelected,
|
||||
'2-3-1': ViewSelectedStatus.selected,
|
||||
'3': ViewSelectedStatus.partiallySelected,
|
||||
'3-1': ViewSelectedStatus.selected,
|
||||
'3-1-1': ViewSelectedStatus.selected,
|
||||
'4': ViewSelectedStatus.partiallySelected,
|
||||
'4-1': ViewSelectedStatus.unselected,
|
||||
'4-2': ViewSelectedStatus.partiallySelected,
|
||||
'4-2-1': ViewSelectedStatus.selected,
|
||||
'4-2-2': ViewSelectedStatus.unselected,
|
||||
};
|
||||
|
||||
final actual = getSelectedStatus(s.visibleSources);
|
||||
|
||||
return lengthCheck && mapEquals(expected, actual);
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'select a source 1',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 100,
|
||||
),
|
||||
act: (cubit) async {
|
||||
final views = createTestViews();
|
||||
await cubit.refreshSources(views, null);
|
||||
cubit.toggleSelectedStatus(
|
||||
cubit.state.visibleSources[1].children[2].children[0], // '2-3-1',
|
||||
false,
|
||||
);
|
||||
},
|
||||
skip: 1,
|
||||
expect: () => [
|
||||
predicate<ViewSelectorState>((s) {
|
||||
return getSelectedStatus(s.visibleSources)
|
||||
.values
|
||||
.every((value) => value == ViewSelectedStatus.unselected);
|
||||
}),
|
||||
predicate<ViewSelectorState>((s) {
|
||||
final selectedStatusMap = getSelectedStatus(s.visibleSources);
|
||||
return selectedStatusMap['2-3'] ==
|
||||
ViewSelectedStatus.partiallySelected &&
|
||||
selectedStatusMap['2-3-1'] == ViewSelectedStatus.selected;
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'select a source 2',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 100,
|
||||
),
|
||||
act: (cubit) async {
|
||||
final views = createTestViews();
|
||||
await cubit.refreshSources(views, null);
|
||||
cubit.toggleSelectedStatus(
|
||||
cubit.state.visibleSources[1].children[2], // '2-3',
|
||||
false,
|
||||
);
|
||||
cubit.toggleSelectedStatus(
|
||||
cubit.state.visibleSources[1].children[2].children[0], // '2-3-1',
|
||||
false,
|
||||
);
|
||||
},
|
||||
skip: 2,
|
||||
expect: () => [
|
||||
predicate<ViewSelectorState>((s) {
|
||||
final selectedStatusMap = getSelectedStatus(s.visibleSources);
|
||||
return selectedStatusMap['2-3'] == ViewSelectedStatus.selected &&
|
||||
selectedStatusMap['2-3-1'] == ViewSelectedStatus.selected;
|
||||
}),
|
||||
predicate<ViewSelectorState>((s) {
|
||||
final selectedStatusMap = getSelectedStatus(s.visibleSources);
|
||||
return selectedStatusMap['2-3'] ==
|
||||
ViewSelectedStatus.partiallySelected &&
|
||||
selectedStatusMap['2-3-1'] == ViewSelectedStatus.unselected;
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'select a source 3',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 100,
|
||||
),
|
||||
act: (cubit) async {
|
||||
final views = createTestViews();
|
||||
cubit.updateSelectedSources(['2-3', '2-3-1']);
|
||||
await cubit.refreshSources(views, null);
|
||||
cubit.toggleSelectedStatus(
|
||||
cubit.state.visibleSources[1].children[2], // '2-3',
|
||||
false,
|
||||
);
|
||||
},
|
||||
skip: 1,
|
||||
expect: () => [
|
||||
predicate<ViewSelectorState>((s) {
|
||||
final selectedStatusMap = getSelectedStatus(s.visibleSources);
|
||||
return selectedStatusMap['2-3'] == ViewSelectedStatus.selected &&
|
||||
selectedStatusMap['2-3-1'] == ViewSelectedStatus.selected;
|
||||
}),
|
||||
predicate<ViewSelectorState>((s) {
|
||||
final selectedStatusMap = getSelectedStatus(s.visibleSources);
|
||||
return selectedStatusMap['2-3'] == ViewSelectedStatus.unselected &&
|
||||
selectedStatusMap['2-3-1'] == ViewSelectedStatus.unselected;
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'select a source 4',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 100,
|
||||
),
|
||||
act: (cubit) async {
|
||||
final views = createTestViews();
|
||||
cubit.updateSelectedSources(['4-2', '4-2-1']);
|
||||
await cubit.refreshSources(views, null);
|
||||
cubit.toggleSelectedStatus(
|
||||
cubit.state.visibleSources[3].children[1].children[1], // 4-2-2,
|
||||
false,
|
||||
);
|
||||
},
|
||||
skip: 1,
|
||||
expect: () => [
|
||||
predicate<ViewSelectorState>((s) {
|
||||
final selectedStatusMap = getSelectedStatus(s.visibleSources);
|
||||
return selectedStatusMap['4-2'] ==
|
||||
ViewSelectedStatus.partiallySelected &&
|
||||
selectedStatusMap['4-2-1'] == ViewSelectedStatus.selected &&
|
||||
selectedStatusMap['4-2-2'] == ViewSelectedStatus.unselected;
|
||||
}),
|
||||
predicate<ViewSelectorState>((s) {
|
||||
final selectedStatusMap = getSelectedStatus(s.visibleSources);
|
||||
return selectedStatusMap['4-2'] == ViewSelectedStatus.selected &&
|
||||
selectedStatusMap['4-2-1'] == ViewSelectedStatus.selected &&
|
||||
selectedStatusMap['4-2-2'] == ViewSelectedStatus.selected;
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'select a source 5',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 100,
|
||||
),
|
||||
act: (cubit) async {
|
||||
final views = createTestViews();
|
||||
cubit.updateSelectedSources(['4-2', '4-2-1']);
|
||||
await cubit.refreshSources(views, null);
|
||||
cubit.toggleSelectedStatus(
|
||||
cubit.state.visibleSources[3].children[1], // 4-2
|
||||
false,
|
||||
);
|
||||
},
|
||||
verify: (cubit) {
|
||||
final selectedStatusMap = getSelectedStatus(cubit.state.visibleSources);
|
||||
expect(selectedStatusMap['4-2'], ViewSelectedStatus.unselected);
|
||||
expect(selectedStatusMap['4-2-1'], ViewSelectedStatus.unselected);
|
||||
expect(selectedStatusMap['4-2-2'], ViewSelectedStatus.unselected);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'cannot select more than maximum selection limit',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 2,
|
||||
),
|
||||
act: (cubit) async {
|
||||
final views = createTestViews();
|
||||
cubit.updateSelectedSources(['1-1', '2-1']);
|
||||
await cubit.refreshSources(views, null);
|
||||
},
|
||||
verify: (cubit) {
|
||||
final s = cubit.state;
|
||||
expect(s.visibleSources[0].children[0].isDisabled, isFalse);
|
||||
expect(s.visibleSources[0].children[1].isDisabled, isFalse);
|
||||
expect(s.visibleSources[1].children[0].isDisabled, isFalse);
|
||||
expect(s.visibleSources[1].children[1].isDisabled, isFalse);
|
||||
expect(s.visibleSources[2].children[0].isDisabled, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<ViewSelectorCubit, ViewSelectorState>(
|
||||
'filter sources correctly',
|
||||
build: () => ViewSelectorCubit(
|
||||
getIgnoreViewType: (_) => IgnoreViewType.none,
|
||||
maxSelectedParentPageCount: 1,
|
||||
),
|
||||
act: (cubit) async {
|
||||
final views = createTestViews();
|
||||
await cubit.refreshSources(views, null);
|
||||
cubit.filterTextController.text = 'View 1';
|
||||
},
|
||||
verify: (cubit) {
|
||||
final s = cubit.state;
|
||||
expect(s.visibleSources.length, 1);
|
||||
expect(s.visibleSources[0].children.length, 2);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FileBlock:', () {
|
||||
test('insert file block in non-empty paragraph', () async {
|
||||
final document = Document.blank()
|
||||
..insert(
|
||||
[0],
|
||||
[paragraphNode(text: 'Hello World')],
|
||||
);
|
||||
final editorState = EditorState(document: document);
|
||||
editorState.selection = Selection.collapsed(Position(path: [0]));
|
||||
|
||||
// insert file block after the first line
|
||||
await editorState.insertEmptyFileBlock(GlobalKey());
|
||||
|
||||
final afterDocument = editorState.document;
|
||||
expect(afterDocument.root.children.length, 2);
|
||||
expect(afterDocument.root.children[1].type, FileBlockKeys.type);
|
||||
expect(afterDocument.root.children[0].type, ParagraphBlockKeys.type);
|
||||
expect(
|
||||
afterDocument.root.children[0].delta!.toPlainText(),
|
||||
'Hello World',
|
||||
);
|
||||
});
|
||||
|
||||
test('insert file block in empty paragraph', () async {
|
||||
final document = Document.blank()
|
||||
..insert(
|
||||
[0],
|
||||
[paragraphNode(text: '')],
|
||||
);
|
||||
final editorState = EditorState(document: document);
|
||||
editorState.selection = Selection.collapsed(Position(path: [0]));
|
||||
|
||||
await editorState.insertEmptyFileBlock(GlobalKey());
|
||||
|
||||
final afterDocument = editorState.document;
|
||||
expect(afterDocument.root.children.length, 1);
|
||||
expect(afterDocument.root.children[0].type, FileBlockKeys.type);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -15,15 +15,16 @@ class WidgetTestApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return EasyLocalization(
|
||||
supportedLocales: const [Locale('en', 'US')],
|
||||
supportedLocales: const [Locale('en')],
|
||||
path: 'assets/translations',
|
||||
fallbackLocale: const Locale('en', 'US'),
|
||||
fallbackLocale: const Locale('en'),
|
||||
useFallbackTranslations: true,
|
||||
saveLocale: false,
|
||||
assetLoader: const TestBundleAssetLoader(),
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
locale: const Locale('en', 'US'),
|
||||
supportedLocales: const [Locale('en')],
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
theme: ThemeData.light().copyWith(
|
||||
extensions: const [
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.03338 4.65784C8.37931 3.78072 9.62067 3.78072 9.9666 4.65785L11.0386 7.37598C11.1442 7.64377 11.3562 7.85575 11.624 7.96136L14.3422 9.03338C15.2193 9.37931 15.2193 10.6207 14.3422 10.9666L11.624 12.0386C11.3562 12.1442 11.1442 12.3562 11.0386 12.624L9.9666 15.3422C9.62066 16.2193 8.37931 16.2193 8.03338 15.3422L6.96136 12.624C6.85574 12.3562 6.64377 12.1442 6.37598 12.0386L3.65784 10.9666C2.78072 10.6207 2.78072 9.37931 3.65785 9.03338L6.37598 7.96136C6.64377 7.85574 6.85575 7.64377 6.96136 7.37598L8.03338 4.65784Z" stroke="url(#paint0_linear_12729_12921)"/>
|
||||
<path d="M14.7235 14.1853C14.826 13.9382 15.1768 13.9382 15.2792 14.1853L15.412 14.5076C15.6152 14.9979 16.0056 15.3874 16.496 15.5906L16.8163 15.7234C17.0631 15.826 17.0624 16.1758 16.8153 16.2781L16.4931 16.4119C16.0022 16.6151 15.6122 17.0051 15.4091 17.4959L15.2772 17.8142C15.1749 18.0615 14.8249 18.0615 14.7226 17.8142L14.5897 17.4929C14.3866 17.0018 13.996 16.6112 13.5048 16.408L13.1854 16.2761C12.9382 16.1738 12.9382 15.8238 13.1854 15.7214L13.5077 15.5877C13.998 15.3846 14.3874 14.9948 14.5907 14.5046L14.7235 14.1853ZM14.7235 2.18531C14.826 1.93825 15.1768 1.93821 15.2792 2.18531L15.412 2.50757C15.6152 2.99793 16.0056 3.38737 16.496 3.59058L16.8163 3.7234C17.0631 3.82603 17.0624 4.1758 16.8153 4.27808L16.4931 4.41187C16.0022 4.61512 15.6122 5.00506 15.4091 5.49586L15.2772 5.81422C15.1749 6.0615 14.8249 6.06147 14.7226 5.81422L14.5897 5.49293C14.3866 5.00175 13.996 4.61121 13.5048 4.40797L13.1854 4.27613C12.9382 4.17381 12.9382 3.82384 13.1854 3.72144L13.5077 3.58765C13.998 3.38457 14.3874 2.99482 14.5907 2.50465L14.7235 2.18531Z" fill="url(#paint1_linear_12729_12921)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_12729_12921" x1="5" y1="17" x2="18.5897" y2="10.1016" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#8032FF"/>
|
||||
<stop offset="1" stop-color="#FB3DFF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_12729_12921" x1="10" y1="19" x2="16.9581" y2="19.0194" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#8032FF"/>
|
||||
<stop offset="1" stop-color="#FB3DFF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.6665 10H15.3332M15.3332 10L11.3332 6M15.3332 10L11.3332 14" stroke="#6F748C" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 241 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2C14.4183 2 18 5.58172 18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2ZM12.8232 7.17676C12.5872 6.94067 12.204 6.94092 11.9678 7.17676L10 9.14453L8.03223 7.17676C7.79598 6.94091 7.41285 6.94067 7.17676 7.17676C6.94067 7.41285 6.94092 7.79598 7.17676 8.03223L9.14453 10L7.17676 11.9678C6.94091 12.204 6.94067 12.5872 7.17676 12.8232C7.41285 13.0593 7.79598 13.0591 8.03223 12.8232L10 10.8555L11.9678 12.8232C12.204 13.0591 12.5872 13.0593 12.8232 12.8232C13.0593 12.5872 13.0591 12.204 12.8232 11.9678L10.8555 10L12.8232 8.03223C13.0591 7.79598 13.0593 7.41285 12.8232 7.17676Z" fill="#B5BBD3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 748 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.8553 16.8427C16.0516 17.0369 16.3681 17.0353 16.5624 16.839C16.7566 16.6428 16.755 16.3262 16.5588 16.1319L15.8553 16.8427ZM15.2946 9.9746H14.7946C14.7946 12.7242 12.5413 14.9619 9.75082 14.9619V15.4619V15.9619C13.0839 15.9619 15.7946 13.2861 15.7946 9.9746H15.2946ZM9.75082 15.4619V14.9619C6.96037 14.9619 4.70703 12.7242 4.70703 9.9746H4.20703H3.70703C3.70703 13.2861 6.41777 15.9619 9.75082 15.9619V15.4619ZM4.20703 9.9746H4.70703C4.70703 7.22501 6.96037 4.9873 9.75082 4.9873V4.4873V3.9873C6.41777 3.9873 3.70703 6.66309 3.70703 9.9746H4.20703ZM9.75082 4.4873V4.9873C12.5413 4.9873 14.7946 7.22501 14.7946 9.9746H15.2946H15.7946C15.7946 6.66309 13.0839 3.9873 9.75082 3.9873V4.4873ZM13.2303 13.5409L12.8785 13.8963L15.8553 16.8427L16.207 16.4873L16.5588 16.1319L13.582 13.1855L13.2303 13.5409Z" fill="#6F748C"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 932 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 7V13" stroke="#6F748C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M11 16C11 16.2652 11.1054 16.5196 11.2929 16.7071C11.4804 16.8946 11.7348 17 12 17C12.2652 17 12.5196 16.8946 12.7071 16.7071C12.8946 16.5196 13 16.2652 13 16C13 15.7348 12.8946 15.4804 12.7071 15.2929C12.5196 15.1054 12.2652 15 12 15C11.7348 15 11.4804 15.1054 11.2929 15.2929C11.1054 15.4804 11 15.7348 11 16Z" fill="#6F748C"/>
|
||||
<path d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12Z" stroke="#6F748C" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 827 B |
@@ -36,9 +36,7 @@
|
||||
"loginButtonText": "تسجيل الدخول",
|
||||
"loginStartWithAnonymous": "ابدأ بجلسة خفية",
|
||||
"continueAnonymousUser": "استمر بجلسة خفية",
|
||||
"continueWithLocalModel": "الاستمرار بالنموذج المحلي",
|
||||
"switchToAppFlowyCloud": "AppFlowy السحابي",
|
||||
"anonymousMode": "الوضع المجهول",
|
||||
"anonymous": "مجهول",
|
||||
"buttonText": "تسجيل الدخول",
|
||||
"signingInText": "جاري تسجيل الدخول...",
|
||||
"forgotPassword": "هل نسيت كلمة السر؟",
|
||||
@@ -48,7 +46,6 @@
|
||||
"createAccount": "إنشاء حساب",
|
||||
"repeatPasswordEmptyError": "إعادة كلمة المرور لا يمكن أن تكون فارغة",
|
||||
"unmatchedPasswordError": "تكرار كلمة المرور ليس هو نفسه كلمة المرور",
|
||||
"passwordMustContain": "يجب أن تحتوي كلمة المرور على حرف واحد ورقم واحد ورمز واحد على الأقل.",
|
||||
"syncPromptMessage": "قد تستغرق مزامنة البيانات بعض الوقت. من فضلك لا تغلق هذه الصفحة",
|
||||
"or": "أو",
|
||||
"signInWithGoogle": "استكمال باستخدام Google",
|
||||
@@ -72,43 +69,6 @@
|
||||
"generalError": "حدث خطأ ما. يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"limitRateError": "لأسباب أمنية، يمكنك طلب Magic Link كل 60 ثانية فقط",
|
||||
"magicLinkSentDescription": "تم إرسال Magic Link إلى بريدك الإلكتروني. انقر على الرابط لإكمال تسجيل الدخول. ستنتهي صلاحية الرابط بعد 5 دقائق.",
|
||||
"tokenHasExpiredOrInvalid": "الرمز منتهي الصلاحية أو غير صالح. يُرجى المحاولة مرة أخرى.",
|
||||
"signingIn": "جاري تسجيل الدخول...",
|
||||
"checkYourEmail": "تحقق من بريدك الإلكتروني",
|
||||
"temporaryVerificationLinkSent": "تم إرسال رابط التحقق المؤقت.\nيرجى التحقق من صندوق الوارد الخاص بك على",
|
||||
"temporaryVerificationCodeSent": "لقد تم إرسال رمز التحقق المؤقت.\nيرجى التحقق من صندوق الوارد الخاص بك على",
|
||||
"continueToSignIn": "متابعة تسجيل الدخول",
|
||||
"continueWithLoginCode": "متابعة مع رمز تسجيل الدخول",
|
||||
"backToLogin": "العودة إلى تسجيل الدخول",
|
||||
"enterCode": "أدخل الرمز",
|
||||
"enterCodeManually": "أدخل الرمز يدويًا",
|
||||
"continueWithEmail": "متابعة مع البريد الإلكتروني",
|
||||
"enterPassword": "أدخل كلمة المرور",
|
||||
"loginAs": "تسجيل الدخول باسم",
|
||||
"invalidVerificationCode": "الرجاء إدخال رمز التحقق الصحيح",
|
||||
"tooFrequentVerificationCodeRequest": "لقد قدمت طلبات كثيرة. يُرجى المحاولة لاحقًا.",
|
||||
"invalidLoginCredentials": "كلمة المرور الخاصة بك غير صحيحة، يرجى المحاولة مرة أخرى",
|
||||
"resetPassword": "إعادة ضبط كلمة المرور",
|
||||
"resetPasswordDescription": "أدخل بريدك الإلكتروني لإعادة ضبط كلمة المرور الخاصة بك",
|
||||
"continueToResetPassword": "متابعة إعادة ضبط كلمة المرور",
|
||||
"resetPasswordSuccess": "تم إعادة ضبط كلمة المرور بنجاح",
|
||||
"resetPasswordFailed": "فشل إعادة ضبط كلمة المرور",
|
||||
"resetPasswordLinkSent": "تم إرسال رابط إعادة ضبط كلمة المرور إلى بريدك الإلكتروني. يُرجى مراجعة بريدك الوارد على",
|
||||
"resetPasswordLinkExpired": "انتهت صلاحية رابط إعادة ضبط كلمة المرور. يُرجى طلب رابط جديد.",
|
||||
"resetPasswordLinkInvalid": "رابط إعادة ضبط كلمة المرور غير صالح. يُرجى طلب رابط جديد.",
|
||||
"enterNewPasswordFor": "أدخل كلمة المرور الجديدة لـ ",
|
||||
"newPassword": "كلمة المرور الجديدة",
|
||||
"enterNewPassword": "أدخل كلمة المرور الجديدة",
|
||||
"confirmPassword": "تأكيد كلمة المرور",
|
||||
"confirmNewPassword": "أدخل كلمة المرور الجديدة",
|
||||
"newPasswordCannotBeEmpty": "لا يمكن أن تكون كلمة المرور الجديدة فارغة",
|
||||
"confirmPasswordCannotBeEmpty": "تأكيد كلمة المرور لا يمكن أن يكون فارغا",
|
||||
"passwordsDoNotMatch": "كلمات المرور غير متطابقة",
|
||||
"verifying": "جاري التحقق...",
|
||||
"continueWithPassword": "متابعة مع كلمة المرور",
|
||||
"youAreInLocalMode": "أنت في الوضع المحلي.",
|
||||
"loginToAppFlowyCloud": "تسجيل الدخول إلى AppFlowy Cloud",
|
||||
"anonymous": "مجهول",
|
||||
"LogInWithGoogle": "تسجيل الدخول عبر جوجل",
|
||||
"LogInWithGithub": "تسجيل الدخول مع جيثب",
|
||||
"LogInWithDiscord": "تسجيل الدخول مع ديسكورد",
|
||||
@@ -570,9 +530,6 @@
|
||||
"selectHomePage": "حدد الصفحة",
|
||||
"clearHomePage": "مسح الصفحة الرئيسية لهذه المساحة الاسمية",
|
||||
"customUrl": "عنوان URL مخصص",
|
||||
"homePage": {
|
||||
"upgradeToPro": "قم بالترقية إلى الخطة الاحترافية لتعيين الصفحة الرئيسية"
|
||||
},
|
||||
"namespace": {
|
||||
"description": "سيتم تطبيق هذا التغيير على جميع الصفحات المنشورة الموجودة على مساحة الاسم هذه بشكل فوري",
|
||||
"tooltip": "نحن نحتفظ بالحق في إزالة أي مساحات أسماء غير مناسبة",
|
||||
@@ -679,8 +636,7 @@
|
||||
"theme": {
|
||||
"title": "السمة",
|
||||
"description": "حدد السمة المحددة مسبقًا، أو قم برفع موضوعك المخصص.",
|
||||
"uploadCustomThemeTooltip": "رفع السمة المخصصة",
|
||||
"failedToLoadThemes": "فشل تحميل السمات، يرجى التحقق من إعدادات الأذونات في إعدادات النظام < الخصوصية والأمان <الملفات والمجلدات < @:appName"
|
||||
"uploadCustomThemeTooltip": "رفع السمة المخصصة"
|
||||
},
|
||||
"workspaceFont": {
|
||||
"title": "خط مساحة العمل",
|
||||
@@ -900,7 +856,6 @@
|
||||
"aiSettingsDescription": "اختر النموذج المفضل لديك لتشغيل AppFlowy AI. يتضمن الآن GPT 4-o وClaude 3,5 وLlama 3.1 وMistral 7B",
|
||||
"loginToEnableAIFeature": "لا يتم تمكين ميزات الذكاء الاصطناعي إلا بعد تسجيل الدخول باستخدام @:appName Cloud. إذا لم يكن لديك حساب @:appName ، فانتقل إلى \"حسابي\" للتسجيل",
|
||||
"llmModel": "نموذج اللغة",
|
||||
"globalLLMModel": "نموذج اللغة العام",
|
||||
"llmModelType": "نوع نموذج اللغة",
|
||||
"downloadLLMPrompt": "تنزيل {}",
|
||||
"downloadAppFlowyOfflineAI": "سيؤدي تنزيل حزمة AI دون اتصال بالإنترنت إلى تفعيل تشغيل AI على جهازك. هل تريد الاستمرار؟",
|
||||
@@ -917,7 +872,6 @@
|
||||
"localAIDisabled": "أنت تستخدم الذكاء الاصطناعي المحلي، ولكنه مُعطّل. يُرجى الانتقال إلى الإعدادات لتفعيله أو تجربة نموذج آخر.",
|
||||
"localAIInitializing": "يتم تهيئة الذكاء الاصطناعي المحلي وقد يستغرق الأمر بضع دقائق، حسب جهازك",
|
||||
"localAINotReadyTextFieldPrompt": "لا يمكنك التحرير أثناء تحميل الذكاء الاصطناعي المحلي",
|
||||
"localAIDisabledTextFieldPrompt": "لا يمكنك التحرير أثناء تعطيل الذكاء الاصطناعي المحلي",
|
||||
"failToLoadLocalAI": "فشل في بدء تشغيل الذكاء الاصطناعي المحلي",
|
||||
"restartLocalAI": "إعادة تشغيل الذكاء الاصطناعي المحلي",
|
||||
"disableLocalAITitle": "تعطيل الذكاء الاصطناعي المحلي",
|
||||
@@ -1384,28 +1338,7 @@
|
||||
"inviteMemberSuccess": "لقد تم ارسال الدعوة بنجاح",
|
||||
"failedToInviteMember": "فشل في دعوة العضو",
|
||||
"workspaceMembersError": "عفواً، حدث خطأ ما",
|
||||
"workspaceMembersErrorDescription": "لم نتمكن من تحميل قائمة الأعضاء في هذا الوقت. يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"inviteLinkToAddMember": "رابط الدعوة لإضافة عضو",
|
||||
"clickToCopyLink": "انقر هنا لنسخ الرابط",
|
||||
"or": "أو",
|
||||
"generateANewLink": "إنشاء رابط جديد",
|
||||
"inviteMemberByEmail": "دعوة الأعضاء عبر البريد الإلكتروني",
|
||||
"inviteMemberHintText": "دعوة عبر البريد الإلكتروني",
|
||||
"resetInviteLink": "إعادة ضبط رابط الدعوة؟",
|
||||
"resetInviteLinkDescription": "ستؤدي إعادة الضبط إلى إلغاء تفعيل الرابط الحالي لجميع أعضاء المساحة وإنشاء رابط جديد. لن يعود الرابط القديم صالحًا.",
|
||||
"adminPanel": "لوحة الإدارة",
|
||||
"reset": "إعادة ضبط",
|
||||
"resetInviteLinkSuccess": "تمت إعادة ضبط رابط الدعوة بنجاح",
|
||||
"resetInviteLinkFailed": "فشل إعادة ضبط رابط الدعوة",
|
||||
"resetInviteLinkFailedDescription": "الرجاء المحاولة مرة أخرى لاحقًا",
|
||||
"memberPageDescription1": "الوصول إلى",
|
||||
"memberPageDescription2": "لإدارة الضيوف والمستخدمين المتقدمين.",
|
||||
"noInviteLink": "لم تقم بإنشاء رابط دعوة بعد.",
|
||||
"copyLink": "انسخ الرابط",
|
||||
"generatedLinkSuccessfully": "تم إنشاء الرابط بنجاح",
|
||||
"generatedLinkFailed": "فشل في إنشاء الرابط",
|
||||
"resetLinkSuccessfully": "تم إعادة ضبط الرابط بنجاح",
|
||||
"resetLinkFailed": "فشل إعادة ضبط الرابط"
|
||||
"workspaceMembersErrorDescription": "لم نتمكن من تحميل قائمة الأعضاء في هذا الوقت. يرجى المحاولة مرة أخرى لاحقًا"
|
||||
},
|
||||
"lightLabel": "فاتح",
|
||||
"darkLabel": "غامق"
|
||||
@@ -2104,27 +2037,6 @@
|
||||
}
|
||||
},
|
||||
"cannotMoveToItsChildren": "لا يمكن الانتقال إلى أطفاله",
|
||||
"linkPreview": {
|
||||
"typeSelection": {
|
||||
"pasteAs": "لصق كـ",
|
||||
"mention": "ذِكْر",
|
||||
"URL": "عنوان URL",
|
||||
"bookmark": "إشارة مرجعية",
|
||||
"embed": "تضمين"
|
||||
},
|
||||
"linkPreviewMenu": {
|
||||
"toMetion": "تحويل إلى ذكر",
|
||||
"toUrl": "تحويل إلى عنوان URL",
|
||||
"toEmbed": "تحويل إلى تضمين",
|
||||
"toBookmark": "تحويل إلى إشارة مرجعية",
|
||||
"copyLink": "نسخ الرابط",
|
||||
"replace": "استبدال",
|
||||
"reload": "إعادة التحميل",
|
||||
"removeLink": "إزالة الرابط",
|
||||
"pasteHint": "ألصق في https://...",
|
||||
"unableToDisplay": "غير قادر على العرض"
|
||||
}
|
||||
},
|
||||
"autoCompletionMenuItemName": "عنصر قائمة الإكمال التلقائي"
|
||||
},
|
||||
"outlineBlock": {
|
||||
@@ -2365,11 +2277,11 @@
|
||||
"layoutDateField": "تقويم التخطيط بواسطة",
|
||||
"changeLayoutDateField": "تغيير حقل التخطيط",
|
||||
"noDateTitle": "بدون تاريخ",
|
||||
"noDateHint": "ستظهر الأحداث غير المجدولة هنا",
|
||||
"unscheduledEventsTitle": "الأحداث غير المجدولة",
|
||||
"clickToAdd": "انقر للإضافة إلى التقويم",
|
||||
"name": "تخطيط التقويم",
|
||||
"clickToOpen": "انقر لفتح السجل",
|
||||
"noDateHint": "ستظهر الأحداث غير المجدولة هنا"
|
||||
"clickToOpen": "انقر لفتح السجل"
|
||||
},
|
||||
"referencedCalendarPrefix": "نظرا ل",
|
||||
"quickJumpYear": "انتقل إلى",
|
||||
@@ -2385,12 +2297,6 @@
|
||||
"search": {
|
||||
"label": "يبحث",
|
||||
"sidebarSearchIcon": "ابحث وانتقل بسرعة إلى الصفحة",
|
||||
"searchOrAskAI": "ابحث أو اسأل الذكاء الاصطناعي",
|
||||
"askAIAnything": "اسأل الذكاء الاصطناعي عن أي شيء",
|
||||
"askAIFor": "اسأل الذكاء الاصطناعي",
|
||||
"noResultForSearching": "لم يتم العثور على أي تطابقات",
|
||||
"noResultForSearchingHint": "حاول استخدام أسئلة أو كلمات رئيسية مختلفة.\nقد تكون بعض الصفحات في سلة المهملات.",
|
||||
"bestMatch": "أفضل تطابق",
|
||||
"placeholder": {
|
||||
"actions": "إجراءات البحث ..."
|
||||
}
|
||||
@@ -2499,19 +2405,6 @@
|
||||
},
|
||||
"notificationHub": {
|
||||
"title": "إشعارات",
|
||||
"closeNotification": "إغلاق الإشعار",
|
||||
"viewNotifications": "عرض الإشعارات",
|
||||
"noNotifications": "لا يوجد إشعارات حتى الآن",
|
||||
"mentionedYou": "ذكرتك",
|
||||
"archivedTooltip": "أرشفة هذا الإشعار",
|
||||
"unarchiveTooltip": "إلغاء أرشفة هذا الإشعار",
|
||||
"markAsReadTooltip": "قم بتمييز هذا الإشعار كمقروء",
|
||||
"markAsArchivedSucceedToast": "تم الأرشفة بنجاح",
|
||||
"markAllAsArchivedSucceedToast": "تم أرشفة كل شيء بنجاح",
|
||||
"markAsReadSucceedToast": "وضع علامة كمقروءة بنجاح",
|
||||
"markAllAsReadSucceedToast": "تم وضع علامة على كل شيء كمقروء بنجاح",
|
||||
"today": "اليوم",
|
||||
"older": "أقدم",
|
||||
"mobile": {
|
||||
"title": "تحديثات"
|
||||
},
|
||||
@@ -2641,7 +2534,6 @@
|
||||
"copyLink": "انسخ الرابط",
|
||||
"removeLink": "إزالة الرابط",
|
||||
"editLink": "تعديل الرابط",
|
||||
"convertTo": "تحويل إلى",
|
||||
"linkText": "نص",
|
||||
"linkTextHint": "الرجاء إدخال النص",
|
||||
"linkAddressHint": "الرجاء إدخال الرابط",
|
||||
@@ -2747,41 +2639,7 @@
|
||||
"failedToGetCurrentUser": "فشل في الحصول على البريد الإلكتروني الحالي للمستخدم",
|
||||
"confirmTextValidationFailed": "نص التأكيد الخاص بك لا يتطابق مع \"@:newSettings.myAccount.deleteAccount.confirmHint3\"",
|
||||
"deleteAccountSuccess": "تم حذف الحساب بنجاح"
|
||||
},
|
||||
"password": {
|
||||
"title": "كلمة المرور",
|
||||
"confirmPassword": "تأكيد كلمة المرور",
|
||||
"changePassword": "تغيير كلمة المرور",
|
||||
"currentPassword": "كلمة المرور الحالية",
|
||||
"newPassword": "كلمة المرور الجديدة",
|
||||
"confirmNewPassword": "تأكيد كلمة المرور الجديدة",
|
||||
"setupPassword": "إعداد كلمة المرور",
|
||||
"error": {
|
||||
"currentPasswordIsRequired": "كلمة المرور الحالية مطلوبة",
|
||||
"newPasswordIsRequired": "مطلوب كلمة مرور جديدة",
|
||||
"confirmPasswordIsRequired": "تأكيد كلمة المرور مطلوب",
|
||||
"passwordsDoNotMatch": "كلمات المرور غير متطابقة",
|
||||
"newPasswordIsSameAsCurrent": "كلمة المرور الجديدة هي نفس كلمة المرور الحالية",
|
||||
"currentPasswordIsIncorrect": "كلمة المرور الحالية غير صحيحة",
|
||||
"passwordShouldBeAtLeast6Characters": "يجب أن تتكون كلمة المرور من {min} مِحْرَف على الأقل",
|
||||
"passwordCannotBeLongerThan72Characters": "لا يمكن أن تكون كلمة المرور أطول من {max} مِحْرَف"
|
||||
},
|
||||
"toast": {
|
||||
"passwordUpdatedSuccessfully": "تم تحديث كلمة المرور بنجاح",
|
||||
"passwordUpdatedFailed": "فشل في تحديث كلمة المرور",
|
||||
"passwordSetupSuccessfully": "تم إعداد كلمة المرور بنجاح",
|
||||
"passwordSetupFailed": "فشل في إعداد كلمة المرور"
|
||||
},
|
||||
"hint": {
|
||||
"enterYourPassword": "أدخل كلمة المرور الخاصة بك",
|
||||
"confirmYourPassword": "تأكيد كلمة المرور الخاصة بك",
|
||||
"enterYourCurrentPassword": "أدخل كلمة المرور الحالية الخاصة بك",
|
||||
"enterYourNewPassword": "أدخل كلمة المرور الجديدة",
|
||||
"confirmYourNewPassword": "تأكيد كلمة المرور الجديدة"
|
||||
}
|
||||
},
|
||||
"myAccount": "حسابي",
|
||||
"myProfile": "ملفي الشخصي"
|
||||
}
|
||||
},
|
||||
"workplace": {
|
||||
"name": "مكان العمل",
|
||||
@@ -2834,11 +2692,6 @@
|
||||
"commandPalette": {
|
||||
"placeholder": "ابحث أو اطرح سؤالا...",
|
||||
"bestMatches": "أفضل المطابقات",
|
||||
"aiOverview": "نظرة عامة على الذكاء الاصطناعي",
|
||||
"aiOverviewSource": "مصادر مرجعية",
|
||||
"aiOverviewMoreDetails": "مزيد من التفاصيل",
|
||||
"pagePreview": "معاينة المحتوى",
|
||||
"clickToOpenPage": "انقر لفتح الصفحة",
|
||||
"recentHistory": "التاريخ الحديث",
|
||||
"navigateHint": "للتنقل",
|
||||
"loadingTooltip": "نحن نبحث عن نتائج...",
|
||||
@@ -3357,38 +3210,7 @@
|
||||
"analyzing": "تحليل",
|
||||
"continueWritingEmptyDocumentTitle": "استمر في كتابة الخطأ",
|
||||
"continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!",
|
||||
"more": "أكثر",
|
||||
"customPrompt": {
|
||||
"browsePrompts": "تصفح المطالبات",
|
||||
"usePrompt": "استخدم المطالبة",
|
||||
"featured": "مميز",
|
||||
"all": "الجميع",
|
||||
"development": "التطوير",
|
||||
"writing": "الكتابة",
|
||||
"healthAndFitness": "الصحة واللياقة البدنية",
|
||||
"business": "العمل",
|
||||
"marketing": "التسويق",
|
||||
"travel": "السفر",
|
||||
"others": "آخر",
|
||||
"prompt": "المطالبة",
|
||||
"example": "مثال المطالبة",
|
||||
"sampleOutput": "عينة الإخراج",
|
||||
"contentSeo": "المحتوى/ت.م.ب",
|
||||
"emailMarketing": "التسويق عبر البريد الإلكتروني",
|
||||
"paidAds": "الإعلانات المدفوعة",
|
||||
"prCommunication": "العلاقات العامة/الاتصالات",
|
||||
"recruiting": "التوظيف",
|
||||
"sales": "مبيعات",
|
||||
"socialMedia": "وسائل التواصل الاجتماعي",
|
||||
"strategy": "الاستراتيجية",
|
||||
"caseStudies": "دراسات الحالة",
|
||||
"salesCopy": "النص البيعي",
|
||||
"education": "التعليم",
|
||||
"work": "العمل",
|
||||
"podcastProduction": "إنتاج البودكاست",
|
||||
"copyWriting": "كتابة التسويق",
|
||||
"customerSuccess": "نجاح العملاء"
|
||||
}
|
||||
"more": "أكثر"
|
||||
},
|
||||
"autoUpdate": {
|
||||
"criticalUpdateTitle": "التحديث ضروري للمتابعة",
|
||||
|
||||
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user