Compare commits

..
Author SHA1 Message Date
Morn 02c86b13d9 chore: update CHANGELOG.md (#7930) 2025-05-14 18:35:50 +08:00
Richard Shiue 13fe4a2395 fix(mobile): initialize scale factor on startup (#7929)
* fix(mobile): initialize scale factor on startup

* fix: widget test
2025-05-13 21:50:37 +08:00
Lucas e8b8644716 chore: bump version 0.9.2 (#7926) 2025-05-13 14:11:32 +08:00
Morn 10472475ac fix: allow ':keyword' make mistakes (#7905) 2025-05-13 13:54:28 +08:00
Lucas 20e10e6bf2 chore: remove unused code (#7924) 2025-05-13 11:47:08 +08:00
Morn cf14a37dcf fix: using the shortcut to show the search panel will hide the 'Ask AI anything' button (#7916)
* fix: show search panel by shortcut will hide the 'Ask for AI' button

* fix: hovering on the search result will hide the border

* fix: improve the logic of updating CommandPalette value

* fix: UI issues

* fix: improve the behavior of search result preview

* chore: add integration test for desktop searching

* feat: enable click search result preview to open the page
2025-05-13 11:37:03 +08:00
Nathan.fooo a359313974 chore: select source when user is not anon (#7921) 2025-05-13 11:10:32 +08:00
Nathan 636794b9ba chore: event for fetch workspace plan 2025-05-13 00:13:57 +08:00
Nathan.fooo 7e5b243e40 chore: remove unused ai code (#7919) 2025-05-13 00:13:25 +08:00
Richard Shiue 5e99eeb167 chore: improve empty ai prompt list placeholder (#7917) 2025-05-12 16:53:06 +08:00
Morn 5029776685 fix: LR issues of desktop search (#7914)
* fix: update error view for search preview

* chore: add some logs for command palette

* fix: alignment issue
2025-05-12 14:12:30 +08:00
Morn 79009d2851 fix: the link name should be a required field (#7915) 2025-05-12 13:43:22 +08:00
NikitamceandRichard Shiue d153b3d9f1 chore(i18n): update ru-RU translations (#7835)
* chore: update translations with Fink 🐦

* Update ru-RU.json

* Update ru-RU.json

* chore: update translations with Fink 🐦

---------

Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-05-12 12:17:58 +08:00
CrEagleOneandRichard Shiue f9aa3ff499 chore(i18n): update fr-FR translations 🐦 (#7832)
Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-05-12 11:46:03 +08:00
Mathias Mogensen b4fbfaf2f8 fix: drop files duplicates (#7620) 2025-05-12 11:25:56 +08:00
FakhriAzzouz a026367f85 chore(i18n): add ar-SA translations (#7800)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2025-05-12 11:13:14 +08:00
Tom GoldenandRichard Shiue ef7063b8fd chore(i18n): add en-GB translations (#7705)
* feat: added en-GB translations

* feat: added en-GB translations

* chore: remove redundant argument

---------

Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-05-12 11:10:11 +08:00
Richard Shiue 808bee5eb2 chore: adjust view selector logic (#7913)
* chore: adjust view selector logic

* chore: click on document to expand

* chore: code cleanup
2025-05-12 10:17:50 +08:00
Nathan 9ecae0a643 chore: ai templte 2025-05-11 20:16:12 +08:00
Nathan.fooo dfc80e6a5a chore: fetch subscription when start (#7910)
* chore: fetch subscription when start

* chore: fetch subscription if billing is enabled
2025-05-11 19:39:24 +08:00
Morn 014928cf19 feat: desktop Search UI revamp (#7895)
* feat: add ai search entrance

* chore: revamp search recent list

* feat: revamp preview widget

* feat: support click the 'Ask ai button'

* feat: support go to trash when there is no search results

* feat: support AI overview

* fix: typo issue

* fix: empty trash item name

* chore: replace some Dividers to AFDividers

* chore: improve some code logic

* chore: update some icon color

* fix: some UI issue

* chore: adjust some paddings

* fix: align issue for reference sources

* fix: change clear icon's hover effect

* fix: clear search field will not make it unfocus

* chore: make 'Ask AI' button scroll with list

* chore: change the size of search panel

* fix: change mobile search field cancel icon

* feat: support preview panel for recent pages

* fix: some UI issues

* fix: hover to show the reference sources

* fix: some padding issues
2025-05-11 16:45:44 +08:00
Richard Shiue 30f5692818 chore: adjust database custom prompt ui (#7902) 2025-05-11 16:45:28 +08:00
Nathan.foooandLucas.Xu 6e850ab8b2 refactor: extract event handler (#7908)
Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-05-11 16:44:59 +08:00
Khor Shu Heng 370b5714a1 chore: update collab version for row deserialization fix (#7907) 2025-05-11 14:08:32 +08:00
Nathan.fooo 2c8060cb1b refactor: chat bloc (#7904)
* refactor: chat bloc

* chore: clippy

* chore: make temp message id private
2025-05-10 22:07:47 +08:00
Nathan.fooo b4d48a63b9 chore: support multiple source search in chat (#7906)
* chore: support multiple source search in chat

* chore: clippy
2025-05-10 15:28:38 +08:00
Nathan.foooandLucas.Xu c986e414f4 Vault workspace suggested question (#7903)
* chore: return suggested question when ai say i don't know

* chore: new message type

* chore: implement uI

* chore: implement UI

* fix: assertion in local ai chat

* chore: implement UI

* chore: update prompt

* chore: update logs

* chore: add tests

* chore: message id

* fix: local ai page animation issue

* chore: remove debug log

* fix: remove repeated setState

* chore: add test

* chore: test

* fix: compile

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-05-09 21:58:23 +08:00
Lucas c79ac3290e test: simple ai chat tests (#7901)
* fix: repeated setState in animation list

* test: add simple chat integration test

* chore: update chat list

* test: send messages without default messages

* fix: refresh space after moving page
2025-05-09 21:40:06 +08:00
Richard Shiue 7f7657edc8 test: add view selector tests (#7900) 2025-05-09 17:53:23 +08:00
Lucas d3ce59fe08 fix: v0.9.2 launch review issues (#7898)
* fix: remove empty paragraphs after inserting file block

* fix: chat page animation

* fix: remove opacity from locked page icon

* feat: open the space after opening the view

* fix: chat page loading issue

* fix: view title assertion

* fix: cover title issue

* fix: flutter analyze

* fix: list state issue

* fix: reverse animation list issue

* fix: integration test
2025-05-09 13:02:19 +08:00
Richard Shiue 0f89b6f690 fix: selected sources changes not being updated correctly (#7897) 2025-05-08 14:38:38 +08:00
Richard Shiue 680015ef66 feat: load custom prompts from a database (#7894)
* feat: load custom prompts from a database

* chore: code review

* chore: process trashed views and reduce widget rebuilds
2025-05-08 11:56:30 +08:00
Richard Shiue b048407b4e fix: select option logic (#7893)
* test: add select option tests

* fix: select option logic

* test: fix existing tests

* test: fix test logic
2025-05-08 09:13:03 +08:00
Nathan.fooo 95f9b07ef7 chore: use tanvity as fallback ai search (#7892)
* chore: use tanvity as fallback ai search

* chore: fix test
2025-05-08 00:17:02 +08:00
Richard Shiue b1d3553110 refactor: view selector (#7891)
* refactor: extract common view selection logic

* refactor: extract common component

* chore: make selected section selectable

* chore: max num of selected top-level pages
2025-05-07 21:10:23 +08:00
Lucas 38839b4255 feat: improve chat message animations (#7890)
* chore: upgrade editor version

* chore: only print the reminder refresh log when it contains new updates

* fix: google font family name issue

* chore: i18n

* feat: improve chat ai animation
2025-05-07 20:32:13 +08:00
224 changed files with 15119 additions and 6203 deletions

No files matched your search

+13
View File
@@ -1,4 +1,17 @@
# 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.1"
APPFLOWY_VERSION = "0.9.2"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -0,0 +1,264 @@
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,6 +4,7 @@ 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;
@@ -31,6 +32,7 @@ Future<void> main() async {
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
sidebar_search_test.main();
}
// database
@@ -0,0 +1,42 @@
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,7 +1,17 @@
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() {
@@ -19,4 +29,70 @@ 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,5 +366,88 @@ 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,5 +1,6 @@
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'
@@ -17,4 +18,5 @@ Future<void> runIntegration9OnDesktop() async {
tabs_test.main();
code_language_selector.main();
database_icon_test.main();
chat_page_test.main();
}
@@ -1,9 +1,14 @@
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 {
@@ -38,4 +43,27 @@ 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,6 +1051,8 @@ 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,11 +71,6 @@ 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):
@@ -112,7 +107,6 @@ 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`)
@@ -126,7 +120,6 @@ SPEC REPOS:
- DKPhotoGallery
- ReachabilitySwift
- SDWebImage
- Sentry
- SwiftyGif
- Toast
@@ -165,8 +158,6 @@ 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:
@@ -202,8 +193,6 @@ 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,7 +5,9 @@ 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,6 +1,7 @@
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';
@@ -14,11 +15,12 @@ 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 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:';
}
enum AiType {
@@ -192,8 +194,23 @@ 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);
@@ -211,7 +228,10 @@ 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];
List<Object?> get props =>
[id, name, content, category, example, isFeatured, isCustom];
}
@@ -1,3 +1,7 @@
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';
@@ -14,7 +18,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
}) : _aiService = aiService ?? AppFlowyAIService(),
super(AiPromptSelectorState.loading()) {
filterTextController.addListener(_filterTextChanged);
_loadPrompts();
_init();
}
final AppFlowyAIService _aiService;
@@ -27,33 +31,131 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
await super.close();
}
void _loadPrompts() async {
void _init() async {
availablePrompts.addAll(await _aiService.getBuiltInPrompts());
final featuredPrompts =
availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(featuredPrompts);
emit(
AiPromptSelectorState.ready(
visiblePrompts: visiblePrompts,
showCustomPrompts: false,
isFeaturedCategorySelected: true,
visiblePrompts: visiblePrompts.toList(),
isCustomPromptSectionSelected: false,
isFeaturedSectionSelected: true,
selectedPromptId: visiblePrompts.firstOrNull?.id,
customPromptDatabaseViewId: null,
isLoadingCustomPrompts: true,
selectedCategory: null,
favoritePrompts: [],
),
);
loadCustomPrompts();
}
void selectFeaturedCategory() {
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() {
state.maybeMap(
ready: (readyState) {
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(prompts);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts,
isFeaturedCategorySelected: true,
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: visiblePrompts.firstOrNull?.id,
isFeaturedSectionSelected: true,
isCustomPromptSectionSelected: false,
selectedCategory: null,
),
);
},
@@ -64,24 +166,23 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
void selectCategory(AiPromptCategory? category) {
state.maybeMap(
ready: (readyState) {
final unfilteredPrompts = category == null
final prompts = category == null
? availablePrompts
: availablePrompts.where((prompt) => prompt.category == category);
final prompts = _getFilteredPrompts(unfilteredPrompts);
final visiblePrompts = _getFilteredPrompts(prompts);
// 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;
}
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(
visiblePrompts: prompts,
isFeaturedCategorySelected: false,
visiblePrompts: visiblePrompts.toList(),
selectedCategory: category,
selectedPromptId: selectedPromptId,
isFeaturedSectionSelected: false,
isCustomPromptSectionSelected: false,
),
);
},
@@ -116,8 +217,6 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
emit(
readyState.copyWith(favoritePrompts: favoritePrompts),
);
// TODO(RS): Save the updated favorite prompts to local storage or database
},
orElse: () {},
);
@@ -130,7 +229,9 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
emit(
readyState.copyWith(
visiblePrompts: availablePrompts,
selectedPromptId: null,
isCustomPromptSectionSelected: false,
isFeaturedSectionSelected: true,
selectedPromptId: availablePrompts.firstOrNull?.id,
selectedCategory: null,
),
);
@@ -139,27 +240,84 @@ 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 unfilteredPrompts = readyState.selectedCategory == null
? readyState.isFeaturedCategorySelected
? availablePrompts.where((prompt) => prompt.isFeatured)
: availablePrompts
: availablePrompts.where(
(prompt) => prompt.category == readyState.selectedCategory,
);
final filteredPrompts = _getFilteredPrompts(unfilteredPrompts);
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(visiblePrompts: filteredPrompts),
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
),
);
},
orElse: () {},
);
}
List<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
Iterable<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
final filterText = filterTextController.value.text.trim().toLowerCase();
return prompts.where((prompt) {
@@ -167,6 +325,33 @@ 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
@@ -175,13 +360,18 @@ 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 showCustomPrompts,
required bool isFeaturedCategorySelected,
required bool isCustomPromptSectionSelected,
required bool isFeaturedSectionSelected,
required AiPromptCategory? selectedCategory,
required String? selectedPromptId,
required bool isLoadingCustomPrompts,
required String? customPromptDatabaseViewId,
}) = _AiPromptSelectorReadyState;
bool get isLoading => this is _AiPromptSelectorLoadingState;
@@ -192,9 +382,4 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
.firstWhereOrNull((prompt) => prompt.id == state.selectedPromptId),
orElse: () => null,
);
AiPromptCategory? get selectedCategory => maybeMap(
ready: (state) => state.selectedCategory,
orElse: () => null,
);
}
@@ -9,6 +9,7 @@ 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;
@@ -41,6 +42,8 @@ abstract class AIRepository {
Future<List<AiPrompt>> getBuiltInPrompts();
Future<List<AiPrompt>?> getDatabasePrompts(String databaseViewId);
void updateFavoritePrompts(List<String> promptIds);
}
@@ -117,6 +120,23 @@ 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) {}
}
@@ -1,3 +1,4 @@
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';
@@ -6,13 +7,12 @@ 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 'chat_select_sources_cubit.freezed.dart';
part 'view_selector_cubit.freezed.dart';
const int _kMaxSelectedParentPageCount = 3;
enum SourceSelectedStatus {
enum ViewSelectedStatus {
unselected,
selected,
partiallySelected;
@@ -22,45 +22,46 @@ enum SourceSelectedStatus {
bool get isPartiallySelected => this == partiallySelected;
}
class ChatSource {
ChatSource({
class ViewSelectorItem {
ViewSelectorItem({
required this.view,
required this.parentView,
required this.children,
required bool isExpanded,
required SourceSelectedStatus selectedStatus,
required IgnoreViewType ignoreStatus,
required ViewSelectedStatus selectedStatus,
required bool isDisabled,
}) : isExpandedNotifier = ValueNotifier(isExpanded),
selectedStatusNotifier = ValueNotifier(selectedStatus),
ignoreStatusNotifier = ValueNotifier(ignoreStatus);
isDisabledNotifier = ValueNotifier(isDisabled);
final ViewPB view;
final ViewPB? parentView;
final List<ChatSource> children;
final List<ViewSelectorItem> children;
final ValueNotifier<bool> isExpandedNotifier;
final ValueNotifier<SourceSelectedStatus> selectedStatusNotifier;
final ValueNotifier<IgnoreViewType> ignoreStatusNotifier;
final ValueNotifier<bool> isDisabledNotifier;
final ValueNotifier<ViewSelectedStatus> selectedStatusNotifier;
bool get isExpanded => isExpandedNotifier.value;
SourceSelectedStatus get selectedStatus => selectedStatusNotifier.value;
IgnoreViewType get ignoreStatus => ignoreStatusNotifier.value;
ViewSelectedStatus get selectedStatus => selectedStatusNotifier.value;
bool get isDisabled => isDisabledNotifier.value;
void toggleIsExpanded() {
isExpandedNotifier.value = !isExpanded;
isExpandedNotifier.value = !isExpandedNotifier.value;
}
ChatSource copy() {
return ChatSource(
ViewSelectorItem copy() {
return ViewSelectorItem(
view: view,
parentView: parentView,
children: children.map<ChatSource>((child) => child.copy()).toList(),
ignoreStatus: ignoreStatus,
isExpanded: isExpanded,
selectedStatus: selectedStatus,
children:
children.map<ViewSelectorItem>((child) => child.copy()).toList(),
isDisabled: isDisabledNotifier.value,
isExpanded: isExpandedNotifier.value,
selectedStatus: selectedStatusNotifier.value,
);
}
ChatSource? findChildBySourceId(String sourceId) {
ViewSelectorItem? findChildBySourceId(String sourceId) {
if (view.id == sourceId) {
return this;
}
@@ -73,20 +74,19 @@ class ChatSource {
return null;
}
void resetIgnoreViewTypeRecursive() {
ignoreStatusNotifier.value = view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable;
void setIsDisabledRecursive(bool Function(ViewSelectorItem) newIsDisabled) {
isDisabledNotifier.value = newIsDisabled(this);
for (final child in children) {
child.resetIgnoreViewTypeRecursive();
child.setIsDisabledRecursive(newIsDisabled);
}
}
void updateIgnoreViewTypeRecursive(IgnoreViewType newIgnoreViewType) {
ignoreStatusNotifier.value = newIgnoreViewType;
void setIsSelectedStatusRecursive(ViewSelectedStatus selectedStatus) {
selectedStatusNotifier.value = selectedStatus;
for (final child in children) {
child.updateIgnoreViewTypeRecursive(newIgnoreViewType);
child.setIsSelectedStatusRecursive(selectedStatus);
}
}
@@ -96,36 +96,45 @@ class ChatSource {
}
isExpandedNotifier.dispose();
selectedStatusNotifier.dispose();
ignoreStatusNotifier.dispose();
isDisabledNotifier.dispose();
}
}
class ChatSettingsCubit extends Cubit<ChatSettingsState> {
ChatSettingsCubit({
this.hideDisabled = false,
}) : super(ChatSettingsState.initial());
class ViewSelectorCubit extends Cubit<ViewSelectorState> {
ViewSelectorCubit({
required this.getIgnoreViewType,
this.maxSelectedParentPageCount,
}) : super(ViewSelectorState.initial()) {
filterTextController.addListener(onFilterChanged);
}
final bool hideDisabled;
final IgnoreViewType Function(ViewSelectorItem) getIgnoreViewType;
final int? maxSelectedParentPageCount;
final List<String> selectedSourceIds = [];
final List<ChatSource> sources = [];
final List<ChatSource> selectedSources = [];
String filter = '';
final List<ViewSelectorItem> sources = [];
final List<ViewSelectorItem> selectedSources = [];
final filterTextController = TextEditingController();
void updateSelectedSources(List<String> newSelectedSourceIds) {
selectedSourceIds.clear();
selectedSourceIds.addAll(newSelectedSourceIds);
}
void refreshSources(List<ViewPB> spaceViews, ViewPB? currentSpace) async {
filter = "";
Future<void> refreshSources(
List<ViewPB> spaceViews,
ViewPB? currentSpace,
) async {
filterTextController.clear();
final newSources = await Future.wait(
spaceViews.map((view) => _recursiveBuild(view, null)),
);
for (final source in newSources) {
_restrictSelectionIfNecessary(source.children);
}
_setIsDisabledAndHideIfNecessary(newSources);
_restrictSelectionIfNecessary(newSources);
if (currentSpace != null) {
newSources
.firstWhereOrNull((e) => e.view.id == currentSpace.id)
@@ -155,29 +164,31 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
..addAll(selected.map((e) => e.copy()));
}
Future<ChatSource> _recursiveBuild(ViewPB view, ViewPB? parentView) async {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
Future<ViewSelectorItem> _recursiveBuild(
ViewPB view,
ViewPB? parentView,
) async {
ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected;
final isThisSourceSelected = selectedSourceIds.contains(view.id);
final childrenViews =
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
final List<ViewPB>? childrenViews;
if (integrationMode().isTest) {
childrenViews = view.childViews;
} else {
childrenViews =
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
}
int selectedCount = 0;
final children = <ChatSource>[];
final children = <ViewSelectorItem>[];
if (childrenViews != null) {
for (final childView in childrenViews) {
if (childView.layout == ViewLayoutPB.Chat) {
continue;
}
if (childView.layout != ViewLayoutPB.Document && hideDisabled) {
continue;
}
final childChatSource = await _recursiveBuild(childView, view);
if (childChatSource.selectedStatus.isSelected) {
final childItem = await _recursiveBuild(childView, view);
if (childItem.selectedStatus.isSelected) {
selectedCount++;
}
children.add(childChatSource);
children.add(childItem);
}
final areAllChildrenSelectedOrNoChildren =
@@ -186,47 +197,64 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
selectedStatus = ViewSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
selectedStatus = ViewSelectedStatus.partiallySelected;
}
} else if (isThisSourceSelected) {
selectedStatus = SourceSelectedStatus.selected;
selectedStatus = ViewSelectedStatus.selected;
}
return ChatSource(
return ViewSelectorItem(
view: view,
parentView: parentView,
children: children,
ignoreStatus: view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable,
isDisabled: false,
isExpanded: false,
selectedStatus: selectedStatus,
);
}
void _restrictSelectionIfNecessary(List<ChatSource> sources) {
void _setIsDisabledAndHideIfNecessary(
List<ViewSelectorItem> sources,
) {
sources.retainWhere((source) {
final ignoreViewType = getIgnoreViewType(source);
return ignoreViewType != IgnoreViewType.hide;
});
for (final source in sources) {
source.resetIgnoreViewTypeRecursive();
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;
});
}
if (sources.where((e) => !e.selectedStatus.isUnselected).length >=
_kMaxSelectedParentPageCount) {
maxSelectedParentPageCount!) {
sources
.where((e) => e.selectedStatus == SourceSelectedStatus.unselected)
.where((e) => e.selectedStatus == ViewSelectedStatus.unselected)
.forEach(
(e) => e.updateIgnoreViewTypeRecursive(IgnoreViewType.disable),
(e) => e.setIsDisabledRecursive((_) => true),
);
}
}
void updateFilter(String filter) {
this.filter = filter;
void onFilterChanged() {
for (final source in state.visibleSources) {
source.dispose();
}
if (sources.isEmpty) {
emit(ChatSettingsState.initial());
emit(ViewSelectorState.initial());
} else {
final selected =
selectedSources.map(_buildSearchResults).nonNulls.toList();
@@ -242,13 +270,13 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
}
/// traverse tree to build up search query
ChatSource? _buildSearchResults(ChatSource chatSource) {
final isVisible = chatSource.view.nameOrDefault
ViewSelectorItem? _buildSearchResults(ViewSelectorItem item) {
final isVisible = item.view.nameOrDefault
.toLowerCase()
.contains(filter.toLowerCase());
.contains(filterTextController.text.toLowerCase());
final childrenResults = <ChatSource>[];
for (final childSource in chatSource.children) {
final childrenResults = <ViewSelectorItem>[];
for (final childSource in item.children) {
final childResult = _buildSearchResults(childSource);
if (childResult != null) {
childrenResults.add(childResult);
@@ -256,48 +284,50 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
}
return isVisible || childrenResults.isNotEmpty
? ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
? ViewSelectorItem(
view: item.view,
parentView: item.parentView,
children: childrenResults,
ignoreStatus: chatSource.ignoreStatus,
isExpanded: chatSource.isExpanded,
selectedStatus: chatSource.selectedStatus,
isDisabled: item.isDisabled,
isExpanded: item.isExpanded,
selectedStatus: item.selectedStatus,
)
: null;
}
/// traverse tree to build up selected sources
Iterable<ChatSource> _buildSelectedSources(ChatSource chatSource) {
final children = <ChatSource>[];
Iterable<ViewSelectorItem> _buildSelectedSources(
ViewSelectorItem item,
) {
final children = <ViewSelectorItem>[];
for (final childSource in chatSource.children) {
for (final childSource in item.children) {
children.addAll(_buildSelectedSources(childSource));
}
return selectedSourceIds.contains(chatSource.view.id)
return selectedSourceIds.contains(item.view.id)
? [
ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
ViewSelectorItem(
view: item.view,
parentView: item.parentView,
children: children,
ignoreStatus: chatSource.ignoreStatus,
selectedStatus: chatSource.selectedStatus,
isDisabled: item.isDisabled,
selectedStatus: item.selectedStatus,
isExpanded: true,
),
]
: children;
}
void toggleSelectedStatus(ChatSource chatSource) {
if (chatSource.view.isSpace) {
void toggleSelectedStatus(ViewSelectorItem item, bool isSelectedSection) {
if (item.view.isSpace) {
return;
}
final allIds = _recursiveGetSourceIds(chatSource);
final allIds = _recursiveGetSourceIds(item);
if (chatSource.selectedStatus.isUnselected ||
chatSource.selectedStatus.isPartiallySelected &&
!chatSource.view.layout.isDocumentView) {
if (item.selectedStatus.isUnselected ||
item.selectedStatus.isPartiallySelected &&
!item.view.layout.isDocumentView) {
for (final id in allIds) {
if (!selectedSourceIds.contains(id)) {
selectedSourceIds.add(id);
@@ -311,13 +341,22 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
}
}
if (isSelectedSection) {
item.setIsSelectedStatusRecursive(
item.selectedStatus.isUnselected ||
item.selectedStatus.isPartiallySelected
? ViewSelectedStatus.selected
: ViewSelectedStatus.unselected,
);
}
updateSelectedStatus();
}
List<String> _recursiveGetSourceIds(ChatSource chatSource) {
List<String> _recursiveGetSourceIds(ViewSelectorItem item) {
return [
if (chatSource.view.layout.isDocumentView) chatSource.view.id,
for (final childSource in chatSource.children)
if (item.view.layout.isDocumentView) item.view.id,
for (final childSource in item.children)
..._recursiveGetSourceIds(childSource),
];
}
@@ -342,44 +381,42 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
);
}
SourceSelectedStatus _recursiveUpdateSelectedStatus(ChatSource chatSource) {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
ViewSelectedStatus _recursiveUpdateSelectedStatus(ViewSelectorItem item) {
ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected;
int selectedCount = 0;
for (final childSource in chatSource.children) {
for (final childSource in item.children) {
final childStatus = _recursiveUpdateSelectedStatus(childSource);
if (childStatus.isSelected) {
selectedCount++;
}
}
final isThisSourceSelected = selectedSourceIds.contains(chatSource.view.id);
final isThisSourceSelected = selectedSourceIds.contains(item.view.id);
final areAllChildrenSelectedOrNoChildren =
chatSource.children.length == selectedCount;
item.children.length == selectedCount;
final isAnyChildNotUnselected =
chatSource.children.any((e) => !e.selectedStatus.isUnselected);
item.children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
selectedStatus = ViewSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
selectedStatus = ViewSelectedStatus.partiallySelected;
}
chatSource.selectedStatusNotifier.value = selectedStatus;
item.selectedStatusNotifier.value = selectedStatus;
return selectedStatus;
}
void toggleIsExpanded(ChatSource chatSource, bool isSelectedSection) {
chatSource.toggleIsExpanded();
void toggleIsExpanded(ViewSelectorItem item, bool isSelectedSection) {
item.toggleIsExpanded();
if (isSelectedSection) {
for (final selectedSource in selectedSources) {
selectedSource
.findChildBySourceId(chatSource.view.id)
?.toggleIsExpanded();
selectedSource.findChildBySourceId(item.view.id)?.toggleIsExpanded();
}
} else {
for (final source in sources) {
final child = source.findChildBySourceId(chatSource.view.id);
final child = source.findChildBySourceId(item.view.id);
if (child != null) {
child.toggleIsExpanded();
break;
@@ -402,18 +439,19 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
for (final child in state.visibleSources) {
child.dispose();
}
filterTextController.dispose();
return super.close();
}
}
@freezed
class ChatSettingsState with _$ChatSettingsState {
const factory ChatSettingsState({
required List<ChatSource> visibleSources,
required List<ChatSource> selectedSources,
}) = _ChatSettingsState;
class ViewSelectorState with _$ViewSelectorState {
const factory ViewSelectorState({
required List<ViewSelectorItem> visibleSources,
required List<ViewSelectorItem> selectedSources,
}) = _ViewSelectorState;
factory ChatSettingsState.initial() => const ChatSettingsState(
factory ViewSelectorState.initial() => const ViewSelectorState(
visibleSources: [],
selectedSources: [],
);
@@ -8,11 +8,8 @@ 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();
}
@@ -23,60 +20,59 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
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,
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),
],
),
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(
context,
category,
),
),
],
),
),
],
),
);
}
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,
@@ -88,18 +84,25 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
},
);
}
}
Widget _buildFeaturedCategory(BuildContext context) {
class AiPromptFeaturedSection extends StatelessWidget {
const AiPromptFeaturedSection({
super.key,
});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
ready: (state) => state.isFeaturedCategorySelected,
ready: (state) => state.isFeaturedSectionSelected,
orElse: () => false,
);
return AFBaseButton(
onTap: () {
if (!isSelected) {
context.read<AiPromptSelectorCubit>().selectFeaturedCategory();
context.read<AiPromptSelectorCubit>().selectFeaturedSection();
}
},
builder: (context, isHovering, disabled) {
@@ -131,6 +134,61 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
}
}
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,
@@ -143,41 +201,47 @@ class AiPromptCategoryItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
ready: (state) =>
!state.isFeaturedCategorySelected &&
state.selectedCategory == category,
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;
},
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,
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;
},
);
},
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;
},
);
}
}
@@ -0,0 +1,346 @@
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,6 +1,8 @@
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';
@@ -8,6 +10,7 @@ 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';
@@ -15,11 +18,20 @@ Future<AiPrompt?> showAiPromptModal(
BuildContext context, {
required AiPromptSelectorCubit aiPromptSelectorCubit,
}) async {
aiPromptSelectorCubit.loadCustomPrompts();
return showDialog<AiPrompt?>(
context: context,
builder: (context) {
return BlocProvider.value(
value: aiPromptSelectorCubit,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider.value(
value: aiPromptSelectorCubit,
),
BlocProvider.value(
value: context.read<UserWorkspaceBloc>(),
),
],
child: const AiPromptModal(),
);
},
@@ -38,92 +50,122 @@ class AiPromptModal extends StatelessWidget {
maxWidth: 1200,
maxHeight: 800,
),
child: Column(
children: [
AFModalHeader(
leading: Text(
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
style: theme.textStyle.heading4.prominent(
color: theme.textColorScheme.primary,
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,
),
),
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),
),
);
},
),
],
),
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(
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(),
),
),
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,
if (readyState.isCustomPromptSectionSelected &&
readyState.customPromptDatabaseViewId == null)
const Expanded(
flex: 5,
child: Center(
child: AiPromptOnboarding(),
),
)
else ...[
const 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: () => null,
);
if (selectedPrompt == null) {
return const SizedBox.shrink();
}
return AiPromptPreview(
prompt: selectedPrompt,
);
},
orElse: () => null,
);
if (selectedPrompt == null) {
return const SizedBox.shrink();
}
return AiPromptPreview(
prompt: selectedPrompt,
);
},
),
),
],
);
},
);
},
),
),
],
],
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
),
),
),
],
],
),
),
);
}
}
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) {},
),
);
}
@@ -0,0 +1,53 @@
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,10 +22,8 @@ class AiPromptPreview extends StatelessWidget {
child: Column(
children: [
Padding(
padding: EdgeInsets.only(
top: theme.spacing.l,
left: theme.spacing.l,
right: theme.spacing.l,
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.l,
),
child: SelectionContainer.disabled(
child: Row(
@@ -7,10 +7,12 @@ 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/widget/spacing.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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 {
@@ -35,12 +37,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);
}
@@ -53,52 +55,87 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
@override
Widget build(BuildContext context) {
final spacing = AppFlowyTheme.of(context).spacing;
final theme = AppFlowyTheme.of(context);
return BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
final list = state.maybeMap(
ready: (state) => state.visiblePrompts,
orElse: () => <AiPrompt>[],
state.maybeMap(
ready: (state) {
handleVisiblePromptListChanged(state.visiblePrompts);
},
orElse: () {},
);
handleVisiblePromptListChanged(list);
},
child: Column(
children: [
VSpace(spacing.l),
Padding(
padding: EdgeInsets.symmetric(horizontal: spacing.l),
padding: EdgeInsets.symmetric(horizontal: theme.spacing.l),
child: buildSearchField(context),
),
VSpace(spacing.s),
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(),
);
},
),
Expanded(
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(),
);
},
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(),
);
},
),
),
),
],
@@ -141,6 +178,68 @@ 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,
) {
@@ -258,6 +357,7 @@ 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
@@ -267,7 +367,6 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
? theme.borderColorScheme.greyTertiaryHover
: theme.borderColorScheme.greyTertiary,
),
color: theme.surfaceColorScheme.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -4,6 +4,7 @@ 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';
@@ -57,7 +58,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
@override
void initState() {
super.initState();
chatUserCubit.fetchUserProfile();
widget.textController.addListener(handleTextControllerChanged);
focusNode
@@ -77,6 +77,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
checkForAskingAI();
});
}
@@ -228,6 +229,26 @@ 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,13 +1,16 @@
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/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/ai/service/view_selector_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';
@@ -32,7 +35,18 @@ class PromptInputMobileSelectSourcesButton extends StatefulWidget {
class _PromptInputMobileSelectSourcesButtonState
extends State<PromptInputMobileSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
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;
},
);
@override
void initState() {
@@ -90,9 +104,12 @@ class _PromptInputMobileSelectSourcesButtonState
],
),
onTap: () async {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
unawaited(
context
.read<ViewSelectorCubit>()
.refreshSources(state.spaces, state.currentSpace),
);
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
@@ -129,32 +146,17 @@ class _PromptInputMobileSelectSourcesButtonState
}
}
class _MobileSelectSourcesSheetBody extends StatefulWidget {
class _MobileSelectSourcesSheetBody extends StatelessWidget {
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: widget.scrollController,
controller: scrollController,
shrinkWrap: true,
slivers: [
SliverPersistentHeader(
@@ -183,10 +185,9 @@ class _MobileSelectSourcesSheetBodyState
child: SizedBox(
height: 44.0,
child: FlowySearchTextField(
controller: textController,
onChanged: (value) => context
.read<ChatSettingsCubit>()
.updateFilter(value),
controller: context
.read<ViewSelectorCubit>()
.filterTextController,
),
),
),
@@ -196,27 +197,25 @@ class _MobileSelectSourcesSheetBodyState
),
),
),
BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
final sources = state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide);
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: sources.length,
childCount: state.visibleSources.length,
(context, index) {
final source = sources.elementAt(index);
return ChatSourceTreeItem(
final source = state.visibleSources.elementAt(index);
return ViewSelectorTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${source.view.id}',
),
chatSource: source,
viewSelectorItem: source,
level: 0,
isDescendentOfSpace: source.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
onSelected: (item) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
.read<ViewSelectorCubit>()
.toggleSelectedStatus(item, false);
},
height: 40.0,
);
@@ -2,15 +2,13 @@ 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';
@@ -18,6 +16,8 @@ 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,7 +38,24 @@ class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
class _PromptInputDesktopSelectSourcesButtonState
extends State<PromptInputDesktopSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
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;
},
);
final popoverController = PopoverController();
@override
@@ -59,23 +76,10 @@ class _PromptInputDesktopSelectSourcesButtonState
@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) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider.value(
value: cubit,
),
],
return ViewSelector(
viewSelectorCubit: BlocProvider.value(
value: cubit,
),
child: BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
return AppFlowyPopover(
@@ -86,18 +90,18 @@ class _PromptInputDesktopSelectSourcesButtonState
controller: popoverController,
onOpen: () {
context
.read<ChatSettingsCubit>()
.read<ViewSelectorCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
onClose: () {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
context
.read<ChatSettingsCubit>()
.read<ViewSelectorCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
value: context.read<ViewSelectorCubit>(),
child: const _PopoverContent(),
);
},
@@ -186,20 +190,26 @@ class _PopoverContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
return BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
child: AFTextField(
size: AFTextFieldSize.m,
controller:
context.read<ViewSelectorCubit>().filterTextController,
hintText: LocaleKeys.search_label.tr(),
),
),
_buildDivider(),
AFDivider(
startIndent: theme.spacing.l,
endIndent: theme.spacing.l,
),
Flexible(
child: ListView(
shrinkWrap: true,
@@ -208,9 +218,10 @@ class _PopoverContent extends StatelessWidget {
..._buildSelectedSources(context, state),
if (state.selectedSources.isNotEmpty &&
state.visibleSources.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: _buildDivider(),
AFDivider(
spacing: 4.0,
startIndent: theme.spacing.l,
endIndent: theme.spacing.l,
),
..._buildVisibleSources(context, state),
],
@@ -222,70 +233,53 @@ 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,
ChatSettingsState state,
ViewSelectorState state,
) {
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,
),
);
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,
),
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
ViewSelectorState state,
) {
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,
),
);
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,
),
);
}
}
class ChatSourceTreeItem extends StatefulWidget {
const ChatSourceTreeItem({
class ViewSelectorTreeItem extends StatefulWidget {
const ViewSelectorTreeItem({
super.key,
required this.chatSource,
required this.viewSelectorItem,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
@@ -296,7 +290,7 @@ class ChatSourceTreeItem extends StatefulWidget {
this.showCheckbox = true,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
/// nested level of the view item
final int level;
@@ -305,9 +299,9 @@ class ChatSourceTreeItem extends StatefulWidget {
final bool isSelectedSection;
final void Function(ChatSource chatSource) onSelected;
final void Function(ViewSelectorItem viewSelectorItem) onSelected;
final void Function(ChatSource chatSource)? onAdd;
final void Function(ViewSelectorItem viewSelectorItem)? onAdd;
final bool showSaveButton;
@@ -316,16 +310,16 @@ class ChatSourceTreeItem extends StatefulWidget {
final bool showCheckbox;
@override
State<ChatSourceTreeItem> createState() => _ChatSourceTreeItemState();
State<ViewSelectorTreeItem> createState() => _ViewSelectorTreeItemState();
}
class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
class _ViewSelectorTreeItemState extends State<ViewSelectorTreeItem> {
@override
Widget build(BuildContext context) {
final child = SizedBox(
height: widget.height,
child: ChatSourceTreeItemInner(
chatSource: widget.chatSource,
child: ViewSelectorTreeItemInner(
viewSelectorItem: widget.viewSelectorItem,
level: widget.level,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
@@ -336,33 +330,30 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
),
);
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;
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;
return ValueListenableBuilder(
valueListenable: widget.chatSource.isExpandedNotifier,
valueListenable: widget.viewSelectorItem.isExpandedNotifier,
builder: (context, isExpanded, child) {
// filter the child views that should be ignored
final childViews = widget.chatSource.children
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.toList();
final childViews = widget.viewSelectorItem.children;
if (!isExpanded || childViews.isEmpty) {
return disabledEnabledChild;
@@ -373,11 +364,11 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
children: [
disabledEnabledChild,
...childViews.map(
(childSource) => ChatSourceTreeItem(
(childSource) => ViewSelectorTreeItem(
key: ValueKey(
'select_sources_tree_item_${childSource.view.id}',
),
chatSource: childSource,
viewSelectorItem: childSource,
level: widget.level + 1,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
@@ -395,10 +386,10 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
}
}
class ChatSourceTreeItemInner extends StatelessWidget {
const ChatSourceTreeItemInner({
class ViewSelectorTreeItemInner extends StatelessWidget {
const ViewSelectorTreeItemInner({
super.key,
required this.chatSource,
required this.viewSelectorItem,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
@@ -408,34 +399,27 @@ class ChatSourceTreeItemInner extends StatelessWidget {
this.onAdd,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
final int level;
final bool isDescendentOfSpace;
final bool isSelectedSection;
final bool showCheckbox;
final bool showSaveButton;
final void Function(ChatSource)? onSelected;
final void Function(ChatSource)? onAdd;
final void Function(ViewSelectorItem)? onSelected;
final void Function(ViewSelectorItem)? onAdd;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (!isSelectedSection) {
onSelected?.call(chatSource);
}
},
onTap: () => onSelected?.call(viewSelectorItem),
child: FlowyHover(
cursor: isSelectedSection ? SystemMouseCursors.basic : null,
style: HoverStyle(
hoverColor: isSelectedSection
? Colors.transparent
: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
),
builder: (context, onHover) {
final isSaveButtonVisible =
showSaveButton && !chatSource.view.isSpace;
showSaveButton && !viewSelectorItem.view.isSpace;
final isAddButtonVisible = onAdd != null;
return Row(
children: [
@@ -443,26 +427,26 @@ class ChatSourceTreeItemInner extends StatelessWidget {
HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)),
// builds the >, ^ or · button
ToggleIsExpandedButton(
chatSource: chatSource,
viewSelectorItem: viewSelectorItem,
isSelectedSection: isSelectedSection,
),
const HSpace(2.0),
// checkbox
if (!chatSource.view.isSpace && showCheckbox) ...[
if (!viewSelectorItem.view.isSpace && showCheckbox) ...[
SourceSelectedStatusCheckbox(
chatSource: chatSource,
viewSelectorItem: viewSelectorItem,
),
const HSpace(4.0),
],
// icon
MentionViewIcon(
view: chatSource.view,
view: viewSelectorItem.view,
),
const HSpace(6.0),
// title
Expanded(
child: FlowyText(
chatSource.view.nameOrDefault,
viewSelectorItem.view.nameOrDefault,
overflow: TextOverflow.ellipsis,
fontSize: 14.0,
figmaLineHeight: 18.0,
@@ -479,7 +463,7 @@ class ChatSourceTreeItemInner extends StatelessWidget {
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
onPressed: () => onSelected?.call(chatSource),
onPressed: () => onSelected?.call(viewSelectorItem),
),
if (isSaveButtonVisible && isAddButtonVisible)
const HSpace(4.0),
@@ -492,7 +476,7 @@ class ChatSourceTreeItemInner extends StatelessWidget {
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
onPressed: () => onAdd?.call(chatSource),
onPressed: () => onAdd?.call(viewSelectorItem),
),
const HSpace(4.0),
],
@@ -507,27 +491,30 @@ class ChatSourceTreeItemInner extends StatelessWidget {
class ToggleIsExpandedButton extends StatelessWidget {
const ToggleIsExpandedButton({
super.key,
required this.chatSource,
required this.viewSelectorItem,
required this.isSelectedSection,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
final bool isSelectedSection;
@override
Widget build(BuildContext context) {
if (isReferencedDatabaseView(chatSource.view, chatSource.parentView)) {
if (isReferencedDatabaseView(
viewSelectorItem.view,
viewSelectorItem.parentView,
)) {
return const _DotIconWidget();
}
if (chatSource.children.isEmpty) {
if (viewSelectorItem.children.isEmpty) {
return const SizedBox.square(dimension: 16.0);
}
return FlowyHover(
child: GestureDetector(
child: ValueListenableBuilder(
valueListenable: chatSource.isExpandedNotifier,
valueListenable: viewSelectorItem.isExpandedNotifier,
builder: (context, value, _) => FlowySvg(
value
? FlowySvgs.view_item_expand_s
@@ -536,8 +523,8 @@ class ToggleIsExpandedButton extends StatelessWidget {
),
),
onTap: () => context
.read<ChatSettingsCubit>()
.toggleIsExpanded(chatSource, isSelectedSection),
.read<ViewSelectorCubit>()
.toggleIsExpanded(viewSelectorItem, isSelectedSection),
),
);
}
@@ -565,20 +552,20 @@ class _DotIconWidget extends StatelessWidget {
class SourceSelectedStatusCheckbox extends StatelessWidget {
const SourceSelectedStatusCheckbox({
super.key,
required this.chatSource,
required this.viewSelectorItem,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: chatSource.selectedStatusNotifier,
valueListenable: viewSelectorItem.selectedStatusNotifier,
builder: (context, selectedStatus, _) => FlowySvg(
switch (selectedStatus) {
SourceSelectedStatus.unselected => FlowySvgs.uncheck_s,
SourceSelectedStatus.selected => FlowySvgs.check_filled_s,
SourceSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
ViewSelectedStatus.unselected => FlowySvgs.uncheck_s,
ViewSelectedStatus.selected => FlowySvgs.check_filled_s,
ViewSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
},
size: const Size.square(18.0),
blendMode: null,
@@ -0,0 +1,39 @@
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.gointToAskAI());
?.add(CommandPaletteEvent.goingToAskAI());
mobileCreateNewAIChatNotifier.value =
mobileCreateNewAIChatNotifier.value + 1;
},
@@ -150,10 +150,9 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 10, 8, 10),
child: FlowySvg(
FlowySvgs.clear_s,
blendMode: null,
color: theme.iconColorScheme.tertiary,
size: Size.square(20),
FlowySvgs.search_clear_m,
color: theme.iconColorScheme.secondary,
size: const Size.square(20),
),
),
)
@@ -1,4 +1,7 @@
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';
@@ -18,6 +21,8 @@ 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) {
@@ -53,3 +58,46 @@ 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,5 +11,4 @@ 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,6 +117,14 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
),
);
});
on<_OnAIFollowUp>((event, emit) {
emit(
state.copyWith(
messageState: MessageState.aiFollowUp(event.followUpData),
),
);
});
}
void _initializeStreamListener() {
@@ -137,6 +145,9 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
},
onLocalAIInitializing: () =>
_safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()),
onAIFollowUp: (data) {
_safeAdd(ChatAIMessageEvent.onAIFollowUp(data));
},
);
}
}
@@ -174,6 +185,9 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.receiveMetadata(
MetadataCollection metadata,
) = _ReceiveMetadata;
const factory ChatAIMessageEvent.onAIFollowUp(
AIFollowUpData followUpData,
) = _OnAIFollowUp;
}
@freezed
@@ -209,4 +223,6 @@ 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,8 +1,6 @@
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';
@@ -15,267 +13,291 @@ 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();
_loadSetting();
_loadSettings();
}
final String chatId;
final String userId;
final ChatMessageListener listener;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final ChatController chatController;
/// The last streaming message id
String answerStreamMessageId = '';
String questionStreamMessageId = '';
// Managers
late final ChatMessageHandler _messageHandler;
late final ChatStreamManager _streamManager;
late final ChatSettingsManager _settingsManager;
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 {
await answerStream?.dispose();
// Safely dispose all resources
await _streamManager.dispose();
await listener.stop();
final request = ViewIdPB(value: chatId);
unawaited(FolderEventCloseView(request).send());
selectedSourcesNotifier.dispose();
_settingsManager.dispose();
chatController.dispose();
return super.close();
}
void _dispatch() {
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);
}
on<ChatEvent>((event, emit) async {
await event.when(
// Chat settings
didReceiveChatSettings: (settings) async =>
_handleChatSettings(settings),
updateSelectedSources: (selectedSourcesIds) async =>
_handleUpdateSources(selectedSourcesIds),
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 loading
didLoadLatestMessages: (messages) async =>
_handleLatestMessages(messages, emit),
loadPreviousMessages: () async => _loadPreviousMessagesIfNeeded(),
didLoadPreviousMessages: (messages, hasMore) async =>
_handlePreviousMessages(messages, hasMore),
final oldestMessage = _getOldestMessage();
// Message handling
receiveMessage: (message) async => _handleReceiveMessage(message),
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}");
// Sending messages
sendMessage: (message, format, metadata) async =>
_handleSendMessage(message, format, metadata, emit),
finishSending: () async => emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
),
),
for (final message in messages) {
chatController.insert(message, index: 0);
}
// Stream control
stopStream: () async => _handleStopStream(emit),
failedSending: () async => _handleFailedSending(emit),
isLoadingPreviousMessages = false;
hasMorePreviousMessages = hasMore;
},
didFinishAnswerStream: () {
emit(
state.copyWith(promptResponseState: PromptResponseState.ready),
);
},
didReceiveRelatedQuestions: (List<String> questions) {
if (questions.isEmpty) {
return;
}
// Answer regeneration
regenerateAnswer: (id, format, model) async =>
_handleRegenerateAnswer(id, format, model, emit),
final metadata = {
onetimeShotType: OnetimeShotType.relatedQuestion,
'questions': questions,
};
// Streaming completion
didFinishAnswerStream: () async => emit(
state.copyWith(promptResponseState: PromptResponseState.ready),
),
final createdAt = DateTime.now();
// Related questions
didReceiveRelatedQuestions: (questions) async =>
_handleRelatedQuestions(questions),
final message = TextMessage(
id: "related_question_$createdAt",
text: '',
metadata: metadata,
author: const User(id: systemUserId),
createdAt: createdAt,
);
// Message management
deleteMessage: (message) async => chatController.remove(message),
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;
// AI follow-up
onAIFollowUp: (followUpData) async {
shouldFetchRelatedQuestions =
followUpData.shouldGenerateRelatedQuestion;
},
);
});
}
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions =
format == null || format.imageFormat.hasText;
// Chat settings handlers
void _handleChatSettings(ChatSettingsPB settings) {
_settingsManager.selectedSourcesNotifier.value = settings.ragIds;
}
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
finishSending: () {
emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
),
);
},
stopStream: () async {
if (answerStream == null) {
return;
}
Future<void> _handleUpdateSources(List<String> selectedSourcesIds) async {
await _settingsManager.updateSelectedSources(selectedSourcesIds);
}
// tell backend to stop
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
// Message loading handlers
Future<void> _handleLatestMessages(
List<Message> messages,
Emitter<ChatState> emit,
) async {
for (final message in messages) {
await chatController.insert(message, index: 0);
}
// allow user input
emit(
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
);
// Check if emit is still valid after async operations
if (emit.isDone) {
return;
}
// no need to remove old message if stream has started already
if (answerStream!.hasStarted) {
return;
}
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;
}
}
// remove the non-started message from the list
final message = chatController.messages.lastWhereOrNull(
(e) => e.id == answerStreamMessageId,
);
if (message != null) {
await chatController.remove(message);
}
void _handlePreviousMessages(List<Message> messages, bool hasMore) {
for (final message in messages) {
chatController.insert(message, index: 0);
}
// 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;
isLoadingPreviousMessages = false;
hasMorePreviousMessages = hasMore;
}
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions = false;
// 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);
}
}
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
didReceiveChatSettings: (settings) {
selectedSourcesNotifier.value = settings.ragIds;
},
updateSelectedSources: (selectedSourcesIds) async {
selectedSourcesNotifier.value = [...selectedSourcesIds];
// Message sending handlers
void _handleSendMessage(
String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
Emitter<ChatState> emit,
) {
_messageHandler.clearErrorMessages();
emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
final payload = UpdateChatSettingsPB(
chatId: ChatId(value: chatId),
ragIds: selectedSourcesIds,
);
await AIEventUpdateChatSettings(payload)
.send()
.onFailure(Log.error);
},
deleteMessage: (mesesage) async {
await chatController.remove(mesesage);
},
);
},
_messageHandler.clearRelatedQuestions();
_startStreamingMessage(message, format, metadata);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions = format == null || format.imageFormat.hasText;
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
}
// 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) {
@@ -283,25 +305,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
return;
}
// 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);
_messageHandler.processReceivedMessage(pb);
final message = _messageHandler.createTextMessage(pb);
add(ChatEvent.receiveMessage(message));
},
chatErrorMessageCallback: (err) {
@@ -312,13 +317,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
latestMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
final messages =
list.messages.map(_messageHandler.createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
prevMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
final messages =
list.messages.map(_messageHandler.createTextMessage).toList();
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
}
},
@@ -328,52 +335,55 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
add(const ChatEvent.didFinishAnswerStream());
// 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"),
);
unawaited(_fetchRelatedQuestionsIfNeeded());
},
);
}
void _loadSetting() async {
// 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;
}
},
(err) => Log.error("Failed to get related questions: $err"),
);
}
void _loadSettings() async {
final getChatSettingsPayload =
AIEventGetChatSettings(ChatId(value: chatId));
await getChatSettingsPayload.send().fold(
(settings) {
if (!isClosed) {
add(ChatEvent.didReceiveChatSettings(settings: settings));
}
},
Log.error,
(err) => Log.error("Failed to load chat settings: $err"),
);
}
@@ -382,10 +392,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatId: chatId,
limit: Int64(10),
);
await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
(list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
final messages =
list.messages.map(_messageHandler.createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
@@ -393,15 +405,22 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
bool _isOneTimeMessage(Message message) {
return message.metadata != null &&
message.metadata!.containsKey(onetimeShotType);
}
void _loadPreviousMessagesIfNeeded() {
if (isLoadingPreviousMessages) {
return;
}
/// get the last message that is not a one-time message
Message? _getOldestMessage() {
return chatController.messages
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
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);
}
}
void _loadPreviousMessages(Int64? beforeMessageId) {
@@ -418,36 +437,23 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
PredefinedFormat? format,
Map<String, dynamic>? metadata,
) async {
await answerStream?.dispose();
// Prepare streams
await _streamManager.prepareStreams();
answerStream = AnswerStream();
final questionStream = QuestionStream();
// add a streaming question message
final questionStreamMessage = _createQuestionStreamMessage(
questionStream,
// Create and add question message
final questionStreamMessage = _messageHandler.createQuestionStreamMessage(
_streamManager.questionStream!,
metadata,
);
add(ChatEvent.receiveMessage(questionStreamMessage));
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(
// Send stream request
await _streamManager.sendStreamRequest(message, format).fold(
(question) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
stream: answerStream!,
// Create and add answer stream message
final streamAnswer = _messageHandler.createAnswerStreamMessage(
stream: _streamManager.answerStream!,
questionMessageId: question.messageId,
fakeQuestionMessageId: questionStreamMessage.id,
);
@@ -481,143 +487,42 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
// Refactored method to handle answer regeneration
void _regenerateAnswer(
String answerMessageIdString,
PredefinedFormat? format,
AIModelPB? model,
) async {
final id = temporaryMessageIDMap.entries
.firstWhereOrNull((e) => e.value == answerMessageIdString)
?.key ??
answerMessageIdString;
final id = _messageHandler.getEffectiveMessageId(answerMessageIdString);
final answerMessageId = Int64.tryParseInt(id);
if (answerMessageId == null) {
return;
}
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) {
await _streamManager.prepareStreams();
await _streamManager
.sendRegenerateRequest(
answerMessageId,
format,
model,
)
.fold(
(_) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
stream: answerStream!,
questionMessageId: answerMessageId - 1,
).copyWith(id: answerMessageIdString);
final streamAnswer = _messageHandler
.createAnswerStreamMessage(
stream: _streamManager.answerStream!,
questionMessageId: answerMessageId - 1,
)
.copyWith(id: answerMessageIdString);
add(ChatEvent.receiveMessage(streamAnswer));
add(const ChatEvent.finishSending());
}
},
(err) => Log.error("Failed to send message: ${err.msg}"),
(err) => Log.error("Failed to regenerate answer: ${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
@@ -668,6 +573,9 @@ class ChatEvent with _$ChatEvent {
) = _DidReceiveRelatedQueston;
const factory ChatEvent.deleteMessage(Message message) = _DeleteMessage;
const factory ChatEvent.onAIFollowUp(AIFollowUpData followUpData) =
_OnAIFollowUp;
}
@freezed
@@ -0,0 +1,171 @@
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,9 +1,14 @@
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.
@@ -37,7 +42,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;
@@ -88,6 +93,15 @@ 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');
}
}
}
@@ -114,6 +128,7 @@ class AnswerStream {
void Function(String message)? onAIMaxRequired,
void Function(MetadataCollection metadata)? onMetadata,
void Function()? onLocalAIInitializing,
void Function(AIFollowUpData)? onAIFollowUp,
}) {
_onData = onData;
_onStart = onStart;
@@ -124,7 +139,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) {
@@ -205,7 +220,6 @@ 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;
@@ -241,3 +255,18 @@ 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);
}
@@ -0,0 +1,48 @@
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();
}
}
@@ -0,0 +1,120 @@
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,15 +1,17 @@
// 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({
@@ -41,7 +43,6 @@ 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,
@@ -53,9 +54,27 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
late final Animation<double> _scrollToBottomAnimation;
Timer? _scrollToBottomShowTimer;
bool _userHasScrolled = false;
final ScrollOffsetController scrollOffsetController =
ScrollOffsetController();
final ItemScrollController itemScrollController = ItemScrollController();
final ScrollOffsetListener scrollOffsetListener =
ScrollOffsetListener.create();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
int _lastUserMessageIndex = 0;
bool _isScrollingToBottom = false;
// String _lastInsertedMessageId = '';
final _loadPreviousMessagesDebounce = Debounce(
duration: const Duration(milliseconds: 200),
);
int initialScrollIndex = 0;
double initialAlignment = 1.0;
List<Message> messages = [];
@override
void initState() {
@@ -64,6 +83,9 @@ 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(
@@ -74,6 +96,7 @@ 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;
@@ -86,6 +109,7 @@ 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;
@@ -109,6 +133,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
}
});
messages = _chatController.messages;
_scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
@@ -119,186 +145,127 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
curve: Curves.easeInOut,
);
widget.scrollController.addListener(_handleLoadPreviousMessages);
itemPositionsListener.itemPositions.addListener(() {
_handleToggleScrollToBottom();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
itemPositionsListener.itemPositions.addListener(() {
_handleLoadPreviousMessages();
});
// A trick to avoid the first message being scrolled to the top
}
@override
void dispose() {
super.dispose();
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.dispose();
_operationsSubscription.cancel();
widget.scrollController.removeListener(_handleLoadPreviousMessages);
super.dispose();
}
@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
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;
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();
}
}
}
if (notification is ScrollUpdateNotification) {
_handleToggleScrollToBottom();
}
if (index == messages.length) {
return VSpace(height - 400);
}
// 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,
final message = messages[index];
return widget.itemBuilder(
context,
Tween<double>(begin: 1, end: 1).animate(
CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
),
),
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,
),
],
),
message,
);
},
),
builders.scrollToBottomBuilder?.call(
context,
_scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: _scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
),
],
);
return child;
}
// 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,
);
}
},
Future<void> _scrollLastUserMessageToTop() async {
final user = Provider.of<User>(context, listen: false);
final lastUserMessageIndex = messages.lastIndexWhere(
(message) => message.author.id == user.id,
);
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;
}
void _handleScrollToBottom() {
Future<void> _handleScrollToBottom() async {
_isScrollingToBottom = true;
_scrollToBottomController.reverse();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!widget.scrollController.hasClients || !mounted) return;
_scrollToBottomShowTimer?.cancel();
final height = MediaQuery.of(context).size.height;
final paddingHeight = height - widget.scrollBottomPadding;
final offset =
widget.scrollController.position.maxScrollExtent - paddingHeight;
await _scrollToBottomController.reverse();
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController.jumpTo(offset);
} else {
await widget.scrollController.animateTo(
offset,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
await itemScrollController.scrollTo(
index: messages.length + 1,
alignment: 1.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
if (!widget.scrollController.hasClients || !mounted) return;
_isScrollingToBottom = false;
});
_isScrollingToBottom = false;
}
void _handleToggleScrollToBottom() {
@@ -306,88 +273,55 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
return;
}
final paddingHeight =
MediaQuery.of(context).size.height - widget.scrollBottomPadding;
_scrollToBottomShowTimer?.cancel();
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();
}
// 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();
}
}
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;
return;
}
_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,
);
}
void _onChanged(int position, Message oldData, Message newData) {
_onRemoved(position, oldData);
_listKey.currentState!.insertItem(
max(_oldList.length - position - 1, 0),
duration: widget.insertAnimationDuration,
);
}
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'),
);
_scrollToBottomShowTimer?.cancel();
_scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
}
});
}
void _handleLoadPreviousMessages() {
if (widget.scrollController.offset >=
widget.scrollController.position.maxScrollExtent) {
widget.onLoadPreviousMessages?.call();
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;
}
_loadPreviousMessagesDebounce.call(
() {
widget.onLoadPreviousMessages?.call();
},
);
}
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 _onRemoved(final int position, final Message data) {}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {}
}
@@ -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,23 +133,22 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return ViewSelector(
viewSelectorCubit: BlocProvider(
create: (context) => ViewSelectorCubit(
getIgnoreViewType: (item) {
final view = item.view;
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
if (view.isSpace) {
return IgnoreViewType.none;
}
if (view.layout != ViewLayoutPB.Document) {
return IgnoreViewType.hide;
}
return IgnoreViewType.none;
},
),
BlocProvider(
create: (context) => ChatSettingsCubit(hideDisabled: true),
),
],
),
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
@@ -188,9 +187,11 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
await updateSelection(documentId);
} else {
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.refreshSources([spaceView], spaceView);
unawaited(
context
.read<ViewSelectorCubit>()
.refreshSources([spaceView], spaceView),
);
}
popoverController.show();
}
@@ -210,7 +211,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
Widget buildPopover(BuildContext context) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
value: context.read<ViewSelectorCubit>(),
child: SaveToPagePopoverContent(
onAddToNewPage: (parentViewId) async {
await addMessageToNewPage(context, parentViewId);
@@ -9,6 +9,9 @@ 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,
@@ -34,7 +37,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) {
if (bloc.chatController.messages.isEmpty && !skipAIChatWelcomePage) {
return ChatWelcomePage(
userProfile: widget.userProfile,
onSelectedQuestion: (question) {
@@ -70,6 +73,9 @@ class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
if (bloc.isClosed) {
return;
}
bloc.add(const ChatEvent.loadPreviousMessages());
},
)
@@ -80,6 +86,9 @@ class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
if (bloc.isClosed) {
return;
}
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
@@ -0,0 +1,33 @@
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,13 +1,14 @@
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';
import 'package:flutter_chat_ui/flutter_chat_ui.dart' hide ChatMessage;
import 'package:universal_platform/universal_platform.dart';
class LoadChatMessageStatusReady extends StatelessWidget {
@@ -44,6 +45,7 @@ class LoadChatMessageStatusReady extends StatelessWidget {
}
Widget _buildBody(BuildContext context) {
final bool enableAnimation = true;
return Expanded(
child: Align(
alignment: Alignment.topCenter,
@@ -61,20 +63,32 @@ 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,
@@ -88,7 +102,7 @@ class LoadChatMessageStatusReady extends StatelessWidget {
userProfile: userProfile,
scrollController: scrollController,
itemBuilder: itemBuilder,
enableReversedList: true,
enableReversedList: !enableAnimation,
),
),
),
@@ -79,6 +79,8 @@ 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,7 +6,6 @@ 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';
@@ -15,16 +14,15 @@ 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';
@@ -515,23 +513,23 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return ViewSelector(
viewSelectorCubit: BlocProvider(
create: (context) => ViewSelectorCubit(
getIgnoreViewType: (item) {
final view = item.view;
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
if (view.isSpace) {
return IgnoreViewType.none;
}
if (view.layout != ViewLayoutPB.Document) {
return IgnoreViewType.hide;
}
return IgnoreViewType.none;
},
),
BlocProvider(
create: (context) => ChatSettingsCubit(hideDisabled: true),
),
],
),
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
@@ -546,7 +544,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
onClose: () {
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.read<ViewSelectorCubit>()
.refreshSources([spaceView], spaceView);
}
widget.onOverrideVisibility?.call(false);
@@ -583,9 +581,11 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
} else {
widget.onOverrideVisibility?.call(true);
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.refreshSources([spaceView], spaceView);
unawaited(
context
.read<ViewSelectorCubit>()
.refreshSources([spaceView], spaceView),
);
}
popoverController.show();
}
@@ -596,7 +596,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
Widget buildPopover(BuildContext context) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
value: context.read<ViewSelectorCubit>(),
child: SaveToPagePopoverContent(
onAddToNewPage: (parentViewId) {
addMessageToNewPage(context, parentViewId);
@@ -697,8 +697,10 @@ class SaveToPagePopoverContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
return BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -707,22 +709,26 @@ class SaveToPagePopoverContent extends StatelessWidget {
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: FlowyText(
child: Text(
LocaleKeys.chat_addToPageTitle.tr(),
fontSize: 12.0,
color: Theme.of(context).hintColor,
style: theme.textStyle.caption
.standard(color: theme.textColorScheme.secondary),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
child: AFTextField(
controller:
context.read<ViewSelectorCubit>().filterTextController,
hintText: LocaleKeys.search_label.tr(),
size: AFTextFieldSize.m,
),
),
_buildDivider(),
AFDivider(
startIndent: theme.spacing.l,
endIndent: theme.spacing.l,
),
Expanded(
child: ListView(
shrinkWrap: true,
@@ -736,44 +742,33 @@ class SaveToPagePopoverContent extends StatelessWidget {
);
}
Widget _buildDivider() {
return const Divider(
height: 1.0,
thickness: 1.0,
indent: 12.0,
endIndent: 12.0,
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
ViewSelectorState 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,
),
);
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,
),
);
}
}
@@ -71,93 +71,106 @@ class ChatAIMessageWidget extends StatelessWidget {
chatId: chatId,
questionId: questionId,
),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
final loadingText =
state.progress?.step ?? LocaleKeys.chat_generatingResponse.tr();
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();
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),
),
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),
),
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(),
);
},
),
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();
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
);
},
aiFollowUp: (followUpData) {
return const SizedBox.shrink();
},
),
);
},
),
);
}
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,7 +219,8 @@ 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,6 +109,10 @@ 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,6 +14,8 @@ 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 StatelessWidget {
class BulletedListIcon extends StatefulWidget {
const BulletedListIcon({
super.key,
required this.node,
@@ -17,28 +17,30 @@ class BulletedListIcon extends StatelessWidget {
FlowySvgs.bulleted_list_icon_3_s,
];
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
State<BulletedListIcon> createState() => _BulletedListIconState();
}
class _BulletedListIconState extends State<BulletedListIcon> {
int index = 0;
double size = 0.0;
@override
Widget build(BuildContext context) {
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;
final size = fontSize * height;
final index = level % bulletedListIcons.length;
index = level % BulletedListIcon.bulletedListIcons.length;
size = fontSize * height;
}
@override
Widget build(BuildContext context) {
final icon = FlowySvg(
bulletedListIcons[index],
BulletedListIcon.bulletedListIcons[index],
size: Size.square(size * 0.8),
);
return Container(
@@ -49,4 +51,16 @@ class BulletedListIcon extends StatelessWidget {
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,11 +27,12 @@ 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,
info.name,
linkName,
attributes: info.toAttribute(),
);
apply(transaction);
@@ -186,8 +186,11 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
onDismiss: () => editMenuController.close(),
onApply: (info) => editorState.applyLink(selection, info),
onRemoveLink: (linkinfo) =>
onRemoveAndReplaceLink(editorState, selection, linkinfo.name),
onRemoveLink: (linkinfo) {
final replaceText =
linkinfo.name.isEmpty ? linkinfo.link : linkinfo.name;
onRemoveAndReplaceLink(editorState, selection, replaceText);
},
),
child: child,
);
@@ -157,6 +157,8 @@ class FileBlockComponent extends BlockComponentStatefulWidget {
super.configuration = const BlockComponentConfiguration(),
});
static const uploadDragKey = 'FileUploadMenu';
@override
State<FileBlockComponent> createState() => FileBlockComponentState();
}
@@ -253,20 +255,23 @@ 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?.isDropEnabled == true) {
if (dropManagerState?.contains(FileBlockKeys.type) == true) {
dropManagerState?.remove(FileBlockKeys.type);
setState(() => isDragging = false);
}
},
onDragDone: (details) {
if (dropManagerState?.isDropEnabled == true) {
insertFileFromLocal(details.files);
}
dropManagerState?.remove(FileBlockKeys.type);
insertFileFromLocal(details.files);
},
child: AppFlowyPopover(
controller: controller,
@@ -277,8 +282,12 @@ class FileBlockComponentState extends State<FileBlockComponent>
minHeight: 80,
),
clickHandler: PopoverClickHandler.gestureDetector,
onOpen: () => dropManagerState?.add(FileBlockKeys.type),
onClose: () => dropManagerState?.remove(FileBlockKeys.type),
onOpen: () => dropManagerState?.add(
FileBlockComponent.uploadDragKey,
),
onClose: () => dropManagerState?.remove(
FileBlockComponent.uploadDragKey,
),
popupBuilder: (_) => FileUploadMenu(
onInsertLocalFile: insertFileFromLocal,
onInsertNetworkFile: insertNetworkFile,
@@ -345,7 +354,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
void _openMenu() {
if (UniversalPlatform.isDesktopOrWeb) {
controller.show();
dropManagerState?.add(FileBlockKeys.type);
dropManagerState?.add(FileBlockComponent.uploadDragKey);
} else {
editorState.updateSelectionWithReason(null, extraInfo: {});
showUploadFileMobileMenu();
@@ -574,7 +583,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
Rect getBlockRect({bool shiftWithBaseOffset = false}) {
final renderBox = fileKey.currentContext?.findRenderObject();
if (renderBox is RenderBox) {
return Offset.zero & renderBox.size;
return padding.topLeft & renderBox.size;
}
return Rect.zero;
}
@@ -1,7 +1,6 @@
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 {
@@ -17,11 +16,18 @@ extension InsertFile on EditorState {
}
final file = fileNode(url: '')..extraInfos = {'global_key': key};
final insertedPath = delta.isEmpty ? path : path.next;
final transaction = this.transaction
..insertNode(insertedPath, file)
..insertNode(insertedPath, paragraphNode())
..afterSelection = Selection.collapsed(Position(path: insertedPath.next));
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);
}
return apply(transaction);
}
@@ -52,6 +52,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
late final titleFocusNode = editorContext.coverTitleFocusNode;
int lineCount = 1;
bool updatingViewName = false;
@override
void initState() {
super.initState();
@@ -87,7 +89,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,
previous.view.name != current.view.name && !updatingViewName,
listener: _onListen,
builder: (context, state) {
final appearance = context.read<DocumentAppearanceCubit>().state;
@@ -206,6 +208,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
}
void _onViewNameChanged() {
updatingViewName = true;
Debounce.debounce(
'update view name',
const Duration(milliseconds: 250),
@@ -222,6 +226,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
context
.read<ViewInfoBloc?>()
?.add(ViewInfoEvent.titleChanged(titleTextController.text));
updatingViewName = false;
},
);
}
@@ -1,5 +1,6 @@
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';
@@ -44,11 +45,27 @@ 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(
@@ -131,8 +148,15 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
);
},
child: DropTarget(
onDragEntered: (_) => setState(() => isDraggingFiles = true),
onDragExited: (_) => setState(() => isDraggingFiles = false),
enable: isDragEnabled,
onDragEntered: (_) {
if (isDragEnabled) {
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,7 +95,8 @@ 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,6 +8,7 @@ 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';
@@ -198,7 +199,11 @@ class EditorStyleCustomizer {
fontSize = state.fontLayout.fontSize;
fontSizes = state.fontLayout.headingFontSizes;
} else {
fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
fontFamily = context
.read<DocumentAppearanceCubit>()
.state
.fontFamily
.orDefault(context.read<AppearanceSettingsCubit>().state.font);
fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
fontSizes = [
fontSize + 16,
@@ -1,8 +1,10 @@
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';
@@ -48,6 +50,7 @@ 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,
);
@@ -99,7 +102,9 @@ class _EmojiHandlerState extends State<EmojiHandler> {
onKeyEvent: onKeyEvent,
child: Container(
constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360),
padding: const EdgeInsets.symmetric(vertical: 16),
padding: noEmojis
? EdgeInsets.zero
: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
color: Theme.of(context).cardColor,
@@ -111,7 +116,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
),
],
),
child: noEmojis ? buildLoading() : buildEmojis(),
child: loaded ? buildEmojis() : buildLoading(),
),
);
}
@@ -130,6 +135,16 @@ 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,
@@ -208,7 +223,14 @@ class _EmojiHandlerState extends State<EmojiHandler> {
_scrollToItem();
});
if (searchedEmojis.isEmpty) {
widget.onDismiss.call();
if (lastSearchedQuery.isEmpty) {
lastSearchedQuery = _search;
}
if (_search.length - lastSearchedQuery.length >= 5) {
widget.onDismiss.call();
}
} else {
lastSearchedQuery = '';
}
}
@@ -359,7 +381,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
void _scrollToItem() {
final noEmojis = searchedEmojis.isEmpty;
if (noEmojis || !mounted) return;
if (noEmojis || !mounted || !scrollController.hasClients) return;
final currentItem = selectedIndexNotifier.value;
final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight;
final maxExtent = scrollController.position.maxScrollExtent;
@@ -1,10 +1,11 @@
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';
@@ -27,7 +28,11 @@ class TrashCell extends StatelessWidget {
children: [
SizedBox(
width: TrashSizes.fileNameWidth,
child: FlowyText(object.name),
child: FlowyText(
object.name.isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: object.name,
),
),
SizedBox(
width: TrashSizes.lashModifyWidth,
@@ -7,6 +7,7 @@ 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';
@@ -78,7 +79,8 @@ class InitAppWidgetTask extends LaunchTask {
Locale('cs', 'CZ'),
Locale('ckb', 'KU'),
Locale('de', 'DE'),
Locale('en'),
Locale('en', 'US'),
Locale('en', 'GB'),
Locale('es', 'VE'),
Locale('eu', 'ES'),
Locale('el', 'GR'),
@@ -106,7 +108,7 @@ class InitAppWidgetTask extends LaunchTask {
Locale('mr', 'IN'),
],
path: 'assets/translations',
fallbackLocale: const Locale('en'),
fallbackLocale: const Locale('en', 'US'),
useFallbackTranslations: true,
child: app,
),
@@ -137,7 +139,7 @@ class ApplicationWidget extends StatefulWidget {
class _ApplicationWidgetState extends State<ApplicationWidget> {
late final GoRouter routerConfig;
final _commandPaletteNotifier = ValueNotifier<bool>(false);
final _commandPaletteNotifier = ValueNotifier(CommandPaletteNotifierValue());
final themeBuilder = AppFlowyDefaultTheme();
@@ -239,8 +241,9 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
routerConfig: routerConfig,
builder: (context, child) {
final brightness = Theme.of(context).brightness;
final fontFamily =
state.font.orDefault(defaultFontFamily);
final fontFamily = state.font
.orDefault(defaultFontFamily)
.fontFamilyName;
return AppFlowyTheme(
data: brightness == Brightness.light
@@ -1,11 +1,9 @@
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';
@@ -21,8 +19,14 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
Future<void> initialize(LaunchContext context) async {
await super.initialize(context);
// Don't initialize on mobile or web.
if (!defaultTargetPlatform.isDesktop || context.env.isIntegrationTest) {
// 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;
return;
}
@@ -50,6 +50,7 @@ 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;
@@ -75,9 +76,20 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
(reminders) async {
final availableReminders =
await filterAvailableReminders(reminders);
Log.info(
'Fetched reminders on refresh: ${availableReminders.length}',
// 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,
);
if (!diff) {
Log.info(
'Fetched reminders on refresh: ${availableReminders.length}',
);
}
if (!isClosed && !emit.isDone) {
emit(
state.copyWith(
@@ -1,4 +1,5 @@
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';
@@ -12,4 +13,11 @@ 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));
emit(state.copyWith(askAI: true, askAISources: event.sources));
}
FutureOr<void> _onAskedAI(
_AskedAI event,
Emitter<CommandPaletteState> emit,
) {
emit(state.copyWith(askAI: false));
emit(state.copyWith(askAI: false, askAISources: null));
}
bool _isActiveSearch(String searchId) =>
@@ -325,7 +325,9 @@ class CommandPaletteEvent with _$CommandPaletteEvent {
@Default(null) String? workspaceId,
}) = _WorkspaceChanged;
const factory CommandPaletteEvent.clearSearch() = _ClearSearch;
const factory CommandPaletteEvent.gointToAskAI() = _GoingToAskAI;
const factory CommandPaletteEvent.goingToAskAI({
@Default(null) List<SearchSourcePB>? sources,
}) = _GoingToAskAI;
const factory CommandPaletteEvent.askedAI() = _AskedAI;
}
@@ -358,6 +360,7 @@ 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,7 +19,8 @@ extension GetIcon on ResultIconPB {
lineHeight: lineHeight,
)
: null;
} else if (ty == ResultIconTypePB.Icon) {
} else if (iconType == ResultIconTypePB.Icon ||
iconType == ResultIconTypePB.Url) {
if (_resultIconValueTypes.contains(iconValue)) {
return FlowySvg(
getViewSvg(),
@@ -46,6 +46,11 @@ class RecentViewsBloc extends Bloc<RecentViewsEvent, RecentViewsState> {
await _service.reset();
add(const RecentViewsEvent.fetchRecentViews());
},
hoverView: (e) async {
emit(
state.copyWith(hoveredView: e.view),
);
},
);
},
);
@@ -64,6 +69,7 @@ class RecentViewsEvent with _$RecentViewsEvent {
RemoveRecentViews;
const factory RecentViewsEvent.fetchRecentViews() = FetchRecentViews;
const factory RecentViewsEvent.resetRecentViews() = ResetRecentViews;
const factory RecentViewsEvent.hoverView(ViewPB view) = HoverView;
}
@freezed
@@ -71,6 +77,7 @@ 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');
newLocale = const Locale('en', 'US');
}
context.setLocale(newLocale).catchError((e) {
File diff suppressed because it is too large. Load diff
@@ -298,6 +298,21 @@ 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,15 +1,29 @@
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,
@@ -19,7 +33,7 @@ class CommandPalette extends InheritedWidget {
child: _CommandPaletteController(notifier: notifier, child: child),
);
final ValueNotifier<bool> notifier;
final ValueNotifier<CommandPaletteNotifierValue> notifier;
static CommandPalette of(BuildContext context) {
final CommandPalette? result =
@@ -30,7 +44,30 @@ class CommandPalette extends InheritedWidget {
return result!;
}
void toggle() => notifier.value = !notifier.value;
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,
);
}
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;
@@ -47,7 +84,7 @@ class _CommandPaletteController extends StatefulWidget {
});
final Widget? child;
final ValueNotifier<bool> notifier;
final ValueNotifier<CommandPaletteNotifierValue> notifier;
@override
State<_CommandPaletteController> createState() =>
@@ -55,7 +92,8 @@ class _CommandPaletteController extends StatefulWidget {
}
class _CommandPaletteControllerState extends State<_CommandPaletteController> {
late ValueNotifier<bool> _toggleNotifier = widget.notifier;
late ValueNotifier<CommandPaletteNotifierValue> _toggleNotifier =
widget.notifier;
bool _isOpen = false;
@override
@@ -81,19 +119,28 @@ class _CommandPaletteControllerState extends State<_CommandPaletteController> {
}
void _onToggle() {
if (_toggleNotifier.value && !_isOpen) {
if (_toggleNotifier.value.isOpen && !_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: (_) => BlocProvider.value(
value: context.read<CommandPaletteBloc>(),
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<CommandPaletteBloc>()),
if (workspaceBloc != null) BlocProvider.value(value: workspaceBloc),
if (spaceBloc != null) BlocProvider.value(value: spaceBloc),
],
child: CommandPaletteModal(shortcutBuilder: _buildShortcut),
),
).then((_) {
_isOpen = false;
_toggleNotifier.value = false;
_toggleNotifier.value = _toggleNotifier.value.copyWith(isOpen: false);
});
} else if (!_toggleNotifier.value && _isOpen) {
} else if (!_toggleNotifier.value.isOpen && _isOpen) {
FlowyOverlay.pop(context);
_isOpen = false;
}
@@ -107,8 +154,8 @@ class _CommandPaletteControllerState extends State<_CommandPaletteController> {
actions: {
_ToggleCommandPaletteIntent:
CallbackAction<_ToggleCommandPaletteIntent>(
onInvoke: (intent) =>
_toggleNotifier.value = !_toggleNotifier.value,
onInvoke: (intent) => _toggleNotifier.value = _toggleNotifier.value
.copyWith(isOpen: !_toggleNotifier.value.isOpen),
),
},
shortcuts: {
@@ -130,68 +177,155 @@ class CommandPaletteModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
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(),
),
],
],
),
),
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(),
),
],
],
),
),
);
},
),
);
}
}
/// Updated _NoResultsHint now centers its content.
class _NoResultsHint extends StatelessWidget {
const _NoResultsHint();
class NoSearchResultsHint extends StatelessWidget {
const NoSearchResultsHint({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context),
textColor = theme.textColorScheme.secondary;
return Center(
child: FlowyText.regular(
LocaleKeys.commandPalette_noResultsHint.tr(),
textAlign: TextAlign.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),
),
);
},
),
],
),
),
],
),
);
}
}
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,
);
}
}
@@ -0,0 +1,209 @@
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,15 +2,24 @@ 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/style_widget/text.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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});
@@ -26,39 +35,133 @@ 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];
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(),
),
);
}
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,
);
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),
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();
},
),
),
),
],
],
),
),
],
);
},
),
@@ -0,0 +1,227 @@
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,3 +1,4 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -6,7 +7,6 @@ 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,32 +63,26 @@ class _SearchFieldState extends State<SearchField> {
valueListenable: controller,
builder: (context, value, _) {
final hasText = value.text.trim().isNotEmpty;
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,
),
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),
),
)
: clearIcon,
),
),
),
),
);
},
);
@@ -96,80 +90,53 @@ class _SearchFieldState extends State<SearchField> {
@override
Widget build(BuildContext 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,
);
final theme = AppFlowyTheme.of(context);
// 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)),
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)),
),
),
),
_buildSuffixIcon(context),
],
),
);
}
@@ -178,5 +145,6 @@ class _SearchFieldState extends State<SearchField> {
context
.read<CommandPaletteBloc>()
.add(const CommandPaletteEvent.clearSearch());
focusNode.requestFocus();
}
}
@@ -1,13 +1,19 @@
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:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.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:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SearchRecentViewCell extends StatelessWidget {
class SearchRecentViewCell extends StatefulWidget {
const SearchRecentViewCell({
super.key,
required this.icon,
@@ -19,33 +25,102 @@ class SearchRecentViewCell extends StatelessWidget {
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) {
return ListTile(
dense: true,
title: Row(
children: [
icon,
const HSpace(6),
Expanded(
child: FlowyText(
view.nameOrDefault,
overflow: TextOverflow.ellipsis,
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),
],
),
),
],
),
),
],
),
),
focusColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
hoverColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
onTap: () {
onSelected();
);
}
getIt<ActionNavigationBloc>().add(
ActionNavigationEvent.performAction(
action: NavigationAction(objectId: view.id),
),
);
},
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),
),
);
}
}
@@ -1,27 +1,33 @@
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.isTrashed = false,
this.query,
this.isHovered = false,
});
final SearchResultItem item;
final bool isTrashed;
final String? query;
final bool isHovered;
@override
@@ -32,6 +38,9 @@ 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();
@@ -41,93 +50,18 @@ class _SearchResultCellState extends State<SearchResultCell> {
/// Helper to handle the selection action.
void _handleSelection() {
context.read<SearchResultListBloc>().add(
SearchResultListEvent.openPage(pageId: widget.item.id),
SearchResultListEvent.openPage(pageId: viewId),
);
}
/// Helper to clean up preview text.
String _cleanPreview(String preview) {
return preview.replaceAll('\n', ' ').trim();
}
@override
Widget build(BuildContext context) {
final title = widget.item.displayName.orDefault(
final title = 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,
@@ -145,7 +79,7 @@ class _SearchResultCellState extends State<SearchResultCell> {
setState(() {
context.read<SearchResultListBloc>().add(
SearchResultListEvent.onHoverResult(
item: widget.item,
item: item,
userHovered: true,
),
);
@@ -156,7 +90,7 @@ class _SearchResultCellState extends State<SearchResultCell> {
onHover: (value) {
context.read<SearchResultListBloc>().add(
SearchResultListEvent.onHoverResult(
item: widget.item,
item: item,
userHovered: true,
),
);
@@ -164,72 +98,143 @@ class _SearchResultCellState extends State<SearchResultCell> {
isSelected: () => _hasFocus || widget.isHovered,
style: HoverStyle(
borderRadius: BorderRadius.circular(8),
hoverColor:
Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
hoverColor: Theme.of(context).colorScheme.secondary,
foregroundColorOnHover: AFThemeExtension.of(context).textColor,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 30),
child: tileContent,
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),
],
),
),
],
),
),
),
),
);
}
}
class _DocumentPreview extends StatelessWidget {
const _DocumentPreview({required this.preview});
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();
}
}
final String preview;
@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,
Widget buildPath(AppFlowyThemeData theme) {
return BlocProvider(
key: ValueKey(viewId),
create: (context) => ViewAncestorBloc(viewId),
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
builder: (context, state) => state.buildPath(context),
),
);
}
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(
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.data,
required this.item,
});
final SearchResultItem data;
final SearchResultItem item;
@override
Widget build(BuildContext context) {
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,
),
],
),
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));
},
);
},
);
}
}
@@ -3,6 +3,10 @@ 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';
@@ -11,10 +15,7 @@ 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({
@@ -47,148 +48,119 @@ class _SearchResultListState extends State<SearchResultList> {
super.dispose();
}
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,
@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!),
),
);
},
);
}
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) {
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,
}
},
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()),
],
],
);
},
),
],
),
);
}
@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(),
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,
),
),
);
}
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,
);
},
),
],
),
),
],
],
),
),
),
),
);
},
);
}
}
@@ -200,79 +172,11 @@ class SearchCellPreview extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<SearchResultListBloc, SearchResultListState>(
builder: (context, state) {
if (state.hoveredSummary != null) {
return SearchSummaryPreview(summary: state.hoveredSummary!);
} else if (state.hoveredResult != null) {
return SearchResultPreview(data: state.hoveredResult!);
if (state.hoveredResult != null) {
return SearchResultPreview(item: 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,142 +1,441 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.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/workspace/application/command_palette/search_result_ext.dart';
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:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
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 StatelessWidget {
class SearchSummaryCell extends StatefulWidget {
const SearchSummaryCell({
required this.summary,
required this.isHovered,
required this.maxWidth,
required this.theme,
super.key,
});
final SearchSummaryPB summary;
final bool isHovered;
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();
}
@override
Widget build(BuildContext context) {
return FlowyHover(
isSelected: () => isHovered,
onHover: (value) {
context.read<SearchResultListBloc>().add(
SearchResultListEvent.onHoverSummary(
summary: summary,
userHovered: true,
),
);
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);
});
},
style: HoverStyle(
borderRadius: BorderRadius.circular(8),
hoverColor:
Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
foregroundColorOnHover: AFThemeExtension.of(context).textColor,
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);
},
),
),
],
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: FlowyText(
summary.content,
maxLines: 10,
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),
),
),
);
}
}
class SearchSummaryPreview extends StatelessWidget {
const SearchSummaryPreview({
required this.summary,
super.key,
});
void showPopover() {
keepEditorFocusNotifier.increase();
popoverController.show();
}
final SearchSummaryPB summary;
void hidePopover() {
popoverController.close();
keepEditorFocusNotifier.decrease();
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (summary.highlights.isNotEmpty) ...[
Opacity(
opacity: 0.5,
child: FlowyText(
LocaleKeys.commandPalette_aiOverviewMoreDetails.tr(),
fontSize: 12,
),
),
const VSpace(6),
SearchSummaryDetail(text: summary.highlights),
const VSpace(36),
],
void tryToHidePopover() {
if (isLinkHovering || isReferenceHovering) return;
Future.delayed(Duration(milliseconds: 500), () {
if (!context.mounted) return;
if (isLinkHovering || isReferenceHovering) return;
hidePopover();
});
}
Opacity(
opacity: 0.5,
child: FlowyText(
LocaleKeys.commandPalette_aiOverviewSource.tr(),
fontSize: 12,
),
),
// Sources
const VSpace(6),
...summary.sources.map((e) => SearchSummarySource(source: e)),
],
),
);
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 SearchSummaryDetail extends StatelessWidget {
const SearchSummaryDetail({
required this.text,
super.key,
});
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;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: AIMarkdownText(markdown: text),
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 SearchSummarySource extends StatelessWidget {
const SearchSummarySource({
required this.source,
class ReferenceSources extends StatelessWidget {
const ReferenceSources(
this.sources, {
super.key,
this.onClose,
});
final SearchSourcePB source;
final List<SearchSourcePB> sources;
final VoidCallback? onClose;
@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),
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,
),
),
),
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,
),
],
),
),
);
}
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,10 +8,12 @@ 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';
@@ -23,6 +25,7 @@ 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';
@@ -116,6 +119,9 @@ 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>(
@@ -123,14 +129,28 @@ class DesktopHomeScreen extends StatelessWidget {
builder: (context, state) => BlocProvider(
create: (_) => UserWorkspaceBloc(userProfile: userProfile)
..add(const UserWorkspaceEvent.initial()),
child: HomeHotKeys(
userProfile: userProfile,
child: FlowyContainer(
Theme.of(context).colorScheme.surface,
child: _buildBody(
context,
userProfile,
workspaceLatest,
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,
),
),
),
),
@@ -290,6 +310,16 @@ 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,6 +8,7 @@ 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';
@@ -18,6 +19,7 @@ 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,7 +516,12 @@ class _SidebarSearchButton extends StatelessWidget {
onTap: () {
// exit editing mode when doing search to avoid the toolbar showing up
EditorNotification.exitEditing().post();
CommandPalette.of(context).toggle();
final workspaceBloc = context.read<UserWorkspaceBloc?>();
final spaceBloc = context.read<SpaceBloc?>();
CommandPalette.of(context).toggle(
workspaceBloc: workspaceBloc,
spaceBloc: spaceBloc,
);
},
leftIcon: const FlowySvg(FlowySvgs.search_s),
iconPadding: 12.0,
@@ -73,7 +73,9 @@ class _SpaceState extends State<_Space> {
@override
void initState() {
super.initState();
switchToTheNextSpace.addListener(_switchToNextSpace);
switchToSpaceNotifier.addListener(_switchToSpace);
}
@override
@@ -81,6 +83,7 @@ class _SpaceState extends State<_Space> {
switchToTheNextSpace.removeListener(_switchToNextSpace);
isHovered.dispose();
isExpandedNotifier.dispose();
super.dispose();
}
@@ -175,4 +178,17 @@ 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,6 +15,7 @@ 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';
@@ -887,6 +888,8 @@ 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,9 +101,7 @@ class _Description extends StatelessWidget {
LocaleKeys.settings_appearance_members_inviteFailedDialogTitle.tr(),
description:
LocaleKeys.settings_appearance_members_inviteFailedMemberLimit.tr(),
confirmLabel: LocaleKeys
.settings_appearance_members_memberLimitExceededUpgrade
.tr(),
confirmLabel: LocaleKeys.upgradePlanModal_actionButton.tr(),
onConfirm: () => context
.read<WorkspaceMemberBloc>()
.add(const WorkspaceMemberEvent.upgradePlan()),
@@ -182,9 +180,7 @@ class _CopyLinkButtonState extends State<_CopyLinkButton> {
description: LocaleKeys
.settings_appearance_members_inviteFailedMemberLimit
.tr(),
confirmLabel: LocaleKeys
.settings_appearance_members_memberLimitExceededUpgrade
.tr(),
confirmLabel: LocaleKeys.upgradePlanModal_actionButton.tr(),
onConfirm: () => context
.read<WorkspaceMemberBloc>()
.add(const WorkspaceMemberEvent.upgradePlan()),
@@ -43,9 +43,10 @@ Future<void> showSimpleAFDialog({
trailing: [
AFGhostButton.normal(
onTap: () => Navigator.of(context).pop(),
padding: EdgeInsets.all(theme.spacing.xs),
builder: (context, isHovering, disabled) {
return FlowySvg(
FlowySvgs.close_s,
FlowySvgs.toast_close_s,
size: Size.square(20),
);
},
@@ -57,7 +58,12 @@ Future<void> showSimpleAFDialog({
// AFModalDimension.dialogHeight - header - footer
constraints: BoxConstraints(minHeight: 108.0),
child: AFModalBody(
child: Text(content),
child: Text(
content,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
),
),
),
@@ -109,10 +109,7 @@ class LockPageButtonWrapper extends StatelessWidget {
return FlowyTooltip(
message: LocaleKeys.lockPage_lockedOperationTooltip.tr(),
child: IgnorePointer(
child: Opacity(
opacity: 0.5,
child: child,
),
child: child,
),
);
}
@@ -136,9 +136,11 @@ class ViewTitleBar extends StatelessWidget {
? ViewTitleBehavior.editable // only the last one is editable
: ViewTitleBehavior.uneditable, // others are not editable
onUpdated: () {
context
.read<ViewTitleBarBloc>()
.add(const ViewTitleBarEvent.reload());
if (context.mounted) {
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: 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
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
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
Sparkle: 9c328bdcfbcaf8f030c4b678eadfd0fcb03822d8
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
window_manager: 990c8e348c4da2a93b81da638245d40554ec9436
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
window_manager: e8d0b1431ab6c454f2b5c9ae26004bbfa43469aa
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823
@@ -4,7 +4,14 @@ String languageFromLocale(Locale locale) {
switch (locale.languageCode) {
// Most often used languages
case "en":
return "English";
switch (locale.countryCode) {
case "GB":
return "English (GB)";
case "US":
return "English (US)";
default:
return "English (US)";
}
case "zh":
switch (locale.countryCode) {
case "CN":
@@ -50,3 +50,32 @@ 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.1
version: 0.9.2
environment:
flutter: ">=3.27.4"
@@ -0,0 +1,363 @@
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);
},
);
});
}
@@ -0,0 +1,46 @@
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,16 +15,15 @@ class WidgetTestApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return EasyLocalization(
supportedLocales: const [Locale('en')],
supportedLocales: const [Locale('en', 'US')],
path: 'assets/translations',
fallbackLocale: const Locale('en'),
fallbackLocale: const Locale('en', 'US'),
useFallbackTranslations: true,
saveLocale: false,
assetLoader: const TestBundleAssetLoader(),
child: Builder(
builder: (context) => MaterialApp(
supportedLocales: const [Locale('en')],
locale: const Locale('en'),
locale: const Locale('en', 'US'),
localizationsDelegates: context.localizationDelegates,
theme: ThemeData.light().copyWith(
extensions: const [
@@ -0,0 +1,14 @@
<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>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 241 B

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 748 B

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 932 B

@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 827 B

+185 -7
View File
@@ -36,7 +36,9 @@
"loginButtonText": "تسجيل الدخول",
"loginStartWithAnonymous": "ابدأ بجلسة خفية",
"continueAnonymousUser": "استمر بجلسة خفية",
"anonymous": "مجهول",
"continueWithLocalModel": "الاستمرار بالنموذج المحلي",
"switchToAppFlowyCloud": "AppFlowy السحابي",
"anonymousMode": "الوضع المجهول",
"buttonText": "تسجيل الدخول",
"signingInText": "جاري تسجيل الدخول...",
"forgotPassword": "هل نسيت كلمة السر؟",
@@ -46,6 +48,7 @@
"createAccount": "إنشاء حساب",
"repeatPasswordEmptyError": "إعادة كلمة المرور لا يمكن أن تكون فارغة",
"unmatchedPasswordError": "تكرار كلمة المرور ليس هو نفسه كلمة المرور",
"passwordMustContain": "يجب أن تحتوي كلمة المرور على حرف واحد ورقم واحد ورمز واحد على الأقل.",
"syncPromptMessage": "قد تستغرق مزامنة البيانات بعض الوقت. من فضلك لا تغلق هذه الصفحة",
"or": "أو",
"signInWithGoogle": "استكمال باستخدام Google",
@@ -69,6 +72,43 @@
"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": "تسجيل الدخول مع ديسكورد",
@@ -530,6 +570,9 @@
"selectHomePage": "حدد الصفحة",
"clearHomePage": "مسح الصفحة الرئيسية لهذه المساحة الاسمية",
"customUrl": "عنوان URL مخصص",
"homePage": {
"upgradeToPro": "قم بالترقية إلى الخطة الاحترافية لتعيين الصفحة الرئيسية"
},
"namespace": {
"description": "سيتم تطبيق هذا التغيير على جميع الصفحات المنشورة الموجودة على مساحة الاسم هذه بشكل فوري",
"tooltip": "نحن نحتفظ بالحق في إزالة أي مساحات أسماء غير مناسبة",
@@ -636,7 +679,8 @@
"theme": {
"title": "السمة",
"description": "حدد السمة المحددة مسبقًا، أو قم برفع موضوعك المخصص.",
"uploadCustomThemeTooltip": "رفع السمة المخصصة"
"uploadCustomThemeTooltip": "رفع السمة المخصصة",
"failedToLoadThemes": "فشل تحميل السمات، يرجى التحقق من إعدادات الأذونات في إعدادات النظام &lt; الخصوصية والأمان &lt;الملفات والمجلدات &lt; @:appName"
},
"workspaceFont": {
"title": "خط مساحة العمل",
@@ -856,6 +900,7 @@
"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 على جهازك. هل تريد الاستمرار؟",
@@ -872,6 +917,7 @@
"localAIDisabled": "أنت تستخدم الذكاء الاصطناعي المحلي، ولكنه مُعطّل. يُرجى الانتقال إلى الإعدادات لتفعيله أو تجربة نموذج آخر.",
"localAIInitializing": "يتم تهيئة الذكاء الاصطناعي المحلي وقد يستغرق الأمر بضع دقائق، حسب جهازك",
"localAINotReadyTextFieldPrompt": "لا يمكنك التحرير أثناء تحميل الذكاء الاصطناعي المحلي",
"localAIDisabledTextFieldPrompt": "لا يمكنك التحرير أثناء تعطيل الذكاء الاصطناعي المحلي",
"failToLoadLocalAI": "فشل في بدء تشغيل الذكاء الاصطناعي المحلي",
"restartLocalAI": "إعادة تشغيل الذكاء الاصطناعي المحلي",
"disableLocalAITitle": "تعطيل الذكاء الاصطناعي المحلي",
@@ -1338,7 +1384,28 @@
"inviteMemberSuccess": "لقد تم ارسال الدعوة بنجاح",
"failedToInviteMember": "فشل في دعوة العضو",
"workspaceMembersError": "عفواً، حدث خطأ ما",
"workspaceMembersErrorDescription": "لم نتمكن من تحميل قائمة الأعضاء في هذا الوقت. يرجى المحاولة مرة أخرى لاحقًا"
"workspaceMembersErrorDescription": "لم نتمكن من تحميل قائمة الأعضاء في هذا الوقت. يرجى المحاولة مرة أخرى لاحقًا",
"inviteLinkToAddMember": "رابط الدعوة لإضافة عضو",
"clickToCopyLink": "انقر هنا لنسخ الرابط",
"or": "أو",
"generateANewLink": "إنشاء رابط جديد",
"inviteMemberByEmail": "دعوة الأعضاء عبر البريد الإلكتروني",
"inviteMemberHintText": "دعوة عبر البريد الإلكتروني",
"resetInviteLink": "إعادة ضبط رابط الدعوة؟",
"resetInviteLinkDescription": "ستؤدي إعادة الضبط إلى إلغاء تفعيل الرابط الحالي لجميع أعضاء المساحة وإنشاء رابط جديد. لن يعود الرابط القديم صالحًا.",
"adminPanel": "لوحة الإدارة",
"reset": "إعادة ضبط",
"resetInviteLinkSuccess": "تمت إعادة ضبط رابط الدعوة بنجاح",
"resetInviteLinkFailed": "فشل إعادة ضبط رابط الدعوة",
"resetInviteLinkFailedDescription": "الرجاء المحاولة مرة أخرى لاحقًا",
"memberPageDescription1": "الوصول إلى",
"memberPageDescription2": "لإدارة الضيوف والمستخدمين المتقدمين.",
"noInviteLink": "لم تقم بإنشاء رابط دعوة بعد.",
"copyLink": "انسخ الرابط",
"generatedLinkSuccessfully": "تم إنشاء الرابط بنجاح",
"generatedLinkFailed": "فشل في إنشاء الرابط",
"resetLinkSuccessfully": "تم إعادة ضبط الرابط بنجاح",
"resetLinkFailed": "فشل إعادة ضبط الرابط"
},
"lightLabel": "فاتح",
"darkLabel": "غامق"
@@ -2037,6 +2104,27 @@
}
},
"cannotMoveToItsChildren": "لا يمكن الانتقال إلى أطفاله",
"linkPreview": {
"typeSelection": {
"pasteAs": "لصق كـ",
"mention": "ذِكْر",
"URL": "عنوان URL",
"bookmark": "إشارة مرجعية",
"embed": "تضمين"
},
"linkPreviewMenu": {
"toMetion": "تحويل إلى ذكر",
"toUrl": "تحويل إلى عنوان URL",
"toEmbed": "تحويل إلى تضمين",
"toBookmark": "تحويل إلى إشارة مرجعية",
"copyLink": "نسخ الرابط",
"replace": "استبدال",
"reload": "إعادة التحميل",
"removeLink": "إزالة الرابط",
"pasteHint": "ألصق في https://...",
"unableToDisplay": "غير قادر على العرض"
}
},
"autoCompletionMenuItemName": "عنصر قائمة الإكمال التلقائي"
},
"outlineBlock": {
@@ -2277,11 +2365,11 @@
"layoutDateField": "تقويم التخطيط بواسطة",
"changeLayoutDateField": "تغيير حقل التخطيط",
"noDateTitle": "بدون تاريخ",
"noDateHint": "ستظهر الأحداث غير المجدولة هنا",
"unscheduledEventsTitle": "الأحداث غير المجدولة",
"clickToAdd": "انقر للإضافة إلى التقويم",
"name": "تخطيط التقويم",
"clickToOpen": "انقر لفتح السجل"
"clickToOpen": "انقر لفتح السجل",
"noDateHint": "ستظهر الأحداث غير المجدولة هنا"
},
"referencedCalendarPrefix": "نظرا ل",
"quickJumpYear": "انتقل إلى",
@@ -2297,6 +2385,12 @@
"search": {
"label": "يبحث",
"sidebarSearchIcon": "ابحث وانتقل بسرعة إلى الصفحة",
"searchOrAskAI": "ابحث أو اسأل الذكاء الاصطناعي",
"askAIAnything": "اسأل الذكاء الاصطناعي عن أي شيء",
"askAIFor": "اسأل الذكاء الاصطناعي",
"noResultForSearching": "لم يتم العثور على أي تطابقات",
"noResultForSearchingHint": "حاول استخدام أسئلة أو كلمات رئيسية مختلفة.\nقد تكون بعض الصفحات في سلة المهملات.",
"bestMatch": "أفضل تطابق",
"placeholder": {
"actions": "إجراءات البحث ..."
}
@@ -2405,6 +2499,19 @@
},
"notificationHub": {
"title": "إشعارات",
"closeNotification": "إغلاق الإشعار",
"viewNotifications": "عرض الإشعارات",
"noNotifications": "لا يوجد إشعارات حتى الآن",
"mentionedYou": "ذكرتك",
"archivedTooltip": "أرشفة هذا الإشعار",
"unarchiveTooltip": "إلغاء أرشفة هذا الإشعار",
"markAsReadTooltip": "قم بتمييز هذا الإشعار كمقروء",
"markAsArchivedSucceedToast": "تم الأرشفة بنجاح",
"markAllAsArchivedSucceedToast": "تم أرشفة كل شيء بنجاح",
"markAsReadSucceedToast": "وضع علامة كمقروءة بنجاح",
"markAllAsReadSucceedToast": "تم وضع علامة على كل شيء كمقروء بنجاح",
"today": "اليوم",
"older": "أقدم",
"mobile": {
"title": "تحديثات"
},
@@ -2534,6 +2641,7 @@
"copyLink": "انسخ الرابط",
"removeLink": "إزالة الرابط",
"editLink": "تعديل الرابط",
"convertTo": "تحويل إلى",
"linkText": "نص",
"linkTextHint": "الرجاء إدخال النص",
"linkAddressHint": "الرجاء إدخال الرابط",
@@ -2639,7 +2747,41 @@
"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": "مكان العمل",
@@ -2692,6 +2834,11 @@
"commandPalette": {
"placeholder": "ابحث أو اطرح سؤالا...",
"bestMatches": "أفضل المطابقات",
"aiOverview": "نظرة عامة على الذكاء الاصطناعي",
"aiOverviewSource": "مصادر مرجعية",
"aiOverviewMoreDetails": "مزيد من التفاصيل",
"pagePreview": "معاينة المحتوى",
"clickToOpenPage": "انقر لفتح الصفحة",
"recentHistory": "التاريخ الحديث",
"navigateHint": "للتنقل",
"loadingTooltip": "نحن نبحث عن نتائج...",
@@ -3210,7 +3357,38 @@
"analyzing": "تحليل",
"continueWritingEmptyDocumentTitle": "استمر في كتابة الخطأ",
"continueWritingEmptyDocumentDescription": "نواجه مشكلة في توسيع نطاق المحتوى في مستندك. اكتب مقدمة قصيرة وسنتولى الأمر من هناك!",
"more": "أكثر"
"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": "نجاح العملاء"
}
},
"autoUpdate": {
"criticalUpdateTitle": "التحديث ضروري للمتابعة",
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more