Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02c86b13d9 | ||
|
|
13fe4a2395 | ||
|
|
e8b8644716 | ||
|
|
10472475ac | ||
|
|
20e10e6bf2 | ||
|
|
cf14a37dcf | ||
|
|
a359313974 | ||
|
|
636794b9ba | ||
|
|
7e5b243e40 | ||
|
|
5e99eeb167 | ||
|
|
5029776685 | ||
|
|
79009d2851 | ||
|
|
d153b3d9f1 | ||
|
|
f9aa3ff499 | ||
|
|
b4fbfaf2f8 | ||
|
|
a026367f85 | ||
|
|
ef7063b8fd | ||
|
|
808bee5eb2 | ||
|
|
9ecae0a643 | ||
|
|
dfc80e6a5a | ||
|
|
014928cf19 | ||
|
|
30f5692818 | ||
|
|
6e850ab8b2 | ||
|
|
370b5714a1 | ||
|
|
2c8060cb1b | ||
|
|
b4d48a63b9 | ||
|
|
c986e414f4 | ||
|
|
c79ac3290e | ||
|
|
7f7657edc8 | ||
|
|
d3ce59fe08 | ||
|
|
0f89b6f690 | ||
|
|
680015ef66 | ||
|
|
b048407b4e | ||
|
|
95f9b07ef7 | ||
|
|
b1d3553110 | ||
|
|
38839b4255 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+42
@@ -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);
|
||||
});
|
||||
}
|
||||
+77
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+83
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
+161
-123
@@ -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: [],
|
||||
);
|
||||
+149
-85
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+346
@@ -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(
|
||||
|
||||
+139
-40
@@ -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,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
|
||||
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';
|
||||
@@ -47,6 +49,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
final layerLink = LayerLink();
|
||||
final overlayController = OverlayPortalController();
|
||||
final inputControlCubit = ChatInputControlCubit();
|
||||
final chatUserCubit = ChatUserCubit();
|
||||
final focusNode = FocusNode();
|
||||
|
||||
late SendButtonState sendButtonState;
|
||||
@@ -74,6 +77,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
focusNode.requestFocus();
|
||||
checkForAskingAI();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,13 +92,17 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
focusNode.dispose();
|
||||
widget.textController.removeListener(handleTextControllerChanged);
|
||||
inputControlCubit.close();
|
||||
chatUserCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: inputControlCubit,
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: inputControlCubit),
|
||||
BlocProvider.value(value: chatUserCubit),
|
||||
],
|
||||
child: BlocListener<ChatInputControlCubit, ChatInputControlState>(
|
||||
listener: (context, state) {
|
||||
state.maybeWhen(
|
||||
@@ -221,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;
|
||||
@@ -634,7 +662,9 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
|
||||
const Spacer(),
|
||||
|
||||
_selectSourcesButton(),
|
||||
if (context.read<ChatUserCubit>().supportSelectSource())
|
||||
_selectSourcesButton(),
|
||||
|
||||
if (extraBottomActionButton != null) extraBottomActionButton!,
|
||||
// _mentionButton(context),
|
||||
if (state.supportChatWithFile) _attachmentButton(context),
|
||||
|
||||
+35
-36
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ class MobileSearchAskAiEntrance extends StatelessWidget {
|
||||
onTap: () {
|
||||
context
|
||||
.read<CommandPaletteBloc?>()
|
||||
?.add(CommandPaletteEvent.gointToAskAI());
|
||||
?.add(CommandPaletteEvent.goingToAskAI());
|
||||
mobileCreateNewAIChatNotifier.value =
|
||||
mobileCreateNewAIChatNotifier.value + 1;
|
||||
},
|
||||
|
||||
+3
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// ChatUserCubit is responsible for fetching and storing the user profile
|
||||
class ChatUserCubit extends Cubit<ChatUserState> {
|
||||
ChatUserCubit() : super(ChatUserLoadingState()) {
|
||||
fetchUserProfile();
|
||||
}
|
||||
|
||||
/// Fetches the user profile from the AuthService
|
||||
Future<void> fetchUserProfile() async {
|
||||
emit(ChatUserLoadingState());
|
||||
final userOrFailure = await getIt<AuthService>().getUser();
|
||||
|
||||
userOrFailure.fold(
|
||||
(userProfile) => emit(ChatUserSuccessState(userProfile)),
|
||||
(error) => emit(ChatUserFailureState(error)),
|
||||
);
|
||||
}
|
||||
|
||||
bool supportSelectSource() {
|
||||
if (state is ChatUserSuccessState) {
|
||||
final userProfile = (state as ChatUserSuccessState).userProfile;
|
||||
if (userProfile.userAuthType == AuthTypePB.Server) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isValueWorkspace() {
|
||||
if (state is ChatUserSuccessState) {
|
||||
final userProfile = (state as ChatUserSuccessState).userProfile;
|
||||
return userProfile.workspaceType == WorkspaceTypePB.LocalW &&
|
||||
userProfile.userAuthType != AuthTypePB.Local;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Refreshes the user profile data
|
||||
Future<void> refresh() async {
|
||||
await fetchUserProfile();
|
||||
}
|
||||
}
|
||||
|
||||
/// Base state class for ChatUserCubit
|
||||
abstract class ChatUserState extends Equatable {
|
||||
const ChatUserState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Loading state when fetching user profile
|
||||
class ChatUserLoadingState extends ChatUserState {}
|
||||
|
||||
/// Success state when user profile is fetched successfully
|
||||
class ChatUserSuccessState extends ChatUserState {
|
||||
const ChatUserSuccessState(this.userProfile);
|
||||
final UserProfilePB userProfile;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [userProfile];
|
||||
}
|
||||
|
||||
/// Failure state when fetching user profile fails
|
||||
class ChatUserFailureState extends ChatUserState {
|
||||
const ChatUserFailureState(this.error);
|
||||
final FlowyError error;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [error];
|
||||
}
|
||||
+162
-228
@@ -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) {}
|
||||
}
|
||||
+22
-21
@@ -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);
|
||||
|
||||
+10
-1
@@ -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());
|
||||
},
|
||||
);
|
||||
|
||||
+33
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
-7
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+63
-68
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+91
-78
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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);
|
||||
+30
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+5
-2
@@ -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,
|
||||
);
|
||||
|
||||
+17
-8
@@ -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;
|
||||
}
|
||||
|
||||
+13
-7
@@ -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);
|
||||
}
|
||||
|
||||
+7
-1
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+26
-2
@@ -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.
|
||||
|
||||
+2
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -146,9 +146,9 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
|
||||
} else {
|
||||
final rootContext = widget.editorState.document.root.context;
|
||||
if (rootContext != null && _reminderId != null) {
|
||||
rootContext
|
||||
.read<ReminderBloc?>()
|
||||
?.add(ReminderEvent.removeReminder(reminderId: _reminderId!));
|
||||
rootContext.read<ReminderBloc?>()?.add(
|
||||
ReminderEvent.removeReminder(reminderId: _reminderId!),
|
||||
);
|
||||
}
|
||||
_updateBlock(selectedDay, includeTime: _includeTime);
|
||||
}
|
||||
|
||||
@@ -8,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;
|
||||
}
|
||||
+6
-3
@@ -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;
|
||||
|
||||
+2
-1
@@ -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: []);
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
|
||||
void setLocale(BuildContext context, Locale newLocale) {
|
||||
if (!context.supportedLocales.contains(newLocale)) {
|
||||
// Log.warn("Unsupported locale: $newLocale, Fallback to locale: en");
|
||||
newLocale = const Locale('en');
|
||||
newLocale = const Locale('en', 'US');
|
||||
}
|
||||
|
||||
context.setLocale(newLocale).catchError((e) {
|
||||
|
||||
+557
-409
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 {
|
||||
|
||||
+196
-62
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+209
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+135
-32
@@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+227
@@ -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;
|
||||
+66
-98
@@ -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();
|
||||
}
|
||||
}
|
||||
+99
-24
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+131
-126
@@ -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));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+107
-203
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+392
-93
@@ -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,
|
||||
|
||||
+16
@@ -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));
|
||||
|
||||
+2
-6
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+1
-4
@@ -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());
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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 |
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user