Compare commits

..
Author SHA1 Message Date
Nathan 4354227a05 chore: enable select source when user is not anon 2025-05-07 14:31:08 +08:00
224 changed files with 6205 additions and 15121 deletions

No files matched your search

-13
View File
@@ -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
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.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
@@ -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,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);
});
});
}
@@ -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
-2
View File
@@ -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) {}
}
@@ -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;
},
);
}
}
@@ -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(
@@ -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;
@@ -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,
);
}
}
@@ -19,7 +19,7 @@ class MobileSearchAskAiEntrance extends StatelessWidget {
onTap: () {
context
.read<CommandPaletteBloc?>()
?.add(CommandPaletteEvent.goingToAskAI());
?.add(CommandPaletteEvent.gointToAskAI());
mobileCreateNewAIChatNotifier.value =
mobileCreateNewAIChatNotifier.value + 1;
},
@@ -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);
}
@@ -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;
}
}
@@ -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();
}
}
}
@@ -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);
@@ -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());
},
);
@@ -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,
),
);
}
}
@@ -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,
),
),
),
@@ -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
@@ -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,
),
);
}
}
@@ -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 {
@@ -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);
@@ -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;
}
}
@@ -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);
@@ -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,
);
@@ -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;
}
@@ -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);
}
@@ -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;
},
);
}
@@ -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.
@@ -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));
}
}
@@ -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;
}
@@ -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;
@@ -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: []);
@@ -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) {
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 {
@@ -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,
);
}
}
@@ -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,
),
],
),
);
}
}
@@ -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),
);
},
),
@@ -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;
@@ -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();
}
}
@@ -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),
),
);
},
);
}
}
@@ -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,
),
],
),
);
}
}
@@ -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),
),
),
);
}
}
@@ -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,
@@ -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));
@@ -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),
),
),
),
@@ -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());
},
),
);
+22 -22
View File
@@ -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,
);
+1 -1
View File
@@ -4,7 +4,7 @@ description: Bring projects, wikis, and teams together with AI. AppFlowy is an
your data. The best open source alternative to Notion.
publish_to: "none"
version: 0.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

+7 -185
View File
@@ -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": "فشل تحميل السمات، يرجى التحقق من إعدادات الأذونات في إعدادات النظام &lt; الخصوصية والأمان &lt;الملفات والمجلدات &lt; @: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